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>
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>
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."
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.
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.
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.
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.
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.
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.
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.
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.
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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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.
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.
- 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.
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 "/".
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.
- 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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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>
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.