Commit Graph
90 Commits
Author SHA1 Message Date
Bendik Aagaard LynghaugandClaude Fable 5 a1fc333079 Attended-bucket lint rule; app-lifetime resources fix first-nav corruption
Test / test (push) Successful in 25s
validate_questions now rejects content where a record_as bucket is
read by nothing: every bucket must be listed by some Kv resource in
the same repo (a desk) or carry aggregates.yaml's new attended_by
annotation naming the automation that consumes it - no publicly
collected answer may land where nothing reads.

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:30:28 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 b737e0a7e7 Design doc: filesystem routes, sections, dynamic segments
Test / test (push) Successful in 24s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 21:58:40 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 b354db2e76 README: frame portal as a generic multi-site question engine
Test / test (push) Successful in 24s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 21:53:17 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 74965376bd Docs: release/rollout flow after the instance-ownership refactor
Test / test (push) Successful in 23s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 21:49:31 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 8858336ecc Semver releases cut with cargo-release; publish on v* tags only
Test / test (push) Successful in 23s
Publish release / publish (push) Successful in 1m8s
cargo release <level> bumps, tags v<semver>, and pushes; publish.yml
reacts to the tag and attaches the artifact to that release. Plain
main pushes now run tests (test.yml) instead of publishing build-<sha>
artifacts on every push.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 21:44:31 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 2b593ba53f Deploy becomes publish: portal ships as a versioned release artifact
Publish release / publish (push) Successful in 1m7s
The portal repo no longer deploys instances. CI builds once, packages
portal + question_lint + site/ into a tarball, and publishes it as a
Gitea release tagged build-<shortsha> using the run's ephemeral token.
Content repos (uhhm/questions, redoal/questions) now own their instance
deploys - env, unit restart, Caddy route - pinned to a release tag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 21:21:06 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 e0094f2e97 Trigger deploy: redoal OAuth2 credentials now configured
Deploy / deploy (push) Successful in 1m8s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 20:39:16 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 0def91f8ab Echo thumbnails fade in via timeout, not rAF
Deploy / deploy (push) Successful in 1m5s
The 'shown' class only needs a later tick for the opacity transition
to run - requestAnimationFrame never fires in headless engines
(caught in hwatu verification), and a timeout is equivalent in real
browsers.

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 13:03:29 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 6cd8cc6ff5 Showcase jq test tracks Gitea repo shape and website-first links
Deploy / deploy (push) Successful in 1m2s
Mirrors the index.yaml filter change in uhhm/questions: fixture now
carries Gitea 1.27's actual fields (stars_count, website, private)
and the test covers all three link outcomes - public repo without a
website links to the repo, private with a website links there, and
private without one gets url: null so the card renders unlinked
instead of 404ing for visitors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 11:27:49 +02:00
Bendik Aagaard Lynghaug 394133b687 increase translucency of laternative cards
Deploy / deploy (push) Successful in 1m4s
2026-08-17 18:50:09 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 1b7ffe0f43 Noise-driven YES, sticky hero with frosted cards, full light theme
Deploy / deploy (push) Successful in 1m0s
Three changes that belong together, all verified against a real
mounted page in headless WebKit:

- yes.js's two behavior dials (containment/wiggle) now drift on a
  smooth two-octave value-noise field at cloud pace (~25s per weather
  change) instead of following the mouse. Includes a real bug fix
  found in verification: the hash's final XOR yields a SIGNED 32-bit
  value in JS, so the "0..1" noise dipped to -0.36 without a
  reinterpreting >>> 0.

- The landing hero is position: sticky, so the piece keeps animating
  behind the whole page. Cards go translucent with backdrop blur and
  a soft shadow so the piece reads faintly through and around them
  (near-opaque fallback where backdrop-filter is unsupported). The
  hero copy fades out over the first half-screen of scroll - pinned,
  it ghosted through the cards. The bottom fade gradient is gone: its
  hard-cut reason disappeared with the canvas behind everything.

