Compare commits

...
67 Commits
Author SHA1 Message Date
Bendik Aagaard LynghaugandClaude Fable 5.1 e4e97a1ada Release 0.3.36
Test / test (push) Successful in 58s
Publish release / publish (push) Successful in 2m6s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-22 16:58:47 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 e67e841f8c Question nav: a followup is offered only once it is earned
The 0.3.34 rule listed every followup "further down" from the current
page, so the front door offered the thank-you pages before anything
was submitted, and a followup one typed in offered the ones beyond
it. The chain half was weaker than it looked too: it matched the
question the chain answered, which is a front door anyway.

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-22 16:58:40 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 366ff51a0a CHANGELOG.md: one line per change, and what each site runs
Test / test (push) Successful in 49s
Forty releases carried only a version bump in their commit, so what
changed lived in the commits between them and in the content repos'
pin bumps. The changelog is reconstructed from that history, newest
first, with an Unreleased section on top and a table of which version
each of the four sites runs. The publish workflow now posts the tag's
section as the Gitea release notes, and the README says to move the
Unreleased lines under a version heading before cutting.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-22 16:51:15 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 e06346e43a tests/jetstream.rs: the store, event log and projection against a real JetStream
Until now nothing outside a live site exercised the NATS side: the
aggregate engine was tested by in-memory replay only. This test
submits an answer, moves it from a desk through the engine, checks
that an undeclared move and an unknown record are refused, and reads
the record back from its own event log. It runs when
PORTAL_TEST_NATS_URL is set; CI starts a throwaway nats:2.14.6-alpine
with JetStream for it and tears it down. Without the variable the
test passes as skipped, so `cargo test` stays green offline.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-22 16:51:15 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 f909657c14 content: split by concern, every path still content::x
content.rs was 2,373 lines doing five jobs. It is now a directory:
mod.rs keeps the types and the resolution rules (router, refs,
reachability, build_questions) and the tests; gitea.rs loads from
Gitea and watches for reload, through crate::http; validate.rs holds
the checks a repo must pass; markdown.rs the inline markdown;
handlers.rs the Axum routes. Each submodule is re-exported from
content, so no caller changes. The loader module is server-only and
gated as such. No behaviour change; 65 unit tests unchanged and green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-22 16:51:15 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 c91bf59946 One bounded HTTP client for every server-side fetch; uploads refused while streaming
Every outbound fetch - Gitea content at boot and on reload, the raw
asset proxy, the repo-info endpoint, the content-declared url resource
- built its own reqwest client with no timeout and read whole bodies
with .text(). `crate::http` is now the one client (5 s connect, 20 s
per request, 5 redirects) with capped body readers: 2 MB for a url
resource, 8 MB for a Gitea reply, 32 MB for a proxied asset; one byte
over is an error, never a truncated document.

The upload handler read a whole field into memory before comparing it
to the 20 MB cap, and axum's 2 MB default body limit had made that cap
unreachable anyway. It now reads chunk by chunk and refuses the moment
the cap is crossed; the /upload route raises the body limit to match.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-22 16:51:15 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 d7f60eaa31 needs: also_moved_by - a need carried by more than one group's desk
Test / test (push) Successful in 27s
Roles overlap on one aggregate: a project manager prices a change, a
site manager completes it. A need may list the other groups whose
desks hold some of its moves; their buttons count toward reaching
`done_when` and toward the strand check exactly as `handled_by`'s do.
`handled_by` must still hold a desk of its own, so that someone is
answerable for the record.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-21 21:46:11 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 6efd7a0c6b needs_replay: make simulated cases real aggregates, report buckets by state
Test / test (push) Successful in 26s
A simulation that lives in JSON never meets the engine. needs_replay
takes a simulation's cases and runs them through the code the site
runs: answers::store_answer for the submission, aggregates::transition
for each desk move, against a real JetStream with its event log and
its refusal of undeclared moves. Buckets are then read back by
replaying each record's events and reported per bucket against
`done_when` and `success`. `--report-only` takes the same scorecard
from any JetStream, a live site's included.

First run, 30 cases from a generated contractor site on a throwaway
in-memory NATS: 30 aggregates, 56 transitions accepted, none refused.
The same routes against the earlier machine that skipped a stage: 8
refused, 8 of 12 enquiries left in `open`.

The local-checkout loaders move from the lint binary to `portal::local`
so both tools share them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-21 19:48:24 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 cbbc1c0da5 needs: stages - every stage the business named must be a state
Test / test (push) Successful in 34s
Found by simulating a generated site: the owner wrote "they come in,
we go and look at the building, we send a quote", and the generated
machine went open -> quoted. Nothing compared the machine to what the
business said, so it passed. A need may now list its stages as the
business describes them; each must be a state of the bucket and
reachable through the handling group's desk.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-21 19:03:42 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 7f90c14554 needs: success states and a resolved simulation model
Test / test (push) Successful in 31s
A need may name which of its `done_when` states are a good outcome
for the business ("lost" is finished, "won" is success), checked to be
a subset of `done_when`.

`question_lint --needs-sim <model.json>` writes everything a
simulation of the business needs, resolved the way the runtime
resolves content: each need's path with the choice on every page and
the form that gets filled, each bucket's state machine with every
desk's labelled buttons, and the personas. Portal exports it because
only portal loads content correctly; the simulator (uhhm/portal-trainer)
reads plain JSON.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-21 18:59:32 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 188f9bf574 Business needs as a checkable spec: needs.yaml + question_lint pass
Test / test (push) Successful in 27s
A content repo may now state what the business needs the site to do,
apart from how the pages word it. question_lint checks each need
against the pages and aggregates.yaml:

- a path of submissions gets the answer into `lands_in`, within
  `max_steps` and a required-field budget (`max_fields`);
- `handled_by` holds a desk over that bucket;
- every `done_when` state is reachable through the moves that desk
  (or a self_transition) offers, and no record strands on the way;
- a disabled alternative is named as the reason when it is one;
- `planned: true` reports the gap as a warning instead of failing.

