# Content reference A content repo is the site. This is everything it may say. ``` site.yaml branding, language, mail aggregates.yaml the state machine of every bucket needs.yaml what the site is for (read by iris, never by portal) questions/ index.yaml the front page, served at / about.yaml served at /about review/ _section.yaml applies to everything under /review index.yaml served at /review [record].yaml serves /review/ ``` Nothing here is required except a `questions/` tree with something in it. Portal's lint refuses content it cannot serve honestly, at boot, on every hot reload, and in CI through `iris check`. ## site.yaml ```yaml title: Vestmark kommune # the browser title and the header wordmark: /site/logo.svg # an image instead of the title wordmark_ink: true # a one-colour SVG mark drawn in the page's own ink wordmark_invert: true # flip it in dark mode wordmark_height: 2.4 # rem lang: no # the interface language: en or no stylesheet: /site/theme.css # served same-origin from the repo favicon: /site/icon.svg hero: { kind: module, module: hero.js } # or { kind: plain } mail: from: Vestmark kommune reply_to: post@vestmark.example invite: { subject: "...", body: "..." } # rewords the built-in invite mail ``` `mail.from` is required as soon as any state declares a mail. Any text a person reads (`name`, `description`, `label`, `placeholder`, a mail's `subject` and `body`, `consequence`, `encouragements`) may be a list instead of one string: one entry per [storyline](storylines.md), all lists the same length. ## A question One YAML file is one page. ```yaml id: /apply # defaults to the file's path name: Apply to build description: What we need from you. qualifies: case_officers # a Kanidm group may open this page requires_chain: /start # or: an answer they must already have given followup: true # keep it out of the nav until earned responsible: { name: Solveig, contact: post@vestmark.example } # or a group's: no account is made for the inbox, and any signed-in # member of the group may suggest a change to the page responsible: { name: The board, contact: board@vestmark.example, group: board } event: # this page announces an occasion starts: 2026-10-14T18:00 duration: 2h place: The town hall place: # this page is about somewhere lat: 59.8938 lon: 11.0332 zoom: 15 name: The town hall alternatives: [ ... ] ``` `qualifies`, `requires_chain` and `responsible` are inherited from the nearest `_section.yaml`. `event` makes a page an occasion with a time; `place` makes it a place, drawing a still map under the question and opening any `type: location` field on the page there rather than on the whole world. ### Pictures across the page `slideshow` lays a strip of pictures under the question, the full width of the screen: every picture as tall as the strip and as wide as its own shape, swiped or arrowed through, its caption over its foot. ```yaml slideshow: max_height: 60 # svh; 60 unless set, between 20 and 100 slides: - image: images/built/01.webp # a path in the content repo, or https:// caption: The canteen at the theatre, renewed width: 3207 # the picture's own size, optional: height: 1400 # holds its place before it has loaded ``` Pictures in the content repo are served from this origin under `/site/`; `webp`, `avif` and `jpeg` may be kept by the browser for an hour. Keep them web-sized: around 1400 pixels tall is plenty. ## An alternative One choice. Never two actions behind one heading: if two things can be done, they are two alternatives. ```yaml alternatives: - name: Send the drawings description: We answer within twelve weeks. action: /sent # the page to go to; /shape/{field} templates record_as: applications # the bucket the answer lands in disabled: false consequence: [What happens next] encouragements: [It takes two minutes] images: [/site/drawing.png] features: [ ... ] ``` Three things an alternative may do instead of, or beside, recording an answer: ```yaml self_transition: # the person moves their own record bucket: applications to: complete from: [open, incomplete] # defaults to the initial state alone label: Send them invite: { group: case_officers } # onboard someone into Kanidm # or with words of its own, using any field of the form: # invite: # group: guests # mail: { subject: "...", body: "... {tidspunkt} ... {link} ..." } ``` `self_transition.from` is what keeps a person in dialogue with their case: an applicant written to for a missing drawing answers from `incomplete`, and may withdraw from wherever the case has got to. Omitted, it is the initial state alone, which is what a one-shot unsubscribe link means. Each state listed becomes its own access rule, and the lint refuses a `from -> to` the state machine does not declare. An `invite` needs a required `name` and `email` on the same alternative, and the inviting group must be allowed to invite that group by the policy. ## A feature What an alternative offers, explained. A feature that is only layout is a feature wasted: each one says something true about the choice. ```yaml features: - name: What you need description: The case number from the letter. icon: document color: "#8ec2c0" requirements: [ ... ] # form fields resource: { ... } # live data ``` ## A requirement (a form field) ```yaml requirements: - name: email # snake_case; this is the stored key label: Your email placeholder: you@example.no type: email optional: false value: "{key}" # a preset; a dynamic page's segment fills it ``` `value: "{user.email}"` or `"{user.name}"` is the signed-in person's own, and a field it fills is carried rather than asked: someone the site knows is not asked who they are. A visitor gets the field, empty. `type` is one of: | type | what it is | |---|---| | `text` | the default | | `email`, `tel`, `date`, `number`, … | anything else is passed straight through as the HTML input type | | `textarea` | several lines | | `prosekit` | a rich-text editor | | `select` | one choice, or `multiple: true` for any number | | `file` | an upload, with `accept:` and `multiple:` | | `location` | a pin on a map, stored as `{lat, lon, zoom}` | | `record` | the record this one belongs to | | `gesture`, `voice` | a drawn stroke or a recording, through a redoal relay | | `hidden` | a carrier value, no label row | | `module` | a widget the content repo ships | A `select` takes either inline `options: [...]` or a `resource:` to draw them from, with `id_field:` naming each item's stable id. A field may load its value from a resource when a sibling changes: ```yaml - name: contents type: textarea bind: field: path # the sibling to watch resource: { source: { kind: url, url: "https://git.example/raw/{path}" } } ``` ### type: record How one case belongs to another — an objection to the application it objects to, an appeal to the decision it appeals. ```yaml - name: case type: record of: applications # the bucket the other record is in value: "{key}" # arrives on the link, on a dynamic page ``` Nobody types one of these: the stored value is the parent's record id, which is a chain hash. It arrives the way it was given out, so the field carries rather than asks, and renders hidden. A submission naming a record that is not there is refused, so a reference in a stored record is one that resolved at least once. The other half is a listing that asks for the children: ```yaml resource: source: { kind: kv, bucket: objections } requires_group: case_officers where: { field: case, equals: "{key}" } ``` And the way there from a list: `link` puts a link on every listed record to a page about it, with `{id}` for the record's id and `{field}` for any of its fields. A notice on a public board links to `/notices/{id}`, the dynamic page `/notices/[key]` whose form carries the notice's id back in with `value: "{key}"`: ```yaml resource: source: { kind: kv, bucket: notices } public: true link: { to: "/notices/{id}", label: I can lend or borrow this } ``` ## A resource Live data, declared by content and read by the server. What a resource reads is never a client-supplied parameter. ```yaml resource: source: { kind: kv, bucket: applications } key: abc123 # one record instead of the list public: true # or requires_group: case_officers states: [approved, refused] # only these; empty means all where: { field: case, equals: "{key}" } jq: ".[] | {name, url: .html_url}" limit: 1 # at most this many, after jq empty: Nothing decided yet. link: { to: "/notices/{id}", label: Answer this } # a page per record transitions: # the desk's buttons - { from: complete, to: on_hearing, label: Send it out } ``` `source.kind` is one of `kv`, `gitea_starred`, `gitea_releases`, `url`. `limit: 1` shows only the first item — for a feed where only the newest matters. A releases card wants the cut someone should run, not every cut there has ever been, and a page that grows by one card per release gets longer forever. It applies *after* `jq`, so a filter can reorder and the limit takes the top of that ordering. A source that yields a single record has no length and ignores it; `limit: 0` on a list shows nothing, and the `empty` line speaks instead. A spec with neither `public: true` nor `requires_group` is unreachable: reads fail closed, and mutations always need a group whatever `public` says. `where: { field: email, equals: "{user.email}" }` is "my cases": the records carrying the signed-in person's own address, read from the session. A file on such a record is theirs alone to open. A record that carries a picture (a `type: file` field holding a JPEG, PNG, WebP, AVIF or GIF) is shown rather than read when the list has no `transitions`: a card with the picture on top, the first field as its heading, and the other fields under it, with no field names, state or time. The cards appear in the order the records were put up. That is how a site shows the people who work there, or a gallery of what it has built. On a desk, the same picture is a thumbnail that opens. `accept: image/jpeg,image/png,image/webp` on the field makes a phone send a JPEG whatever it keeps its photos as. What the person wrote is shown as markdown on stricter rules than the site's own: paragraphs, line breaks as typed, lists, emphasis and links to https, mailto or tel (marked `nofollow ugc`); no images, no raw HTML. A desk (a list with `transitions`) shows every record still moving, and of those in a final state - one the state machine has no move out of - only the three most recently decided, newest first. "Show older" under the list brings three more each time it is pressed. The desk stays about the work in hand however long the site has run; nothing is hidden from a read-only list, and `leaves_after` still takes a record out of every list altogether. `states` and `where` narrow a listing. They matter most for a register that is public by law — public about what was decided, not about what was withdrawn — while the desk working the same bucket is a different spec and still sees all of it. A record that has no such field never matches: "everything that did not say" is not something a page can ask for by accident. ## needs.yaml What the site is for, as a checkable spec: who wants what, which bucket the answer must land in, which group carries it to a finished state. The runtime never reads it. `iris check` proves the site meets it, and `iris replay` runs cases through the real engine.