- Full prefers-color-scheme light theme: warm paper, near-black ink,
  accent deepened from dusty cyan to teal ink (#8ec2c0 washes out on
  white), wordmark inverted via filter. yes.js mirrors the palette
  itself (canvas can't read CSS vars): CMYK process-ink strokes dark
  enough to carry on paper, raster ghost repainted as multiply-blended
  gray on white, live re-theme on scheme flip with a trail clear.

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 12:18:15 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 5b3ffa62e8 Editor type scales for real: drop prosekit typography.css, fix selectors
Deploy / deploy (push) Successful in 1m16s
The earlier rem override never matched: editor.mount() turns the
.prosekit-editor div itself into the .ProseMirror root, so
'.prosekit-editor .ProseMirror p' selected nothing and prosekit
typography.css's fixed 16px kept winning. Verified by mounting the
editor standalone and reading the mount div's classes.

Rather than out-specificity a stylesheet we don't control, stop
loading it: prosekit's typography.css is optional px-based sugar. Our
own rem scale (now selected as direct children of .prosekit-editor)
plus UA em defaults for lists/blockquote/code is the whole editor
typography, all tracking the responsive root.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 23:16:16 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 7b5d4dbf52 No translucent input chrome: solid ink-dim placeholders and embed meta
Deploy / deploy (push) Successful in 1m15s
One placeholder rule for native inputs and the prosekit editor alike;
kills ProseKit's 0.3-opacity placeholder and Firefox's UA dimming. The
embed description drops inline opacity for a fixed mid-gray that stays
dim on both the newsletter's light background and our dark paper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 23:05:43 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 117cd3216d Ban px from stylesheets: every length rem/em off the responsive root
Deploy / deploy (push) Successful in 1m15s
Only the :root font-size definition itself stays absolute - it is the
base everything derives from. Embed-card inline styles in the editor
follow the same rule; already-stored embeds keep their old px inline
styles until re-edited.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 22:46:14 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 428716e479 Editor type in rem: prosekit's px typography ignored the responsive root
Deploy / deploy (push) Successful in 1m5s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 19:45:58 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 5571d21b21 Accent to a dusty press-cyan, a muted echo of the canvas's cyan ink
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 19:45:58 +02:00
Bendik Aagaard Lynghaug f3ca83b9f9 add some more width to our alternatives
Deploy / deploy (push) Successful in 1m0s
2026-08-13 14:46:30 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 357d755b8c Bound inputs: a field that loads its value from a sibling-parameterized resource
Deploy / deploy (push) Successful in 1m7s
Requirement.bind {field, param, resource}: when the watched sibling
changes (a file select, say), the bind's resource is fetched with the
sibling's value as a parameter and the result becomes this field's
value - the on-site editing flow for /develop-proposal, and a generic
select-plus-detail primitive anywhere else. Url sources gain {name}
path templating (percent-encoded, / preserved, SSRF check runs after
substitution); an empty sibling never fetches and never clears an
edit. Bound textareas render monospace.

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 23:05:23 +02:00
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 20b24f08c2 Reseed lost event history from the KV projection on transition
Deploy / deploy (push) Successful in 1m0s
A record whose event log is empty (the EVENTS stream was purged, or
the record predates event-sourcing) but whose projection exists used
to be stranded - replay found nothing, every transition failed with
"unknown item". transition_or_reseed reconstructs a minimal one-event
history from the projection's own current state and retries, making
"zap the whole event stream" a recoverable operation by design instead
of a slow-motion data loss. Reseed races are guarded by the same
expected-sequence-0 CAS create uses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 22:27:57 +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 27dac946a3 Drop the feature list's left-padding
Deploy / deploy (push) Successful in 1m1s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 20:32:53 +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 a29c025a85 get_resource/get_requirement_options: #[server(default)] on params
Deploy / deploy (push) Successful in 1m1s
An empty params map serializes to no `params` key at all on a real
client-side call, so every Resource::refetch() after an empty-params
fetch failed with "missing field params" - only ever masked before
because a resource's *first* load is always server-resolved during
SSR, never round-tripping through serialization at all. Surfaced by
the new shared batch-confirm button's post-confirm refetch, but this
already affected the old per-row confirm's refetch identically -
pre-existing, not new.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 16:53:12 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 95ad459b24 One shared, content-labeled Confirm button per alternative instead of one per row
Deploy / deploy (push) Successful in 1m5s
Each AnswerRow used to carry its own ServerAction<TransitionAnswer> and
its own "Confirm" button - a page with N open rows showed N buttons.
Selecting a transition now just toggles an entry in a shared
pending_transitions map (keyed by feature_name+item_id) owned by
AlternativeCard; one button, labeled from the alternative's own
consequence field (same mechanism Subscribers' "Send" already used),
confirms every selection at once via the new batch transition_answers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 14:32:10 +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 1c1cbfc13b deploy.yml: build and publish question_lint alongside the app binary
Deploy / deploy (push) Successful in 1m5s
questions' CI never actually ran this - the binary existed in-repo but
nothing built or exposed it. Publish it to the same stable release path
as the app binary so questions' lint job can exec it directly (same
bare-metal runner/host, no artifact download needed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 21:16:34 +02:00
Bendik Aagaard Lynghaug 3a4cb467bb add responsive container width
Deploy / deploy (push) Successful in 34s
2026-08-09 21:29:02 +02:00
Bendik Aagaard Lynghaug 2422f5fb49 yes.js: freeze .hero-yes's height via inline style, don't trust svh alone
Deploy / deploy (push) Successful in 34s
The container-rect approach (reading .hero-canvas's rendered rect via
ResizeObserver) still jumped on the real device: .hero-copy kept
sliding down as the address bar collapsed even with svh (then lvh)
driving .hero-yes's height, meaning the box's actual rendered height
wasn't holding still the way the "stable" viewport units are spec'd
to. Reading a rect only helps if what it reads is actually fixed - it
wasn't, so no amount of matching the canvas to it would help.

yes.js now writes the fix instead of reading around it: it measures
window.innerWidth/innerHeight once and sets that literal px height as
an inline style on the hero element. Inline style beats the
stylesheet's `height: 100svh` in the cascade, so the box's rendered
height becomes a fixed number in the DOM rather than something
recomputed from a viewport unit on every layout - nothing the browser
does with svh afterward can move it. The old width-gated resize
listener comes back to re-freeze on a genuine resize (orientation
change), since that's still the correct signal for "the address-bar
animation is not what's happening right now."
2026-08-06 21:38:58 +02:00
Bendik Aagaard Lynghaug d7f280e681 YES canvas: size off its own container rect, not window.innerHeight
Deploy / deploy (push) Successful in 34s
setupCanvas() measured window.innerWidth/innerHeight directly, then a
resize listener gated on innerWidth changing (guessing which resize
events were "real" vs mobile Safari's address-bar animation). That
measurement had nothing to do with .hero-yes's actual CSS height, so
the canvas and its box could end up disagreeing - which is what was
producing the observed jump in content below the hero on scroll, not
the choice of viewport unit on its own.

Switched to ResizeObserver on .hero-canvas (which tracks .hero-yes via
inset:0), using its contentRect directly. This also drops the
innerWidth-gating heuristic entirely: a fixed-height box never fires
a ResizeObserver callback during the toolbar animation in the first
place, so there's nothing to gate.

Also updated the mouse/touch position normalization to use the
canvas's own displayWidth/displayHeight instead of
window.innerWidth/innerHeight, for the same reason - and fixed the
.hero-yes comment, which still described the lvh reasoning from a
prior attempt after the height value itself had been changed back to
svh directly on origin/main.
2026-08-06 21:22:00 +02:00
Bendik Aagaard Lynghaug 1f22dda2d5 Use lvh/lvmin, not svh/svmin - svh was the wrong end of the viewport
Deploy / deploy (push) Successful in 34s
Both stable, but svh pins to the *smallest* viewport (toolbar
visible), which never grows once mobile Safari auto-collapses its
toolbar on the first scroll. The actual visible screen becomes taller
than a box sized with svh, so content below it appears to snap into
the newly-revealed space the moment the toolbar collapses - exactly
the "text jumps down with the expanded area" behavior reported.

lvh pins to the toolbar-collapsed size instead, matching the settled
state scrolling actually lands on, so there's no gap to reveal. svh
is the right choice for the opposite case (a fixed element that must
never be hidden behind the toolbar), not a hero at the top of a
scrolling page - wrong tool for this particular job.
2026-08-06 16:51:52 +02:00
Bendik Aagaard Lynghaug f0a571916d Quicksand: font-display optional, not swap, to stop the text-jump on scroll
Deploy / deploy (push) Successful in 34s
Confirmed via user testing that the jump was in DOM text below the
hero (headings using Quicksand), not the YES canvas (which draws in
plain Arial, unaffected). swap always paints the fallback font first
then reflows once Quicksand loads - a real layout shift if that swap
lands after the user has already scrolled past that heading, which
is exactly what was happening. optional skips the swap entirely
unless the font is already cached/ready almost immediately.
2026-08-06 16:48:11 +02:00
Bendik Aagaard Lynghaug f8b3f47598 Stop blocking native scroll on the YES canvas's touch handlers
Deploy / deploy (push) Successful in 34s
The real cause of the scroll jump, confirmed by "doesn't jump before
yes loads": preventDefault() on touchstart/touchmove was inherited
from the original standalone page (where it was harmless - nothing
else on the page to scroll to). Embedded as a hero, .hero-canvas
covers the entire first screen, so any touch-scroll gesture starting
there fought against blocked native scroll the whole time this
component was mounted. The position tracking these handlers do (for
the cosmetic line-cluster wiggle) never needed default prevented -
just switched to passive listeners so the browser scrolls natively.
2026-08-06 16:36:05 +02:00
Bendik Aagaard Lynghaug e630fde200 Revert canvas sizing to window.innerWidth/innerHeight, gate resize on width change
Deploy / deploy (push) Successful in 33s
The container-rect-based sizing (getBoundingClientRect on the parent
element) broke on real mobile Safari - the hero stopped rendering
entirely, most likely a layout-timing dependency window.innerWidth/
innerHeight never had. Revert to the simple, reliable measurement.

For the actual jump: mobile browsers only change window.innerHeight
(not width) as the address bar hides/shows during scroll, firing
`resize` with no real layout change to react to. Genuine resizes
(orientation change, desktop window drag) always change the width
too, so gate the redraw on that instead of reacting to every resize
event or trying to debounce/detect the toolbar animation itself.
2026-08-06 16:31:29 +02:00
Bendik Aagaard Lynghaug 71613b5ef5 Debounce the YES canvas resize handler and skip no-op resizes
Deploy / deploy (push) Successful in 33s
Even with dimensions now sourced from the stable CSS container
(previous commit), assigning canvas.width/height unconditionally
clears the canvas buffer regardless of whether the value actually
changed. Mobile Safari fires `resize` repeatedly *during* the address
-bar hide/show animation, not just once at the end, so the handler
was still clearing+redrawing on every one of those events. Debounce
to let the animation settle, then skip the redraw entirely if the
resolved size didn't change.
2026-08-06 16:24:11 +02:00
Bendik Aagaard Lynghaug f0022cd037 Size the YES canvas from its container, not window.innerHeight
Deploy / deploy (push) Successful in 34s
The rasterized "YES" text (and everything downstream: fontSize, line
rendering) was recomputed from window.innerWidth/innerHeight on every
`resize` event. Mobile Safari fires `resize` continuously as the
address bar hides/shows while scrolling, so the text visibly rescaled
mid-scroll even after .hero-yes's own height was already stabilized
via 100svh. Reading the containing .hero-canvas box's own rendered
size instead ties canvas sizing to that already-stable CSS layout, so
a toolbar-only resize recomputes to the same numbers.
2026-08-06 15:46:41 +02:00
Bendik Aagaard Lynghaug adaaf7f3e3 Add plain-unit fallbacks for svmin/svh in case of unsupported engines
Deploy / deploy (push) Successful in 33s
Couldn't reproduce the reported "hero-yes doesn't show" in a live
WebKitGTK check (svmin/svh both resolved correctly there), but an
engine that doesn't support these newer viewport units would treat
the whole declaration as invalid and drop it - for .hero-yes's height
that means falling through to auto, which collapses the section to
its in-flow content height and clips the canvas via overflow:hidden.
Declaring a plain px/vh value first means an unsupported engine keeps
that instead of falling through further.
2026-08-06 15:37:49 +02:00
Bendik Aagaard Lynghaug a6dfa30f71 Responsive base font size via svmin, not fixed 17px
Deploy / deploy (push) Successful in 34s
Scales with viewport instead of capping at a fixed px value.
svmin (small-viewport min) rather than plain vmin - plain viewport
units recalculate as mobile Safari's address bar hides/shows on
scroll, which would reflow every text size mid-scroll; svmin stays
pinned to the smallest viewport state, matching the stability
.hero-yes already gets from 100svh.
2026-08-06 15:28:32 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 2d424d70ed Use the actual UHHM-letters wordmark, not the institutional mark
Deploy / deploy (push) Successful in 37s
The institutional (triple-arch) mark is project.uhhm.no's identity,
not uhhm.no's - swapped the header wordmark for the real letterform
asset (matching infrastructure/assets/brand/wordmark-*.svg).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 14:09:20 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 bdab23d0f6 Use the real institutional logo for the header wordmark, not plain text
Deploy / deploy (push) Successful in 35s
The header link was just styled "UHHM" text, unrelated to the actual
brand mark - swapped in the institutional logo (the three-arch
"building" mark, matching infrastructure/assets/brand/institutional-*.svg
and Gitea's own homepage logo) instead. Dark-stroke variant only -
portal is a single permanent dark theme, no light mode to switch for.
Same explicit-route treatment the favicon files needed (the /*any
wildcard route would otherwise swallow the request before the static
file handler gets a chance).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 13:59:03 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 400316f622 Inline encouragements with the submit button, vertically centered
Deploy / deploy (push) Successful in 33s
Was stacked vertically; now a row (encouragements left, button right,
center-aligned), falling back to the stacked layout under 640px where
a row would get cramped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 13:03:43 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 a200161334 Fix bucket-404 on empty resources, redesign review actions as select-then-confirm, CSS polish
Deploy / deploy (push) Successful in 35s
get_resource's Kv path treated a not-yet-created bucket (nothing
submitted there yet) as a hard error instead of an empty list -
projects bucket never got created since the backfill found nothing to
migrate, so /review's Projects alternative 404'd outright. Now matches
store_answer's own "doesn't exist yet is normal" posture; a specific
key request still errors, only listing degrades gracefully.

AnswerRow's Invite/Decline buttons fired transition_answer immediately
on click, with no staging step and no visible confirmation once it
landed (the resource list never refetched, so a click barely looked
like it did anything). Redesigned as toggle-select (reusing the
.select-option pattern from the resource-backed select requirement)
plus one explicit Confirm button; a successful transition now refetches
the parent Resource so the row actually reflects the change.

CSS: soften the hard cut where the hero canvas meets the page
background below it (gradient fade over the last few ems, matching
--paper); center the "Asked by X" responsible note with the em-dash
starting its own line; give it and the report button real styling
(previously unstyled default text).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 12:42:54 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 11c6f3e8f7 Fix favicon (real icon, not an unrelated orange circle) + stack encouragements with the submit button
Deploy / deploy (push) Successful in 35s
favicon.svg was a plain solid circle, unrelated to the actual brand
mark - replaced with the real single-arch icon (matching
infrastructure/assets/icon-{light,dark}.svg), split into two
prefers-color-scheme-scoped files rather than one SVG with an embedded
@media query - the latter doesn't reliably re-evaluate via <link> on
Safari/iOS (confirmed by prior real-device testing on the Gitea side,
see gitea-head.tmpl).

Encouragements used to render right after the description, separated
from the actual submit button by the whole features/requirements
block - moved them into the same wrapper as the form, directly above
the button, so reassurance text and the call to action read together.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 12:11:17 +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 43fa496778 Fix recursion-limit build failure in the new item-card renderer
Deploy / deploy (push) Successful in 33s
Same class of bug as the earlier ResourceValue/AnswerRow one: impl
IntoView doesn't erase a component's concrete type within the same
crate, so nested <For> children closures need their own explicit
.into_any() at the call site, not just at the outer return - missing
it on the two new <For>s (item list, item-card-fields) overflowed the
query depth limit on release build. Verified locally with a full
`cargo build --release` this time, not just cargo check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 11:34:18 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 bd702bfbae Render generic resource lists as cards, not raw JSON dump
Deploy / deploy (push) Failing after 32s
A jq-shaped resource (e.g. the GiteaStarred showcase) previously fell
through to the raw <pre> JSON dump - same fallback path meant for
truly unstructured data. Added a card renderer for the common case
(array of plain objects): name/title as a linked heading, description
as body text, everything else as small key/value chips - same visual
language as AnswerRow's answer-fields, just for non-Answer data.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 11:31:42 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 1db558974e Trigger redeploy to pick up PORTAL_GITEA_API_TOKEN secret
Deploy / deploy (push) Successful in 31s
No code change - the secret was just set (Settings -> Actions ->
Secrets), and the running service's env file only gets written at
deploy time, not live.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 10:32:39 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 3a2a19b79b Add manually-triggered backfill workflow, no terminal/sudo needed
Deploy / deploy (push) Successful in 31s
Runs backfill_events using secrets.PORTAL_NATS_URL directly from the
CI job's own env (same source deploy.yml's env-write step already
uses) - avoids needing a sudo'd terminal session to read
/etc/app/uhhm-portal.env's secrets off disk just to run a one-time
migration tool. workflow_dispatch only, defaults to dry run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 10:09:08 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 8f0eead8da Cargo.toml: disambiguate bin-target for cargo-leptos
Deploy / deploy (push) Successful in 1m13s
The new question_lint/backfill_events [[bin]] targets left cargo-leptos
unable to tell which of the three binaries is the actual app server -
broke the last two deploy runs ("Several bin targets found for member
portal"). bin-target = "portal" fixes it; confirmed locally that
`cargo leptos build` gets past config resolution now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 08:57:58 +02:00
Bendik Aagaard LynghaugandClaude Sonnet 5 f6c5d8e05c deploy.yml: write GITEA_API_TOKEN for the new GiteaStarred/OrgRepos resource sources
Deploy / deploy (push) Failing after 3s
Needs the PORTAL_GITEA_API_TOKEN repo secret set (Settings -> Actions ->
Secrets) - a fresh read-only token, not yet wired in as a value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 08:55:27 +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 6df895b1d3 Fix get_resource/transition_answer: scope feature lookup to its own alternative
Deploy / deploy (push) Successful in 28s
Both resolved a feature by name flattened across every alternative on
the question, not scoped to the one the caller actually meant - fine
when every question had exactly one alternative with a resource
feature, silently wrong now that /review has three (Applicants,
Inquiries, Subscribers all use the same empty feature name). Every
lookup always resolved to the first alternative's feature - Subscribers
was reading the (empty) Applicants bucket instead of its own, and
Invite/Decline/Mark-handled buttons would have had the same problem
had two of those transitions ever been clicked side by side.

Threaded a new `alternative` parameter through get_resource,
transition_answer, and their client-side callers
(ResourceFeature/ResourceValue/AnswerRow) - the fix lives entirely in
the already-generic resource-fetching machinery, no per-alternative
special-casing.
2026-08-05 15:04:02 +02:00
Bendik Aagaard Lynghaug c73981da2d yes.js: guard against the canvas not being in the DOM yet
Deploy / deploy (push) Successful in 28s
The Rust-side NodeRef gate (previous commit) didn't actually close the
race on fast client-side re-navigation back to / - reproduced the
same crash again after that fix shipped. Harden the actual failure
point directly instead of chasing the exact Leptos/wasm-bindgen
timing: skip setup (not throw) if either canvas is missing.
2026-08-05 14:55:55 +02:00
Bendik Aagaard Lynghaug 00d2a95625 Fix hero-canvas navigation race; style the prosekit editor and add a toolbar
Deploy / deploy (push) Successful in 28s
- Hero: gate RasterizedYES construction on a NodeRef resolving, not
  just "the Effect ran". Root cause of the reported "loading..." stall
  + "RefCell already borrowed" panic - navigating back to / client-side
  could run the Effect before the new <canvas> was actually in the
  DOM, yes.js did an unchecked getElementById(...).getContext() on
  null and threw mid-reactive-update, corrupting wasm_bindgen_futures'
  executor badly enough to panic on the next tick. Same NodeRef-gating
  pattern the prosekit editor's own mount already used.
- style/main.css: .prosekit-wrap/.prosekit-toolbar/.prosekit-editor to
  match the existing input/textarea look (border, background, focus
  ring).
- prosekit-editor.js: a real toolbar (Bold, Italic, H1, H2, Link, and
  a dedicated Gitea-repo-embed button reusing the paste rule's own
  insert logic) with active-state highlighting via the standard
  ProseMirror markActive/nodeActive idiom.
2026-08-05 14:52:08 +02:00
Bendik Aagaard Lynghaug 3242482c8e Redirect back to the originating page after sign-in, not always /
Deploy / deploy (push) Successful in 29s
Sign in link now carries ?redirect=<question_id>; /auth/login stashes
it in the session (validated same-site-path-only to avoid an open
redirect via a crafted query param), /auth/callback reads it back for
the final redirect instead of a hardcoded "/".
2026-08-05 14:40:44 +02:00
Bendik Aagaard Lynghaug d5bbe799ce Fix prosekit-editor.js: remove bare CSS imports that aborted the whole module
Deploy / deploy (push) Successful in 28s
import 'x.css' with a real text/css response isn't a valid JS module
without a `with { type: 'css' }` assertion browsers don't uniformly
support - it threw and prevented mountEditor from ever being defined,
so the prosekit container just stayed empty. ensureStylesheet() (a
<link> tag, already written) is the actual CSS-loading path; the
top-level imports were dead-wrong duplicates of it.
2026-08-05 14:23:46 +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 Lynghaug b0086b2ed9 Fix static asset 404s and build-time SITE_NAME
Deploy / deploy (push) Successful in 27s
Two runtime gaps found via hwatu after the first successful deploy:
- /pkg/*.{js,wasm,css} 404'd because the running binary looked for
  target/site (Cargo.toml's site-root, a build-time path) but the
  deploy step copies the bundle to releases/<sha>/site with no
  target/ prefix - LEPTOS_SITE_ROOT=site in the runtime env corrects
  it.
- SITE_NAME is read via option_env! (compile-time), so writing it
  only into the runtime env file never reached the binary - the
  wordmark stayed "portal". Now exported for the Build step too.

Verified live: applied both fixes directly to the running deployment
first (no CI round-trip), confirmed via hwatu (no console errors,
hero renders, all three alternatives' forms present) before folding
them back into the workflow.
2026-08-04 20:46:00 +02:00
Bendik Aagaard Lynghaug 6b1af34d57 Caddy snippet: use multi-line log block
Deploy / deploy (push) Successful in 27s
Single-line "log { output file X }" parses fine directly in the main
Caddyfile but not through import services.d/*.caddy - Caddy rejects it
with "Unexpected next token after '{' on same line". Verified the
multi-line form validates cleanly (caddy validate) against the real
Caddyfile.
2026-08-04 20:39:12 +02:00
Bendik Aagaard Lynghaug 70a7586471 Drop sudo from the deploy workflow - NoNewPrivileges blocks it outright
Deploy / deploy (push) Failing after 27s
sudo needs setuid escalation, which the runner's own unit disables
entirely via NoNewPrivileges=yes (unconditionally, independent of any
sudoers config - the earlier sudoers.d/deploy-runner grant was never
going to work). Caddy reload already works via the runner's existing
docker group membership; the service restart now goes through plain
systemctl (talks to PID1 over D-Bus, no escalation needed), authorized
by a new polkit rule scoped to deploy-runner + app@*.service.
2026-08-04 20:36:54 +02:00
Bendik Aagaard Lynghaug 634e8dcdfa Fix Ship release: site-root is project-relative, not CARGO_TARGET_DIR
Deploy / deploy (push) Failing after 26s
cp was looking for the site bundle under $CARGO_TARGET_DIR/site, but
site-root = "target/site" in Cargo.toml is resolved relative to the
project directory regardless of CARGO_TARGET_DIR - only the plain
cargo build outputs (release/, front/) actually move when that's
overridden.
2026-08-04 20:31:58 +02:00
Bendik Aagaard Lynghaug 6ac64eeb8f Redirect cargo-leptos's tool cache out of act-runner-bare's StateDirectory
Deploy / deploy (push) Failing after 27s
wasm-bindgen/wasm-opt binaries cargo-leptos downloads under
XDG_CACHE_HOME ended up owned by the kernel's overflow "nobody" uid
inside systemd's StateDirectory for this unit - a quirk of that
specific path, not anything about our shared-cache setup (CARGO_TARGET_DIR
elsewhere is owned correctly). Pointing XDG_CACHE_HOME at our own
shared /var/local/leptos-cache sidesteps it; verified with a clean
checkout + a from-scratch env matching the workflow's exactly.
2026-08-04 20:30:09 +02:00
Bendik Aagaard Lynghaug 44c2fa8f82 Erase view types at every list/component boundary, not just leaves
Deploy / deploy (push) Failing after 27s
Release builds were hitting rustc's recursion limit compiling the
hydrate target: impl IntoView doesn't erase a component's concrete
type within the same crate, so calling a component (or a <For>/<Show>
children closure) without wrapping the result in .into_any() lets the
caller's own type keep growing to include everything nested inside it.
AlternativeCard's requirement fields already did this correctly at
their own leaves; QuestionView's <For alternatives> call into
AlternativeCard, and AlternativeCard's own <For features> closure,
plus ResourceValue/AnswerRow's nested <For>s, didn't - so the depth
compounded across all of them. Verified with a real
`cargo leptos build --release` (was previously untested - only dev
builds had been run against this code).
2026-08-04 19:58:55 +02:00
Bendik Aagaard Lynghaug abe13c139c Give CI its own SCCACHE_SERVER_PORT
Deploy / deploy (push) Failing after 1m21s
Sharing the default port meant whichever context's server happened to
be running (usually the interactive shell's, via rust-analyzer's
background cargo checks) silently served the other's build requests
too - and since sccache's server writes outputs under its own user,
that meant permission-denied for whichever context didn't own it.
Separate ports keep the server processes apart; SCCACHE_DIR is what's
actually shared, giving real cache hits across both.
2026-08-04 13:30:39 +02:00
Bendik Aagaard Lynghaug 76d101b7a8 Share sccache between manual dev builds and CI
Deploy / deploy (push) Failing after 4s
SCCACHE_DIR/CARGO_TARGET_DIR now point at /var/local/sccache and
/var/local/cargo-target, shared with the interactive dev shell via a
new `builders` group, instead of the CI job hitting a leftover
personal sccache server it had no permission to write build output for.
2026-08-04 13:27:22 +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