39 Commits
Author SHA1 Message Date
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
16 changed files with 1734 additions and 933 deletions
+4 -1
View File
@@ -68,11 +68,14 @@ jobs:
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 -
Generated
+20 -1
View File
@@ -2948,7 +2948,7 @@ dependencies = [
[[package]] [[package]]
name = "portal" name = "portal"
version = "0.2.2" version = "0.3.15"
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"
+10 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "portal" name = "portal"
version = "0.2.2" version = "0.3.15"
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 }
@@ -131,6 +134,12 @@ 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, # cargo release <level> is how a portal version is cut: bump, commit,
# tag v{{version}}, push. CI (publish.yml) reacts to the tag and # tag v{{version}}, push. CI (publish.yml) reacts to the tag and
+27 -4
View File
@@ -8,9 +8,12 @@ serves is declared in its content repo, and the same build serves any
number of sites. number of sites.
Point an instance at a content repo and that repo *is* the site: Point an instance at a content repo and that repo *is* the site:
pages, copy, state graphs, and branding (`site.yaml`: title, pages, copy, state graphs, branding, and even the landing hero
wordmark, and which hero the landing page opens on — the YES canvas, (`site.yaml`: title, wordmark, and `hero: {kind: module, module:
a gesture-drawing canvas wired to a relay, or plain copy). Content 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 pushes hot-reload every running instance; instances differ only by
env vars. [uhhm.no](https://uhhm.no) env vars. [uhhm.no](https://uhhm.no)
([uhhm/questions](https://project.uhhm.no/uhhm/questions)) and ([uhhm/questions](https://project.uhhm.no/uhhm/questions)) and
@@ -36,6 +39,23 @@ stream.
`requires_chain` gates a page on verifiable answer provenance next `requires_chain` gates a page on verifiable answer provenance next
to `qualifies`' Kanidm-group identity gate (see to `qualifies`' Kanidm-group identity gate (see
`docs/design/filesystem-routes.md`). `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
@@ -102,7 +122,10 @@ cargo release patch # or minor / major
That bumps `Cargo.toml`, tags `v<version>`, and pushes; CI That bumps `Cargo.toml`, tags `v<version>`, and pushes; CI
(`.gitea/workflows/publish.yml`) reacts to the tag, builds once, and (`.gitea/workflows/publish.yml`) reacts to the tag, builds once, and
attaches `portal-v<version>.tar.gz` (`portal` + `question_lint` + attaches `portal-v<version>.tar.gz` (`portal` + `question_lint` +
`site/`) to the Gitea release. Plain pushes to `main` only run the `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. tests (`test.yml`) — nothing reaches production from this repo.
**Roll it out** (in a content repo): edit one line in that repo's **Roll it out** (in a content repo): edit one line in that repo's
+89 -42
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)',
}, },
}; };
@@ -42,14 +44,15 @@ const RECONNECT_BASE_MS = 1000;
const RECONNECT_MAX_MS = 30000; const RECONNECT_MAX_MS = 30000;
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 +63,14 @@ 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) { 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');
container.appendChild(this.echoes); this.echoes.setAttribute('aria-label', 'places near your shape');
} 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';
@@ -115,11 +121,15 @@ 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.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 +227,75 @@ 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') {
this.addEchoThumbnail(msg); if (!msg.key || this.seenKeys.has(msg.key)) return;
} this.seenKeys.add(msg.key);
this.empty.textContent = '';
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();
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 });
}
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));
@@ -284,11 +341,6 @@ 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) {
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) { 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);
} }
@@ -308,6 +360,7 @@ class GestureWidget {
stop() { stop() {
this.stopped = true; this.stopped = true;
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 +378,5 @@ class GestureWidget {
} }
export function mountGesture(container, hidden, relayUrl) { export function mountGesture(container, hidden, relayUrl) {
return new GestureWidget(container, hidden, relayUrl, false); return new GestureWidget(container, hidden, relayUrl);
}
// Hero variant: no form field to mirror into, and echoes render as
// ambient strokes on the drawing canvas itself instead of thumbnails.
export function mountGestureHero(container, relayUrl) {
return new GestureWidget(container, null, relayUrl, true);
} }
-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());
}
}
+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,
}, },
); );
} }
+147
View File
@@ -0,0 +1,147 @@
//! 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,
};
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");
}
}
+394 -159
View File
@@ -1,5 +1,5 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title}; use leptos_meta::{provide_meta_context, HashedStylesheet, MetaTags, Title};
use leptos_router::{ use leptos_router::{
components::{Route, Router, Routes}, components::{Route, Router, Routes},
hooks::{use_location, use_navigate, use_query_map}, hooks::{use_location, use_navigate, use_query_map},
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswers, TransitionItem}; use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswers, TransitionItem};
use crate::auth::{current_user, User}; use crate::auth::{current_user, User};
use crate::content::{is_qualified, Alternative, Question, Responsible, SiteConfig, Transition}; use crate::content::{is_qualified, render_inline_markdown, Alternative, Question, Responsible, SiteConfig, Transition};
use crate::resource::{get_requirement_binding, get_requirement_options, get_resource}; use crate::resource::{get_requirement_binding, get_requirement_options, get_resource};
/// The visible site name/wordmark - "portal" is just this codebase's /// The visible site name/wordmark - "portal" is just this codebase's
@@ -39,6 +39,9 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
// once loaded via <link>/<img> on Safari/iOS. // once loaded via <link>/<img> on Safari/iOS.
<link rel="icon" media="(prefers-color-scheme: light)" href="/favicon-light.svg" type="image/svg+xml"/> <link rel="icon" media="(prefers-color-scheme: light)" href="/favicon-light.svg" type="image/svg+xml"/>
<link rel="icon" media="(prefers-color-scheme: dark)" href="/favicon-dark.svg" type="image/svg+xml"/> <link rel="icon" media="(prefers-color-scheme: dark)" href="/favicon-dark.svg" type="image/svg+xml"/>
// Server-side, so it can read hash.txt and link the
// content-hashed stylesheet (hash-files = true).
<HashedStylesheet id="leptos" options=options.clone()/>
<AutoReload options=options.clone()/> <AutoReload options=options.clone()/>
<HydrationScripts options/> <HydrationScripts options/>
<MetaTags/> <MetaTags/>
@@ -67,7 +70,6 @@ pub fn App() -> impl IntoView {
provide_context(site); provide_context(site);
view! { view! {
<Stylesheet id="leptos" href="/pkg/portal.css"/>
<Suspense fallback=|| ()> <Suspense fallback=|| ()>
{move || { {move || {
site.get() site.get()
@@ -81,16 +83,49 @@ pub fn App() -> impl IntoView {
}} }}
</Suspense> </Suspense>
<Router> <Router>
<Routes fallback=|| view! { <NotFound/> }> <PortalShell/>
<Route path=path!("") view=QuestionPage/>
<Route path=path!("/*any") view=QuestionPage/>
</Routes>
</Router> </Router>
} }
} }
/// Every server-fn resource the pages read, created exactly once for
/// the app's lifetime and handed down via context. Wrapper structs
/// because a bare `Resource<T>` context is claimed by whoever provides
/// that T last.
#[derive(Clone, Copy)]
struct QuestionRes(Resource<Result<Option<Page>, ServerFnError>>);
#[derive(Clone, Copy)]
struct UserRes(Resource<Result<Option<User>, ServerFnError>>);
#[derive(Clone, Copy)]
struct NavRes(Resource<Result<Vec<(String, String)>, ServerFnError>>);
#[derive(Clone, Copy)]
struct AnnounceRes(Resource<Result<Vec<Announcement>, ServerFnError>>);
/// One live announced page (`Question.event`), pre-formatted on the
/// server so both renders agree and no client clock is involved.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Announcement {
pub id: String,
pub name: String,
/// "Sat 12 Sep, 18:00"
pub when: String,
/// "in 3 days" / "in 4 h" / "now"
pub relative: String,
pub place: Option<String>,
}
/// Owns the app's data resources, above the routes and reactive on the
/// location instead of recreated per page. Route components creating
/// their own resources broke on the first client-side navigation: the
/// remounted component's fresh Resource consumed a stale SSR hydration
/// buffer instead of fetching - concretely, the nav list
/// `[[id, name], ..]` deserialized as a `Page` (serde fills a struct
/// from a sequence in field order), rendering the landing question
/// gated behind its own nav entry. Stable resources + context makes
/// that class of misalignment impossible: navigation only changes a
/// key, and a key change always refetches.
#[component] #[component]
fn QuestionPage() -> impl IntoView { fn PortalShell() -> impl IntoView {
let location = use_location(); let location = use_location();
let query = use_query_map(); let query = use_query_map();
let path = Memo::new(move |_| { let path = Memo::new(move |_| {
@@ -101,16 +136,38 @@ fn QuestionPage() -> impl IntoView {
p p
} }
}); });
let parent_hash = Memo::new(move |_| query.with(|q| q.get("chain"))); let chain = Memo::new(move |_| query.with(|q| q.get("chain")));
let query_email = Memo::new(move |_| query.with(|q| q.get("email")));
let question = Resource::new( let question = Resource::new(
move || (path.get(), parent_hash.get()), move || (path.get(), chain.get()),
|(path, chain)| get_question(path, chain), |(path, chain)| get_question(path, chain),
); );
let user = Resource::new(|| (), |_| current_user()); let user = Resource::new(|| (), |_| current_user());
// App's shared site resource (see App) - one fetch per session, let nav = Resource::new(move || chain.get().is_some(), list_qualifying_questions);
// and the Title tied to it survives navigation. let announcements = Resource::new(|| (), |_| list_announcements());
provide_context(QuestionRes(question));
provide_context(UserRes(user));
provide_context(NavRes(nav));
provide_context(AnnounceRes(announcements));
view! {
<Routes fallback=|| view! { <NotFound/> }>
<Route path=path!("") view=QuestionPage/>
<Route path=path!("/*any") view=QuestionPage/>
</Routes>
}
}
#[component]
fn QuestionPage() -> impl IntoView {
let query = use_query_map();
let parent_hash = Memo::new(move |_| query.with(|q| q.get("chain")));
let query_email = Memo::new(move |_| query.with(|q| q.get("email")));
// All shared, app-lifetime resources (see PortalShell / App) -
// never created per navigation.
let QuestionRes(question) = expect_context();
let UserRes(user) = expect_context();
let site = expect_context::<Resource<Result<SiteConfig, ServerFnError>>>(); let site = expect_context::<Resource<Result<SiteConfig, ServerFnError>>>();
view! { view! {
@@ -162,6 +219,7 @@ fn QuestionView(
site: SiteConfig, site: SiteConfig,
) -> impl IntoView { ) -> impl IntoView {
let question_id = question.id.clone(); let question_id = question.id.clone();
let has_chain = parent_hash.is_some();
// The provenance counterpart to the qualifies gate below: a // The provenance counterpart to the qualifies gate below: a
// requires_chain page whose visitor holds no verifiable lineage to // requires_chain page whose visitor holds no verifiable lineage to
@@ -178,6 +236,7 @@ fn QuestionView(
title=question.name.clone() title=question.name.clone()
description=question.description.clone() description=question.description.clone()
landing=question_id == "/" landing=question_id == "/"
current_id=question_id.clone()
site=site.clone() site=site.clone()
/> />
<div class="alternatives"> <div class="alternatives">
@@ -188,6 +247,8 @@ fn QuestionView(
</a> </a>
</section> </section>
</div> </div>
// A gate is never a dead end: the same nav as every page.
<QuestionNav current_id=question_id has_chain=has_chain/>
} }
.into_any(); .into_any();
} }
@@ -202,6 +263,7 @@ fn QuestionView(
title=question.name.clone() title=question.name.clone()
description=question.description.clone() description=question.description.clone()
landing=question_id == "/" landing=question_id == "/"
current_id=question_id.clone()
site=site.clone() site=site.clone()
/> />
<div class="alternatives"> <div class="alternatives">
@@ -223,6 +285,7 @@ fn QuestionView(
}} }}
</section> </section>
</div> </div>
<QuestionNav current_id=question_id has_chain=has_chain/>
} }
.into_any(); .into_any();
} }
@@ -232,7 +295,6 @@ fn QuestionView(
// on pages where some alternative actually declares a deck. // on pages where some alternative actually declares a deck.
let needs_swiper = question.alternatives.iter().any(|a| a.images.len() > 1); let needs_swiper = question.alternatives.iter().any(|a| a.images.len() > 1);
let has_chain = parent_hash.is_some();
view! { view! {
{needs_swiper.then(|| view! { <leptos_meta::Script src="/swiper-element-bundle.min.js"/> })} {needs_swiper.then(|| view! { <leptos_meta::Script src="/swiper-element-bundle.min.js"/> })}
@@ -241,6 +303,7 @@ fn QuestionView(
description=question.description.clone() description=question.description.clone()
landing=question_id == "/" landing=question_id == "/"
site=site.clone() site=site.clone()
current_id=question_id.clone()
/> />
<div class="alternatives"> <div class="alternatives">
<For <For
@@ -295,7 +358,8 @@ fn QuestionView(
/// claiming front-page space (an owner also sees the gated desks here). /// claiming front-page space (an owner also sees the gated desks here).
#[component] #[component]
fn QuestionNav(current_id: String, has_chain: bool) -> impl IntoView { fn QuestionNav(current_id: String, has_chain: bool) -> impl IntoView {
let nav = Resource::new(move || has_chain, list_qualifying_questions); let _ = has_chain; // keyed into the shared resource by PortalShell
let NavRes(nav) = expect_context();
view! { view! {
<Suspense fallback=|| ()> <Suspense fallback=|| ()>
{move || { {move || {
@@ -323,6 +387,45 @@ fn QuestionNav(current_id: String, has_chain: bool) -> impl IntoView {
} }
} }
/// Live announced pages, as a strip at the top of the header - the one
/// place a page gets to claim attention before the question. Empty
/// (and unrendered) when nothing is announced.
#[component]
fn Announcements(current_id: String) -> impl IntoView {
let AnnounceRes(announcements) = expect_context();
view! {
<Suspense fallback=|| ()>
{move || {
let current_id = current_id.clone();
announcements.get().and_then(|res| res.ok()).map(|items| {
(!items.is_empty()).then(|| view! {
<nav class="announce" aria-label="Announcements">
<For
each=move || items.clone()
key=|a| a.id.clone()
children=move |a: Announcement| {
let current = a.id == current_id;
view! {
<a href=a.id.clone() class="announce-item" aria-current=current.then_some("page")>
<span class="announce-name">{a.name}</span>
<span class="announce-when">
{a.when}
{a.place.map(|p| format!(" · {p}"))}
</span>
<span class="announce-relative">{a.relative}</span>
</a>
}
.into_any()
}
/>
</nav>
})
})
}}
</Suspense>
}
}
/// "Asked by X — contact them if you get stuck." /// "Asked by X — contact them if you get stuck."
/// The mailto address is assembled from `data-user`/`data-domain` on a /// The mailto address is assembled from `data-user`/`data-domain` on a
/// real mouse event, never baked into the server-rendered `href` - /// real mouse event, never baked into the server-rendered `href` -
@@ -365,32 +468,6 @@ fn ResponsibleNote(responsible: Responsible) -> impl IntoView {
} }
} }
// Binds the actual "YES - Rasterized Lines" piece live at uhhm.no
// (ported to `public/yes.js`, an ES module now instead of a page-owning
// script) as a typed JS class - the idiomatic wasm-bindgen way to drive
// an existing JS module from Rust, rather than re-implementing canvas
// animation logic here.
#[cfg(feature = "hydrate")]
mod yes {
use wasm_bindgen::prelude::*;
// raw_module (a runtime URL, not a bundled snippet) on purpose:
// the file lives in public/ so it's served at the stable /yes.js
// - the same URL the hero's inline early-mount script imports.
// A bundled snippet would sit under a per-build hashed
// /pkg/snippets/ path the inline script couldn't know.
#[wasm_bindgen(raw_module = "/yes.js")]
extern "C" {
#[wasm_bindgen(js_name = RasterizedYES)]
pub type RasterizedYes;
#[wasm_bindgen(constructor, js_class = "RasterizedYES")]
pub fn new() -> RasterizedYes;
#[wasm_bindgen(method)]
pub fn stop(this: &RasterizedYes);
}
}
// Same idiomatic-typed-module approach as `yes` above, for the // Same idiomatic-typed-module approach as `yes` above, for the
// prosekit-backed rich-text requirement kind (`prosekit-editor.js`, // prosekit-backed rich-text requirement kind (`prosekit-editor.js`,
@@ -421,6 +498,28 @@ mod prosekit {
// prosekit's hidden-input bridge for the value, yes.js's typed-handle // prosekit's hidden-input bridge for the value, yes.js's typed-handle
// lifecycle for cleanup - it may own a live WebSocket to a // lifecycle for cleanup - it may own a live WebSocket to a
// redoal-relay, which SPA navigation must close (`stop()`). // redoal-relay, which SPA navigation must close (`stop()`).
#[cfg(feature = "hydrate")]
#[cfg(feature = "hydrate")]
mod voice {
use wasm_bindgen::prelude::*;
#[wasm_bindgen(module = "/voice.js")]
extern "C" {
pub type VoiceWidget;
#[wasm_bindgen(js_name = mountVoice)]
pub fn mount_voice(
container: &web_sys::HtmlDivElement,
hidden: &web_sys::HtmlInputElement,
relay_url: &str,
key: &str,
) -> VoiceWidget;
#[wasm_bindgen(method)]
pub fn stop(this: &VoiceWidget);
}
}
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
mod gesture { mod gesture {
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
@@ -436,144 +535,111 @@ mod gesture {
relay_url: &str, relay_url: &str,
) -> GestureWidget; ) -> GestureWidget;
#[wasm_bindgen(js_name = mountGestureHero)]
pub fn mount_gesture_hero(
container: &web_sys::HtmlDivElement,
relay_url: &str,
) -> GestureWidget;
#[wasm_bindgen(method)] #[wasm_bindgen(method)]
pub fn stop(this: &GestureWidget); pub fn stop(this: &GestureWidget);
} }
} }
#[component] #[component]
fn Hero(title: String, description: String, landing: bool, site: SiteConfig) -> impl IntoView { fn Hero(title: String, description: String, landing: bool, site: SiteConfig, current_id: String) -> impl IntoView {
// Only the landing page gets a full interactive piece - it's the // Only the landing page gets a piece - it's the one page a hero is
// one page a hero is actually "the" hero for; other pages // actually "the" hero for; every other page gets the plain header.
// (thank-you pages, /review) get the plain dark header below. // WHAT the piece is belongs to the content repo: site.yaml's
// WHICH piece is the content repo's call (`site.yaml`'s // `hero: {kind: module, module: hero.js}` names a JavaScript module
// hero.kind): the YES canvas (default - uhhm's look), a redoal // (served same-origin at /site/<path>) exporting
// gesture canvas, or nothing. // `mount(container) -> handle` with `handle.stop()`. Portal knows
let hero_kind = if landing { site.hero.kind.clone() } else { "plain".to_string() }; // nothing about what it draws.
let show_yes = hero_kind == "yes"; let module = if landing && site.hero.kind == "module" {
let show_gesture = hero_kind == "gesture"; site.hero.module.clone()
} else {
None
};
let has_module = module.is_some();
let wordmark = site.wordmark.clone().unwrap_or_else(|| "/wordmark.svg".to_string()); let wordmark = site.wordmark.clone().unwrap_or_else(|| "/wordmark.svg".to_string());
let site_title = site.title.clone().unwrap_or_else(|| SITE_NAME.to_string()); let site_title = site.title.clone().unwrap_or_else(|| SITE_NAME.to_string());
// Starts the YES animation once the canvas elements exist, and let piece_ref: NodeRef<leptos::html::Div> = NodeRef::new();
// explicitly stops the requestAnimationFrame loop on unmount - the
// original page-owning script never needed this since navigating
// away meant a full document unload, which doesn't happen in an
// SPA.
let raster_ref: NodeRef<leptos::html::Canvas> = NodeRef::new();
let gesture_ref: NodeRef<leptos::html::Div> = NodeRef::new();
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
if show_yes { if has_module {
// `on_cleanup` requires Send + Sync (even single-threaded, wasm), use wasm_bindgen::JsCast;
// which a JS-backed value never is - `StoredValue`'s `LocalStorage` // Handle = whatever the module's mount() returned; kept as a
// variant is the established way around this (same pattern // raw JsValue so the contract stays "has a stop()", nothing
// cnats' WebRTC call state uses for its own non-Send peer map). // typed on the Rust side.
let instance: StoredValue<Option<yes::RasterizedYes>, LocalStorage> = let handle: StoredValue<Option<wasm_bindgen::JsValue>, LocalStorage> =
StoredValue::new_local(None); StoredValue::new_local(None);
// Gated on the canvas NodeRef resolving, not just "the Effect
// ran" - on a fresh page load the DOM is already there by the
// time this fires, but navigating back to `/` client-side hit a
// real race: the Effect ran before the new view's <canvas> was
// actually inserted, RasterizedYES::new() (yes.js) did an
// unchecked `document.getElementById(...).getContext(...)` on
// null and threw mid-reactive-update - which then corrupted
// wasm_bindgen_futures' single-threaded executor state badly
// enough to panic ("RefCell already borrowed") on the next
// tick. Waiting for the NodeRef itself guarantees DOM presence
// the same way the prosekit editor's own mount Effect does.
Effect::new(move |_| { Effect::new(move |_| {
if raster_ref.get().is_none() { let Some(container) = piece_ref.get() else {
return;
};
if handle.with_value(|h| h.is_some()) {
return; return;
} }
if instance.with_value(|i| i.is_some()) { // Adopt the inline early-mount's instance (started at HTML
return; // parse time) if it's there; otherwise (client-side
} // navigation back to /) mount fresh through the helper the
// Adopt the hero's inline early-mount instance (started at // inline script left on window. Either order is safe.
// HTML parse time, long before this wasm was even fetched)
// instead of starting a second animation. The flag covers
// the opposite ordering too: an inline script that runs
// after this sees it and stays inert.
use wasm_bindgen::JsCast;
let global = js_sys::global(); let global = js_sys::global();
let _ = js_sys::Reflect::set(&global, &"__yesAdopted".into(), &true.into()); let _ = js_sys::Reflect::set(&global, &"__heroAdopted".into(), &true.into());
let early = js_sys::Reflect::get(&global, &"__yesEarly".into()) let early = js_sys::Reflect::get(&global, &"__heroEarly".into())
.ok() .ok()
.filter(|v| !v.is_undefined() && !v.is_null()); .filter(|v| !v.is_undefined() && !v.is_null());
let inst = match early { match early {
Some(v) => { Some(v) => {
let _ = js_sys::Reflect::delete_property(&global, &"__yesEarly".into()); let _ = js_sys::Reflect::delete_property(&global, &"__heroEarly".into());
v.unchecked_into::<yes::RasterizedYes>() handle.set_value(Some(v));
} }
None => yes::RasterizedYes::new(), None => {
}; if let Ok(mounter) = js_sys::Reflect::get(&global, &"__mountHero".into()) {
instance.set_value(Some(inst)); if let Some(f) = mounter.dyn_ref::<js_sys::Function>() {
if let Ok(promise) = f.call1(&global, &container) {
let promise: js_sys::Promise = promise.unchecked_into();
leptos::task::spawn_local(async move {
if let Ok(h) =
wasm_bindgen_futures::JsFuture::from(promise).await
{
handle.set_value(Some(h));
}
});
}
}
}
}
}
}); });
on_cleanup(move || { on_cleanup(move || {
instance.update_value(|opt| { handle.update_value(|opt| {
if let Some(inst) = opt.take() { if let Some(h) = opt.take() {
inst.stop(); if let Ok(stop) = js_sys::Reflect::get(&h, &"stop".into()) {
if let Some(f) = stop.dyn_ref::<js_sys::Function>() {
let _ = f.call0(&h);
}
}
} }
}); });
}); });
} }
// The gesture hero mounts the same widget the `type: gesture` // The inline script starts the piece at HTML parse time instead of
// requirement uses, in hero mode (no form field; echoes render as // waiting out the wasm bundle; it also leaves __mountHero for the
// ambient strokes on the canvas itself). Same StoredValue + // hydrate side's client-side-navigation case. Module path is
// on_cleanup shape as the YES piece - it may own a live WebSocket. // validated to plain segments by SiteConfig::validate, so it can't
#[cfg(feature = "hydrate")] // break out of the string here.
if show_gesture { let early_script = module.as_ref().map(|m| {
let relay = site.hero.relay.clone().unwrap_or_default(); format!(
let widget: StoredValue<Option<gesture::GestureWidget>, LocalStorage> = "const url = '/site/{m}'; window.__mountHero = (el) => import(url).then((mod) => mod.mount(el)); if (!window.__heroAdopted && !window.__heroEarly) window.__mountHero(document.querySelector('.hero-piece')).then((h) => {{ if (!window.__heroAdopted) window.__heroEarly = h; else if (h && h.stop) h.stop(); }});"
StoredValue::new_local(None); )
Effect::new(move |_| { });
let Some(container) = gesture_ref.get() else {
return;
};
if widget.with_value(|w| w.is_some()) {
return;
}
widget.set_value(Some(gesture::mount_gesture_hero(&container, &relay)));
});
on_cleanup(move || {
widget.update_value(|opt| {
if let Some(w) = opt.take() {
w.stop();
}
});
});
}
view! { view! {
<header class="hero" class:hero-yes=show_yes class:hero-gesture=show_gesture> <header class="hero" class:hero-module=has_module>
{show_yes <Announcements current_id=current_id/>
{has_module
.then(|| { .then(|| {
view! { view! {
<div class="hero-canvas" aria-hidden="true"> <div class="hero-piece" aria-hidden="true" node_ref=piece_ref></div>
<canvas id="lineCanvas"></canvas> <script type="module" inner_html=early_script.unwrap_or_default()></script>
<canvas id="rasterCanvas" node_ref=raster_ref></canvas>
</div>
// Starts the animation at HTML parse time
// instead of waiting out the wasm bundle's
// fetch + hydration; the hydrate Effect above
// adopts (never duplicates) the instance, and
// the guards make either execution order safe.
<script
type="module"
inner_html="import('/yes.js').then((m) => { if (!window.__yesAdopted && !window.__yesEarly) window.__yesEarly = new m.RasterizedYES(); });"
></script>
} }
})} })}
{show_gesture
.then(|| {
view! { <div class="hero-draw" node_ref=gesture_ref></div> }
})}
<div class="hero-copy"> <div class="hero-copy">
<a class="wordmark" href="/"> <a class="wordmark" href="/">
<img src=wordmark alt=site_title/> <img src=wordmark alt=site_title/>
@@ -636,7 +702,7 @@ fn AlternativeCard(
return view! { return view! {
<section class="alt-card"> <section class="alt-card">
<h2>{alternative.name.clone()}</h2> <h2>{alternative.name.clone()}</h2>
<p class="alt-description">{alternative.description.clone()}</p> <p class="alt-description" inner_html=render_inline_markdown(&alternative.description)></p>
{move || { {move || {
let question_id_for_action = question_id_for_action.clone(); let question_id_for_action = question_id_for_action.clone();
let alt_name_for_action = alt_name_for_action.clone(); let alt_name_for_action = alt_name_for_action.clone();
@@ -722,12 +788,16 @@ fn AlternativeCard(
.or_insert_with(|| RwSignal::new(Vec::new())); .or_insert_with(|| RwSignal::new(Vec::new()));
select_multi.insert(req.name.clone(), req.multiple); select_multi.insert(req.name.clone(), req.multiple);
} else { } else {
if req.kind == "gesture" { // Widget-owned fields hold JSON objects, not strings.
if req.kind == "gesture" || req.kind == "voice" {
gesture_fields.insert(req.name.clone()); gesture_fields.insert(req.name.clone());
} }
// A voice field's preset is the key it records at,
// not a value to submit.
let preset = if req.kind == "voice" { String::new() } else { req.value.clone().unwrap_or_default() };
field_map field_map
.entry(req.name.clone()) .entry(req.name.clone())
.or_insert_with(|| RwSignal::new(String::new())); .or_insert_with(|| RwSignal::new(preset));
} }
} }
} }
@@ -858,7 +928,7 @@ fn AlternativeCard(
<section class="alt-card"> <section class="alt-card">
<AltImages images=alternative.images.clone() /> <AltImages images=alternative.images.clone() />
<h2>{alternative.name.clone()}</h2> <h2>{alternative.name.clone()}</h2>
<p class="alt-description">{alternative.description.clone()}</p> <p class="alt-description" inner_html=render_inline_markdown(&alternative.description)></p>
<div class="features"> <div class="features">
<For <For
each={ each={
@@ -907,7 +977,7 @@ fn AlternativeCard(
let description = feature.description.clone(); let description = feature.description.clone();
move || !description.is_empty() move || !description.is_empty()
}> }>
<p>{feature.description.clone()}</p> <p inner_html=render_inline_markdown(&feature.description)></p>
</Show> </Show>
{resource.map(|spec| { {resource.map(|spec| {
view! { view! {
@@ -916,6 +986,7 @@ fn AlternativeCard(
alternative=alt_name.clone() alternative=alt_name.clone()
feature_name=feature_name.clone() feature_name=feature_name.clone()
transitions=spec.transitions.clone() transitions=spec.transitions.clone()
empty=spec.empty.clone()
pending_transitions=pending_transitions pending_transitions=pending_transitions
transition_batch=transition_batch transition_batch=transition_batch
/> />
@@ -1093,6 +1164,54 @@ fn AlternativeCard(
.into_any(); .into_any();
} }
if req.kind == "voice" {
let container_ref: NodeRef<leptos::html::Div> = NodeRef::new();
let hidden_ref: NodeRef<leptos::html::Input> = NodeRef::new();
#[cfg(feature = "hydrate")]
{
let relay = req.relay.clone().unwrap_or_default();
let key = req.value.clone().unwrap_or_default();
let widget: StoredValue<Option<voice::VoiceWidget>, LocalStorage> =
StoredValue::new_local(None);
Effect::new(move |_| {
let (Some(container), Some(hidden)) =
(container_ref.get(), hidden_ref.get())
else {
return;
};
if widget.with_value(|w| w.is_some()) {
return;
}
widget.set_value(Some(voice::mount_voice(
&container, &hidden, &relay, &key,
)));
});
on_cleanup(move || {
widget.update_value(|opt| {
if let Some(w) = opt.take() {
w.stop();
}
});
});
}
return view! {
<div class="field">
{label_text}
<input
id=field_id
type="hidden"
node_ref=hidden_ref
prop:value=move || sig.get()
on:input=move |ev| sig.set(event_target_value(&ev))
/>
<div class="voice-wrap" node_ref=container_ref></div>
</div>
}
.into_any();
}
if req.kind == "prosekit" { if req.kind == "prosekit" {
let container_ref: NodeRef<leptos::html::Div> = NodeRef::new(); let container_ref: NodeRef<leptos::html::Div> = NodeRef::new();
let hidden_ref: NodeRef<leptos::html::Input> = NodeRef::new(); let hidden_ref: NodeRef<leptos::html::Input> = NodeRef::new();
@@ -1125,6 +1244,20 @@ fn AlternativeCard(
.into_any(); .into_any();
} }
if req.kind == "hidden" {
// A carrier value only — no label row, or a
// preset key reads as a second visible field.
return view! {
<input
id=field_id
type="hidden"
prop:value=move || sig.get()
on:input=move |ev| sig.set(event_target_value(&ev))
/>
}
.into_any();
}
view! { view! {
<label class="field" for=label_for> <label class="field" for=label_for>
{label_text} {label_text}
@@ -1192,7 +1325,17 @@ fn AlternativeCard(
children=move |e| view! { <p class="alt-encouragement">{e}</p> } children=move |e| view! { <p class="alt-encouragement">{e}</p> }
/> />
</div> </div>
{if is_gateway { {if alternative.disabled {
// Advertised, not yet takeable: the label
// stays (it says what will be possible),
// the button doesn't act.
view! {
<button type="button" class="alt-submit soon" disabled=true title="Not yet">
{button_label.clone()}
</button>
}
.into_any()
} else if is_gateway {
let href = alternative.action.clone().unwrap_or_default(); let href = alternative.action.clone().unwrap_or_default();
view! { view! {
<a class="alt-submit" href=href>{button_label.clone()}</a> <a class="alt-submit" href=href>{button_label.clone()}</a>
@@ -1235,6 +1378,7 @@ fn ResourceFeature(
alternative: String, alternative: String,
feature_name: String, feature_name: String,
transitions: Vec<Transition>, transitions: Vec<Transition>,
empty: Option<String>,
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>, pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
transition_batch: ServerAction<TransitionAnswers>, transition_batch: ServerAction<TransitionAnswers>,
) -> impl IntoView { ) -> impl IntoView {
@@ -1268,6 +1412,7 @@ fn ResourceFeature(
{move || { {move || {
let feature_name = feature_name.clone(); let feature_name = feature_name.clone();
let transitions = transitions.clone(); let transitions = transitions.clone();
let empty = empty.clone().unwrap_or_else(|| "Nothing here yet.".to_string());
data.get() data.get()
.map(|res| match res { .map(|res| match res {
Ok(value) => { Ok(value) => {
@@ -1275,6 +1420,7 @@ fn ResourceFeature(
<ResourceValue <ResourceValue
feature_name=feature_name feature_name=feature_name
transitions=transitions transitions=transitions
empty=empty
value=value value=value
pending_transitions=pending_transitions pending_transitions=pending_transitions
transition_batch=transition_batch transition_batch=transition_batch
@@ -1301,11 +1447,23 @@ fn ResourceFeature(
fn ResourceValue( fn ResourceValue(
feature_name: String, feature_name: String,
transitions: Vec<Transition>, transitions: Vec<Transition>,
empty: String,
value: serde_json::Value, value: serde_json::Value,
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>, pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
transition_batch: ServerAction<TransitionAnswers>, transition_batch: ServerAction<TransitionAnswers>,
) -> impl IntoView { ) -> impl IntoView {
// A jq like `.[] | {...}` over an empty list yields no output at
// all - that arrives as null, and it's the same silence as [].
if value.is_null() {
return view! { <p class="resource-empty">{empty}</p> }.into_any();
}
if let serde_json::Value::Array(items) = &value { if let serde_json::Value::Array(items) = &value {
// Before the Answer parse below: an empty list deserializes as
// an (empty) Vec<Answer> too, and rendered as a blank
// answer-list instead of saying anything.
if items.is_empty() {
return view! { <p class="resource-empty">{empty}</p> }.into_any();
}
if let Ok(answers) = serde_json::from_value::<Vec<Answer>>(value.clone()) { if let Ok(answers) = serde_json::from_value::<Vec<Answer>>(value.clone()) {
let mut answers = answers; let mut answers = answers;
answers.sort_by(|a, b| { answers.sort_by(|a, b| {
@@ -1338,9 +1496,6 @@ fn ResourceValue(
} }
.into_any(); .into_any();
} }
if items.is_empty() {
return view! { <p class="resource-empty">"Nothing here yet."</p> }.into_any();
}
// A list of plain objects (e.g. a jq-shaped GiteaStarred/Url // A list of plain objects (e.g. a jq-shaped GiteaStarred/Url
// resource) - render as cards rather than dumping raw JSON. // resource) - render as cards rather than dumping raw JSON.
// Generic on purpose, no content-declared "kind": `name`/`title` // Generic on purpose, no content-declared "kind": `name`/`title`
@@ -1387,8 +1542,11 @@ fn ItemCard(item: serde_json::Value) -> impl IntoView {
let name = text_field(&obj, &["name", "title"]); let name = text_field(&obj, &["name", "title"]);
let description = text_field(&obj, &["description"]); let description = text_field(&obj, &["description"]);
let url = text_field(&obj, &["url", "html_url"]); let url = text_field(&obj, &["url", "html_url"]);
// A playable recording (an https URL to audio) renders as a
// player, not a link - the relay's /blob/<digest>.wav.
let audio = text_field(&obj, &["audio"]).filter(|a| a.starts_with("https://"));
let known = ["name", "title", "description", "url", "html_url"]; let known = ["name", "title", "description", "url", "html_url", "audio"];
let extra: Vec<(String, String)> = obj let extra: Vec<(String, String)> = obj
.iter() .iter()
.filter(|(k, v)| !known.contains(&k.as_str()) && !v.is_null()) .filter(|(k, v)| !known.contains(&k.as_str()) && !v.is_null())
@@ -1428,6 +1586,7 @@ fn ItemCard(item: serde_json::Value) -> impl IntoView {
} }
})} })}
{description.map(|d| view! { <p class="item-card-description">{d}</p> })} {description.map(|d| view! { <p class="item-card-description">{d}</p> })}
{audio.map(|src| view! { <audio class="item-card-audio" controls preload="none" src=src></audio> })}
{(!extra.is_empty()) {(!extra.is_empty())
.then(|| { .then(|| {
view! { view! {
@@ -1808,12 +1967,17 @@ pub async fn list_qualifying_questions(
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
let now_ms = chrono::Utc::now().timestamp_millis();
let mut out: Vec<(String, String)> = state let mut out: Vec<(String, String)> = state
.questions .questions
.load() .load()
.values() .values()
.filter(|q| is_qualified(user.as_ref(), q)) .filter(|q| is_qualified(user.as_ref(), q))
.filter(|q| !q.is_followup() || has_chain) // An ended event page is a followup from then on - only a
// visitor carrying a chain (they answered it) still sees it.
.filter(|q| !(q.is_followup() || q.event_ended_at(now_ms)) || has_chain)
// A live event is announced in the header instead.
.filter(|q| !q.event_live_at(now_ms))
// A dynamic page has no URL of its own to link to. // A dynamic page has no URL of its own to link to.
.filter(|q| !q.is_dynamic()) .filter(|q| !q.is_dynamic())
.map(|q| (q.id.clone(), q.name.clone())) .map(|q| (q.id.clone(), q.name.clone()))
@@ -1822,6 +1986,67 @@ pub async fn list_qualifying_questions(
Ok(out) Ok(out)
} }
/// Every announced page whose window is still open and that the
/// visitor qualifies for, soonest first.
#[server(endpoint = "list_announcements")]
pub async fn list_announcements() -> Result<Vec<Announcement>, ServerFnError> {
use crate::auth::{User, SESSION_USER_KEY};
use crate::content::is_qualified;
use crate::server::AppState;
let state = expect_context::<AppState>();
let session: tower_sessions::Session = leptos_axum::extract().await?;
let user = session
.get::<User>(SESSION_USER_KEY)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let now_ms = chrono::Utc::now().timestamp_millis();
let mut live: Vec<(i64, Announcement)> = state
.questions
.load()
.values()
.filter(|q| is_qualified(user.as_ref(), q) && q.event_live_at(now_ms))
.filter_map(|q| {
let event = q.event.as_ref()?;
let starts = chrono::DateTime::parse_from_rfc3339(&event.starts).ok()?;
let starts_ms = starts.timestamp_millis();
let relative = relative_until(starts_ms - now_ms);
Some((
starts_ms,
Announcement {
id: q.id.clone(),
name: q.name.clone(),
when: starts.format("%a %-d %b, %H:%M").to_string(),
relative,
place: event.place.clone(),
},
))
})
.collect();
live.sort_by_key(|(starts, _)| *starts);
Ok(live.into_iter().map(|(_, a)| a).collect())
}
/// "in 3 days" / "in 4 h" / "in 20 min" / "now" (already started).
#[cfg(feature = "ssr")]
fn relative_until(delta_ms: i64) -> String {
const MIN: i64 = 60_000;
const HOUR: i64 = 60 * MIN;
const DAY: i64 = 24 * HOUR;
if delta_ms <= 0 {
"now".to_string()
} else if delta_ms >= 2 * DAY {
format!("in {} days", delta_ms / DAY)
} else if delta_ms >= DAY {
"tomorrow".to_string()
} else if delta_ms >= HOUR {
format!("in {} h", delta_ms / HOUR)
} else {
format!("in {} min", (delta_ms / MIN).max(1))
}
}
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SubmitResult { pub struct SubmitResult {
pub next: Option<String>, pub next: Option<String>,
@@ -1863,11 +2088,21 @@ pub async fn submit_answer(
.iter() .iter()
.find(|a| a.name == alternative) .find(|a| a.name == alternative)
.ok_or_else(|| ServerFnError::new("unknown alternative"))?; .ok_or_else(|| ServerFnError::new("unknown alternative"))?;
let next = alt.action.clone(); if alt.disabled {
return Err(ServerFnError::new("this alternative isn't open yet"));
}
let record_as = alt.record_as.clone(); let record_as = alt.record_as.clone();
let responses: serde_json::Value = serde_json::from_str(&responses_json) let responses: serde_json::Value = serde_json::from_str(&responses_json)
.map_err(|e| ServerFnError::new(format!("invalid responses: {e}")))?; .map_err(|e| ServerFnError::new(format!("invalid responses: {e}")))?;
// `/shape/{curve.key}`: the next page may be chosen by the answer.
let next = match alt.action.as_deref() {
Some(action) => Some(
crate::content::template_action(action, &responses)
.ok_or_else(|| ServerFnError::new("this answer is missing what the next page needs"))?,
),
None => None,
};
let parent_hashes: Vec<String> = parent_hash.into_iter().collect(); let parent_hashes: Vec<String> = parent_hash.into_iter().collect();
let timestamp_ms = chrono::Utc::now().timestamp_millis(); let timestamp_ms = chrono::Utc::now().timestamp_millis();
+13
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(),
+601 -33
View File
@@ -50,6 +50,59 @@ pub struct Question {
/// (not a followup). /// (not a followup).
#[serde(default)] #[serde(default)]
pub followup: Option<bool>, pub followup: Option<bool>,
/// This page announces something with a time window. While the
/// window is open the page is announced in the header (see
/// `app::Announcements`) instead of listed in the footer nav; once
/// it closes the page behaves like a `followup` (visible only to
/// visitors carrying an answer chain) and a "post what happened"
/// task lands in the `portal_events` desk (see `announce.rs`).
#[serde(default)]
pub event: Option<EventConfig>,
}
/// A page's announcement window. `starts` is RFC 3339 with an offset
/// (`2026-09-12T18:00:00+02:00`); `duration` is a plain span like
/// `3h`, `90m`, `2d`, `1d 6h`. Optional `place` is shown verbatim.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct EventConfig {
pub starts: String,
pub duration: String,
#[serde(default)]
pub place: Option<String>,
}
/// `3h`, `90m`, `2d`, `1d 6h 30m` -> milliseconds. Whole units only;
/// the smallest is a minute.
pub fn parse_duration_ms(text: &str) -> Result<i64, String> {
let mut total: i64 = 0;
let mut seen = false;
for token in text.split_whitespace() {
let (num, unit) = token.split_at(token.trim_end_matches(|c: char| c.is_ascii_alphabetic()).len());
let n: i64 = num.parse().map_err(|_| format!("bad duration {text:?}: {token:?}"))?;
let per = match unit {
"m" => 60_000,
"h" => 3_600_000,
"d" => 86_400_000,
_ => return Err(format!("bad duration {text:?}: unit {unit:?} (use m, h, d)")),
};
total += n * per;
seen = true;
}
if !seen || total <= 0 {
return Err(format!("bad duration {text:?}: must be positive"));
}
Ok(total)
}
#[cfg(feature = "ssr")]
impl EventConfig {
/// `(starts_ms, ends_ms)` as Unix milliseconds.
pub fn window_ms(&self) -> Result<(i64, i64), String> {
let starts = chrono::DateTime::parse_from_rfc3339(&self.starts)
.map_err(|e| format!("bad starts {:?}: {e} (RFC 3339 with offset)", self.starts))?
.timestamp_millis();
Ok((starts, starts + parse_duration_ms(&self.duration)?))
}
} }
impl Question { impl Question {
@@ -57,6 +110,24 @@ impl Question {
self.followup.unwrap_or(false) self.followup.unwrap_or(false)
} }
/// The announcement window is still open at `now_ms` (Unix ms).
#[cfg(feature = "ssr")]
pub fn event_live_at(&self, now_ms: i64) -> bool {
self.event
.as_ref()
.and_then(|e| e.window_ms().ok())
.is_some_and(|(_, ends)| now_ms < ends)
}
/// The announcement window has closed at `now_ms`.
#[cfg(feature = "ssr")]
pub fn event_ended_at(&self, now_ms: i64) -> bool {
self.event
.as_ref()
.and_then(|e| e.window_ms().ok())
.is_some_and(|(_, ends)| now_ms >= ends)
}
/// A dynamic page - one whose id still contains a `[name]` /// A dynamic page - one whose id still contains a `[name]`
/// segment. Served per-value via `resolve_question`, never listed /// segment. Served per-value via `resolve_question`, never listed
/// in nav, never a valid `action` target. /// in nav, never a valid `action` target.
@@ -65,6 +136,76 @@ impl Question {
} }
} }
/// An alternative's `action` may carry `{field}` / `{field.sub}`
/// placeholders filled from the submitted responses - `/shape/{curve.key}`
/// lands on the dynamic page `/shape/[key]` for the key the visitor's
/// stroke was given. Returns `None` when a placeholder has no value
/// (or a non-scalar one) in `responses`.
pub fn template_action(action: &str, responses: &serde_json::Value) -> Option<String> {
let mut out = String::new();
let mut rest = action;
while let Some(start) = rest.find('{') {
out.push_str(&rest[..start]);
let end = rest[start..].find('}')? + start;
let path = &rest[start + 1..end];
let mut value = responses;
for seg in path.split('.') {
value = value.get(seg)?;
}
let scalar = match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
_ => return None,
};
if scalar.is_empty() {
return None;
}
// A URL segment: keep it to what a segment can carry.
let safe: String = scalar
.chars()
.map(|c| if c.is_ascii_alphanumeric() || "-_.".contains(c) { c } else { '-' })
.collect();
out.push_str(&safe);
rest = &rest[end + 1..];
}
out.push_str(rest);
Some(out)
}
/// Descriptions are inline markdown: `[text](https://…)` links,
/// `*emphasis*`, `**strong**`, `` `code` ``. Block structure is
/// flattened (a description is one paragraph) and raw HTML in the
/// source is dropped, so content can link out without being able to
/// inject markup. Link targets are limited to https, mailto and
/// site-relative paths; anything else renders as plain text.
pub fn render_inline_markdown(text: &str) -> String {
use pulldown_cmark::{html, Event, Options, Parser, Tag, TagEnd};
let parser = Parser::new_ext(text, Options::empty());
let mut in_link = 0usize;
let filtered = parser.filter_map(|event| match event {
Event::Html(_) | Event::InlineHtml(_) => None,
Event::Start(Tag::Paragraph) | Event::End(TagEnd::Paragraph) => None,
Event::SoftBreak => Some(Event::Text(" ".into())),
Event::Start(Tag::Link { dest_url, .. })
if !(dest_url.starts_with("https://")
|| dest_url.starts_with("mailto:")
|| dest_url.starts_with('/')) =>
{
in_link += 1;
None
}
Event::End(TagEnd::Link) if in_link > 0 => {
in_link -= 1;
None
}
other => Some(other),
});
let mut out = String::new();
html::push_html(&mut out, filtered);
out.trim().to_string()
}
/// Directory-scoped defaults: a `_section.yaml` file applies to every /// Directory-scoped defaults: a `_section.yaml` file applies to every
/// question at or below its directory (nearest ancestor wins per /// question at or below its directory (nearest ancestor wins per
/// field, and a question's own declaration always overrides). This is /// field, and a question's own declaration always overrides). This is
@@ -111,6 +252,11 @@ pub struct Alternative {
pub description: String, pub description: String,
#[serde(default)] #[serde(default)]
pub action: Option<String>, pub action: Option<String>,
/// Shown but not takeable yet - the button renders disabled and the
/// server refuses the submission. For advertising a path before it
/// works (lysbue had the same flag).
#[serde(default)]
pub disabled: bool,
#[serde(default)] #[serde(default)]
pub consequence: Vec<String>, pub consequence: Vec<String>,
#[serde(default)] #[serde(default)]
@@ -202,6 +348,12 @@ pub struct ResourceSpec {
/// `.[] | {name, url: .html_url}`. `None` returns it as-is. /// `.[] | {name, url: .html_url}`. `None` returns it as-is.
#[serde(default)] #[serde(default)]
pub jq: Option<String>, pub jq: Option<String>,
/// What to say when the resource yields nothing - an empty list,
/// or `null` (a `.[] | ...` jq over an empty list produces no
/// output at all). Defaults to "Nothing here yet."; content sets
/// it where the silence needs a voice ("Nothing released yet").
#[serde(default)]
pub empty: Option<String>,
} }
/// Where a resource's live data comes from. `Kv` (a NATS KV bucket /// Where a resource's live data comes from. `Kv` (a NATS KV bucket
@@ -287,7 +439,13 @@ pub struct Requirement {
/// the selected file's current content. /// the selected file's current content.
#[serde(default)] #[serde(default)]
pub bind: Option<Bind>, pub bind: Option<Bind>,
/// `type: gesture` only - ws(s):// URL of a redoal-relay instance /// A preset value the field starts with. On a dynamic page the
/// URL segment substitutes into `{name}` placeholders here, like
/// resource keys - `value: "{key}"` on a hidden field carries the
/// page's key into the answer.
#[serde(default)]
pub value: Option<String>,
/// `type: gesture` / `type: voice` - ws(s):// URL of a redoal-relay instance
/// the drawing widget connects to for live echoes of similar /// the drawing widget connects to for live echoes of similar
/// strokes. Absent means the widget works offline: the drawn path /// strokes. Absent means the widget works offline: the drawn path
/// still submits, it just never gets a network-computed key or /// still submits, it just never gets a network-computed key or
@@ -347,51 +505,76 @@ pub struct SiteConfig {
pub hero: HeroConfig, pub hero: HeroConfig,
} }
/// What the landing page's hero is: the YES canvas piece (`yes`, the /// What the landing page's hero is. `plain` is just the copy; `module`
/// default), a redoal gesture-drawing canvas (`gesture`), or copy /// hands the hero to a JavaScript module the content repo itself
/// only (`plain`). /// ships (`module: hero.js`, a repo-relative path portal serves
/// same-origin at `/site/<path>`, since Gitea's raw endpoint sends no
/// CORS headers). The module exports `mount(container) -> handle` and
/// the handle has `stop()`; portal starts it at HTML parse time and
/// adopts it on hydration. The piece's look - canvases, SVG, CSS - is
/// entirely the module's: portal only reserves the box.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct HeroConfig { pub struct HeroConfig {
#[serde(default = "default_hero_kind")] #[serde(default = "default_hero_kind")]
pub kind: String, pub kind: String,
/// `kind: gesture` only - ws(s):// URL of a redoal-relay for
/// ambient echoes. Absent means the hero draws offline.
#[serde(default)] #[serde(default)]
pub relay: Option<String>, pub module: Option<String>,
} }
impl Default for HeroConfig { impl Default for HeroConfig {
fn default() -> Self { fn default() -> Self {
Self { kind: default_hero_kind(), relay: None } Self { kind: default_hero_kind(), module: None }
} }
} }
fn default_hero_kind() -> String { fn default_hero_kind() -> String {
"yes".to_string() "plain".to_string()
} }
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
impl SiteConfig { impl SiteConfig {
/// Shared by `load_site_from_gitea` and the `question-lint` binary /// Same policy as `validate_questions`: content declares, the
/// so a typo'd hero kind is a caught rejection, not a silently /// runtime refuses what it doesn't understand at load time, so a
/// plain hero. /// typo'd hero kind is a caught rejection, not a silently plain
/// hero.
pub fn validate(&self) -> anyhow::Result<()> { pub fn validate(&self) -> anyhow::Result<()> {
if !matches!(self.hero.kind.as_str(), "yes" | "gesture" | "plain") { match self.hero.kind.as_str() {
anyhow::bail!( "plain" => {
"site.yaml: hero.kind {:?} is not one of yes | gesture | plain", if self.hero.module.is_some() {
self.hero.kind anyhow::bail!("site.yaml: hero.module only makes sense with hero.kind: module");
); }
}
if let Some(relay) = &self.hero.relay {
if self.hero.kind != "gesture" {
anyhow::bail!("site.yaml: hero.relay only makes sense with hero.kind: gesture");
} }
validate_relay_url(relay).map_err(|e| anyhow::anyhow!("site.yaml: {e}"))?; "module" => {
let Some(module) = &self.hero.module else {
anyhow::bail!("site.yaml: hero.kind: module needs hero.module (a repo-relative path like hero.js)");
};
if !is_safe_site_path(module) {
anyhow::bail!(
"site.yaml: hero.module {module:?} must be a plain repo-relative path (no scheme, no .., no leading /)"
);
}
}
other => anyhow::bail!("site.yaml: hero.kind {other:?} is not one of plain | module"),
} }
Ok(()) Ok(())
} }
} }
/// A repo-relative asset path portal is willing to proxy from the
/// content repo: plain segments only - no traversal, no leading slash,
/// no scheme - so `/site/<path>` can only ever reach the content repo.
pub fn is_safe_site_path(path: &str) -> bool {
!path.is_empty()
&& !path.starts_with('/')
&& path.split('/').all(|seg| {
!seg.is_empty()
&& seg != ".."
&& seg
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
})
}
/// A relay must be a ws:// or wss:// URL - shared between site.yaml's /// A relay must be a ws:// or wss:// URL - shared between site.yaml's
/// hero and `Requirement.relay` validation. /// hero and `Requirement.relay` validation.
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
@@ -428,6 +611,15 @@ pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Resul
/// Extracts `scheme://host` from a repo's normal browser URL - the /// Extracts `scheme://host` from a repo's normal browser URL - the
/// Gitea API base every helper in this module builds requests against. /// Gitea API base every helper in this module builds requests against.
#[cfg(feature = "ssr")]
/// `{gitea}/api/v1/repos/{owner}/{repo}/raw` - the base
/// `site_asset_handler` proxies content-shipped files from.
#[cfg(feature = "ssr")]
pub fn gitea_raw_base(repo_url: &str) -> anyhow::Result<String> {
let (owner, repo) = parse_owner_repo(repo_url)?;
Ok(format!("{}/api/v1/repos/{owner}/{repo}/raw", gitea_api_base(repo_url)?))
}
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
pub fn gitea_api_base(repo_url: &str) -> anyhow::Result<String> { pub fn gitea_api_base(repo_url: &str) -> anyhow::Result<String> {
let parsed = url::Url::parse(repo_url) let parsed = url::Url::parse(repo_url)
@@ -631,15 +823,26 @@ pub fn resolve_question(
} }
} }
}; };
// A url source's address takes the segment too:
// `https://relay.redoal.com/place/{key}`.
let substitute_url = |src: &mut ResourceSource| {
if let ResourceSource::Url { url } = src {
for (name, value) in &captures {
*url = url.replace(&format!("{{{name}}}"), value);
}
}
};
for alt in &mut resolved.alternatives { for alt in &mut resolved.alternatives {
for feature in &mut alt.features { for feature in &mut alt.features {
if let Some(res) = &mut feature.resource { if let Some(res) = &mut feature.resource {
substitute(&mut res.key); substitute(&mut res.key);
substitute_url(&mut res.source);
} }
for req in &mut feature.requirements { for req in &mut feature.requirements {
if let Some(res) = &mut req.resource { if let Some(res) = &mut req.resource {
substitute(&mut res.key); substitute(&mut res.key);
} }
substitute(&mut req.value);
} }
} }
} }
@@ -826,12 +1029,71 @@ pub async fn load_questions_from_gitea(
/// standalone `question-lint` binary, so a YAML typo becomes a caught, /// standalone `question-lint` binary, so a YAML typo becomes a caught,
/// logged rejection instead of a silently-accepted, later-broken /// logged rejection instead of a silently-accepted, later-broken
/// string. /// string.
/// Bucket the runtime keeps for announced pages (`Question.event`):
/// one record per event question, `announced` while the window is
/// open, `awaiting_summary` once it closes - the "post what happened"
/// task a desk reading this bucket picks up - and `summarized` when an
/// owner marks it done. Declared here, not in content, because the
/// runtime's sweeper (`announce.rs`) is what moves the records.
pub const EVENTS_BUCKET: &str = "portal_events";
#[cfg(feature = "ssr")]
const BUILTIN_AGGREGATES_YAML: &str = "\
aggregates:
- bucket: portal_events
initial: announced
states:
announced: { event: announced }
awaiting_summary: { event: ended }
summarized: { event: summarized }
transitions:
announced: [awaiting_summary, summarized]
awaiting_summary: [summarized]
";
/// Adds the runtime's own state graphs to a content repo's. Content
/// wins if it declares the same bucket (say, to add states).
#[cfg(feature = "ssr")]
pub fn with_builtin_aggregates(
mut aggregates: std::collections::HashMap<String, crate::aggregates::AggregateSchema>,
) -> std::collections::HashMap<String, crate::aggregates::AggregateSchema> {
let builtin = crate::aggregates::parse_aggregates_yaml(BUILTIN_AGGREGATES_YAML)
.expect("builtin aggregates yaml is valid");
for (bucket, schema) in builtin {
aggregates.entry(bucket).or_insert(schema);
}
aggregates
}
/// True when some kv resource in the content reads `bucket` - the
/// attended-bucket check, exposed so the lint can warn about the
/// runtime's own `portal_events` (a warning, not a failure: a repo
/// with no event pages has nothing to attend).
#[cfg(feature = "ssr")]
pub fn bucket_is_read(questions: &std::collections::HashMap<String, Question>, bucket: &str) -> bool {
questions.values().any(|q| {
q.alternatives.iter().any(|a| {
a.features
.iter()
.any(|f| f.resource.as_ref().and_then(|r| r.bucket()).is_some_and(|b| b == bucket))
})
})
}
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
pub fn validate_questions( pub fn validate_questions(
questions: &std::collections::HashMap<String, Question>, questions: &std::collections::HashMap<String, Question>,
aggregates: &std::collections::HashMap<String, crate::aggregates::AggregateSchema>, aggregates: &std::collections::HashMap<String, crate::aggregates::AggregateSchema>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
for question in questions.values() { for question in questions.values() {
if let Some(event) = &question.event {
if question.is_dynamic() {
anyhow::bail!("question {:?}: a dynamic page can't carry an event", question.id);
}
if let Err(e) = event.window_ms() {
anyhow::bail!("question {:?}: event: {e}", question.id);
}
}
if let Some(target) = &question.requires_chain { if let Some(target) = &question.requires_chain {
if !questions.contains_key(target) { if !questions.contains_key(target) {
anyhow::bail!( anyhow::bail!(
@@ -845,6 +1107,31 @@ pub fn validate_questions(
// A dangling action is a literal dead end: the submit // A dangling action is a literal dead end: the submit
// button navigates to "Nothing here". // button navigates to "Nothing here".
if let Some(action) = &alternative.action { if let Some(action) = &alternative.action {
if action.contains('{') {
// Placeholders are filled at submit time; the
// shape must land on a dynamic page once filled.
let probe = template_action(action, &serde_json::json!({}));
let filled = {
let mut a = action.clone();
while let (Some(i), Some(j)) = (a.find('{'), a.find('}')) {
a.replace_range(i..=j, "x");
}
a
};
let _ = probe;
// Match the pattern itself: resolve_question would
// hand back a clone whose id is already concrete.
let lands = questions
.values()
.any(|q| q.is_dynamic() && path_matches(&q.id, &filled).is_some());
if lands {
continue;
}
anyhow::bail!(
"question {:?} alternative {:?}: templated action {:?} must land on a dynamic page ([name].yaml)",
question.id, alternative.name, action
);
}
match questions.get(action) { match questions.get(action) {
None => anyhow::bail!( None => anyhow::bail!(
"question {:?} alternative {:?}: action {:?} does not match any declared question id", "question {:?} alternative {:?}: action {:?} does not match any declared question id",
@@ -886,9 +1173,9 @@ pub fn validate_questions(
); );
} }
if let Some(relay) = &requirement.relay { if let Some(relay) = &requirement.relay {
if requirement.kind != "gesture" { if requirement.kind != "gesture" && requirement.kind != "voice" {
anyhow::bail!( anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: requirement {:?} declares relay but is type {:?} - relay only makes sense on type: gesture", "question {:?} alternative {:?} feature {:?}: requirement {:?} declares relay but is type {:?} - relay only makes sense on type: gesture or voice",
question.id, alternative.name, feature.name, requirement.name, requirement.kind question.id, alternative.name, feature.name, requirement.name, requirement.kind
); );
} }
@@ -948,6 +1235,54 @@ pub fn validate_questions(
} }
} }
} }
// Attended buckets: no publicly collected answer may land somewhere
// nothing reads. Every `record_as` bucket must either be read back
// by some Kv resource in this same content repo (a desk or listing)
// or carry an explicit `attended_by:` annotation in aggregates.yaml
// naming the automation that consumes it. This is a contract, not a
// convention, because convention already failed once: a question
// was dropped and its bucket - answers included - silently fell out
// of every reader's view.
let mut read_buckets = std::collections::HashSet::new();
let note_resource = |spec: &ResourceSpec, set: &mut std::collections::HashSet<String>| {
if let ResourceSource::Kv { bucket } = &spec.source {
set.insert(bucket.clone());
}
};
for question in questions.values() {
for alternative in &question.alternatives {
for feature in &alternative.features {
if let Some(spec) = &feature.resource {
note_resource(spec, &mut read_buckets);
}
for requirement in &feature.requirements {
if let Some(spec) = &requirement.resource {
note_resource(spec, &mut read_buckets);
}
if let Some(bind) = &requirement.bind {
note_resource(&bind.resource, &mut read_buckets);
}
}
}
}
}
for question in questions.values() {
for alternative in &question.alternatives {
if let Some(bucket) = &alternative.record_as {
let attended = read_buckets.contains(bucket)
|| aggregates
.get(bucket)
.is_some_and(|schema| schema.attended_by.is_some());
if !attended {
anyhow::bail!(
"question {:?} alternative {:?}: record_as {bucket:?} is unattended - no page reads that bucket back, and aggregates.yaml declares no attended_by for it. Add a desk/listing resource over it, or annotate the automation that consumes it.",
question.id, alternative.name
);
}
}
}
}
Ok(()) Ok(())
} }
@@ -988,7 +1323,7 @@ pub async fn watch_for_reload(
use futures::StreamExt; use futures::StreamExt;
while sub.next().await.is_some() { while sub.next().await.is_some() {
let loaded_aggregates = match load_aggregates_from_gitea(&repo_url, &branch).await { let loaded_aggregates = match load_aggregates_from_gitea(&repo_url, &branch).await {
Ok(loaded) => loaded, Ok(loaded) => with_builtin_aggregates(loaded),
Err(e) => { Err(e) => {
tracing::error!(error = %e, "aggregates.yaml reload failed, keeping last-good content"); tracing::error!(error = %e, "aggregates.yaml reload failed, keeping last-good content");
continue; continue;
@@ -1043,6 +1378,54 @@ pub struct GiteaRepoQuery {
pub repo: String, pub repo: String,
} }
/// `GET /site/{*path}` - serves a file from the content repo's default
/// branch same-origin, so content-shipped assets (the hero module,
/// its stylesheet) are importable by the page: Gitea's raw endpoint
/// sends no CORS headers, and an ES module import across origins is
/// refused without them. Path is validated to plain segments
/// (`is_safe_site_path`), the response revalidates like /pkg
/// (Cache-Control: no-cache) so a content push shows up on reload.
#[cfg(feature = "ssr")]
pub async fn site_asset_handler(
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
axum::extract::Path(path): axum::extract::Path<String>,
) -> axum::response::Response {
use axum::http::{header, StatusCode};
use axum::response::IntoResponse;
if !is_safe_site_path(&path) {
return (StatusCode::BAD_REQUEST, "invalid asset path").into_response();
}
let url = format!("{}/{path}?ref={}", state.content_raw_base, state.content_branch);
let client = openidconnect::reqwest::Client::new();
let resp = match client.get(&url).send().await.and_then(|r| r.error_for_status()) {
Ok(r) => r,
Err(e) => {
tracing::warn!("site asset {path}: {e}");
return (StatusCode::NOT_FOUND, "no such site asset").into_response();
}
};
let bytes = match resp.bytes().await {
Ok(b) => b,
Err(e) => return (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
};
let mime = match path.rsplit('.').next() {
Some("js") | Some("mjs") => "text/javascript; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
Some("svg") => "image/svg+xml",
Some("json") => "application/json",
Some("png") => "image/png",
Some("webp") => "image/webp",
Some("woff2") => "font/woff2",
_ => "application/octet-stream",
};
(
[(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, "no-cache")],
bytes,
)
.into_response()
}
/// Looks up `owner/repo` on the same Gitea instance content is loaded /// Looks up `owner/repo` on the same Gitea instance content is loaded
/// from (`AppState.gitea_base`) - a raw Axum handler (mounted at /// from (`AppState.gitea_base`) - a raw Axum handler (mounted at
/// `/gitea-repo` in `main.rs`), not a Leptos server fn, since the /// `/gitea-repo` in `main.rs`), not a Leptos server fn, since the
@@ -1338,9 +1721,9 @@ alternatives:
} }
#[test] #[test]
fn site_config_defaults_to_the_yes_hero() { fn site_config_defaults_to_the_plain_hero() {
let site = SiteConfig::default(); let site = SiteConfig::default();
assert_eq!(site.hero.kind, "yes"); assert_eq!(site.hero.kind, "plain");
assert!(site.validate().is_ok()); assert!(site.validate().is_ok());
// And an empty file parses to the same thing. // And an empty file parses to the same thing.
let parsed: SiteConfig = serde_yaml::from_str("{}").unwrap(); let parsed: SiteConfig = serde_yaml::from_str("{}").unwrap();
@@ -1355,21 +1738,28 @@ alternatives:
} }
#[test] #[test]
fn site_config_rejects_relay_without_gesture_hero() { fn site_config_module_hero_needs_a_safe_path() {
let site: SiteConfig = let missing: SiteConfig = serde_yaml::from_str("hero: { kind: module }").unwrap();
serde_yaml::from_str("hero: { kind: yes, relay: \"wss://relay.redoal.com\" }").unwrap(); assert!(missing.validate().is_err());
assert!(site.validate().is_err()); let traversal: SiteConfig =
serde_yaml::from_str("hero: { kind: module, module: ../etc/passwd }").unwrap();
assert!(traversal.validate().is_err());
let absolute: SiteConfig =
serde_yaml::from_str("hero: { kind: module, module: https://x/y.js }").unwrap();
assert!(absolute.validate().is_err());
let ok: SiteConfig = serde_yaml::from_str("hero: { kind: module, module: hero.js }").unwrap();
assert!(ok.validate().is_ok());
} }
#[test] #[test]
fn redoal_site_yaml_shape_parses() { fn redoal_site_yaml_shape_parses() {
let site: SiteConfig = serde_yaml::from_str( let site: SiteConfig = serde_yaml::from_str(
"title: redoal\nwordmark: https://project.uhhm.no/redoal/questions/raw/branch/main/wordmark.svg\nhero:\n kind: gesture\n relay: wss://relay.redoal.com\n", "title: redoal\nwordmark: https://project.uhhm.no/redoal/questions/raw/branch/main/wordmark.svg\nhero:\n kind: module\n module: hero.js\n",
) )
.unwrap(); .unwrap();
assert!(site.validate().is_ok()); assert!(site.validate().is_ok());
assert_eq!(site.title.as_deref(), Some("redoal")); assert_eq!(site.title.as_deref(), Some("redoal"));
assert_eq!(site.hero.kind, "gesture"); assert_eq!(site.hero.module.as_deref(), Some("hero.js"));
} }
#[test] #[test]
@@ -1501,6 +1891,40 @@ alternatives:
assert!(validate_questions(&questions, &Default::default()).is_err()); assert!(validate_questions(&questions, &Default::default()).is_err());
} }
#[test]
fn recorded_buckets_must_be_attended() {
let write_only = vec![(
"index.yaml".to_string(),
"name: Home\nalternatives:\n - name: send\n record_as: black_hole\n".to_string(),
)];
let questions = build_questions(&write_only).unwrap();
let err = validate_questions(&questions, &Default::default()).unwrap_err();
assert!(err.to_string().contains("unattended"), "got: {err}");
// A desk (any Kv resource over the bucket, anywhere in the
// repo) attends it.
let with_desk = vec![
(
"index.yaml".to_string(),
"name: Home\nalternatives:\n - name: send\n record_as: inbox\n".to_string(),
),
(
"review/index.yaml".to_string(),
"name: Desk\nalternatives:\n - name: Inbox\n features:\n - name: \"\"\n resource:\n requires_group: owners\n source:\n kind: kv\n bucket: inbox\n".to_string(),
),
];
let questions = build_questions(&with_desk).unwrap();
assert!(validate_questions(&questions, &Default::default()).is_ok());
// ..or an explicit attended_by annotation in aggregates.yaml.
let aggregates = crate::aggregates::parse_aggregates_yaml(
"aggregates:\n - bucket: black_hole\n initial: open\n attended_by: n8n nightly digest\n states:\n open: { event: submitted }\n",
)
.unwrap();
let questions = build_questions(&write_only).unwrap();
assert!(validate_questions(&questions, &aggregates).is_ok());
}
#[test] #[test]
fn actions_may_not_target_dynamic_pages() { fn actions_may_not_target_dynamic_pages() {
let files = vec![ let files = vec![
@@ -1513,4 +1937,148 @@ alternatives:
let questions = build_questions(&files).unwrap(); let questions = build_questions(&files).unwrap();
assert!(validate_questions(&questions, &Default::default()).is_err()); assert!(validate_questions(&questions, &Default::default()).is_err());
} }
// ── events (announced pages) ─────────────────────────────────────────
#[test]
fn duration_parses_whole_units() {
assert_eq!(parse_duration_ms("3h").unwrap(), 3 * 3_600_000);
assert_eq!(parse_duration_ms("90m").unwrap(), 90 * 60_000);
assert_eq!(parse_duration_ms("1d 6h").unwrap(), 30 * 3_600_000);
assert!(parse_duration_ms("0h").is_err());
assert!(parse_duration_ms("3 hours").is_err());
assert!(parse_duration_ms("").is_err());
}
#[test]
fn event_window_and_liveness() {
let q: Question = serde_yaml::from_str(
"id: /opening\nname: Will you be there?\nevent:\n starts: 2026-09-12T18:00:00+02:00\n duration: 3h\n",
)
.unwrap();
let (starts, ends) = q.event.as_ref().unwrap().window_ms().unwrap();
assert_eq!(ends - starts, 3 * 3_600_000);
assert!(q.event_live_at(starts - 1));
assert!(q.event_live_at(ends - 1));
assert!(!q.event_live_at(ends));
assert!(q.event_ended_at(ends));
assert!(!q.event_ended_at(starts));
let plain: Question = serde_yaml::from_str("id: /x\nname: X\n").unwrap();
assert!(!plain.event_live_at(0) && !plain.event_ended_at(i64::MAX));
}
#[test]
fn event_validation() {
let bad: Question = serde_yaml::from_str(
"id: /e\nname: E\nevent:\n starts: next friday\n duration: 3h\n",
)
.unwrap();
let mut qs = std::collections::HashMap::new();
qs.insert(bad.id.clone(), bad);
let err = validate_questions(&qs, &Default::default()).unwrap_err().to_string();
assert!(err.contains("bad starts"), "{err}");
let dynamic: Question = serde_yaml::from_str(
"id: /e/[slot]\nname: E\nevent:\n starts: 2026-09-12T18:00:00Z\n duration: 3h\n",
)
.unwrap();
let mut qs = std::collections::HashMap::new();
qs.insert(dynamic.id.clone(), dynamic);
let err = validate_questions(&qs, &Default::default()).unwrap_err().to_string();
assert!(err.contains("dynamic page can't carry an event"), "{err}");
}
#[test]
fn builtin_events_schema_yields_to_content() {
let merged = with_builtin_aggregates(Default::default());
let schema = &merged[EVENTS_BUCKET];
assert_eq!(schema.initial, "announced");
assert_eq!(schema.allowed("announced"), ["awaiting_summary", "summarized"]);
assert_eq!(schema.allowed("awaiting_summary"), ["summarized"]);
let content = crate::aggregates::parse_aggregates_yaml(
"aggregates:\n - bucket: portal_events\n initial: mine\n states:\n mine: { event: mine }\n",
)
.unwrap();
assert_eq!(with_builtin_aggregates(content)[EVENTS_BUCKET].initial, "mine");
}
#[test]
fn bucket_is_read_sees_kv_resources() {
let qs = question_with_transitions(" - { to: middle, label: Go }");
assert!(bucket_is_read(&qs, "things"));
assert!(!bucket_is_read(&qs, EVENTS_BUCKET));
}
// ── inline markdown ──────────────────────────────────────────────────
#[test]
fn markdown_links_and_emphasis() {
assert_eq!(
render_inline_markdown("See [the programme](https://attac.no/x) *soon*."),
"See <a href=\"https://attac.no/x\">the programme</a> <em>soon</em>."
);
assert_eq!(render_inline_markdown("plain text"), "plain text");
assert_eq!(render_inline_markdown("a\nb"), "a b");
}
#[test]
fn markdown_drops_html_and_unsafe_links() {
assert_eq!(render_inline_markdown("x <script>y</script> z"), "x y z");
assert_eq!(render_inline_markdown("[bad](javascript:alert(1))"), "bad");
assert_eq!(render_inline_markdown("[ok](/shape)"), "<a href=\"/shape\">ok</a>");
assert_eq!(render_inline_markdown("[mail](mailto:bl@uhhm.no)"), "<a href=\"mailto:bl@uhhm.no\">mail</a>");
}
#[test]
fn templated_action_validates_against_the_dynamic_page() {
let landing: Question = serde_yaml::from_str(
"id: /\nname: L\nalternatives:\n - name: A\n action: /shape/{curve.key}\n",
)
.unwrap();
let place: Question = serde_yaml::from_str("id: /shape/[key]\nname: P\n").unwrap();
let mut qs = std::collections::HashMap::new();
qs.insert(landing.id.clone(), landing.clone());
qs.insert(place.id.clone(), place);
validate_questions(&qs, &Default::default()).unwrap();
let mut only_landing = std::collections::HashMap::new();
only_landing.insert(landing.id.clone(), landing);
assert!(validate_questions(&only_landing, &Default::default()).is_err());
}
#[test]
fn action_templates_from_responses() {
let r = serde_json::json!({"curve": {"key": "20aa98", "points": [[0,0]]}, "n": 3});
assert_eq!(template_action("/shape/{curve.key}", &r).as_deref(), Some("/shape/20aa98"));
assert_eq!(template_action("/x/{n}/y", &r).as_deref(), Some("/x/3/y"));
assert_eq!(template_action("/shape", &r).as_deref(), Some("/shape"));
assert!(template_action("/shape/{curve.missing}", &r).is_none());
assert!(template_action("/shape/{curve.points}", &r).is_none());
let odd = serde_json::json!({"k": "a/b c"});
assert_eq!(template_action("/p/{k}", &odd).as_deref(), Some("/p/a-b-c"));
}
#[test]
fn dynamic_page_fills_requirement_values() {
let q: Question = serde_yaml::from_str(
"id: /shape/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n requirements:\n - name: key\n type: hidden\n value: \"{key}\"\n",
)
.unwrap();
let mut qs = std::collections::HashMap::new();
qs.insert(q.id.clone(), q);
let page = resolve_question(&qs, "/shape/20aa98").unwrap();
assert_eq!(page.alternatives[0].features[0].requirements[0].value.as_deref(), Some("20aa98"));
let q: Question = serde_yaml::from_str(
"id: /shape/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n resource:\n source: { kind: url, url: \"https://relay.example/place/{key}\" }\n public: true\n",
)
.unwrap();
let mut qs = std::collections::HashMap::new();
qs.insert(q.id.clone(), q);
let page = resolve_question(&qs, "/shape/20aa98").unwrap();
match &page.alternatives[0].features[0].resource.as_ref().unwrap().source {
ResourceSource::Url { url } => assert_eq!(url, "https://relay.example/place/20aa98"),
_ => panic!(),
}
}
} }
+3
View File
@@ -9,6 +9,9 @@ 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;
+13 -17
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
@@ -129,10 +138,6 @@ async fn main() -> anyhow::Result<()> {
let favicon_dark_path = format!("{}/favicon-dark.svg", leptos_options.site_root); let favicon_dark_path = format!("{}/favicon-dark.svg", leptos_options.site_root);
let wordmark_path = format!("{}/wordmark.svg", leptos_options.site_root); let wordmark_path = format!("{}/wordmark.svg", leptos_options.site_root);
let swiper_path = format!("{}/swiper-element-bundle.min.js", leptos_options.site_root); let swiper_path = format!("{}/swiper-element-bundle.min.js", leptos_options.site_root);
// yes.js sits in public/ (not a wasm-bindgen snippet) so the
// hero's inline early-mount script and the wasm binding can share
// one stable URL - see `mod yes` in app.rs.
let yes_path = format!("{}/yes.js", leptos_options.site_root);
let fonts_dir = format!("{}/fonts", leptos_options.site_root); let fonts_dir = format!("{}/fonts", leptos_options.site_root);
let app = Router::new() let app = Router::new()
@@ -142,6 +147,8 @@ 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))
// no-cache = "revalidate before reuse", not "don't cache": // no-cache = "revalidate before reuse", not "don't cache":
// pkg files keep the same names across releases (portal.js, // pkg files keep the same names across releases (portal.js,
@@ -167,17 +174,6 @@ async fn main() -> anyhow::Result<()> {
"/swiper-element-bundle.min.js", "/swiper-element-bundle.min.js",
ServeFile::new(swiper_path), ServeFile::new(swiper_path),
) )
// Same skew concern as /pkg: the wasm's raw_module import and
// the hero's inline script both load this by fixed name.
.route_service(
"/yes.js",
tower::ServiceBuilder::new()
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("no-cache"),
))
.service(ServeFile::new(yes_path)),
)
.leptos_routes_with_context( .leptos_routes_with_context(
&state, &state,
routes, routes,
+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.
+195 -97
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;
@@ -105,12 +106,6 @@
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;
}
} }
* { * {
@@ -200,96 +195,65 @@ main.not-found {
max-width: 46ch; max-width: 46ch;
} }
/* 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 {
/* 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%;
/* svh here is only the pre-JS/no-JS fallback (and first paint before
yes.js's constructor runs). Once yes.js mounts, it overwrites this
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 {
position: absolute;
inset: 0;
}
.hero-canvas canvas {
position: absolute;
top: 0;
left: 0;
cursor: pointer;
}
.hero-canvas #rasterCanvas {
mix-blend-mode: overlay;
opacity: 0.5;
}
.hero-yes .hero-copy {
position: relative;
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
landing): a tall drawing surface where the visitor's stroke and
ambient echoes from the relay share the stage. Not sticky like the
YES piece - drawing and scrolling fight over the same finger. */
.hero-gesture .hero-draw {
position: relative; position: relative;
width: 100%; width: 100%;
max-width: 46rem;
/* Same jump-avoidance as .gesture-wrap: the canvas (55svh tall)
only exists after hydration, so hold its height open from the
first paint. Plain-vh fallback first, like the canvas itself. */
min-height: 55vh; min-height: 55vh;
min-height: 55svh; min-height: 55svh;
} }
.hero-gesture .gesture-canvas { /* announcements - live event pages, a strip above the hero copy.
aspect-ratio: auto; Header, not footer: the one place a page claims attention before
/* Plain-vh fallback first, same reasoning as .hero-yes's height. */ the question is asked. */
height: 55vh; .announce {
height: 55svh; width: 100%;
border: none; display: flex;
background: transparent; flex-wrap: wrap;
justify-content: center;
gap: 0.6rem 1.2rem;
margin: -2rem 0 0.5rem;
font-size: 0.88rem;
position: relative;
z-index: 1;
} }
.hero-gesture .gesture-status { .announce-item {
top: auto; display: inline-flex;
bottom: 0.55rem; flex-wrap: wrap;
right: 0.55rem; align-items: baseline;
gap: 0.35rem 0.7rem;
padding: 0.45rem 0.9rem;
border: 0.06rem solid var(--line);
border-left: 0.2rem solid var(--accent);
border-radius: 0.3rem;
color: var(--ink);
text-decoration: none;
background: var(--paper);
transition: border-color 120ms;
}
.announce-item:hover,
.announce-item[aria-current="page"] {
border-color: var(--accent);
}
.announce-name {
font-weight: 600;
}
.announce-when {
color: var(--ink-dim);
}
.announce-relative {
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 0.72rem;
} }
/* alternatives */ /* alternatives */
@@ -466,10 +430,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;
@@ -479,11 +442,105 @@ 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);
}
.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;
@@ -564,10 +621,16 @@ 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;
/* The canvas is created by JS only after wasm hydration - reserve width: 100%;
its 3/2 box now so content below doesn't jump when it appears. }
aspect-ratio is a preferred size, so the box still grows when the
echo thumbnail strip shows up under the canvas. */ /* 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; aspect-ratio: 3 / 2;
} }
@@ -609,6 +672,34 @@ textarea:focus {
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 {
margin: 0.5rem 0 0;
font-size: 0.85rem;
color: var(--ink-dim);
}
.gesture-empty:empty {
display: none;
}
.gesture-status { .gesture-status {
position: absolute; position: absolute;
top: 0.55rem; top: 0.55rem;
@@ -755,6 +846,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);
}