Commit Graph
73 Commits
Author SHA1 Message Date
Bendik Aagaard LynghaugandClaude Fable 5.1 f047e21c4a Memberships: groups kept by portal itself, a person by phone number first
Test / test (pull_request) Successful in 58s
A membership is a case in the built-in portal_memberships bucket:
invited by a desk, a grants, the seed or the responsible pass; active
from the person's first sign-in, when the provider's subject, phone
number or address matches the record; removed by a desk, and invited
again after that. One record per group and person however it was
keyed, and at sign-in every record that matched learns what the
provider verified. The active memberships join the groups claim, so
Cedar sees the same parents as before.

PEOPLE_BACKEND picks Kanidm or memberships; unset, a KANIDM_API_TOKEN
means Kanidm. The NATS access service looks a person's groups up when
a question names them by sub, phone or email and lists none. The
sign-in asks for the phoneNumber scope and reads it from userinfo.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 13:28:28 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 014594283a A group may hold a page's responsibility
Test / test (pull_request) Successful in 56s
responsible: { name, contact, group } says the address is a group's
inbox, not a person: no account or mail is made for it, and any
signed-in member of the group gets the suggest-a-change card. A
board's shared address was onboarded as a person before this.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 08:17:27 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 fc998c8592 One record answers another, from a list
Test / test (pull_request) Successful in 56s
Three small things that together make a board work: a link on every
listed record to a page about it (ResourceSpec.link, {id} and {field}
in the address); a state mail whose `to` is a field of the record this
one belongs to ({notice.email}), with that record's fields in the
text, and a lint that the form carries the record field it reaches
through; and value: "{user.email}" filled from the session and hidden,
so someone the site knows is not asked who they are.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 06:47:36 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 86ec51012e The way down the tree is the link at the foot, not an arrow that follows
Test / test (pull_request) Successful in 56s
0.5.3 put the same link in two places: in words at the foot of the
page, and as an arrow fixed at the left edge half-way up the screen.
The arrow stayed with the reader the whole way down a page to offer
what the foot offers where they end up anyway. It is removed; the foot
link and where it leads are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 21:22:54 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 ef4789f9b2 Invites and suggestions are cases; the responsible follow the content
Test / test (pull_request) Successful in 56s
Five things the first cut of accounts-from-content left open.

The first person and the responsible people are set up side by side,
and both try again a minute apart: a seed that waited twenty minutes on
Kanidm used to hold everyone else up, and a responsible person who
failed waited for the next restart.

The responsible are gone over again whenever the content changes.
watch_for_reload publishes portal.content.reloaded once new content is
in place, and seed.rs listens.

Being responsible ends when the content stops saying so: someone no
page names any more leaves RESPONSIBLE_GROUP in the same pass, if
portal is who put them there (Invited.already_member). The account
stays.

An invite keeps itself current. portal_invites has an expired state;
signing in closes a person's invites, a sweep moves the ones whose link
ran out, and a desk's move from expired back to open makes a fresh link
and mails it. Invites made at start are on file with the desks'.

A suggested edit is a case in the built-in portal_edit_suggestions
bucket, the person who made it is mailed at each move, and the card
lists what they sent and where it stands.

Found on the way, against Kanidm 1.11.2: a link's end comes as seconds
since the epoch, which portal did not read, so invite mails said
"until" and nothing; and a mail search answered with a 5xx was taken
for a refusal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 21:00:10 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 0886869607 Accounts the content mandates, a seed for the first person, and edits suggested by who is responsible
Test / test (pull_request) Successful in 51s
Every person a page names as responsible gets a Kanidm account at start
if they have none, and a mail saying they are listed as responsible for
asking that question, with the link to sign in; RESPONSIBLE_GROUP puts
them in one group. SEED_ADMIN_EMAIL invites the first person into the
most privileged group - the one whose desk may invite into the most -
once. Signed in, a responsible person may suggest a change to their
own pages; checked on the server, kept, published, and mailed to the
site's inbox.

