# Portal content reference (for the generator) Portal serves a site that is one question per page. Each page offers a few alternatives. An alternative may carry a form; submitting it records the answer into a *bucket* whose records move through a state machine, worked from a *desk*: a page gated to a group, listing the bucket with one button per allowed move. There is no other mechanism. Everything a business needs is expressed as: who answers which question, where the answer lands, who moves it, through which states. A content repo is these files and nothing else: ``` needs.yaml what the business needs (the spec you are held to) aggregates.yaml one state machine per bucket site.yaml title and language questions/index.yaml the front door, "/" questions/.yaml "/" questions//index.yaml "/" questions//_section.yaml applies `qualifies` to the whole directory questions//.yaml "//" - a followup page by default ``` ## needs.yaml ```yaml needs: - id: kebab-case who: the kind of person, in plain words wants: what they want from the business enters: / # page they arrive on; default / enters_as: # only when they are logged-in members of a group; omit for the public via: # only when several alternatives on the entry page record into the same bucket lands_in: handled_by: # the role inside the business that must see and move the record done_when: [, ] # every state that counts as finished, good or bad max_steps: 1 # submissions before the record exists (1 or 2) max_fields: 4 # required fields the visitor will tolerate across the path success: [] # the done_when states that are a good outcome for the business stages: [open, , , , ] # EVERY stage the business said a case goes through, first to last, # read off their own sentences one verb at a time ("they come in, we # go and look, we send a quote, we win or lose it" is five stages). # The state machine must have a state for each, or the verifier fails. personas: - id: kebab-case need: text: > Two or three sentences in the visitor's own voice and vocabulary. ``` Rules: - One need per (kind of person, thing they want). Internal work counts: a role inside the business recording or posting something is a need with `enters_as`. - States come from the business's own description of how a thing progresses. A pipeline is not an inbox: "answered" is rarely the real outcome. - `max_fields` is small on purpose. Reporting something from a phone gets 3. A first enquiry gets 4 to 6. - Two or three personas per public need. At least one must avoid the trade's own vocabulary. A persona must never quote a page heading. ## aggregates.yaml ```yaml aggregates: - bucket: leads initial: open states: # state: { event: past-tense verb } open: { event: received } quoted: { event: quoted } won: { event: won } lost: { event: lost } transitions: # only moves the business described open: [quoted, lost] quoted: [won, lost] ``` The initial state should be `open` (a desk transition with no `from` means from `open`). ## A page ```yaml name: What brings you here? # always a question the visitor could ask themselves description: > One or two sentences. responsible: { name: , contact: } # required on every entry page alternatives: - name: # what the visitor picks. Must work WITHOUT the description. description: > One or two sentences. action: /received # absolute path of an existing page consequence: [Send] # the button label record_as: leads # omit for a pure navigation alternative features: - name: About the building description: Optional line of guidance. requirements: - { name: email, label: Email, type: email } - { name: problem, label: What is wrong, type: textarea } - { name: urgency, label: How soon, type: select, options: [This week, This month, No rush] } - { name: address, label: Address, optional: true } ``` - Field types: `text` (default), `email`, `tel`, `textarea`, `date`, `select` (must list `options`). Use no other types. - Every `action` must name a page that exists. After a recording alternative, send the visitor to a followup page that says who reads it, when they answer, and what happens next. A followup page is a top-level file with `followup: true`, or any non-index file in a subdirectory. Give it one alternative with `action: /` to go back. - A page for members of a group sets `qualifies: ` (or sits in a directory whose `_section.yaml` does). Only `enters_as: ` needs can use it. - Every bucket that is recorded into must be read by a desk. ## A desk ```yaml # questions/desk/_section.yaml qualifies: project_managers ``` ```yaml # questions/desk/index.yaml name: What came in? responsible: { name: ..., contact: ... } alternatives: - name: Leads description: One line on what to do with them. action: /desk consequence: [Confirm] features: - name: "" description: "" resource: source: { kind: kv, bucket: leads } requires_group: project_managers transitions: - { to: quoted, label: Quote sent } - { to: lost, label: Not for us } - { from: quoted, to: won, label: Won } - { from: quoted, to: lost, label: Lost } ``` - One desk directory per handling group; one alternative per bucket that group handles. - Offer every move the state machine allows and no other. A state with no move out of it, on the way to a finished state, strands records. - A group may read a bucket it does not handle: the same `resource` block with its own `requires_group` and no `transitions`. ## site.yaml ```yaml title: lang: en ``` ## Language Everything a person reads is written in the intake's `language`: page names, headings, descriptions, field labels, button labels, the `who`/`wants` of each need, and the personas. Write it the way people in that trade and country actually speak, not as a translation. Everything a machine reads stays plain ASCII snake_case or kebab-case: need ids, persona ids, bucket names, state names, field `name`s, file names and paths. Letters such as æ, ø and å belong in the copy and never in an identifier. Set `lang` in site.yaml to the intake's code. ## Copy rules - Headings say what the visitor gets, in the visitor's words. - Lead with what a thing enables. Never define something by what it is not. No negations in headings. - Two alternatives must never be separable only by a distinction the business makes and the visitor does not. - Every required field is one the business will use to answer. ## The verifier `question_lint` runs after every attempt. Non-zero exit means you are not done. Its messages are exact: - `no path from ... records into ...`: no chain of alternatives from the entry page reaches an alternative with that `record_as`. - `the path asks for N required field(s), over the budget`: mark fields `optional: true` or drop them. - `no desk gated on "g" reads "b"`: add the desk. - `"g" cannot move a record to "s"` / `records strand in "s"`: the desk is missing a transition the state machine allows, or the state machine is missing the edge. - `action ... does not match any declared question id`: the page does not exist. - `entry page ... names no one responsible`: add `responsible`.