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.