Files
portal/vendor/trainer/generator/reference.md
T
Bendik Aagaard Lynghaug bedd306854 Bring the trainer in (uhhm/portal-trainer main, history kept)
git-subtree-dir: vendor/trainer
git-subtree-mainline: c7c2956e15
git-subtree-split: a2a9f4edcf
2026-09-30 15:23:15 +02:00

7.8 KiB

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/<name>.yaml   "/<name>"
questions/<dir>/index.yaml      "/<dir>"
questions/<dir>/_section.yaml   applies `qualifies` to the whole directory
questions/<dir>/<name>.yaml     "/<dir>/<name>" - a followup page by default

needs.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: <group>     # only when they are logged-in members of a group; omit for the public
    via: <alternative name>  # only when several alternatives on the entry page record into the same bucket
    lands_in: <bucket>
    handled_by: <group>    # the role inside the business that must see and move the record
    done_when: [<state>, <state>]   # 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: [<state>]     # the done_when states that are a good outcome for the business
    stages: [open, <stage>, <stage>, <end>, <end>]
                           # 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: <need id>
    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

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

name: What brings you here?          # always a question the visitor could ask themselves
description: >
  One or two sentences.
responsible: { name: <person or team>, contact: <email> }   # required on every entry page
alternatives:
  - name: <heading>                  # 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: <group> (or sits in a directory whose _section.yaml does). Only enters_as: <group> needs can use it.
  • Every bucket that is recorded into must be read by a desk.

A desk

# questions/desk/_section.yaml
qualifies: project_managers
# 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

title: <business name>
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 names, 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.