Bendik Aagaard LynghaugandClaude Fable 5 b4f12a1a6a
Deploy instance / deploy (push) Successful in 1s
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
Pin portal v0.3.18
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 16:01:04 +02:00
2026-09-01 16:01:04 +02:00

questions

Content for the portal runtime at 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).

This repo runs the instance

Besides the content, this repo owns the uhhm.no portal instance itself. .gitea/workflows/deploy.yml pins the portal version:

env:
  PORTAL_RELEASE: v0.2.3   # a release tag on uhhm/portal

Upgrading (or downgrading) portal is bumping that line and pushing — the workflow downloads the pinned release artifact, ships it, rewrites the instance env from this repo's Actions variables/secrets, restarts app@uhhm-portal, and refreshes the Caddy route. Nothing else deploys this site; a push to the portal repo only publishes a new version for repos like this one to opt into.

Day to day you rarely touch it: content edits (any *.yaml here) hot-reload without a deploy, and the deploy workflow only triggers on changes to itself (or a manual run from the Actions tab).

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:

    - bucket: partners        # illustrative
      initial: open
      states:
        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:

    - name: A bold statement to get behind
      action: /thanks            # follow-up page to advance to
      consequence: [The button's label]
      record_as: partners
      features:
        - 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:

    - name: Partners
      action: /review
      consequence: [Confirm]
      features:
        - name: ""
          resource:
            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 (the questions/ tree, aggregates.yaml, or site.yaml — nothing else is in scope) — from a person, or eventually a locally-run model, through the same public form. The submission itself becomes a branch and a draft pull request at once (n8n "Portal: proposal received"), so lint reports on it immediately and the submitter gets one acknowledgement mail if they left an address. An owner approves it on /develop; approval un-drafts the PR and merges it once the same lint that gates every human push passes ("Portal: commit approved development proposal"). Nothing merges on autopilot. The alternative is disabled: true while this loop is being finished — announced, not yet open.

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 draw people in. Each one suggests a real path a visitor could take — a position to get behind or a thing to do next, in our voice, never the visitor's presumed voice and never a question. A category label ("Our Composition") is not an alternative; "Follow the build as it lands" is.
  • 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 (nested non-index files, or followup: true) only join the nav once the visitor holds an answer chain.

Schema quick reference

The exact structs live in portal's src/content.rs; the shape:

Question            id (derived from the file's path - see routing
                    below - declare only to override), name,
                    description, qualifies (Kanidm group gate),
                    requires_chain (question ref the visitor's ?chain=
                    lineage must end at), followup (nav-hidden until
                    the visitor carries a chain; inferred from the
                    tree when unset), event {starts, duration, place}
                    (announced page - see below), responsible
                    {name, contact}, alternatives[]
  Alternative       name, description, action, disabled (announced,
                    not yet takeable), 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),
                    bind {field, param, resource} (load this field's
                    value from a resource whenever the named sibling
                    changes - {param} templates into a url source's path),
                    value (preset; on a dynamic page {name} takes the
                    URL segment - value: "{key}" hands a voice field
                    its address), relay (gesture/voice - ws(s)://
                    redoal-relay URL)
      ResourceSpec  source (kv | gitea_starred | gitea_org_repos |
                    gitea_releases | url),
                    key, public, requires_group, transitions[{from, to, label}],
                    jq (reshape filter), empty (text when the
                    resource yields nothing; default "Nothing here yet.")

Requirement type: text (default), textarea, email, tel, select, file, prosekit (rich text), gesture, voice, or any HTML input type.

  • gesture draws a stroke on a canvas and submits {points, key}. With relay set it announces the stroke to a redoal-relay: the relay answers with the key and its decode (drawn under the stroke), with who is at a similar shape right now, and with places - keys that hold recordings. Picking a place makes its key the field's: the value becomes {points, key, own_key, selected_from, selected_distance}. Mark it optional: true (hidden inputs skip HTML required-validation).
  • voice records in the browser and sends the audio to the relay, which keeps it at the field's value key (value: "{key}" on a /shape/[key] page). The value becomes {key, digest, duration_ms}. Needs relay; optional: true for the same reason.

A [name].yaml page's segment also substitutes into a url resource source (https://relay.redoal.com/place/{key}), and a resource item with an https audio field renders an <audio> player.

A repo may also carry an optional site.yaml at its root (sibling of aggregates.yaml) declaring instance branding: title, wordmark (image URL), and the landing page's hero:

hero:
  kind: module      # plain (default) | module
  module: hero.js   # repo-relative path, served by portal at /site/hero.js

A module hero is a JavaScript module this repo ships, exporting mount(container) -> handle where the handle has stop(). Portal serves it same-origin (Gitea's raw endpoint sends no CORS headers, so /site/<path> proxies any plain-segment path from the repo), starts it at HTML parse time, adopts it on hydration, and stops it on navigation. Everything visual is the module's - it builds its own DOM inside .hero-piece and may inject its own stylesheet, including rules for the .hero-module header itself. uhhm's hero.js is the YES canvas piece; redoal's is the sine-swings band. Absent file = plain hero, portal's SITE_NAME, /wordmark.svg.

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).

Descriptions are inline markdown (alternative and feature): [text](https://…) links, *emphasis*, **strong**, `code`. One paragraph - block structure flattens; raw HTML is dropped; link targets may be https, mailto or a site-relative path, anything else renders as plain text.

Announced pages. A question carrying an event: block

event:
  starts: 2026-09-12T18:00:00+02:00   # RFC 3339, with offset
  duration: 3h                        # m / h / d, e.g. "1d 6h"
  place: Galleri X, Oslo              # optional, shown verbatim

is announced in the header of every page (name, when, "in 3 days") while its window is open, instead of listed in the footer nav. The page itself is an ordinary question - RSVP, directions, whatever its alternatives say. When the window closes the page turns into a followup (only visitors carrying an answer chain still see it) and portal moves the page's record in the runtime-owned portal_events bucket from announced to awaiting_summary, publishing that on portal.answers.submitted like any decision (alternative "Summary due") - the "post what happened" task. /review's "Announced pages" desk reads that bucket; Posted what happened closes the task. Lint warns when pages carry event: but nothing reads portal_events.

Attended buckets (lint-enforced): every record_as bucket must be read by some kv resource in this repo (a desk or listing) — or its aggregates.yaml entry must carry attended_by: <who/what consumes it> naming the automation that does. A bucket nothing reads fails lint, so no publicly collected answer can land where no one will ever see it.

Routing: the tree is the router

The questions/ directory tree is the URL tree — index.yaml names its directory, everything else appends its stem:

questions/
  index.yaml              /
  applied.yaml            /applied
  develop/
    index.yaml            /develop
    proposal.yaml         /develop/proposal
    proposed.yaml         /develop/proposed
  review/
    _section.yaml         (not a page - defaults for the directory)
    index.yaml            /review
    [record].yaml         /review/<any value>
  • id: is derived from the path; declaring it still wins (legacy), with a lint warning when it disagrees.
  • action: and requires_chain: take relative refs — proposed names a sibling, ../x climbs, /x is absolute. A directory is a self-contained flow: git mv renames every internal edge with it.
  • Files nested in a subdirectory infer followup: true unless they're the directory's index.yaml — declare followup: false on a nested page that should stay in the nav. Top-level files keep the flat-repo default (not a followup).
  • _section.yaml applies qualifies, requires_chain, and responsible to every page at or below its directory (nearest ancestor wins; a page's own declaration always overrides). A URL prefix is a trust boundary.
  • Any other _-prefixed file is skipped entirely — drafts live in the tree without being served.
  • [name].yaml is a dynamic page: it serves every /dir/<value>, with the segment substituted into {name} placeholders in the page's resource keys (/review/<chain-hash> shows that one record). One per directory; never in the nav; not a valid action target.
  • requires_chain: <ref> gates a page on provenance instead of identity: the visitor's ?chain= lineage must verifiably end at an answer to the referenced question, otherwise the page renders a pointer there instead of its alternatives.

Adding a page

Drop a YAML file where its URL should live (questions/foo.yaml/foo, questions/flow/step.yaml/flow/step), reference it 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.

S
Description
Question/alternative content for the portal app, kept separate so content edits never need a Rust rebuild.
Readme
261 KiB
Languages
JavaScript 100%