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

197 lines
7.8 KiB
Markdown

# 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
```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
```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: <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
```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: <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 `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`.