Personas - visitors in their own words - become typed `choice` tasks
(the page's question, its alternatives as the options) written as
JSONL by `--needs-tasks` (`--skim`: headings only). Any external
answering engine returns `{id, choice, confidence}` lines, graded by
`--needs-score [--min-accuracy]`: a misroute is a wording defect. The
engine stays outside this crate on purpose so classifiers and LLMs
can be compared on the same tasks. The runtime never reads the file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-21 16:29:04 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 75000421ae Release 0.3.35
Test / test (push) Successful in 25s
Publish release / publish (push) Successful in 1m40s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-12 11:50:36 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 9bcf46acd8 Item cards: no paragraph margin under the description
`.item-card` is a grid with a 0.4rem gap; the global `p` bottom
margin (0.9em) stacked on top of it under every description.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-12 11:50:31 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 b24b4f5582 Release 0.3.34
Test / test (push) Successful in 26s
Publish release / publish (push) Successful in 1m42s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-11 19:40:51 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 74bba2e9b1 Question nav: offer followups by reach, not by carrying any chain
`list_qualifying_questions` reduced the visitor's `?chain=` to a
bool, so one followup path unlocked every followup on the site -
redoal's `/watching` (an email-link page whose header says "never in
the nav") showed up for anyone who had just subscribed.

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-02 11:48:11 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 9c22b52fda Release 0.3.32
Test / test (push) Successful in 26s
Publish release / publish (push) Successful in 1m43s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 22:12:20 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 01504fb120 Per-site favicon via site.yaml
A content repo can ship a favicon (svg/png/ico) named in site.yaml;
portal serves it at /site/<path> and injects a <link rel=icon> after
the static defaults so it wins. Also serves .ico assets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 22:12:20 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 b25a5839b0 Release 0.3.31
Test / test (push) Successful in 27s
Publish release / publish (push) Successful in 1m45s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 21:48:25 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 c4609f4370 Markdown links allow tel:
Phone numbers can now be [+47 …](tel:+47…) links in descriptions,
same gate as https/mailto/relative.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 21:48:25 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 2c94ba4379 Release 0.3.30
Test / test (push) Successful in 28s
Publish release / publish (push) Successful in 1m50s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 21:42:20 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 065bd0a86a Single alt-image shows natural height
Was cropped to a 14rem band via object-fit: cover; now shows the whole
image at natural aspect, bounded at 32rem so a tall one can't tower.
The image deck keeps its fixed-height cards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 21:42:20 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 035ad7830b Release 0.3.29
Test / test (push) Successful in 27s
Publish release / publish (push) Successful in 1m45s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 21:36:57 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 bf7f2e11e7 Markdown image layout hints + AVIF assets
Content-only image control: a markdown image title carries layout —
left/right to float text around it, a number for max-width in rem
(validated, capped) — translated to md-float-* classes and a numeric
inline max-width. No arbitrary CSS reaches the page. Portal also now
serves .avif site assets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 21:36:57 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 57b70c8445 Release 0.3.28
Test / test (push) Successful in 27s
Publish release / publish (push) Successful in 1m41s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:31:02 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 0d3221c22f Hero description renders inline markdown
The question-level description (hero subtitle) was plain text, so
**bold** showed literal asterisks. Now it runs through
render_inline_markdown like every other description.

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 17:17:11 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 3b2cdc07b7 Release 0.3.21
Test / test (push) Failing after 29s
Publish release / publish (push) Successful in 1m42s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 17:10:49 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 2de936d995 Hero paragraph centers its measure box
max-width: 46ch without auto margins left-anchored the box inside the
centered column; the text centered in the wrong frame.

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-08-30 23:13:29 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 3de069f41d Release 0.3.15
Test / test (push) Successful in 24s
Publish release / publish (push) Successful in 1m33s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-08-30 23:09:11 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 ab3a649646 Hidden fields: carrier value without a label row
A type: hidden requirement rendered through the fallback branch, whose
label wrapper shows the field name — so a preset key listed as a second
visible field under the email. Bare hidden input, no row.

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:02:41 +02:00
Bendik Aagaard Lynghaug 243b6a5704 README: gesture and voice fields
Test / test (push) Successful in 24s
2026-08-30 16:00:25 +02:00
Bendik Aagaard Lynghaug 3a20870762 Release 0.3.10
Test / test (push) Successful in 24s
Publish release / publish (push) Successful in 1m34s
2026-08-30 15:58:05 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 07776be2bb type: voice, Requirement.value, playable resource cards
A voice requirement records in the browser and ships PCM to the relay
as one binary frame (voice.js, same mount/stop contract as gesture);
its value becomes {key, digest, duration_ms}. Requirement.value
presets a field and, on a dynamic page, takes the URL segment - so
value: "{key}" tells the voice field where to record. A url resource
source takes the segment too. Resource items with an https `audio`
field render an <audio> player. The gesture widget reports picks to
the relay for ranking.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 15:38:23 +02:00
29 changed files with 4138 additions and 915 deletions
+3 -3
View File
@@ -88,9 +88,9 @@ jobs:
tag="${{ github.ref_name }}"
api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
auth="Authorization: token ${{ secrets.GITHUB_TOKEN }}"
subject=$(git log -1 --format=%s)
body=$(printf '{"tag_name":"%s","name":"%s"}' \
"$tag" "$tag: $(echo "$subject" | sed 's/"/\\"/g')")
# The release notes are this version's section of CHANGELOG.md.
notes=$(awk -v v="${tag#v}" '/^## /{p = ($2 == v)} p' CHANGELOG.md | tail -n +2)
body=$(jq -n --arg tag "$tag" --arg notes "$notes" '{tag_name: $tag, name: $tag, body: $notes}')
id=$(curl -sf -X POST -H "$auth" -H 'Content-Type: application/json' \
-d "$body" "$api/releases" | jq .id) \
|| id=$(curl -sf -H "$auth" "$api/releases/tags/$tag" | jq .id)
+17
View File
@@ -22,5 +22,22 @@ jobs:
steps:
- uses: actions/checkout@v4
# A throwaway JetStream for tests/jetstream.rs. Best effort: if the
# runner cannot use docker the variable stays unset and that one
# test reports itself skipped rather than failing the build.
- name: Start a throwaway JetStream
run: |
docker rm -f portal-test-nats >/dev/null 2>&1 || true
if docker run -d --rm --name portal-test-nats -p 127.0.0.1:14223:4222 nats:2.14.6-alpine -js >/dev/null 2>&1; then
sleep 2
echo "PORTAL_TEST_NATS_URL=nats://127.0.0.1:14223" >> "$GITHUB_ENV"
else
echo "::warning::no docker on this runner - the JetStream integration test will be skipped"
fi
- name: Test
run: cargo test --features ssr
- name: Stop the throwaway JetStream
if: always()
run: docker rm -f portal-test-nats >/dev/null 2>&1 || true
+283
View File
@@ -0,0 +1,283 @@
# Changelog
One line per change, grouped by the release that shipped it. Newest
first. A release is a Cargo.toml bump plus a `v<version>` tag; the
content repos pin the tag they run (see the end of this file).
## 0.3.36 (2026-09-22)
- Question nav: a followup is offered only once earned - the verified chain answered the alternative leading there, or a link that records nothing leads on from an earned page. Standing on a page no longer unlocks the pages behind its forms; the chain index now records which alternative was answered
- One bounded HTTP client for every server-side fetch: 20 s budget, size caps per kind of fetch (`src/http.rs`)
- Uploads are refused past 20 MB while streaming, and the route body limit that had capped them at axum's 2 MB default is raised to match
- `tests/jetstream.rs`: the store, the event log and the projection tested against a real JetStream (CI starts a throwaway one)
- `content` split into `gitea`, `validate`, `markdown`, `handlers`; every path still `content::x`
- This changelog, and the table of what each site runs
- Business needs as a checkable spec: needs.yaml + question_lint pass
- needs: `success` states and a resolved simulation model
- needs: `stages` - every stage the business named must be a state
- needs_replay: make simulated cases real aggregates, report buckets by state
- needs: also_moved_by - a need carried by more than one group's desk
## 0.3.35 (2026-09-12)
- Item cards: no paragraph margin under the description
## 0.3.34 (2026-09-11)
- Question nav: offer followups by reach, not by carrying any chain
## 0.3.33 (2026-09-02)
- Head preloads: latin font, wordmark, content-host preconnect
## 0.3.32 (2026-09-01)
- Per-site favicon via site.yaml
## 0.3.31 (2026-09-01)
- Markdown links allow tel:
## 0.3.30 (2026-09-01)
- Single alt-image shows natural height
## 0.3.29 (2026-09-01)
- Markdown image layout hints + AVIF assets
## 0.3.28 (2026-09-01)
- Hero description renders inline markdown
## 0.3.27 (2026-09-01)
- wordmark_invert: explicit, not inferred from .svg
## 0.3.26 (2026-09-01)
- Select fields: inline static options
## 0.3.25 (2026-09-01)
- Content-shipped stylesheet override (site.yaml stylesheet)
## 0.3.24 (2026-09-01)
- Gateway submit-as-link loses the underline
## 0.3.23 (2026-09-01)
- Chrome speaks the site's language: site.yaml lang + i18n table
## 0.3.22 (2026-09-01)
- site.yaml wordmark_height; raster logos keep their colors
## 0.3.21 (2026-09-01)
- Hero paragraph centers its measure box
## 0.3.20 (2026-09-01)
- Sign-in becomes optional: KANIDM_URL unset disables auth cleanly
## 0.3.19 (2026-09-01)
- Markdown images in descriptions, gated and framed
## 0.3.18 (2026-09-01)
- Item cards render inline markdown descriptions
- Convergence: ink converges onto the decoded key (WebGL glow)
- Gesture results overlay the canvas - the page never jumps
## 0.3.16 (2026-08-30)
- Announce sweeper: refresh open records from content
## 0.3.15 (2026-08-30)
- Hidden fields: carrier value without a label row
## 0.3.14 (2026-08-30)
- Feature cards: icon column, one text edge
## 0.3.13 (2026-08-30)
- Voice field: nothing leaves the browser until Keep it here
## 0.3.12 (2026-08-30)
- Voice preview stays hidden until there is something to play
## 0.3.11 (2026-08-30)
- README: gesture and voice fields
- Validate templated actions against the page pattern
## 0.3.10 (2026-08-30)
- type: voice, Requirement.value, playable resource cards
## 0.3.9 (2026-08-30)
- Templated actions; places are pickable in the gesture input
## 0.3.8 (2026-08-30)
- Descriptions are inline markdown; Feature.link withdrawn
## 0.3.7 (2026-08-30)
- Feature.link: a card's name may point elsewhere
## 0.3.6 (2026-08-30)
- Announced pages: event windows, header announcements, summary tasks
## 0.3.5 (2026-08-30)
- Gesture widget: stop drawing the decoded ghost path
- Gesture widget: ghost path back, for v2 keys only
## 0.3.4 (2026-08-25)
- Alternative.disabled: announced but not yet takeable
## 0.3.3 (2026-08-25)
- Empty list says so before it can parse as zero answers
## 0.3.2 (2026-08-25)
- README: hashed pkg assets in the release flow
- Resource empty text: content-declared, and null counts as empty
## 0.3.1 (2026-08-25)
- Content-hashed pkg assets (hash-files) so stale bundles can't pair with new wasm
## 0.3.0 (2026-08-25)
- Hero becomes a content-owned module; site asset proxy; gesture growth fix
## 0.2.4 (2026-08-25)
- Gates keep the question nav
## 0.2.3 (2026-08-25)
- Attended-bucket lint rule; app-lifetime resources fix first-nav corruption
## 0.2.2 (2026-08-24)
- One app-level Title fed by a shared site resource
## 0.2.1 (2026-08-24)
- Docs: routing bullet + design doc marked implemented
- Revalidating cache on app assets; site title survives SPA navigation
## 0.2.0 (2026-08-24)
- Initial commit: content-driven onboarding portal
- Load content from Gitea directly, drop the local clone; add deploy workflow
- Share sccache between manual dev builds and CI
- Give CI its own SCCACHE_SERVER_PORT
- Erase view types at every list/component boundary, not just leaves
- Redirect cargo-leptos's tool cache out of act-runner-bare's StateDirectory
- Fix Ship release: site-root is project-relative, not CARGO_TARGET_DIR
- Drop sudo from the deploy workflow - NoNewPrivileges blocks it outright
- Caddy snippet: use multi-line log block
- Fix static asset 404s and build-time SITE_NAME
- Hot-reload content on a NATS trigger instead of requiring a restart
- Add prosekit rich-text field, Gitea repo embeds, automation KV read endpoint; fix apex/www session-cookie mismatch on /auth/callback
- Fix prosekit-editor.js: remove bare CSS imports that aborted the whole module
- Redirect back to the originating page after sign-in, not always /
- Fix hero-canvas navigation race; style the prosekit editor and add a toolbar
- yes.js: guard against the canvas not being in the DOM yet
- Fix get_resource/transition_answer: scope feature lookup to its own alternative
- Add self-service transitions, authorized by item possession not group membership
- Event-sourced applicant/subscriber/project aggregates, generalized resources
- deploy.yml: write GITEA_API_TOKEN for the new GiteaStarred/OrgRepos resource sources
- Cargo.toml: disambiguate bin-target for cargo-leptos
- Add manually-triggered backfill workflow, no terminal/sudo needed
- Trigger redeploy to pick up PORTAL_GITEA_API_TOKEN secret
- Render generic resource lists as cards, not raw JSON dump
- Fix recursion-limit build failure in the new item-card renderer
- Resource-backed multi/single-select requirement
- Add Organization aggregate (client-as-status), wired but inert
- Fix favicon (real icon, not an unrelated orange circle) + stack encouragements with the submit button
- Fix bucket-404 on empty resources, redesign review actions as select-then-confirm, CSS polish
- Inline encouragements with the submit button, vertically centered
- Use the real institutional logo for the header wordmark, not plain text
- Use the actual UHHM-letters wordmark, not the institutional mark
- Responsive base font size via svmin, not fixed 17px
- Add plain-unit fallbacks for svmin/svh in case of unsupported engines
- Size the YES canvas from its container, not window.innerHeight
- Debounce the YES canvas resize handler and skip no-op resizes
- Revert canvas sizing to window.innerWidth/innerHeight, gate resize on width change
- Stop blocking native scroll on the YES canvas's touch handlers
- Quicksand: font-display optional, not swap, to stop the text-jump on scroll
- Use lvh/lvmin, not svh/svmin - svh was the wrong end of the viewport
- Update style/main.css
- Update style/main.css
- YES canvas: size off its own container rect, not window.innerHeight
- yes.js: freeze .hero-yes's height via inline style, don't trust svh alone
- add responsive container width
- deploy.yml: build and publish question_lint alongside the app binary
- Make aggregate state graphs content-driven, not compiled Rust
- One shared, content-labeled Confirm button per alternative instead of one per row
- get_resource/get_requirement_options: #[server(default)] on params
- Add optional Alternative.image and Feature.color/icon for richer layouts
- Drop the feature list's left-padding
- Drop question-report entirely, obfuscate the mailto link instead
- Transition.from: state graphs deeper than one decision
- Alternative.images: array of urls, rendered as a Swiper card deck
- Reseed lost event history from the KV projection on transition
- Lighten the comment load
- Validate action targets; format timestamps client-side; clippy cleanup
- Question nav + gateway alternatives
- Responsible note under the title; followup pages out of the nav until a chain exists
- README: what the engine provides
- Feature icons: masked span painted by currentColor, not <img>
- Responsible note: one line, same hue and size as the hero description
- Responsible note as <small>: meta information, dimmer than the description
- Responsible note back to the footer, above the question nav
- Bound inputs: a field that loads its value from a sibling-parameterized resource
- add some more width to our alternatives
- Accent to a dusty press-cyan, a muted echo of the canvas's cyan ink
- Editor type in rem: prosekit's px typography ignored the responsive root
- Ban px from stylesheets: every length rem/em off the responsive root
- No translucent input chrome: solid ink-dim placeholders and embed meta
- Editor type scales for real: drop prosekit typography.css, fix selectors
- Stable server fn endpoints: open tabs survive deploys
- Drop stray local tool temp file, ignore .claude/
- Noise-driven YES, sticky hero with frosted cards, full light theme
- increase translucency of laternative cards
- Showcase jq test tracks Gitea repo shape and website-first links
- Gesture input type, redoal-relay client, gitea_releases, content-driven branding
- Echo thumbnails fade in via timeout, not rAF
- Trigger deploy: redoal OAuth2 credentials now configured
- Deploy becomes publish: portal ships as a versioned release artifact
- Semver releases cut with cargo-release; publish on v* tags only
- Docs: release/rollout flow after the instance-ownership refactor
- README: frame portal as a generic multi-site question engine
- Design doc: filesystem routes, sections, dynamic segments
- Filesystem routes, sections, dynamic segments; instant YES hero
## What each site runs
Read from the hosts on 2026-09-22; a rollout is a pin bump in the
site's content repo (`PORTAL_RELEASE` in its `deploy.yml`), or a manual
tarball deploy on kasse.
| Site | Host | Instance | Version |
|---|---|---|---|
| uhhm.no | ergo | `app@uhhm-portal` | v0.3.35 |
| redoal.com | ergo | `app@redoal-portal` | v0.3.35 |
| westra.klingenbergbygg.no | kasse | `app@westra-portal` | v0.3.35 |
| portal.klingenbergbygg.no | kasse | `app@klingenberg-portal` | v0.3.35 |
Generated
+1 -1
View File
@@ -2948,7 +2948,7 @@ dependencies = [
[[package]]
name = "portal"
version = "0.3.8"
version = "0.3.36"
dependencies = [
"anyhow",
"arc-swap",
+10 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "portal"
version = "0.3.8"
version = "0.3.36"
edition = "2021"
[lib]
@@ -106,11 +106,20 @@ ssr = [
[dev-dependencies]
proptest = "1"
[[test]]
name = "jetstream"
required-features = ["ssr"]
[[bin]]
name = "question_lint"
path = "src/bin/question_lint.rs"
required-features = ["ssr"]
[[bin]]
name = "needs_replay"
path = "src/bin/needs_replay.rs"
required-features = ["ssr"]
[profile.wasm-release]
inherits = "release"
opt-level = 'z'
+31 -1
View File
@@ -39,6 +39,16 @@ stream.
`requires_chain` gates a page on verifiable answer provenance next
to `qualifies`' Kanidm-group identity gate (see
`docs/design/filesystem-routes.md`).
- **Gesture and voice fields** (`gesture.js`, `voice.js`): a stroke
becomes a redoal gesture key through a relay (ADR-0013/0015 in the
redoal repo) - the input shows the key's own decode under the
stroke, who is at a similar shape now, and *places* with recordings
the visitor can pick to make that key theirs; a voice field records
in the browser and leaves the audio at a key. Actions may be
templated from the answer (`/shape/{curve.key}`) onto dynamic
pages, whose URL segment also fills `value: "{key}"` presets and
`url` resource sources; resource items with an https `audio` field
render a player.
- **Announced pages** (`src/announce.rs`): a question with an
`event: {starts, duration, place}` window is announced in the
header (not the footer nav) while the window is open, becomes a
@@ -60,7 +70,22 @@ stream.
- **Answer chains** (`src/chain.rs`): each submission hashes its
parent(s), so a visitor's path through the questions is a verifiable
lineage; `?chain=` links carry it, and self-service transitions
(unsubscribe) authorize by holding one.
(unsubscribe) authorize by holding one. The question nav reads it
too: a `followup` page is offered only once the visitor has earned
it - the chain verifiably answered the alternative that leads there,
or it hangs off an earned page by a link that records nothing. A
page behind an answer they have not given is never offered.
- **Business needs as a checkable spec** (`src/needs.rs`,
`question_lint`): a content repo may ship a `needs.yaml` saying who
wants what, which bucket the answer must land in, and which group
has to carry it to a finished state. The lint proves the structure
(a path of submissions within an effort budget, a desk held by that
group, every finished state reachable, no record stranding; a
`planned` need warns instead of failing). Its personas - visitors in
their own words - become typed `choice` tasks for any external
answering engine (`--needs-tasks`, `--skim` for headings only) whose
picks are graded with `--needs-score`: a misroute is a wording
defect. The runtime never reads the file.
- **Live resources** (`src/resource.rs`): content can pull a KV
bucket, Gitea starred/org repos/releases, or any public JSON URL
(SSRF fail-closed), reshaped by a content-declared `jq` filter.
@@ -118,6 +143,11 @@ content-hashed (`hash-files = true`; instances run with
stale cached bundle with new wasm. Plain pushes to `main` only run the
tests (`test.yml`) — nothing reaches production from this repo.
Before cutting, move the `Unreleased` lines in `CHANGELOG.md` under a
heading for the new version. That section becomes the Gitea release
notes, and the table at the end of the file records which version each
site runs; update it when a pin moves.
**Roll it out** (in a content repo): edit one line in that repo's
`.gitea/workflows/deploy.yml` —
+304 -9
View File
@@ -43,12 +43,86 @@ const MAX_ECHOES = 8;
const RECONNECT_BASE_MS = 1000;
const RECONNECT_MAX_MS = 30000;
// ── Convergence animation (ink → what the network heard) ──────────
//
// When the ack lands, a glowing copy of the stroke peels off and
// converges point-by-point onto the key's decoded path: a wave of
// "being understood" travels from the stroke's start to its end,
// and the ghost materializes where the light settles. WebGL point
// sprites with additive glow on a dedicated overlay canvas; the 2d
// layer underneath never changes its final render, so losing WebGL
// (or prefers-reduced-motion) degrades to exactly today's behavior.
const CONVERGE_MS = 1500;
const CONVERGE_FADE_MS = 450;
const CONVERGE_POINTS = 128;
const CONVERGE_STAGGER = 0.6; // fraction of the timeline spent on the travelling wave
const GLOW_VERT = `
attribute vec2 aFrom;
attribute vec2 aTo;
attribute float aIndex;
uniform float uT;
uniform vec2 uResolution;
uniform float uDpr;
varying float vLife;
void main() {
float lead = aIndex * ${CONVERGE_STAGGER};
float p = clamp((uT * ${1.0 + CONVERGE_STAGGER} - lead), 0.0, 1.0);
float e = p < 0.5 ? 4.0 * p * p * p : 1.0 - pow(-2.0 * p + 2.0, 3.0) / 2.0;
vec2 pos = mix(aFrom, aTo, e);
vec2 clip = (pos / uResolution) * 2.0 - 1.0;
gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0);
float pulse = 1.0 + 1.4 * sin(3.14159 * p);
gl_PointSize = 9.0 * uDpr * pulse;
vLife = p;
}`;
const GLOW_FRAG = `
precision mediump float;
uniform vec3 uColor;
uniform float uAlpha;
varying float vLife;
void main() {
vec2 d = gl_PointCoord - 0.5;
float fall = exp(-dot(d, d) * 18.0);
float breathe = 0.55 + 0.45 * sin(3.14159 * vLife);
gl_FragColor = vec4(uColor, 1.0) * (fall * uAlpha * breathe);
}`;
// Uniform arc-length resample of a pixel-space polyline to n points.
function resamplePx(points, n) {
if (points.length === 1) return Array(n).fill(points[0]);
const dists = [0];
for (let i = 1; i < points.length; i++) {
const dx = points[i][0] - points[i - 1][0];
const dy = points[i][1] - points[i - 1][1];
dists.push(dists[i - 1] + Math.hypot(dx, dy));
}
const total = dists[dists.length - 1] || 1;
const out = [];
let seg = 0;
for (let i = 0; i < n; i++) {
const target = (i / (n - 1)) * total;
while (seg < points.length - 2 && dists[seg + 1] < target) seg++;
const span = dists[seg + 1] - dists[seg] || 1;
const t = (target - dists[seg]) / span;
out.push([
points[seg][0] + (points[seg + 1][0] - points[seg][0]) * t,
points[seg][1] + (points[seg + 1][1] - points[seg][1]) * t,
]);
}
return out;
}
class GestureWidget {
constructor(container, hidden, relayUrl) {
this.container = container;
this.hidden = hidden || null;
this.relayUrl = relayUrl || '';
this.ghost = null;
this.ownKey = null;
this.selected = null; // {key, path, distance} - a place the visitor picked
this.seenKeys = new Set();
this.points = [];
this.drawing = false;
this.stopped = false;
@@ -60,9 +134,25 @@ class GestureWidget {
this.canvas = document.createElement('canvas');
this.canvas.className = 'gesture-canvas';
container.appendChild(this.canvas);
// Overlay for the convergence glow; sized with the main canvas,
// inert to pointers, empty until an ack arrives.
this.glow = document.createElement('canvas');
this.glow.className = 'gesture-glow';
this.glow.style.position = 'absolute';
this.glow.style.pointerEvents = 'none';
if (!container.style.position) container.style.position = 'relative';
container.appendChild(this.glow);
this.gl = null;
this.animFrame = null;
this.converging = false;
this.echoes = document.createElement('div');
this.echoes.className = 'gesture-echoes';
this.echoes.setAttribute('role', 'listbox');
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) {
this.status = document.createElement('span');
this.status.className = 'gesture-status offline';
@@ -102,6 +192,13 @@ class GestureWidget {
this.ctx.scale(dpr, dpr);
this.cssWidth = rect.width;
this.cssHeight = rect.height;
this.glow.style.left = `${this.canvas.offsetLeft}px`;
this.glow.style.top = `${this.canvas.offsetTop}px`;
this.glow.style.width = `${rect.width}px`;
this.glow.style.height = `${rect.height}px`;
this.glow.width = this.canvas.width;
this.glow.height = this.canvas.height;
this.cancelConvergence(); // buffers are in old pixels
}
pos(e) {
@@ -115,7 +212,13 @@ class GestureWidget {
// One stroke only - a new pointerdown replaces the old drawing
// (and any ghost from the previous round).
this.drawing = true;
this.cancelConvergence();
this.ghost = null;
this.ownKey = null;
this.selected = null;
this.seenKeys.clear();
this.echoes.replaceChildren();
this.empty.textContent = '';
this.points = [this.pos(e)];
this.render();
}
@@ -214,14 +317,20 @@ class GestureWidget {
return;
}
if (msg.type === 'ack') {
if (this.points.length >= 2) {
this.setValue({ points: this.normalized(), key: msg.key });
}
this.ownKey = msg.key;
// Version nibble is the key's first hex digit.
const version = parseInt((msg.key || '0')[0], 16);
this.ghost = version >= 2 && Array.isArray(msg.path) ? msg.path : null;
this.render();
} else if (msg.type === 'echo') {
this.ownGhost = version >= 2 && Array.isArray(msg.path) ? msg.path : null;
this.applySelection();
// Places arrive right after the ack; say so if none do.
clearTimeout(this.emptyTimer);
this.emptyTimer = setTimeout(() => {
if (!this.echoes.children.length) this.empty.textContent = msg.empty || 'Nothing kept at this shape yet.';
}, 1500);
} else if (msg.type === 'echo' || msg.type === 'place') {
if (!msg.key || this.seenKeys.has(msg.key)) return;
this.seenKeys.add(msg.key);
this.empty.textContent = '';
this.addEchoThumbnail(msg);
}
// 'error' frames are intentionally silent: the widget is a
@@ -229,9 +338,56 @@ class GestureWidget {
// alarm anyone mid-form.
}
// The key the input carries: the visitor's own, or the place they
// picked (selection re-derives the key - the ghost under the
// stroke becomes that place's decode, and the answer records both).
applySelection() {
if (this.points.length < 2) return;
const points = this.normalized();
const before = this.ghost;
if (this.selected) {
this.ghost = this.selected.path;
this.setValue({ points, key: this.selected.key, own_key: this.ownKey, selected_from: this.ownKey, selected_distance: this.selected.distance });
} else {
this.ghost = this.ownGhost || null;
this.setValue({ points, key: this.ownKey });
}
if (this.ghost && this.ghost !== before) this.startConvergence(this.ghost);
for (const el of this.echoes.children) {
el.classList.toggle('selected', !!this.selected && el.dataset.key === this.selected.key);
el.setAttribute('aria-selected', String(!!this.selected && el.dataset.key === this.selected.key));
}
this.render();
}
select(thumb) {
const key = thumb.dataset.key;
if (this.selected && this.selected.key === key) {
this.selected = null;
} else {
this.selected = { key, path: JSON.parse(thumb.dataset.path), distance: Number(thumb.dataset.distance) };
// The relay counts picks per place for ranking.
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.ownKey) {
this.ws.send(JSON.stringify({ type: 'select', key, from: this.ownKey, distance: this.selected.distance }));
}
}
this.applySelection();
}
addEchoThumbnail(msg) {
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
// relay's default threshold, so everything stays visible.
const strength = Math.max(0.35, Math.min(1, 1 - msg.distance / 500));
@@ -253,6 +409,140 @@ class GestureWidget {
setTimeout(() => thumb.classList.add('shown'), 30);
}
// ── Convergence ────────────────────────────────────────────────
initGlow() {
if (this.gl) return this.gl;
const gl = this.glow.getContext('webgl', { alpha: true, premultipliedAlpha: true });
if (!gl) return null;
const compile = (type, src) => {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
return s;
};
const prog = gl.createProgram();
gl.attachShader(prog, compile(gl.VERTEX_SHADER, GLOW_VERT));
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, GLOW_FRAG));
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return null;
gl.useProgram(prog);
this.gl = gl;
this.glProg = prog;
this.glLoc = {
aFrom: gl.getAttribLocation(prog, 'aFrom'),
aTo: gl.getAttribLocation(prog, 'aTo'),
aIndex: gl.getAttribLocation(prog, 'aIndex'),
uT: gl.getUniformLocation(prog, 'uT'),
uResolution: gl.getUniformLocation(prog, 'uResolution'),
uDpr: gl.getUniformLocation(prog, 'uDpr'),
uColor: gl.getUniformLocation(prog, 'uColor'),
uAlpha: gl.getUniformLocation(prog, 'uAlpha'),
};
this.glBufFrom = gl.createBuffer();
this.glBufTo = gl.createBuffer();
this.glBufIndex = gl.createBuffer();
return gl;
}
// The ghost's pixel frame - same fit drawPath uses.
ghostToPx(path) {
const scale = (Math.min(this.cssWidth, this.cssHeight) / 2) * 0.8;
const cx = this.cssWidth / 2, cy = this.cssHeight / 2;
return path.map(([x, y]) => [cx + x * scale, cy + y * scale]);
}
startConvergence(targetPath) {
this.cancelConvergence();
if (!targetPath || this.points.length < 2) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const gl = this.initGlow();
if (!gl) return;
const dpr = window.devicePixelRatio || 1;
const from = resamplePx(this.points.map((p) => [p.x, p.y]), CONVERGE_POINTS);
const to = resamplePx(this.ghostToPx(targetPath), CONVERGE_POINTS);
const f = new Float32Array(CONVERGE_POINTS * 2);
const t = new Float32Array(CONVERGE_POINTS * 2);
const idx = new Float32Array(CONVERGE_POINTS);
for (let i = 0; i < CONVERGE_POINTS; i++) {
f[i * 2] = from[i][0] * dpr;
f[i * 2 + 1] = from[i][1] * dpr;
t[i * 2] = to[i][0] * dpr;
t[i * 2 + 1] = to[i][1] * dpr;
idx[i] = i / (CONVERGE_POINTS - 1);
}
const bind = (buf, data, loc, size) => {
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, size, gl.FLOAT, false, 0, 0);
};
gl.useProgram(this.glProg);
bind(this.glBufFrom, f, this.glLoc.aFrom, 2);
bind(this.glBufTo, t, this.glLoc.aTo, 2);
bind(this.glBufIndex, idx, this.glLoc.aIndex, 1);
gl.viewport(0, 0, this.glow.width, this.glow.height);
gl.uniform2f(this.glLoc.uResolution, this.glow.width, this.glow.height);
gl.uniform1f(this.glLoc.uDpr, dpr);
const light = this._themeQuery.matches;
const color = light ? [0x47 / 255, 0x80 / 255, 0x7e / 255] : [0x8e / 255, 0xc2 / 255, 0xc0 / 255];
gl.uniform3f(this.glLoc.uColor, color[0], color[1], color[2]);
// Additive light on the dark ground; normal ink on the light one.
gl.enable(gl.BLEND);
if (light) gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
else gl.blendFunc(gl.ONE, gl.ONE);
this.converging = true;
this.render();
const started = performance.now();
const step = (now) => {
const raw = (now - started) / CONVERGE_MS;
if (raw >= 1) {
// Deposit the ghost, then let the glow breathe out.
if (this.converging) {
this.converging = false;
this.render();
}
const fade = (now - started - CONVERGE_MS) / CONVERGE_FADE_MS;
if (fade >= 1) {
this.cancelConvergence();
return;
}
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform1f(this.glLoc.uT, 1);
gl.uniform1f(this.glLoc.uAlpha, 0.32 * (1 - fade));
gl.drawArrays(gl.POINTS, 0, CONVERGE_POINTS);
} else {
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
// Trailing passes give the wave a comet tail.
for (const [lag, alpha] of [[0, 0.32], [0.035, 0.16], [0.07, 0.08], [0.105, 0.04]]) {
gl.uniform1f(this.glLoc.uT, Math.max(0, raw - lag));
gl.uniform1f(this.glLoc.uAlpha, alpha);
gl.drawArrays(gl.POINTS, 0, CONVERGE_POINTS);
}
}
this.animFrame = requestAnimationFrame(step);
};
this.animFrame = requestAnimationFrame(step);
}
cancelConvergence() {
if (this.animFrame) cancelAnimationFrame(this.animFrame);
this.animFrame = null;
if (this.converging) {
this.converging = false;
this.render();
}
if (this.gl) {
this.gl.clearColor(0, 0, 0, 0);
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
}
}
// ── Rendering ──────────────────────────────────────────────────
// Draw a normalized (-1..1) path fitted into w×h with padding.
@@ -277,7 +567,7 @@ class GestureWidget {
if (!this.ctx) return;
const t = this.theme();
this.ctx.clearRect(0, 0, this.cssWidth, this.cssHeight);
if (this.ghost) {
if (this.ghost && !this.converging) {
this.drawPath(this.ctx, this.ghost, this.cssWidth, this.cssHeight, t.ghost, 2, 0.2);
}
if (this.points.length >= 1) {
@@ -296,6 +586,8 @@ class GestureWidget {
stop() {
this.stopped = true;
if (this.animFrame) cancelAnimationFrame(this.animFrame);
clearTimeout(this.emptyTimer);
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
if (this.ws) {
// The close event still fires, but scheduleReconnect
@@ -313,5 +605,8 @@ class GestureWidget {
}
export function mountGesture(container, hidden, relayUrl) {
return new GestureWidget(container, hidden, relayUrl);
// Dev convenience: `?relay=ws://127.0.0.1:9040` points every widget
// on the page at a local relay (prod relays reject foreign Origins).
const override = new URLSearchParams(window.location.search).get('relay');
return new GestureWidget(container, hidden, override || relayUrl);
}
+12
View File
@@ -92,6 +92,18 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
Some(a) => a,
};
// Content is the source of truth while the window is still
// open: a corrected date or place flows into the record, so the
// followup fires on the schedule the page actually announces.
if answer.state == schema.initial && answer.responses != responses {
let mut refreshed = answer.clone();
refreshed.responses = responses.clone();
if let Some(store) = &store {
store.put(&id, serde_json::to_vec(&refreshed)?.into()).await?;
tracing::info!(question = %q.id, "event record refreshed from content");
}
}
if answer.state == schema.initial && now_ms >= ends {
let target = "awaiting_summary";
let payload = serde_json::json!({ "to": target, "item": id, "by": "portal" });
+239 -42
View File
@@ -1,5 +1,5 @@
use leptos::prelude::*;
use leptos_meta::{provide_meta_context, HashedStylesheet, MetaTags, Title};
use leptos_meta::{provide_meta_context, HashedStylesheet, Html, Link, MetaTags, Stylesheet, Title};
use leptos_router::{
components::{Route, Router, Routes},
hooks::{use_location, use_navigate, use_query_map},
@@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize};
use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswers, TransitionItem};
use crate::auth::{current_user, User};
use crate::content::{is_qualified, render_inline_markdown, Alternative, Question, Responsible, SiteConfig, Transition};
use crate::i18n::t;
use crate::resource::{get_requirement_binding, get_requirement_options, get_resource};
/// The visible site name/wordmark - "portal" is just this codebase's
@@ -39,6 +40,20 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
// 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: dark)" href="/favicon-dark.svg" type="image/svg+xml"/>
// Fonts hide behind the stylesheet: the browser must
// fetch and parse CSS before it discovers them. The
// latin subset is on every page's critical path, so
// announce it up front; latin-ext and vietnamese stay
// unicode-range-gated and load only when script needs
// them - preloading those would tax everyone for a few.
// (Font preloads require crossorigin even same-origin.)
<link
rel="preload"
href="/fonts/quicksand-semibold-latin.woff2"
r#as="font"
type="font/woff2"
crossorigin="anonymous"
/>
// Server-side, so it can read hash.txt and link the
// content-hashed stylesheet (hash-files = true).
<HashedStylesheet id="leptos" options=options.clone()/>
@@ -68,8 +83,54 @@ pub fn App() -> impl IntoView {
// context instead of fetching their own copy.
let site = Resource::new(|| (), |_| get_site());
provide_context(site);
// The chrome's language, from site.yaml (default "en"); content
// speaks for itself. Components read this via context for t().
let lang = Memo::new(move |_| {
site.get()
.and_then(|r| r.ok())
.and_then(|s| s.lang)
.unwrap_or_else(|| "en".to_string())
});
provide_context(Lang(lang));
// A content-shipped stylesheet (site.yaml `stylesheet:`) layered
// after the default one - leptos_meta appends it at the MetaTags
// slot, which the shell places after HashedStylesheet, so content
// rules win at equal specificity.
let custom_css = Memo::new(move |_| {
site.get()
.and_then(|r| r.ok())
.and_then(|s| s.stylesheet)
.map(|p| format!("/site/{p}"))
});
// The wordmark paints in the hero on first view; announcing it in
// the head starts the fetch during hydration instead of after. On
// redoal.com it lives on the content host, so a preconnect opens
// that TLS session while the preload is still queued.
let wordmark_href = Memo::new(move |_| {
site.get().and_then(|r| r.ok()).and_then(|s| s.wordmark)
});
let wordmark_origin = Memo::new(move |_| {
wordmark_href.get().and_then(|w| {
let rest = w.strip_prefix("https://")?;
Some(format!("https://{}", rest.split('/').next()?))
})
});
// A content-shipped favicon overrides the built-in one; injected
// into the head after the static defaults, so it wins.
let favicon = Memo::new(move |_| {
site.get()
.and_then(|r| r.ok())
.and_then(|s| s.favicon)
.map(|p| format!("/site/{p}"))
});
view! {
<Html attr:lang=move || lang.get()/>
{move || wordmark_origin.get().map(|href| view! { <Link rel="preconnect" href=href crossorigin="anonymous"/> })}
{move || wordmark_href.get().map(|href| view! { <Link rel="preload" as_="image" href=href/> })}
{move || custom_css.get().map(|href| view! { <Stylesheet id="site-custom" href=href/> })}
{move || favicon.get().map(|href| view! { <Link rel="icon" href=href/> })}
<Suspense fallback=|| ()>
{move || {
site.get()
@@ -88,6 +149,15 @@ pub fn App() -> impl IntoView {
}
}
/// The site's chrome language as a context signal - see App.
#[derive(Clone, Copy)]
pub struct Lang(pub Memo<String>);
/// The current chrome language, for `t()` calls inside views.
fn lang() -> Memo<String> {
expect_context::<Lang>().0
}
/// 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
@@ -143,7 +213,10 @@ fn PortalShell() -> impl IntoView {
|(path, chain)| get_question(path, chain),
);
let user = Resource::new(|| (), |_| current_user());
let nav = Resource::new(move || chain.get().is_some(), list_qualifying_questions);
let nav = Resource::new(
move || (path.get(), chain.get()),
|(path, chain)| list_qualifying_questions(path, chain),
);
let announcements = Resource::new(|| (), |_| list_announcements());
provide_context(QuestionRes(question));
provide_context(UserRes(user));
@@ -219,7 +292,6 @@ fn QuestionView(
site: SiteConfig,
) -> impl IntoView {
let question_id = question.id.clone();
let has_chain = parent_hash.is_some();
// The provenance counterpart to the qualifies gate below: a
// requires_chain page whose visitor holds no verifiable lineage to
@@ -241,14 +313,14 @@ fn QuestionView(
/>
<div class="alternatives">
<section class="alt-card gate-card">
<p>"This page follows from an answer you don't seem to carry yet."</p>
<p>{move || t(&lang().get(), "follows_answer")}</p>
<a class="alt-submit" href=target>
{label}
</a>
</section>
</div>
// A gate is never a dead end: the same nav as every page.
<QuestionNav current_id=question_id has_chain=has_chain/>
<QuestionNav current_id=question_id/>
}
.into_any();
}
@@ -269,7 +341,7 @@ fn QuestionView(
<div class="alternatives">
<section class="alt-card gate-card">
{if signed_in {
view! { <p>"This part of the site is for organizational owners — sign in with that account to take a look."</p> }
view! { <p>{move || t(&lang().get(), "owners_only")}</p> }
.into_any()
} else {
view! {
@@ -278,14 +350,14 @@ fn QuestionView(
href=format!("/auth/login?redirect={question_id}")
rel="external"
>
"Sign in"
{move || t(&lang().get(), "sign_in")}
</a>
}
.into_any()
}}
</section>
</div>
<QuestionNav current_id=question_id has_chain=has_chain/>
<QuestionNav current_id=question_id/>
}
.into_any();
}
@@ -348,7 +420,7 @@ fn QuestionView(
.map(|r| {
view! { <ResponsibleNote responsible=r/> }
})}
<QuestionNav current_id=question_id has_chain=has_chain/>
<QuestionNav current_id=question_id/>
}
.into_any()
}
@@ -357,8 +429,7 @@ fn QuestionView(
/// small nav - how a concern reaches the people it speaks to without
/// claiming front-page space (an owner also sees the gated desks here).
#[component]
fn QuestionNav(current_id: String, has_chain: bool) -> impl IntoView {
let _ = has_chain; // keyed into the shared resource by PortalShell
fn QuestionNav(current_id: String) -> impl IntoView {
let NavRes(nav) = expect_context();
view! {
<Suspense fallback=|| ()>
@@ -371,7 +442,7 @@ fn QuestionNav(current_id: String, has_chain: bool) -> impl IntoView {
.collect();
(!others.is_empty()).then(|| view! {
<nav class="question-nav">
<span class="question-nav-lead">"Also worth asking"</span>
<span class="question-nav-lead">{move || t(&lang().get(), "also_worth_asking")}</span>
<For
each=move || others.clone()
key=|(id, _)| id.clone()
@@ -399,7 +470,7 @@ fn Announcements(current_id: String) -> impl IntoView {
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">
<nav class="announce" aria-label=move || t(&lang().get(), "announcements")>
<For
each=move || items.clone()
key=|a| a.id.clone()
@@ -459,11 +530,11 @@ fn ResponsibleNote(responsible: Responsible) -> impl IntoView {
view! {
<small class="question-responsible">
"Asked by " {responsible.name.clone()} " — "
{move || t(&lang().get(), "asked_by")} {responsible.name.clone()} " — "
<a data-user=user data-domain=domain on:mouseover=assemble on:click=assemble>
"contact them"
{move || t(&lang().get(), "contact_them")}
</a>
" if you get stuck."
{move || t(&lang().get(), "if_stuck")}
</small>
}
}
@@ -498,6 +569,28 @@ mod prosekit {
// prosekit's hidden-input bridge for the value, yes.js's typed-handle
// lifecycle for cleanup - it may own a live WebSocket to a
// 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")]
mod gesture {
use wasm_bindgen::prelude::*;
@@ -533,7 +626,17 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig, cur
None
};
let has_module = module.is_some();
// Light theme can invert a white-stroke wordmark to ink. Default:
// only the built-in house mark inverts; a content-shipped logo
// keeps its colours unless site.yaml opts in with wordmark_invert.
let house_wordmark = site.wordmark_invert.unwrap_or(site.wordmark.is_none());
let wordmark = site.wordmark.clone().unwrap_or_else(|| "/wordmark.svg".to_string());
// Clamped server-side too, but belt and braces for the inline style.
let wordmark_style = site
.wordmark_height
.filter(|h| (0.5..=6.0).contains(h))
.map(|h| format!("height: {h}rem"))
.unwrap_or_default();
let site_title = site.title.clone().unwrap_or_else(|| SITE_NAME.to_string());
let piece_ref: NodeRef<leptos::html::Div> = NodeRef::new();
@@ -619,11 +722,11 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig, cur
}
})}
<div class="hero-copy">
<a class="wordmark" href="/">
<img src=wordmark alt=site_title/>
<a class="wordmark" class:wordmark-house=house_wordmark href="/">
<img src=wordmark alt=site_title style=wordmark_style/>
</a>
<h1>{title}</h1>
<p>{description}</p>
<p inner_html=render_inline_markdown(&description)></p>
</div>
</header>
}
@@ -705,7 +808,7 @@ fn AlternativeCard(
</button>
}
.into_any(),
Some(Ok(())) => view! { <p>"Done."</p> }.into_any(),
Some(Ok(())) => view! { <p>{move || t(&lang().get(), "done")}</p> }.into_any(),
Some(Err(e)) => view! { <p class="resource-error">{e.to_string()}</p> }.into_any(),
}
}}
@@ -766,12 +869,16 @@ fn AlternativeCard(
.or_insert_with(|| RwSignal::new(Vec::new()));
select_multi.insert(req.name.clone(), req.multiple);
} 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());
}
// 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
.entry(req.name.clone())
.or_insert_with(|| RwSignal::new(String::new()));
.or_insert_with(|| RwSignal::new(preset));
}
}
}
@@ -896,7 +1003,7 @@ fn AlternativeCard(
.consequence
.first()
.cloned()
.unwrap_or_else(|| "Send".to_string());
.unwrap_or_else(|| t(&lang().get(), "send").to_string());
view! {
<section class="alt-card">
@@ -1138,6 +1245,54 @@ fn AlternativeCard(
.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" {
let container_ref: NodeRef<leptos::html::Div> = NodeRef::new();
let hidden_ref: NodeRef<leptos::html::Input> = NodeRef::new();
@@ -1170,6 +1325,20 @@ fn AlternativeCard(
.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! {
<label class="field" for=label_for>
{label_text}
@@ -1324,7 +1493,7 @@ fn ResourceFeature(
{move || {
let feature_name = feature_name.clone();
let transitions = transitions.clone();
let empty = empty.clone().unwrap_or_else(|| "Nothing here yet.".to_string());
let empty = empty.clone().unwrap_or_else(|| t(&lang().get(), "nothing_here_yet").to_string());
data.get()
.map(|res| match res {
Ok(value) => {
@@ -1454,8 +1623,11 @@ fn ItemCard(item: serde_json::Value) -> impl IntoView {
let name = text_field(&obj, &["name", "title"]);
let description = text_field(&obj, &["description"]);
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
.iter()
.filter(|(k, v)| !known.contains(&k.as_str()) && !v.is_null())
@@ -1494,7 +1666,9 @@ fn ItemCard(item: serde_json::Value) -> impl IntoView {
</div>
}
})}
{description.map(|d| view! { <p class="item-card-description">{d}</p> })}
{description
.map(|d| view! { <p class="item-card-description" inner_html=render_inline_markdown(&d)></p> })}
{audio.map(|src| view! { <audio class="item-card-audio" controls preload="none" src=src></audio> })}
{(!extra.is_empty())
.then(|| {
view! {
@@ -1788,8 +1962,8 @@ fn format_ms(ms: i64) -> String {
fn NotFound() -> impl IntoView {
view! {
<main class="not-found">
<h1>"Nothing here"</h1>
<a href="/">"back to the start"</a>
<h1>{move || t(&lang().get(), "nothing_here")}</h1>
<a href="/">{move || t(&lang().get(), "back_to_start")}</a>
</main>
}
}
@@ -1856,16 +2030,22 @@ pub async fn get_site() -> Result<SiteConfig, ServerFnError> {
Ok(state.site.load().as_ref().clone())
}
/// Every question the current visitor qualifies for, as (id, name) -
/// the site nav's data. Context-dependent: an owner session sees the
/// gated pages too, and `followup` pages only appear once the visitor
/// carries an answer chain.
/// Every question the visitor may be offered from `current`, as (id,
/// name) - the question nav's data. Front-door pages the visitor
/// qualifies for are always in (an owner session sees the gated desks
/// too). A `followup` page or an ended event is in only when the
/// visitor has earned it: their verified `?chain=` answered the
/// alternative that leads there, or it is reachable from an earned
/// page over links that record nothing (see `content::earned_followups`).
/// Nothing is offered that sits behind an answer they have not given;
/// an unverifiable hash unlocks nothing.
#[server(endpoint = "list_qualifying_questions")]
pub async fn list_qualifying_questions(
has_chain: bool,
current: String,
chain: Option<String>,
) -> Result<Vec<(String, String)>, ServerFnError> {
use crate::auth::{User, SESSION_USER_KEY};
use crate::content::is_qualified;
use crate::content::{earned_followups, is_qualified, unlocked_by, Question};
use crate::server::AppState;
let state = expect_context::<AppState>();
@@ -1876,14 +2056,24 @@ pub async fn list_qualifying_questions(
.map_err(|e| ServerFnError::new(e.to_string()))?;
let now_ms = chrono::Utc::now().timestamp_millis();
let mut out: Vec<(String, String)> = state
.questions
.load()
let questions = state.questions.load();
// What the chain verifiably answered - the same lookup a
// `requires_chain` gate does, so a forged or foreign hash resolves
// to nothing - and therefore where it sent the visitor.
let seeds: Vec<String> = match &chain {
Some(hash) => match crate::chain::lookup_node(&state.jetstream, hash).await {
Some(node) => unlocked_by(&questions, &node.question_id, node.alternative.as_deref()),
None => Vec::new(),
},
None => Vec::new(),
};
// An ended event page is a followup from then on.
let is_outcome = |q: &Question| q.is_followup() || q.event_ended_at(now_ms);
let earned = earned_followups(&questions, &current, &seeds, &is_outcome);
let mut out: Vec<(String, String)> = questions
.values()
.filter(|q| is_qualified(user.as_ref(), q))
// 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)
.filter(|q| !is_outcome(q) || earned.contains(&q.id))
// 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.
@@ -1999,11 +2189,18 @@ pub async fn submit_answer(
if alt.disabled {
return Err(ServerFnError::new("this alternative isn't open yet"));
}
let next = alt.action.clone();
let record_as = alt.record_as.clone();
let responses: serde_json::Value = serde_json::from_str(&responses_json)
.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 timestamp_ms = chrono::Utc::now().timestamp_millis();
@@ -2026,7 +2223,7 @@ pub async fn submit_answer(
// Index the node so requires_chain pages can verify lineage.
// Best-effort: the NATS event above is the durable record.
if let Err(e) =
crate::chain::record_node(&state.jetstream, &chain_hash, &question_id, timestamp_ms).await
crate::chain::record_node(&state.jetstream, &chain_hash, &question_id, &alternative, timestamp_ms).await
{
tracing::error!("failed to index chain node: {e}");
}
+107
View File
@@ -0,0 +1,107 @@
//! Makes a simulation's cases real: every case becomes an aggregate in
//! its bucket, created and moved by the same code the running site
//! uses - `answers::store_answer` for the submission,
//! `aggregates::transition` for each desk move, against a real
//! JetStream with its event log and its refusal of undeclared moves.
//! Then the buckets are read back by replaying each record's events,
//! and reported per need against `done_when` and `success`.
//!
//! The report half runs on its own (`--report-only`) against any
//! JetStream, which is how the same scorecard is taken from a live
//! site's buckets.
//!
//! Point it at a throwaway NATS, never a site's own: it writes.
#![cfg(feature = "ssr")]
use std::collections::{BTreeMap, HashMap};
use futures::StreamExt;
use portal::events::store::{ensure_stream, load_events};
use portal::local::{load_aggregates_from_dir, load_from_dir, load_needs_from_dir};
use portal::{aggregates, answers, content, needs};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let argv: Vec<String> = std::env::args().skip(1).collect();
let value = |flag: &str| argv.iter().position(|a| a == flag).and_then(|i| argv.get(i + 1)).cloned();
let usage = "usage: needs_replay --path <questions dir> --nats <url> (--cases <cases.jsonl> | --report-only)";
let dir = value("--path").ok_or_else(|| anyhow::anyhow!(usage))?;
let nats_url = value("--nats").ok_or_else(|| anyhow::anyhow!(usage))?;
let cases_path = value("--cases");
if cases_path.is_none() && !argv.iter().any(|a| a == "--report-only") {
anyhow::bail!(usage);
}
let questions = load_from_dir(&dir)?;
let schemas = content::with_builtin_aggregates(load_aggregates_from_dir(&dir)?);
let file = needs::parse(&load_needs_from_dir(&dir)?.ok_or_else(|| anyhow::anyhow!("the repo has no needs.yaml"))?)?;
let js = async_nats::jetstream::new(async_nats::connect(&nats_url).await?);
// The same call the site makes at boot.
ensure_stream(&js).await?;
if let Some(path) = cases_path {
let raw = std::fs::read_to_string(&path).map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?;
let (mut created, mut moved, mut refused) = (0, 0, 0);
for (n, line) in raw.lines().filter(|l| !l.trim().is_empty()).enumerate() {
let case: serde_json::Value = serde_json::from_str(line)?;
let Some(need) = file.needs.iter().find(|need| case["need"] == need.id.as_str()) else { continue };
let Some(schema) = schemas.get(&need.lands_in) else { continue };
// Only a case that got as far as submitting leaves a record.
let states: Vec<&str> = case["states"].as_array().into_iter().flatten().filter_map(|s| s.as_str()).collect();
if states.is_empty() {
continue;
}
let step = needs::path_for(&questions, need).and_then(|p| p.last().cloned());
let (page, alternative) = step.map(|s| (s.page, s.alternative)).unwrap_or_default();
let id = format!("sim-{}-{}-{n}", case["persona"].as_str().unwrap_or("x"), case["run"]);
let now = chrono::Utc::now().timestamp_millis();
answers::store_answer(&js, &schemas, &need.lands_in, id.clone(), &page, &alternative, &case["record"], now).await?;
created += 1;
for target in &states[1..] {
match aggregates::transition(&js, schema, &id, target, serde_json::json!({"by": "simulation"}), now).await {
Ok(_) => moved += 1,
Err(e) => {
refused += 1;
eprintln!("REFUSED by the engine: {id} -> {target}: {e:?}");
break;
}
}
}
}
println!("{created} aggregate(s) created, {moved} transition(s) accepted, {refused} refused by the engine\n");
}
// Read every bucket back the way the engine knows it: by replaying
// each record's own event log.
let mut by_bucket: HashMap<String, BTreeMap<String, usize>> = HashMap::new();
for bucket in file.needs.iter().map(|n| n.lands_in.clone()).collect::<std::collections::BTreeSet<_>>() {
let (Some(schema), Ok(store)) = (schemas.get(&bucket), js.get_key_value(&bucket).await) else { continue };
let mut keys = store.keys().await?;
while let Some(Ok(id)) = keys.next().await {
let events = load_events(&js, &bucket, &id).await?;
if let Some(record) = aggregates::replay(schema, &id, &events) {
*by_bucket.entry(bucket.clone()).or_default().entry(record.state).or_default() += 1;
}
}
}
println!("{:<18} {:>8} {:>9} {:>9} {}", "bucket", "records", "finished", "success", "by state");
let mut buckets: Vec<_> = by_bucket.iter().collect();
buckets.sort();
for (bucket, states) in buckets {
let needs_here: Vec<&needs::Need> = file.needs.iter().filter(|n| &n.lands_in == bucket).collect();
let is = |pick: fn(&needs::Need) -> &Vec<String>, state: &String| needs_here.iter().any(|n| pick(n).contains(state));
let total: usize = states.values().sum();
let finished: usize = states.iter().filter(|(s, _)| is(|n| &n.done_when, s)).map(|(_, c)| c).sum();
let success: usize = states.iter().filter(|(s, _)| is(|n| &n.success, s)).map(|(_, c)| c).sum();
let pct = |n: usize| if total == 0 { "-".to_string() } else { format!("{:.0}%", n as f64 * 100.0 / total as f64) };
println!(
"{:<18} {:>8} {:>9} {:>9} {}",
bucket,
total,
pct(finished),
pct(success),
states.iter().map(|(s, c)| format!("{s} {c}")).collect::<Vec<_>>().join(", ")
);
}
Ok(())
}
+144 -76
View File
@@ -9,7 +9,8 @@
//! static binary," not "build a Rust workspace."
#![cfg(feature = "ssr")]
use portal::{aggregates, content};
use portal::local::{load_aggregates_from_dir, load_from_dir, load_needs_from_dir, load_site_from_dir};
use portal::{aggregates, content, needs};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
@@ -18,6 +19,11 @@ async fn main() -> anyhow::Result<()> {
let mut branch = "main".to_string();
let mut subdir = "questions".to_string();
let mut path: Option<String> = None;
let mut tasks_out: Option<String> = None;
let mut answers_in: Option<String> = None;
let mut min_accuracy: f64 = 0.0;
let mut skim = false;
let mut sim_out: Option<String> = None;
while let Some(arg) = args.next() {
match arg.as_str() {
@@ -25,6 +31,13 @@ async fn main() -> anyhow::Result<()> {
"--branch" => branch = args.next().unwrap_or(branch),
"--subdir" => subdir = args.next().unwrap_or(subdir),
"--path" => path = args.next(),
"--needs-tasks" => tasks_out = args.next(),
"--skim" => skim = true,
"--needs-sim" => sim_out = args.next(),
"--needs-score" => answers_in = args.next(),
"--min-accuracy" => {
min_accuracy = args.next().and_then(|v| v.parse().ok()).unwrap_or(min_accuracy)
}
other => {
eprintln!("unknown argument: {other}");
std::process::exit(2);
@@ -32,18 +45,24 @@ async fn main() -> anyhow::Result<()> {
}
}
let (questions, aggregates_map, site) = if let Some(dir) = path {
(load_from_dir(&dir)?, load_aggregates_from_dir(&dir)?, load_site_from_dir(&dir)?)
let (questions, aggregates_map, site, needs_raw) = if let Some(dir) = path {
(
load_from_dir(&dir)?,
load_aggregates_from_dir(&dir)?,
load_site_from_dir(&dir)?,
load_needs_from_dir(&dir)?,
)
} else if let Some(repo_url) = repo {
let questions = content::load_questions_from_gitea(&repo_url, &branch, &subdir).await?;
let aggregates_map = content::load_aggregates_from_gitea(&repo_url, &branch).await?;
// load_site_from_gitea already validates; a missing file is
// the default config, same as at portal boot.
let site = content::load_site_from_gitea(&repo_url, &branch).await?;
(questions, aggregates_map, site)
let needs_raw = content::load_needs_from_gitea(&repo_url, &branch).await?;
(questions, aggregates_map, site, needs_raw)
} else {
eprintln!(
"usage: question-lint --repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>"
"usage: question-lint (--repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>) [--needs-tasks <out.jsonl> [--skim]] [--needs-sim <model.json>] [--needs-score <answers.jsonl> [--min-accuracy 0.8]]"
);
std::process::exit(2);
};
@@ -72,7 +91,16 @@ async fn main() -> anyhow::Result<()> {
questions.len(),
aggregates_map.len()
);
Ok(())
check_needs(
needs_raw.as_deref(),
&questions,
&aggregates_map,
tasks_out.as_deref(),
skim,
sim_out.as_deref(),
answers_in.as_deref(),
min_accuracy,
)
}
Err(e) => {
eprintln!("FAIL: {e}");
@@ -81,78 +109,118 @@ async fn main() -> anyhow::Result<()> {
}
}
/// The offline counterpart to `content::load_site_from_gitea` -
/// `site.yaml` lives at the repo root like `aggregates.yaml`, and is
/// just as optional locally: missing means default branding.
fn load_site_from_dir(dir: &str) -> anyhow::Result<content::SiteConfig> {
let site_path = std::path::Path::new(dir)
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join("site.yaml");
if !site_path.exists() {
return Ok(content::SiteConfig::default());
}
let raw = std::fs::read_to_string(&site_path)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", site_path.display()))?;
serde_yaml::from_str(&raw).map_err(|e| anyhow::anyhow!("parsing {}: {e}", site_path.display()))
}
/// The offline counterpart to `content::load_aggregates_from_gitea` -
/// `aggregates.yaml` lives at the repo root, one level up from the
/// pages directory `--path` names, so `dir`'s parent is where it's
/// looked for. Missing entirely is not an error here (unlike
/// `--repo` mode, where `aggregates.yaml` is required) - a local
/// checkout being linted may not have one, and every declared
/// transition still gets checked against whatever *is* found; an
/// empty map just means nothing is checked.
fn load_aggregates_from_dir(
dir: &str,
) -> anyhow::Result<std::collections::HashMap<String, aggregates::AggregateSchema>> {
let aggregates_path = std::path::Path::new(dir)
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join("aggregates.yaml");
if !aggregates_path.exists() {
return Ok(std::collections::HashMap::new());
}
let raw = std::fs::read_to_string(&aggregates_path)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", aggregates_path.display()))?;
aggregates::parse_aggregates_yaml(&raw)
}
/// The offline counterpart to `content::load_questions_from_gitea` -
/// the same recursive tree walk and `content::build_questions`
/// pipeline (derived ids, relative refs, sections, followup
/// inference), just reading a local checkout instead of Gitea's API,
/// for linting a branch that hasn't been pushed yet.
fn load_from_dir(
dir: &str,
) -> anyhow::Result<std::collections::HashMap<String, content::Question>> {
let base = std::path::Path::new(dir);
let mut files = Vec::new();
collect_yaml(base, base, &mut files)?;
content::build_questions(&files)
}
fn collect_yaml(
base: &std::path::Path,
dir: &std::path::Path,
out: &mut Vec<(String, String)>,
/// The business-needs pass (see `portal::needs`): structure always,
/// then the wording tasks written out and/or an engine's answers
/// scored, when asked. A repo without `needs.yaml` skips all of it.
fn check_needs(
raw: Option<&str>,
questions: &std::collections::HashMap<String, content::Question>,
aggregates_map: &std::collections::HashMap<String, aggregates::AggregateSchema>,
tasks_out: Option<&str>,
skim: bool,
sim_out: Option<&str>,
answers_in: Option<&str>,
min_accuracy: f64,
) -> anyhow::Result<()> {
for entry in std::fs::read_dir(dir)? {
let path = entry?.path();
if path.is_dir() {
collect_yaml(base, &path, out)?;
} else if path.extension().and_then(|e| e.to_str()) == Some("yaml") {
let rel = path
.strip_prefix(base)
.expect("walked paths sit under their base")
.to_string_lossy()
.replace('\\', "/");
let raw = std::fs::read_to_string(&path)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", path.display()))?;
out.push((rel, raw));
let Some(raw) = raw else {
if tasks_out.is_some() || answers_in.is_some() {
eprintln!("FAIL: --needs-tasks/--needs-score given, but the repo has no needs.yaml");
std::process::exit(1);
}
return Ok(());
};
let file = match needs::parse(raw) {
Ok(file) => file,
Err(e) => {
eprintln!("FAIL: {e}");
std::process::exit(1);
}
};
let findings = needs::check(&file, questions, aggregates_map);
let mut failed = 0;
for finding in &findings {
let level = match finding.level {
needs::Level::Fail => {
failed += 1;
"FAIL"
}
needs::Level::Warn => "WARN",
};
eprintln!("{level}: need {:?}: {}", finding.need, finding.message);
}
for need in &file.needs {
if let Some(path) = needs::path_for(questions, need) {
let steps: Vec<String> = path
.iter()
.map(|s| format!("{} [{}]", s.page, s.alternative))
.collect();
println!("need {:?}: {} -> {:?}", need.id, steps.join(" -> "), need.lands_in);
}
}
if failed == 0 {
let planned = file.needs.iter().filter(|n| n.planned).count();
println!(
"OK: {} need(s) structurally met, {} planned, {} persona(s)",
file.needs.len() - planned,
planned,
file.personas.len()
);
}
// The wording pass still runs for the needs that do have a path -
// a closed flow elsewhere is no reason to stop grading the rest.
if let Some(out) = sim_out {
let model = needs::sim_model(&file, questions, aggregates_map);
std::fs::write(out, serde_json::to_string_pretty(&model)?)
.map_err(|e| anyhow::anyhow!("writing {out}: {e}"))?;
println!("wrote the simulation model to {out}");
}
let tasks = needs::tasks(&file, questions, skim);
if let Some(out) = tasks_out {
let mut lines = String::new();
for task in &tasks {
lines.push_str(&serde_json::to_string(task)?);
lines.push('\n');
}
std::fs::write(out, lines).map_err(|e| anyhow::anyhow!("writing {out}: {e}"))?;
println!("wrote {} wording task(s) to {out}", tasks.len());
}
if let Some(path) = answers_in {
let raw = std::fs::read_to_string(path).map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?;
let mut answers = Vec::new();
for (n, line) in raw.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) {
answers.push(
serde_json::from_str::<needs::Answer>(line)
.map_err(|e| anyhow::anyhow!("{path}:{}: {e}", n + 1))?,
);
}
let score = needs::score(&tasks, &answers, 0.6);
for (id, picked, expected, confidence) in &score.misroutes {
eprintln!(
"MISROUTE: {id}: picked {picked:?}{}, the need is met by {expected:?}",
confidence.map(|c| format!(" ({c:.2})")).unwrap_or_default()
);
}
for (id, confidence) in &score.hesitant {
eprintln!("HESITANT: {id}: right, at {confidence:.2}");
}
for id in &score.unanswered {
eprintln!("UNANSWERED: {id}");
}
println!(
"wording: {}/{} routed right ({:.0}%)",
score.correct,
score.total,
score.accuracy() * 100.0
);
if score.accuracy() < min_accuracy {
eprintln!("FAIL: below --min-accuracy {min_accuracy}");
std::process::exit(1);
}
}
if failed > 0 {
eprintln!("FAIL: {failed} unmet business need finding(s)");
std::process::exit(1);
}
Ok(())
}
+7
View File
@@ -20,6 +20,11 @@ pub const CHAIN_BUCKET: &str = "portal_chains";
#[derive(serde::Serialize, serde::Deserialize)]
pub struct ChainNode {
pub question_id: String,
/// Which alternative was answered - what decides where the visitor
/// was sent next, and so which followup the chain has earned. Nodes
/// indexed before this field existed carry `None`.
#[serde(default)]
pub alternative: Option<String>,
pub timestamp_ms: i64,
}
@@ -30,6 +35,7 @@ pub async fn record_node(
js: &async_nats::jetstream::Context,
chain_hash: &str,
question_id: &str,
alternative: &str,
timestamp_ms: i64,
) -> anyhow::Result<()> {
let store = match js.get_key_value(CHAIN_BUCKET).await {
@@ -44,6 +50,7 @@ pub async fn record_node(
};
let node = ChainNode {
question_id: question_id.to_string(),
alternative: Some(alternative.to_string()),
timestamp_ms,
};
store.put(chain_hash, serde_json::to_vec(&node)?.into()).await?;
+252
View File
@@ -0,0 +1,252 @@
//! Loading a content repo from Gitea, and the NATS-signalled reload
//! that hot-swaps it (last-good content is kept on any failure). Every
//! fetch goes through `crate::http`: bounded in time and size.
use super::*;
/// Fetches `needs.yaml` from the content repo root - the business's
/// needs spec `question_lint` checks the pages against (see
/// `crate::needs`). Optional: a fetch failure means the repo declares
/// none. The runtime never reads it.
#[cfg(feature = "ssr")]
pub async fn load_needs_from_gitea(repo_url: &str, branch: &str) -> anyhow::Result<Option<String>> {
let (owner, repo) = parse_owner_repo(repo_url)?;
let api_base = gitea_api_base(repo_url)?;
Ok(fetch_gitea_file(&api_base, &owner, &repo, branch, "needs.yaml")
.await
.ok())
}
/// Fetches and parses `site.yaml` from the content repo root. A fetch
/// failure (typically 404 - the file is optional) yields the default
/// config; a file that exists but doesn't parse or validate is a real
/// error, surfaced at boot rather than papered over.
#[cfg(feature = "ssr")]
pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Result<SiteConfig> {
let (owner, repo) = parse_owner_repo(repo_url)?;
let api_base = gitea_api_base(repo_url)?;
let raw = match fetch_gitea_file(&api_base, &owner, &repo, branch, "site.yaml").await {
Ok(raw) => raw,
Err(e) => {
tracing::info!("no site.yaml in content repo ({e}), using default branding");
return Ok(SiteConfig::default());
}
};
let site: SiteConfig =
serde_yaml::from_str(&raw).map_err(|e| anyhow::anyhow!("parsing site.yaml: {e}"))?;
site.validate()?;
Ok(site)
}
/// Extracts `scheme://host` from a repo's normal browser URL - the
/// 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")]
pub fn gitea_api_base(repo_url: &str) -> anyhow::Result<String> {
let parsed = url::Url::parse(repo_url)
.map_err(|e| anyhow::anyhow!("parsing repo url {repo_url}: {e}"))?;
Ok(format!(
"{}://{}",
parsed.scheme(),
parsed
.host_str()
.ok_or_else(|| anyhow::anyhow!("no host in repo url {repo_url}"))?
))
}
/// Splits a repo's normal browser URL into `(owner, repo)` - shared by
/// every loader in this module that needs to build a Gitea contents API
/// URL (`load_questions_from_gitea`, `load_aggregates_from_gitea`).
#[cfg(feature = "ssr")]
pub(crate) fn parse_owner_repo(repo_url: &str) -> anyhow::Result<(String, String)> {
let parsed = url::Url::parse(repo_url)
.map_err(|e| anyhow::anyhow!("parsing content repo url {repo_url}: {e}"))?;
let mut segments = parsed
.path_segments()
.ok_or_else(|| anyhow::anyhow!("no path in content repo url {repo_url}"))?;
let owner = segments
.next()
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("missing owner in content repo url {repo_url}"))?
.to_string();
let repo = segments
.next()
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("missing repo name in content repo url {repo_url}"))?
.to_string();
Ok((owner, repo))
}
/// Fetches one file from a Gitea repo's contents API and returns its raw
/// text - the single-file counterpart to `load_questions_from_gitea`'s
/// directory-listing loop, used by `load_aggregates_from_gitea` for the
/// one `aggregates.yaml` file at the repo root.
#[cfg(feature = "ssr")]
pub(crate) async fn fetch_gitea_file(
api_base: &str,
owner: &str,
repo: &str,
branch: &str,
path: &str,
) -> anyhow::Result<String> {
let meta_url = format!("{api_base}/api/v1/repos/{owner}/{repo}/contents/{path}?ref={branch}");
let meta_text = crate::http::get_text(&meta_url, crate::http::GITEA_CAP)
.await
.map_err(|e| anyhow::anyhow!("fetching {meta_url}: {e}"))?;
let meta: serde_json::Value = serde_json::from_str(&meta_text)
.map_err(|e| anyhow::anyhow!("parsing contents response from {meta_url}: {e}"))?;
let download_url = meta
.get("download_url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("no download_url for {path}"))?;
crate::http::get_text(download_url, crate::http::GITEA_CAP)
.await
.map_err(|e| anyhow::anyhow!("fetching {path}: {e}"))
}
/// Fetches and parses `aggregates.yaml` from the repo root (sibling to
/// the pages `subdir`, so the "every yaml under subdir is a page"
/// convention needs no exclusion). A malformed file fails here, before
/// ever reaching `AppState`.
#[cfg(feature = "ssr")]
pub async fn load_aggregates_from_gitea(
repo_url: &str,
branch: &str,
) -> anyhow::Result<std::collections::HashMap<String, crate::aggregates::AggregateSchema>> {
let (owner, repo) = parse_owner_repo(repo_url)?;
let api_base = gitea_api_base(repo_url)?;
let raw = fetch_gitea_file(&api_base, &owner, &repo, branch, "aggregates.yaml").await?;
crate::aggregates::parse_aggregates_yaml(&raw)
}
#[cfg(feature = "ssr")]
pub async fn load_questions_from_gitea(
repo_url: &str,
branch: &str,
subdir: &str,
) -> anyhow::Result<std::collections::HashMap<String, Question>> {
let (owner, repo) = parse_owner_repo(repo_url)?;
let api_base = gitea_api_base(repo_url)?;
// One recursive git-trees call for the whole repo instead of a
// contents listing per directory - the tree IS the router now, so
// nested files matter.
let tree_url =
format!("{api_base}/api/v1/repos/{owner}/{repo}/git/trees/{branch}?recursive=true");
let listing = crate::http::get_text(&tree_url, crate::http::GITEA_CAP)
.await
.map_err(|e| anyhow::anyhow!("listing {tree_url}: {e}"))?;
let tree: serde_json::Value = serde_json::from_str(&listing)
.map_err(|e| anyhow::anyhow!("parsing tree from {tree_url}: {e}"))?;
if tree.get("truncated").and_then(|v| v.as_bool()) == Some(true) {
anyhow::bail!("git tree listing for {owner}/{repo} was truncated - repo too large");
}
let prefix = format!("{subdir}/");
let mut files = Vec::new();
for entry in tree
.get("tree")
.and_then(|v| v.as_array())
.map(|v| v.as_slice())
.unwrap_or_default()
{
let path = entry.get("path").and_then(|v| v.as_str()).unwrap_or("");
if entry.get("type").and_then(|v| v.as_str()) != Some("blob")
|| !path.starts_with(&prefix)
|| !path.ends_with(".yaml")
{
continue;
}
// Brackets (dynamic pages like `[record].yaml`) must be
// percent-encoded in the raw URL's path.
let encoded = path.replace('[', "%5B").replace(']', "%5D");
let raw_url = format!("{api_base}/api/v1/repos/{owner}/{repo}/raw/{encoded}?ref={branch}");
let raw = crate::http::get_text(&raw_url, crate::http::GITEA_CAP)
.await
.map_err(|e| anyhow::anyhow!("fetching {path}: {e}"))?;
files.push((path[prefix.len()..].to_string(), raw));
}
build_questions(&files)
}
/// Published by the content repo's own CI (after it lints a push) to
/// tell every running instance to pick up the change - a plain fire
/// and forget NATS publish, no payload, matching `events.rs`'s
/// `ANSWERS_SUBJECT` pattern.
#[cfg(feature = "ssr")]
pub const CONTENT_RELOAD_SUBJECT: &str = "portal.content.reload";
/// Runs for the life of the process: re-fetches `repo_url`/`branch` and
/// atomically swaps both `questions` and `aggregates` on every
/// `CONTENT_RELOAD_SUBJECT` message - the two only ever swap together,
/// after both have loaded and validated successfully, so a reader never
/// sees pages that reference a half-updated state graph. A fetch/parse
/// failure logs and keeps serving the last-good content rather than
/// clearing it - a bad push to the content repo (which should already
/// have been caught by its own lint step) doesn't take the site down.
#[cfg(feature = "ssr")]
pub async fn watch_for_reload(
nats: async_nats::Client,
repo_url: String,
branch: String,
subdir: String,
questions: std::sync::Arc<arc_swap::ArcSwap<std::collections::HashMap<String, Question>>>,
aggregates: std::sync::Arc<
arc_swap::ArcSwap<std::collections::HashMap<String, crate::aggregates::AggregateSchema>>,
>,
site: std::sync::Arc<arc_swap::ArcSwap<SiteConfig>>,
) {
let mut sub = match nats.subscribe(CONTENT_RELOAD_SUBJECT).await {
Ok(sub) => sub,
Err(e) => {
tracing::error!(error = %e, "failed to subscribe to content reload subject");
return;
}
};
use futures::StreamExt;
while sub.next().await.is_some() {
let loaded_aggregates = match load_aggregates_from_gitea(&repo_url, &branch).await {
Ok(loaded) => with_builtin_aggregates(loaded),
Err(e) => {
tracing::error!(error = %e, "aggregates.yaml reload failed, keeping last-good content");
continue;
}
};
// Branding swaps with the same all-or-nothing rule: a
// present-but-broken site.yaml keeps last-good everything
// (load_site distinguishes "absent" - a normal default - from
// "present but invalid").
let loaded_site = match load_site_from_gitea(&repo_url, &branch).await {
Ok(loaded) => loaded,
Err(e) => {
tracing::error!(error = %e, "site.yaml reload failed, keeping last-good content");
continue;
}
};
match load_questions_from_gitea(&repo_url, &branch, &subdir).await {
Ok(loaded) => {
if let Err(e) = validate_questions(&loaded, &loaded_aggregates) {
tracing::error!(error = %e, "content reload failed validation, keeping last-good content");
continue;
}
let count = loaded.len();
questions.store(std::sync::Arc::new(loaded));
aggregates.store(std::sync::Arc::new(loaded_aggregates));
site.store(std::sync::Arc::new(loaded_site));
tracing::info!(count, "reloaded content");
}
Err(e) => {
tracing::error!(error = %e, "content reload failed, keeping last-good content");
}
}
}
}
+193
View File
@@ -0,0 +1,193 @@
//! The Axum routes that expose content and buckets outside the Leptos
//! app: content-shipped assets proxied same-origin, repo info for the
//! showcase, and the automation read API over KV buckets.
use super::*;
/// 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 public Gitea repo's basic info - what `resolve_gitea_repo` returns
/// for the prosekit editor's repo-embed node to render as a static
/// card, baked in once at embed time rather than re-fetched by every
/// reader (an emailed newsletter can't run JS to do that anyway).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GiteaRepoInfo {
pub owner: String,
pub repo: String,
pub description: String,
pub url: String,
}
#[derive(Deserialize)]
#[cfg(feature = "ssr")]
pub struct GiteaRepoQuery {
pub owner: 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 resp = match crate::http::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 crate::http::bytes(resp, crate::http::ASSET_CAP).await {
Ok(b) => axum::body::Bytes::from(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("avif") => "image/avif",
Some("ico") => "image/x-icon",
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
/// from (`AppState.gitea_base`) - a raw Axum handler (mounted at
/// `/gitea-repo` in `main.rs`), not a Leptos server fn, since the
/// caller here is the prosekit editor's own paste-to-embed rule (see
/// `prosekit-editor.js`) doing a plain `fetch`, the same reason
/// `/upload` (`src/upload.rs`) is a raw handler rather than a `#[server]`
/// fn. Keeping this server-resolved (rather than having the browser
/// call Gitea's API directly) is consistent with every other backing
/// store in this app, and sidesteps needing a CORS allowance on Gitea's
/// side just for this. No auth, same as content loading - resolves
/// only what's already public.
#[cfg(feature = "ssr")]
pub async fn gitea_repo_handler(
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
axum::extract::Query(query): axum::extract::Query<GiteaRepoQuery>,
) -> Result<axum::Json<GiteaRepoInfo>, (axum::http::StatusCode, String)> {
let is_safe_segment = |s: &str| {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
};
if !is_safe_segment(&query.owner) || !is_safe_segment(&query.repo) {
return Err((axum::http::StatusCode::BAD_REQUEST, "invalid owner/repo".to_string()));
}
let GiteaRepoQuery { owner, repo } = query;
let api_url = format!("{}/api/v1/repos/{owner}/{repo}", state.gitea_base);
let body = crate::http::get_text(&api_url, crate::http::GITEA_CAP)
.await
.map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, format!("fetching {api_url}: {e}")))?;
let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
(
axum::http::StatusCode::BAD_GATEWAY,
format!("parsing repo info from {api_url}: {e}"),
)
})?;
Ok(axum::Json(GiteaRepoInfo {
owner: owner.clone(),
repo: repo.clone(),
description: json
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
url: json
.get("html_url")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("{}/{owner}/{repo}", state.gitea_base)),
}))
}
/// Lists every entry in a NATS KV bucket as JSON - a raw Axum handler
/// (mounted at `/automation/kv/{bucket}` in `main.rs`), for backing
/// automations (e.g. an n8n workflow reading `portal_subscribers` to
/// send a newsletter) that aren't a signed-in browser session and so
/// can't go through `resource::get_resource`'s Kanidm-group check.
/// Gated by a single shared bearer token (`AUTOMATION_READ_TOKEN`) -
/// deliberately not per-caller/per-bucket scoped, since every current
/// caller is a trusted internal automation, not a third party. Read
/// only, matching `get_resource`'s own "reads can be public/shared,
/// mutations always need real identity" split - nothing here writes.
#[cfg(feature = "ssr")]
pub async fn automation_kv_handler(
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
axum::extract::Path(bucket): axum::extract::Path<String>,
headers: axum::http::HeaderMap,
) -> Result<axum::Json<serde_json::Value>, (axum::http::StatusCode, String)> {
let expected = std::env::var("AUTOMATION_READ_TOKEN").unwrap_or_default();
let presented = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.unwrap_or("");
if expected.is_empty() || presented != expected {
return Err((axum::http::StatusCode::UNAUTHORIZED, "unauthorized".to_string()));
}
let store = state
.jetstream
.get_key_value(&bucket)
.await
.map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, format!("bucket unavailable: {e}")))?;
use futures::TryStreamExt;
let keys: Vec<String> = store
.keys()
.await
.map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, e.to_string()))?
.try_collect()
.await
.map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, e.to_string()))?;
let mut items = Vec::new();
for key in keys {
if let Ok(Some(bytes)) = store.get(&key).await {
if let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) {
items.push(value);
}
}
}
Ok(axum::Json(serde_json::Value::Array(items)))
}
+120
View File
@@ -0,0 +1,120 @@
//! Inline markdown for descriptions: links on a short allow-list of
//! schemes, images gated and framed, with layout hints read from the
//! image title. Never raw HTML.
/// 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 mut in_dropped_image = 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("tel:")
|| dest_url.starts_with('/')) =>
{
in_link += 1;
None
}
Event::End(TagEnd::Link) if in_link > 0 => {
in_link -= 1;
None
}
// Images take the same gate as links: https or same-origin
// only - no data:, no plain http. Unlike a dropped link (whose
// label is real text worth keeping), a dropped image's alt
// text is a caption for something that isn't there - swallow it.
Event::Start(Tag::Image { dest_url, .. })
if !(dest_url.starts_with("https://") || dest_url.starts_with('/')) =>
{
in_dropped_image += 1;
None
}
Event::End(TagEnd::Image) if in_dropped_image > 0 => {
in_dropped_image -= 1;
None
}
Event::Text(_) if in_dropped_image > 0 => None,
other => Some(other),
});
let mut out = String::new();
html::push_html(&mut out, filtered);
apply_image_hints(out.trim())
}
/// Translate a markdown image's title (`![alt](url "right 9rem")`)
/// into layout: `left`/`right` float via a class, a bare number
/// (optionally with `rem`) into a validated `max-width`. Unknown
/// tokens are ignored; the title attribute is dropped either way.
/// Only touches `<img>` tags in our own render output, and only ever
/// emits a numeric max-width - no arbitrary CSS reaches the page.
pub(crate) fn apply_image_hints(html: &str) -> String {
let mut out = String::new();
let mut rest = html;
while let Some(pos) = rest.find("<img ") {
out.push_str(&rest[..pos]);
let after = &rest[pos..];
let end = after.find('>').map(|e| e + 1).unwrap_or(after.len());
out.push_str(&rewrite_img_tag(&after[..end]));
rest = &after[end..];
}
out.push_str(rest);
out
}
pub(crate) fn rewrite_img_tag(tag: &str) -> String {
// Pull the title value, if any.
let title = tag
.find("title=\"")
.map(|i| &tag[i + 7..])
.and_then(|r| r.find('"').map(|e| &r[..e]))
.unwrap_or("");
let mut class = String::new();
let mut max_rem: Option<f32> = None;
for tok in title.split_whitespace() {
match tok {
"left" => class = "md-float-left".into(),
"right" => class = "md-float-right".into(),
other => {
if let Ok(n) = other.trim_end_matches("rem").parse::<f32>() {
if n > 0.0 && n <= 60.0 {
max_rem = Some(n);
}
}
}
}
}
// Strip the title attribute from the tag.
let mut cleaned = tag.to_string();
if let Some(i) = cleaned.find(" title=\"") {
if let Some(e) = cleaned[i + 8..].find('"') {
cleaned.replace_range(i..i + 8 + e + 1, "");
}
}
// Inject class/style right after `<img`.
let mut attrs = String::new();
if !class.is_empty() {
attrs.push_str(&format!(" class=\"{class}\""));
}
if let Some(n) = max_rem {
attrs.push_str(&format!(" style=\"max-width:{n}rem\""));
}
if attrs.is_empty() {
return cleaned;
}
cleaned.replacen("<img", &format!("<img{attrs}"), 1)
}
File diff suppressed because it is too large Load Diff
+290
View File
@@ -0,0 +1,290 @@
//! What a content repo must satisfy before it serves: every action
//! lands on a page, every transition is one the state machine allows,
//! every recorded bucket is read back somewhere, and the built-in
//! aggregates the runtime owns are always present.
#[allow(unused_imports)] // the browser build sees fewer items
use super::*;
/// A relay must be a ws:// or wss:// URL - shared between site.yaml's
/// hero and `Requirement.relay` validation.
#[cfg(feature = "ssr")]
pub fn validate_relay_url(relay: &str) -> anyhow::Result<()> {
let parsed = url::Url::parse(relay)
.map_err(|e| anyhow::anyhow!("relay {relay:?} is not a valid URL: {e}"))?;
if !matches!(parsed.scheme(), "ws" | "wss") {
anyhow::bail!("relay {relay:?} must use the ws:// or wss:// scheme");
}
Ok(())
}
/// Validates every declared transition target (`SelfTransition.to`,
/// `ResourceSpec.transitions[].to`) against `aggregates` - the real
/// state graph loaded from `aggregates.yaml` - for every bucket that
/// has one declared. A bucket with no entry in `aggregates` is left
/// alone entirely (no validation applied): not every bucket needs to
/// be event-sourced to keep working. Called on every content
/// load/reload (`watch_for_reload`, `main.rs`'s boot path) and by the
/// standalone `question-lint` binary, so a YAML typo becomes a caught,
/// logged rejection instead of a silently-accepted, later-broken
/// 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")]
pub fn validate_questions(
questions: &std::collections::HashMap<String, Question>,
aggregates: &std::collections::HashMap<String, crate::aggregates::AggregateSchema>,
) -> anyhow::Result<()> {
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 !questions.contains_key(target) {
anyhow::bail!(
"question {:?}: requires_chain {:?} does not match any declared question id",
question.id,
target
);
}
}
for alternative in &question.alternatives {
// A dangling action is a literal dead end: the submit
// button navigates to "Nothing here".
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) {
None => anyhow::bail!(
"question {:?} alternative {:?}: action {:?} does not match any declared question id",
question.id, alternative.name, action
),
// A dynamic page needs a concrete segment value to
// be a URL - a submit button can't supply one.
Some(target) if target.is_dynamic() => anyhow::bail!(
"question {:?} alternative {:?}: action {:?} targets a dynamic page - actions must name a concrete question",
question.id, alternative.name, action
),
Some(_) => {}
}
}
if let Some(st) = &alternative.self_transition {
if let Some(schema) = aggregates.get(&st.bucket) {
if !schema.has_state(&st.to) {
anyhow::bail!(
"question {:?} alternative {:?}: self_transition.to {:?} is not a declared state for bucket {:?}",
question.id, alternative.name, st.to, st.bucket
);
}
}
}
// Bind targets resolve across the whole alternative, same
// scope as the signal maps the renderer builds.
let sibling_names: std::collections::HashSet<&str> = alternative
.features
.iter()
.flat_map(|f| f.requirements.iter())
.map(|r| r.name.as_str())
.collect();
for feature in &alternative.features {
for requirement in &feature.requirements {
if requirement.kind == "select"
&& requirement.resource.is_none()
&& requirement.options.is_empty()
{
anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: select but declares neither a resource nor inline options",
question.id, alternative.name, feature.name, requirement.name
);
}
if let Some(relay) = &requirement.relay {
if requirement.kind != "gesture" && requirement.kind != "voice" {
anyhow::bail!(
"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
);
}
validate_relay_url(relay).map_err(|e| anyhow::anyhow!(
"question {:?} alternative {:?} feature {:?}: requirement {:?}: {e}",
question.id, alternative.name, feature.name, requirement.name
))?;
}
if requirement.kind == "gesture"
&& (requirement.resource.is_some() || requirement.bind.is_some())
{
anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: gesture - it draws its value, it can't also load one from a resource or bind",
question.id, alternative.name, feature.name, requirement.name
);
}
if let Some(bind) = &requirement.bind {
if bind.field == requirement.name
|| !sibling_names.contains(bind.field.as_str())
{
anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: requirement {:?} binds to {:?}, which is not another requirement on this alternative",
question.id, alternative.name, feature.name, requirement.name, bind.field
);
}
}
}
let Some(resource) = &feature.resource else {
continue;
};
let Some(bucket) = resource.bucket() else {
continue;
};
let Some(schema) = aggregates.get(bucket) else {
continue;
};
for transition in &resource.transitions {
if !schema.has_state(&transition.from) {
anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: transition.from {:?} is not a declared state for bucket {:?}",
question.id, alternative.name, feature.name, transition.from, bucket
);
}
if !schema.has_state(&transition.to) {
anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: transition.to {:?} is not a declared state for bucket {:?}",
question.id, alternative.name, feature.name, transition.to, bucket
);
}
if !schema.allowed(&transition.from).iter().any(|s| s == &transition.to) {
anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: transition {:?} -> {:?} is not a declared edge for bucket {:?}",
question.id, alternative.name, feature.name, transition.from, transition.to, bucket
);
}
}
}
}
}
// 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(())
}
+64
View File
@@ -0,0 +1,64 @@
//! The one outbound HTTP client, bounded. Every server-side fetch -
//! Gitea content at boot and on reload, the raw-asset proxy, the
//! repo-info endpoint, and the content-declared `url` resource - goes
//! through here, so a slow or oversized upstream can cost a request at
//! most `TIMEOUT` and `cap` bytes, never a thread or the heap. Before
//! this module each call site built its own client with no timeout and
//! read whole bodies with `.text()`.
#![cfg(feature = "ssr")]
use std::sync::OnceLock;
use std::time::Duration;
use openidconnect::reqwest;
/// Whole-request budget: connect, headers, and body.
pub const TIMEOUT: Duration = Duration::from_secs(20);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
/// A content-declared URL resource: JSON for one card.
pub const URL_RESOURCE_CAP: usize = 2 * 1024 * 1024;
/// A Gitea API reply or one content file (a tree listing, a YAML page).
pub const GITEA_CAP: usize = 8 * 1024 * 1024;
/// A content-shipped asset proxied to the browser (an image, a font).
pub const ASSET_CAP: usize = 32 * 1024 * 1024;
pub fn client() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::Client::builder()
.connect_timeout(CONNECT_TIMEOUT)
.timeout(TIMEOUT)
.redirect(reqwest::redirect::Policy::limited(5))
.user_agent(concat!("portal/", env!("CARGO_PKG_VERSION")))
.build()
.expect("a client with static options always builds")
})
}
/// Reads a body up to `cap` bytes; one byte more is an error, not a
/// truncation, so a caller never parses half a document.
pub async fn bytes(mut response: reqwest::Response, cap: usize) -> anyhow::Result<Vec<u8>> {
let hint = response.content_length().unwrap_or(0) as usize;
if hint > cap {
anyhow::bail!("response of {hint} bytes exceeds the cap of {cap}");
}
let mut out = Vec::with_capacity(hint.min(cap));
while let Some(chunk) = response.chunk().await? {
if out.len() + chunk.len() > cap {
anyhow::bail!("response exceeds the cap of {cap} bytes");
}
out.extend_from_slice(&chunk);
}
Ok(out)
}
pub async fn text(response: reqwest::Response, cap: usize) -> anyhow::Result<String> {
Ok(String::from_utf8(bytes(response, cap).await?)?)
}
/// `GET url`, status checked, body read under `cap`.
pub async fn get_text(url: &str, cap: usize) -> anyhow::Result<String> {
let response = client().get(url).send().await?.error_for_status()?;
text(response, cap).await
}
+97
View File
@@ -0,0 +1,97 @@
//! Chrome-string translations, selected by `site.yaml`'s `lang`.
//!
//! Content carries its own language; this covers only the strings the
//! portal itself speaks around it ("Asked by", the nav lead, the
//! not-found page...). Unknown languages and unknown keys fall back
//! to English, so a typo degrades to the default instead of a blank.
/// Translate `key` for `lang`. `en` is the reference table; every
/// other language falls through to it for keys it doesn't carry.
pub fn t(lang: &str, key: &str) -> &'static str {
if let Some(s) = lookup(lang, key) {
return s;
}
lookup("en", key).unwrap_or(key_missing(key))
}
fn lookup(lang: &str, key: &str) -> Option<&'static str> {
Some(match (lang, key) {
("en", "also_worth_asking") => "Also worth asking",
("en", "asked_by") => "Asked by ",
("en", "contact_them") => "contact them",
("en", "if_stuck") => " if you get stuck.",
("en", "send") => "Send",
("en", "sign_in") => "Sign in",
("en", "nothing_here_yet") => "Nothing here yet.",
("en", "nothing_here") => "Nothing here",
("en", "back_to_start") => "back to the start",
("en", "done") => "Done.",
("en", "follows_answer") => {
"This page follows from an answer you don't seem to carry yet."
}
("en", "owners_only") => {
"This part of the site is for organizational owners — sign in with that account to take a look."
}
("en", "announcements") => "Announcements",
("no", "also_worth_asking") => "Også verdt å spørre",
("no", "asked_by") => "Stilt av ",
("no", "contact_them") => "ta kontakt",
("no", "if_stuck") => " om du står fast.",
("no", "send") => "Send",
("no", "sign_in") => "Logg inn",
("no", "nothing_here_yet") => "Ingenting her ennå.",
("no", "nothing_here") => "Ingenting her",
("no", "back_to_start") => "tilbake til start",
("no", "done") => "Ferdig.",
("no", "follows_answer") => {
"Denne siden følger av et svar du ikke ser ut til å bære ennå."
}
("no", "owners_only") => {
"Denne delen av siden er for organisasjonens eiere — logg inn med den kontoen for å ta en titt."
}
("no", "announcements") => "Kunngjøringer",
_ => return None,
})
}
/// A missing key is a programmer error; render the key itself so it
/// is findable, never a panic in a view.
fn key_missing(key: &str) -> &'static str {
// Leak is bounded: keys are a small fixed set of literals.
Box::leak(key.to_string().into_boxed_str())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn norwegian_covers_the_english_table() {
for key in [
"also_worth_asking",
"asked_by",
"contact_them",
"if_stuck",
"send",
"sign_in",
"nothing_here_yet",
"nothing_here",
"back_to_start",
"done",
"follows_answer",
"owners_only",
"announcements",
] {
assert!(lookup("en", key).is_some(), "en missing {key}");
assert!(lookup("no", key).is_some(), "no missing {key}");
}
}
#[test]
fn unknown_language_falls_back_to_english() {
assert_eq!(t("de", "sign_in"), "Sign in");
assert_eq!(t("en", "sign_in"), "Sign in");
}
}
+10
View File
@@ -4,6 +4,7 @@ pub mod auth;
pub mod chain;
pub mod content;
pub mod events;
pub mod i18n;
pub mod resource;
#[cfg(feature = "ssr")]
@@ -12,6 +13,15 @@ pub mod aggregates;
#[cfg(feature = "ssr")]
pub mod announce;
#[cfg(feature = "ssr")]
pub mod needs;
#[cfg(feature = "ssr")]
pub mod local;
#[cfg(feature = "ssr")]
pub mod http;
#[cfg(feature = "ssr")]
pub mod server;
+100
View File
@@ -0,0 +1,100 @@
//! Loading a content repo from a local checkout - the offline
//! counterparts to the Gitea loaders in `content`, for tools that lint
//! or replay a branch that has not been pushed (question_lint,
//! needs_replay). `dir` is always the pages directory (`questions/`);
//! `site.yaml`, `aggregates.yaml` and `needs.yaml` sit one level up.
#![cfg(feature = "ssr")]
use crate::{aggregates, content};
/// The offline counterpart to `content::load_site_from_gitea` -
/// `site.yaml` lives at the repo root like `aggregates.yaml`, and is
/// just as optional locally: missing means default branding.
pub fn load_site_from_dir(dir: &str) -> anyhow::Result<content::SiteConfig> {
let site_path = std::path::Path::new(dir)
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join("site.yaml");
if !site_path.exists() {
return Ok(content::SiteConfig::default());
}
let raw = std::fs::read_to_string(&site_path)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", site_path.display()))?;
serde_yaml::from_str(&raw).map_err(|e| anyhow::anyhow!("parsing {}: {e}", site_path.display()))
}
/// The offline counterpart to `content::load_aggregates_from_gitea` -
/// `aggregates.yaml` lives at the repo root, one level up from the
/// pages directory `--path` names, so `dir`'s parent is where it's
/// looked for. Missing entirely is not an error here (unlike
/// `--repo` mode, where `aggregates.yaml` is required) - a local
/// checkout being linted may not have one, and every declared
/// transition still gets checked against whatever *is* found; an
/// empty map just means nothing is checked.
pub fn load_aggregates_from_dir(
dir: &str,
) -> anyhow::Result<std::collections::HashMap<String, aggregates::AggregateSchema>> {
let aggregates_path = std::path::Path::new(dir)
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join("aggregates.yaml");
if !aggregates_path.exists() {
return Ok(std::collections::HashMap::new());
}
let raw = std::fs::read_to_string(&aggregates_path)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", aggregates_path.display()))?;
aggregates::parse_aggregates_yaml(&raw)
}
/// The offline counterpart to `content::load_questions_from_gitea` -
/// the same recursive tree walk and `content::build_questions`
/// pipeline (derived ids, relative refs, sections, followup
/// inference), just reading a local checkout instead of Gitea's API,
/// for linting a branch that hasn't been pushed yet.
pub fn load_from_dir(
dir: &str,
) -> anyhow::Result<std::collections::HashMap<String, content::Question>> {
let base = std::path::Path::new(dir);
let mut files = Vec::new();
collect_yaml(base, base, &mut files)?;
content::build_questions(&files)
}
fn collect_yaml(
base: &std::path::Path,
dir: &std::path::Path,
out: &mut Vec<(String, String)>,
) -> anyhow::Result<()> {
for entry in std::fs::read_dir(dir)? {
let path = entry?.path();
if path.is_dir() {
collect_yaml(base, &path, out)?;
} else if path.extension().and_then(|e| e.to_str()) == Some("yaml") {
let rel = path
.strip_prefix(base)
.expect("walked paths sit under their base")
.to_string_lossy()
.replace('\\', "/");
let raw = std::fs::read_to_string(&path)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", path.display()))?;
out.push((rel, raw));
}
}
Ok(())
}
/// The offline counterpart to `content::load_needs_from_gitea` -
/// `needs.yaml` sits at the repo root beside `aggregates.yaml`.
pub fn load_needs_from_dir(dir: &str) -> anyhow::Result<Option<String>> {
let needs_path = std::path::Path::new(dir)
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join("needs.yaml");
if !needs_path.exists() {
return Ok(None);
}
std::fs::read_to_string(&needs_path)
.map(Some)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", needs_path.display()))
}
+7 -1
View File
@@ -145,7 +145,13 @@ async fn main() -> anyhow::Result<()> {
.route("/auth/callback", get(oidc::callback))
.route("/auth/logout", get(oidc::logout))
.route("/api/{*fn_name}", any(server_fn_handler))
.route("/upload", post(upload::upload))
// axum caps request bodies at 2 MB by default, which had made the
// 20 MB upload limit unreachable; raise the fence on this route only,
// with room for the multipart framing.
.route(
"/upload",
post(upload::upload).layer(axum::extract::DefaultBodyLimit::max(upload::MAX_UPLOAD_BYTES + 64 * 1024)),
)
.route("/gitea-repo", get(content::gitea_repo_handler))
// Content-shipped assets (hero module etc.), same-origin.
.route("/site/{*path}", get(content::site_asset_handler))
+969
View File
@@ -0,0 +1,969 @@
//! A business's needs, stated apart from how its pages word them.
//!
//! A content repo may ship a `needs.yaml` at its root: each *need* says
//! who wants what, where they enter, which bucket the answer must land
//! in, and which group has to be able to carry it to a finished state.
//! Two things are checked against it:
//!
//! - **Structure** (`check`): deterministic. A path of submissions
//! leads from the entry page to the bucket, within an effort budget;
//! the handling group has a desk over that bucket; every finished
//! state is reachable through the moves that desk offers, and no
//! record strands on the way.
//! - **Wording** (`tasks` / `score`): each *persona* is a visitor in
//! their own words. Every page on their path becomes one typed
//! `choice` question for an external answering engine - the page's
//! question, its alternatives as the options - and the engine's picks
//! are scored against the alternatives that actually meet the need.
//! A misroute is a wording defect: the page said one thing and the
//! visitor heard another.
//!
//! The engine is deliberately outside this crate: tasks go out as
//! JSONL, answers come back as JSONL, so any classifier or LLM can sit
//! in between and be compared on the same tasks.
#![cfg(feature = "ssr")]
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use serde::{Deserialize, Serialize};
use crate::aggregates::AggregateSchema;
use crate::content::{action_target_id, Alternative, Question};
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NeedsFile {
#[serde(default)]
pub needs: Vec<Need>,
#[serde(default)]
pub personas: Vec<Persona>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Need {
pub id: String,
/// The kind of visitor - prose, for the reader of the report.
pub who: String,
pub wants: String,
/// The page they arrive on.
#[serde(default = "root")]
pub enters: String,
/// The group they hold when they arrive; unset means anonymous.
#[serde(default)]
pub enters_as: Option<String>,
/// The alternative on the entry page that serves this need, when
/// several record into the same bucket.
#[serde(default)]
pub via: Option<String>,
/// The bucket their answer must be recorded into.
pub lands_in: String,
/// The group that must be able to see the record and move it.
pub handled_by: String,
/// The states that count as handled.
pub done_when: Vec<String>,
/// Submissions the visitor may be asked for before the record
/// exists.
#[serde(default = "default_max_steps")]
pub max_steps: usize,
/// Required fields across the whole path - the onboarding effort
/// budget.
#[serde(default)]
pub max_fields: Option<usize>,
/// Declared, and knowingly not open yet: what is missing reports
/// as a warning, so the gap stays visible without failing CI.
#[serde(default)]
pub planned: bool,
/// The finished states that are a good outcome for the business -
/// a subset of `done_when`. "lost" is finished; "won" is success.
/// What a simulation or a live bucket report counts toward.
#[serde(default)]
pub success: Vec<String>,
/// Every stage the business said a case goes through, in its own
/// order, first to last. Written from how the business describes
/// its work, before any state machine exists, so that a machine
/// which quietly skips a stage ("we go and look, then we quote"
/// becoming open -> quoted) fails instead of passing.
#[serde(default)]
pub stages: Vec<String>,
/// Other groups whose desks carry some of this need's moves: a
/// site manager closing what a project manager opened. Their
/// buttons count toward reaching `done_when` exactly as
/// `handled_by`'s do. `handled_by` must still hold a desk of its
/// own - someone has to be answerable for the record.
#[serde(default)]
pub also_moved_by: Vec<String>,
}
fn root() -> String {
"/".to_string()
}
fn default_max_steps() -> usize {
2
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Persona {
pub id: String,
pub need: String,
/// The visitor's situation in their own words - never the page's.
pub text: String,
}
pub fn parse(raw: &str) -> anyhow::Result<NeedsFile> {
serde_yaml::from_str(raw).map_err(|e| anyhow::anyhow!("parsing needs.yaml: {e}"))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Level {
Fail,
Warn,
}
#[derive(Debug)]
pub struct Finding {
pub need: String,
pub level: Level,
pub message: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Step {
pub page: String,
pub alternative: String,
}
/// One alternative on a page that still meets the need, with the
/// cheapest way on from it.
struct Viable {
alternative: String,
path: Vec<Step>,
fields: usize,
records_with_action: Option<bool>,
}
type Questions = HashMap<String, Question>;
fn visible(q: &Question, as_group: Option<&str>) -> bool {
match &q.qualifies {
None => true,
Some(group) => as_group == Some(group.as_str()),
}
}
fn required_fields(alt: &Alternative) -> usize {
alt.features
.iter()
.flat_map(|f| &f.requirements)
.filter(|r| !r.optional)
.count()
}
/// The alternatives on `page` from which the need is still met within
/// `remaining` submissions.
fn viable(
questions: &Questions,
need: &Need,
page: &Question,
remaining: usize,
entry: bool,
include_disabled: bool,
trail: &mut Vec<String>,
) -> Vec<Viable> {
let mut out = Vec::new();
if remaining == 0 {
return out;
}
for alt in page.alternatives.iter().filter(|a| include_disabled || !a.disabled) {
if entry && need.via.as_deref().is_some_and(|via| via != alt.name) {
continue;
}
let step = Step {
page: page.id.clone(),
alternative: alt.name.clone(),
};
let fields = required_fields(alt);
if alt.record_as.as_deref() == Some(need.lands_in.as_str()) {
out.push(Viable {
alternative: alt.name.clone(),
path: vec![step],
fields,
records_with_action: Some(alt.action.is_some()),
});
continue;
}
let Some(target) = alt
.action
.as_deref()
.and_then(|a| action_target_id(questions, a))
.and_then(|id| questions.get(&id))
else {
continue;
};
if trail.contains(&target.id) || !visible(target, need.enters_as.as_deref()) {
continue;
}
trail.push(target.id.clone());
let onward = viable(questions, need, target, remaining - 1, false, include_disabled, trail);
trail.pop();
if let Some(best) = onward.into_iter().min_by_key(|v| (v.path.len(), v.fields)) {
let mut path = vec![step];
path.extend(best.path);
out.push(Viable {
alternative: alt.name.clone(),
path,
fields: fields + best.fields,
records_with_action: best.records_with_action,
});
}
}
out
}
fn entry_viable(questions: &Questions, need: &Need, include_disabled: bool) -> Option<Vec<Viable>> {
let page = questions.get(&need.enters)?;
let mut trail = vec![page.id.clone()];
Some(viable(questions, need, page, need.max_steps, true, include_disabled, &mut trail))
}
/// The cheapest path that meets the need, if any.
pub fn path_for(questions: &Questions, need: &Need) -> Option<Vec<Step>> {
entry_viable(questions, need, false)?
.into_iter()
.min_by_key(|v| (v.path.len(), v.fields))
.map(|v| v.path)
}
struct Desk {
page: String,
group: Option<String>,
/// (from, to, button label)
moves: Vec<(String, String, String)>,
}
fn desks_over(questions: &Questions, bucket: &str) -> Vec<Desk> {
let mut desks = Vec::new();
for q in questions.values() {
for resource in q
.alternatives
.iter()
.flat_map(|a| &a.features)
.filter_map(|f| f.resource.as_ref())
.filter(|r| r.bucket() == Some(bucket))
{
desks.push(Desk {
page: q.id.clone(),
group: resource.requires_group.clone().or_else(|| q.qualifies.clone()),
moves: resource
.transitions
.iter()
.map(|t| (t.from.clone(), t.to.clone(), t.label.clone()))
.collect(),
});
}
}
desks.sort_by(|a, b| a.page.cmp(&b.page));
desks
}
/// Self-service moves into `bucket` - a visitor holding their own
/// record's link, no group involved (unsubscribe).
fn self_moves(questions: &Questions, bucket: &str) -> Vec<String> {
questions
.values()
.flat_map(|q| &q.alternatives)
.filter_map(|a| a.self_transition.as_ref())
.filter(|s| s.bucket == bucket)
.map(|s| s.to.clone())
.collect()
}
/// Structural check of every need. `Fail` findings mean the business
/// need is not met by the content as written.
pub fn check(
file: &NeedsFile,
questions: &Questions,
aggregates: &HashMap<String, AggregateSchema>,
) -> Vec<Finding> {
let mut findings = Vec::new();
let mut seen = HashSet::new();
for need in &file.needs {
let mut report = |level: Level, message: String| {
findings.push(Finding {
need: need.id.clone(),
level: if need.planned { Level::Warn } else { level },
message: if need.planned && level == Level::Fail {
format!("planned, not met yet: {message}")
} else {
message
},
})
};
if !seen.insert(need.id.as_str()) {
report(Level::Fail, "declared more than once".to_string());
continue;
}
// The visitor's side: can they get their answer recorded?
match questions.get(&need.enters) {
None => report(Level::Fail, format!("entry page {:?} does not exist", need.enters)),
Some(page) if !visible(page, need.enters_as.as_deref()) => report(
Level::Fail,
format!(
"entry page {:?} is gated on {:?}, which this visitor does not hold",
need.enters, page.qualifies
),
),
Some(page) => {
if page.responsible.is_none() {
report(
Level::Warn,
format!("entry page {:?} names no one responsible to turn to", need.enters),
);
}
let best = entry_viable(questions, need, false)
.unwrap_or_default()
.into_iter()
.min_by_key(|v| (v.path.len(), v.fields));
match best {
None if entry_viable(questions, need, true).is_some_and(|v| !v.is_empty()) => {
let blocked = entry_viable(questions, need, true)
.unwrap_or_default()
.into_iter()
.min_by_key(|v| (v.path.len(), v.fields))
.map(|v| v.path)
.unwrap_or_default();
let at = blocked
.iter()
.find(|s| {
questions[&s.page]
.alternatives
.iter()
.any(|a| a.name == s.alternative && a.disabled)
})
.map(|s| format!("{:?} on {}", s.alternative, s.page))
.unwrap_or_default();
report(
Level::Fail,
format!(
"the path exists but is closed: {at} is `disabled`, so the answer is advertised and refused"
),
)
}
None => report(
Level::Fail,
format!(
"no path from {:?}{} records into {:?} within {} submission(s)",
need.enters,
need.via
.as_ref()
.map(|v| format!(" via {v:?}"))
.unwrap_or_default(),
need.lands_in,
need.max_steps
),
),
Some(best) => {
if let Some(budget) = need.max_fields {
if best.fields > budget {
report(
Level::Fail,
format!(
"the path asks for {} required field(s), over the budget of {budget}",
best.fields
),
);
}
}
if best.records_with_action == Some(false) {
report(
Level::Warn,
"the recording alternative has no `action` - the visitor is never told what happens next".to_string(),
);
}
}
}
}
}
// The business's side: can the handling group finish it?
let Some(schema) = aggregates.get(&need.lands_in) else {
report(
Level::Fail,
format!("aggregates.yaml declares no state machine for {:?}", need.lands_in),
);
continue;
};
let desks = desks_over(questions, &need.lands_in);
let own: Vec<&Desk> = desks
.iter()
.filter(|d| {
d.group.as_deref().is_some_and(|g| g == need.handled_by || need.also_moved_by.iter().any(|m| m == g))
})
.collect();
if !desks.iter().any(|d| d.group.as_deref() == Some(need.handled_by.as_str())) {
let others: Vec<String> = desks
.iter()
.map(|d| format!("{} ({})", d.page, d.group.as_deref().unwrap_or("ungated")))
.collect();
report(
Level::Fail,
format!(
"no desk gated on {:?} reads {:?}{}",
need.handled_by,
need.lands_in,
if others.is_empty() {
String::new()
} else {
format!(" - it is read by {}", others.join(", "))
}
),
);
continue;
}
let selfs = self_moves(questions, &need.lands_in);
let exits = |state: &str| -> Vec<String> {
let allowed = schema.allowed(state);
let mut to: Vec<String> = own
.iter()
.flat_map(|d| &d.moves)
.filter(|(from, to, _)| from == state && allowed.contains(to))
.map(|(_, to, _)| to.clone())
.collect();
to.extend(selfs.iter().filter(|to| allowed.contains(to)).cloned());
to
};
let done: HashSet<&str> = need.done_when.iter().map(String::as_str).collect();
// Reachability walks the whole machine (a finished state may
// lead on to another: subscribed, then unsubscribed). Stranding
// only counts on the way to the first finished state - what
// happens to a handled record afterwards is not this need.
let mut reached: HashSet<String> = HashSet::from([schema.initial.clone()]);
let mut queue = VecDeque::from([(schema.initial.clone(), false)]);
let mut stranded = Vec::new();
while let Some((state, past_done)) = queue.pop_front() {
let past_done = past_done || done.contains(state.as_str());
let next = exits(&state);
if next.is_empty() && !past_done {
stranded.push(state.clone());
}
for to in next {
if reached.insert(to.clone()) {
queue.push_back((to, past_done));
}
}
}
for state in &need.done_when {
if !schema.has_state(state) {
report(
Level::Fail,
format!("done_when names {state:?}, which {:?} has no such state", need.lands_in),
);
} else if !reached.contains(state) {
report(
Level::Fail,
format!(
"{:?} cannot move a record to {state:?} - no desk of theirs offers the moves",
need.handled_by
),
);
}
}
for stage in &need.stages {
if !schema.has_state(stage) {
report(
Level::Fail,
format!(
"the business describes a stage {stage:?} that {:?} has no state for",
need.lands_in
),
);
} else if !reached.contains(stage) {
report(
Level::Fail,
format!(
"stage {stage:?} exists but {:?} is offered no moves that reach it",
need.handled_by
),
);
}
}
for state in &need.success {
if !need.done_when.contains(state) {
report(
Level::Fail,
format!("success names {state:?}, which is not one of done_when"),
);
}
}
stranded.sort();
for state in stranded {
report(
Level::Fail,
format!(
"records strand in {state:?}: {:?} is offered no move out of it",
need.handled_by
),
);
}
}
let need_ids: HashSet<&str> = file.needs.iter().map(|n| n.id.as_str()).collect();
for persona in &file.personas {
if !need_ids.contains(persona.need.as_str()) {
findings.push(Finding {
need: persona.need.clone(),
level: Level::Fail,
message: format!("persona {:?} points at a need that is not declared", persona.id),
});
}
}
findings
}
/// Everything a simulation of the business needs, resolved: each
/// need's path with the choice on every page and the form that gets
/// filled, each bucket's state machine with the buttons every desk
/// offers, and the personas. Portal resolves it because only portal
/// loads content the way the runtime does (derived ids, relative
/// refs, sections); whoever simulates reads plain JSON.
pub fn sim_model(
file: &NeedsFile,
questions: &Questions,
aggregates: &HashMap<String, AggregateSchema>,
) -> serde_json::Value {
use serde_json::json;
let mut needs = Vec::new();
let mut buckets = serde_json::Map::new();
for need in &file.needs {
let Some(path) = path_for(questions, need) else {
continue;
};
let steps: Vec<serde_json::Value> = path
.iter()
.enumerate()
.map(|(i, step)| {
let page = &questions[&step.page];
let mut trail: Vec<String> = path[..=i].iter().map(|s| s.page.clone()).collect();
let expect: Vec<String> =
viable(questions, need, page, need.max_steps - i, i == 0, false, &mut trail)
.into_iter()
.map(|v| v.alternative)
.collect();
let fields: Vec<serde_json::Value> = page
.alternatives
.iter()
.find(|a| a.name == step.alternative)
.into_iter()
.flat_map(|a| &a.features)
.flat_map(|f| &f.requirements)
.map(|r| {
json!({
"name": r.name,
"label": r.label.clone().unwrap_or_else(|| r.name.clone()),
"type": r.kind,
"optional": r.optional,
"options": r.options,
})
})
.collect();
json!({
"page": step.page,
"question": page.name,
"options": page.alternatives.iter().filter(|a| !a.disabled).map(|a| json!({
"name": a.name,
"description": plain(&a.description),
})).collect::<Vec<_>>(),
"expect": expect,
"alternative": step.alternative,
"fields": fields,
})
})
.collect();
needs.push(json!({
"id": need.id,
"who": need.who,
"wants": need.wants,
"enters_as": need.enters_as,
"lands_in": need.lands_in,
"handled_by": need.handled_by,
"done_when": need.done_when,
"success": need.success,
"steps": steps,
}));
if let Some(schema) = aggregates.get(&need.lands_in) {
buckets.entry(need.lands_in.clone()).or_insert_with(|| {
json!({
"initial": schema.initial,
"transitions": schema.transitions,
"desks": desks_over(questions, &need.lands_in).iter().map(|d| json!({
"page": d.page,
"group": d.group,
"moves": d.moves.iter().map(|(from, to, label)| json!({
"from": from, "to": to, "label": label,
})).collect::<Vec<_>>(),
})).collect::<Vec<_>>(),
"self_moves": self_moves(questions, &need.lands_in),
})
});
}
}
json!({
"needs": needs,
"buckets": buckets,
"personas": file.personas.iter().map(|p| json!({
"id": p.id, "need": p.need, "text": plain(&p.text),
})).collect::<Vec<_>>(),
})
}
/// One decision for the answering engine - the shape is laya's
/// `predict(state, questions)` input, so an adapter is a loop and
/// nothing more; any other engine reads the same fields.
#[derive(Debug, Serialize)]
pub struct Task {
pub id: String,
pub persona: String,
pub need: String,
pub page: String,
pub state: BTreeMap<&'static str, String>,
pub questions: BTreeMap<&'static str, ChoiceQuestion>,
/// Every alternative that still meets the need from here. The
/// engine never sees this.
pub expect: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct ChoiceQuestion {
#[serde(rename = "type")]
pub kind: &'static str,
pub instructions: String,
pub criteria: BTreeMap<String, String>,
}
fn plain(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// Every (persona, page-on-their-path) pair with a real choice on it.
/// `skim` shows the engine only each alternative's heading - what a
/// visitor who never reads the description has to go on.
pub fn tasks(file: &NeedsFile, questions: &Questions, skim: bool) -> Vec<Task> {
let mut out = Vec::new();
for persona in &file.personas {
let Some(need) = file.needs.iter().find(|n| n.id == persona.need) else {
continue;
};
let Some(path) = path_for(questions, need) else {
continue;
};
for (i, step) in path.iter().enumerate() {
let page = &questions[&step.page];
let open: Vec<&Alternative> = page.alternatives.iter().filter(|a| !a.disabled).collect();
if open.len() < 2 {
continue;
}
let mut trail: Vec<String> = path[..=i].iter().map(|s| s.page.clone()).collect();
let expect: Vec<String> = viable(questions, need, page, need.max_steps - i, i == 0, false, &mut trail)
.into_iter()
.map(|v| v.alternative)
.collect();
let criteria = open
.iter()
.map(|a| {
let description = plain(&a.description);
(
a.name.clone(),
if skim || description.is_empty() { a.name.clone() } else { description },
)
})
.collect();
out.push(Task {
id: format!("{}@{}", persona.id, step.page),
persona: persona.id.clone(),
need: need.id.clone(),
page: step.page.clone(),
state: BTreeMap::from([("body", plain(&persona.text))]),
questions: BTreeMap::from([(
"pick",
ChoiceQuestion {
kind: "choice",
instructions: format!(
"A visitor reads the page question {:?}. Which alternative do they choose?",
page.name
),
criteria,
},
)]),
expect,
});
}
}
out
}
#[derive(Debug, Deserialize)]
pub struct Answer {
pub id: String,
pub choice: String,
#[serde(default)]
pub confidence: Option<f64>,
}
#[derive(Debug, Default)]
pub struct Score {
pub total: usize,
pub correct: usize,
/// (task id, picked, expected, confidence)
pub misroutes: Vec<(String, String, Vec<String>, Option<f64>)>,
/// Right, but the engine was under `hesitant_below` - wording that
/// works and is still close to a coin flip.
pub hesitant: Vec<(String, f64)>,
pub unanswered: Vec<String>,
}
impl Score {
pub fn accuracy(&self) -> f64 {
if self.total == 0 {
1.0
} else {
self.correct as f64 / self.total as f64
}
}
}
pub fn score(tasks: &[Task], answers: &[Answer], hesitant_below: f64) -> Score {
let by_id: HashMap<&str, &Answer> = answers.iter().map(|a| (a.id.as_str(), a)).collect();
let mut score = Score {
total: tasks.len(),
..Score::default()
};
for task in tasks {
match by_id.get(task.id.as_str()) {
None => score.unanswered.push(task.id.clone()),
Some(answer) if task.expect.contains(&answer.choice) => {
score.correct += 1;
if let Some(c) = answer.confidence.filter(|c| *c < hesitant_below) {
score.hesitant.push((task.id.clone(), c));
}
}
Some(answer) => score.misroutes.push((
task.id.clone(),
answer.choice.clone(),
task.expect.clone(),
answer.confidence,
)),
}
}
score
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aggregates::parse_aggregates_yaml;
use crate::content::build_questions;
fn site(desk_moves: &str) -> (Questions, HashMap<String, AggregateSchema>) {
let files = vec![
(
"index.yaml".to_string(),
"name: What are you here for?\nresponsible: { name: A, contact: a@b.c }\nalternatives:\n - name: Hire us\n description: Bring a problem.\n action: /thanks\n record_as: projects\n features:\n - name: Brief\n requirements:\n - name: email\n type: email\n - name: brief\n type: textarea\n - name: budget\n optional: true\n - name: Follow along\n description: Notes by mail.\n action: /thanks\n record_as: subscribers\n".to_string(),
),
("thanks.yaml".to_string(), "name: Thanks\nfollowup: true\n".to_string()),
(
"desk/index.yaml".to_string(),
format!("name: Desk\nqualifies: owners\nalternatives:\n - name: Projects\n features:\n - name: \"\"\n resource:\n source: {{ kind: kv, bucket: projects }}\n requires_group: owners\n transitions:\n{desk_moves}"),
),
];
let aggregates = parse_aggregates_yaml(
"aggregates:\n - bucket: projects\n initial: open\n states:\n open: { event: submitted }\n quoted: { event: quoted }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [quoted, lost]\n quoted: [won, lost]\n - bucket: subscribers\n attended_by: a mailer\n initial: open\n states:\n open: { event: subscribed }\n transitions: {}\n",
)
.unwrap();
(build_questions(&files).unwrap(), aggregates)
}
const FULL_DESK: &str = " - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n - { from: quoted, to: won, label: Win }\n - { from: quoted, to: lost, label: Lose }\n";
fn needs(extra: &str) -> NeedsFile {
parse(&format!(
"needs:\n - id: hire\n who: a client\n wants: work done\n lands_in: projects\n handled_by: owners\n done_when: [won, lost]\n{extra}personas:\n - id: founder\n need: hire\n text: I need someone to build our booking tool.\n"
))
.unwrap()
}
fn fails(findings: &[Finding]) -> Vec<&str> {
findings
.iter()
.filter(|f| f.level == Level::Fail)
.map(|f| f.message.as_str())
.collect()
}
#[test]
fn a_met_need_is_silent() {
let (questions, aggregates) = site(FULL_DESK);
let findings = check(&needs(" max_fields: 2\n"), &questions, &aggregates);
assert!(findings.is_empty(), "{findings:?}");
}
#[test]
fn effort_budget_counts_required_fields_only() {
let (questions, aggregates) = site(FULL_DESK);
let findings = check(&needs(" max_fields: 1\n"), &questions, &aggregates);
assert_eq!(fails(&findings), vec!["the path asks for 2 required field(s), over the budget of 1"]);
}
#[test]
fn a_desk_missing_a_move_strands_records_and_cannot_finish() {
// The desk can quote, but never close a quoted job.
let (questions, aggregates) = site(" - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n");
let findings = check(&needs(""), &questions, &aggregates);
assert_eq!(
fails(&findings),
vec![
"\"owners\" cannot move a record to \"won\" - no desk of theirs offers the moves",
"records strand in \"quoted\": \"owners\" is offered no move out of it",
]
);
}
#[test]
fn a_finished_state_may_lead_on_to_another() {
// quoted counts as handled here, yet won must still be
// reachable past it; lost-after-quoted is not this need's
// business, so nothing strands.
let (questions, aggregates) = site(FULL_DESK);
let mut file = needs("");
file.needs[0].done_when = vec!["quoted".to_string(), "won".to_string(), "lost".to_string()];
let findings = check(&file, &questions, &aggregates);
assert!(findings.is_empty(), "{findings:?}");
}
#[test]
fn a_disabled_alternative_is_named_as_the_reason() {
let (mut questions, aggregates) = site(FULL_DESK);
questions.get_mut("/").unwrap().alternatives[0].disabled = true;
let findings = check(&needs(""), &questions, &aggregates);
assert_eq!(
fails(&findings),
vec!["the path exists but is closed: \"Hire us\" on / is `disabled`, so the answer is advertised and refused"]
);
}
#[test]
fn success_must_be_a_finished_state_and_the_sim_model_carries_the_whole_flow() {
let (questions, aggregates) = site(FULL_DESK);
let mut file = needs(" success: [won]\n");
assert!(check(&file, &questions, &aggregates).is_empty());
let model = sim_model(&file, &questions, &aggregates);
let need = &model["needs"][0];
assert_eq!(need["success"][0], "won");
let step = &need["steps"][0];
assert_eq!(step["question"], "What are you here for?");
assert_eq!(step["expect"][0], "Hire us");
assert_eq!(step["options"].as_array().unwrap().len(), 2);
// The form the visitor fills, required and optional alike.
let fields = step["fields"].as_array().unwrap();
assert_eq!(fields.len(), 3);
assert_eq!(fields[2]["optional"], true);
// The desk's buttons, labelled, per state.
let moves = model["buckets"]["projects"]["desks"][0]["moves"].as_array().unwrap();
assert!(moves.iter().any(|m| m["from"] == "quoted" && m["to"] == "won" && m["label"] == "Win"));
assert_eq!(model["buckets"]["projects"]["initial"], "open");
assert_eq!(model["personas"][0]["id"], "founder");
// A stage the business named must exist as a state.
file.needs[0].stages = vec!["open".into(), "surveyed".into(), "quoted".into()];
assert_eq!(
fails(&check(&file, &questions, &aggregates)),
vec!["the business describes a stage \"surveyed\" that \"projects\" has no state for"]
);
file.needs[0].stages = vec!["open".into(), "quoted".into(), "won".into()];
assert!(check(&file, &questions, &aggregates).is_empty());
file.needs[0].success = vec!["quoted".to_string()];
assert_eq!(
fails(&check(&file, &questions, &aggregates)),
vec!["success names \"quoted\", which is not one of done_when"]
);
}
#[test]
fn a_need_can_be_carried_by_more_than_one_groups_desk() {
// owners may only quote or lose; closing a quoted job is the
// site managers' button, on a desk of their own.
let (mut questions, aggregates) = site(" - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n");
let more = build_questions(&[(
"site/index.yaml".to_string(),
"name: Site desk\nqualifies: site_managers\nalternatives:\n - name: Projects\n features:\n - name: \"\"\n resource:\n source: { kind: kv, bucket: projects }\n requires_group: site_managers\n transitions:\n - { from: quoted, to: won, label: Win }\n - { from: quoted, to: lost, label: Lose }\n".to_string(),
)])
.unwrap();
questions.extend(more);
let mut file = needs("");
// Alone, owners cannot finish it...
assert_eq!(fails(&check(&file, &questions, &aggregates)).len(), 2);
// ...with the site managers' buttons counted, they can.
file.needs[0].also_moved_by = vec!["site_managers".to_string()];
assert!(check(&file, &questions, &aggregates).is_empty());
// But someone has to be answerable: handled_by needs its own desk.
file.needs[0].handled_by = "nobody".to_string();
assert!(fails(&check(&file, &questions, &aggregates))[0].starts_with("no desk gated on \"nobody\""));
}
#[test]
fn the_wrong_group_holding_the_desk_is_a_failure() {
let (questions, aggregates) = site(FULL_DESK);
let mut file = needs("");
file.needs[0].handled_by = "sales".to_string();
let findings = check(&file, &questions, &aggregates);
assert_eq!(
fails(&findings),
vec!["no desk gated on \"sales\" reads \"projects\" - it is read by /desk (owners)"]
);
}
#[test]
fn via_pins_the_entry_alternative() {
let (questions, aggregates) = site(FULL_DESK);
let mut file = needs("");
file.needs[0].via = Some("Follow along".to_string());
let findings = check(&file, &questions, &aggregates);
assert_eq!(
fails(&findings),
vec!["no path from \"/\" via \"Follow along\" records into \"projects\" within 2 submission(s)"]
);
}
#[test]
fn tasks_carry_the_page_as_a_choice_and_score_separates_misroutes() {
let (questions, _) = site(FULL_DESK);
let file = needs("");
let tasks = tasks(&file, &questions, false);
let skimmed = super::tasks(&file, &questions, true);
assert_eq!(skimmed[0].questions["pick"].criteria["Follow along"], "Follow along");
assert_eq!(tasks.len(), 1);
let task = &tasks[0];
assert_eq!(task.id, "founder@/");
assert_eq!(task.expect, vec!["Hire us"]);
let q = &task.questions["pick"];
assert_eq!(q.kind, "choice");
assert_eq!(q.criteria["Follow along"], "Notes by mail.");
assert!(q.instructions.contains("What are you here for?"));
let right = vec![Answer { id: "founder@/".into(), choice: "Hire us".into(), confidence: Some(0.55) }];
let s = score(&tasks, &right, 0.6);
assert_eq!((s.correct, s.misroutes.len(), s.hesitant.len()), (1, 0, 1));
let wrong = vec![Answer { id: "founder@/".into(), choice: "Follow along".into(), confidence: Some(0.9) }];
let s = score(&tasks, &wrong, 0.6);
assert_eq!((s.correct, s.misroutes.len()), (0, 1));
assert_eq!(s.accuracy(), 0.0);
assert_eq!(score(&tasks, &[], 0.6).unanswered, vec!["founder@/"]);
}
}
+21 -11
View File
@@ -68,6 +68,16 @@ pub async fn get_requirement_options(
.iter()
.find(|r| r.name == requirement_name)
.ok_or_else(|| ServerFnError::new("unknown requirement"))?;
// Inline options: return them as {id,label} objects, the shape the
// SelectField renders, without touching a resource.
if !requirement.options.is_empty() {
let items: Vec<serde_json::Value> = requirement
.options
.iter()
.map(|o| serde_json::json!({ "id": o, "label": o }))
.collect();
return Ok(serde_json::Value::Array(items));
}
let resource = requirement
.resource
.as_ref()
@@ -250,21 +260,20 @@ async fn fetch_gitea_json(
for (k, v) in params {
url.query_pairs_mut().append_pair(k, v);
}
let client = openidconnect::reqwest::Client::new();
let mut req = client.get(url.clone());
let mut req = crate::http::client().get(url.clone());
if let Ok(token) = std::env::var("GITEA_API_TOKEN") {
if !token.is_empty() {
req = req.header("Authorization", format!("token {token}"));
}
}
let text = req
let response = req
.send()
.await
.and_then(|r| r.error_for_status())
.map_err(|e| ServerFnError::new(format!("fetching {url}: {e}")))?
.text()
.map_err(|e| ServerFnError::new(format!("fetching {url}: {e}")))?;
let text = crate::http::text(response, crate::http::GITEA_CAP)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
.map_err(|e| ServerFnError::new(format!("reading {url}: {e}")))?;
serde_json::from_str(&text).map_err(|e| ServerFnError::new(e.to_string()))
}
@@ -331,16 +340,17 @@ async fn fetch_url_json(
for (k, v) in &leftover {
parsed.query_pairs_mut().append_pair(k, v);
}
let client = openidconnect::reqwest::Client::new();
let text = client
let response = crate::http::client()
.get(parsed.clone())
.send()
.await
.and_then(|r| r.error_for_status())
.map_err(|e| ServerFnError::new(format!("fetching {parsed}: {e}")))?
.text()
.map_err(|e| ServerFnError::new(format!("fetching {parsed}: {e}")))?;
// Content picks the URL; the cap is what keeps a runaway endpoint
// from becoming a runaway allocation here.
let text = crate::http::text(response, crate::http::URL_RESOURCE_CAP)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
.map_err(|e| ServerFnError::new(format!("reading {parsed}: {e}")))?;
serde_json::from_str(&text).map_err(|e| ServerFnError::new(e.to_string()))
}
+26 -4
View File
@@ -33,6 +33,13 @@ type OidcClient = CoreClient<
>;
pub struct Oidc {
/// `None` when `KANIDM_URL` is unset: a content-only instance with
/// sign-in disabled - auth routes answer 503, everything public
/// renders as usual.
inner: Option<OidcInner>,
}
struct OidcInner {
client: OidcClient,
http: openidconnect::reqwest::Client,
}
@@ -45,7 +52,13 @@ const REDIRECT_KEY: &str = "oidc_post_login_redirect";
impl Oidc {
/// Discovers the provider and builds the client from environment:
/// `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET`, `PUBLIC_URL`.
/// With `KANIDM_URL` unset, sign-in is disabled instead of fatal -
/// the shape of a public content instance without a review desk.
pub async fn from_env() -> anyhow::Result<Self> {
if std::env::var("KANIDM_URL").is_err() {
tracing::warn!("KANIDM_URL not set - sign-in disabled on this instance");
return Ok(Self { inner: None });
}
let kanidm_url = require_env("KANIDM_URL")?;
let client_id = require_env("OAUTH2_CLIENT_ID")?;
let client_secret = require_env("OAUTH2_CLIENT_SECRET")?;
@@ -75,7 +88,16 @@ impl Oidc {
)
.set_redirect_uri(redirect);
Ok(Self { client, http })
Ok(Self {
inner: Some(OidcInner { client, http }),
})
}
fn configured(&self) -> Result<&OidcInner, HandlerError> {
self.inner.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"sign-in is not configured on this instance".to_string(),
))
}
}
@@ -118,10 +140,10 @@ pub async fn login(
session.insert(REDIRECT_KEY, redirect).await.map_err(internal)?;
}
let oidc = state.oidc.configured()?;
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let (auth_url, csrf_state, nonce) = state
.oidc
let (auth_url, csrf_state, nonce) = oidc
.client
.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
@@ -182,7 +204,7 @@ pub async fn callback(
));
}
let oidc = &state.oidc;
let oidc = state.oidc.configured()?;
let token_response = oidc
.client
.exchange_code(AuthorizationCode::new(params.code))
+14 -6
View File
@@ -15,7 +15,7 @@ use serde::Serialize;
use crate::server::AppState;
const MAX_UPLOAD_BYTES: usize = 20 * 1024 * 1024;
pub const MAX_UPLOAD_BYTES: usize = 20 * 1024 * 1024;
#[derive(Clone)]
pub struct Garage {
@@ -84,12 +84,20 @@ pub async fn upload(
.unwrap_or("application/octet-stream")
.to_string();
let filename = field.file_name().unwrap_or("upload").to_string();
let bytes = field
.bytes()
// Chunk by chunk, refused the moment the cap is crossed: an
// oversized body is never buffered whole before being turned
// away. The route's body limit (main.rs) is the outer fence.
let mut bytes: Vec<u8> = Vec::new();
let mut field = field;
while let Some(chunk) = field
.chunk()
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
if bytes.len() > MAX_UPLOAD_BYTES {
return Err((StatusCode::PAYLOAD_TOO_LARGE, "file too large".to_string()));
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
{
if bytes.len() + chunk.len() > MAX_UPLOAD_BYTES {
return Err((StatusCode::PAYLOAD_TOO_LARGE, "file too large".to_string()));
}
bytes.extend_from_slice(&chunk);
}
let key = format!("{}-{filename}", uuid::Uuid::new_v4());
+182 -10
View File
@@ -99,10 +99,10 @@
--hero-glow: rgba(246, 245, 241, 0.85);
}
/* The wordmark SVG is a hardcoded white stroke (also used raw in
dark contexts elsewhere) - flip it to ink here rather than fork
the asset. */
.wordmark img {
/* The house wordmark SVG is a hardcoded white stroke (also used
raw in dark contexts elsewhere) - flip it to ink here rather
than fork the asset. A content-provided logo keeps its colors. */
.wordmark-house img {
filter: invert(0.92);
}
@@ -147,6 +147,10 @@ a {
main.loading,
main.not-found {
min-height: 60vh;
/* svh so wrapper browsers that resize the webview as their toolbar
animates (Firefox/Chrome iOS) don't reflow this placeholder - the
small viewport is a fixed value. Same ladder as .hero-piece. */
min-height: 60svh;
display: flex;
flex-direction: column;
align-items: center;
@@ -193,6 +197,10 @@ main.not-found {
color: var(--ink-dim);
font-size: 1.05rem;
max-width: 46ch;
/* The measure cap shrinks the box below the copy column; without
auto margins the box left-anchors and its centered text centers
in the wrong frame. */
margin-inline: auto;
}
/* A content-shipped hero module (site.yaml hero.kind: module) draws
@@ -364,8 +372,12 @@ main.not-found {
.alt-image {
display: block;
width: 100%;
max-height: 14rem;
object-fit: cover;
height: auto;
/* Natural aspect, not a cropped band; a very tall image is still
bounded so it can't tower. The deck below overrides for its
fixed-height cards. */
max-height: 32rem;
object-fit: contain;
border-radius: calc(var(--radius) - 0.3rem);
margin-bottom: 1.1rem;
}
@@ -430,10 +442,9 @@ main.not-found {
}
.feature-icon {
float: left;
width: 1.3rem;
height: 1.3rem;
margin: 0.1rem 0.6rem 0.4rem 0;
margin-top: 0.1rem;
background-color: var(--feature-accent, currentColor);
mask-repeat: no-repeat;
mask-size: contain;
@@ -443,11 +454,87 @@ main.not-found {
-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 {
font-size: 1rem;
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 {
@@ -461,6 +548,42 @@ main.not-found {
color: var(--accent);
}
/* images inside markdown descriptions: full-width figures in the
card's flow, framed like the rest of the press sheet */
.alt-description img,
.item-card-description img,
.feature p img {
display: block;
width: 100%;
margin: 0.6rem 0 0.2rem;
border-radius: 0.5rem;
border: 0.06rem solid var(--line);
}
/* Content hint via a markdown image title (`![alt](url "right 9rem")`):
float and shrink so text wraps around a portrait. */
.alt-description img.md-float-left,
.item-card-description img.md-float-left,
.feature p img.md-float-left,
.alt-description img.md-float-right,
.item-card-description img.md-float-right,
.feature p img.md-float-right {
width: auto;
max-width: 45%;
}
.alt-description img.md-float-left,
.item-card-description img.md-float-left,
.feature p img.md-float-left {
float: left;
margin: 0.2rem 1.1rem 0.5rem 0;
}
.alt-description img.md-float-right,
.item-card-description img.md-float-right,
.feature p img.md-float-right {
float: right;
margin: 0.2rem 0 0.5rem 1.1rem;
}
.alt-description code,
.feature p code {
font-size: 0.9em;
@@ -572,11 +695,17 @@ textarea:focus {
touch-action: none;
}
/* Results overlay the canvas instead of growing the widget - the
page never jumps when places arrive (or don't). */
.gesture-echoes {
position: absolute;
left: 0.6rem;
right: 0.6rem;
bottom: 0.6rem;
display: flex;
justify-content: center;
gap: 0.5rem;
margin-top: 0.5rem;
min-height: 3rem;
pointer-events: none;
}
.gesture-echoes:empty {
@@ -591,12 +720,48 @@ textarea:focus {
border-radius: 0.4rem;
opacity: 0;
transition: opacity 0.5s ease;
pointer-events: auto;
box-shadow: 0 0.15rem 0.6rem rgba(0, 0, 0, 0.18);
}
.gesture-echo.shown {
opacity: var(--echo-strength, 1);
}
/* places are pickable: the pick re-derives the input's key */
.gesture-echo {
cursor: pointer;
transition: opacity 0.5s ease, border-color 120ms, transform 120ms;
}
.gesture-echo:hover,
.gesture-echo:focus-visible {
border-color: var(--accent);
outline: none;
}
.gesture-echo.selected {
border-color: var(--accent);
box-shadow: 0 0 0 0.12rem var(--accent);
transform: translateY(-0.1rem);
}
.gesture-empty {
position: absolute;
left: 0;
right: 0;
bottom: 0.7rem;
margin: 0;
text-align: center;
font-size: 0.85rem;
color: var(--ink-dim);
pointer-events: none;
}
.gesture-empty:empty {
display: none;
}
.gesture-status {
position: absolute;
top: 0.55rem;
@@ -728,6 +893,10 @@ textarea:focus {
padding: 0.75rem 1.4rem;
cursor: pointer;
transition: filter 120ms, transform 120ms;
/* A gateway alternative renders this as an <a> (navigation, no POST);
kill the link chrome so it reads as the button it looks like. */
text-decoration: none;
display: inline-block;
}
.alt-submit:hover {
@@ -876,6 +1045,9 @@ textarea:focus {
.item-card-description {
font-size: 0.9rem;
color: var(--ink-dim);
/* The card is a grid with its own gap; the global p bottom margin
would stack on top of it. */
margin: 0;
}
.item-card-fields {
+66
View File
@@ -0,0 +1,66 @@
//! The one test that meets a real JetStream: a submission becomes an
//! aggregate, a desk move is accepted, an undeclared move is refused,
//! and the record reads back from its own event log. Everything else
//! in the suite replays in memory; this is what proves the store, the
//! event log and the projection agree with each other.
//!
//! Runs only when `PORTAL_TEST_NATS_URL` points at a JetStream (CI
//! starts a throwaway one, see `.gitea/workflows/test.yml`); without
//! it the test passes as skipped, so `cargo test` stays green offline.
#![cfg(feature = "ssr")]
use portal::aggregates::{self, parse_aggregates_yaml, TransitionError};
use portal::answers::{store_answer, Answer};
use portal::events::store::{ensure_stream, load_events};
use serde_json::json;
#[tokio::test]
async fn a_submission_is_created_moved_refused_and_read_back_through_the_real_engine() {
let Ok(url) = std::env::var("PORTAL_TEST_NATS_URL") else {
eprintln!("skipped: PORTAL_TEST_NATS_URL is not set");
return;
};
let js = async_nats::jetstream::new(async_nats::connect(&url).await.expect("connect"));
ensure_stream(&js).await.expect("event stream");
// A bucket of its own per run, so reruns and parallel runs never
// read each other's records.
let bucket = format!("it_{}", chrono::Utc::now().timestamp_millis());
let schemas = parse_aggregates_yaml(&format!(
"aggregates:\n - bucket: {bucket}\n initial: open\n states:\n open: {{ event: received }}\n quoted: {{ event: quoted }}\n won: {{ event: won }}\n lost: {{ event: lost }}\n transitions:\n open: [quoted, lost]\n quoted: [won, lost]\n"
))
.expect("schema");
let schema = &schemas[&bucket];
let now = chrono::Utc::now().timestamp_millis();
// The submission: a KV record in the initial state, plus the first event.
store_answer(&js, &schemas, &bucket, "rec-1".into(), "/", "Hire us", &json!({"email": "a@b.example"}), now)
.await
.expect("store");
let store = js.get_key_value(&bucket).await.expect("bucket exists");
let stored: Answer = serde_json::from_slice(&store.get("rec-1").await.unwrap().expect("record")).unwrap();
assert_eq!(stored.state, "open");
assert_eq!(stored.responses["email"], "a@b.example");
// A desk move the machine allows.
let moved = aggregates::transition(&js, schema, "rec-1", "quoted", json!({"by": "test"}), now + 1)
.await
.expect("open -> quoted is declared");
assert_eq!(moved.state, "quoted");
// Moves the machine does not allow are refused, whoever asks.
let back = aggregates::transition(&js, schema, "rec-1", "open", json!({}), now + 2).await;
assert!(matches!(back, Err(TransitionError::DisallowedTransition { .. })), "{back:?}");
let skip = aggregates::transition(&js, schema, "rec-1", "won", json!({}), now + 2).await;
assert!(skip.is_ok(), "quoted -> won is declared: {skip:?}");
let nobody = aggregates::transition(&js, schema, "rec-9", "quoted", json!({}), now + 3).await;
assert!(matches!(nobody, Err(TransitionError::UnknownAggregate)), "{nobody:?}");
// The record reads back from its own event log, in the state the
// accepted moves left it, with a sequence that moved forward.
let events = load_events(&js, &bucket, "rec-1").await.expect("events");
assert_eq!(events.len(), 3, "received, quoted, won");
let replayed = aggregates::replay(schema, "rec-1", &events).expect("replays");
assert_eq!(replayed.state, "won");
assert!(replayed.sequence > moved.sequence);
}
+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);
}