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>
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>
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>
- 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>
- 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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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.