Skip to content

refactor(scss): warm-wash, film-grade, on-accent, monogram/avatar/pulse tokens - #363

Closed
ryzizub wants to merge 38 commits into
2026from
agent/devf-55-rest
Closed

ryzizub wants to merge 38 commits into
2026from
agent/devf-55-rest

Conversation

@ryzizub

@ryzizub ryzizub commented Sep 10, 2026

Copy link
Copy Markdown
Member

Summary

DEVF-55, batch 1/5 of the "zbytek v jednom PR" plan: PR 11 (S-D4, S-D9, S-D14, S-D3 step 1) plus the remainder of PR 28 (S-D15, S-D16, S-D17). Draft — more batches land in this same PR before it's ready for review.

  • S-D3 step 1--focus-ring: 2px solid var(--color-accent-hot) token; all 37 restated :focus-visible outlines (33 outside BaseLayout.scss + 4 inside it, including the canonical rule) now read outline: var(--focus-ring). Step 2 (dropping the ~24 offset-only restatements) is deferred to its own PR per the report.
  • S-D4--wash-warm / --wash-warm-lit tokens for the 104deg warm-wash gradient (row default + the 3-copy cell/tile variant). The two genuinely one-off recipes (Tickets.module.scss's mobile boost, InvoiceForm.module.scss's .success confirmation state) keep their own local value with a reason comment, as instructed.
  • S-D9@mixin film($contrast, $brightness) in new src/styles/_film.scss. Every one of the 9 sites keeps its own measured contrast/brightness through the mixin parameters — none of the drifted values were unified.
  • S-D14 — five measured on-accent colours: --ink-on-accent, --accent-deep, --field-border-on-accent (new), plus raw #F7EFE6 sites now reusing the pre-existing --on-accent. #FFFFFF (hover white) was left raw — the report proposed no token name for it.
  • S-D15@mixin monogram($tracking, $size: var(--fs-monogram)) in src/styles/_type.scss. team.scss keeps its own clamp() size and text-shadow — only the shared part goes through the mixin, per instruction not to unify that drift.
  • S-D16@mixin avatar-ring($width, $color) in new src/styles/_avatar.scss. Ring-colour drift (#0b0a0a / var(--panel) / var(--rule)) preserved per site.
  • S-D17 — one shared @keyframes pulse-opacity in BaseLayout.scss, replacing statePulse/skelPulse/agendaPulse. agendaPulse had zero animation: references anywhere (dead, matches S-O8) so it's removed rather than migrated — the local @keyframes block and its now-empty section comment are gone.

The one real value fix, called out separately as instructed: faq.scss:63's warm-wash alpha was .028 where the canonical recipe (and every other copy) is .026 — a drifted value nobody chose. Corrected to .026, which also lets it share --wash-warm with the canonical site.

Verification

  • sass --style=compressed compiled before/after on all 23 touched files (baseline = origin/2026 at the start of this batch, 7de89163). Every diff was inspected rule-by-rule; each one traces to a token/mixin indirection from the list above, or to the faq.scss value fix — nothing else moved. Where the compiled text differs (a raw literal replaced by var(...), or a mixin-driven property reorder within the same selector), the change is the intended one and computes to the identical value.
  • npm run build — clean, 15 pages, no errors.

Files

BaseLayout.scss, faq.scss, Agenda.module.scss, partners.scss, press/downloads.scss, InvoiceForm.module.scss, Tickets.module.scss, Sheet.module.scss, Sessions.module.scss, Menu.scss, SpeakersTeaser.module.scss, CookieBanner.scss, Speakers.module.scss, SpeakerDetail.module.scss, NewsletterForm.module.scss, Ticker.scss, Footer.scss, privacy-policy.scss, index.scss, team.scss, HeroBackground.scss, SessionDetail.module.scss, DataState.module.scss, plus new src/styles/_film.scss, src/styles/_avatar.scss and an addition to src/styles/_type.scss.

Batch 2/5

  • S-D17 correction — the batch-1 shared @keyframes pulse-opacity in BaseLayout.scss was unreachable: CSS Modules hash keyframe names per file, so a .module.scss's animation: can never resolve a name defined in the global stylesheet. Replaced with @mixin pulse-opacity($name: pulse-opacity) in new src/styles/_motion.scss, included in both consumers (Sessions.module.scss, DataState.module.scss) so each gets its own scoped @keyframes copy. The stale global block in BaseLayout.scss is removed.
  • S-D9 completion — two copies missed in batch 1: index.scss:445 (gallery hover, grayscale(1) contrast(1.06) brightness(0.78)) and Sessions.module.scss:184 (avatar hover, same shape) now go through @include film. @mixin film now has 10 call sites across 9 files.
  • S-D11 — the "lede" recipe (Special Elite intro prose): 11 copies found (BaseLayout.scss ×3, SubpageHero.scss, index.scss ×2, press.scss, DataState.module.scss, SessionDetail.module.scss, SpeakerDetail.module.scss, Sessions.module.scss) collapsed into @mixin lede($line-height, $color, $margin: 0, $max-width: none) in _type.scss. Line-height (1.5/1.6/1.7), color and margin/max-width all drift per site through the mixin params — none snapped. SessionDetail/SpeakerDetail pass no $max-width (their column is the measure, unchanged).
  • S-D12 — the hairline-underline link recipe: 12 copies (9 red-rule + 3 cream-rule) across InvoiceForm.module.scss, SpeakersTeaser.module.scss, Tickets.module.scss, CookieBanner.scss, NewsletterForm.module.scss, privacy-policy.scss, faq.scss, press/downloads.scss (×2), Footer.scss (×3) collapsed into @mixin link-rule($color, $border: var(--rule-red)) in new src/styles/_link.scss. Callers keep their own min-height, padding-bottom, ::after arrow and :focus-visible outline extras.
  • New required check (per instruction): after npm run build, every animation: name in dist was matched against a @keyframes in the same compiled file/page — no misses.

Nothing from batch 1 was reopened besides the S-D17/S-D9 corrections above. Not merging, not deploying.

Batch 3/5

  • Fixed the two snapped sites Mika flagged at the top of batch 2 (194b8113): SpeakersTeaser.module.scss .allLink and press/downloads.scss .inline-link were quietly moved onto the shared link-rule mixin even though their hover/transition shape didn't match it (.allLink had no border-color hover; .inline-link had no hover/transition at all). Reverted both to raw, same as the 7 raw mono sites — if one-off snapping is approved later, they land there.
  • PR 4 — chore: dead code sweep. O-A1b/O-A6 (faq.astro numbering block + unreferenced id), O-A2 (Desk level/action props + dead .desk-action CSS), O-A3b (partners.astro DECK_READY dead branch + ~55 lines of unreachable CSS), O-A4/O-A5 (dead .invoice-page / unstyled .press-section), O-R1 (unexported SpeakerLineup), O-R2 (unused formRef), O-R3 (AgendaPartition.roomTba never read outside agenda.ts), O-R4/C-R7 (SpeakersTeaser unreachable effect + its stale comment), O-R6 (Countdown initial-time literal deduped), O-R7 (getPartnerTiers made module-private), O-S6 (.itemLive border-color on a zero-width border — mobile agenda's "on now" mark was a no-op, fixed), O-S7 (five dead @keyframes in BaseLayout.scss; fadeInUp kept, it backs index.scss's reveal animation), O-S9 (.kit-badge's dead first letter-spacing), O-S10 (Sessions .avatarMono/.avatarMore no longer restate properties the cascade already overrides), O-S14 batch (redundant .red rules, dead .u-prose, empty media query, doubled display:flex), O-F11/O-F12/O-F13 (dead functions exports, unread fields, hand-rolled Storage types replaced by the real SDK types), C-O21 (@types/react* moved to devDependencies). O-A7/O-S2 dropped — already fixed upstream. O-R5 left alone (no proposed fix; may be required by the SmartEmailing form contract even unpopulated).
  • PR 5 — fix(invoice): stop stranding a paid invoice in processing (O-F2). claimInvoiceForProcessing now stamps processingSince; a new listStrandedProcessing(30min) finds docs stuck in processing by a crash between claim and completion, and the poller reclaims + retries them (idempotent — the code is persisted before delivery).
  • PR 6 — ci: harden the deploy workflows, without C-O7. Pinned firebase-tools and w9jds/firebase-action (SHA, not tag); added concurrency to every workflow (queue on the two deploy workflows, cancel-on-repush on the PR-triggered ones); added permissions: contents: read where missing; widened accessibility.yml's path filter to scripts/**; firebase-functions-merge.yml now fails loudly on an empty TITO_ACCOUNT_SLUG/TITO_EVENT_SLUG repo var instead of deploying an empty slug. C-O7 (same-repo PR branch can reach production hosting) intentionally untouched — decision, not mechanics.
  • PR 14 — refactor(functions): one helper for a non-OK HTTP response (F-D1). assertOk(label, res) in lib/http.ts, adopted at all 7 call sites.
  • PR 15 — refactor(functions): one ti.to client for both domains (F-D2/F-D3). tickets/tito-api.ts now exports TITO_API_BASE/titoHeaders/TitoCredentials; tickets/params.ts gains requireTitoConfig(). The invoice domain's own TitoConfig type and header/base-URL duplication are gone; process.ts/poll.ts previously built the ti.to config triple with no guard at all — now go through the same guarded helper as the tickets domain.
  • PR 16 — fix(functions): coalesce concurrent /api/* cache misses (O-F3). cachedJsonEndpoint now tracks the in-flight promise, not just the resolved memo. New test file, 2 cases.
  • PR 17 — refactor(sessionize): deduplicate the All-view guards and validators (F-D4/F-D5). allViewArray() replaces the repeated "is this the All-view object" preamble at 4 call sites; validateEntities<T>() replaces validateSpeakers/validateSessions's near-duplicate bodies (both exported names stay thin wrappers).
  • PR 18 — fix(invoice): type the iDoklad envelope; hash the IČO in the rate-limit log (O-F4/O-F9/O-F5). Four anys on the money path replaced with unknown + a real IdokladEnvelope interface; the OAuth token response and lib/run.ts's HealthState read are now narrowed instead of blindly cast; the rate-limit log now cites the same SHA-256 key the throttle already keys on instead of the raw IČO.
  • PR 22 — refactor(scripts): one fixture server, one route list (C-D1/C-D2/C-D5/C-D6). New scripts/lib/fixture-server.mjs and scripts/routes.mjs, both audit scripts now import them; openPage() replaces a 3x-repeated preamble; the mobile-agenda block now reuses auditModals() instead of hand-rolling its own dialog flow. Fixed the /agenda sitemap-priority omission C-D2 flagged (now 0.9, matching /speakers//sessions).
  • PR 25 — chore: drop dead config (C-O19/C-O20/C-O22/C-A10). tsconfig.json exclude list tightened (133 → 65 files scanned by astro check, same 9 pre-existing errors). CSP change (C-O20), called out separately as instructed: dropped the dead *.firebasedatabase.app connect-src entries (no browser code opens RTDB) and frame-src https://www.youtube.com (zero <iframe> in src/) from firebase.json; https://www.google.com stays for reCAPTCHA. Also added a Permissions-Policy header (C-O22) and removed the dead prefetchAll config + its stale comment (C-A10).
  • PR 27 — refactor(ci): composite build action (C-D3). New .github/actions/build-site composite action, used by both hosting workflows after PR 2/PR 6 landed (avoids the conflict the report flagged).
  • O-F10 (ti.to params import direction) intentionally not done — flagged as pending Dominik's decision, per this batch's instructions.

Verification

  • npm run build (A11Y_MOCK=1) — clean, 15 pages, every batch.
  • astro check — still exactly 9 pre-existing errors (same as origin/2026 baseline, verified via git stash), no new ones, across every commit in this batch.
  • functions && npm test — 19/19 (2 new, for the cache-coalescing fix), across every commit.
  • npm run a11y — all 15 pages + the mobile agenda dialog flow pass, after PR 22's script refactor.
  • npm run anchors — all 42 anchor landings + all 660 header checks pass, after PR 22's script refactor.

Branch agent/devf-55-rest, origin/2026 merged in (no new commits since batch 2, verified up to date). Not merging, not deploying — PR stays draft.

Batch 4/5

React islands, no visual change intended.

  • PR 9 — refactor(react): useRemoteData (R-D1). New src/lib/useRemoteData.ts hook (fetch + loading/error/data state); adopted at all 6 call-sites (Speakers, Sessions, Agenda, SpeakersTeaser, Tickets, InvoiceForm). +160/-166 across 7 files.
  • PR 10 — refactor(react): useDialog + <Sheet> (R-D2, O-R16, R-D5, R-D6 paragraph helper). New src/lib/useDialog.ts (focus trap, Esc-close, autofocus) and src/components/Sheet.tsx, adopted by SessionDetail/SpeakerDetail; new src/components/SpeakerAvatars.tsx; paragraphs() moved into src/lib/sessions.ts. O-R16: #main-content now gets a refcounted inert while any dialog is open — a stacked SpeakerDetail over SessionDetail holds it until both close. No change to the close-on-Esc contract; no new backdrop-close behavior. +431/-350 across 8 files.
  • PR 19 — fix(forms): validation and a11y consistency (O-R8, O-R9, O-R10, O-R15, O-R18, O-R19, O-R22). InvoiceForm + NewsletterForm: ticket count keeps the raw string while typing, validated (and any error surfaced) on blur/submit; update() now uses functional setFields; wire() caches its ref callbacks; NewsletterForm no longer disables its submit button (matches InvoiceForm's pattern, keeps the error text reachable); the error live region is role="alert" only while there's an error; the client now reads the server's field name from details.field first, falling back to parsing message only through an allowlist. +111/-41 across 2 files.
  • PR 24 — refactor(react): time & shared helpers (R-D3, R-D4, O-R13, R-D6, R-D7, O-R20, O-R21). New useMediaQuery/usePrefersReducedMotion; pragueNow removed in favor of the already-exported pragueParts; nowState/byStart now read from a memoized placements map instead of re-parsing times; speakerNames/round2 centralized; TicketsSection extracted out of 4 repeated blocks; formatAmount replaces formatPrice. O-R21: InvoiceForm now derives laterWaveOnSale the same way Tickets.tsx:231 does — a real bug fix, not just a rename. +194/-119 across 10 files.

Verification

  • A11Y_MOCK=1 npm run build — clean, 15 pages, every commit.
  • npm run a11y — 15/15 pages + the mobile agenda dialog flow pass, every commit.
  • npx astro check — still exactly 9 pre-existing errors, no new ones, every commit.
  • No live browser in this runtime — PR 9/10 coverage is a static diff trace, same caveat as prior batches.

Known gap, deliberate: O-R19 was fixed client-side only in this batch (allowlist fallback on message, primary read from details.field); the server (functions/src/invoice/submit.ts) didn't send details.field yet — closed in batch 5.

Batch 5/5 (final)

Three small fixes, then Astro/visual, then docs.

  • fix(react): stable onCloseAgenda.tsx/Sessions.tsx passed onClose={() => setSelected(null)} inline; useDialog has onClose in its effect deps, so Agenda's per-minute re-render (useNowMinutes) replayed the autofocus effect and jumped focus back to Close. Pre-existing on baseline, cheap to close now. +6/-4, 2 files.
  • fix(invoice): O-R19 server-sidesubmitInvoiceCallable now throws through a new invalidArgument(field) helper that sets details: { field }, not just message, closing the client-side-only fix from batch 4. New test file (submit.test.ts, 1 case). +27/-2, 2 files.
  • PR 8 — refactor(astro): Closer slot + /partners de-duplication (A-D1, A-D13, A-D4 partners half, O-S5). Closer.astro gets a default <slot/> + optional class prop; /partners uses it instead of a hand-rolled copy of the same markup. Its two logo loops (tiers, media/community) collapse into one array + one loop; the shared link-wrap/is-plated logic moves into new PartnerLogo.astro. partners.scss's dead, unscoped .contact-name is deleted, the surviving one wrapped in :global(.band--accent) like .email-link already is. No visible change. +66/-78, 4 files.
  • PR 12 — refactor(astro): SectionHead + press SCSS deletion (A-D2, S-D5). New SectionHead.astro wraps the global .head-stack primitive; /press and /press/downloads both switch to it. Visible change on both: neither page's local .section-head matched the canonical spacing — /press's header padding-bottom 1.1rem → 1.6rem, border color var(--rule)var(--rule-soft); /press/downloads's header gains the border-bottom + padding it never had. The other 8 pages A-D2 names already use .head-stack/.head-split correctly and are not touched here (same-output swap, no value fix riding along — left out to keep the diff to the pages that actually change). +34/-46, 5 files.
  • PR 13 — refactor(astro): NextSteps + EmptyState + NoScriptNote (A-D3, A-D8, A-D9, S-D6). Three components: NextSteps.astro (the "what now?" band, triplicated across /404, /thank-you, /newsletter-subscription-thank-you), NoScriptNote.astro (the <noscript> fallback on /agenda, /sessions, /speakers), EmptyState.astro (the "nothing here yet" block on /press, /press/downloads, CSS moved into BaseLayout.scss beside .fallback-note). Visible change on /press and /press/downloads: the two local .empty-state recipes had drifted (52ch/56ch width, --fs-label/--fs-label-xs stamp, 1.6/1.7 line-height) — the shared primitive takes 56ch/--fs-label/1.7. +151/-140, 14 files.
  • PR 20 remainder — refactor(scss): .sr-only global primitive (S-D7). Three scoped .sr-only/.srOnly copies (Countdown, NewsletterForm, privacy-policy) all omitted clip-path; deleted in favor of the global one, with Countdown.tsx/NewsletterForm.tsx now referencing it by literal class name per DESIGN.md's MUST. A-D14 (.back-link styled twice) intentionally not done — the two recipes disagree on type scale and focus treatment, so unifying them is a value decision, not a mechanical dedup; deferred. +2/-38, 5 files.
  • PR 29 — docs: refresh DESIGN.md / CLAUDE.md (O-S17 corrections, S-D19 cross-references). --fs-figure removed from the ramp table (zero call sites, no longer declared); Footer.scss's two font-size: inherit corrected off the ramp-bypass list (they reset <small>'s UA default, not a bypass); new src/styles/*.scss row documenting the five mixin partials this review pass added; new "Shared building blocks" paragraph in CLAUDE.md naming the hooks/components from all five batches. C-D4 and the full O-S19 line-renumbering pass are not done — left for a follow-up, noted in the doc. +38/-29, 4 files.
  • fix(astro): PartnerLogo Image spread — the naive version of PR 8's PartnerLogo.astro pushed astro check to 11 errors (a Record<string, unknown> spread against <Image>'s discriminated-union props doesn't type-check cleanly). Folded class/alt/loading into the same object before the cast instead of spreading alongside them — back to 9. +10/-2, 1 file.

PR 21 (.btn-primary composition, S-D8/S-D13) and PR 26 (speaker print plate, S-D1) intentionally not done. Both are flagged in the report itself as needing a per-value decision, not a mechanical dedup: the four .btn-primary-shaped recipes (BaseLayout.scss, Tickets, InvoiceForm, NewsletterForm) have already drifted on hover box-shadow blur/spread (12px/30px vs 10px/26px vs 8px/22px) and on whether the button lifts on hover — composing them onto the shared class would change that behavior, and it's cascade-order-dependent in a way this runtime has no live browser to verify. The print plate's proposal itself says "the drifted values force a per-value decision" (bracket size, grade, vignette center all differ between Speakers/SpeakersTeaser). Both deferred pending a value decision, same posture as the other pending one-off items.

Verification (batch 5, full branch)

  • A11Y_MOCK=1 npm run build — clean, 15 pages.
  • npm run a11y — 15/15 pages + mobile agenda dialog pass, WCAG 2.2 AA, every commit.
  • npm run anchors — 42/42 anchor landings, 660/660 header checks pass.
  • npx astro check — 9 pre-existing errors, unchanged from origin/2026 baseline.
  • functions && npm test — 20/20 (1 new, for the O-R19 server fix).

Branch agent/devf-55-rest, origin/2026 (651b43d1, #362) merged in — it's an ancestor of this branch's tip. This batch closes the "zbytek v jednom PR" plan: batches 1–3 (SCSS + backend), batch 4 (React islands), batch 5 (three fixes + Astro/visual + docs). Not merging, not deploying — moving this PR from draft to ready for review.

Merge 2026 (d1774cfd, #325 invite pages + #364 ti.to secret + #361 Impeccable fix pass)

12 conflicting files, resolved manually (no 2026 value or 363 structure dropped):

  • .github/workflows/firebase-hosting-{merge,pull-request}.yml — kept the shared build-site composite action (PR 27); added an invite-discount-url input to the action so both workflows still pass PUBLIC_INVITE_DISCOUNT_URL through to the build (feat(invite): wire the ti.to discount URL as a secret, not repo code #364's addition).
  • package.json/package-lock.json — kept @types/react/@types/react-dom in devDependencies only (PR 4's move), added @resvg/resvg-js (new dependency from the invite-image pipeline); lock regenerated with npm install --package-lock-only.
  • scripts/a11y.mjs — kept the shared AUDIT_ROUTES list + fixture-server.mjs (PR 22), deleted 2026's now-duplicate inline path list and local static-file server; added the new /invite/eliska-cejpova/ route to scripts/routes.mjs so both the a11y and anchor sweeps cover it.
  • src/components/Sessions.module.scss — kept 2026's Impeccable-approved values (the .chip ambient red focus glow removed, .moreDots made static instead of pulsing) on top of 363's structure; the now-unused pulse-opacity mixin include and _motion import were removed from this file since nothing in it still animates.
  • src/components/{SpeakerDetail,SpeakersTeaser}.module.scss, src/pages/team.scss — kept the @include monogram(...) mixin call (PR 11's structure); values are identical to 2026's raw properties, so no visual change.
  • src/components/Tickets.tsx — kept 363's useRemoteData-destructured status, combined with 2026's actual fix: the empty-state branch no longer returns null when ti.to slugs are missing (that used to delete #tickets from the DOM after hydration and break every /#tickets link).
  • src/lib/tito.ts — kept both additions: 363's round2 (GA4 rounding) and 2026's checkoutUrl/eventUrl (used by the new InviteCta.tsx); removed the accidental duplicate eventUrl the merge left behind.
  • src/pages/partners.scss — restored 2026's .contact-name rule, which the merge would otherwise have silently dropped even though partners.astro still renders that class.

Verification, all foreground (npm run build, npx astro check, npm run a11y, npm run anchors, functions && npm test) — none backgrounded:

  • A11Y_MOCK=1 npm run build — clean, 27 pages (up from 15: 2026 added the 11 /invite/<member>/ pages + one shared og-image route).
  • npx astro check10 errors, not 9: the 9 known baseline errors plus one new one already present on origin/2026 itself (src/pages/og/invite/[member].png.ts:276, a satori() argument-type mismatch) — not introduced by this merge or by anything in PR 363's own commits.
  • npm run a11y — 16/16 pages incl. /invite/eliska-cejpova/, WCAG 2.2 AA, no regressions.
  • npm run anchors — all anchor landings within tolerance, 704 header checks pass.
  • functions && npm test — 20/20.

Not merging, not deploying.

ryzizub and others added 30 commits September 10, 2026 21:59
Name the six recurring cream-alpha steps (rgba(240,237,230,α)) as
--ink-strong/--ink/--ink-soft/--ink-meta/--ink-muted/--ink-faint next
to the existing --rule/--field-border tokens, and fold the two raw
--field-border literals in Footer.scss into the existing token.

Pure substitution, no value changes. One-off alphas (.56 etc.) are
left as raw literals — snapping them to the nearest step is a
separate, undecided call.
Duplicate of --field-border (same 0.4 alpha), zero call sites.
Introduce src/styles/_type.scss with @mixin mono($size, $track, $case)
and replace the repeated font-family: var(--font-jetbrains-mono),
monospace; + font-size/letter-spacing/text-transform blocks (S-D10).

86 of 93 declarations converted across 25 files; 7 left raw where the
mixin can't reproduce the same property order (font-variant-numeric or
font-weight interleaved between the mono properties, or no font-size
at all): Agenda.module.scss (5), Footer.scss (1), press.scss (1).

Compiled CSS verified byte-identical before/after across all 12
build_and_preview CSS bundles.
…se tokens

DEVF-55 batch 1/5 (PR 11 + rest of PR 28):

- S-D3 step 1: --focus-ring token, all 37 restated :focus-visible outlines
  point at it (step 2, dropping the ~24 offset-only restatements, deferred).
- S-D4: --wash-warm / --wash-warm-lit tokens for the 104deg warm wash.
  faq.scss:63's drifted .028 corrected to .026 (the only value change in
  this batch — it silently diverged from the primitive it copies).
- S-D9: @mixin film($contrast, $brightness) in src/styles/_film.scss.
  Every site keeps its own measured value through the parameter — none of
  the 9 distinct grades were unified.
- S-D14: five measured on-accent colours get tokens/reuse an existing one
  (--ink-on-accent, --accent-deep, --field-border-on-accent; raw #F7EFE6
  sites now read var(--on-accent), which already existed).
- S-D15: @mixin monogram($tracking, $size) in src/styles/_type.scss.
  team.scss keeps its own clamp() size and text-shadow, only the shared
  part goes through the mixin.
- S-D16: @mixin avatar-ring($width, $color) in src/styles/_avatar.scss.
  Ring colour drift (#0b0a0a / --panel / --rule) preserved per site.
- S-D17: shared @Keyframes pulse-opacity in BaseLayout.scss, replacing
  statePulse/skelPulse/agendaPulse. agendaPulse had zero animation
  references (dead, per S-O8) so it's removed rather than migrated.

Verification: sass --style=compressed before/after on all 23 touched
files. Every diff traced to one of the above (new custom-property/mixin
indirection or the faq.scss value fix) — nothing else moved. Full
`npm run build` also green.
…ives (S-D11/S-D12)

- pulse-opacity: scoped @mixin in _motion.scss, included per-module in
  Sessions.module.scss and DataState.module.scss; dead global @Keyframes
  removed from BaseLayout.scss (CSS Modules hash keyframe names per file,
  so a shared global block was unreachable from a module's animation:).
- S-D9: index.scss:445 and Sessions.module.scss hover both migrated to
  @include film(1.06, ...), closing the last two raw grayscale/contrast/
  brightness copies.
- S-D11: 11 lede-recipe copies (Special Elite intro prose) collapsed into
  @mixin lede in _type.scss; each site keeps only its drifted line-height/
  color/margin/max-width.
- S-D12: 12 hairline-underline-link copies (9 red-rule + 3 cream-rule)
  collapsed into @mixin link-rule in the new _link.scss; callers keep only
  their own min-height/padding/::after/focus-visible outline extras.
- Verified in the built dist: every animation: name has a matching
  @Keyframes in the same file/page.
SpeakersTeaser .allLink and press/downloads .inline-link had no border-color
hover or transition before batch 2's link-rule mixin swap — applying the
mixin quietly snapped them onto the shared recipe. Reverted to raw, same as
the 7 raw mono sites; if one-off values get approved for snapping later they
land here.
O-A1b/O-A6 (faq.astro numbering block + unreferenced id), O-A2 (Desk
level/action props + dead .desk-action CSS), O-A3b (partners.astro
DECK_READY dead branch + ~55 lines of unreachable CSS), O-A4/O-A5 (dead
.invoice-page / unstyled .press-section), O-R1 (unexported SpeakerLineup),
O-R2 (NewsletterForm unused formRef), O-R3 (AgendaPartition.roomTba never
read outside agenda.ts), O-R4/C-R7 (SpeakersTeaser unreachable effect +
its stale comment), O-R6 (Countdown initial-time literal deduped),
O-R7 (getPartnerTiers made module-private), O-S6 (.itemLive border-color
on a zero-width border — mobile agenda's "on now" mark was a no-op),
O-S7 (five dead @Keyframes in BaseLayout.scss; fadeInUp kept, it backs
index.scss's reveal animation), O-S9 (.kit-badge's dead first
letter-spacing), O-S10 (Sessions .avatarMono/.avatarMore no longer
restate properties the cascade already overrides), O-S14 batch (redundant
.red rules already covered by the global :where(h1,h2,h3) .red, dead
.u-prose selector, empty press.scss media query, Speakers.module.scss's
doubled display:flex), O-F11 (functions exports with no consumer outside
their own file), O-F12 (CreatedDiscountCode.id and IdokladApiError.detail,
neither read anywhere), O-F13 (notify-purchase.ts's rawBody cast — the v2
Request type already declares it; mirror-images.ts's hand-rolled
BucketLike/FileLike replaced by the real Storage SDK types), C-O21
(@types/react and @types/react-dom moved to devDependencies).

O-A7 and O-S2 already fixed upstream (mobileFocus is passed, .skelRow no
longer exists) — dropped from this batch per the delta re-validation.
O-R5 (NewsletterForm's always-empty sessionid/sessionUid hidden inputs)
is left alone: no proposed fix, and they may be required by the
SmartEmailing form contract even unpopulated.

functions/npm test: 17/17. astro check: still 9 pre-existing errors, no
new ones. npm run build clean.
claimInvoiceForProcessing flips invoiced -> processing; the only release
path back was a caught error inside the same run. A hard stop between the
two (the 300s timeoutSeconds, an OOM, an instance kill) left a doc stuck
in processing forever: listAwaitingPayment only ever queried status ==
'invoiced', so it was never polled again — the company paid, never got
its code, and nothing alerted.

claimInvoiceForProcessing now stamps processingSince. A new
listStrandedProcessing(staleAfterMs) finds processing docs stamped more
than 30 minutes ago — long past how long one completion (an already-done
status check, a code mint, one email) actually takes — and the poller
reclaims and retries them alongside the normal invoiced queue.
completeInvoice was already idempotent (the code is persisted before
delivery), so re-running it on a stranded doc is safe.

The age filter runs in memory rather than as a Firestore range query
alongside the status equality filter, so this needs no composite index.

functions/npm test: 17/17. tsc --noEmit clean.
C-O2: pin firebase-tools to 15.30.0 in firebase-functions-merge.yml
instead of resolving @latest on every deploy — an unpinned CLI on a
production deploy path is neither reviewable nor reproducible.

C-O3: add a concurrency group to every workflow. cancel-in-progress:
false on the two deploy workflows (queue, never let an older build land
last after a newer one); true on the PR-triggered ones (a re-push cancels
the stale run instead of stacking duplicate preview deploys/audits).

C-O4: add permissions: { contents: read } to firebase-functions-merge.yml
and firebase-hosting-merge.yml, matching what accessibility.yml and
firebase-hosting-pull-request.yml already had.

C-O5: accessibility.yml's path filter listed scripts/a11y.mjs but not
scripts/a11y-mocks/** — editing the fixtures the whole audit renders
against skipped the audit on that PR. Widened to scripts/**.

C-O6: pin w9jds/firebase-action to its v15.22.3 commit SHA (both hosting
workflows) instead of the mutable tag — this action receives GCP_SA_KEY.

C-O8: firebase-functions-merge.yml now fails the "write functions env"
step when TITO_ACCOUNT_SLUG or TITO_EVENT_SLUG is empty, instead of
writing an empty slug into functions/.env and deploying it — an unset or
renamed repo var used to succeed and break every ti.to call at runtime.

C-O7 (a same-repo PR branch can reach production hosting) is deliberately
left out — it needs a decision, not a mechanical fix.
The "<label> <status> <statusText>: <body>" throw was hand-written at
seven call sites (lib/slack.ts, tickets/tito-api.ts,
invoice/tito-discount.ts, invoice/idoklad-api.ts x2, invoice/email.ts,
sessionize/mirror-images.ts). Added httpError()/assertOk() to lib/http.ts
and switched all seven to assertOk(label, res).

fetchWithRetry's "returns non-OK, caller decides" contract is unchanged —
assertOk is opt-in at the call site, not baked into the retry helper.
fetchSessionizePayload keeps branching on status directly (it needs the
distinction to fall back to the Speakers view) and does not adopt it.

functions/npm test: 17/17. tsc --noEmit clean.
…-D3)

F-D2: tickets/tito-api.ts now exports TITO_API_BASE, titoHeaders() and
TitoCredentials — the invoice domain's own TitoConfig was a field-by-field
copy of the same three-property shape and its own base URL/header
construction. invoice/tito-discount.ts, poll.ts and process.ts now import
the shared type/helpers instead of re-declaring them.

F-D3: tickets/params.ts gains requireTitoConfig(), replacing the
hand-rolled param-read + "Missing config" guard duplicated verbatim in
refresh-cache.ts and weekly-status.ts. invoice/process.ts and poll.ts
built the same triple with NO guard at all — an unset ti.to param there
used to produce empty-string credentials silently instead of failing
loudly; both now go through the same guarded helper.

functions/npm test: 17/17. tsc --noEmit clean.
cachedJsonEndpoint memoised only the resolved payload, not the in-flight
read, so N simultaneous requests landing on a cold/expired memo each
called spec.load() — the module's own header comment claimed this was
already coalesced, true only for sequential requests. Same class of bug
CLAUDE.md documents for initAnalytics()'s in-flight-promise memoisation;
it hadn't reached this file.

Added an `inflight` promise alongside the memo, cleared in `finally` so a
rejection is never cached as a pending success. New test file exercises
both: five concurrent misses share one load() call, and a failed load
doesn't poison a later successful one.

functions/npm test: 19/19 (2 new).
…(PR 17, F-D4/F-D5)

F-D4: the "is this the All-view object, and is <key> an array" preamble
was repeated at buildSessionMap, buildSpeakerSummaryMap, buildRoomMap and
buildCategoryMap. Extracted module-private allViewArray(payload, key).

F-D5: validateSpeakers and validateSessions were one function with two
flags apart — same non-array check, same non-object-entry check, same
duplicate-id Set loop, differing only in whether an empty list is an
error, whether ids may be numeric, and the noun in messages. Extracted
validateEntities<T>({ noun, allowEmpty, numericIds }); both exported names
stay thin wrappers, since CLAUDE.md calls this the highest-risk sync path
and the exported surface is deliberate.

Error message text changed slightly ("contains an entry without a valid
id" / "contains a duplicate id: X" instead of per-noun phrasing) — nothing
in functions/ matches on the old strings.

functions/npm test: 19/19. tsc --noEmit clean.
…it log (PR 18, O-F4/O-F9/O-F5)

O-F4: idoklad-api.ts had four `any`s on the money path — apiEnvelope's
return type, apiJson's default type param, the invoice-template fetch,
and the mail-send envelope. Replaced with `unknown` + a real
`IdokladEnvelope { Data?; IsSuccess?; Message? }` interface, and gave
`tpl.DateOfIssue` an explicit `typeof === 'string'` narrow before handing
it to `new Date(...)` — the previous `any` let an iDoklad shape change
compile clean and fail at runtime on this path.

O-F9: submit.ts logged the full IČO on a rate-limit hit
(`{ ic: registrationNumberIC }`) — CLAUDE.md lists IČO among the data that
identifies a customer, and this domain already masks email for exactly
that reason. Exported the rate-limit key derivation as
firestore.ts::rateLimitKey() (same SHA-256 of IČO|email the throttle
already keys on) and log that instead: equally greppable, identifies
nobody.

O-F5 (the two casts that gate control flow): the iDoklad OAuth token
response was cast straight to `{ access_token: string; expires_in:
number }` with no check — a malformed token response would have cached
`undefined` as a bearer token. Now narrows `access_token`/`expires_in`
and throws on a missing token instead. `lib/run.ts`'s readHealth() cast
`snap.val()` straight to `HealthState` — a corrupt RTDB node fed
non-numeric `failures` into streak arithmetic (NaN). Added
isHealthState() and degrade to null (the module's own "assume healthy"
rule) on a malformed node, same as a failed read.

functions/npm test: 19/19. tsc --noEmit clean.
…D2/C-D5/C-D6)

C-D1: a11y.mjs and anchor-measure.mjs each implemented their own static
fixture server — DIST, a MIME table, resolveFile(), a createServer serving
API_FIXTURES — already diverged (MIME coverage, dir-vs-file precedence,
Cache-Control, whether a `#` fragment got stripped). Extracted
scripts/lib/fixture-server.mjs::startFixtureServer({ port, delayMs,
cacheControl }); the no-store and .jpeg behaviours are now shared instead
of accidental.

C-D2: the route list was declared three times, in three different orders,
and one copy was missing /agenda — scripts/a11y.mjs PATHS, anchor-
measure.mjs HEADER_ROUTES (both 15 routes) and astro.config.mjs's sitemap
PRIORITY map (11 routes, no /agenda, so it shipped priority 0.6 while
/speakers and /sessions got 0.9). New scripts/routes.mjs exports
AUDIT_ROUTES, imported by both audit scripts. PRIORITY isn't the same
shape (full URL keys, and several routes deliberately fall to the 0.6
default), so it isn't merged into AUDIT_ROUTES — but the /agenda omission
this finding flagged is fixed: it's now keyed at 0.9 alongside its two
sibling nav pages.

C-D5: the "load, wait briefly for network idle, then hydrate islands"
preamble was repeated at three call sites in a11y.mjs. Extracted
openPage(page, url).

C-D6: the mobile-agenda block hand-rolled its own click →
waitForSelector('[role="dialog"]') → scoped AxeBuilder flow, duplicating
what auditModals() already does for every other page. It now calls
auditModals(mp, '/agenda/', url, tags) — the same MODAL_FLOWS['/agenda/']
entry the desktop grid-cell pass uses.

Verified locally: `npm run build` clean, sitemap now carries
`/agenda` at priority 0.9, `npm run a11y` passes all 15 pages + the mobile
agenda dialog flow, `npm run anchors` passes all 42 landings and all 660
header checks (15 routes x 11 widths x 4 roots).
C-O19: tsconfig.json's "exclude": ["dist"] replaced TypeScript's default
excludes, so combined with "include": ["**/*"] the root program was
pulling in node_modules/**, functions/src/** (its own NodeNext tsconfig,
not this one) and functions/lib-test/**. Invisible only because nothing
ran `astro check` until #356 — now that it runs on every PR, excluding
dist/node_modules/functions/.firebase keeps the program scoped to what it
should type-check. Confirmed unchanged: still 9 pre-existing errors, just
133 -> 65 files scanned.

C-O20: firebase.json's CSP carried two dead source lists — the
`*.firebasedatabase.app`/`wss://*.firebasedatabase.app` connect-src
entries (no browser code opens an RTDB connection; confirmed no
`getDatabase` call anywhere in src/) and `frame-src
https://www.youtube.com` (zero `<iframe>` in src/; the YouTube links in
Footer.astro/BaseLayout.astro are plain anchors). `www.google.com` stays —
reCAPTCHA Enterprise injects a frame.

C-O22: added a `Permissions-Policy: camera=(), microphone=(), geolocation=(),
interest-cohort=()` header next to the existing security headers — the
site uses none of those, so it's a free denial.

C-A10: BaseLayout.astro's docblock said the explicit `prefetch.prefetchAll`
in astro.config.mjs was "now redundant" and nobody had removed it —
deleted the config key and the sentence about it together.

Verified: `npm run build` and `astro check` (still 9 pre-existing errors,
no new ones) both clean; functions/npm test 19/19.
firebase-hosting-merge.yml and firebase-hosting-pull-request.yml were
identical through the build step (checkout, setup-node, npm ci, npm run
build), differing only in the deploy args after it. Extracted
.github/actions/build-site — a local composite action can't check out the
repo itself (its own action.yml has to already be on disk to be loaded at
all), so each workflow still does its own actions/checkout@v5 first, then
delegates setup-node + npm ci + npm run build to the shared action.

Deliberately after PR 2 (correctness.yml) and PR 6 (the concurrency/
permissions/pinned-SHA changes to both hosting workflows) per the review's
ordering note, so this doesn't conflict with either.

Not touched: the node-version/cache triple in accessibility.yml — it's a
third, differently-shaped job (also runs Playwright browser setup) and
isn't one of the two hosting workflows this PR scopes to.
Six islands each hand-rolled the same loading/ready/empty/error fetch
state machine (an AbortController effect + `.catch(console.warn)`).
Extract src/lib/useRemoteData.ts and rewire all six call sites:
Speakers.tsx, Sessions.tsx, Agenda.tsx, SpeakersTeaser.tsx, Tickets.tsx,
InvoiceForm.tsx.

- Speakers/Agenda/SpeakersTeaser/Sessions/Tickets: same isEmpty predicate
  each used before (empty speakers/sessions/releases array, or a null
  ti.to cache), same status branches, same JSX per status.
- Sessions/Agenda additionally derive `speakersById` via useMemo off the
  hook's `data` instead of separate `useState` + manual
  `Object.fromEntries` in the fetch `.then()` — same computation, same
  timing (empty until the fetch resolves).
- InvoiceForm only reads `data` (the company-funded release estimate),
  never `status` — matches the task's note that this call-site has no
  status-driven rendering. One behavior note: a failed estimate fetch
  now logs via `console.warn('[invoice] Failed to load ticket price
  estimate:', err)` (the hook's contract), where it was silently
  swallowed before; the UI is unaffected (the estimate line is simply
  absent either way).

`git diff --stat --cached`:
 src/components/Agenda.tsx         | 54 ++++++++++++--------------------
 src/components/InvoiceForm.tsx    | 32 ++++++++++---------
 src/components/Sessions.tsx       | 57 ++++++++++++++++------------------
 src/components/Speakers.tsx       | 39 ++++++-----------------
 src/components/SpeakersTeaser.tsx | 29 ++++++-----------
 src/components/Tickets.tsx        | 65 ++++++++++++++++-----------------------
 src/lib/useRemoteData.ts          | 50 ++++++++++++++++++++++++++++++
 7 files changed, 160 insertions(+), 166 deletions(-)

Verification (static trace, no browser available):
- Traced each of the 6 call sites' loading/ready/empty/error branches
  against the diff: every `state.status === X` check maps 1:1 onto the
  hook's `status`, every prior `state.<field>` derived value maps onto
  `data` (with the same null-safe defaults) with no branch reordering.
- `A11Y_MOCK=1 npm run build` clean.
- `npm run a11y`: 15/15 pages pass (WCAG 2.2 AA, axe-core + control-contrast).
- `npx astro check`: 9 errors (unchanged from the origin/agent/devf-55-rest
  baseline, confirmed before starting).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…paragraphs)

SpeakerDetail and SessionDetail were the same dialog object with different
content, carrying two copies of the focus trap / Esc / body-scroll-lock
effect and of the portal + bar + Close button + close SVG markup.

- src/lib/useDialog.ts: the shared a11y contract (body scroll lock, Tab
  focus trap, Esc-close, `[data-autofocus]`, wraps `useReturnFocus`).
  `opts.enabled` is read through a ref (not an effect dependency) so a
  stacked sub-dialog toggling it doesn't re-run the outer effect/re-fire
  autofocus/re-lock scroll on every open/close of the inner one — mirrors
  the original `speakerOpenRef` pattern exactly.
- O-R16: added `inert` on `#main-content` for as long as any dialog is
  open (refcounted via a module-level counter, so the stacked
  SpeakerDetail closing first doesn't un-inert the page while SessionDetail
  is still open underneath). This is a genuine a11y ADDITION, not pure
  extraction: an AT virtual cursor could previously still reach page
  content behind the `aria-modal` sheet. No pointer/backdrop close was
  added — Esc + Close button remain the complete close contract, per the
  review's explicit note that the sheet is full-bleed by design.
- src/components/Sheet.tsx: the portal to `<body>`, the `role="dialog"
  aria-modal` outer element, the sticky bar, and the Close button + SVG.
  Takes `className`/`contentClassName` so `SpeakerDetail` still composes
  its own `.stacked` (outer) and `.split` (content) classes.
- SpeakerDetail.tsx / SessionDetail.tsx now hold only their own content
  markup (portrait grid / meta line / abstract measure) plus `useDialog` +
  `<Sheet>` wiring.
- R-D5: src/components/SpeakerAvatars.tsx replaces three speaker-photo-row
  variants — Sessions.tsx's `SpeakerStack` (3-max + `+N` overflow chip,
  40px), Agenda.tsx's `TalkAvatars` (3-max, no overflow, 24px), and
  SessionDetail's inline single-avatar wrapper (72px, deleted entirely).
  Every differing value (size, overflow chip, wrapper class) stayed a
  prop — nothing was unified that wasn't already identical.
- R-D6 (paragraph helper only — the rest of R-D6 is PR 24): `paragraphs()`
  moved to src/lib/sessions.ts, used by both SpeakerDetail's bio and
  SessionDetail's abstract in place of their inline
  `text.split(/\n{2,}|\r\n\r\n/).filter(...)`.

`git diff --stat --cached`:
 src/components/Agenda.tsx         |  24 ++--
 src/components/SessionDetail.tsx  | 254 +++++++++++++-------------------------
 src/components/Sessions.tsx       |  35 ++----
 src/components/Sheet.tsx          |  69 +++++++++++
 src/components/SpeakerAvatars.tsx |  66 ++++++++++
 src/components/SpeakerDetail.tsx  | 217 +++++++++++---------------------
 src/lib/sessions.ts               |   7 ++
 src/lib/useDialog.ts              | 109 ++++++++++++++++
 8 files changed, 431 insertions(+), 350 deletions(-)

Verification (static trace, no browser available — same limitation noted
by earlier reviewers on this issue):
- Traced both dialogs' open/close/stacked paths against the diff: body
  scroll lock nesting (SessionDetail sets 'hidden', SpeakerDetail's nested
  effect sees 'hidden' and restores to 'hidden' on its own close, leaving
  SessionDetail's lock intact), the `inert` refcount reaching 0 only when
  the last dialog unmounts, Esc bailing in SessionDetail while
  `activeSpeaker` is set (capture-phase listener order: SessionDetail's
  registered first, still runs first, returns early via `enabledRef`,
  SpeakerDetail's handles it), and the `event.detail === 0` keyboard-close
  flag threaded through `Sheet`'s `onCloseClick` unchanged.
- Confirmed `SpeakerAvatars` with no `wrapperClassName` renders a bare
  fragment (no extra DOM node) for `SessionDetail`'s single-avatar case,
  matching the original inline `<SpeakerPhoto />` with no wrapper.
- `A11Y_MOCK=1 npm run build` clean.
- `npm run a11y`: 15/15 pages pass (WCAG 2.2 AA) — this sweep does not open
  either dialog (they only mount on click), so it does not itself exercise
  the new `inert`/focus-trap code; the static trace above is the coverage
  for that part.
- `npx astro check`: 9 errors (unchanged from baseline).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…9/22)

InvoiceForm.tsx and NewsletterForm.tsx, brought to one consistent theme:

- O-R8: `countTickets` was clamped with `Math.max(1, Math.min(50, …))` on
  every keystroke, so a visitor could never clear the field to type a new
  value, and the out-of-range branch in `validate()` was unreachable.
  `Fields.countTickets` is now the raw string; `parseCount()` parses it
  wherever a number is needed (validate, the estimate, the GA4 `quantity`),
  and the range error only surfaces through `validate()` (on blur/submit),
  never silently.
- O-R9: `update()` built `next` off the closed-over `fields` instead of the
  functional form, so two field changes in the same tick (autofill) could
  drop one. Now `setFields(prev => ({...prev, [key]: value}))`; revalidation
  moved out of `update()`/the checkbox `onChange` into one `useEffect` keyed
  on `[fields, consented, attempted]`.
- O-R10: `wire()` returned a fresh ref-callback closure per field per
  render, so React detached/reattached all nine inputs on every keystroke.
  Added a `fieldRef()` factory backed by a `useRef(new Map())` cache — same
  function instance per field across renders.
- O-R15: NewsletterForm disabled its submit on `!consented` (a11y
  anti-pattern: out of tab order, explains nothing), which also made its
  "Please confirm the consent checkbox to continue." message unreachable
  (no click fires on a disabled button). Removed `disabled`, matching
  InvoiceForm's documented non-disabling pattern — the existing
  `handleSubmit` guard now actually runs.
- O-R18: InvoiceForm's submit-result region was `role="status"` for both
  tones. Now `role="alert"` (no explicit `aria-live`, matching
  `ErrorState` in DataState.tsx) when `status === 'error'`, `role="status"
  aria-live="polite"` otherwise.
- O-R19: the catch handler read `(e as {message?}).message` as a field
  name and interpolated it verbatim — a sentence-shaped `HttpsError`
  message would have read as nonsense. Added `isCallableError()` (checks
  `code` presence) and `fieldFromCallableError()`, which prefers
  `details.field` and only falls back to `message` when it's actually one
  of this form's own field names (an allowlist via `FIELD_NAMES`), never
  an arbitrary string. Scope note: the current `submitInvoiceCallable`
  still sends the field name in `message` (not `details`) — out of scope
  here per the file list for this PR (functions/) — so today's behavior is
  unchanged (the allowlist passes it through); the guard is what stops a
  future/different error shape from leaking raw text to the visitor.
- O-R22: NewsletterForm reached `document.getElementById('se-ref-field-id')`
  for an input it renders itself. Replaced with a `useRef`.

`git diff --stat --cached`:
 src/components/InvoiceForm.tsx    | 136 +++++++++++++++++++++++++++-----------
 src/components/NewsletterForm.tsx |  16 +++--
 2 files changed, 111 insertions(+), 41 deletions(-)

Verification:
- `A11Y_MOCK=1 npm run build` clean.
- `npm run a11y`: 15/15 pages pass (WCAG 2.2 AA).
- `npx astro check`: 9 errors (unchanged from baseline).
- Manual trace (no browser): countTickets typing/clearing/out-of-range →
  blur/submit error path; two-field functional update; NewsletterForm's
  consent-blocked submit now reaches its message; InvoiceForm's own
  estimate line goes blank (not stale/NaN) while the count field is
  mid-edit, by design (see code comment).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- R-D3: src/lib/useMediaQuery.ts (`useMediaQuery` + `usePrefersReducedMotion`)
  replaces three hand-rolled `matchMedia` effects: SpeakersTeaser's own
  `usePrefersReducedMotion`, Agenda's `useIsNarrow` (now `useMediaQuery(NARROW_QUERY)`).
  Speakers.tsx's imperative one-shot `canMorph()` stays imperative (it's
  called inside a click handler) but now reads the same `REDUCED_MOTION_QUERY`
  constant instead of its own copy of the query string.
- R-D4: `pragueParts` exported from src/lib/agenda.ts; Agenda.tsx's hand-rolled
  `pragueNow()` (rebuilt its `Intl.DateTimeFormat` on every 30s tick) deleted,
  `useNowMinutes` now calls `pragueParts(new Date().toISOString())` — reuses
  agenda.ts's module-scoped formatter.
- O-R13: `nowState()` and the sort comparator behind `byStart()` now consult
  a precomputed `placements` map instead of re-running `placement()`/
  `parseLocalMinutes()` (Intl parsing) per session per call. `nowState` takes
  the map as a required third argument; `byStart` takes it as an optional
  third argument (existing 2-arg callers inside `partitionAgenda` still fall
  back to a direct parse — those sorts run once per memoized partition, not
  per render, so they were not the flagged cost). `Agenda.tsx` threads its
  existing memoised `placements` into `nowState`, `AgendaGrid`'s sort, and
  `AgendaList`'s sort (the latter was the specifically-flagged unmemoised
  per-render sort).
- R-D6 (remainder — paragraph helper was PR 10): `speakerNames(session)`
  moved to src/lib/sessions.ts, used by both Sessions.tsx and Agenda.tsx in
  place of their identical inline `.map(sp => sp.fullName).filter(Boolean).join(', ')`.
  `round2` moved to src/lib/tito.ts, used by both Tickets.tsx (unchanged call
  site) and InvoiceForm.tsx (replacing its inline `Math.round(total * 100) / 100`).
- R-D7: `TicketsSection` in Tickets.tsx replaces the hand-repeated
  `<header className="head-split"><h2 id="tickets-heading">…</h2>…</header>`
  block across all four render branches (error/loading/empty/ready).
  `id="tickets-heading"` now lives on exactly one element.
- O-R20: `formatPrice` (one caller, stringified a number to have it parsed
  back) deleted from src/lib/tito.ts; `formatAmount` exported instead.
  InvoiceForm.tsx's estimate now calls `formatAmount(total, release.currency)`
  directly — `total` was already a number.
- O-R21 (genuine small behavior fix, not pure refactor): InvoiceForm.tsx's
  `findCompanyRelease` now takes `{ laterWaveOnSale }` and passes it to
  `releaseStatus()`, derived the same way Tickets.tsx derives it
  (`visible.some(r => releaseStatus(r).purchasable)`). Previously the
  /invoice estimate could treat as purchasable a wave Tickets.tsx already
  labels "Ended" (a paused wave superseded by a later on-sale wave).

`git diff --stat --cached`:
 src/components/Agenda.tsx         | 63 ++++++++-----------------
 src/components/InvoiceForm.tsx    | 24 +++++++---
 src/components/Sessions.tsx       |  3 +-
 src/components/Speakers.tsx       |  3 +-
 src/components/SpeakersTeaser.tsx | 13 +-----
 src/components/Tickets.tsx        | 98 +++++++++++++++++++++++++--------------
 src/lib/agenda.ts                 | 51 ++++++++++++++++----
 src/lib/sessions.ts               |  6 +++
 src/lib/tito.ts                   | 23 +++++----
 src/lib/useMediaQuery.ts          | 29 ++++++++++++
 10 files changed, 194 insertions(+), 119 deletions(-)

Verification:
- `A11Y_MOCK=1 npm run build` clean.
- `npm run a11y`: 15/15 pages pass (WCAG 2.2 AA).
- `npx astro check`: 9 errors (unchanged from baseline).
- No browser available: did a careful manual trace of /agenda's diff instead
  of the one preview check the review suggested covers most of this PR —
  traced `nowState`/`byStart`'s new placements-map path against the same
  inputs the old Intl-parsing path would have produced (same `startMin`
  values, since the map is built by the same `placement()`), and confirmed
  `pragueParts(new Date().toISOString())` produces the same
  `{date, minutes}` shape `pragueNow()` did (both format the same instant
  with the same options against Europe/Prague).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…-run

Agenda.tsx and Sessions.tsx passed onClose={() => setSelected(null)} inline,
and useDialog has onClose in its effect deps. Agenda re-renders every minute
(useNowMinutes), replaying the effect and jumping [data-autofocus] back to
Close. Pre-existing on baseline (no regression), but cheap to close now that
useDialog exists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
InvoiceForm's client already reads details.field first, falling back to
parsing message only through an allowlist — but the server only ever sent
the field name in message. submitInvoiceCallable now throws through a new
invalidArgument(field) helper that sets both, closing the gap the client
side of O-R19 (batch 4) left open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A-D1, A-D13, A-D4 (partners half), O-S5. /partners hand-rolled Closer.astro
node-for-node; Closer now has a default <slot/> between .closer-note and
.closer-actions plus an optional class prop, and /partners uses it (carrying
the partner-contact block through the slot, `.partners-cta` through class).
The two logo-loop sections (tiers, media/community) collapse into one array
+ one loop; the shared {p.url ? <a><Image/></a> : <Image/>} + is-plated
class logic moves into new PartnerLogo.astro (also usable by index.astro's
partner strip, not migrated here to keep this PR scoped to /partners).
partners.scss's dead, unscoped .contact-name (always overridden by the
accent-band definition) is deleted; the surviving one is wrapped in the
same :global(.band--accent) .email-link already uses.

Visible change: none — same markup shape, same classes, same computed CSS.

Verification: A11Y_MOCK=1 npm run build clean, 15 pages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A-D2, S-D5. New SectionHead.astro wraps the global .head-stack primitive
(BaseLayout.scss) — press.astro's coverage header and press/downloads.astro's
kit header both go through it instead of their own .section-head/.section-
title. downloads.astro's numbered "Asset library" label stays its own
.section-label/.section-no (a distinct recipe, not a copy of the plain
eyebrow). Dead local CSS removed from both press.scss and downloads.scss.

Visible change on both pages: neither .section-head reimplementation matched
the canonical .head-stack spacing/rule — press's header padding-bottom goes
1.1rem -> 1.6rem and its border-bottom color var(--rule) -> var(--rule-soft);
downloads' header gains the border-bottom + 1.6rem padding-bottom it never
had. Same [MUST] as S-D1/S-D4: a drifted copy, not a value anyone chose.

Scope note: A-D2 also names 6 other pages that already use .head-stack/
.head-split correctly (404, agenda, contact, index, newsletter-subscription-
thank-you, sessions, speakers, thank-you) — left as-is here since migrating
them to SectionHead is a same-output, no-visual-change swap with no S-D5
value fix riding along; not done in this batch to keep the diff to the two
pages that actually change.

Verification: A11Y_MOCK=1 npm run build clean, 15 pages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ryzizub and others added 4 commits September 11, 2026 13:04
A-D3, A-D8, A-D9, S-D6. Three triplicated/duplicated sections become
components:

- NextSteps.astro — the "what now?" band + head-stack + open field of
  NextStep rows, byte-identical on /404, /thank-you and
  /newsletter-subscription-thank-you apart from copy.
- NoScriptNote.astro — the <noscript> .fallback-note fallback under
  /agenda, /sessions, /speakers' data-backed islands.
- EmptyState.astro — the "nothing here yet" block on /press and
  /press/downloads; its CSS moves into BaseLayout.scss beside
  .fallback-note, which it already visually rhymes with.

Visible change on /press and /press/downloads: the two local .empty-state
recipes had already drifted (52ch/56ch max-width, --fs-label/--fs-label-xs
stamp, 1.6/1.7 line-height) — the shared primitive takes 56ch, --fs-label
and 1.7, so /press's empty state gets marginally wider and its stamp a
touch larger; /press/downloads is unchanged. Dropped downloads' unused
`.empty-stamp, .empty-line { position: relative; z-index: 1 }` — no
overlapping element in either page needs it.

Verification: A11Y_MOCK=1 npm run build clean, 15 pages.
npm run a11y — 15/15 pages + mobile agenda dialog pass, WCAG 2.2 AA.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
S-D7. Three scoped .sr-only/.srOnly copies (Countdown.module.scss,
NewsletterForm.module.scss, privacy-policy.scss) all omitted clip-path and
relied on deprecated clip: rect() — the global BaseLayout.scss:293 one has
both. Countdown.tsx and NewsletterForm.tsx now reference the global class by
name (className="sr-only", per DESIGN.md's MUST that global primitives are
used by class name from both .astro and .tsx) instead of their own CSS
Modules copy; the copies are deleted.

Visible change: privacy-policy's <caption class="sr-only"> (its markup
already referenced the global class name) picks up clip-path — restores the
intended "not just visually hidden, unreachable by any hit-testing" behavior
that its own page-scoped copy was silently overriding. No visible change on
either page (sr-only content is never rendered).

**A-D14 (`.back-link` styled twice, privacy-policy.astro vs
press/downloads.astro) intentionally NOT done here** — the two recipes
disagree on type scale and focus treatment, so unifying them is a real value
decision (which one wins), not a mechanical dedup; deferred pending that
decision, same as the other pending one-off-value items.

Verification: A11Y_MOCK=1 npm run build clean, 15 pages.
npm run a11y — 15/15 pages + mobile agenda dialog pass, WCAG 2.2 AA.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
O-S17 corrections, S-D19 cross-references. C-D4 (six copies of the fixtures
rationale) and O-S19's full line-number refresh are not done here — left for
a future pass, noted below.

DESIGN.md:
- `--fs-figure` removed from the font-size ramp table and Open point 6
  marked resolved — the token has zero call sites and is no longer declared
  in BaseLayout.scss (the ramp runs --fs-label-xs -> --fs-monogram).
- Open point 12 corrected: Footer.scss's two `font-size: inherit` declarations
  are not ramp bypasses (both elements are <small>, so inherit resets the
  UA's 0.8em); dropped off the list. Noted separately: privacy-policy.scss's
  <h1> uses --fs-hero (documented as "home hero statement") where
  --fs-display is the subpage h1 token.
- New `src/styles/*.scss` row in "Where things live" — the five `@use`d
  mixin partials this review pass added (_type, _film, _avatar, _link,
  _motion), each parameterizing per-site drift rather than snapping it.

CLAUDE.md: new "Shared building blocks" paragraph under Pages & Routing —
useRemoteData, useDialog + <Sheet>, useMediaQuery/usePrefersReducedMotion,
and the six Astro components this review pass extracted (Closer's new slot,
SectionHead, NextSteps, NoScriptNote, EmptyState, PartnerLogo).

Also added the S-D19 cross-reference comments (CookieBanner.scss,
NewsletterForm.module.scss) marking their 500px breakpoint as the same
threshold as Footer.scss's 31.25em, without converting either — per
instruction, not a value anyone has decided to snap.

Verification: A11Y_MOCK=1 npm run build clean, 15 pages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Folding class/alt/loading into the same object as imageProps before the
type cast, instead of spreading it alongside literal props, so TS doesn't
read the cast's Image props type as a possible duplicate of alt. astro
check is back to the 9 pre-existing baseline errors (was 11 with the naive
version of this component).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@ryzizub
ryzizub marked this pull request as ready for review September 11, 2026 11:17
useDialog's autofocus/keydown effect lists setKeyboardClose as a
dependency; useReturnFocus previously returned a new closure on every
render, re-running that effect on every render. All useDialog callers
(SpeakerDetail, SessionDetail via Agenda/Sessions/Speakers) already
pass a useCallback([])-stabilized onClose, so setKeyboardClose was the
only unstable dependency in that effect — fixing it here closes the
loop.
DESIGN.md named display/prose alongside mono/monogram/lede, but
_type.scss only defines mono, monogram, and lede.
firestore.ts:135-144 documented checkInvoiceRateLimit but sat above
rateLimitKey (which has its own correct docblock); sessionize-api.ts
had the same pattern above validateEntities. Both were stale
duplicates, not documentation for the function they preceded.
…855cf7561fe9

# Conflicts:
#	.github/workflows/firebase-hosting-merge.yml
#	.github/workflows/firebase-hosting-pull-request.yml
#	package-lock.json
#	package.json
#	scripts/a11y.mjs
#	src/components/Sessions.module.scss
#	src/components/SpeakerDetail.module.scss
#	src/components/SpeakersTeaser.module.scss
#	src/components/Tickets.tsx
#	src/lib/tito.ts
#	src/pages/partners.scss
#	src/pages/team.scss
@ryzizub ryzizub closed this Sep 14, 2026
@ryzizub
ryzizub deleted the agent/devf-55-rest branch September 14, 2026 17:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant