Commit Graph
85 Commits
Author SHA1 Message Date
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>
v0.1.0
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>
build-2b593ba
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
bl cc5174813c Update style/main.css
Deploy / deploy (push) Successful in 34s
2026-08-06 19:01:18 +00:00
bl 0241c506cb Update style/main.css
Deploy / deploy (push) Successful in 34s
2026-08-06 18:57:17 +00: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