Compare commits

..
73 Commits
Author SHA1 Message Date
Bendik Aagaard LynghaugandClaude Fable 5 b08e1af139 Release 0.3.26
Test / test (push) Successful in 26s
Publish release / publish (push) Successful in 1m42s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:20:50 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 c1cdc53187 Select fields: inline static options
A select requirement can now declare a plain options: [A, B, C] list
instead of a resource - each string is both value and label. Restores
the ancestor question format's inline enums (used by the klingenberg
port) without needing a resource endpoint for a fixed list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:20:50 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 4fee02ab82 Release 0.3.25
Test / test (push) Successful in 28s
Publish release / publish (push) Successful in 1m41s
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 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 7fd40aaca9 Release 0.3.24
Test / test (push) Successful in 26s
Publish release / publish (push) Successful in 1m39s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:01:28 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 de6a90bbf5 Gateway submit-as-link loses the underline
A no-requirements gateway alternative renders .alt-submit as an <a>
for plain navigation; strip the link underline so it reads as the
button it's styled to be.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:01:28 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 88b1b96322 Release 0.3.23
Test / test (push) Successful in 28s
Publish release / publish (push) Successful in 1m44s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 17:23:33 +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 528e2badaf Release 0.3.22
Test / test (push) Failing after 25s
Publish release / publish (push) Successful in 1m39s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 17:17:26 +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 3b2cdc07b7 Release 0.3.21
Test / test (push) Failing after 29s
Publish release / publish (push) Successful in 1m42s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 17:10:49 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 2de936d995 Hero paragraph centers its measure box
max-width: 46ch without auto margins left-anchored the box inside the
centered column; the text centered in the wrong frame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 17:10:44 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 9fca79b8ee Release 0.3.20
Test / test (push) Failing after 25s
Publish release / publish (push) Successful in 1m36s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 16:54:40 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 3d51aa1e6a Sign-in becomes optional: KANIDM_URL unset disables auth cleanly
A content-only instance (westra preview) has no review desk and no
Kanidm client; booting no longer demands one. Auth routes answer 503
'sign-in is not configured on this instance'; everything public
renders as usual.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 16:54:35 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 d5eb362c87 Release 0.3.19
Test / test (push) Failing after 25s
Publish release / publish (push) Successful in 1m40s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 16:47:01 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 7f10ba20d3 Markdown images in descriptions, gated and framed
Images already flowed through render_inline_markdown ungated; they
now take the same scheme gate as links (https or same-origin only -
no data:, no plain http) and render as full-width framed figures in
alternative, feature, and item-card descriptions. Carries whole-site
imagery for content-driven instances (westra).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 16:46:56 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 756818cacd Release 0.3.18
Test / test (push) Successful in 26s
Publish release / publish (push) Successful in 1m42s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 15:58:45 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 0423e9c368 Merge convergence: ink-to-key animation + overlay results
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 15:58:41 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 ea7acf4d7d Gesture results overlay the canvas - the page never jumps
Echo thumbnails float centered along the canvas's bottom edge and the
empty-state line sits in the same band; both are out of flow, so the
widget's height is fixed by the canvas alone whether results come
back or not. The strip is pointer-inert except the thumbnails
themselves, so drawing near the bottom edge still works.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 15:55:58 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 19f8fbaf03 Convergence: ink converges onto the decoded key (WebGL glow)
On ack (and on picking a place) a glowing copy of the stroke peels
off and converges point-by-point onto the key's decoded path - a
staggered wave from stroke start to end, comet trails, additive on
dark / ink on light, ghost deposited where the light settles. WebGL
point sprites on an overlay canvas; no WebGL or reduced-motion means
exactly the previous behavior. ?relay= query override points widgets
at a local relay for dev.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 15:47:09 +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 d161677155 Release 0.3.16
Test / test (push) Successful in 25s
Publish release / publish (push) Successful in 1m33s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-08-30 23:13:33 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 8229dbf4c2 Announce sweeper: refresh open records from content
A date or place corrected after first announce never reached the
portal_events record, so the followup fired on the stale schedule.
While a record is still in its initial state, content is the source
of truth: differing responses are written back on each sweep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-08-30 23:13:29 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 3de069f41d Release 0.3.15
Test / test (push) Successful in 24s
Publish release / publish (push) Successful in 1m33s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-08-30 23:09:11 +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 Lynghaug f1e7573b36 Release 0.3.14
Test / test (push) Successful in 24s
Publish release / publish (push) Successful in 1m33s
2026-08-30 16:56:19 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 2082e8ae48 Feature cards: icon column, one text edge
The icon floated, so a description's second line wrapped back under
it. With an icon the feature is a two-column grid: icon left, every
other child in the text column.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:56:18 +02:00
Bendik Aagaard Lynghaug 313126768b Release 0.3.13
Test / test (push) Successful in 25s
Publish release / publish (push) Successful in 1m33s
2026-08-30 16:36:15 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 78953a4633 Voice field: nothing leaves the browser until Keep it here
The widget holds the alternative's submit: on the first press it
uploads, sets its value on the relay's confirmation, then lets the
submit through. A relay error keeps the recording for another try and
submits nothing. Status copy says what to do at each step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:36:14 +02:00
Bendik Aagaard Lynghaug 703e6b6356 Release 0.3.12
Test / test (push) Successful in 24s
Publish release / publish (push) Successful in 1m32s
2026-08-30 16:06:58 +02:00
Bendik Aagaard Lynghaug 2c545cd1b0 Voice preview stays hidden until there is something to play 2026-08-30 16:06:57 +02:00
Bendik Aagaard Lynghaug 47c894cc1d Release 0.3.11
Test / test (push) Successful in 25s
Publish release / publish (push) Successful in 1m32s
2026-08-30 16:02:42 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 7bc59a65e0 Validate templated actions against the page pattern
resolve_question returns a clone with a concrete id, so is_dynamic()
on it was always false and every templated action failed lint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:02:41 +02:00
Bendik Aagaard Lynghaug 243b6a5704 README: gesture and voice fields
Test / test (push) Successful in 24s
2026-08-30 16:00:25 +02:00
Bendik Aagaard Lynghaug 3a20870762 Release 0.3.10
Test / test (push) Successful in 24s
Publish release / publish (push) Successful in 1m34s
2026-08-30 15:58:05 +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 Lynghaug 39fdb8e0e9 Release 0.3.9
Test / test (push) Successful in 25s
Publish release / publish (push) Successful in 3m8s
2026-08-30 15:38:24 +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 Lynghaug ff2f94e549 Release 0.3.8
Publish release / publish (push) Successful in 1m35s
Test / test (push) Successful in 25s
2026-08-30 14:44:43 +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 Lynghaug 7fb0afcdc5 Release 0.3.7
Test / test (push) Successful in 25s
Publish release / publish (push) Successful in 1m30s
2026-08-30 14:39:59 +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 Lynghaug 7a0724af7f Release 0.3.6
Test / test (push) Successful in 24s
Publish release / publish (push) Successful in 1m31s
2026-08-30 12:34:35 +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 Lynghaug 47d7f9d2c3 Release 0.3.5
Publish release / publish (push) Successful in 1m27s
Test / test (push) Successful in 22s
2026-08-30 11:40:47 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 12e9fcd609 Gesture widget: ghost path back, for v2 keys only
Test / test (push) Successful in 23s
The relay's ack path is drawn under the stroke when the key's version
nibble is >= 2 (ADR-0014's ordered turning chain decodes to the
stroke itself); a v1 key's blob stays hidden. Safe to ship ahead of
the relay: nothing shows until the relay encodes v2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 11:38:48 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 72674afe05 Gesture widget: stop drawing the decoded ghost path
Test / test (push) Successful in 25s
decode_key's reconstruction is too rough to have value on screen;
the ack's key is kept, its path ignored. Revisit only once the
reconstruction is fixed at the core in redoal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 11:21:20 +02:00
Bendik Aagaard Lynghaug 7ba9b08d76 Release 0.3.4
Test / test (push) Successful in 24s
Publish release / publish (push) Successful in 1m27s
2026-08-25 20:25:52 +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 Lynghaug be54f54804 Release 0.3.3
Test / test (push) Successful in 24s
Publish release / publish (push) Successful in 1m26s
2026-08-25 20:09:00 +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 Lynghaug e97470c2f6 Release 0.3.2
Publish release / publish (push) Successful in 1m35s
Test / test (push) Successful in 40s
2026-08-25 20:04:38 +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 d6fdcd0bce README: hashed pkg assets in the release flow
Test / test (push) Successful in 23s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 19:52:31 +02:00
Bendik Aagaard Lynghaug d65c5593b3 Release 0.3.1
Test / test (push) Successful in 25s
Publish release / publish (push) Successful in 1m25s
2026-08-25 19:48:49 +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 Lynghaug d403eda5da Release 0.3.0
Test / test (push) Successful in 24s
Publish release / publish (push) Successful in 1m5s
2026-08-25 17:26:31 +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 Lynghaug 307fd7e753 Release 0.2.4
Test / test (push) Successful in 24s
Publish release / publish (push) Successful in 1m16s
2026-08-25 17:05:12 +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 Lynghaug b875045bbc Release 0.2.3
Test / test (push) Successful in 24s
Publish release / publish (push) Successful in 1m8s
2026-08-25 15:36:10 +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 Lynghaug 0d6cdb8a00 Release 0.2.2
Test / test (push) Successful in 23s
Publish release / publish (push) Successful in 1m5s
2026-08-24 22:47:12 +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 Lynghaug 7429f1b9e1 Release 0.2.1
Test / test (push) Successful in 23s
Publish release / publish (push) Successful in 1m23s
2026-08-24 22:40:30 +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 c67f6f1a59 Docs: routing bullet + design doc marked implemented
Test / test (push) Successful in 23s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:33:37 +02:00
Bendik Aagaard Lynghaug 4e69f26fcc Release 0.2.0
Test / test (push) Successful in 24s
Publish release / publish (push) Successful in 1m8s
2026-08-24 22:30:35 +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 b737e0a7e7 Design doc: filesystem routes, sections, dynamic segments
Test / test (push) Successful in 24s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 21:58:40 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 b354db2e76 README: frame portal as a generic multi-site question engine
Test / test (push) Successful in 24s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 21:53:17 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 74965376bd Docs: release/rollout flow after the instance-ownership refactor
Test / test (push) Successful in 23s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 21:49:31 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 8858336ecc Semver releases cut with cargo-release; publish on v* tags only
Test / test (push) Successful in 23s
Publish release / publish (push) Successful in 1m8s
cargo release <level> bumps, tags v<semver>, and pushes; publish.yml
reacts to the tag and attaches the artifact to that release. Plain
main pushes now run tests (test.yml) instead of publishing build-<sha>
artifacts on every push.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 21:44:31 +02:00
22 changed files with 3252 additions and 1008 deletions
@@ -1,14 +1,16 @@
name: Publish release name: Publish release
# Portal no longer deploys itself. Each content repo (uhhm/questions, # Portal does not deploy itself. Cutting a version is deliberate:
# redoal/questions) owns its instance - domain, port, env, Caddy route - # `cargo release <level>` bumps Cargo.toml, commits, tags v<semver>,
# and its deploy workflow downloads a pinned release published here. # and pushes; this workflow reacts to the tag and publishes the
# Rolling a new portal version out to a site = bumping PORTAL_RELEASE # artifact as a Gitea release. Each content repo (uhhm/questions,
# in that site's .gitea/workflows/deploy.yml (an auditable commit). # redoal/questions) pins PORTAL_RELEASE to one of these tags in its
# own deploy workflow - bumping the pin there is what rolls a version
# out to a site. Plain main pushes only run test.yml.
on: on:
push: push:
branches: [main] tags: ["v*"]
jobs: jobs:
publish: publish:
@@ -62,37 +64,41 @@ jobs:
- name: Package - name: Package
run: | run: |
set -euo pipefail set -euo pipefail
tag="build-$(echo ${{ github.sha }} | cut -c1-7)" tag="${{ github.ref_name }}"
echo "TAG=$tag" >> "$GITHUB_ENV"
stage=$(mktemp -d) stage=$(mktemp -d)
cp "$CARGO_TARGET_DIR/release/portal" "$stage/portal" cp "$CARGO_TARGET_DIR/release/portal" "$stage/portal"
cp "$CARGO_TARGET_DIR/release/question_lint" "$stage/question_lint" cp "$CARGO_TARGET_DIR/release/question_lint" "$stage/question_lint"
# hash-files = true: leptos resolves hash.txt next to the running
# binary, so it ships beside portal in the release dir.
cp "$CARGO_TARGET_DIR/release/hash.txt" "$stage/hash.txt"
# site-root ("target/site" in Cargo.toml) is project-relative, # site-root ("target/site" in Cargo.toml) is project-relative,
# not affected by CARGO_TARGET_DIR - only the plain `cargo build` # not affected by CARGO_TARGET_DIR - only the plain `cargo build`
# outputs (release/, front/) move with that override. # outputs (release/, front/) move with that override.
cp -r target/site "$stage/site" cp -r target/site "$stage/site"
tar -C "$stage" -czf "portal-$tag.tar.gz" portal question_lint site tar -C "$stage" -czf "portal-$tag.tar.gz" portal question_lint hash.txt site
rm -rf "$stage" rm -rf "$stage"
# The run's own ephemeral token has write access to this repo - # The run's own ephemeral token has write access to this repo -
# no long-lived PAT to manage. Re-running a build for the same sha # no long-lived PAT to manage. The tag already exists (cargo
# finds the existing release instead of failing on the tag. # release pushed it), so the release attaches to it; a re-run
# finds the existing release instead of failing.
- name: Publish release - name: Publish release
run: | run: |
set -euo pipefail set -euo pipefail
tag="${{ github.ref_name }}"
api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}" api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
auth="Authorization: token ${{ secrets.GITHUB_TOKEN }}" auth="Authorization: token ${{ secrets.GITHUB_TOKEN }}"
subject=$(git log -1 --format=%s) subject=$(git log -1 --format=%s)
body=$(printf '{"tag_name":"%s","target_commitish":"%s","name":"%s"}' \ body=$(printf '{"tag_name":"%s","name":"%s"}' \
"$TAG" "${{ github.sha }}" "$TAG: $(echo "$subject" | sed 's/"/\\"/g')") "$tag" "$tag: $(echo "$subject" | sed 's/"/\\"/g')")
id=$(curl -sf -X POST -H "$auth" -H 'Content-Type: application/json' \ id=$(curl -sf -X POST -H "$auth" -H 'Content-Type: application/json' \
-d "$body" "$api/releases" | jq .id) \ -d "$body" "$api/releases" | jq .id) \
|| id=$(curl -sf -H "$auth" "$api/releases/tags/$TAG" | jq .id) || id=$(curl -sf -H "$auth" "$api/releases/tags/$tag" | jq .id)
# Replace the asset if a re-run already uploaded one. # Replace the asset if a re-run already uploaded one.
for aid in $(curl -sf -H "$auth" "$api/releases/$id/assets" | jq '.[].id'); do for aid in $(curl -sf -H "$auth" "$api/releases/$id/assets" | jq '.[].id'); do
curl -sf -X DELETE -H "$auth" "$api/releases/$id/assets/$aid" curl -sf -X DELETE -H "$auth" "$api/releases/$id/assets/$aid"
done done
curl -sf -X POST -H "$auth" \ curl -sf -X POST -H "$auth" \
-F "attachment=@portal-$TAG.tar.gz" \ -F "attachment=@portal-$tag.tar.gz" \
"$api/releases/$id/assets?name=portal-$TAG.tar.gz" > /dev/null "$api/releases/$id/assets?name=portal-$tag.tar.gz" > /dev/null
echo "published $TAG" echo "published $tag"
+26
View File
@@ -0,0 +1,26 @@
name: Test
# Publishing only happens on v* tags (publish.yml), so this is what
# keeps plain main pushes honest between releases.
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: bare
env:
# Same shared-toolchain/cache story as publish.yml.
CARGO_HOME: /var/local/cargo
RUSTUP_HOME: /var/local/rustup
PATH: /var/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/bin
SCCACHE_DIR: /var/local/sccache
SCCACHE_SERVER_PORT: "4228"
CARGO_TARGET_DIR: /var/local/cargo-target
steps:
- uses: actions/checkout@v4
- name: Test
run: cargo test --features ssr
Generated
+20 -1
View File
@@ -2948,7 +2948,7 @@ dependencies = [
[[package]] [[package]]
name = "portal" name = "portal"
version = "0.1.0" version = "0.3.25"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
@@ -2970,6 +2970,7 @@ dependencies = [
"leptos_router", "leptos_router",
"openidconnect", "openidconnect",
"proptest", "proptest",
"pulldown-cmark",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
@@ -3104,6 +3105,24 @@ dependencies = [
"unarray", "unarray",
] ]
[[package]]
name = "pulldown-cmark"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
dependencies = [
"bitflags 2.13.1",
"memchr",
"pulldown-cmark-escape",
"unicase",
]
[[package]]
name = "pulldown-cmark-escape"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]] [[package]]
name = "quick-error" name = "quick-error"
version = "1.2.3" version = "1.2.3"
+20 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "portal" name = "portal"
version = "0.1.0" version = "0.3.26"
edition = "2021" edition = "2021"
[lib] [lib]
@@ -12,6 +12,9 @@ leptos_meta = { version = "0.8" }
leptos_router = { version = "0.8" } leptos_router = { version = "0.8" }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
# Inline markdown for content descriptions (links, emphasis, code) -
# runs on both server and client renders, so not feature-gated.
pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
# --- server only --- # --- server only ---
leptos_axum = { version = "0.8", optional = true } leptos_axum = { version = "0.8", optional = true }
@@ -19,7 +22,7 @@ axum = { version = "0.8", features = ["multipart"], optional = true }
aws-sdk-s3 = { version = "1", optional = true } aws-sdk-s3 = { version = "1", optional = true }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"], optional = true } tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"], optional = true }
tower = { version = "0.5", optional = true } tower = { version = "0.5", optional = true }
tower-http = { version = "0.6", features = ["fs"], optional = true } tower-http = { version = "0.6", features = ["fs", "set-header"], optional = true }
tower-sessions = { version = "0.14", optional = true } tower-sessions = { version = "0.14", optional = true }
async-nats = { version = "0.38", optional = true } async-nats = { version = "0.38", optional = true }
arc-swap = { version = "1", optional = true } arc-swap = { version = "1", optional = true }
@@ -131,3 +134,18 @@ bin-default-features = false
lib-features = ["hydrate"] lib-features = ["hydrate"]
lib-default-features = false lib-default-features = false
lib-profile-release = "wasm-release" lib-profile-release = "wasm-release"
# Content-hashed pkg names (portal.<hash>.js/wasm/css) + hash.txt: a
# freshly served page can never reference a stale cached bundle - an
# iPhone kept a heuristically-cached portal.js across three releases
# and paired it with new wasm, silently killing hydration. Runtime
# needs LEPTOS_HASH_FILES=true (set in each content repo's deploy env).
hash-files = true
# cargo release <level> is how a portal version is cut: bump, commit,
# tag v{{version}}, push. CI (publish.yml) reacts to the tag and
# publishes the release artifact; nothing on crates.io.
[package.metadata.release]
publish = false
push = true
tag-name = "v{{version}}"
pre-release-commit-message = "Release {{version}}"
+115 -24
View File
@@ -1,17 +1,61 @@
# portal # portal
The question runtime behind [uhhm.no](https://uhhm.no). Every page, A content-driven question engine: one Rust binary that turns a Git
form, review desk, and state machine it serves is declared in the repo of YAML into a live site — pages, forms, review desks, and state
[`questions`](https://project.uhhm.no/uhhm/questions) content repo — machines, with a durable event-sourced record of every answer
this codebase is the engine that renders, enforces, and records, and underneath. The engine special-cases nothing: everything a site
it special-cases none of it. serves is declared in its content repo, and the same build serves any
number of sites.
Point an instance at a content repo and that repo *is* the site:
pages, copy, state graphs, branding, and even the landing hero
(`site.yaml`: title, wordmark, and `hero: {kind: module, module:
hero.js}` — a JavaScript module the content repo ships, served
same-origin at `/site/<path>`, that portal mounts at parse time and
stops on navigation; the YES canvas and redoal's sine swings are both
content, not engine). Content
pushes hot-reload every running instance; instances differ only by
env vars. [uhhm.no](https://uhhm.no)
([uhhm/questions](https://project.uhhm.no/uhhm/questions)) and
[redoal.com](https://redoal.com)
([redoal/questions](https://project.uhhm.no/redoal/questions)) are
two faces of the same binary, deployed from the same release
artifact.
It composes with the surrounding stack rather than bundling it: Gitea
hosts and serves the content, NATS JetStream stores events and
projections, Kanidm provides identity, and anything downstream
(automations, newsletters, onboarding) subscribes to the answer
stream.
## What the engine provides ## What the engine provides
- **Content-driven pages** (`src/content.rs`, `src/app.rs`): YAML - **Content-driven pages** (`src/content.rs`, `src/app.rs`): YAML
loaded from Gitea at boot and hot-swapped on a NATS reload signal; loaded from Gitea at boot and hot-swapped on a NATS reload signal;
a bad push keeps the last-good content serving. A page's `id` is a bad push keeps the last-good content serving. The `questions/`
its URL; `qualifies` gates it to a Kanidm group. tree IS the router — file paths become URLs, `_section.yaml`
applies criteria to a whole directory, `[name].yaml` pages serve
any `/dir/<value>` with the segment fed into resource keys, and
`requires_chain` gates a page on verifiable answer provenance next
to `qualifies`' Kanidm-group identity gate (see
`docs/design/filesystem-routes.md`).
- **Gesture and voice fields** (`gesture.js`, `voice.js`): a stroke
becomes a redoal gesture key through a relay (ADR-0013/0015 in the
redoal repo) - the input shows the key's own decode under the
stroke, who is at a similar shape now, and *places* with recordings
the visitor can pick to make that key theirs; a voice field records
in the browser and leaves the audio at a key. Actions may be
templated from the answer (`/shape/{curve.key}`) onto dynamic
pages, whose URL segment also fills `value: "{key}"` presets and
`url` resource sources; resource items with an https `audio` field
render a player.
- **Announced pages** (`src/announce.rs`): a question with an
`event: {starts, duration, place}` window is announced in the
header (not the footer nav) while the window is open, becomes a
followup when it closes, and its record in the runtime-owned
`portal_events` bucket moves to `awaiting_summary` - a "post what
happened" task any review desk can read, published on NATS like a
decision. A one-minute sweeper keeps it all idempotent.
- **State machines as content** (`src/aggregates/`): `aggregates.yaml` - **State machines as content** (`src/aggregates/`): `aggregates.yaml`
declares each bucket's states and legal transitions; the engine declares each bucket's states and legal transitions; the engine
replays a record's event history and refuses undeclared moves, with replays a record's event history and refuses undeclared moves, with
@@ -28,8 +72,13 @@ it special-cases none of it.
lineage; `?chain=` links carry it, and self-service transitions lineage; `?chain=` links carry it, and self-service transitions
(unsubscribe) authorize by holding one. (unsubscribe) authorize by holding one.
- **Live resources** (`src/resource.rs`): content can pull a KV - **Live resources** (`src/resource.rs`): content can pull a KV
bucket, Gitea starred/org repos, or any public JSON URL (SSRF bucket, Gitea starred/org repos/releases, or any public JSON URL
fail-closed), reshaped by a content-declared `jq` filter. (SSRF fail-closed), reshaped by a content-declared `jq` filter.
- **Input kinds as content**: a requirement's `type:` picks the
widget — plain fields, a rich-text editor, or a `gesture` drawing
canvas that can connect to a redoal relay so similar strokes echo
between visitors live. Submitted values are arbitrary JSON; the
engine records what the widget produced.
- **Review desks**: any Kv resource with `transitions` renders rows - **Review desks**: any Kv resource with `transitions` renders rows
with per-state action buttons and one shared confirm per with per-state action buttons and one shared confirm per
alternative — owners walk records through their graphs without alternative — owners walk records through their graphs without
@@ -38,9 +87,10 @@ it special-cases none of it.
## Binaries ## Binaries
- `portal` — the server (Leptos SSR + hydrate, Axum underneath). - `portal` — the server (Leptos SSR + hydrate, Axum underneath).
- `question_lint` — headless content validation, run by the - `question_lint` — headless content validation, shipped inside every
`questions` repo's CI against a prebuilt copy this repo's deploy release artifact so each content repo's CI lints with the exact
publishes; also works offline: `question_lint --path <dir>`. portal version its instance runs; also works offline:
`question_lint --path <dir>`.
## Development ## Development
@@ -50,17 +100,58 @@ cargo test --features ssr # engine tests
cargo build --features ssr --bin question_lint cargo build --features ssr --bin question_lint
``` ```
Runtime configuration is env vars (see `src/main.rs` and Runtime configuration is env vars (see `src/main.rs`, and each
`.gitea/workflows/deploy.yml`): `NATS_URL`, `CONTENT_REPO`/ content repo's `.gitea/workflows/deploy.yml` for the values in
`CONTENT_BRANCH`, Kanidm OIDC (`KANIDM_URL`, `OAUTH2_CLIENT_*`), production): `NATS_URL`, `CONTENT_REPO`/`CONTENT_BRANCH`, Kanidm OIDC
optional `GARAGE_*` for uploads, `GITEA_API_TOKEN` for authenticated (`KANIDM_URL`, `OAUTH2_CLIENT_*`), optional `GARAGE_*` for uploads,
resource pulls, `AUTOMATION_READ_TOKEN` for the automation KV read `GITEA_API_TOKEN` for authenticated resource pulls,
endpoint. `AUTOMATION_READ_TOKEN` for the automation KV read endpoint.
## Deploy ## Release and deploy
Pushing `main` triggers `.gitea/workflows/deploy.yml` on the Portal doesn't deploy itself — it publishes versions, and each site
bare-metal runner: release build, ship to decides when to take one. The whole day-to-day surface is two
`/srv/app/uhhm-portal/releases/<sha>`, flip the `current` symlink, commands:
restart `app@uhhm-portal`, reload Caddy. Content changes never come
through here — they hot-reload live from the `questions` repo. **Cut a release** (here):
```sh
cargo release patch # or minor / major
```
That bumps `Cargo.toml`, tags `v<version>`, and pushes; CI
(`.gitea/workflows/publish.yml`) reacts to the tag, builds once, and
attaches `portal-v<version>.tar.gz` (`portal` + `question_lint` +
`hash.txt` + `site/`) to the Gitea release. Pkg files are
content-hashed (`hash-files = true`; instances run with
`LEPTOS_HASH_FILES=true`), so a freshly served page can never pair a
stale cached bundle with new wasm. Plain pushes to `main` only run the
tests (`test.yml`) — nothing reaches production from this repo.
**Roll it out** (in a content repo): edit one line in that repo's
`.gitea/workflows/deploy.yml`
```yaml
env:
PORTAL_RELEASE: v0.1.1 # <- bump, commit, push
```
Its CI downloads the pinned artifact, ships
`/srv/app/<instance>/releases/<tag>`, flips `current`, rewrites the
instance env from that repo's own Actions variables/secrets, restarts
`app@<instance>`, and refreshes the Caddy route. Every rollout is a
commit, so reverting a bad version is `git revert` + push. Sites
upgrade independently: uhhm.no (uhhm/questions → `app@uhhm-portal`,
:3010) and redoal.com (redoal/questions → `app@redoal-portal`,
:3020) can pin different versions.
Content changes never come through any of this — they hot-reload
live from the content repos over NATS.
**Add a site**: new content repo with a copy of an existing
`deploy.yml` (change `INSTANCE`, port, domains), Actions variables
(`KANIDM_URL`, `OAUTH2_CLIENT_ID`, `PUBLIC_URL`, `SITE_NAME`) and
secrets (`NATS_URL`, `OAUTH2_CLIENT_SECRET`,
`PORTAL_GITEA_API_TOKEN` — Gitea reserves the `GITEA_` prefix for
secret names), a Kanidm OAuth2 client, and DNS. `site.yaml` in the
content repo handles all branding; no portal changes needed.
+133
View File
@@ -0,0 +1,133 @@
# Filesystem routes, sections, and where chains fit
Status: implemented in v0.2.0 (2026-08-24) — all three phases, with
one deviation: dynamic-page params substitute into resource keys via
server-side `resolve_question` at lookup time (get_question,
find_feature, submit_answer all resolve concrete paths), so no param
threading exists client-side. The user-facing routing contract is
documented in uhhm/questions' README ("Routing: the tree is the
router"); this file stays as the design rationale.
## What exists today, precisely
- A question's `id` doubles as its URL. Filenames are meaningless:
`questions/` is loaded as a **flat** directory (both the Gitea
loader and `question_lint --path`), every `*.yaml` becomes a
`Question` keyed by its own declared `id`.
- Hierarchy exists only as strings: `action: /proposed` edges, plus a
manual `followup: true` flag that hides post-submission pages from
the nav. The graph is invisible in a repo listing — you open every
file to learn the flow. (uhhm's actual graph: `/` fans out to three
followups; `/develop``/develop-proposal``/proposed` is a
two-step flow flattened into three top-level files.)
- Criteria are per-file: `qualifies: <kanidm-group>` on a question,
`requires_group` on a resource. Gating a whole area means
repeating the flag in every file of that area.
- Chains are query-string lineage: submitting hashes
`(question, parents, responses, ts)` into `chain_hash`, the next
page is `action + ?chain=<hash>`, and holding a chain is itself a
capability (`self_transition` + email second factor). `chain.rs`
reserves DAG shape (multiple parents) but nothing produces it yet.
- `Question.route` is a declared-but-never-read field — a fossil of
this exact idea.
## The proposal, in three phases
### Phase 1 — the tree is the router
Directory structure becomes the URL structure, next-js style:
```
questions/
index.yaml → /
applied.yaml → /applied
develop/
index.yaml → /develop
proposal.yaml → /develop/proposal
proposed.yaml → /develop/proposed
review/
index.yaml → /review
```
- `id:` becomes optional and **derived from the path** (`index.yaml`
names its directory). An explicit `id:` still wins so both content
repos keep working unchanged; lint warns when it disagrees with the
path, and the field can retire later along with `route`.
- `action:` accepts **relative references**: `action: proposed`
resolves against the file's directory, `action: /subscribed` stays
absolute. Resolution happens at load time, so validation and the
runtime see absolute ids exactly as today. A flow directory becomes
self-contained: rename `develop/` and every internal edge moves
with it.
- **`followup:` is inferred**: `index.yaml` files are nav pages,
non-index files are followups unless they say `nav: true`. This
matches the real content exactly (uhhm's four `followup: true`
files are precisely its non-index leaf pages) and deletes a flag
people must remember.
- Loader: switch from the per-directory contents API to Gitea's git
trees API (`/git/trees/{branch}?recursive=true`) — one request for
the whole tree instead of one per directory, which the flat loader
should be using anyway.
### Phase 2 — sections: criteria scoped by URL prefix
A `_section.yaml` in any directory applies to everything beneath it
(underscore = not a page, like next's private folders):
```
questions/review/_section.yaml:
qualifies: portal_owners
responsible: { name: Bendik, contact: … }
```
This is the URL/criteria interplay actually worth having: **a URL
prefix becomes a trust boundary**. "Everything under /review is
owner-only" is one line in one place, instead of a flag per file that
drifts. Per-question `qualifies` still overrides (tighter or looser —
lint should warn on looser). Sections are also the natural home for
shared `responsible` contacts and, later, per-section branding
accents.
### Phase 3 — dynamic segments, and chains stay out of the path
Two criteria axes exist: **who you are** (Kanidm group) and **what
you've done** (holding a chain). Phase 2 scopes the first by prefix;
phase 3 does the same for the second, plus gives records addresses:
- `[record].yaml` — a dynamic segment, one YAML file rendered per
record: `questions/review/[record].yaml` serves `/review/<key>`,
with the param available to the page's `ResourceSpec.key`. Today a
single record is only reachable through a desk row or a
`?chain=` link; this gives every record a real, gated URL —
linkable from review desks, automations, and n8n notifications.
- `requires_chain: <question-ref>` (page- or section-level): the page
only renders for a visitor whose chain tip answers the referenced
question. Today's followup pages are soft-hidden (out of nav) but
fully reachable; this makes "you must have come from X" an actual
criterion, declared with a relative ref like actions are.
**Deliberately not proposed: encoding the chain in the path.** The
page tree is static structure; the chain is runtime lineage and a
bearer capability. Putting it in the path makes it look canonical and
shareable — exactly what a capability URL shouldn't invite — and a
DAG (the reserved multi-parent shape) doesn't linearize into a path
anyway. `?chain=` stays a query parameter: pages keep one canonical
URL, lineage rides along only when it's actually held.
## Migration
Phase 1 is fully backward compatible (explicit `id` wins, flat repos
are just trees of depth one). The content repos migrate by `git mv`:
uhhm's develop flow nests under `develop/`, its followups either stay
top-level (`/applied` keeps its URL) or move with their flows if URL
churn is acceptable; redoal's three files are already the tree. Lint
learns the same resolution rules in the same commit, so a bad
reference stays a caught push, never a 404.
## Order of value
Phase 1 is cheap and pays immediately (the repo listing becomes the
sitemap). Phase 2 is small and unlocks gated areas properly. Phase 3
is the real feature work — `[record]` pages change what desks and
automations can link to — and can wait until something concrete needs
it.
+316 -39
View File
@@ -12,8 +12,10 @@
// //
// When a relay URL is given, the widget also speaks the redoal-relay // When a relay URL is given, the widget also speaks the redoal-relay
// protocol (ADR-0013 in the redoal repo): announce the stroke, receive // protocol (ADR-0013 in the redoal repo): announce the stroke, receive
// an ack carrying the stroke's gesture key + the key's own decoded // an ack carrying the stroke's gesture key plus the key's own decoded
// path ("what the network heard", drawn as a ghost), and receive // path - "what the network heard", drawn as a ghost under the stroke
// when the key is v2 or later (ADR-0014's ordered turning chain; a v1
// key's histogram decode is a blob and stays hidden) - and receive
// echoes of similar strokes other visitors drew, shown as thumbnails. // echoes of similar strokes other visitors drew, shown as thumbnails.
// Everything network is best-effort: no relay, refused connection, or // Everything network is best-effort: no relay, refused connection, or
// a dropped socket all degrade to a plain offline drawing input. // a dropped socket all degrade to a plain offline drawing input.
@@ -26,13 +28,13 @@
const THEMES = { const THEMES = {
dark: { dark: {
stroke: '#8ec2c0', stroke: '#8ec2c0',
ghost: 'rgba(255, 255, 255, 0.28)',
echo: '#8ec2c0', echo: '#8ec2c0',
ghost: 'rgba(255, 255, 255, 0.28)',
}, },
light: { light: {
stroke: '#47807e', stroke: '#47807e',
ghost: 'rgba(29, 29, 27, 0.30)',
echo: '#47807e', echo: '#47807e',
ghost: 'rgba(29, 29, 27, 0.30)',
}, },
}; };
@@ -41,15 +43,87 @@ const MAX_ECHOES = 8;
const RECONNECT_BASE_MS = 1000; const RECONNECT_BASE_MS = 1000;
const RECONNECT_MAX_MS = 30000; const RECONNECT_MAX_MS = 30000;
// ── Convergence animation (ink → what the network heard) ──────────
//
// When the ack lands, a glowing copy of the stroke peels off and
// converges point-by-point onto the key's decoded path: a wave of
// "being understood" travels from the stroke's start to its end,
// and the ghost materializes where the light settles. WebGL point
// sprites with additive glow on a dedicated overlay canvas; the 2d
// layer underneath never changes its final render, so losing WebGL
// (or prefers-reduced-motion) degrades to exactly today's behavior.
const CONVERGE_MS = 1500;
const CONVERGE_FADE_MS = 450;
const CONVERGE_POINTS = 128;
const CONVERGE_STAGGER = 0.6; // fraction of the timeline spent on the travelling wave
const GLOW_VERT = `
attribute vec2 aFrom;
attribute vec2 aTo;
attribute float aIndex;
uniform float uT;
uniform vec2 uResolution;
uniform float uDpr;
varying float vLife;
void main() {
float lead = aIndex * ${CONVERGE_STAGGER};
float p = clamp((uT * ${1.0 + CONVERGE_STAGGER} - lead), 0.0, 1.0);
float e = p < 0.5 ? 4.0 * p * p * p : 1.0 - pow(-2.0 * p + 2.0, 3.0) / 2.0;
vec2 pos = mix(aFrom, aTo, e);
vec2 clip = (pos / uResolution) * 2.0 - 1.0;
gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0);
float pulse = 1.0 + 1.4 * sin(3.14159 * p);
gl_PointSize = 9.0 * uDpr * pulse;
vLife = p;
}`;
const GLOW_FRAG = `
precision mediump float;
uniform vec3 uColor;
uniform float uAlpha;
varying float vLife;
void main() {
vec2 d = gl_PointCoord - 0.5;
float fall = exp(-dot(d, d) * 18.0);
float breathe = 0.55 + 0.45 * sin(3.14159 * vLife);
gl_FragColor = vec4(uColor, 1.0) * (fall * uAlpha * breathe);
}`;
// Uniform arc-length resample of a pixel-space polyline to n points.
function resamplePx(points, n) {
if (points.length === 1) return Array(n).fill(points[0]);
const dists = [0];
for (let i = 1; i < points.length; i++) {
const dx = points[i][0] - points[i - 1][0];
const dy = points[i][1] - points[i - 1][1];
dists.push(dists[i - 1] + Math.hypot(dx, dy));
}
const total = dists[dists.length - 1] || 1;
const out = [];
let seg = 0;
for (let i = 0; i < n; i++) {
const target = (i / (n - 1)) * total;
while (seg < points.length - 2 && dists[seg + 1] < target) seg++;
const span = dists[seg + 1] - dists[seg] || 1;
const t = (target - dists[seg]) / span;
out.push([
points[seg][0] + (points[seg + 1][0] - points[seg][0]) * t,
points[seg][1] + (points[seg + 1][1] - points[seg][1]) * t,
]);
}
return out;
}
class GestureWidget { class GestureWidget {
constructor(container, hidden, relayUrl, hero) { constructor(container, hidden, relayUrl) {
this.container = container; this.container = container;
this.hidden = hidden || null; this.hidden = hidden || null;
this.relayUrl = relayUrl || ''; this.relayUrl = relayUrl || '';
this.hero = !!hero;
this.points = [];
this.ghost = null; this.ghost = null;
this.ambient = []; // hero mode: echoed strokes drawn in place this.ownKey = null;
this.selected = null; // {key, path, distance} - a place the visitor picked
this.seenKeys = new Set();
this.points = [];
this.drawing = false; this.drawing = false;
this.stopped = false; this.stopped = false;
this.ws = null; this.ws = null;
@@ -60,11 +134,25 @@ class GestureWidget {
this.canvas = document.createElement('canvas'); this.canvas = document.createElement('canvas');
this.canvas.className = 'gesture-canvas'; this.canvas.className = 'gesture-canvas';
container.appendChild(this.canvas); container.appendChild(this.canvas);
if (!this.hero) { // Overlay for the convergence glow; sized with the main canvas,
// inert to pointers, empty until an ack arrives.
this.glow = document.createElement('canvas');
this.glow.className = 'gesture-glow';
this.glow.style.position = 'absolute';
this.glow.style.pointerEvents = 'none';
if (!container.style.position) container.style.position = 'relative';
container.appendChild(this.glow);
this.gl = null;
this.animFrame = null;
this.converging = false;
this.echoes = document.createElement('div'); this.echoes = document.createElement('div');
this.echoes.className = 'gesture-echoes'; this.echoes.className = 'gesture-echoes';
this.echoes.setAttribute('role', 'listbox');
this.echoes.setAttribute('aria-label', 'places near your shape');
container.appendChild(this.echoes); container.appendChild(this.echoes);
} this.empty = document.createElement('p');
this.empty.className = 'gesture-empty';
container.appendChild(this.empty);
if (this.relayUrl) { if (this.relayUrl) {
this.status = document.createElement('span'); this.status = document.createElement('span');
this.status.className = 'gesture-status offline'; this.status.className = 'gesture-status offline';
@@ -104,6 +192,13 @@ class GestureWidget {
this.ctx.scale(dpr, dpr); this.ctx.scale(dpr, dpr);
this.cssWidth = rect.width; this.cssWidth = rect.width;
this.cssHeight = rect.height; this.cssHeight = rect.height;
this.glow.style.left = `${this.canvas.offsetLeft}px`;
this.glow.style.top = `${this.canvas.offsetTop}px`;
this.glow.style.width = `${rect.width}px`;
this.glow.style.height = `${rect.height}px`;
this.glow.width = this.canvas.width;
this.glow.height = this.canvas.height;
this.cancelConvergence(); // buffers are in old pixels
} }
pos(e) { pos(e) {
@@ -115,11 +210,16 @@ class GestureWidget {
e.preventDefault(); e.preventDefault();
this.canvas.setPointerCapture(e.pointerId); this.canvas.setPointerCapture(e.pointerId);
// One stroke only - a new pointerdown replaces the old drawing // One stroke only - a new pointerdown replaces the old drawing
// (and any ghost/ambient trails from the previous round). // (and any ghost from the previous round).
this.drawing = true; this.drawing = true;
this.points = [this.pos(e)]; this.cancelConvergence();
this.ghost = null; this.ghost = null;
this.ambient = []; this.ownKey = null;
this.selected = null;
this.seenKeys.clear();
this.echoes.replaceChildren();
this.empty.textContent = '';
this.points = [this.pos(e)];
this.render(); this.render();
} }
@@ -217,28 +317,77 @@ class GestureWidget {
return; return;
} }
if (msg.type === 'ack') { if (msg.type === 'ack') {
if (this.points.length >= 2) { this.ownKey = msg.key;
this.setValue({ points: this.normalized(), key: msg.key }); // Version nibble is the key's first hex digit.
} const version = parseInt((msg.key || '0')[0], 16);
this.ghost = msg.path; this.ownGhost = version >= 2 && Array.isArray(msg.path) ? msg.path : null;
this.render(); this.applySelection();
} else if (msg.type === 'echo') { // Places arrive right after the ack; say so if none do.
if (this.hero) { clearTimeout(this.emptyTimer);
this.ambient.push(msg.path); this.emptyTimer = setTimeout(() => {
if (this.ambient.length > MAX_ECHOES) this.ambient.shift(); if (!this.echoes.children.length) this.empty.textContent = msg.empty || 'Nothing kept at this shape yet.';
this.render(); }, 1500);
} else { } else if (msg.type === 'echo' || msg.type === 'place') {
if (!msg.key || this.seenKeys.has(msg.key)) return;
this.seenKeys.add(msg.key);
this.empty.textContent = '';
this.addEchoThumbnail(msg); this.addEchoThumbnail(msg);
} }
}
// 'error' frames are intentionally silent: the widget is a // 'error' frames are intentionally silent: the widget is a
// form field first, and a rate-limited announce shouldn't // form field first, and a rate-limited announce shouldn't
// alarm anyone mid-form. // alarm anyone mid-form.
} }
// The key the input carries: the visitor's own, or the place they
// picked (selection re-derives the key - the ghost under the
// stroke becomes that place's decode, and the answer records both).
applySelection() {
if (this.points.length < 2) return;
const points = this.normalized();
const before = this.ghost;
if (this.selected) {
this.ghost = this.selected.path;
this.setValue({ points, key: this.selected.key, own_key: this.ownKey, selected_from: this.ownKey, selected_distance: this.selected.distance });
} else {
this.ghost = this.ownGhost || null;
this.setValue({ points, key: this.ownKey });
}
if (this.ghost && this.ghost !== before) this.startConvergence(this.ghost);
for (const el of this.echoes.children) {
el.classList.toggle('selected', !!this.selected && el.dataset.key === this.selected.key);
el.setAttribute('aria-selected', String(!!this.selected && el.dataset.key === this.selected.key));
}
this.render();
}
select(thumb) {
const key = thumb.dataset.key;
if (this.selected && this.selected.key === key) {
this.selected = null;
} else {
this.selected = { key, path: JSON.parse(thumb.dataset.path), distance: Number(thumb.dataset.distance) };
// The relay counts picks per place for ranking.
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.ownKey) {
this.ws.send(JSON.stringify({ type: 'select', key, from: this.ownKey, distance: this.selected.distance }));
}
}
this.applySelection();
}
addEchoThumbnail(msg) { addEchoThumbnail(msg) {
const thumb = document.createElement('canvas'); const thumb = document.createElement('canvas');
thumb.className = 'gesture-echo'; thumb.className = 'gesture-echo' + (msg.type === 'place' ? ' place' : '');
thumb.dataset.key = msg.key;
thumb.dataset.path = JSON.stringify(msg.path || []);
thumb.dataset.distance = String(msg.distance || 0);
thumb.setAttribute('role', 'option');
thumb.setAttribute('tabindex', '0');
thumb.setAttribute('aria-selected', 'false');
thumb.title = msg.type === 'place'
? `A place with ${msg.recordings || 0} recording${msg.recordings === 1 ? '' : 's'} - tap to make it your address`
: 'Someone at this shape right now';
thumb.addEventListener('click', () => this.select(thumb));
thumb.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.select(thumb); } });
// Closer strokes render stronger; 500 is comfortably past the // Closer strokes render stronger; 500 is comfortably past the
// relay's default threshold, so everything stays visible. // relay's default threshold, so everything stays visible.
const strength = Math.max(0.35, Math.min(1, 1 - msg.distance / 500)); const strength = Math.max(0.35, Math.min(1, 1 - msg.distance / 500));
@@ -260,6 +409,140 @@ class GestureWidget {
setTimeout(() => thumb.classList.add('shown'), 30); setTimeout(() => thumb.classList.add('shown'), 30);
} }
// ── Convergence ────────────────────────────────────────────────
initGlow() {
if (this.gl) return this.gl;
const gl = this.glow.getContext('webgl', { alpha: true, premultipliedAlpha: true });
if (!gl) return null;
const compile = (type, src) => {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
return s;
};
const prog = gl.createProgram();
gl.attachShader(prog, compile(gl.VERTEX_SHADER, GLOW_VERT));
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, GLOW_FRAG));
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return null;
gl.useProgram(prog);
this.gl = gl;
this.glProg = prog;
this.glLoc = {
aFrom: gl.getAttribLocation(prog, 'aFrom'),
aTo: gl.getAttribLocation(prog, 'aTo'),
aIndex: gl.getAttribLocation(prog, 'aIndex'),
uT: gl.getUniformLocation(prog, 'uT'),
uResolution: gl.getUniformLocation(prog, 'uResolution'),
uDpr: gl.getUniformLocation(prog, 'uDpr'),
uColor: gl.getUniformLocation(prog, 'uColor'),
uAlpha: gl.getUniformLocation(prog, 'uAlpha'),
};
this.glBufFrom = gl.createBuffer();
this.glBufTo = gl.createBuffer();
this.glBufIndex = gl.createBuffer();
return gl;
}
// The ghost's pixel frame - same fit drawPath uses.
ghostToPx(path) {
const scale = (Math.min(this.cssWidth, this.cssHeight) / 2) * 0.8;
const cx = this.cssWidth / 2, cy = this.cssHeight / 2;
return path.map(([x, y]) => [cx + x * scale, cy + y * scale]);
}
startConvergence(targetPath) {
this.cancelConvergence();
if (!targetPath || this.points.length < 2) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const gl = this.initGlow();
if (!gl) return;
const dpr = window.devicePixelRatio || 1;
const from = resamplePx(this.points.map((p) => [p.x, p.y]), CONVERGE_POINTS);
const to = resamplePx(this.ghostToPx(targetPath), CONVERGE_POINTS);
const f = new Float32Array(CONVERGE_POINTS * 2);
const t = new Float32Array(CONVERGE_POINTS * 2);
const idx = new Float32Array(CONVERGE_POINTS);
for (let i = 0; i < CONVERGE_POINTS; i++) {
f[i * 2] = from[i][0] * dpr;
f[i * 2 + 1] = from[i][1] * dpr;
t[i * 2] = to[i][0] * dpr;
t[i * 2 + 1] = to[i][1] * dpr;
idx[i] = i / (CONVERGE_POINTS - 1);
}
const bind = (buf, data, loc, size) => {
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, size, gl.FLOAT, false, 0, 0);
};
gl.useProgram(this.glProg);
bind(this.glBufFrom, f, this.glLoc.aFrom, 2);
bind(this.glBufTo, t, this.glLoc.aTo, 2);
bind(this.glBufIndex, idx, this.glLoc.aIndex, 1);
gl.viewport(0, 0, this.glow.width, this.glow.height);
gl.uniform2f(this.glLoc.uResolution, this.glow.width, this.glow.height);
gl.uniform1f(this.glLoc.uDpr, dpr);
const light = this._themeQuery.matches;
const color = light ? [0x47 / 255, 0x80 / 255, 0x7e / 255] : [0x8e / 255, 0xc2 / 255, 0xc0 / 255];
gl.uniform3f(this.glLoc.uColor, color[0], color[1], color[2]);
// Additive light on the dark ground; normal ink on the light one.
gl.enable(gl.BLEND);
if (light) gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
else gl.blendFunc(gl.ONE, gl.ONE);
this.converging = true;
this.render();
const started = performance.now();
const step = (now) => {
const raw = (now - started) / CONVERGE_MS;
if (raw >= 1) {
// Deposit the ghost, then let the glow breathe out.
if (this.converging) {
this.converging = false;
this.render();
}
const fade = (now - started - CONVERGE_MS) / CONVERGE_FADE_MS;
if (fade >= 1) {
this.cancelConvergence();
return;
}
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform1f(this.glLoc.uT, 1);
gl.uniform1f(this.glLoc.uAlpha, 0.32 * (1 - fade));
gl.drawArrays(gl.POINTS, 0, CONVERGE_POINTS);
} else {
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
// Trailing passes give the wave a comet tail.
for (const [lag, alpha] of [[0, 0.32], [0.035, 0.16], [0.07, 0.08], [0.105, 0.04]]) {
gl.uniform1f(this.glLoc.uT, Math.max(0, raw - lag));
gl.uniform1f(this.glLoc.uAlpha, alpha);
gl.drawArrays(gl.POINTS, 0, CONVERGE_POINTS);
}
}
this.animFrame = requestAnimationFrame(step);
};
this.animFrame = requestAnimationFrame(step);
}
cancelConvergence() {
if (this.animFrame) cancelAnimationFrame(this.animFrame);
this.animFrame = null;
if (this.converging) {
this.converging = false;
this.render();
}
if (this.gl) {
this.gl.clearColor(0, 0, 0, 0);
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
}
}
// ── Rendering ────────────────────────────────────────────────── // ── Rendering ──────────────────────────────────────────────────
// Draw a normalized (-1..1) path fitted into w×h with padding. // Draw a normalized (-1..1) path fitted into w×h with padding.
@@ -284,12 +567,7 @@ class GestureWidget {
if (!this.ctx) return; if (!this.ctx) return;
const t = this.theme(); const t = this.theme();
this.ctx.clearRect(0, 0, this.cssWidth, this.cssHeight); this.ctx.clearRect(0, 0, this.cssWidth, this.cssHeight);
for (const path of this.ambient) { if (this.ghost && !this.converging) {
this.ctx.globalAlpha = 0.25;
this.drawPath(this.ctx, path, this.cssWidth, this.cssHeight, t.echo, 1.5, 0.2);
}
this.ctx.globalAlpha = 1;
if (this.ghost) {
this.drawPath(this.ctx, this.ghost, this.cssWidth, this.cssHeight, t.ghost, 2, 0.2); this.drawPath(this.ctx, this.ghost, this.cssWidth, this.cssHeight, t.ghost, 2, 0.2);
} }
if (this.points.length >= 1) { if (this.points.length >= 1) {
@@ -308,6 +586,8 @@ class GestureWidget {
stop() { stop() {
this.stopped = true; this.stopped = true;
if (this.animFrame) cancelAnimationFrame(this.animFrame);
clearTimeout(this.emptyTimer);
if (this.reconnectTimer) clearTimeout(this.reconnectTimer); if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
if (this.ws) { if (this.ws) {
// The close event still fires, but scheduleReconnect // The close event still fires, but scheduleReconnect
@@ -325,11 +605,8 @@ class GestureWidget {
} }
export function mountGesture(container, hidden, relayUrl) { export function mountGesture(container, hidden, relayUrl) {
return new GestureWidget(container, hidden, relayUrl, false); // Dev convenience: `?relay=ws://127.0.0.1:9040` points every widget
} // on the page at a local relay (prod relays reject foreign Origins).
const override = new URLSearchParams(window.location.search).get('relay');
// Hero variant: no form field to mirror into, and echoes render as return new GestureWidget(container, hidden, override || relayUrl);
// ambient strokes on the drawing canvas itself instead of thumbnails.
export function mountGestureHero(container, relayUrl) {
return new GestureWidget(container, null, relayUrl, true);
} }
+9
View File
@@ -30,6 +30,12 @@ pub struct AggregateSchema {
pub event_for_state: HashMap<String, String>, pub event_for_state: HashMap<String, String>,
pub state_for_event: HashMap<String, String>, pub state_for_event: HashMap<String, String>,
pub transitions: HashMap<String, Vec<String>>, pub transitions: HashMap<String, Vec<String>>,
/// Names what consumes this bucket's answers when no page in the
/// content repo reads it (e.g. "n8n newsletter compose").
/// Free-text - it exists so `validate_questions`' attended-bucket
/// rule has an explicit, auditable opt-out instead of a silent
/// one, and so the next reader knows where the answers go.
pub attended_by: Option<String>,
} }
impl AggregateSchema { impl AggregateSchema {
@@ -57,6 +63,8 @@ struct RawAggregateSchema {
states: HashMap<String, RawState>, states: HashMap<String, RawState>,
#[serde(default)] #[serde(default)]
transitions: HashMap<String, Vec<String>>, transitions: HashMap<String, Vec<String>>,
#[serde(default)]
attended_by: Option<String>,
} }
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]
@@ -123,6 +131,7 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
event_for_state, event_for_state,
state_for_event, state_for_event,
transitions: raw_schema.transitions, transitions: raw_schema.transitions,
attended_by: raw_schema.attended_by,
}, },
); );
} }
+159
View File
@@ -0,0 +1,159 @@
//! Announced pages (`Question.event`): keeps one `portal_events` record
//! per event question and, once a window closes, moves it to
//! `awaiting_summary` - the "post what happened" task - publishing the
//! transition on `portal.answers.submitted` like any desk decision, so
//! the desktop notifier and n8n hear it. Runs on a one-minute tick;
//! every step is idempotent, so a restart or a content reload in the
//! middle changes nothing.
use crate::answers::{store_answer, Answer};
use crate::content::EVENTS_BUCKET;
use crate::events::{emit_answer_submitted, AnswerSubmitted};
use crate::server::AppState;
/// Alternative name stamped on the auto-created record and on the
/// transition event - what an n8n workflow gates on.
pub const EVENT_ALTERNATIVE: &str = "Announced";
pub const SUMMARY_DUE_LABEL: &str = "Summary due";
/// Record id for an event question: its id with `/` folded to `-`
/// (`/events/opening` -> `events-opening`), so one page is one record
/// however many times the sweeper runs.
pub fn record_id(question_id: &str) -> String {
question_id.trim_matches('/').replace('/', "-")
}
pub async fn run(state: AppState) {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(60));
loop {
tick.tick().await;
if let Err(e) = sweep(&state).await {
tracing::warn!(error = %e, "event sweep failed");
}
}
}
/// One pass: create missing records, close ended windows.
pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
let now_ms = chrono::Utc::now().timestamp_millis();
let questions = state.questions.load();
let events: Vec<_> = questions.values().filter(|q| q.event.is_some()).cloned().collect();
if events.is_empty() {
return Ok(());
}
let aggregates = state.aggregates.load();
let schema = aggregates
.get(EVENTS_BUCKET)
.ok_or_else(|| anyhow::anyhow!("no {EVENTS_BUCKET} schema"))?;
let store = match state.jetstream.get_key_value(EVENTS_BUCKET).await {
Ok(store) => Some(store),
Err(_) => None,
};
for q in events {
let event = q.event.as_ref().expect("filtered");
let (starts, ends) = match event.window_ms() {
Ok(w) => w,
Err(_) => continue, // validated on load; belt and braces
};
let id = record_id(&q.id);
let existing: Option<Answer> = match &store {
Some(store) => store
.get(&id)
.await?
.and_then(|bytes| serde_json::from_slice(&bytes).ok()),
None => None,
};
let responses = serde_json::json!({
"name": q.name,
"starts": event.starts,
"starts_ms": starts,
"ends_ms": ends,
"place": event.place,
"page": q.id,
});
let answer = match existing {
None => {
store_answer(
&state.jetstream,
&aggregates,
EVENTS_BUCKET,
id.clone(),
&q.id,
EVENT_ALTERNATIVE,
&responses,
now_ms,
)
.await?;
tracing::info!(question = %q.id, "event record created");
continue;
}
Some(a) => a,
};
// Content is the source of truth while the window is still
// open: a corrected date or place flows into the record, so the
// followup fires on the schedule the page actually announces.
if answer.state == schema.initial && answer.responses != responses {
let mut refreshed = answer.clone();
refreshed.responses = responses.clone();
if let Some(store) = &store {
store.put(&id, serde_json::to_vec(&refreshed)?.into()).await?;
tracing::info!(question = %q.id, "event record refreshed from content");
}
}
if answer.state == schema.initial && now_ms >= ends {
let target = "awaiting_summary";
let payload = serde_json::json!({ "to": target, "item": id, "by": "portal" });
match crate::aggregates::transition(&state.jetstream, schema, &id, target, payload.clone(), now_ms).await {
Ok(_) => {}
Err(crate::aggregates::TransitionError::UnknownAggregate) => {
crate::aggregates::reseed(&state.jetstream, schema, &id, &answer.state, now_ms)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
crate::aggregates::transition(&state.jetstream, schema, &id, target, payload.clone(), now_ms)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
}
Err(e) => return Err(anyhow::anyhow!("{e}")),
}
let mut answer = answer;
answer.state = target.to_string();
answer.decided_ms = Some(now_ms);
answer.decided_by = Some("portal".to_string());
let store = state.jetstream.get_key_value(EVENTS_BUCKET).await?;
store.put(&id, serde_json::to_vec(&answer)?.into()).await?;
let parent_hashes = vec![id.clone()];
let chain_hash = crate::chain::hash_node(&q.id, &parent_hashes, &payload, now_ms);
emit_answer_submitted(
&state.nats,
&AnswerSubmitted {
chain_hash,
parent_hashes,
question_id: q.id.clone(),
alternative: SUMMARY_DUE_LABEL.to_string(),
responses: payload,
timestamp_ms: now_ms,
},
)
.await?;
tracing::info!(question = %q.id, "event ended - summary due");
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::record_id;
#[test]
fn record_id_is_stable_and_flat() {
assert_eq!(record_id("/events/opening"), "events-opening");
assert_eq!(record_id("/"), "");
assert_eq!(record_id("/opening"), "opening");
}
}
+579 -156
View File
File diff suppressed because it is too large Load Diff
+40 -12
View File
@@ -52,8 +52,21 @@ async fn main() -> anyhow::Result<()> {
eprintln!("FAIL: {e}"); eprintln!("FAIL: {e}");
std::process::exit(1); std::process::exit(1);
} }
let aggregates_map = content::with_builtin_aggregates(aggregates_map);
match content::validate_questions(&questions, &aggregates_map) { match content::validate_questions(&questions, &aggregates_map) {
Ok(()) => { Ok(()) => {
// The runtime's own event desk: a repo announcing pages
// (Question.event) should read portal_events somewhere, or
// "post what happened" tasks pile up unseen. Warn, don't
// fail - the runtime creates the records either way.
if questions.values().any(|q| q.event.is_some())
&& !content::bucket_is_read(&questions, content::EVENTS_BUCKET)
{
eprintln!(
"WARN: pages carry `event:` but no kv resource reads bucket {:?} - add a desk so summaries get posted",
content::EVENTS_BUCKET
);
}
println!( println!(
"OK: {} question(s), {} aggregate(s) valid", "OK: {} question(s), {} aggregate(s) valid",
questions.len(), questions.len(),
@@ -108,23 +121,38 @@ fn load_aggregates_from_dir(
} }
/// The offline counterpart to `content::load_questions_from_gitea` - /// The offline counterpart to `content::load_questions_from_gitea` -
/// same "every `*.yaml` file becomes a `Question` keyed by its own /// the same recursive tree walk and `content::build_questions`
/// `id`" shape, just reading a local checkout instead of Gitea's API, /// pipeline (derived ids, relative refs, sections, followup
/// inference), just reading a local checkout instead of Gitea's API,
/// for linting a branch that hasn't been pushed yet. /// for linting a branch that hasn't been pushed yet.
fn load_from_dir( fn load_from_dir(
dir: &str, dir: &str,
) -> anyhow::Result<std::collections::HashMap<String, content::Question>> { ) -> anyhow::Result<std::collections::HashMap<String, content::Question>> {
let mut out = std::collections::HashMap::new(); let base = std::path::Path::new(dir);
let mut files = Vec::new();
collect_yaml(base, base, &mut files)?;
content::build_questions(&files)
}
fn collect_yaml(
base: &std::path::Path,
dir: &std::path::Path,
out: &mut Vec<(String, String)>,
) -> anyhow::Result<()> {
for entry in std::fs::read_dir(dir)? { for entry in std::fs::read_dir(dir)? {
let entry = entry?; let path = entry?.path();
let path = entry.path(); if path.is_dir() {
if path.extension().and_then(|e| e.to_str()) != Some("yaml") { collect_yaml(base, &path, out)?;
continue; } else if path.extension().and_then(|e| e.to_str()) == Some("yaml") {
let rel = path
.strip_prefix(base)
.expect("walked paths sit under their base")
.to_string_lossy()
.replace('\\', "/");
let raw = std::fs::read_to_string(&path)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", path.display()))?;
out.push((rel, raw));
} }
let raw = std::fs::read_to_string(&path)?;
let question: content::Question = serde_yaml::from_str(&raw)
.map_err(|e| anyhow::anyhow!("parsing {}: {e}", path.display()))?;
out.insert(question.id.clone(), question);
} }
Ok(out) Ok(())
} }
+54
View File
@@ -9,6 +9,60 @@ use sha2::{Digest, Sha256};
/// submitted responses, and a timestamp. Parents are sorted first so the /// submitted responses, and a timestamp. Parents are sorted first so the
/// hash doesn't depend on the order multiple parents happened to arrive /// hash doesn't depend on the order multiple parents happened to arrive
/// in. /// in.
/// Where submitted chain nodes are indexed - hash → which question was
/// answered. Small on purpose (no responses), and shared by every
/// portal instance on the JetStream (hashes are globally unique, so
/// cross-site collisions can't happen). This is what lets
/// `requires_chain` pages verify a visitor's `?chain=` actually ends
/// at the question they claim to have answered.
pub const CHAIN_BUCKET: &str = "portal_chains";
#[derive(serde::Serialize, serde::Deserialize)]
pub struct ChainNode {
pub question_id: String,
pub timestamp_ms: i64,
}
/// Indexes one submitted node. Best-effort by design (the caller logs
/// and continues): the NATS event is the durable record, this is a
/// lookup convenience.
pub async fn record_node(
js: &async_nats::jetstream::Context,
chain_hash: &str,
question_id: &str,
timestamp_ms: i64,
) -> anyhow::Result<()> {
let store = match js.get_key_value(CHAIN_BUCKET).await {
Ok(store) => store,
Err(_) => {
js.create_key_value(async_nats::jetstream::kv::Config {
bucket: CHAIN_BUCKET.to_string(),
..Default::default()
})
.await?
}
};
let node = ChainNode {
question_id: question_id.to_string(),
timestamp_ms,
};
store.put(chain_hash, serde_json::to_vec(&node)?.into()).await?;
Ok(())
}
/// Looks a chain hash up - `None` covers both "no such node" and
/// "bucket not created yet" (no submissions anywhere), which read the
/// same to a `requires_chain` check: the claimed lineage can't be
/// verified, so the gate stays shut.
pub async fn lookup_node(
js: &async_nats::jetstream::Context,
chain_hash: &str,
) -> Option<ChainNode> {
let store = js.get_key_value(CHAIN_BUCKET).await.ok()?;
let bytes = store.get(chain_hash).await.ok()??;
serde_json::from_slice(&bytes).ok()
}
pub fn hash_node( pub fn hash_node(
question_id: &str, question_id: &str,
parent_hashes: &[String], parent_hashes: &[String],
+1138 -70
View File
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
//! Chrome-string translations, selected by `site.yaml`'s `lang`.
//!
//! Content carries its own language; this covers only the strings the
//! portal itself speaks around it ("Asked by", the nav lead, the
//! not-found page...). Unknown languages and unknown keys fall back
//! to English, so a typo degrades to the default instead of a blank.
/// Translate `key` for `lang`. `en` is the reference table; every
/// other language falls through to it for keys it doesn't carry.
pub fn t(lang: &str, key: &str) -> &'static str {
if let Some(s) = lookup(lang, key) {
return s;
}
lookup("en", key).unwrap_or(key_missing(key))
}
fn lookup(lang: &str, key: &str) -> Option<&'static str> {
Some(match (lang, key) {
("en", "also_worth_asking") => "Also worth asking",
("en", "asked_by") => "Asked by ",
("en", "contact_them") => "contact them",
("en", "if_stuck") => " if you get stuck.",
("en", "send") => "Send",
("en", "sign_in") => "Sign in",
("en", "nothing_here_yet") => "Nothing here yet.",
("en", "nothing_here") => "Nothing here",
("en", "back_to_start") => "back to the start",
("en", "done") => "Done.",
("en", "follows_answer") => {
"This page follows from an answer you don't seem to carry yet."
}
("en", "owners_only") => {
"This part of the site is for organizational owners — sign in with that account to take a look."
}
("en", "announcements") => "Announcements",
("no", "also_worth_asking") => "Også verdt å spørre",
("no", "asked_by") => "Stilt av ",
("no", "contact_them") => "ta kontakt",
("no", "if_stuck") => " om du står fast.",
("no", "send") => "Send",
("no", "sign_in") => "Logg inn",
("no", "nothing_here_yet") => "Ingenting her ennå.",
("no", "nothing_here") => "Ingenting her",
("no", "back_to_start") => "tilbake til start",
("no", "done") => "Ferdig.",
("no", "follows_answer") => {
"Denne siden følger av et svar du ikke ser ut til å bære ennå."
}
("no", "owners_only") => {
"Denne delen av siden er for organisasjonens eiere — logg inn med den kontoen for å ta en titt."
}
("no", "announcements") => "Kunngjøringer",
_ => return None,
})
}
/// A missing key is a programmer error; render the key itself so it
/// is findable, never a panic in a view.
fn key_missing(key: &str) -> &'static str {
// Leak is bounded: keys are a small fixed set of literals.
Box::leak(key.to_string().into_boxed_str())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn norwegian_covers_the_english_table() {
for key in [
"also_worth_asking",
"asked_by",
"contact_them",
"if_stuck",
"send",
"sign_in",
"nothing_here_yet",
"nothing_here",
"back_to_start",
"done",
"follows_answer",
"owners_only",
"announcements",
] {
assert!(lookup("en", key).is_some(), "en missing {key}");
assert!(lookup("no", key).is_some(), "no missing {key}");
}
}
#[test]
fn unknown_language_falls_back_to_english() {
assert_eq!(t("de", "sign_in"), "Sign in");
assert_eq!(t("en", "sign_in"), "Sign in");
}
}
+4
View File
@@ -4,11 +4,15 @@ pub mod auth;
pub mod chain; pub mod chain;
pub mod content; pub mod content;
pub mod events; pub mod events;
pub mod i18n;
pub mod resource; pub mod resource;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
pub mod aggregates; pub mod aggregates;
#[cfg(feature = "ssr")]
pub mod announce;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
pub mod server; pub mod server;
+29 -3
View File
@@ -29,7 +29,10 @@ async fn main() -> anyhow::Result<()> {
.unwrap_or_else(|_| "https://project.uhhm.no/uhhm/questions".to_string()); .unwrap_or_else(|_| "https://project.uhhm.no/uhhm/questions".to_string());
let content_branch = std::env::var("CONTENT_BRANCH").unwrap_or_else(|_| "main".to_string()); let content_branch = std::env::var("CONTENT_BRANCH").unwrap_or_else(|_| "main".to_string());
let gitea_base = content::gitea_api_base(&content_repo)?; let gitea_base = content::gitea_api_base(&content_repo)?;
let aggregates = content::load_aggregates_from_gitea(&content_repo, &content_branch).await?; let content_raw_base = content::gitea_raw_base(&content_repo)?;
let aggregates = content::with_builtin_aggregates(
content::load_aggregates_from_gitea(&content_repo, &content_branch).await?,
);
let questions = content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?; let questions = content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?;
content::validate_questions(&questions, &aggregates)?; content::validate_questions(&questions, &aggregates)?;
let site = content::load_site_from_gitea(&content_repo, &content_branch).await?; let site = content::load_site_from_gitea(&content_repo, &content_branch).await?;
@@ -67,7 +70,7 @@ async fn main() -> anyhow::Result<()> {
tokio::spawn(content::watch_for_reload( tokio::spawn(content::watch_for_reload(
nats.clone(), nats.clone(),
content_repo, content_repo,
content_branch, content_branch.clone(),
"questions".to_string(), "questions".to_string(),
questions.clone(), questions.clone(),
aggregates.clone(), aggregates.clone(),
@@ -82,10 +85,16 @@ async fn main() -> anyhow::Result<()> {
aggregates, aggregates,
site, site,
gitea_base, gitea_base,
content_raw_base,
content_branch: content_branch.clone(),
oidc: oidc_state, oidc: oidc_state,
garage, garage,
}; };
// Announced pages: keep their desk records current, close ended
// windows (see announce.rs).
tokio::spawn(portal::announce::run(state.clone()));
// Dev-friendly defaults: in-memory sessions, secure cookies only when // Dev-friendly defaults: in-memory sessions, secure cookies only when
// COOKIE_SECURE=true (set it behind TLS in production) - same // COOKIE_SECURE=true (set it behind TLS in production) - same
// defaults cnats uses. SameSite=Lax (tower-sessions defaults to // defaults cnats uses. SameSite=Lax (tower-sessions defaults to
@@ -138,8 +147,25 @@ async fn main() -> anyhow::Result<()> {
.route("/api/{*fn_name}", any(server_fn_handler)) .route("/api/{*fn_name}", any(server_fn_handler))
.route("/upload", post(upload::upload)) .route("/upload", post(upload::upload))
.route("/gitea-repo", get(content::gitea_repo_handler)) .route("/gitea-repo", get(content::gitea_repo_handler))
// Content-shipped assets (hero module etc.), same-origin.
.route("/site/{*path}", get(content::site_asset_handler))
.route("/automation/kv/{bucket}", get(content::automation_kv_handler)) .route("/automation/kv/{bucket}", get(content::automation_kv_handler))
.nest_service("/pkg", ServeDir::new(pkg_dir)) // no-cache = "revalidate before reuse", not "don't cache":
// pkg files keep the same names across releases (portal.js,
// portal.wasm), and without this browsers heuristically cache
// them - a stale wasm from the previous release then talks to
// a server whose server-fn wire format has moved on and every
// page renders as its error branch. A 304 per load is the
// price of never shipping that skew again.
.nest_service(
"/pkg",
tower::ServiceBuilder::new()
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("no-cache"),
))
.service(ServeDir::new(pkg_dir)),
)
.nest_service("/fonts", ServeDir::new(fonts_dir)) .nest_service("/fonts", ServeDir::new(fonts_dir))
.route_service("/favicon-light.svg", ServeFile::new(favicon_light_path)) .route_service("/favicon-light.svg", ServeFile::new(favicon_light_path))
.route_service("/favicon-dark.svg", ServeFile::new(favicon_dark_path)) .route_service("/favicon-dark.svg", ServeFile::new(favicon_dark_path))
+16 -5
View File
@@ -68,6 +68,16 @@ pub async fn get_requirement_options(
.iter() .iter()
.find(|r| r.name == requirement_name) .find(|r| r.name == requirement_name)
.ok_or_else(|| ServerFnError::new("unknown requirement"))?; .ok_or_else(|| ServerFnError::new("unknown requirement"))?;
// Inline options: return them as {id,label} objects, the shape the
// SelectField renders, without touching a resource.
if !requirement.options.is_empty() {
let items: Vec<serde_json::Value> = requirement
.options
.iter()
.map(|o| serde_json::json!({ "id": o, "label": o }))
.collect();
return Ok(serde_json::Value::Array(items));
}
let resource = requirement let resource = requirement
.resource .resource
.as_ref() .as_ref()
@@ -122,11 +132,12 @@ fn find_feature(
alternative: &str, alternative: &str,
feature_name: &str, feature_name: &str,
) -> Result<crate::content::Feature, ServerFnError> { ) -> Result<crate::content::Feature, ServerFnError> {
let question = state // resolve_question, not a plain map get: a dynamic page's client
.questions // holds its concrete path as the question id, and resolution is
.load() // also what substitutes the URL segment into the page's resource
.get(question_id) // keys - so this lookup is where a `/review/<record>` page's
.cloned() // feature acquires its record-specific key.
let question = crate::content::resolve_question(&state.questions.load(), question_id)
.ok_or_else(|| ServerFnError::new("unknown question"))?; .ok_or_else(|| ServerFnError::new("unknown question"))?;
question question
.alternatives .alternatives
+4
View File
@@ -38,6 +38,10 @@ pub struct AppState {
/// rather than re-derived per call, since `resolve_gitea_repo` needs /// rather than re-derived per call, since `resolve_gitea_repo` needs
/// it too and has no other reason to see `CONTENT_REPO` itself. /// it too and has no other reason to see `CONTENT_REPO` itself.
pub gitea_base: String, pub gitea_base: String,
/// `{gitea}/api/v1/repos/{owner}/{repo}/raw` and the branch - what
/// `content::site_asset_handler` proxies content-shipped assets from.
pub content_raw_base: String,
pub content_branch: String,
pub oidc: Arc<oidc::Oidc>, pub oidc: Arc<oidc::Oidc>,
/// `None` when `GARAGE_*` env vars aren't set - uploads are the one /// `None` when `GARAGE_*` env vars aren't set - uploads are the one
/// optional feature, everything else works without Garage. /// optional feature, everything else works without Garage.
+26 -4
View File
@@ -33,6 +33,13 @@ type OidcClient = CoreClient<
>; >;
pub struct Oidc { pub struct Oidc {
/// `None` when `KANIDM_URL` is unset: a content-only instance with
/// sign-in disabled - auth routes answer 503, everything public
/// renders as usual.
inner: Option<OidcInner>,
}
struct OidcInner {
client: OidcClient, client: OidcClient,
http: openidconnect::reqwest::Client, http: openidconnect::reqwest::Client,
} }
@@ -45,7 +52,13 @@ const REDIRECT_KEY: &str = "oidc_post_login_redirect";
impl Oidc { impl Oidc {
/// Discovers the provider and builds the client from environment: /// Discovers the provider and builds the client from environment:
/// `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET`, `PUBLIC_URL`. /// `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET`, `PUBLIC_URL`.
/// With `KANIDM_URL` unset, sign-in is disabled instead of fatal -
/// the shape of a public content instance without a review desk.
pub async fn from_env() -> anyhow::Result<Self> { pub async fn from_env() -> anyhow::Result<Self> {
if std::env::var("KANIDM_URL").is_err() {
tracing::warn!("KANIDM_URL not set - sign-in disabled on this instance");
return Ok(Self { inner: None });
}
let kanidm_url = require_env("KANIDM_URL")?; let kanidm_url = require_env("KANIDM_URL")?;
let client_id = require_env("OAUTH2_CLIENT_ID")?; let client_id = require_env("OAUTH2_CLIENT_ID")?;
let client_secret = require_env("OAUTH2_CLIENT_SECRET")?; let client_secret = require_env("OAUTH2_CLIENT_SECRET")?;
@@ -75,7 +88,16 @@ impl Oidc {
) )
.set_redirect_uri(redirect); .set_redirect_uri(redirect);
Ok(Self { client, http }) Ok(Self {
inner: Some(OidcInner { client, http }),
})
}
fn configured(&self) -> Result<&OidcInner, HandlerError> {
self.inner.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"sign-in is not configured on this instance".to_string(),
))
} }
} }
@@ -118,10 +140,10 @@ pub async fn login(
session.insert(REDIRECT_KEY, redirect).await.map_err(internal)?; session.insert(REDIRECT_KEY, redirect).await.map_err(internal)?;
} }
let oidc = state.oidc.configured()?;
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let (auth_url, csrf_state, nonce) = state let (auth_url, csrf_state, nonce) = oidc
.oidc
.client .client
.authorize_url( .authorize_url(
CoreAuthenticationFlow::AuthorizationCode, CoreAuthenticationFlow::AuthorizationCode,
@@ -182,7 +204,7 @@ pub async fn callback(
)); ));
} }
let oidc = &state.oidc; let oidc = state.oidc.configured()?;
let token_response = oidc let token_response = oidc
.client .client
.exchange_code(AuthorizationCode::new(params.code)) .exchange_code(AuthorizationCode::new(params.code))
+233 -91
View File
@@ -51,7 +51,7 @@
--ink-dim: #9a9a9a; --ink-dim: #9a9a9a;
--line: #2a2a2a; --line: #2a2a2a;
/* Cards float over the sticky YES canvas - translucent so the piece /* Cards float over a sticky hero piece - translucent so the piece
reads faintly through them (paired with backdrop-filter on reads faintly through them (paired with backdrop-filter on
.alt-card), shadowed so their edge against the animated backdrop .alt-card), shadowed so their edge against the animated backdrop
is a soft lift rather than a hard contrast line. */ is a soft lift rather than a hard contrast line. */
@@ -78,8 +78,9 @@
/* Light theme: the same muted-press idea on white stock - warm paper, /* Light theme: the same muted-press idea on white stock - warm paper,
near-black ink, and the accent deepened from dusty cyan to a teal near-black ink, and the accent deepened from dusty cyan to a teal
ink that actually carries as text on light paper (the dark theme's ink that actually carries as text on light paper (the dark theme's
#8ec2c0 washes out there). yes.js mirrors this palette on its own #8ec2c0 washes out there). Canvas-drawing modules (gesture.js, and
(matchMedia in setupTheme) since canvas paint can't read CSS vars. */ content-shipped hero modules) mirror this palette on their own,
since canvas paint can't read CSS vars. */
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
:root { :root {
--accent: #47807e; --accent: #47807e;
@@ -98,19 +99,13 @@
--hero-glow: rgba(246, 245, 241, 0.85); --hero-glow: rgba(246, 245, 241, 0.85);
} }
/* The wordmark SVG is a hardcoded white stroke (also used raw in /* The house wordmark SVG is a hardcoded white stroke (also used
dark contexts elsewhere) - flip it to ink here rather than fork raw in dark contexts elsewhere) - flip it to ink here rather
the asset. */ than fork the asset. A content-provided logo keeps its colors. */
.wordmark img { .wordmark-house img {
filter: invert(0.92); filter: invert(0.92);
} }
/* overlay against near-white paper resolves to ~white and the
raster ghost vanishes; multiply lets the light theme's gray YES
(see rasterizeText's themed repaint in yes.js) show as ink. */
.hero-canvas #rasterCanvas {
mix-blend-mode: multiply;
}
} }
* { * {
@@ -198,93 +193,71 @@ main.not-found {
color: var(--ink-dim); color: var(--ink-dim);
font-size: 1.05rem; font-size: 1.05rem;
max-width: 46ch; max-width: 46ch;
/* The measure cap shrinks the box below the copy column; without
auto margins the box left-anchors and its centered text centers
in the wrong frame. */
margin-inline: auto;
} }
/* the actual "YES - Rasterized Lines" piece live at uhhm.no (see /* A content-shipped hero module (site.yaml hero.kind: module) draws
public/yes.js) - landing page only. Full-viewport so it's a real into .hero-piece; the module owns its look (it may restyle .hero
moment, not a thumbnail; hero-copy overlays near the bottom rather itself). Portal only reserves the box so the copy doesn't jump when
than interrupting the canvas. */ the module mounts - plain-vh fallback first, as everywhere. */
.hero-module .hero-piece {
.hero-yes { position: relative;
/* Sticky at the viewport top for the whole scroll (its containing
block is the page itself), so the piece stays animating behind
everything that follows - the translucent cards scroll over it
and it shows through them and in the gaps around them. z-index 0
so positioned content below can stack above with z-index 1. */
position: sticky;
top: 0;
z-index: 0;
max-width: none;
width: 100%; width: 100%;
/* svh here is only the pre-JS/no-JS fallback (and first paint before min-height: 55vh;
yes.js's constructor runs). Once yes.js mounts, it overwrites this min-height: 55svh;
with an inline `height: <px>` frozen from a single measurement -
see setupCanvas()'s comment in yes.js for why: on real mobile
Safari, content bottom-aligned inside this box kept sliding down
as the address bar collapsed even with a spec'd-stable viewport
unit here, so the box's actual height can't be trusted to stay
put on that unit alone. An inline style set from JS always wins
the cascade over this rule, so that frozen number is what
actually governs once the page is interactive.
Plain-vh fallback declared first - an engine without svh support
ignores the invalid second line rather than falling through to
auto height, which would collapse this to the height of its
in-flow content and clip the canvas via overflow:hidden below. */
height: 100vh;
height: 100svh;
padding: 0;
justify-content: flex-end;
overflow: hidden;
} }
.hero-canvas { /* announcements - live event pages, a strip above the hero copy.
position: absolute; Header, not footer: the one place a page claims attention before
inset: 0; the question is asked. */
} .announce {
width: 100%;
.hero-canvas canvas { display: flex;
position: absolute; flex-wrap: wrap;
top: 0; justify-content: center;
left: 0; gap: 0.6rem 1.2rem;
cursor: pointer; margin: -2rem 0 0.5rem;
} font-size: 0.88rem;
.hero-canvas #rasterCanvas {
mix-blend-mode: overlay;
opacity: 0.5;
}
.hero-yes .hero-copy {
position: relative; position: relative;
z-index: 1; z-index: 1;
padding: 0 1.5rem 3.5rem;
gap: 0.6rem;
text-shadow: 0 0.12em 1.4em var(--hero-glow);
} }
/* The gesture hero (site.yaml hero.kind: gesture - redoal.com's .announce-item {
landing): a tall drawing surface where the visitor's stroke and display: inline-flex;
ambient echoes from the relay share the stage. Not sticky like the flex-wrap: wrap;
YES piece - drawing and scrolling fight over the same finger. */ align-items: baseline;
.hero-gesture .hero-draw { gap: 0.35rem 0.7rem;
position: relative; padding: 0.45rem 0.9rem;
width: 100%; border: 0.06rem solid var(--line);
max-width: 46rem; border-left: 0.2rem solid var(--accent);
border-radius: 0.3rem;
color: var(--ink);
text-decoration: none;
background: var(--paper);
transition: border-color 120ms;
} }
.hero-gesture .gesture-canvas { .announce-item:hover,
aspect-ratio: auto; .announce-item[aria-current="page"] {
/* Plain-vh fallback first, same reasoning as .hero-yes's height. */ border-color: var(--accent);
height: 55vh;
height: 55svh;
border: none;
background: transparent;
} }
.hero-gesture .gesture-status { .announce-name {
top: auto; font-weight: 600;
bottom: 0.55rem; }
right: 0.55rem;
.announce-when {
color: var(--ink-dim);
}
.announce-relative {
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 0.72rem;
} }
/* alternatives */ /* alternatives */
@@ -461,10 +434,9 @@ main.not-found {
} }
.feature-icon { .feature-icon {
float: left;
width: 1.3rem; width: 1.3rem;
height: 1.3rem; height: 1.3rem;
margin: 0.1rem 0.6rem 0.4rem 0; margin-top: 0.1rem;
background-color: var(--feature-accent, currentColor); background-color: var(--feature-accent, currentColor);
mask-repeat: no-repeat; mask-repeat: no-repeat;
mask-size: contain; mask-size: contain;
@@ -474,11 +446,117 @@ main.not-found {
-webkit-mask-position: center; -webkit-mask-position: center;
} }
/* With an icon the feature is two columns: the icon in the first,
everything else - name, description, fields, resources - in the
second, so every line of text shares one left edge instead of
wrapping back under the icon. */
.feature:has(> .feature-icon) {
display: grid;
grid-template-columns: 1.3rem minmax(0, 1fr);
column-gap: 0.7rem;
align-items: start;
}
.feature:has(> .feature-icon) > .feature-icon {
grid-column: 1;
grid-row: 1;
}
.feature:has(> .feature-icon) > :not(.feature-icon) {
grid-column: 2;
}
.feature h3 { .feature h3 {
font-size: 1rem; font-size: 1rem;
margin-bottom: 0.15em; margin-bottom: 0.15em;
} }
/* voice field (voice.js): a record button, a status line, a preview */
.voice-wrap {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem 1rem;
}
.voice-button {
font: inherit;
padding: 0.55rem 1.1rem;
border: 0.06rem solid var(--accent);
border-radius: 2rem;
background: transparent;
color: var(--ink);
cursor: pointer;
}
.voice-button:hover:not(:disabled) {
background: var(--accent);
color: var(--paper);
}
.voice-button.recording {
background: var(--accent);
color: var(--paper);
animation: voice-pulse 1.2s ease-in-out infinite;
}
.voice-button:disabled {
opacity: 0.5;
cursor: default;
}
@keyframes voice-pulse {
50% { opacity: 0.6; }
}
.voice-status {
font-size: 0.85rem;
color: var(--ink-dim);
}
.voice-preview,
.item-card-audio {
display: block;
width: 100%;
max-width: 28rem;
margin-top: 0.4rem;
}
/* display:block above would beat the hidden attribute */
.voice-preview[hidden] {
display: none;
}
/* links inside markdown descriptions */
.alt-description a,
.feature p a {
color: inherit;
text-decoration: none;
border-bottom: 0.06rem solid var(--accent);
}
.alt-description a:hover,
.feature p a:hover {
color: var(--accent);
}
/* images inside markdown descriptions: full-width figures in the
card's flow, framed like the rest of the press sheet */
.alt-description img,
.item-card-description img,
.feature p img {
display: block;
width: 100%;
margin: 0.6rem 0 0.2rem;
border-radius: 0.5rem;
border: 0.06rem solid var(--line);
}
.alt-description code,
.feature p code {
font-size: 0.9em;
}
.feature p { .feature p {
color: var(--ink-dim); color: var(--ink-dim);
font-size: 0.95rem; font-size: 0.95rem;
@@ -559,6 +637,17 @@ textarea:focus {
custom properties - so palette changes go there AND here. */ custom properties - so palette changes go there AND here. */
.gesture-wrap { .gesture-wrap {
position: relative; position: relative;
width: 100%;
}
/* The canvas is created by JS only after wasm hydration - reserve its
3/2 box until then so content below doesn't jump. Scoped to :empty
on purpose: on the mounted wrap (a stretched item inside the flex
.field) the same aspect-ratio turned the echo strip's extra height
into extra WIDTH and the whole card ballooned past the viewport
after the first stroke. */
.gesture-wrap:empty {
aspect-ratio: 3 / 2;
} }
.gesture-canvas { .gesture-canvas {
@@ -574,11 +663,17 @@ textarea:focus {
touch-action: none; touch-action: none;
} }
/* Results overlay the canvas instead of growing the widget - the
page never jumps when places arrive (or don't). */
.gesture-echoes { .gesture-echoes {
position: absolute;
left: 0.6rem;
right: 0.6rem;
bottom: 0.6rem;
display: flex; display: flex;
justify-content: center;
gap: 0.5rem; gap: 0.5rem;
margin-top: 0.5rem; pointer-events: none;
min-height: 3rem;
} }
.gesture-echoes:empty { .gesture-echoes:empty {
@@ -593,12 +688,48 @@ textarea:focus {
border-radius: 0.4rem; border-radius: 0.4rem;
opacity: 0; opacity: 0;
transition: opacity 0.5s ease; transition: opacity 0.5s ease;
pointer-events: auto;
box-shadow: 0 0.15rem 0.6rem rgba(0, 0, 0, 0.18);
} }
.gesture-echo.shown { .gesture-echo.shown {
opacity: var(--echo-strength, 1); opacity: var(--echo-strength, 1);
} }
/* places are pickable: the pick re-derives the input's key */
.gesture-echo {
cursor: pointer;
transition: opacity 0.5s ease, border-color 120ms, transform 120ms;
}
.gesture-echo:hover,
.gesture-echo:focus-visible {
border-color: var(--accent);
outline: none;
}
.gesture-echo.selected {
border-color: var(--accent);
box-shadow: 0 0 0 0.12rem var(--accent);
transform: translateY(-0.1rem);
}
.gesture-empty {
position: absolute;
left: 0;
right: 0;
bottom: 0.7rem;
margin: 0;
text-align: center;
font-size: 0.85rem;
color: var(--ink-dim);
pointer-events: none;
}
.gesture-empty:empty {
display: none;
}
.gesture-status { .gesture-status {
position: absolute; position: absolute;
top: 0.55rem; top: 0.55rem;
@@ -730,6 +861,10 @@ textarea:focus {
padding: 0.75rem 1.4rem; padding: 0.75rem 1.4rem;
cursor: pointer; cursor: pointer;
transition: filter 120ms, transform 120ms; transition: filter 120ms, transform 120ms;
/* A gateway alternative renders this as an <a> (navigation, no POST);
kill the link chrome so it reads as the button it looks like. */
text-decoration: none;
display: inline-block;
} }
.alt-submit:hover { .alt-submit:hover {
@@ -745,6 +880,13 @@ textarea:focus {
cursor: wait; cursor: wait;
} }
/* A content-disabled alternative (`disabled: true`): announced, not
yet takeable - reads as a promise, not as a stuck form. */
.alt-submit.soon:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.gate-card { .gate-card {
text-align: center; text-align: center;
} }
+205
View File
@@ -0,0 +1,205 @@
// A `type: voice` requirement: leave a recording at a gesture key.
// Same contract as gesture.js - portal mounts it with the field's
// hidden input and the relay URL, and calls stop() on navigation.
//
// The browser records with MediaRecorder (whatever container it
// likes), decodes that to PCM with WebAudio, resamples to mono 48 kHz,
// and ships one binary frame `[0x01][20-byte key][i16le PCM]` to the
// relay, which encodes with the one recording codec, signs as itself,
// keeps it and announces it (ADR-0015).
//
// Nothing leaves the browser until the alternative's own submit
// ("Keep it here") is pressed: the widget intercepts that click,
// uploads, sets the field's value to {key, digest, duration_ms} on
// the relay's confirmation, and only then lets the submit through.
// A relay error keeps the recording for another try and submits
// nothing.
//
// Palette: colors come from the page's custom properties, no copy here.
const MAX_SECONDS = 60;
const SAMPLE_RATE = 48000;
class VoiceWidget {
constructor(container, hidden, relayUrl, key) {
this.container = container;
this.hidden = hidden || null;
this.relayUrl = relayUrl || '';
this.key = (key || '').trim();
this.stopped = false;
this.recorder = null;
this.stream = null;
this.chunks = [];
this.ws = null;
this.button = document.createElement('button');
this.button.type = 'button';
this.button.className = 'voice-button';
this.button.textContent = 'Record';
this.status = document.createElement('span');
this.status.className = 'voice-status';
this.preview = document.createElement('audio');
this.preview.className = 'voice-preview';
this.preview.controls = true;
this.preview.hidden = true;
container.append(this.button, this.status, this.preview);
this._onClick = () => this.toggle();
this.button.addEventListener('click', this._onClick);
this.pcm = null; // decoded, ready to upload
this.published = null;
this.passthrough = false;
// The alternative's submit button: hold it until the recording
// is kept at the relay.
this.card = container.closest('.alt-card');
this._onSubmit = (e) => this.onSubmitClick(e);
if (this.card) this.card.addEventListener('click', this._onSubmit, true);
if (!this.key || this.key.length !== 40) {
this.button.disabled = true;
this.say('This page has no address to leave a voice at.');
} else if (!navigator.mediaDevices || !window.MediaRecorder) {
this.button.disabled = true;
this.say('Recording needs a browser with a microphone API.');
}
}
say(text) {
this.status.textContent = text;
}
async toggle() {
if (this.recorder && this.recorder.state === 'recording') {
this.recorder.stop();
return;
}
try {
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch {
this.say('Microphone access was declined.');
return;
}
this.chunks = [];
this.recorder = new MediaRecorder(this.stream);
this.recorder.addEventListener('dataavailable', (e) => { if (e.data.size) this.chunks.push(e.data); });
this.recorder.addEventListener('stop', () => this.finish());
this.recorder.start();
this.button.textContent = 'Stop';
this.button.classList.add('recording');
this.say(`Recording — up to ${MAX_SECONDS} seconds.`);
this.capTimer = setTimeout(() => { if (this.recorder && this.recorder.state === 'recording') this.recorder.stop(); }, MAX_SECONDS * 1000);
}
async finish() {
clearTimeout(this.capTimer);
this.button.textContent = 'Record again';
this.button.classList.remove('recording');
if (this.stream) { this.stream.getTracks().forEach((t) => t.stop()); this.stream = null; }
const blob = new Blob(this.chunks, { type: this.recorder.mimeType || 'audio/webm' });
if (!blob.size) { this.say('Nothing was recorded.'); return; }
this.preview.src = URL.createObjectURL(blob);
this.preview.hidden = false;
this.published = null;
this.setValue(null);
try {
this.pcm = await this.toPcm16(await blob.arrayBuffer());
} catch (e) {
this.pcm = null;
this.say('Could not decode the recording — try once more.');
return;
}
this.say(`${Math.round(this.pcm.length / 2 / SAMPLE_RATE)} s recorded. Listen back, then press the button below to keep it here.`);
}
onSubmitClick(e) {
const button = e.target.closest && e.target.closest('.alt-submit');
if (!button || this.passthrough) return;
if (this.published) return; // already kept: let the submit go
e.preventDefault();
e.stopPropagation();
if (!this.pcm) {
this.say(this.recorder && this.recorder.state === 'recording' ? 'Stop the recording first.' : 'Record something first.');
return;
}
this.say('Sending to the relay…');
this.upload(this.pcm, () => {
// The relay confirmed: now the answer itself is submitted.
this.passthrough = true;
button.click();
this.passthrough = false;
});
}
// Decode → mono 48 kHz Float32 → Int16 little-endian bytes.
async toPcm16(buffer) {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const decoded = await ctx.decodeAudioData(buffer);
await ctx.close();
const frames = Math.ceil(decoded.duration * SAMPLE_RATE);
const offline = new OfflineAudioContext(1, frames, SAMPLE_RATE);
const src = offline.createBufferSource();
src.buffer = decoded;
src.connect(offline.destination);
src.start();
const rendered = await offline.startRendering();
const f32 = rendered.getChannelData(0);
const out = new Uint8Array(f32.length * 2);
const view = new DataView(out.buffer);
for (let i = 0; i < f32.length; i++) {
const s = Math.max(-1, Math.min(1, f32[i]));
view.setInt16(i * 2, s < 0 ? s * 32768 : s * 32767, true);
}
return out;
}
upload(pcm, onPublished) {
if (!this.relayUrl) { this.say('No relay to send to.'); return; }
const frame = new Uint8Array(1 + 20 + pcm.length);
frame[0] = 1;
for (let i = 0; i < 20; i++) frame[1 + i] = parseInt(this.key.substr(i * 2, 2), 16);
frame.set(pcm, 21);
const ws = new WebSocket(this.relayUrl);
ws.binaryType = 'arraybuffer';
this.ws = ws;
ws.addEventListener('open', () => ws.send(frame));
ws.addEventListener('message', (e) => {
let msg; try { msg = JSON.parse(e.data); } catch { return; }
if (msg.type === 'published') {
this.published = { key: this.key, digest: msg.digest, duration_ms: msg.duration_ms };
this.setValue(this.published);
this.say(`Kept at this address — ${Math.round(msg.duration_ms / 1000)} s.`);
this.button.disabled = true;
ws.close();
if (onPublished) onPublished();
} else if (msg.type === 'error') {
this.say((msg.message || 'The relay declined the recording.') + ' Your recording is still here — try again.');
ws.close();
}
});
ws.addEventListener('error', () => this.say('The relay could not be reached. Your recording is still here — try again.'));
ws.addEventListener('close', (e) => {
if (!this.published && e.code !== 1000 && e.code !== 1005) {
this.say('The relay closed the connection. Your recording is still here — try again.');
}
});
}
setValue(value) {
if (!this.hidden) return;
this.hidden.value = value ? JSON.stringify(value) : '';
this.hidden.dispatchEvent(new Event('input', { bubbles: true }));
}
stop() {
this.stopped = true;
clearTimeout(this.capTimer);
if (this.recorder && this.recorder.state === 'recording') this.recorder.stop();
if (this.stream) this.stream.getTracks().forEach((t) => t.stop());
if (this.ws) this.ws.close();
this.button.removeEventListener('click', this._onClick);
if (this.card) this.card.removeEventListener('click', this._onSubmit, true);
}
}
export function mountVoice(container, hidden, relayUrl, key) {
return new VoiceWidget(container, hidden, relayUrl, key);
}
-578
View File
@@ -1,578 +0,0 @@
// Ported from ~/repos/webpage/content/visualize/ah.html ("YES - Rasterized
// Lines"), the piece currently live at uhhm.no - kept as the actual asset,
// not reinvented. Two changes from the original: exported as a class (no
// auto-init on `window load`, since Leptos controls when this mounts) and
// a `stop()` method that actually breaks the requestAnimationFrame loop -
// the original ran forever once started, fine for a static page that's
// the whole document, not fine in an SPA where this hero mounts/unmounts
// as you navigate.
export class RasterizedYES {
constructor() {
this.rasterCanvas = document.getElementById('rasterCanvas');
this.lineCanvas = document.getElementById('lineCanvas');
// The Rust side only constructs this once it's confirmed (via a
// NodeRef) that the canvas is mounted, but that guard has shown
// a real gap on fast client-side re-navigation back to `/` -
// this is the actual failure point, so it gets its own defense
// rather than depending on getting that timing exactly right
// from the other side of the wasm boundary. Leaving the
// instance otherwise-inert (no crash, no animation) rather than
// throwing mid-render - `stop()` already tolerates a partially
// (non-)initialized instance.
if (!this.rasterCanvas || !this.lineCanvas) {
console.warn('RasterizedYES: canvas not in DOM yet, skipping');
this.destroyed = true;
return;
}
this.rasterCtx = this.rasterCanvas.getContext('2d');
this.lineCtx = this.lineCanvas.getContext('2d');
this.lines = [];
this.rasterData = null;
this.isActive = true;
this.destroyed = false;
this.time = 0;
this.containmentStrength = 0.5;
this.wiggleAmount = 0.5;
// .hero-canvas is inset:0 inside this - freezing an inline
// height here (below) is what actually locks the box, not
// just reading its rect.
this.heroEl = this.rasterCanvas.closest('.hero');
this.setupCanvas();
this.setupResizeHandler();
this.setupDrift();
this.setupTheme();
this.setupScrollFade();
this.setupClickHandler();
this.rasterizeText();
this.initializeLines();
this.animate();
}
stop() {
this.destroyed = true;
if (this._resizeHandler) {
window.removeEventListener('resize', this._resizeHandler);
}
if (this._themeQuery) {
this._themeQuery.removeEventListener('change', this._themeHandler);
}
if (this._scrollHandler) {
window.removeEventListener('scroll', this._scrollHandler);
}
}
// The hero is position: sticky (main.css), so without this the
// title/wordmark stay pinned at the viewport bottom for the whole
// page and ghost through the translucent cards scrolling over
// them. Fade the copy out across the first half-screen of scroll;
// visibility: hidden at the end so the wordmark link can't be
// clicked while invisible.
setupScrollFade() {
this.heroCopy = this.heroEl ? this.heroEl.querySelector('.hero-copy') : null;
if (!this.heroCopy) return;
this._scrollHandler = () => {
const opacity = Math.max(0, 1 - window.scrollY / (this.displayHeight * 0.5));
this.heroCopy.style.opacity = opacity;
this.heroCopy.style.visibility = opacity <= 0.01 ? 'hidden' : '';
};
window.addEventListener('scroll', this._scrollHandler, { passive: true });
this._scrollHandler();
}
// Canvas paint can't read CSS custom properties, so the piece
// carries its own copy of both palettes and follows
// prefers-color-scheme itself - values must track main.css's :root
// (--paper especially: the fade fill IS the page background where
// the canvas shows through translucent cards). Dark keeps the
// original neon-on-black inks; light restates them as CMYK process
// inks dark enough to carry on paper, since 80%-lightness pastels
// vanish on white.
setupTheme() {
this._themeQuery = window.matchMedia('(prefers-color-scheme: light)');
this._themeHandler = () => {
this.applyTheme();
// Repaint the raster ghost in the new theme's ink and
// hard-clear the trails - a slow 3%-alpha fade from the
// old paper color would smear across the flip otherwise.
this.rasterizeText();
this.lineCtx.fillStyle = this.theme.paper;
this.lineCtx.fillRect(0, 0, this.displayWidth, this.displayHeight);
};
this._themeQuery.addEventListener('change', this._themeHandler);
this.applyTheme();
}
applyTheme() {
this.theme = this._themeQuery.matches
? {
paper: '#f6f5f1',
fade: 'rgba(246, 245, 241, 0.03)',
// White ground so multiply (the light theme's CSS
// blend mode for #rasterCanvas) leaves the paper
// untouched; gray ink becomes the faint YES ghost.
rasterBg: '#ffffff',
rasterInk: '#6b6b6b',
strokes: [
'hsla(185, 70%, 32%, 0.85)',
'hsla(315, 60%, 38%, 0.85)',
'hsla(50, 90%, 40%, 0.85)'
]
}
: {
paper: '#0a0a0a',
fade: 'rgba(10, 10, 10, 0.03)',
rasterBg: '#111111',
rasterInk: '#ffffff',
strokes: [
'hsla(180, 90%, 80%, 0.8)',
'hsla(300, 90%, 80%, 0.8)',
'hsla(60, 90%, 80%, 0.8)'
]
};
}
// Reading .hero-canvas's rendered rect (a prior attempt at this)
// only helps if that rect actually stays put - and on real mobile
// Safari it didn't: .hero-copy (bottom-aligned via flexbox inside
// .hero-yes) kept sliding down as the address bar collapsed, even
// with a spec'd-stable viewport unit (svh, then lvh) driving the
// box's height. Either the browser isn't holding up its end, or
// something in the cascade is still landing on a live value - not
// provable from here without the device. Rather than keep
// guessing at which CSS viewport unit actually holds still, yes.js
// becomes the source of truth instead: it freezes .hero-yes's
// rendered height to a literal inline px value once, up front. An
// inline style always wins the cascade over the stylesheet's
// `height: 100svh`, so once this runs, nothing the browser does
// with that unit afterward can move the box - the number is fixed
// in the DOM, not recomputed from a unit at all.
setupCanvas() {
const pixelRatio = window.devicePixelRatio || 1;
const width = window.innerWidth;
const height = window.innerHeight;
if (this.heroEl) {
this.heroEl.style.height = height + 'px';
}
this.rasterCanvas.style.width = width + 'px';
this.rasterCanvas.style.height = height + 'px';
this.lineCanvas.style.width = width + 'px';
this.lineCanvas.style.height = height + 'px';
this.rasterCanvas.width = width * pixelRatio;
this.rasterCanvas.height = height * pixelRatio;
this.lineCanvas.width = width * pixelRatio;
this.lineCanvas.height = height * pixelRatio;
this.rasterCtx.scale(pixelRatio, pixelRatio);
this.lineCtx.scale(pixelRatio, pixelRatio);
this.displayWidth = width;
this.displayHeight = height;
this.pixelRatio = pixelRatio;
}
setupResizeHandler() {
// Gate on width, not height: mobile Safari's address-bar
// animation changes window.innerHeight continuously with no
// real layout change to react to (that's the live value this
// whole method exists to stop trusting). A genuine resize -
// orientation change, desktop window drag - always changes
// width too, so that's the real signal to re-freeze on.
this._resizeHandler = () => {
if (window.innerWidth === this.displayWidth) return;
this.setupCanvas();
this.rasterizeText();
};
window.addEventListener('resize', this._resizeHandler);
}
// The two behavior dials (containmentStrength from x, wiggleAmount
// from y) used to follow the pointer. Now a smooth noise field
// wanders them instead - the same 0..1 inputs a mouse would give,
// but drifting at cloud pace, so the piece breathes on its own and
// behaves identically with nobody touching it (which on a landing
// hero is most of the time, and on touch devices was always the
// case between taps). Value noise with two octaves: smooth
// (C1-continuous via smoothstep), never repeats visibly, no jumps.
setupDrift() {
const channel = (seed) => {
const rand = (i) => {
let h = Math.imul(i ^ seed, 2654435761) >>> 0;
h ^= h >>> 13;
h = Math.imul(h, 0x5bd1e995) >>> 0;
// The >>> 0 here is load-bearing: ^ yields a SIGNED
// 32-bit value, and without the reinterpret a set top
// bit made this "0..1" noise go as low as -0.5,
// pushing both dials below their intended floors.
h = (h ^ (h >>> 15)) >>> 0;
return h / 4294967296;
};
const noise = (t) => {
const i = Math.floor(t);
const f = t - i;
const s = f * f * (3 - 2 * f);
return rand(i) * (1 - s) + rand(i + 1) * s;
};
// Two octaves, renormalized to 0..1: the slow octave sets
// the overall weather, the faster one keeps it from
// feeling like a pendulum.
return (t) => (noise(t) * 2 / 3 + noise(t * 2.7 + 913) * 1 / 3);
};
// One full "weather change" roughly every DRIFT_PERIOD
// seconds per octave - the pace of watching clouds, not of a
// hand on a mouse.
this.driftPeriod = 25;
this.driftX = channel(0x9e3779b9);
this.driftY = channel(0x85ebca6b);
this.containmentStrength = 0.55;
this.wiggleAmount = 1.05;
}
updateDrift() {
const t = this.time / this.driftPeriod;
const x = this.driftX(t);
const y = this.driftY(t);
// Same mapping the mouse position used to feed.
this.containmentStrength = 0.1 + (x * 0.9);
this.wiggleAmount = 0.1 + (y * 1.9);
}
setupClickHandler() {
this.lineCanvas.addEventListener('click', () => {
this.restartAnimation();
});
}
restartAnimation() {
this.time = 0;
this.lineCtx.fillStyle = this.theme.paper;
this.lineCtx.fillRect(0, 0, this.displayWidth, this.displayHeight);
this.initializeLines();
this.isActive = true;
}
rasterizeText() {
const width = this.displayWidth;
const height = this.displayHeight;
const aspectRatio = width / height;
let fontSize;
if (aspectRatio < 1) {
fontSize = width * 0.4;
} else {
fontSize = height * 0.5;
}
this.fontSize = fontSize;
this.rasterCtx.font = `bold ${fontSize}px Arial, sans-serif`;
this.rasterCtx.textAlign = 'left';
this.rasterCtx.textBaseline = 'middle';
const fullTextMetrics = this.rasterCtx.measureText('YES');
const textWidth = fullTextMetrics.width;
const textStartX = (width - textWidth) / 2;
const textY = height / 2;
const letters = ['Y', 'E', 'S'];
this.letterPositions = [];
let currentX = textStartX;
for (let i = 0; i < letters.length; i++) {
const letterMetrics = this.rasterCtx.measureText(letters[i]);
this.letterPositions[i] = {
x: currentX,
y: textY,
width: letterMetrics.width,
centerX: currentX + letterMetrics.width / 2
};
currentX += letterMetrics.width;
}
this.letterRasters = [];
for (let i = 0; i < 3; i++) {
this.rasterCtx.fillStyle = '#111';
this.rasterCtx.fillRect(0, 0, width, height);
this.rasterCtx.fillStyle = '#ffffff';
this.rasterCtx.fillText(letters[i], this.letterPositions[i].x, this.letterPositions[i].y);
this.letterRasters[i] = this.rasterCtx.getImageData(0, 0, this.rasterCanvas.width, this.rasterCanvas.height);
}
// The visible layer, distinct from the letterRasters sampled
// above (those stay #111/#fff - isInSpecificLetter's red>128
// test depends on it): painted in theme ink so the ghost works
// under the theme's blend mode (overlay on dark, multiply on
// light - see #rasterCanvas in main.css).
this.rasterCtx.fillStyle = this.theme.rasterBg;
this.rasterCtx.fillRect(0, 0, width, height);
this.rasterCtx.fillStyle = this.theme.rasterInk;
this.rasterCtx.fillText('YES', textStartX, textY);
this.rasterData = this.rasterCtx.getImageData(0, 0, this.rasterCanvas.width, this.rasterCanvas.height);
}
isInSpecificLetter(x, y, letterIndex) {
const canvasX = x * this.pixelRatio;
const canvasY = y * this.pixelRatio;
if (!this.letterRasters || !this.letterRasters[letterIndex] ||
canvasX < 0 || canvasY < 0 ||
canvasX >= this.rasterCanvas.width || canvasY >= this.rasterCanvas.height) {
return false;
}
const index = (Math.floor(canvasY) * this.rasterCanvas.width + Math.floor(canvasX)) * 4;
const red = this.letterRasters[letterIndex].data[index];
return red > 128;
}
initializeLines() {
this.lines = [];
const numLines = 60;
const letterCentroids = this.calculateLetterCentroids();
for (let i = 0; i < numLines; i++) {
const letterIndex = Math.floor(i / (numLines / 3));
let startX, startY, centerX, centerY;
if (letterCentroids[letterIndex]) {
centerX = letterCentroids[letterIndex].x;
centerY = letterCentroids[letterIndex].y;
const startVariation = this.fontSize * 0.1;
startX = centerX + (Math.random() - 0.5) * startVariation;
startY = centerY + (Math.random() - 0.5) * startVariation;
if (!this.isInSpecificLetter(startX, startY, letterIndex)) {
const nearestPoint = this.findNearestSpecificLetterPixel(startX, startY, letterIndex);
if (nearestPoint) {
startX = nearestPoint.x;
startY = nearestPoint.y;
} else {
startX = centerX;
startY = centerY;
}
}
} else {
const letterPos = this.letterPositions[letterIndex];
startX = letterPos.centerX;
startY = letterPos.y;
centerX = startX;
centerY = startY;
}
// Stroke color lives on the theme, looked up per frame by
// letterIndex (see draw()) - not frozen per line - so a
// theme flip recolors live lines instead of leaving
// dark-theme neon smearing across light paper.
this.lines.push({
relativeX: (startX - centerX) / this.fontSize,
relativeY: (startY - centerY) / this.fontSize,
prevRelativeX: (startX - centerX) / this.fontSize,
prevRelativeY: (startY - centerY) / this.fontSize,
angle: Math.random() * Math.PI * 2,
letterIndex: letterIndex,
lastSeenInside: { x: (startX - centerX) / this.fontSize, y: (startY - centerY) / this.fontSize },
outsideDuration: 0
});
}
}
calculateLetterCentroids() {
const centroids = [];
for (let letterIndex = 0; letterIndex < 3; letterIndex++) {
let sumX = 0, sumY = 0, count = 0;
const letterPos = this.letterPositions[letterIndex];
const searchStartX = Math.max(0, letterPos.x - this.fontSize * 0.1);
const searchEndX = Math.min(this.displayWidth, letterPos.x + letterPos.width + this.fontSize * 0.1);
const searchStartY = Math.max(0, letterPos.y - this.fontSize * 0.6);
const searchEndY = Math.min(this.displayHeight, letterPos.y + this.fontSize * 0.6);
const step = Math.max(1, Math.floor(this.fontSize * 0.02));
for (let y = searchStartY; y <= searchEndY; y += step) {
for (let x = searchStartX; x <= searchEndX; x += step) {
if (this.isInSpecificLetter(x, y, letterIndex)) {
sumX += x;
sumY += y;
count++;
}
}
}
if (count > 0) {
centroids[letterIndex] = {
x: sumX / count,
y: sumY / count
};
} else {
centroids[letterIndex] = {
x: letterPos.centerX,
y: letterPos.y
};
}
}
return centroids;
}
updateLines() {
this.time += 0.016;
this.updateDrift();
if (!this.isActive) return;
const letterCentroids = this.calculateLetterCentroids();
this.lines.forEach(line => {
line.prevRelativeX = line.relativeX;
line.prevRelativeY = line.relativeY;
const centroid = letterCentroids[line.letterIndex];
if (!centroid) return;
const currentX = centroid.x + line.relativeX * this.fontSize;
const currentY = centroid.y + line.relativeY * this.fontSize;
const currentlyInside = this.isInSpecificLetter(currentX, currentY, line.letterIndex);
if (currentlyInside) {
line.outsideDuration = 0;
line.lastSeenInside = { x: line.relativeX, y: line.relativeY };
} else {
line.outsideDuration++;
}
const visionDistance = 0.08 * this.fontSize;
const centerX = currentX + Math.cos(line.angle) * visionDistance;
const centerY = currentY + Math.sin(line.angle) * visionDistance;
const leftX = currentX + Math.cos(line.angle - 0.4) * visionDistance;
const leftY = currentY + Math.sin(line.angle - 0.4) * visionDistance;
const rightX = currentX + Math.cos(line.angle + 0.4) * visionDistance;
const rightY = currentY + Math.sin(line.angle + 0.4) * visionDistance;
const centerSees = this.isInSpecificLetter(centerX, centerY, line.letterIndex);
const leftSees = this.isInSpecificLetter(leftX, leftY, line.letterIndex);
const rightSees = this.isInSpecificLetter(rightX, rightY, line.letterIndex);
let speed = 0.02;
const attractionThreshold = Math.floor(15 + (1 - this.containmentStrength) * 45);
if (line.outsideDuration > attractionThreshold) {
const targetX = line.lastSeenInside.x;
const targetY = line.lastSeenInside.y;
const deltaX = targetX - line.relativeX;
const deltaY = targetY - line.relativeY;
const angleToTarget = Math.atan2(deltaY, deltaX);
let angleDiff = angleToTarget - line.angle;
while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
const baseAttraction = Math.min(0.4, line.outsideDuration / 80);
const attractionStrength = baseAttraction * this.containmentStrength;
line.angle += angleDiff * attractionStrength;
}
if (centerSees) {
const baseWiggle = 0.15;
line.angle += (Math.random() - 0.5) * baseWiggle * this.wiggleAmount;
} else {
speed *= (0.3 + this.containmentStrength * 0.4);
const baseTurnStrength = 0.3 + (this.containmentStrength * 0.4);
const randomTurnAmount = 0.2 * this.wiggleAmount;
if (leftSees && !rightSees) {
line.angle -= baseTurnStrength + Math.random() * randomTurnAmount;
} else if (rightSees && !leftSees) {
line.angle += baseTurnStrength + Math.random() * randomTurnAmount;
} else {
const randomTurn = (Math.random() - 0.5) * (0.8 + this.wiggleAmount * 0.7);
line.angle += randomTurn;
}
}
line.relativeX += Math.cos(line.angle) * speed;
line.relativeY += Math.sin(line.angle) * speed;
line.relativeX = Math.max(-1.7, Math.min(1.7, line.relativeX));
line.relativeY = Math.max(-1.7, Math.min(1.7, line.relativeY));
});
}
findNearestSpecificLetterPixel(x, y, letterIndex) {
const searchRadius = this.fontSize * 0.08;
const step = Math.max(1, Math.floor(this.fontSize * 0.01));
let nearestPoint = null;
let nearestDistance = Infinity;
for (let dy = -searchRadius; dy <= searchRadius; dy += step) {
for (let dx = -searchRadius; dx <= searchRadius; dx += step) {
const testX = x + dx;
const testY = y + dy;
if (this.isInSpecificLetter(testX, testY, letterIndex)) {
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestPoint = { x: testX, y: testY };
}
}
}
}
return nearestPoint;
}
draw() {
this.lineCtx.fillStyle = this.theme.fade;
this.lineCtx.fillRect(0, 0, this.displayWidth, this.displayHeight);
const letterCentroids = this.calculateLetterCentroids();
this.lines.forEach(line => {
const centroid = letterCentroids[line.letterIndex];
if (!centroid) return;
const currentX = centroid.x + line.relativeX * this.fontSize;
const currentY = centroid.y + line.relativeY * this.fontSize;
const prevX = centroid.x + line.prevRelativeX * this.fontSize;
const prevY = centroid.y + line.prevRelativeY * this.fontSize;
if (prevX === currentX && prevY === currentY) return;
this.lineCtx.strokeStyle = this.theme.strokes[line.letterIndex];
this.lineCtx.lineWidth = this.fontSize * 0.003;
this.lineCtx.lineCap = 'round';
this.lineCtx.beginPath();
this.lineCtx.moveTo(prevX, prevY);
this.lineCtx.lineTo(currentX, currentY);
this.lineCtx.stroke();
});
}
animate() {
if (this.destroyed) return;
this.updateLines();
this.draw();
requestAnimationFrame(() => this.animate());
}
}