Conversation
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.
…devf-55-3d8476c39c4d
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.
…devf-55-3d8476c39c4d
…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>
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
--focus-ring: 2px solid var(--color-accent-hot)token; all 37 restated:focus-visibleoutlines (33 outsideBaseLayout.scss+ 4 inside it, including the canonical rule) now readoutline: var(--focus-ring). Step 2 (dropping the ~24 offset-only restatements) is deferred to its own PR per the report.--wash-warm/--wash-warm-littokens 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.successconfirmation state) keep their own local value with a reason comment, as instructed.@mixin film($contrast, $brightness)in newsrc/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.--ink-on-accent,--accent-deep,--field-border-on-accent(new), plus raw#F7EFE6sites now reusing the pre-existing--on-accent.#FFFFFF(hover white) was left raw — the report proposed no token name for it.@mixin monogram($tracking, $size: var(--fs-monogram))insrc/styles/_type.scss.team.scsskeeps its ownclamp()size andtext-shadow— only the shared part goes through the mixin, per instruction not to unify that drift.@mixin avatar-ring($width, $color)in newsrc/styles/_avatar.scss. Ring-colour drift (#0b0a0a/var(--panel)/var(--rule)) preserved per site.@keyframes pulse-opacityinBaseLayout.scss, replacingstatePulse/skelPulse/agendaPulse.agendaPulsehad zeroanimation:references anywhere (dead, matches S-O8) so it's removed rather than migrated — the local@keyframesblock 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.028where the canonical recipe (and every other copy) is.026— a drifted value nobody chose. Corrected to.026, which also lets it share--wash-warmwith the canonical site.Verification
sass --style=compressedcompiled before/after on all 23 touched files (baseline =origin/2026at 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 thefaq.scssvalue fix — nothing else moved. Where the compiled text differs (a raw literal replaced byvar(...), 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 newsrc/styles/_film.scss,src/styles/_avatar.scssand an addition tosrc/styles/_type.scss.Batch 2/5
@keyframes pulse-opacityinBaseLayout.scsswas unreachable: CSS Modules hash keyframe names per file, so a.module.scss'sanimation:can never resolve a name defined in the global stylesheet. Replaced with@mixin pulse-opacity($name: pulse-opacity)in newsrc/styles/_motion.scss, included in both consumers (Sessions.module.scss,DataState.module.scss) so each gets its own scoped@keyframescopy. The stale global block inBaseLayout.scssis removed.index.scss:445(gallery hover,grayscale(1) contrast(1.06) brightness(0.78)) andSessions.module.scss:184(avatar hover, same shape) now go through@include film.@mixin filmnow has 10 call sites across 9 files.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/SpeakerDetailpass no$max-width(their column is the measure, unchanged).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 newsrc/styles/_link.scss. Callers keep their ownmin-height,padding-bottom,::afterarrow and:focus-visibleoutline extras.npm run build, everyanimation:name indistwas matched against a@keyframesin 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
194b8113):SpeakersTeaser.module.scss.allLinkandpress/downloads.scss.inline-linkwere quietly moved onto the sharedlink-rulemixin even though their hover/transition shape didn't match it (.allLinkhad no border-color hover;.inline-linkhad 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.chore: dead code sweep. O-A1b/O-A6 (faq.astro numbering block + unreferenced id), O-A2 (Desklevel/actionprops + dead.desk-actionCSS), O-A3b (partners.astroDECK_READYdead branch + ~55 lines of unreachable CSS), O-A4/O-A5 (dead.invoice-page/ unstyled.press-section), O-R1 (unexportedSpeakerLineup), O-R2 (unusedformRef), O-R3 (AgendaPartition.roomTbanever read outsideagenda.ts), O-R4/C-R7 (SpeakersTeaser unreachable effect + its stale comment), O-R6 (Countdown initial-time literal deduped), O-R7 (getPartnerTiersmade module-private), O-S6 (.itemLiveborder-color on a zero-width border — mobile agenda's "on now" mark was a no-op, fixed), O-S7 (five dead@keyframesinBaseLayout.scss;fadeInUpkept, it backsindex.scss's reveal animation), O-S9 (.kit-badge's dead first letter-spacing), O-S10 (Sessions.avatarMono/.avatarMoreno longer restate properties the cascade already overrides), O-S14 batch (redundant.redrules, dead.u-prose, empty media query, doubleddisplay: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).fix(invoice): stop stranding a paid invoice in processing(O-F2).claimInvoiceForProcessingnow stampsprocessingSince; a newlistStrandedProcessing(30min)finds docs stuck inprocessingby a crash between claim and completion, and the poller reclaims + retries them (idempotent — the code is persisted before delivery).ci: harden the deploy workflows, without C-O7. Pinnedfirebase-toolsandw9jds/firebase-action(SHA, not tag); addedconcurrencyto every workflow (queue on the two deploy workflows, cancel-on-repush on the PR-triggered ones); addedpermissions: contents: readwhere missing; widenedaccessibility.yml's path filter toscripts/**;firebase-functions-merge.ymlnow fails loudly on an emptyTITO_ACCOUNT_SLUG/TITO_EVENT_SLUGrepo var instead of deploying an empty slug. C-O7 (same-repo PR branch can reach production hosting) intentionally untouched — decision, not mechanics.refactor(functions): one helper for a non-OK HTTP response(F-D1).assertOk(label, res)inlib/http.ts, adopted at all 7 call sites.refactor(functions): one ti.to client for both domains(F-D2/F-D3).tickets/tito-api.tsnow exportsTITO_API_BASE/titoHeaders/TitoCredentials;tickets/params.tsgainsrequireTitoConfig(). The invoice domain's ownTitoConfigtype and header/base-URL duplication are gone;process.ts/poll.tspreviously built the ti.to config triple with no guard at all — now go through the same guarded helper as the tickets domain.fix(functions): coalesce concurrent /api/* cache misses(O-F3).cachedJsonEndpointnow tracks the in-flight promise, not just the resolved memo. New test file, 2 cases.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>()replacesvalidateSpeakers/validateSessions's near-duplicate bodies (both exported names stay thin wrappers).fix(invoice): type the iDoklad envelope; hash the IČO in the rate-limit log(O-F4/O-F9/O-F5). Fouranys on the money path replaced withunknown+ a realIdokladEnvelopeinterface; the OAuth token response andlib/run.ts'sHealthStateread 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.refactor(scripts): one fixture server, one route list(C-D1/C-D2/C-D5/C-D6). Newscripts/lib/fixture-server.mjsandscripts/routes.mjs, both audit scripts now import them;openPage()replaces a 3x-repeated preamble; the mobile-agenda block now reusesauditModals()instead of hand-rolling its own dialog flow. Fixed the/agendasitemap-priority omission C-D2 flagged (now 0.9, matching/speakers//sessions).chore: drop dead config(C-O19/C-O20/C-O22/C-A10).tsconfig.jsonexclude list tightened (133 → 65 files scanned byastro check, same 9 pre-existing errors). CSP change (C-O20), called out separately as instructed: dropped the dead*.firebasedatabase.appconnect-src entries (no browser code opens RTDB) andframe-src https://www.youtube.com(zero<iframe>insrc/) fromfirebase.json;https://www.google.comstays for reCAPTCHA. Also added aPermissions-Policyheader (C-O22) and removed the deadprefetchAllconfig + its stale comment (C-A10).refactor(ci): composite build action(C-D3). New.github/actions/build-sitecomposite 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 asorigin/2026baseline, verified viagit 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/2026merged 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.
refactor(react): useRemoteData(R-D1). Newsrc/lib/useRemoteData.tshook (fetch + loading/error/data state); adopted at all 6 call-sites (Speakers,Sessions,Agenda,SpeakersTeaser,Tickets,InvoiceForm).+160/-166across 7 files.refactor(react): useDialog + <Sheet>(R-D2, O-R16, R-D5, R-D6 paragraph helper). Newsrc/lib/useDialog.ts(focus trap, Esc-close, autofocus) andsrc/components/Sheet.tsx, adopted bySessionDetail/SpeakerDetail; newsrc/components/SpeakerAvatars.tsx;paragraphs()moved intosrc/lib/sessions.ts. O-R16:#main-contentnow gets a refcountedinertwhile any dialog is open — a stackedSpeakerDetailoverSessionDetailholds it until both close. No change to the close-on-Esc contract; no new backdrop-close behavior.+431/-350across 8 files.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 functionalsetFields;wire()caches its ref callbacks;NewsletterFormno longer disables its submit button (matchesInvoiceForm's pattern, keeps the error text reachable); the error live region isrole="alert"only while there's an error; the client now reads the server's field name fromdetails.fieldfirst, falling back to parsingmessageonly through an allowlist.+111/-41across 2 files.refactor(react): time & shared helpers(R-D3, R-D4, O-R13, R-D6, R-D7, O-R20, O-R21). NewuseMediaQuery/usePrefersReducedMotion;pragueNowremoved in favor of the already-exportedpragueParts;nowState/byStartnow read from a memoized placements map instead of re-parsing times;speakerNames/round2centralized;TicketsSectionextracted out of 4 repeated blocks;formatAmountreplacesformatPrice. O-R21:InvoiceFormnow deriveslaterWaveOnSalethe same wayTickets.tsx:231does — a real bug fix, not just a rename.+194/-119across 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.Known gap, deliberate: O-R19 was fixed client-side only in this batch (allowlist fallback on
message, primary read fromdetails.field); the server (functions/src/invoice/submit.ts) didn't senddetails.fieldyet — closed in batch 5.Batch 5/5 (final)
Three small fixes, then Astro/visual, then docs.
fix(react): stable onClose—Agenda.tsx/Sessions.tsxpassedonClose={() => setSelected(null)}inline;useDialoghasonClosein its effect deps, soAgenda'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-side—submitInvoiceCallablenow throws through a newinvalidArgument(field)helper that setsdetails: { field }, not justmessage, closing the client-side-only fix from batch 4. New test file (submit.test.ts, 1 case).+27/-2, 2 files.refactor(astro): Closer slot + /partners de-duplication(A-D1, A-D13, A-D4 partners half, O-S5).Closer.astrogets a default<slot/>+ optionalclassprop;/partnersuses 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-platedlogic moves into newPartnerLogo.astro.partners.scss's dead, unscoped.contact-nameis deleted, the surviving one wrapped in:global(.band--accent)like.email-linkalready is. No visible change.+66/-78, 4 files.refactor(astro): SectionHead + press SCSS deletion(A-D2, S-D5). NewSectionHead.astrowraps the global.head-stackprimitive;/pressand/press/downloadsboth switch to it. Visible change on both: neither page's local.section-headmatched the canonical spacing —/press's header padding-bottom 1.1rem → 1.6rem, border colorvar(--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-splitcorrectly 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.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 intoBaseLayout.scssbeside.fallback-note). Visible change on/pressand/press/downloads: the two local.empty-staterecipes had drifted (52ch/56ch width,--fs-label/--fs-label-xsstamp, 1.6/1.7 line-height) — the shared primitive takes 56ch/--fs-label/1.7.+151/-140, 14 files.refactor(scss): .sr-only global primitive(S-D7). Three scoped.sr-only/.srOnlycopies (Countdown,NewsletterForm,privacy-policy) all omittedclip-path; deleted in favor of the global one, withCountdown.tsx/NewsletterForm.tsxnow referencing it by literal class name perDESIGN.md's MUST. A-D14 (.back-linkstyled 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.docs: refresh DESIGN.md / CLAUDE.md(O-S17 corrections, S-D19 cross-references).--fs-figureremoved from the ramp table (zero call sites, no longer declared);Footer.scss's twofont-size: inheritcorrected off the ramp-bypass list (they reset<small>'s UA default, not a bypass); newsrc/styles/*.scssrow documenting the five mixin partials this review pass added; new "Shared building blocks" paragraph inCLAUDE.mdnaming 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'sPartnerLogo.astropushedastro checkto 11 errors (aRecord<string, unknown>spread against<Image>'s discriminated-union props doesn't type-check cleanly). Foldedclass/alt/loadinginto the same object before the cast instead of spreading alongside them — back to 9.+10/-2, 1 file.PR 21 (
.btn-primarycomposition, 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 betweenSpeakers/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 fromorigin/2026baseline.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
2026value or363structure dropped):.github/workflows/firebase-hosting-{merge,pull-request}.yml— kept the sharedbuild-sitecomposite action (PR 27); added aninvite-discount-urlinput to the action so both workflows still passPUBLIC_INVITE_DISCOUNT_URLthrough 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-domin devDependencies only (PR 4's move), added@resvg/resvg-js(new dependency from the invite-image pipeline); lock regenerated withnpm install --package-lock-only.scripts/a11y.mjs— kept the sharedAUDIT_ROUTESlist +fixture-server.mjs(PR 22), deleted2026's now-duplicate inline path list and local static-file server; added the new/invite/eliska-cejpova/route toscripts/routes.mjsso both the a11y and anchor sweeps cover it.src/components/Sessions.module.scss— kept2026's Impeccable-approved values (the.chipambient red focus glow removed,.moreDotsmade static instead of pulsing) on top of363's structure; the now-unusedpulse-opacitymixin include and_motionimport 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 to2026's raw properties, so no visual change.src/components/Tickets.tsx— kept363'suseRemoteData-destructuredstatus, combined with2026's actual fix: the empty-state branch no longer returnsnullwhen ti.to slugs are missing (that used to delete#ticketsfrom the DOM after hydration and break every/#ticketslink).src/lib/tito.ts— kept both additions:363'sround2(GA4 rounding) and2026'scheckoutUrl/eventUrl(used by the newInviteCta.tsx); removed the accidental duplicateeventUrlthe merge left behind.src/pages/partners.scss— restored2026's.contact-namerule, which the merge would otherwise have silently dropped even thoughpartners.astrostill 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:2026added the 11/invite/<member>/pages + one shared og-image route).npx astro check— 10 errors, not 9: the 9 known baseline errors plus one new one already present onorigin/2026itself (src/pages/og/invite/[member].png.ts:276, asatori()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.