2026-09-21 18:14:25 +02:00
|
|
|
# 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
|
2026-09-21 19:16:28 +02:00
|
|
|
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.
|
2026-09-21 18:14:25 +02:00
|
|
|
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
|
|
|
|
|
```
|
|
|
|
|
|
2026-09-21 20:21:46 +02:00
|
|
|
## 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.
|
|
|
|
|
|
2026-09-21 18:14:25 +02:00
|
|
|
## 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`.
|