Fixes an invite of someone new on a Kanidm with only the onboarding
account: the person is created with their address in one call, since
idm_people_on_boarding may create but not modify (it answered 403).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 15:44:20 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 e2596bc3cf A way down the tree from every page
Test / test (pull_request) Successful in 53s
An arrow fixed at the left edge, half-way up the screen, and the same
link in words at the foot of the page. It goes one level down: the
nearest page below the current path that the visitor may open, found by
taking the path apart a segment at a time, and the front door when none
of those is a page (/status/oppslag -> /, /review/abc -> /review). The
front door itself has none. Labelled with where it goes: 'Tilbake til
starten', or the target page's question in quotation marks.

Content stops needing a 'back' alternative on every page, and a visitor
who went up a branch whose context did not apply is never stranded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 07:05:39 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 8d6f29011c An upload asks the policy, like every other way in
/upload asked nothing. It took a file, capped it at 20 MB, and wrote
it to the bucket for whoever sent it - no session, no page, no policy.
It was the one server entry point that did not ask the question every
other one asks, and on a site whose forms are public that makes the
route public too: a free disk for anyone who finds the host.

Harmless while no instance had storage configured, which is why it sat
there. kasse now has a garage wired to klingenberg's portal, so it is
not harmless any more.

An upload now carries the question and the alternative the file is
being attached to. The page has to exist, the alternative has to be
open and to actually declare a `type: file` field, and the policy has
to allow Submit on that page for whoever is asking - checked after the
bytes are read and before any of them are written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 16:08:12 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 9891c462c6 A page says nothing until it has something to say
The Suspense fallback on a question is the whole served page until
the question resolves - no header, no wordmark - so the word
"loading…" was the first and only thing a visitor or a crawler read
about the site. True, and not about them. It holds its space and
stays quiet now; `aria-busy` says what is happening to a reader that
would otherwise find an empty document. Same for the two resource
slots inside a page, which keep a line's worth of room.

Also gone: the `gitea_org_repos` resource kind, which no site ever
named. `gitea_starred` and `gitea_releases` stay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-26 13:25:11 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 f18d1a5181 A person stays in dialogue with their case
Five things a building authority needed and portal could not say,
found by building one and replaying its cases through the engine.

The one that mattered: a submitter could only ever act on a record
nobody had touched. `access.rs` pinned a self_transition's from-state
to `open`, though the runtime had always passed the real state and
asked. So the applicant written to for a missing drawing could not
send it, and nobody could withdraw once anything had happened.
`self_transition` now takes `from: [state, ...]` and compiles one rule
per state; omitted it still means the initial state alone, which is
what every unsubscribe link already meant.

Then the four beside it. A record can belong to another record
(`type: record`, `of:`), with `where: { field, equals }` on a listing
as the other half, so an application's page lists the objections that
name it. A public view can be narrowed to the states worth showing,
so a register that is public by law is public about what was decided
and not about what was withdrawn. A state can grant a group, so an
approval that means membership needs no second step. And a state can
be moved by a clock (`after: { days, to }`, src/deadlines.rs) - held
to the same declared edge as a desk button, firing the same mail,
publishing the same event.

Also: the README is a README again, and everything that was crowding
it is in docs/ - architecture, content reference, state machines,
access, operations.

95 tests, up from 82.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 21:35:25 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 26c1762dfa A reply to a case mail becomes a note on its record
Test / test (push) Failing after 53s
Publish release / publish (push) Successful in 3m14s
Whatever reads the mailbox publishes on portal.mail.received; portal
appends the note after checking the record exists in a bucket this
site has and the sender is the address the record itself holds. A
note never moves a case: a mailbox is the one door anyone may knock
on, so it cannot drive a state machine even if someone forges their
way past the checks. The desks show the conversation beside the case.

Tested: the address parser, every refusal (a stranger, no address to
compare, an empty reply, the same reply twice, an unknown contract),
and the whole path through a real JetStream - the note lands, the
state does not move, and no event is written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 21:26:57 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 4fd60293fa A field can be a place, and a question can be about one
`type: location` puts a pin on a map and stores {lat, lon, zoom}; a
record that holds one renders as a still, on a desk and in any
resource card. `place:` on a question is the counterpart to `event:`:
event makes a page an occasion with a time, place makes it somewhere,
shows the map under the question, and opens any location field on the
page there instead of on the globe.

