Commit Graph
40 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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 405bb98185 Content-shipped stylesheet override (site.yaml stylesheet)
A content repo can now ship a CSS file named in site.yaml's
stylesheet field; portal serves it same-origin at /site/<path> and
leptos_meta appends it after the default stylesheet so content rules
win. Plain repo-relative .css path only, validated on load.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 23:05:23 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 6932816c42 Lighten the comment load
Deploy / deploy (push) Successful in 1m1s
Drops porting-history narratives (dodrenett), superseded-behavior
explanations, and restatements of what the next line does. Constraint
notes (fail-closed policies, CAS semantics, cascade behavior, id
uniqueness) stay, just shorter. No code changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 22:55:36 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 3233d9a3db Alternative.images: array of urls, rendered as a Swiper card deck
Deploy / deploy (push) Successful in 1m0s
image: Option<String> becomes images: Vec<String> (nothing in live
content used the old field). One url renders as the plain banner it
was; several become a swipeable cards-effect deck via Swiper Element
12.2.0, vendored into public/ (MIT) like prosekit-editor.js/yes.js
rather than pulled from a CDN - the bundle only loads on pages where
some alternative actually declares more than one image.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 22:11:07 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 40694a8f3f Transition.from: state graphs deeper than one decision
Deploy / deploy (push) Successful in 1m0s
Transitions now declare which state they fire from (default "open",
today's implicit behavior - existing content needs no edits). AnswerRow
renders only the buttons legal from a row's current state; two declared
transitions may share a target (open -> declined, in_dialogue ->
declined), so apply_transition matches on (from, to) against the row's
actual state instead of the old blanket "already decided" check.
validate_questions/question_lint reject a from naming an undeclared
state or a from -> to pair the bucket's graph never declared.

store_answer's KV projection now starts a record at its schema's
declared initial state, not a hardcoded "open" - organizations begin
at "prospect", and the read model has to agree with the event log
about that.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 22:03:43 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 35663a28c1 Drop question-report entirely, obfuscate the mailto link instead
Deploy / deploy (push) Successful in 1m2s
The report button logged to an event stream nothing ever read - no
notification, no dashboard, just a durable no-op. A browser back
button already covers "this page wasn't helpful" better than a button
that silently does nothing visible to anyone but the visitor who
clicked it.

The mailto address is now assembled from data-user/data-domain on a
real mouse event instead of baked into the server-rendered href -
keeps a plain mailto: string (what bulk scrapers regex HTML for) out
of what a generic bot crawl sees, without hiding the contact option
from an actual visitor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 20:48:27 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 3ccc50248f Add optional Alternative.image and Feature.color/icon for richer layouts
Deploy / deploy (push) Successful in 1m6s
image: a banner url rendered as <img>, client-fetched directly (never
server-side, so none of ResourceSource::Url's SSRF concern applies).
color: any CSS color, set as the feature's own --feature-accent custom
property (never interpolated into a stylesheet) - draws a left-border
accent; unset means no border, not a fallback to the global --accent.
icon: an Iconify name (lucide:star etc.), rendered via Iconify's public
SVG API - no icon library bundled, matching this app's zero-JS-
dependency content otherwise keeps to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 17:34:07 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 84c3fad339 Make aggregate state graphs content-driven, not compiled Rust
Deploy / deploy (push) Successful in 59s
Six hand-maintained copies of the bucket->aggregate-type table
(4 per-type State enums, content::aggregate_type_for_bucket +
is_valid_transition_target, answers.rs's two dispatch matches,
backfill_events.rs's own migration table) collapse into one:
questions/aggregates.yaml, loaded and hot-swapped in AppState.aggregates
the same way AppState.questions already is. aggregates/mod.rs's
replay/create/transition now run off a runtime-loaded AggregateSchema
instead of a compile-time AggregateKind trait impl per type - the
CAS/JetStream mechanics underneath are unchanged. A new aggregate type
is now a content-only change, no portal deploy required.

backfill_events.rs is deleted outright rather than ported - its
migration job was already done and production data here is minimal
and expendable (the EVENTS stream gets purged by hand post-deploy).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 21:43:32 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 aa18c69be4 Add Organization aggregate (client-as-status), wired but inert
Deploy / deploy (push) Successful in 34s
Prospect -> Client -> PastClient (Prospect -> PastClient allowed
directly too - a prospect that never converted). "Client" is a status
on Organization, not a separate aggregate, per the event-sourcing
plan's design decision. Wired into the same dispatch points as the
other three aggregates (content.rs's bucket map, answers.rs's
create/transition dispatch) so it's ready the moment content
references an "organizations" bucket - nothing does yet, so this lands
compiled and tested but with zero production surface.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 12:01:17 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 755b796ad1 Resource-backed multi/single-select requirement
Deploy / deploy (push) Successful in 34s
Requirement gains an optional `resource` (reuses ResourceSpec/
ResourceSource/jq wholesale - a resource is a resource whether it's
displayed read-only or offered as choices to pick from) and `id_field`
(which field in each item is its stable id, defaults to _id then id).
`type: select` + `multiple` (already-existing field, previously file
-only) picks single vs multi. New get_requirement_options server fn
shares its auth/fetch/jq logic with get_resource via two extracted
helpers rather than duplicating it.

Submitted value is the selected id (single) or a JSON array of ids
(multi) - a new select_field_map (RwSignal<Vec<String>>, alongside the
existing field_map/file_refs maps, since a multi-select's value is a
set, not a string) threaded through the same nested <For> structure
the other requirement kinds already use.

Content validation extended: a `type: select` requirement declaring no
resource now fails at load time instead of rendering a dead field.
Caught a real bug in my own first version of that check while testing
it - it was nested inside a feature-level resource guard, so it never
ran unless the *feature* also happened to have its own resource.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 11:57:15 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 ebf4bf91b3 Event-sourced applicant/subscriber/project aggregates, generalized resources
Deploy / deploy (push) Failing after 3s
Replaces the free-string, direct-KV-mutate state model in answers.rs
with a proper event log (events/store.rs, JetStream-backed, CAS via
expected_last_subject_sequence) and three pure state machines
(aggregates/{applicant,subscriber,project}.rs). Closes a real
lost-update race in the old transition_answer (concurrent decisions on
the same item could both win, publishing contradictory events). KV
buckets become best-effort read-model projections, not the source of
truth. Content-declared transition targets are now validated at
load/reload time against the real compiled transition tables, not
accepted as arbitrary strings.

Buckets renamed to describe their content, not their relation to the
app (portal_applicants -> applicants, etc); "inquiry" folded into a
richer "project" concept.

ResourceSpec generalized beyond a single KV bucket: Kv | GiteaStarred |
GiteaOrgRepos | Url sources, with an optional jq filter (via the jaq
crate) to shape live data for the frontend. Url source is SSRF-guarded
(https-only, rejects loopback/private/link-local, real DNS resolve).

New headless question_lint binary (validates content against compiled
transition tables with no NATS/OIDC/server involved) and a one-time
backfill_events binary (dry-run by default) for migrating existing KV
data onto the new event log.

Questions get an optional `responsible` contact plus a lightweight
"report this question" action.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 08:52:29 +02:00
Bendik Aagaard Lynghaug 213b1130bb Add self-service transitions, authorized by item possession not group membership
Deploy / deploy (push) Successful in 33s
New Alternative.self_transition: like ResourceSpec.transitions, but for
an anonymous visitor holding one specific item's own chain hash (from
a ?chain= link, the same reference /subscribed?chain=... already
carries) rather than a signed-in owner browsing a whole bucket. email
is a second factor checked against the stored item, not the lookup
key - defense in depth against a leaked/guessed chain hash alone.

AlternativeCard renders it as a single confirm button when both
?chain= and ?email= are present; QuestionView hides the alternative
entirely otherwise, rather than showing a dead card with nothing to
click.

Powers /subscribed's new "Unsubscribe" alternative, which the
newsletter's own recipient links now carry - no email lookup needed,
just the same chain_hash the subscription itself already produced.
2026-08-05 18:44:28 +02:00
Bendik Aagaard Lynghaug 44c8c9d410 Add prosekit rich-text field, Gitea repo embeds, automation KV read endpoint; fix apex/www session-cookie mismatch on /auth/callback
Deploy / deploy (push) Successful in 29s
- type: prosekit requirement kind, backed by public/prosekit-editor.js
  (ProseMirror via prosekit, loaded from esm.sh, no bundler) - mirrors
  its HTML into a paired hidden input so it reuses the existing
  RwSignal/on:input wiring.
- Pasting a project.uhhm.no/<owner>/<repo> URL in the editor embeds a
  repo card, resolved server-side via a new /gitea-repo handler
  (content::gitea_repo_handler) so the browser never needs Gitea API
  CORS.
- New /automation/kv/{bucket} handler, bearer-token gated
  (AUTOMATION_READ_TOKEN), for backing automations (n8n) to read a
  NATS KV bucket without a browser session.
- Fix: a login started on one of apex/www set its session cookie
  there, but Kanidm's redirect_uri is fixed to PUBLIC_URL - landing
  the callback on a different, empty session ("no login in
  progress"). Caddy now redirects www -> apex so every visit stays on
  one canonical host.
2026-08-05 13:45:56 +02:00
Bendik Aagaard Lynghaug 18025bf870 Hot-reload content on a NATS trigger instead of requiring a restart
Deploy / deploy (push) Successful in 29s
questions is now Arc<ArcSwap<HashMap<...>>> - readers do a lock-free
atomic load (state.questions.load().get(&id).cloned()), never blocking
on or blocked by a reload. content::watch_for_reload subscribes to
portal.content.reload (published by the questions repo's own CI after
it lints a push - see that repo's lint-and-reload.yml) and swaps in a
freshly re-fetched HashMap on each message. A fetch/parse failure logs
and keeps serving the last-good content rather than clearing it.
2026-08-05 07:20:40 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 286fdbf67f Load content from Gitea directly, drop the local clone; add deploy workflow
Deploy / deploy (push) Failing after 16s
load_questions_from_gitea (content.rs) fetches question YAML straight
from the questions repo's public Gitea contents API at startup instead
of scanning a local directory - one fewer moving part in production
(no git clone to keep in sync, no separate questions-repo deploy
workflow). Still just an in-memory startup load, same as before -
served from RAM for every request, no per-request network call.
Verified against the real repo (all 5 questions fetch correctly).

CONTENT_DIR is replaced by CONTENT_REPO/CONTENT_BRANCH, defaulting to
the real questions repo so local dev needs no env override.

Also adds .gitea/workflows/deploy.yml: builds with cargo-leptos,
ships the release under /srv/app/uhhm-portal (the generic app@.service
deploy layout), writes /etc/app/uhhm-portal.env from this repo's own
Actions Variables/Secrets, restarts the service, and drops this app's
Caddy routing snippet into services.d/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 06:10:06 +02:00
Bendik Aagaard Lynghaug aa1a7fa572 Initial commit: content-driven onboarding portal
Leptos/Axum app that renders a Question/Alternative/Feature schema
loaded from a sibling content repo (portal-content). Kanidm OIDC login,
content-driven authorization (Question.qualifies), a generic NATS
KV-backed resource + state-transition mechanism (no bespoke "applicant"
concept baked into the runtime - it's all content), a SHA-256 DAG chain
tying submissions and decisions together, and the "YES - Rasterized
Lines" piece (ported from the live uhhm.no site) as the landing hero.
2026-07-29 19:38:40 +02:00