Files
questions/README.md
T

181 lines
7.6 KiB
Markdown
Raw Normal View History

# questions
Content for the [`portal`](https://project.uhhm.no/uhhm/portal) runtime
at [uhhm.no](https://uhhm.no) — every page, form, review desk, and
state machine on the site is declared in this repo's YAML, kept apart
from the Rust so a content change never needs a rebuild. The intent:
the person asking a question is only responsible for *asking it well*
declaring the context it's valid in, the alternatives a visitor can
choose, what each alternative needs to be answerable, and where an
answer's story goes next. The runtime handles everything else.
## How it runs
Portal loads every `questions/*.yaml` file (one page each) plus
`aggregates.yaml` (the state graphs) from this repo over Gitea's
contents API — at boot, and again on every push here: this repo's CI
lints the content with portal's own `question_lint` binary, then
publishes a NATS message that makes every running portal instance
re-fetch and atomically swap the new content in. Bad content never
replaces good: a failed lint stops the push's reload, and a running
instance keeps serving its last-good content even if a reload slips
past.
What the runtime provides underneath:
- **Portal** (Rust/Leptos) — renders pages, takes submissions, enforces
the state graphs.
- **NATS** — every submission and decision publishes to
`portal.answers.submitted`; JetStream holds the durable per-record
event log and the KV buckets pages read.
- **Gitea** — hosts this repo, serves the content API, runs the lint
CI, and gates the proposal loop's merges (branch protection on
`main` requires the lint check).
- **Kanidm** — identity; a page or resource naming a group
(`qualifies`, `requires_group`) is gated to signed-in members of it.
- **n8n** — automations subscribed to the NATS subject act on specific
decisions (send the newsletter, onboard an invited applicant, commit
an approved development proposal).
## The optimal usecase, in order
A full concern — say, tracking a new kind of actor — is stood up
entirely in this repo, in this order:
1. **Declare the state graph** (`aggregates.yaml`). Name a bucket,
its states, the event each state is entered by, and the legal
transitions:
```yaml
2026-08-12 23:33:53 +02:00
- bucket: partners # illustrative
initial: open
states:
2026-08-12 23:33:53 +02:00
open: { event: introduced }
in_dialogue: { event: entered_dialogue }
committed: { event: committed }
declined: { event: declined }
transitions:
open: [in_dialogue, declined]
in_dialogue: [committed, declined]
```
This graph is the authority: the runtime refuses any transition the
graph doesn't declare, no matter who asks. A bucket with no entry
here still works as plain storage — declare a graph when the
records have a lifecycle worth enforcing.
2. **Give it a way in** — a submission alternative on some page, with
`record_as` naming the bucket:
```yaml
2026-08-12 23:33:53 +02:00
- name: A bold statement to get behind
action: /thanks # follow-up page to advance to
consequence: [The button's label]
record_as: partners
features:
2026-08-12 23:33:53 +02:00
- name: A way to reach you
description: Say why the input is needed, then hand over the field.
requirements:
- name: email
label: Email
type: email
```
Each submission becomes a record in `open` (the graph's `initial`),
hashed into the visitor's answer chain, logged as the record's
first event, and published on NATS.
3. **Give it a way through** — a review alternative reading the bucket
back, offering the graph's transitions as buttons. `from` scopes a
button to rows actually in that state; one shared button confirms
every selection at once:
```yaml
2026-08-12 23:33:53 +02:00
- name: Partners
action: /review
consequence: [Confirm]
features:
- name: ""
resource:
2026-08-12 23:33:53 +02:00
source: { kind: kv, bucket: partners }
requires_group: owners
transitions:
- { to: in_dialogue, label: Open dialogue }
- { to: declined, label: Decline }
- { from: in_dialogue, to: committed, label: Commit }
```
4. **Let automations react** (optional). Every decision publishes
`question_id` + the transition's `label` on NATS — an n8n workflow
gates on those two strings and does the rest (see the
infrastructure repo's `n8n-workflows/`). Rename a page or a label
and its workflow must be updated in lockstep.
5. **Change the system through itself.** `/develop-proposal` takes a
proposed replacement for any content file (`questions/*.yaml` or
`aggregates.yaml` — nothing else is in scope) — from a person, or
eventually a locally-run model, through the same public form. An
owner approves it on `/develop`; approval triggers the automation
that branches, commits, opens a PR, and merges only when the same
lint that gates every human push passes. Nothing merges on
autopilot.
2026-08-12 23:29:53 +02:00
## Voice
The rules every page here follows:
- **The title is the only question.** A page asks one thing — its
`name`. Nothing below it asks anything.
- **Alternatives are bold statements**, argued against each other —
positions we'd like a visitor to get behind, in our voice, never
the visitor's presumed voice and never a question.
- **Explain why, then hand over the field.** Where input is needed,
the feature's name and description state the reason ("the reply is
personal — it needs an address"), and labels are nouns, not
questions.
- **Don't bucket people into roles.** We join the visitor's narrative
in the simplest way: statements they can get behind and information
that gives them an answer — not segments to self-select into. The
question nav (every page's footer) surfaces all questions the
current visitor qualifies for, so nothing depends on front-page
space; post-submission pages carry `followup: true` and only join
the nav once the visitor holds an answer chain.
2026-08-12 23:29:53 +02:00
## Schema quick reference
The exact structs live in portal's `src/content.rs`; the shape:
```
Question id (doubles as the URL path), name, description,
2026-08-12 23:29:53 +02:00
qualifies (Kanidm group gate), followup (nav-hidden
until the visitor carries a chain),
responsible {name, contact}, alternatives[]
Alternative name, description, action, consequence[label],
encouragements[], images[] (1 = banner, 2+ = card deck),
record_as (bucket), self_transition {bucket, to, label},
features[]
Feature name, description, color (CSS accent), icon
(Iconify name, e.g. lucide:star), requirements[],
resource
Requirement name, label, type, optional, multiple, placeholder,
accept (file), resource + id_field (select options)
ResourceSpec source (kv | gitea_starred | gitea_org_repos | url),
key, public, requires_group, transitions[{from, to, label}],
jq (reshape filter)
```
Requirement `type`: `text` (default), `textarea`, `email`, `tel`,
`select`, `file`, `prosekit` (rich text), or any HTML input type.
`self_transition` is the anonymous, single-record counterpart to a
review resource's transitions — fireable by whoever holds one specific
record's `?chain=` link plus its matching email (the unsubscribe
pattern).
## Adding a page
Drop a `questions/<name>.yaml` with a unique `id`, reference that `id`
from some alternative's `action`, push. Lint runs, portal hot-reloads,
the page is live — no registration, no deploy. Or propose it through
`/develop-proposal` and let the loop do the pushing.