Every tile and every still is fetched by portal and served from this
origin (src/maps.rs, MapLibre vendored in public/), so a page with a
map on it still talks to nobody but us: no third-party request, no
consent banner, and the token never leaves the server. MAPBOX_TOKEN
unset turns the whole thing off, the way GARAGE_* does for uploads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 21:17:28 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 19c8f541de Mail is content: a state's mail: and the invite mail, published for gdo
`mail: { to, subject, body }` on a state in aggregates.yaml sends when a
record enters that state, `to: submitter` or one address, templates
over the record's fields plus {site}, {chain}, {email}. An invite mails
its one-time link built in, reworded by `mail.invite` in site.yaml,
which also carries the sender (`mail.from`, `mail.reply_to`); without
it a site sends nothing and says so in the log. Portal renders the
text and an HTML alternative and publishes one message per state
change on portal.mail.send in the WORMHOLE stream, id <bucket>:<record>
:<state> so a retried transition sends once; uhhm/gdo holds the
durable consumer and talks SMTP. The lint requires a required email
field on every form recording into a bucket that mails the submitter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 19:45:48 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 eeeb89b217 One access policy from content; onboarding into Kanidm as a form
Who may do what to what was five checks in five files: `qualifies` on
a page, `requires_group`/`public` on a resource, a desk's transition
allow-list, a self_transition's email check, the automation token.
They are now compiled from content on every load into one Cedar
policy set (src/access.rs), and every server entry point asks it: open
and submit a page, read a resource, move a record, fire a self
transition, read a bucket as the automation service, extend an
invite. Each decision names the rules that granted it; refusals and
every mutation are published on portal.access.decided, and
portal.access.<site>.may answers the same question for other apps
over request-reply (proven live against uhhm's content). The policy
validates against a fixed schema, so a content shape the compiler
cannot express fails instead of granting nothing. Identity is still
Kanidm's; the browser keeps is_qualified as a rendering hint only.

`invite: {group}` on an alternative of a gated page onboards a person:
src/people.rs creates the account, records the email, adds the group
and mints a one-time credential link through Kanidm's REST API with a
service-account token (KANIDM_API_TOKEN, in idm_people_on_boarding).
The result is recorded with the answer into the runtime's
portal_invites bucket and announced on portal.people.invited. Validated:
an invite needs a gated page and required name and email fields.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 11:44:39 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 e67e841f8c Question nav: a followup is offered only once it is earned
The 0.3.34 rule listed every followup "further down" from the current
page, so the front door offered the thank-you pages before anything
was submitted, and a followup one typed in offered the ones beyond
it. The chain half was weaker than it looked too: it matched the
question the chain answered, which is a front door anyway.

Now a followup is offered only when the visitor has earned it: their
verified chain answered the alternative that leads there (the chain
index records the alternative from here on; older nodes fall back to
every recording alternative of the answered question, and nothing
beyond it), or it hangs off an earned page by a link that records
nothing. A page behind an answer they have not given is never offered.
The walk ends at any front door, so "back to the start" never fans out.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-22 16:58:40 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 74bba2e9b1 Question nav: offer followups by reach, not by carrying any chain
`list_qualifying_questions` reduced the visitor's `?chain=` to a
bool, so one followup path unlocked every followup on the site -
redoal's `/watching` (an email-link page whose header says "never in
the nav") showed up for anyone who had just subscribed.

The nav now resolves the chain hash to the one question it verifiably
answered (the same `lookup_node` a `requires_chain` gate uses) and
offers a followup only when it is on that path or further down from
the current page over `action` edges. `content::outcomes_reachable_from`
walks those edges through followup pages only, so a "back to the
start" action ends the walk at the front door instead of fanning out.
Templated actions (`/shape/{curve.key}`) resolve to their dynamic
page's pattern. No schema change: the `followup` flag keeps meaning
"not a front door", and the edges were already in the content.

Also: `main.loading`/`main.not-found` gets the same `svh` ladder as
`.hero-piece`, the last bare `vh` in the stylesheet, so wrapper
browsers that resize the webview as their toolbar animates don't
reflow the placeholder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-11 19:40:46 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 b044780e61 Head preloads: latin font, wordmark, content-host preconnect
Fonts hide behind the stylesheet - the browser parses CSS before it
finds them - so the latin Quicksand subset (every page's critical
path) is announced in the shell head; latin-ext/vietnamese stay
unicode-range-gated. The wordmark preloads from the resolved site
config, with a preconnect to its origin when it lives on the content
host (redoal.com's does), opening that TLS session early.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-02 11:48:11 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 01504fb120 Per-site favicon via site.yaml
A content repo can ship a favicon (svg/png/ico) named in site.yaml;
portal serves it at /site/<path> and injects a <link rel=icon> after
the static defaults so it wins. Also serves .ico assets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 22:12:20 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 0d3221c22f Hero description renders inline markdown
The question-level description (hero subtitle) was plain text, so
**bold** showed literal asterisks. Now it runs through
render_inline_markdown like every other description.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:31:02 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 99b29bb09f wordmark_invert: explicit, not inferred from .svg
A content-shipped colour logo (Klingenberg's red tile) was being
inverted to teal in light theme by the .svg heuristic. Invert is now
an explicit site.yaml flag defaulting to house-mark-only; content
logos keep their colours unless they opt in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:26:34 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 405bb98185 Content-shipped stylesheet override (site.yaml stylesheet)
A content repo can now ship a CSS file named in site.yaml's
stylesheet field; portal serves it same-origin at /site/<path> and
leptos_meta appends it after the default stylesheet so content rules
win. Plain repo-relative .css path only, validated on load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:12:06 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 b259bf39bb Chrome speaks the site's language: site.yaml lang + i18n table
The strings the portal itself says - Asked by, Also worth asking,
Sign in, the not-found page, defaults - translate via a small en/no
table selected by site.yaml's lang, which also sets the html lang
attribute. Content keeps speaking for itself; unknown languages fall
back to English per key.

Also: a scheme-gated markdown image now swallows its alt text instead
of leaking it as stray text - this is the fix for the test that has
been red since 0.3.19 (publish is tag-triggered and does not gate on
the test workflow; caught late).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 17:23:28 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 f4e9f6d0ba site.yaml wordmark_height; raster logos keep their colors
Custom wordmark sizing per site (0.5-6 rem, clamped at render), and
the light-theme invert now applies only to .svg wordmarks - the
white-stroke house style - so a client's raster logo is never
recolored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 17:17:11 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 ee295be6a7 Item cards render inline markdown descriptions
Test / test (push) Successful in 25s
Publish release / publish (push) Successful in 1m39s
Resource-fed cards follow the same convention as every other
description: links live there, sanitized by render_inline_markdown.
Carries the per-recording report link on place pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-08-31 17:18:40 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 ab3a649646 Hidden fields: carrier value without a label row
A type: hidden requirement rendered through the fallback branch, whose
label wrapper shows the field name — so a preset key listed as a second
visible field under the email. Bare hidden input, no row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-08-30 23:08:59 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 07776be2bb type: voice, Requirement.value, playable resource cards
A voice requirement records in the browser and ships PCM to the relay
as one binary frame (voice.js, same mount/stop contract as gesture);
its value becomes {key, digest, duration_ms}. Requirement.value
presets a field and, on a dynamic page, takes the URL segment - so
value: "{key}" tells the voice field where to record. A url resource
source takes the segment too. Resource items with an https `audio`
field render an <audio> player. The gesture widget reports picks to
the relay for ranking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 15:58:04 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 b5ee79da8e Templated actions; places are pickable in the gesture input
action: /shape/{curve.key} fills placeholders from the submitted
responses and must land on a dynamic page (validated). The gesture
widget now treats relay 'place' (kept) and 'echo' (live presence)
frames as pickable options: picking one re-derives the input's key -
that place's decode becomes the ghost under the stroke and the answer
records {key: picked, own_key, selected_from, selected_distance}, the
labelled pair that later ranks places and calibrates the key. Dedupe
by key; an empty state when nothing is kept at the shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 15:38:23 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 be954bc43e Descriptions are inline markdown; Feature.link withdrawn
Alternative and feature descriptions render links, emphasis and code
(pulldown-cmark, html feature only). Block structure flattens to one
paragraph, raw HTML is dropped, link targets are limited to https,
mailto and site-relative paths. A link belongs in the prose, so the
title-link field shipped in 0.3.7 goes before anyone uses it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 14:44:42 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 73efa6a4eb Feature.link: a card's name may point elsewhere
An https URL on a feature renders its name as an outbound link -
an announcement's programme page, a venue. Validated on load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 14:39:58 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 0154f6c8c0 Announced pages: event windows, header announcements, summary tasks
A question may carry event: {starts, duration, place}. While the
window is open the page is announced in a strip at the top of every
header (name, when, 'in 3 days'), soonest first, and kept out of the
footer nav; when it closes the page becomes a followup - only a
visitor carrying an answer chain still sees it.

announce.rs keeps one record per event page in the runtime-owned
portal_events bucket (built-in state graph: announced ->
awaiting_summary -> summarized, content may override) and, on a
one-minute idempotent sweep, moves ended windows to awaiting_summary,
publishing the transition on portal.answers.submitted as 'Summary
due' - the post-what-happened task a review desk picks up. Lint
warns when event pages exist but nothing reads portal_events.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 12:34:34 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 1a45839699 Alternative.disabled: announced but not yet takeable
Test / test (push) Successful in 24s
The button renders disabled (title 'Not yet') and submit_answer
refuses the alternative server-side - lysbue's disabled flag, back as
content, for advertising a path before it works.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 20:25:49 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 8212477377 Empty list says so before it can parse as zero answers
Test / test (push) Successful in 24s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 20:08:59 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 9f26203ed5 Resource empty text: content-declared, and null counts as empty
Test / test (push) Successful in 25s
A `.[] | {...}` jq over an empty list yields no output, which arrived
as null and rendered nothing - redoal's Releases feature was a bare
heading. Null now reads as the same silence as [], and ResourceSpec
gains `empty:` so content can word it ("Nothing released yet");
default stays "Nothing here yet."

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 20:04:35 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 330ab11fe9 Content-hashed pkg assets (hash-files) so stale bundles can't pair with new wasm
Test / test (push) Successful in 26s
An iPhone kept a heuristically-cached portal.js across three releases
(cached before Cache-Control: no-cache existed) and loaded it against
fresh wasm - its snippet imports 404'd, hydration never started, and
the gesture field never mounted. With hash-files = true every pkg
file is content-named and the freshly served page references exactly
its own bundle; hash.txt ships beside the binary (leptos resolves it
next to current_exe), the shell links the stylesheet through
HashedStylesheet, and instances set LEPTOS_HASH_FILES=true.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 19:48:47 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 1afd34c22e Hero becomes a content-owned module; site asset proxy; gesture growth fix
Test / test (push) Successful in 24s
site.yaml's hero is now plain | module, where module names a
JavaScript file the content repo ships (mount(container) -> handle
with stop()). Portal serves content assets same-origin at
/site/<path> (Gitea raw sends no CORS headers), starts the module at
HTML parse time, adopts it on hydration, mounts fresh via the inline
script's __mountHero on client-side navigation, and stops it on
leave. The YES canvas (yes.js) and the gesture hero mode leave the
engine - uhhm/questions ships YES as its hero.js, redoal/questions
ships a sine-swings band. Gesture form canvas no longer balloons after
a stroke: the wrap's aspect-ratio reservation is scoped to :empty
(pre-mount) so it can't turn echo-strip height into width inside the
flex field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 17:26:29 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 30c2b6bea9 Gates keep the question nav
Test / test (push) Successful in 24s
The qualifies and requires_chain gates replaced the whole page,
footer nav included - a visitor hitting one had only the wordmark as
a way out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 17:05:09 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 a1fc333079 Attended-bucket lint rule; app-lifetime resources fix first-nav corruption
Test / test (push) Successful in 25s
validate_questions now rejects content where a record_as bucket is
read by nothing: every bucket must be listed by some Kv resource in
the same repo (a desk) or carry aggregates.yaml's new attended_by
annotation naming the automation that consumes it - no publicly
collected answer may land where nothing reads.

Separately, all server-fn resources (question/user/nav) move to a
PortalShell above the routes, created once and provided via context.
Per-page Resources broke on the first client-side navigation: the
remounted component's fresh Resource consumed a stale SSR hydration
buffer - the nav list [[id, name], ..] deserialized as a Page (serde
fills structs from sequences in field order), so uhhm.no's landing
question rendered chain-gated behind its own nav entry instead of
the /develop/proposal form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 15:36:07 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 b2342f074b One app-level Title fed by a shared site resource
Test / test (push) Successful in 24s
The per-page override remounted on every SPA navigation and lost the
leptos_meta race to App's static SITE_NAME fallback regardless of
hoisting - single source of truth instead: App owns the site resource
(provided via context, pages reuse it) and the only Title, wrapped in
Suspense so SSR still serves the resolved name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:47:09 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 a0305282ef Revalidating cache on app assets; site title survives SPA navigation
Test / test (push) Successful in 24s
pkg files keep stable names across releases, so an uncontrolled
browser cache could pair last release's wasm with the new server's
server-fn wire format - every page then renders its error branch
(redoal.com's 'Nothing here' after v0.2.0). Cache-Control: no-cache
on /pkg and /yes.js makes clients revalidate (a 304 per load) instead
of guessing. The content-declared site title also moves out of the
question Suspense: remounting with each navigation lost the
leptos_meta race to the compile-time fallback, flipping redoal.com's
tab to 'uhhm' on the first client-side nav.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:40:29 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 452ea88fbf Filesystem routes, sections, dynamic segments; instant YES hero
Test / test (push) Successful in 23s
The questions/ tree is the router now: ids derive from file paths
(index.yaml names its directory; explicit id still wins for legacy
content), actions and requires_chain accept relative refs, nested
non-index files infer followup, and _section.yaml applies qualifies/
requires_chain/responsible to everything under its directory. Dynamic
[name].yaml pages serve any /dir/<value> with the segment substituted
into {name} resource-key placeholders; submissions index their chain
node in a portal_chains KV so requires_chain pages can verify a
visitor's ?chain= lineage actually ends at the required question.
Loading uses one recursive git-trees call; question_lint walks
subdirectories the same way. Implements docs/design/filesystem-routes.md.

Also: the YES hero now starts at HTML parse time via an inline module
script (yes.js moved to public/ for a stable /yes.js the wasm binding
raw_module-imports too - snippet paths are per-build-hashed), with
hydration adopting the running instance; and both gesture containers
reserve their box in CSS so mounting doesn't shift content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:30:28 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 0221253cba Gesture input type, redoal-relay client, gitea_releases, content-driven branding
Four coupled additions that let one portal build serve a second face
(redoal.com) next to uhhm.no:

- type: gesture requirement - gesture.js draws a single stroke on a
  DPR-aware canvas (pointer events, touch-action: none), mirrors
  {points, key} into the paired hidden input prosekit-style, and -
  when content declares relay: wss://... - speaks the redoal-relay
  protocol: announce on stroke end, ghost the ack's decoded key path,
  show echoes of similar strokes as thumbnails. Offline/broken relay
  degrades to a plain drawing input; the widget handle's stop()
  closes the socket on SPA navigation (yes.js lifecycle, not
  prosekit's fire-and-forget). Submit re-parses gesture values so the
  bucket stores a real object, not double-encoded JSON.
- gitea_releases resource source - token-authenticated
  /repos/{owner}/{repo}/releases, for advertising a private repo's
  releases (content pins url: null - private html_urls 404 publicly).
- site.yaml branding - optional, at the content repo root: title,
  wordmark, hero {kind: yes|gesture|plain, relay}. Absent file means
  the historical uhhm look, so uhhm changes nothing without a content
  edit. Hot-swapped with questions/aggregates on content reload;
  question_lint validates it in both --path and --repo modes.
- deploy.yml ships the same build twice: uhhm-portal (3010) as
  before, redoal-portal (3020, CONTENT_REPO=redoal/questions,
  redoal.com vhost). Needs host prep + REDOAL_OAUTH2_* repo
  secrets/vars before the new steps succeed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 13:03:29 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 c9c5caf3ff Stable server fn endpoints: open tabs survive deploys
Deploy / deploy (push) Successful in 1m3s
The 'previous question's resources until refresh' mystery: leptos's
auto-generated server fn routes embed a hash that changes across
builds. Every deploy therefore breaks every already-open tab - its
wasm keeps calling /api/get_question<oldhash>, the new server answers
400 'Could not find a server function at the route', and client-side
navigation quietly leaves the previous question's data on screen.
Refresh loads the new wasm with matching hashes, which is why it
always fixed it. Confirmed live: a pre-deploy client 400ed on
get_question2970801986613442004 while the freshly served wasm calls
get_question13103328088426240960, same source on both builds.

Explicit endpoint names decouple the URL from the build. This deploy
is the last breaking one; after it, old clients keep working.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 12:18:15 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 357d755b8c Bound inputs: a field that loads its value from a sibling-parameterized resource
Deploy / deploy (push) Successful in 1m7s
Requirement.bind {field, param, resource}: when the watched sibling
changes (a file select, say), the bind's resource is fetched with the
sibling's value as a parameter and the result becomes this field's
value - the on-site editing flow for /develop-proposal, and a generic
select-plus-detail primitive anywhere else. Url sources gain {name}
path templating (percent-encoded, / preserved, SSRF check runs after
substitution); an empty sibling never fetches and never clears an
edit. Bound textareas render monospace.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 13:49:42 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 3d8af23167 Responsible note back to the footer, above the question nav
Deploy / deploy (push) Successful in 1m2s
Under the title it read as asking for feedback before the visitor had
seen anything - it's meta, it closes the page rather than opening it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 23:48:35 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 f1d835d107 Responsible note as <small>: meta information, dimmer than the description
Deploy / deploy (push) Successful in 1m4s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 23:47:14 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 ab2b3ec128 Responsible note: one line, same hue and size as the hero description
Deploy / deploy (push) Successful in 1m3s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 23:39:08 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 ca09dce90c Feature icons: masked span painted by currentColor, not <img>
Deploy / deploy (push) Successful in 1m1s
An image can't take the text color; a mask-image painted by
background-color can. Icons now follow --feature-accent when the
feature declares a color, and the surrounding text color otherwise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 23:36:14 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 811c48be77 Responsible note under the title; followup pages out of the nav until a chain exists
Deploy / deploy (push) Successful in 1m2s
- The "Asked by X" line moves from the page footer to directly below
  the hero, where the question it belongs to is.
- New Question.followup flag: a post-submission page only appears in
  the question nav once the visitor's context actually carries an
  answer chain - nobody qualifies for "what happens now" before
  something happened.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 23:25:06 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 e237a4b3c0 Question nav + gateway alternatives
Deploy / deploy (push) Successful in 1m1s
- QuestionNav: every other question the visitor currently qualifies
  for, rendered under each page's alternatives - a concern reaches
  the people it speaks to without claiming front-page space, and an
  owner sees the gated desks in the same nav. Context-dependent by
  session (list_qualifying_questions filters via is_qualified).
- An alternative with an action but nothing to submit, confirm, or
  record is a gateway: its button now renders as a real link instead
  of a form submit that did nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 23:09:55 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 4d71b6ccd0 Validate action targets; format timestamps client-side; clippy cleanup
Deploy / deploy (push) Successful in 1m0s
- A dangling alternative.action (navigating to "Nothing here") is now
  a lint/load-time rejection like any other dead reference.
- Rows re-rendered after a client-side refetch showed raw milliseconds
  - format_ms now formats via js_sys::Date under hydrate, matching the
  server's rendering.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 23:05:23 +02:00