---
spec_id: event-setup-wizard
status: accepted
---

# Event setup wizard—drafts, the occasion matrix, and the shared launch gates (`event-setup-wizard`)

The contract the rest of [#786](https://github.com/nathanjohnpayne/fiveacross/issues/786) consumes: the working state of the five-step organizer wizard (`EventDraft`), the occasion → Edition/defaults matrix as data (`OccasionDef`), and the validation predicates Steps 3–5 and the launch provisioner share. No UI ships here—the wizard shell is #788, the steps are #789–#792 and #794, the provisioner is #793, and the preview strip is #795.

Design shape comes from `plans/daily-cards-wireframes.html` § "Five Across · New event setup—the organizer wizard" (`#frame-setup-occasion` … `#frame-setup-launch`), which is explicitly **exploratory**—that section says so itself, and says the durable output of designing it is [#785](https://github.com/nathanjohnpayne/fiveacross/issues/785). Every binding fact below comes from #785 or from the code it cites. Where the frames and #785 disagree, #785 wins, and the deviation is recorded here rather than left for a reader to spot.

The single idea underneath all of it: each rule this module enforces is already enforced somewhere the organizer cannot see until it is too late—`dealBoard`'s `MIN_POOL` throw, the `daysThemeLockOk` index unroll in `firestore.rules`, `finaleTimes` returning `null`, `activeSnapshotIds` excluding Prompts newer than a Day's cutoff. Moving those failures forward to a moment when they are still editable is the whole job.

## Glossary

- **Draft**: an unlaunched Event under construction. Device-local, resumable, and never the holder of a claimed Slug. Not a Firestore document and not an `EventDoc` with a different `status`.
- **Slug candidate**: the address the organizer is trying for. Checked live while editing; claimed once, transactionally, at launch. A draft holds only the candidate.
- **Occasion**: the Step 1 choice that binds an Edition, a starter pack, and a set of defaults. A starting point, not a mode—every default it applies stays editable.
- **Assigned pool**: a Pool at least one Day of the draft deals from (for a one-card Event, the main pool). The unit the 24-Prompt minimum is measured in.

## Contract

- **`EventDraft`** (`src/domainTypes.d.ts`). The wizard's whole working state: identity and timestamps, the resume `step`, `occasion` and the `edition` it bound, `name`/`startsOn`/`endsOn`/`timezone`, `slugCandidate`, `claimMode`, `cardFormat`, `hostedBy`, `defaultTheme`, per-pool `prompts`, the `days` schedule, and a `settings` block. It carries a schema version `v` and lives only behind `EventDraftStore`.
- **There is no Event-level Free Space field, deliberately.** The Free Space text is the build-time constant `FREE_TEXT` in `src/data/seed.ts` (`dealBoard(pool, day.freeText ?? FREE_TEXT, …)`); `EventDoc` has no per-Event home for it, so a draft field would have nowhere to land at launch. `#frame-setup-look` agrees—it offers "Free space, per day" and nothing Event-wide—so per-Day copy lives on `DraftDayDef.freeText` and nowhere else. Worth the epic's attention: `FREE_TEXT` is `'Complain about circuit music'`, Gay Cruise Bingo's own voice, and a one-card Event has no Day to override it. Giving a self-served Event its own Free Space copy needs a new `EventDoc` field, which is outside this ticket.
- **A draft never holds a claimed Slug.** There is no `slug`, `eventId` or `hostname` field on the type, and `parseEventDraft` reads a stored blob carrying any of those as a **miss** rather than repairing it—repairing would mean choosing which half of a contradictory record to believe. The claim happens once, at launch, inside the provisioner's transaction (#793).
- **`EventDraft['settings']` is derived from `EventDoc['settings']`**, as `Required<Pick<…>>`. Derived so a draft cannot carry a setting the Event contract has no home for; `Required` because "absent means apply the call-site default" is a posture for documents seeded before a field existed, not for a flow whose entire job is to decide. `dailyEmailEnabled` is in that set precisely because it reads false unless explicitly `true` and has **no** Admin control (#785)—an Event created through a flow that never collects it can never send the documented daily email without a direct Firestore write.
- **`spicy` is main-pool only, at the type level.** `DraftMainPrompt` carries a required `spicy: boolean`; `DraftCuratedPrompt` declares `spicy?: never`, so `{ text, spicy: true }` does not typecheck as an easy- or closing-pool Prompt at all. Two further layers close the untyped paths: `parseEventDraft` rejects a stored blob that breaks it, and `promptPoolIssues` reports it for a draft that reached memory through a cast, a fixture, or a future imported pack. The rule exists because `adminAddItem` forces `spicy: false` outside main and the 18+ posture derivation ignores non-main pools, so a spicy curated Prompt is silently de-flagged—an explicit Square reaching a card with no 18+ gate.
- **A Day is not a calendar date.** `DraftDayDef.index` is the identity and `date` is deliberately **not** unique across the list: Bodega runs two Days on 2026-08-09 (competitive main at 06:00, closing wrap-up at 11:00), and collapsing them would delete the competitive card and freeze standings early.
- **`tutorial` is independent of `pool`.** `pool` decides what a Day deals; `tutorial` decides only whether its wins count toward the Event-wide First to BINGO. Bodega's easy-pool Friday is `tutorial: false`. Both fields are total and every combination is meaningful, so no predicate infers one from the other. Whether a Day is *ceremonial* for standings is a third, separately derived set (`ceremonialDayIndexSet`) which the wizard does not author.
- **`DraftDayDef.pool` and `.theme` are derived from `DayDef`**, not re-declared, so the draft's vocabulary cannot drift from the launched Day's—the posture `CardSnapshotDay` already takes with its `Pick<DayDef, …>`.
- **`timezone` is organizer-editable and only auto-*suggested*.** `createEventDraft` seeds the device zone so the field is never blank, never so the question is treated as answered: `EventDoc.timezone` is the zone the whole schedule is interpreted in, and auto-detecting the organizer's own zone silently provisions a destination Event hours off (#785)—the common case for a trip planned from home. The frame caption reads "America/Los_Angeles (auto)"; #785 supersedes it, and the frames should catch up next time they are touched.
- **`hostedBy` is display metadata.** The creator is granted **Admin**, and that grant—not this string—authorizes every setup write. A Host is a social identity, not a permission (CONTEXT.md).
- **`cardFormat`** is the frames' Step-3 segment: `daily_cards` is an Event with an ordered `days[]`; `one_card` is an Event with an **empty** `days[]`—the legacy single-Board shape behind the Wedding occasion's "One card, one celebration". The distinction is load-bearing for validation, because a one-card Event has no Day to fail a Day-shaped gate.

## Draft lifecycle

- **Create.** `createEventDraft` returns a draft standing on `step: 'occasion'`, stamped at the current `DRAFT_SCHEMA_VERSION`, with no occasion, an empty slug candidate, and every Event setting explicitly stated. `draftId` is device-local and is **not** an Event id—no Event exists yet.
- **Resume.** `EventDraftStore.load` returns the draft or `null` on **any** miss: absent, unparseable, version-drifted, or carrying a forbidden claimed-slug field. `list()` returns a summary per readable draft, most recently updated first, and **skips** the blobs it cannot read. A resumed draft reopens at its stored `step` with no field loss. A blob whose **key suffix disagrees with its embedded `draftId`** is skipped from the listing but deliberately *not* reclaimed: advertising the embedded id would produce a permanently stuck row (`load()` would miss on a different key and `discard()` could never reach the original), yet the blob still parses, so it is somebody's real draft and this module never deletes valid work to tidy a listing.
- **Listing deletes nothing.** Reading the resume list is not a destructive operation, and this is a *structural* guarantee rather than a careful one. Earlier revisions reclaimed unreadable blobs, guarding the delete with a re-read that spared future schema versions and concurrent saves. That guard cannot be made correct: the classification and the removal are two separate `localStorage` operations, another same-origin tab can land a valid save in the window between them, and `localStorage` offers no compare-and-delete primitive to close it—the window can be narrowed forever and never shut. Given a race that cannot be eliminated, the asymmetry decides it: wrongly keeping a dead blob costs some quota, and `save` already tolerates a full store without losing the draft being edited, whereas wrongly deleting one destroys an organizer's work permanently and silently. So nothing is reclaimed automatically. Quota is left to `discard()`—the one path where deletion is what the organizer actually asked for. This is also why the storage key carries no schema version: an unreadable blob must stay *reachable* so `discard()` can still reach it.
- **Discard.** `discard(draftId)` removes one draft; discarding something already gone succeeds. Success means the raw storage key was observed absent after removal, not merely that `removeItem` returned: a silent no-op remains a failure, and a verification read that throws cannot be collapsed into the same `null` as an absent key. `load()` is deliberately not the proof because it also returns `null` for unreadable or malformed blobs. There is no soft-delete: a device-local draft has no audit story worth keeping.
- **`unreadable()` is the discovery path `list()` deliberately does not provide.** It returns the raw storage-key suffixes for every blob under the draft namespace that `list()` cannot show — unparseable, version-drifted, or key/id-mismatched — so a caller can pass one straight to `discard(id)`, which reconstructs the exact same key. Without it, `discard()`'s `draftId` parameter has nothing to name a hidden blob with, and stale entries from a retired `DRAFT_SCHEMA_VERSION` accumulate toward the storage quota with no path back (#814; once full, `save()` silently stops persisting new writes). Like `list()`, it is read-only and deletes nothing — surfacing a hidden entry is not reclaiming it, the caller still has to call `discard()` on purpose. This does not loosen the "listing never deletes" guarantee above; it is what lets an organizer, or a future cleanup surface, act on it deliberately instead of automatically.
- **Save.** `save` stamps `updatedAt` from an injected clock and re-stamps `v`, so a hand-edited blob cannot persist a stale version. It writes **only a snapshot that reads back**: `JSON.stringify` is not round-trip safe for every in-memory draft—a sparse array becomes explicit `null` entries that `parseEventDraft` rejects—so the serialized value is re-parsed before it replaces the stored one. Without that check a save could overwrite a perfectly good draft with a blob the next `load()` misses: a save *destroying* the thing it was meant to preserve. On failure the previous stored value is left exactly as it was. A quota or serialization failure is swallowed—the in-memory draft the organizer is editing is unaffected, exactly as the durable card snapshot behaves.
- **`parseEventDraft` bounds the values, not just the shapes.** A stored blob is refused when a setting is well-typed but outside the range its consumer honours, because each such value is one a downstream contract silently reinterprets: `reportHideThreshold` must be **strictly positive** (`isReportHidden` and the server auto-hide path both read non-positive as "report hiding is off", so a stored `0` launches an Event whose moderation is quietly disabled); `spicyRatio` and `easyMixRatio` must be within **0–1 inclusive** (`dealBoard` clamps, so an out-of-range value is not a stronger preference but a stored setting the organizer never receives); and Prompt text must satisfy the same 1–80 bound `promptTextIssues` enforces. Shape-only validation would let each of these resume, pass the launch gate, and then disagree with what players actually get.
- **Version drift is a miss, never a repair.** A blob at another `DRAFT_SCHEMA_VERSION` reads as `null`, matching the ADR 0009 cache convention (`eventResolution.CACHE_VERSION`, `CardSnapshot.v`). That is the forward-migration lever: when a field is added to the draft, bumping the version retires half-shaped drafts instead of resuming one at Step 5.

**Storage is a seam, not a backend choice.** `EventDraftStore` is the one interface the wizard talks to and `createLocalDraftStore` is its only implementation today. Every method is asynchronous even though `localStorage` is not, because that is the seam's entire purpose: a server draft model is necessarily async, and a synchronous interface would have to be rewritten at every call site the day epic Decision 1 rules the other way. `list()` scans the key namespace rather than maintaining an index document—an index is a second source of truth that drifts the first time a write half-fails, and the draft count on one device is small enough that scanning is free. It also guards the whole enumeration, because `length` and `key()` **throw** in a restricted `Storage` rather than merely returning nothing.

**The storage key carries no schema version, on purpose.** Versioning the key would make a bump orphan every existing draft—`list()` would stop matching them and `discard()` could never reach them—which would also quietly demote `parseEventDraft`'s version check to a guard against hand-edited blobs rather than the migration path it claims to be. One stable namespace keeps a stale draft both unreadable *and* reachable, which is what lets `discard()` still remove it.

## The occasion matrix

`OCCASIONS` (`src/data/occasions.ts`) is a pure code table, for the same reason `src/editions.ts` is one: Step 1 has to render before anything about the Event exists, and what it binds is itself a code table. Each `OccasionDef` carries the frame's own `label`/`blurb`/`emoji`, the bound `edition`, a `starterPackId`, and `defaults`: `cardFormat`, `claimMode`, an `OccasionScheduleShape | null`, a `defaultTheme`, proposed `dayThemes`, and the full `settings` block.

| Occasion | Edition | Card format | Schedule shape | Default Theme |
| --- | --- | --- | --- | --- |
| 🏝 Weekend away | `vacay` | daily cards | 4 Days · 06:00 · easy opener (counts) → closing finale | The Birds |
| 🏙 City break | `vacay` | daily cards | 3 Days · 06:00 · easy opener (counts) → closing finale | The Birds |
| 💍 Wedding | `fiveacross` | one card | — | Confetti Hour |
| 🎤 Conference or offsite | `fiveacross` | daily cards | 3 Days · 06:00 · easy opener (tutorial) → closing finale | Marquee |
| 🚢 Cruise | `vacay` | daily cards | 7 Days · 06:00 · easy opener (tutorial) → closing finale | The Birds |
| ✨ Custom | `fiveacross` | daily cards | — (starts bare) | Marquee |

Decisions worth stating, because each is a place the obvious answer is wrong:

- **Every `starterPackId` is `null`.** Content ownership is open (#786 Decision 2), and the frames name exactly one pack—"🌊 Coastal weekend pack", 86 squares / 32 main / 28 easy / 26 closing, on the Weekend-away worked example—which does not exist in the repo. Naming it here would be a promise rather than a reference. When a pack is authored, flipping its id from `null` is the whole change.
- **Cruise binds to `vacay`, not `gcb`.** Gay Cruise Bingo is "the original adults-only cruise edition" (CONTEXT.md)—a branded line with its own wordmark, cruise Lexicon and adult posture—so handing every self-served cruise that identity would be wrong by default in exactly the way #785 catalogues. Vacay's travel register serves a cruise correctly. Recorded as an open question for #786.
- **`schedule: null` means the occasion proposes no Days at all.** True for Wedding (a one-card Event has no schedule) and for Custom ("Start empty, choose everything"—Step 4 authors every Day from nothing rather than deleting rows the organizer never asked for). `dayThemes` is empty exactly when `schedule` is `null`.
- **`dayThemes` may be shorter than `dayCount`.** Each Edition registers three Day Themes today; a 4-Day weekend repeats the tail, exactly as Bodega's two Sunday Days share one Theme. Step 4 owns the repeat.
- **Every occasion defaults to Honor.** `EventDoc.claimMode` is required with three real values, so something must choose. The frames put the question in Step 2 while the PRD lists claim mode among the defaults-with-an-escape-hatch that get no step (the contradiction #786 records). The matrix satisfies both readings: a default so the flow is never blocked on the question, and Step 2 may still ask. What it must not do is leave the field unset and let the provisioner hard-code Honor invisibly.
- **`spicyRatio` defaults to `0.4`**—`dealBoard`'s own default—rather than Bodega's explicit `0`. The draft *writes* the key rather than omitting it, and writing a value the engine would not have chosen is a behaviour change disguised as a default. A pool with no spicy Prompts deals identically either way.
- **The alternate Namespace is not modelled here.** An Edition *may* own a second Namespace and does not have to (#785): a Five Across-Edition occasion has no distinct alternate, so "claim both namespaces" must be conditional. The alternate is an Edition fact resolved at Steps 2 and 5 (#790, #793); the matrix supplies only the Edition binding that feeds it.

`applyOccasionDefaults(draft, occasion)` returns a **new** draft with the Edition, card format, claim mode, default Theme and settings committed. The Edition is *copied* rather than re-derived on read, so editing the matrix cannot restyle a draft that is already half-built. It deliberately builds no `days[]` and seeds no Prompts: turning an `OccasionScheduleShape` into absolute `unlockAt` instants needs a zone-aware clock and is Step 4's job (#792), and seeding a pack is Step 3's (#791).

It does make one destructive change, and only one: **re-picking a one-card occasion clears `days[]`**. A one-card Event *is* an Event with an empty `days[]`, so an organizer who authors a schedule under Custom or Cruise and then switches to Wedding would otherwise hold a draft that `dayCountIssues` refuses and that no Day-authoring surface—all scoped to `daily_cards`—can reach to fix. Switching between two daily-cards occasions leaves the schedule alone.

## Step 1 · Occasion (#789)

`src/components/setup/OccasionStep.tsx` is the `STEP_CONTENT.occasion.render` entry (`stepRegistry.tsx`, #788): a full-width row per `OCCASIONS` entry, in matrix (frame) order, each carrying its `emoji`, `label` and `blurb` verbatim. It reads the matrix and calls `applyOccasionDefaults`—it does not restate any of #787's binding logic.

- **The Edition pill draws on the SELECTED row only**, naming the occasion's bound Edition (`editionBrand(occasion.edition).appName`, e.g. "Vacay Bingo edition")—matching `#frame-setup-occasion`'s own mock, which highlights exactly one row and pins the pill there. The frames are exploratory and non-binding on control placement (§ "Shell & navigation"), so the pill's exact wording is this step's call, not a pixel-match requirement; naming the player-facing Edition is the acceptance criterion.
- **A first pick (`draft.occasion === null`) commits immediately.** Nothing is at risk of being overwritten, so there is nothing to confirm.
- **The re-selection guard.** `EventDraft` carries no hand-edited tracking for the fields `applyOccasionDefaults` recommits (card format, claim mode, default Theme, settings)—only their current value, never whether a later step (Basics #790, Look #792, the provisioner #793) already changed one away from what the FIRST occasion pick set. Re-seeding "only the untouched fields" would therefore mean inventing dirty-tracking neither this spec nor #787's merged contract carries, and `applyOccasionDefaults` is documented above as recommitting those fields unconditionally (its one stated exception is clearing `days[]` on a one-card re-pick)—so that re-seeding is unconditional by design, not a gap this step should paper over. The step takes the acceptance criteria's other branch instead: picking a **different** occasion than the one already selected opens `OccasionChangeConfirm`, naming both occasions and, when the incoming occasion is one-card and the draft holds Days, calling out that the schedule will be cleared too. Confirming applies `applyOccasionDefaults` exactly as a first pick would; "Keep `<current>`" leaves the draft untouched. Re-picking the SAME occasion again is a TRUE no-op: `handleSelect` returns without calling `applyOccasionDefaults` at all (Codex P1, PR #855 round 1), rather than re-running it and trusting the result to equal what's already there—re-running it would silently discard exactly the later-step hand-edits the confirm dialog exists to protect against for a *different* occasion. Matches the acceptance criteria's literal wording ("picks a *different* occasion").
- **Three edge cases**, all about a draft this step did not itself produce—resumed, imported, or hand-crafted, never a normal in-flow edit:
  - **An UNSET `draft.occasion` that isn't actually fresh.** `createEventDraft` always pairs `occasion: null` with every OTHER field at its baseline, and the wizard's own Continue gate keeps an organizer from ever reaching Steps 2–5 while `occasion` is unset—so in-app, `null` and "nothing customized yet" are the same fact. But `parseEventDraft` validates each field independently, not their joint plausibility (Codex P2, PR #855 round 5): a resumed/imported/hand-crafted draft can be `occasion: null` while still carrying a customized edition, card format, claim mode, default Theme, settings or Days. `unsetOccasionLooksFresh` decides which case this is by comparing against a FRESH `createEventDraft()` call rather than hand-duplicated constants (so it can't drift the moment that factory changes); only a true baseline commits directly. `edition` is checked too (Codex P2, PR #855 Phase 4b round 1)—`applyOccasionDefaults` overwrites it same as the other fields, so a draft with `occasion: null`, a non-baseline `edition`, and everything else at baseline would otherwise slip past the check and have its stored edition silently replaced. A not-actually-fresh unset occasion routes through the SAME `from: null` confirm the stale-id case below uses—neither has a real "current" occasion to name.
  - **A stale/unrecognized `draft.occasion`.** `draft.occasion` may hold an id `OCCASIONS` no longer recognizes (a removed matrix entry), so `occasionById` returns `null` for `current`. An earlier draft of this step treated `current === null` as "nothing worth protecting" and committed any pick directly (Codex P2, PR #855 round 2)—but that's wrong (Codex P2, PR #855 round 3): the REST of the draft (claim mode, card format, default Theme, settings, Days) can still be real, organizer-entered data, and a direct commit would silently overwrite it, or for a one-card pick, clear the schedule outright. So this still confirms first. `OccasionChangeConfirm`'s `from` prop is `OccasionDef | null` to make that possible: when `from` is `null` it can't name "what you're leaving" (there's nothing valid to name), so its copy adapts—"Apply `<to>`?" / "This draft's saved occasion isn't available anymore…" / "Keep current answers" / "Apply `<to>`"—rather than reusing the "Switch to `<to>`?" / "Keep `<from>`" wording that needs a real `from`.
  - **A recognized `draft.occasion` that disagrees with `draft.edition`.** `eventCompletenessIssues`' own `event-occasion-edition-mismatch` routes the organizer back to THIS step specifically to fix it by re-picking. A same-occasion click there repairs it (rather than the true no-op that would otherwise leave "switch to another occasion, then switch back" as the only way out)—but the repair is a SCOPED patch to `edition` alone, never the full `applyOccasionDefaults` commit (Codex P2, PR #855 round 4): going through `commit` here would reintroduce the exact silent-overwrite risk round 3 fixed for a stale occasion id, just for a stale EDITION on an otherwise-recognized one—card format, claim mode, default Theme and settings (and, for a one-card `to`, the whole schedule) would reset even though the validator's only complaint is that `edition` disagrees with `occasion`.
- **Escape while the confirm is open closes ONLY the confirm.** `WizardChrome` owns its own document-level Escape → Cancel listener (bubble phase), registered for the whole time the wizard is mounted—well before `OccasionChangeConfirm` ever opens. An earlier draft of this step deferred to that listener the same way `CancelConfirmDialog` does, on the theory that a second Escape handler would race it—but that reasoning doesn't hold here (Codex P1, PR #855 Phase 4b round 1): `CancelConfirmDialog`'s deferral is correct because Escape opening the discard confirm from anywhere in the wizard IS the intended behavior, while `OccasionChangeConfirm` is a DIFFERENT, unrelated confirmation, so letting the same keypress also open the discard confirm on top of it produces two stacked `aria-modal` dialogs with competing Tab traps—every path into this guard represents a non-empty, customized draft, so this was reachable on every occasion switch, not a rare edge case. The fix mirrors `PreviewStrip`'s identical problem (Codex P2, PR #857): `OccasionChangeConfirm` listens on the CAPTURE phase and calls `stopPropagation()`, which runs before—and suppresses—WizardChrome's later bubble-phase listener on the same keypress, regardless of mount order. Escape then behaves exactly like "Keep current".

## Validation

`src/data/draftValidation.ts` exports pure predicates over an `EventDraft`, each returning `DraftIssue[]`. Issues rather than booleans, because Step 5 renders each one as its own checklist row with its own Edit link—a single "not ready" would be exactly the total-based reporting #785 warns about. `now` is always passed in; the module keeps no clock. `MIN_POOL` is imported from `src/game/logic.ts` and pool normalization from `src/game/pool.ts`; neither is re-declared.

- **`assignedPoolIssues`** — every **assigned** pool independently holds at least `MIN_POOL` (24) Prompts, counted **per pool, never as a total**. A closing Day snapshots only its closing pool and `dealBoard` rejects an unstratified pool below `MIN_POOL`, so a pack with 62 Prompts of which 4 are closing yields an undealable farewell card while every total-based check reports success. `assignedPools` reads the Days' pools through `normalizePool`, so the legacy persisted `embark`/`farewell` spellings both live Events still carry resolve correctly. The count is of authored Prompts alone—the Free Space lives in `freeSpaceText` and per-Day overrides, not in a pool, so this is already the non-free count `MIN_POOL` means—and it counts **real entries, not the array's nominal length**: a sparse array of length 24 satisfies a length check while holding fewer Prompt objects, and `every`/`forEach` both *skip* holes, so neither the parser nor `promptTextIssues` would notice while a save/load turns each hole into `null` and makes the same draft unreadable. `parseEventDraft` rejects sparse arrays outright—**every** array in the draft, not just the pools: `tonight` and `days` too, since `new Array(2)` passes an `every`-based predicate, serializes to `[null, null]`, and makes the very next `load` reject the draft it just saved. In memory (where no serialization has happened) the predicates step over holes rather than dereferencing them, so a sparse pool reports a missing entry instead of throwing inside the launch checklist. The same rule governs the schedule: **every predicate that reads Days goes through `daysInOrder`**, which drops holes rather than dereferencing them, because `assignedPoolIssues` composes *before* the predicate that reports the gap—so a sparse schedule would otherwise crash the Step 5 checklist and the provisioner gate rather than returning issues. The gap is reported exactly once, by `dayCompletenessIssues`, which walks by index. The Easy Mix is deliberately **not** treated as an assignment: on a main Day a short easy pool backfills from tame main (`specs/easy-mix.md`), so it is a degraded card, not an undealable one, and this gate exists for undealable.
- **`finaleClosingPoolIssues`** — a daily-cards draft's **final** Day carries the closing pool, judged by highest `index` rather than array position, **and is the only Day that does**. The uniqueness half is not tidiness: every finale consumer resolves the finale Day by *first* match, on both the client (`finaleDayIndex`) and the server (`finaleTimes`)—so an earlier closing Day silently becomes the finale and freezes standings, posts the podium and excludes its own scores days before the intended one, while a final-Day-only check still passes. Non-final closing Days are identified **by their own index**, not by position in the filtered list, so when the final Day is *also* not closing both failures are reported together rather than the second surfacing only after the organizer has fixed the first. `finaleTimes` returns `null` when the Event has neither a ceremonial Day nor a configured freeze: no standings freeze, no podium, no Most-Loved Photo. The finale is never an automatic consequence of the schedule ending. A closing Day in the middle does not satisfy it. Inert for `one_card`.

  **Why this predicate still speaks in pools, after ADR 0011 (#551).** The platform no longer infers scoring from the pool—a Day states `scoring`, and the Event states `standingsFreezeAt`. This validation is nonetheless still correct, because it constrains what the WIZARD can express, and the wizard authors neither field: a draft-launched Event carries no `scoring` and no `standingsFreezeAt`, so its finale is resolved entirely by the closing-pool fallback, and requiring exactly one closing Day on the final position is exactly the right guard for that. What it cannot yet express is a competitive final morning—an Event that plays to a stated check-out freeze. Relaxing this predicate is part of teaching the wizard to author both fields, not a separate cleanup, and must land WITH that authoring rather than before it: dropping the requirement while the draft still writes neither field would produce Events with no finale at all.
- **`draftLastCall` (`src/data/draftLastCall.ts`, #1378)** — the derived **last-call instant** Look (#792) and the Launch checklist (#794) both render, so neither re-derives it. It is a client mirror of `finaleTimes` in `functions/src/unlockDay.ts` (the only place the scheduler decides when the beat posts): given a draft's Days and an optional configured freeze (mirroring `finaleTimes`' signature) it returns `null` when there is no finale, otherwise `{ lastCallAt, standingsFreezeAt, branch: 'forward' | 'backward', fires }`. `branch` is `'forward'` for the preceding Day's `unlockAt` + `LAST_CALL_LEAD_MS` (12h) and `'backward'` for `standingsFreezeAt` − 12h, taken when there is no preceding Day or the forward candidate would land at or after the freeze (#784/#796). `fires` is exactly `lastCallAt < standingsFreezeAt`. **It reports; it never corrects**—`lastCallAt` is the scheduler's value, and a surface that wants to warn reads `fires` rather than substituting an instant of its own. The wizard authors no `standingsFreezeAt` (#551), so a draft resolves the derived freeze (first ceremonial Day's unlock) through `standingsFreezeAtFor`, the client twin `tests/functions/finale-parity.test.ts` already pins. It is also `null` while any Day's `unlockAt` is still unset, rather than a confident instant computed over a Day that is not there (`firstUnlockIssues`/`dayCompletenessIssues` already report the gap); Days are read in stored order, never re-sorted, because `finaleTimes` reads them that way. The module imports nothing from `functions/` at runtime; `src/draft-last-call-parity.test.ts` is what keeps it honest. Note that `fires` can only be `false` for a freeze so large that `freeze − 12h` rounds back to the freeze—the class of finite values above roughly 6.04e23 ms (2^79, where the spacing between doubles exceeds twice the lead; `1e25` and `Number.MAX_VALUE` both qualify), all far outside the `Date` range, and the only inputs that trip `finaleTimes`' own guard: a configured freeze earlier than the preceding unlock still resolves the backward branch with a non-empty window.
- **`firstUnlockIssues`** — the first Day's unlock is strictly in the future. "First" means **the Day found by `index === 0`, located directly**, not "whichever Day sorts first"—`daysInOrder`'s ascending sort means position `[0]` in that array is the Day with the *lowest* index, which on a malformed schedule carrying a negative index is not Day 0 at all, and would wrongly report no first-unlock issue for a real, unlockless Day 0 sitting right behind it. A schedule missing Day 0 entirely is already its own failure (`dayCompletenessIssues` reports it as `day-index-out-of-order`), and treating a later Day as "first" would misreport it twice over—the hardcoded "Day 1" message would actually describe a later Day, and `validateEventDraft`'s dedup would suppress that Day's own generic `day-missing-unlock` diagnostic in the belief the specific row already covers it (#816). So when index 0 is absent, this predicate reports nothing and leaves the gap and the later Day's own diagnostics to their owning checks. Prompts seeded at creation carry `createdAt` and `activeSnapshotIds` excludes Prompts newer than a Day's cutoff, so launching after Day 1's unlock has passed permanently stamps an **empty** arrival snapshot. The `0` open sentinel is reported under its own code rather than lumped in with a past instant: the Bodega seed uses it legitimately, but only alongside a snapshot pre-stamped atomically by the Admin SDK, which no client can write—so from the wizard it is a provisioner capability request, not an organizer mistake, and the message says which.
- **`dayCountIssues`** — between one and **ten** Days. The ceiling is the rules' unrolled schedule lock: `daysThemeLockOk` checks indexes 0–9 one by one. A longer schedule is locked two Days at a time instead (`scheduleEditWindowOk`, #1357), but it must state its Standings Freeze when it is born and can change only through that window—neither of which the wizard can do—so a long schedule is seeded by script and the wizard stays at ten (`draftValidation`'s `MAX_DAYS`, distinct from the platform's twenty). A one-card draft must carry no Days at all.
- **`dayCompletenessIssues`** — per Day, walking the **stored array in its stored order**: `days[position].index === position`. Sorting first and then checking contiguity would accept a *shuffled* schedule whose indexes are individually perfect—and the stored order is what ships, because `Board`, `eventPreview` and `DaySwitcher` all select `days[i]` **by array position**, so a launched Event would deal and reshuffle using another Day's unlock and snapshot. The one comparison collapses contiguity, uniqueness and order into the single property every consumer actually relies on. Then, per Day: a `date` that is a **real ISO calendar date** (reported as its own issue, separate from a blank one, because they are different repairs—a malformed `2026-02-30` renders `Invalid Date` in `DaySwitcher.weekday`, and `coerceEventPreview` drops the *entire* pre-auth schedule preview over the one bad entry), a **finite** `unlockAt` that **lands on that Day's stated date** when converted through the Event's `timezone` (the scheduler and the board lock read `unlockAt` while daily-email ownership and every piece of schedule copy read `date`, so a Day edited on one side only opens its card on one day and is labelled and emailed as another—converted with the same `isoDateInTz` the Day header uses, now extracted to `src/data/tzDate.ts` so the gate and the consumer cannot disagree), `place` **and** `placeEmoji`, a **registered** `ThemeId` that the bound Edition also offers, **exactly two** `tonight` entries that are both non-blank, and—if a per-Day Free Space override is present at all—a **non-blank** one. That last rule is not tidiness: both the deal path and the locked-card preview read `day.freeText ?? FREE_TEXT`, so a present empty string does not mean "no override", it *suppresses* the fallback and deals an empty centre Square. Absent means default, present means override, blank means neither—so clearing the Free Space input must produce `undefined`, never `''`. Those render on the card, the locked tease, the schedule, the leaderboard and the share copy—and of them only `tonight` has an Admin editor after creation, so a Day that launches without a `place` has no repair path. Three details are deliberate: the unlock check is a **representable-instant** test, not `!== null` and not merely `Number.isFinite`, because schedule arithmetic over a malformed date yields `NaN` (a bare null-check would launch a Day that never unlocks) and because a finite-but-huge value such as `Number.MAX_VALUE` is outside the `Date` range, where *every* `Intl` formatter throws—so an unguarded value would crash the launch checklist rather than report an issue. `isoDateInTz` is independently hardened to return `''` instead of propagating that `RangeError`; the `tonight` check counts the **array**, not the non-blank subset, because the array is persisted verbatim and consumers join it or assume `length === 2`; and an off-Edition Theme is its own issue rather than a silent reset, since re-picking an occasion rebinds the Edition under Days that were already themed. `pool` and `tutorial` are unchecked: both are total and every combination is meaningful.
- **`eventCompletenessIssues`** — an `occasion` that resolves in the matrix (it is what binds the Edition), `name`, `startsOn`, `endsOn`, `timezone`, `slugCandidate`, and a registered, in-Edition `defaultTheme` (required, independent of every `DayDef.theme`, and what a new player sees first). Two of those need more than presence, because each has a downstream contract that would silently rewrite a value this validator had called done:
  - **`timezone` must survive `normalizeTimezone`.** `eventConverter` applies it on every read and it substitutes `Europe/Rome` for UTC, `Etc/*`, bare offsets and anything without a region prefix. Since `createEventDraft` seeds the *device* zone as a suggestion and plenty of runtimes report `UTC`, a presence-only check would let a schedule authored in UTC launch and then run every unlock, email and finale beat at Rome wall-clock. The predicate imports `normalizeTimezone` rather than restating the rule—the point is that the two agree by construction—but normalization alone is not sufficient: it is a **shape** rule (region prefix, not UTC/`Etc`/an offset), not a registry lookup, so a region-shaped but nonexistent `America/Not_A_Zone` survives it—after which every consumer falls back to the device zone, the Day-date check can agree *by accident*, and an Event launches with its entire schedule interpreted in the wrong zone. The predicate therefore also requires the **runtime to recognise** the identifier via `Intl.DateTimeFormat`. And it requires the **stored value itself** to be canonical rather than trimming first: a padded `' America/Los_Angeles '` would pass a trimming check while every consumer receives the padding, `Intl` rejects it, and `isoDateInTz` silently falls back to the *device* zone, which near a date boundary can make a wrong unlock look correct.
  - **`startsOn`/`endsOn` must be real ISO dates with `startsOn <= endsOn`.** `formatDateRange` already rejects reversed or malformed windows and renders nothing, so a launched Event would carry a title and metadata with no date range. `2026-02-30` is caught by round-tripping the parse, not by the regex.

  - **The `occasion` must agree with the draft's `edition`.** The occasion is what *binds* the Edition, so the two agreeing is the entire content of that claim. A resumed or imported draft can carry a recognized occasion beside a stale `edition`—re-picking an occasion rebinds it, and a half-applied rebind leaves the pair disagreeing. Such a draft still passes every Theme check by using the stale Edition's Themes, so nothing else here would catch it, and it would launch with a player-facing identity the organizer never chose. Reported as its own issue anchored to `edition`, never as a silent rewrite of one side.

  `slugCandidate` is checked for **presence only**: its format and the reserved-name list are the shared contract #790 introduces, and duplicating a weaker version here would be a second answer to a question that needs one.
- **`settingsIssues`** — `reportHideThreshold` is strictly positive and both ratios sit within 0–1. These are the *same* bounds `parseEventDraft` applies, restated here because the launch gate runs over drafts that never went through storage—one created with custom settings, or edited since the last save—so a parser-only guard would let `isDraftLaunchable` hand the provisioner an Event with report hiding silently off.
- **Day dates sit inside the Event window.** `startsOn <= day.date <= endsOn`, checked only once the window and the Day date are each independently valid **and the window is itself ordered** (`startsOn <= endsOn`)—a reversed window is `eventCompletenessIssues`'s own `event-invalid-date-window` report, and comparing a Day's date against a backwards window is not a second, independent repair: every Day would additionally fail `day-outside-event-window` for the one mistake the event-level issue already names (#815). So one bad date does not also accuse every Day, and a reversed window does not accuse every Day either. Editing `startsOn`/`endsOn` after authoring the schedule leaves both values individually valid while stranding Days outside the window, after which `More` advertises the Event-level range and the schedule and daily-email behaviour follow the out-of-window `day.date`—one record disagreeing with itself. ISO dates compare lexicographically, so no parsing is involved.
- **`promptPoolIssues`** — main-pool `spicy` is a **real boolean**, and no easy- or closing-pool Prompt carries `spicy`. The main-pool half matters because an untyped starter pack or import can supply `undefined` or the *string* `'true'`: `parseEventDraft` refuses that, but an in-memory draft never faces the parser, so the provisioner would either trip the Firestore boolean requirement or—on a trusted path—persist a value the dealer and the 18+ derivation do not recognise as true, serving explicit content with no gate. For the curated pools, for drafts that reached memory through an untyped path. The test is on the **value**, not on key presence: this project does not enable `exactOptionalPropertyTypes`, so `{ text, spicy: undefined }` typechecks as a curated Prompt, and `JSON.stringify` drops that property—meaning a presence rule would call a draft unlaunchable in memory and launchable after one save/load. Treating `spicy: undefined` as equivalent to an absent key makes the verdict identical on both sides of serialization, while any **defined** flag is still refused.
- **`promptTextIssues`** — every authored Prompt's text, in **all three** pools, is non-blank and at most 80 characters. That is the persisted item-write contract (`firestore.rules` requires `text.size()` in 1–80, and every authoring input caps at 80), and it needs its own gate because `assignedPoolIssues` **counts** entries without reading them: a pack padded with blank Prompts satisfies the 24-Prompt minimum and clears the launch gate, only to be refused by rules at provisioning—or, through a trusted provisioner that bypasses them, to create blank Squares no Admin editor can repair. The bound is shared with `parseEventDraft` (`MAX_PROMPT_TEXT`) so the in-memory gate and the persistence gate cannot drift, and it is measured on the **raw stored string**—`firestore.rules` applies its limit to the persisted value, so a trimmed reading would accept 80 visible characters plus trailing whitespace, store 82, and be refused at provisioning. The walk is by **index**, not `forEach`, because `forEach` skips holes.
- **`validateEventDraft` / `isDraftLaunchable`** compose all of the above, and **deduplicate**: `firstUnlockIssues` speaks specifically about Day 1's unlock (missing, the open sentinel, already past) and each of those also trips the generic per-Day unlock checks. Editing the unlock clears both, so the specific row wins and the generic one for that same Day is suppressed—one organizer mistake is one checklist row, not two supposedly independent repairs. Not the whole launch gate: the live Slug claim, the server-derived 18+ posture (authoritative over the Step-2 audience choice), and the last-call derivation defect (#784) are all checked where they are resolved, not here.

## Shell & navigation

`#788`'s contribution: the route, the five-step navigation frame, and device-local Save draft / resume — everything the step tickets (#789–#792, #794) mount inside rather than build themselves. Design shape is the shared chrome across `#frame-setup-occasion` … `#frame-setup-launch`; those frames are exploratory and non-binding on control placement (their own banner says so), so this section — not pixel-matching a frame — is what a step ticket's real UI is checked against.

- **Mount point.** `/setup/*` is one sibling `<Route>` in `src/App.tsx`, added before the catch-all — `src/components/tabs.ts` stays frozen because the wizard is not a tab. Because it is a top-level segment, `setup` is in the reserved path-segment floor (`src/slug.ts` `RESERVED_PATH_SEGMENTS`; `specs/path-addressing-and-root.md` § Reserved paths), so no Event can be slugged `setup` and the path parser never reads `/setup` as an Event address (#1223, #1387). The wizard's own sub-navigation (which draft, which step) is parsed from the pathname with `matchPath` (`src/components/setup/route.ts`) rather than a nested `<Routes>` tree, mirroring `More.tsx`'s `adminSectionFromPath` convention for a route-driven sub-surface under one top-level splat.
- **Two addressable shapes.** `/setup` (bare) always starts a **fresh** draft — a deliberate choice, not an oversight: conflating "New event" with "resume my last one" would silently hand an organizer a stale or someone else's in-progress draft when they asked to start over. `/setup/:draftId/:step` is the one shape a reload or a deep link resumes from; where the app links *to* either shape is #766's call (the root create-Event entry point), not this ticket's.
- **Step order and the registry (`src/components/setup/wizardSteps.ts` / `stepRegistry.tsx`).** `SETUP_STEP_ORDER` (`wizardSteps.ts`) is the ONE place step order is stated — `SetupWizard`'s navigation (`handleAdvance`, `firstIncompleteStep`, `stepIndex`) reads it directly. `STEP_REGISTRY` (`stepRegistry.tsx`) is an array of `{ id, label, heading, render }` DERIVED from `SETUP_STEP_ORDER` — built by mapping over it and looking up each id's content — rather than a second, hand-listed sequence of five objects, so the registry's rendering order and the navigation order are structurally the same sequence, not two lists a future edit could quietly desync (Codex, PR #840, rounds 1–2: an earlier draft of both this section and the code stated the opposite ownership). Every `render` starts as `PlaceholderStep` (inert: no inputs, no draft mutation) until its owning ticket replaces that one entry in `STEP_CONTENT` — never adds, removes, or reorders steps, which stays this ticket's contract; a reorder belongs in `SETUP_STEP_ORDER` itself and touches the `stepForIssue` classifier and every step ticket at once. Occasion (#789, `OccasionStep`), Squares (#791, `StepSquares`) and Basics (#790, `StepBasics`) have already made that swap; Look and Launch (#792, #794) still render `PlaceholderStep` until their own tickets do the same (Codex P2, PR #855; Basics moved here in PR #911, which is #790 — this sentence named it as pending while the registry it describes already pointed at the real component). `render` receives `{ draft, updateDraft }`; `updateDraft` is the one mutation channel a step ever needs, and the shell — not the step — persists every call. `heading` is rendered by the SHELL (`SetupWizard`), once, immediately above the step's own body — never by `render` itself (Codex P2, PR #840, round 3): a step ticket updates its `heading` in `STEP_CONTENT` and the chrome picks it up automatically, rather than every step re-implementing identical heading markup that could drift from what the registry declares.
- **Per-step Continue gating, from the SAME shared gate.** `src/components/setup/wizardSteps.ts`'s `stepForIssue` classifies each `DraftIssue` `validateEventDraft` reports into the step whose Continue button it blocks — never a parallel, hand-written per-step re-check. That is what keeps "Step 2 is done" from drifting from what the launch gate (and the provisioner, #793) actually require: a step reads complete only once the shared gate has nothing left to say about its fields. The classification is EXHAUSTIVE over `DraftIssueCode` at compile time (a `never`-typed default arm, mirroring `App.tsx`'s own `Record<TabId, ReactElement>` pattern), so a code added to `draftValidation.ts` without a matching case here fails `npm run typecheck` rather than silently gating nothing. Launch is not a bucket in that classifier — every code resolves to an earlier step — so its own completeness is `isDraftLaunchable` directly, the full gate, not a per-code subset. Tapping Continue when the current step's gate is unsatisfied does not advance; it surfaces the blocking issues' own `message`s inline instead of a generic "not ready".
- **Deep-link / resume landing.** On mount, a requested step further ahead than `firstIncompleteStep(draft, now)` (the first step, in order, whose gate is not yet satisfied) redirects to that earlier step instead of rendering an inconsistent state — Step 4 with Steps 1–3 unanswered, for instance. Landing exactly on the first incomplete step, or anywhere **before** it, is not a redirect: that is ordinary review/back-navigation, and every step before the first incomplete one is, by construction, already complete.
- **Back navigation.** The five-step indicator's pill for a step is a real `<button>` only when it is both COMPLETE and already REACHED — its index is strictly before the current step's. Completeness alone is not the rule: a later step can independently satisfy its own gate (nothing assigned to it yet, so it has no issues to report) while an earlier one is still the reason the organizer hasn't reached it, and a clickable pill there would offer a forward jump the deep-link landing rule immediately bounces back from (Codex, PR #840, round 2). Every other pill — the current step, anything not yet reached, and anything reached but still incomplete — renders as an inert `<span>`, never a `disabled` interactive control (a disabled button on an interactive role reads as broken to assistive tech, not as "not yet"). The header's left-hand affordance mirrors the frames exactly: Step 1 shows `✕ Cancel`; Steps 2–5 show `‹ <previous step's label>` and step back exactly one position.
- **Draft persistence.** Every `updateDraft` call and every step transition (`draft.step`, "where Resume reopens") commits through `EventDraftStore.save` immediately — no field mutation waits for an explicit save. The header's `Save draft (local)` button is a direct, visible save trigger with its own brief confirmation, matching the ticket's "reassurance, not the only writer" framing — it is *a* writer, not *the* writer. It is hidden on the terminal Launch step, where there is nothing left to save toward.
- **Cancel.** `✕ Cancel` (Step 1's header slot) and `Escape` (any step — the one behavior this shell borrows from `AdminSheet`'s dialog conventions) both request Cancel. An **empty** draft (`draftHasContent` false — nothing beyond `createEventDraft`'s own seeded timezone suggestion and default settings) discards without ceremony. A **non-empty** draft opens a confirm ("Discard this draft?" / "Keep editing" / "Discard"), matching `ReshuffleSheet`'s irreversible-action shape: the safe choice is the primary button. Confirmed or ceremony-free, Cancel calls `EventDraftStore.discard` and leaves for the app's Card tab (`FALLBACK_PATH`) — the wizard does not know its own entry point (#766), so it returns to a page that always exists rather than guessing one that does not yet.
- **What this shell deliberately does NOT mirror from `AdminSheet`.** No backdrop-tap-to-dismiss and no header swipe-down: `AdminSheet` overlays other tab content and needs both, because there is a page behind it to protect and escape into. This wizard mounts at its own top-level route — a full page, not an overlay — so neither applies. What IS mirrored: focus moves to the sheet's own title on mount and on every step change, the same "the surface's name is what assistive tech hears on arrival" convention `AdminSheet` uses for its section changes.

## Step 2 · Basics

`#790`'s contribution: `StepBasics.tsx` (`src/components/setup/`), plugged into `basics`'s `STEP_CONTENT` entry (`stepRegistry.tsx`) — name, dates, timezone, the live-checked address, marking mode, and audience. Design shape is `#frame-setup-basics`, which is exploratory and non-binding on control placement (its own banner says so, and the frame draws the timezone control and a daily-email opt-in that this ticket does not follow — see below); this section is what the built step is checked against.

- **The shared Slug contract lives in `src/slug.ts`**, dependency-free like `src/eventResolution.ts` and rooted under `src/` rather than `worker/` for the same reason `src/domainTypes.d.ts` is (#545's edge Worker imports it the same way `functions/` already reaches into `src/domainTypes`). `RESERVED_LABELS` — `admin`, `api`, `auth`, `d`, `play`, `send`, `status`, `www` — is the reserved-infrastructure floor, and `RESERVED_SLUGS` is its union with the path-segment floor (`__`, `admin`, `assets`, `feed`, `items`, `leaderboard`, `more`, `setup`, `unsubscribe`; `specs/path-addressing-and-root.md` § Reserved paths, #1387), which is the set `validateSlug` refuses; `SLUG_MIN_LENGTH` is 3, `SLUG_MAX_LENGTH` is the RFC 1035 label ceiling (63); `validateSlug` refuses empty, too-short, too-long, non-LDH characters, edge hyphens, the `??--` reserved-tag form, and reserved labels — in that order, reserved-first, so `d` reads as reserved rather than merely short. `normalizeSlug` only trims and lowercases; it does not repair an invalid candidate. **This is the ONE list** — the wizard's address step, the Worker's namespace guard and the client's path parser `parseAddress` all consume it, never a second copy (#786 Decision 3, #785).
- **`alternateNamespaceApex` (`src/editions.ts`)** is the Edition-keyed table `specs/event-setup-wizard.md`'s occasion-matrix section names as deliberately absent from `OccasionDef` — "the alternate is an Edition fact resolved at Steps 2 and 5". `null` for `fiveacross` (it IS the canonical Namespace, so it owns no distinct alternate) and for any unrecognized Edition id; `vacaybingo.com` for `vacay`. **`gcb` is `null` too, and an earlier draft of this line was wrong to map it to `gaycruisebingo.com`** (Phase 4b P2, PR #911). A Namespace is an apex whose WILDCARD subdomains address Events; `gaycruisebingo.com` is Gay Cruise Bingo's own site and is not one. `CONTEXT.md` § Namespace names exactly two, and `worker/src/host.ts`'s `NAMESPACES` guard admits exactly those two, so `<slug>.gaycruisebingo.com` is refused as `out-of-namespace` before its hostname document is read. The old reasoning — that the fact is true of the Edition regardless of whether the wizard can reach it — is what made it wrong, by treating owning a domain as owning a Namespace. Add a row only when wildcard serving for that apex actually lands; the table is pinned against the served apexes by a test so the reasoning cannot be repeated. The launch provisioner (#793) reads the same function to know which second hostname to claim.
- **`checkSlugAvailability` (`src/data/hostnames.ts`)** is the read-only availability helper over `fetchHostnameDoc`: `'available'` for no document, `'taken'` for a document in ANY status (active, disabled, or archived — a retired mapping still occupies the label), and `'check-failed'` for the network failure `fetchHostnameDoc` itself documents as a throw. It reads **existence**, not servability, and deliberately does NOT route through `fetchHostnameDoc`: that helper runs `coerceHostnameDoc`, which returns `null` for a document whose `eventId` or `status` is missing or invalid, so an EXISTING but half-written record was reported as a free address (Codex P1, PR #911 — the sentence here previously asserted the correct intent and then described the implementation that inverted it, and the unit test did the same, passing while its own name said the opposite). A half-written record still occupies its label: the provisioner claims by document id (#793) and will refuse it, so answering "available" only moves the refusal to the end of the wizard.
- **The address field commits to `draft.slugCandidate` only once confirmed — with one optimistic exception.** `StepBasics` keeps the raw typed text as local component state; a GENUINELY NEW candidate (one that does not already equal `draft.slugCandidate`) is written to the draft only once it (1) passes `validateSlug` and (2) a fresh `checkEventAddressAvailability` call has resolved `'available'` for **every** address a launch would claim — the canonical `<candidate>.fiveacross.app` and, when the Edition owns one, `<candidate>.<alternateNamespaceApex(edition)>` — every other moment for a new candidate (empty, invalid, reserved, checking, taken, or check-failed) clears `draft.slugCandidate` to `''`. This is what makes the acceptance criterion "an availability check in flight or failed fails closed" true through the EXISTING shared gate (`eventCompletenessIssues`'s presence check on `slugCandidate`, unchanged and still presence-only per the note above) rather than by adding a second, parallel Continue-blocking mechanism: `validateEventDraft` is pure and synchronous and cannot itself await a network read, so the only way an async, in-flight-or-failed check can block the shell's Continue button is by controlling what gets written to the field the pure gate already inspects. **Verification is its own field, because the gate is synchronous** (Phase 4b P1, PR #911). `EventDraft.slugVerifiedForEdition` records the Edition the candidate was last CONFIRMED available for, and `eventCompletenessIssues` requires it to equal `draft.edition`, raising `event-slug-unverified` otherwise. Presence of `slugCandidate` alone was doing two jobs — recording the organizer's choice and standing in for "this address is claimable" — and they come apart twice: the optimistic exception below shows a candidate as available before any read, so an organizer advancing inside the debounce carried something unverified; and an Edition change alters WHICH hostnames a launch claims, so a candidate confirmed under one Edition says nothing about another. Storing the Edition rather than a boolean answers both with one field. An earlier fix cleared the candidate on unmount instead, which does not work: cleanup runs AFTER the synchronous Continue gate has already let the organizer advance, so it never prevented the navigation and only made the already-advanced draft retroactively incomplete. Drafts saved before this field parse with it absent and are treated as UNVERIFIED, so they re-verify on arrival rather than failing to load. **The verification key is the pair, not the Edition alone** (#993, observation from PR #911). `EventDraft.slugVerifiedCandidate` records the exact `slugCandidate` the Edition marker was confirmed for, and `eventCompletenessIssues` requires BOTH `slugVerifiedForEdition === edition` AND `slugVerifiedCandidate === slugCandidate` before it accepts the address, raising the same `event-slug-unverified` otherwise. The Edition marker alone said WHICH hostnames were checked but not WHICH label: a parsed or imported draft could carry a new `slugCandidate` beside a same-Edition marker earned by an earlier address, and the gate accepted it without any availability read (the launch transaction still refused an invalid claim, so the wizard was merely permissive, not unsafe). `StepBasics` writes both halves together from the one confirmed check and clears both together whenever it clears the candidate, and a draft saved with the Edition marker but without the candidate parses with `slugVerifiedCandidate: ''`, so it re-verifies on arrival like any other unverified draft.

The exception: a candidate that on mount ALREADY equals `draft.slugCandidate` (a revisited or resumed step) is shown as available immediately, and the committed field is left untouched, rather than blocking Continue on a network round trip for a value already confirmed once — the shared shell's own tests seed "already complete" drafts and expect them to read as complete on arrival, not after a debounce-plus-network delay. A background re-check still runs against the SAME candidate and downgrades it (clearing the field) the instant it learns the address is no longer free — an optimistic trust, never a permanent one. A stale race, in either branch, is guarded by an incrementing check id — a response for a superseded candidate is dropped, never applied.
- **A failed check is recoverable without editing the address.** `'check-failed'` clears the candidate like any other non-available answer, but the effect's only natural triggers are the address text and the Edition — so retyping the same address fires no `onChange` and nothing re-runs (Phase 4b P2, PR #911). An explicit Retry control, backed by a nonce the effect depends on, is what makes "try again" an action the organizer can take rather than a wait for something that never happens on its own.
- **The availability status is a live region.** It changes asynchronously — checking → available / taken / check-failed — while the organizer's focus has typically left the field, which is the whole point of the debounce. `aria-describedby` associates the text with the input but does not oblige assistive tech to announce a change, so the element carries `aria-live="polite"` and `aria-atomic="true"` rather than `role="status"`: the shell's "Saved" flash already holds that role and the shell suite queries it in the singular, so a second one would break those queries (Codex, PR #911).
- **Reserved labels are refused before any network read.** `validateSlug`'s format check runs FIRST; `checkSlugAvailability` is only ever called on a candidate that already passed it — the acceptance criterion "rejected client-side without a network read" is structural, not a race that usually wins.
- **The address is auto-generated from the name until the organizer edits it directly** ("editable once", #785). A local `slugify(name)` helper (lowercase, collapse non-`[a-z0-9]` runs to one hyphen, trim edge hyphens, cap at `SLUG_MAX_LENGTH`) proposes a starting candidate; it is a UI convenience only; `validateSlug` remains the one authority on whether the result is claimable, and a name that folds to something reserved or too short is left for the organizer to fix by typing their own address. A resumed draft that already carries a candidate is treated as already-edited, so reopening this step never silently overwrites a manual choice with a fresh auto-suggestion.
- **Both hostnames preview inline, canonical first, and BOTH are checked.** `<slug>.fiveacross.app` always renders once the candidate is non-empty; `<slug>.<alternateNamespaceApex(draft.edition)>` renders second, and only when that function returns non-null. **The alternate is a guarantee, not a bonus** (owner ruling, 2026-08-19): #793 claims both atomically, so checking only the canonical let a candidate whose alternate was already taken pass this step, survive the remaining ones, and be refused at launch. `checkEventAddressAvailability` therefore checks every previewed address and the field names WHICH one is occupied — "already taken" without naming it would block a candidate for a reason the organizer can see previewed but cannot act on, since the canonical may be free and the alternate not. `check-failed` outranks `taken` when the two disagree across addresses: "we could not tell" must never render as a definite refusal. The Edition is a dependency of the check, not just of the preview — re-picking an occasion that rebinds the Edition changes which pair a launch would claim.
- **Marking mode reuses the CONTEXT.md § "Claim Mode" vocabulary verbatim** — Honor / Proof-to-mark / Admin-confirmed — the same labels `GameSettings.tsx`'s live Admin control already uses for the identical field, so an organizer sees one name for one concept whether setting it here or adjusting it after launch. Every occasion already defaults `claimMode` to Honor (the occasion matrix); this step is what lets the organizer change it before launch, and the value always reaches the draft explicitly.
- **Audience persists `settings.forceAdult` and nothing more.** Selecting 18+ sets `forceAdult: true`; selecting "All ages" sets it `false`. The copy is explicit that this is half the verdict — `forceAdult || any active spicy main-pool Prompt` — never a promise that "All ages" suppresses the gate; Step 5 shows the resolved posture with the reason.
- **`hostedBy` is out of scope for this step.** It is display metadata (CONTEXT.md: "a Host is a social identity, not a permission") that neither #790's acceptance criteria nor `eventCompletenessIssues`'s required-field list mention; no ticket in the epic currently collects it in the UI.
- **Timezone stays a plain editable text field**, not the frame's auto-suggested-and-implicitly-locked control — `createEventDraft` already seeds `deviceTimezoneSuggestion()` as the starting value (#785), and this step adds a **conditional** "Use this device's zone" reset and an inline hint from `isSupportedTimezone` (imported, never re-derived) when the typed value is not one `Intl.DateTimeFormat` recognizes. **The reset is offered only when the device's OWN zone passes `isSupportedTimezone`, and replaced by an explanation when it does not** (CodeRabbit Major, PR #911). `deviceTimezoneSuggestion()` returns `UTC` on a UTC host — every CI runner, and plenty of servers and VMs — and the read-side contract refuses `UTC` because it demands a REGION zone: DST and date boundaries decide which calendar day a Day unlocks on, and `UTC` cannot express that. Offering the control there hands the organizer a button that writes a value the very next line renders an error for, so it is withdrawn in favour of naming what the device reported and what to type instead. Relaxing `isSupportedTimezone` to accept `UTC` was the alternative and is rejected: it would trade a visible bug for a silent scheduling one. The frame's "(auto)" caption is the recorded contradiction #785 supersedes; this step does not reproduce it.

## Squares (Step 3)

`#791`'s contribution: `src/components/setup/StepSquares.tsx` (the step body the registry's `squares` entry now renders), `src/data/draftSquares.ts` (the draft transforms it commits through `updateDraft`) and `src/data/starterPacks.ts` (the pack seam). Design shape is `#frame-setup-squares`, which is exploratory and non-binding on control placement — this section is what the built step is checked against. Every write is draft-only; no `ItemDoc` is created here, because the launch provisioner (#793) seeds the items, which is also why nothing on the write side has to think about the legacy persisted `embark`/`farewell` pool spellings. Reads still normalize, since a resumed draft can carry them.

**The step owns four things and defers the rest.** Card format, the Prompt pools, the per-Day `pool`/`tutorial` assignment, and how many Days there are. Unlock times, Themes, `place`/`placeEmoji`, `tonight` and the per-Day Free Space are Step 4's (#792) — a Day this step creates carries only `index`, `pool` and `tutorial`, and `dayCompletenessIssues` reports every other field against Look until Step 4 fills it. The split is not a layout preference: it is exactly how `stepForIssue` already routes the shared gate's codes, so "what Step 3 must fix" is read from the classifier rather than restated beside it.

**Nothing on this step re-derives a verdict.** The counts, the shortfall, the finale and the Day ceiling are all rendered from `validateEventDraft` through `issuesForStep('squares', …)`, anchored to the control that fixes them by the `pool` and `dayIndex` fields `DraftIssue` carries for the purpose — a per-Day problem renders inside its own row, a pool shortfall inside its own count. A step that counted for itself would be free to disagree with the gate that actually blocks the launch, which is the drift `wizardSteps.ts` exists to prevent. The clock that classifier requires is inert here: all nine codes it routes to `squares` come from clock-free predicates, and only `firstUnlockIssues` reads `now` — but the shared classifier is called anyway rather than hand-picking five predicates, because `StepRenderProps` deliberately carries no clock and the hand-picked list is precisely what would go stale.

**The per-pool counter has three states, never two.** Each pool renders its own real-entry count and its own verdict: `✓`, `needs 24`, or *no Day deals from it*. An unassigned pool is not passing — the minimum does not apply to it — and a `✓` there would tell an organizer their four closing Prompts were fine right up until they assigned the finale. The count comes from `countPrompts`, now exported from `draftValidation.ts` so the number shown and the number judged are one function: a nominal `.length` reading would render "24 ✓" beside a `pool-below-minimum` issue raised over 23 real entries, which is the "looks satisfied, is not" reporting #785 catalogues. No total is ever rendered with a verdict attached, and the step says in as many words that a total is not enough, so a 62-Prompt pack with four closing Prompts does not read as *nearly there*.

**Pack seeding ships as a seam with an empty registry, deliberately.** `STARTER_PACKS` is `[]` and every `OccasionDef.starterPackId` is still `null`: content ownership is open (#786 Decision 2), the one pack the frames name does not exist in the repo, and inventing prompts under a UI ticket would bind whichever voice this file happened to use to every self-served Event. `src/data/seed.ts` is not a substitute — that is Gay Cruise Bingo's own adults-only cruise voice, and handing it to a wedding is the same wrong-by-default move #785 rejects when it binds Cruise to `vacay` rather than `gcb`. What ships is everything else, so that "flipping its `starterPackId` from `null` is the whole change" is literally true: resolution, the counts, and a `seedPromptsFromPack` that returns fresh objects at every level (the table is module-level state shared by every draft on the device, so aliasing it would let one inline edit rewrite the pack itself) and rebuilds each entry field by field rather than spreading, so an untyped future pack cannot smuggle a `spicy` flag into a curated pool. It does not *coerce*: a non-boolean main-pool `spicy` is copied verbatim and reported by `promptPoolIssues`, because defaulting it would decide an Event's 18+ posture silently. With no pack bound the step renders an honest empty state rather than a seed button with nothing behind it. Seeding **replaces** rather than merges, and says so before the tap: the draft carries no per-Prompt provenance — a Prompt is `{ text, spicy }` and nothing more — so nothing can tell a pack's Prompt from one the organizer typed, and adding provenance is a `DraftMainPrompt` change behind a `DRAFT_SCHEMA_VERSION` bump that belongs to #787, not here. Stating the cost beats guessing at it.

**`spicy` renders on main-pool rows only, and is absent rather than disabled.** `adminAddItem` forces `spicy: false` outside main and the 18+ derivation ignores non-main pools, so a control offered on an easy or closing Prompt would look honoured and be silently dropped — an explicit Square reaching a card with no 18+ gate. Admin → Prompt pool disables its equivalent instead; that is a different surface with a different audience, and this one removes the control and says *off · main only* in its place. A flag tapped while the add bar still said "main" is cleared when the pool changes, so it cannot ride along into a curated add, and `addPrompt` drops it for the curated pools at the API level so the drop is unrepresentable rather than merely unrendered. The toggle is also offered on existing main-pool rows, not just at the add bar: the Event's 18+ acknowledgement derives from these flags, and a seeded or mis-tapped one with no way back would decide an Event's audience posture with no repair path inside the wizard. While a row's own text editor is open its flag renders as a static chip rather than a control — toggling it replaces that row's entry object, which the editor's identity check cannot distinguish from a different Prompt sliding into the position, so the editor would close and discard a half-typed rename. Reading the flag stays available throughout; only setting it waits for Save or Cancel, which is a moment away, whereas the lost text is not recoverable.

**Prompt text is stored trimmed, and blank is refused rather than stored and then reported.** `firestore.rules` applies `text.size() <= 80` to the value as persisted, so 80 visible characters plus a trailing space is 81 stored and refused at provisioning — the same reason `promptTextIssues` measures the raw string. A Prompt the organizer can only blank and never restore is a worse editor than one that declines.

**A hole is rendered, not skipped — in both lists.** `promptTextIssues` reports a sparse pool's gaps by index, but `map` and `forEach` both step over them, so an unrepaired hole would be invisible in the very list that is supposed to fix it. The Day list walks by index for the same reason and not merely by analogy: `map` never passes `undefined` for a hole, so a gap branch written behind it is unreachable, and a sparse schedule rendered no gap row at all while the shared gate reported one. `countPrompts` also excludes an **explicit `null`**, not only a hole: `filter` drops holes but keeps a `null`, and the two are the same missing Prompt to `promptTextIssues` and to the repair row, so counting one let a 24-slot pool holding 23 usable Prompts read as passing — the reporting failure #785 catalogues, arriving through the one function meant to prevent it — and a sparse draft silently stops persisting, because `save` re-parses its own serialization and `parseEventDraft` refuses the `null` a hole serializes to. So the list walks by index and renders each gap as its own removable row, and every transform in `draftSquares.ts` returns a dense array — dropping an explicit `null` as well as a hole, which is what makes the promise true where it matters most: `addPrompt` compacts through the same helper, so a compaction that kept an explicit missing entry left the draft unstorable, and the Prompt just added appeared in memory and vanished on the next load. A hole holds nothing an organizer could lose, so compacting it out on the next edit is a repair with no cost. Compaction covers **both collections on every edit**, through a single `normalizeDraft` chokepoint every successful transform returns through — not just the pool being edited, and not just the Days. Storability is a property of the whole draft, because `save` re-parses the whole serialization and `parseEventDraft` refuses the entire blob when *either* `days` or any pool carries a gap: so a Day edit on a draft with a sparse pool, or a Prompt edit on a draft with a sparse schedule, still leaves the draft unstorable and the change vanishes on the next load. The rule lives in one function rather than being restated per transform for a reason worth recording — four separate review rounds each found a different helper that had forgotten it (`countPrompts`, then `densePrompts`, then `mapPool`, then the Day/Prompt split). Refused no-op transforms deliberately do *not* route through it: they return the original draft by identity, because nothing was edited and a refusal should not quietly rewrite the draft it refused. Positions passed to a transform are the ones the step just rendered from the stored array, holes included, so a row and its edit always refer to the same entry. Rows are therefore keyed by **position**, not by text — duplicates are explicitly allowed, so text is not an identity — which means a delete, a compaction or a pack swap can slide a different Prompt under a row whose inline editor is open. That row resets its editor when the **entry object** under it changes, compared by identity rather than by text for exactly that reason: two Prompts may read the same, and a text comparison would miss the swap between them. Because every transform preserves the object of each entry it does not touch, a row nobody edited keeps its half-typed draft. Compared during render rather than in an effect (the pattern `WizardChrome` already uses for `showIssues`), because otherwise Save would commit one Prompt's draft over another. The editor's own **Escape stops propagation**: the shell holds a document-level Escape listener that requests Cancel on the whole draft, and React dispatches from its root container, so without it an organizer backing out of a text edit would be asked whether to discard the entire Event. Both text inputs also ignore an **Enter that is only confirming an IME composition**, which would otherwise commit a half-composed Prompt — and, at the add bar, clear the field it came from. Both inputs take their `maxLength` from `MAX_PROMPT_TEXT` rather than restating 80, so the editor and the persistence bound cannot drift apart.

**Day rows are per Day and never merged by date.** Two Days may share one — Bodega's Sunday is a competitive main Day at 06:00 and a closing wrap-up at 11:00 — so `setDayPool` and `setDayTutorial` address a Day by its position in the schedule, and collapsing the two would delete the competitive card and freeze standings early. `pool` and `tutorial` are edited by two separate controls and neither transform touches the other's field: *counts ✓ / warm-up* decides only whether a Day's wins count toward Event-wide First to BINGO, it is not inferable from the pool in either direction, and it is a different set again from *ceremonial* for standings, which the wizard does not author.

**Adding a Day moves nothing else — in particular, not the closing pool.** The new Day lands on `main` with `tutorial: false`, and the Day that currently holds the finale keeps it. Reassigning a pool the organizer chose is the "looked honoured, silently changed" class #785 catalogues, and the two predicates that fire instead say exactly what to do: `no-closing-day` names the new final Day, `extra-closing-day` names the old one and explains that the finale resolves to the *first* closing Day. Both render anchored to their own rows, where the pool control already sits. Removing a Day renumbers the rest so `days[position].index === position` still holds — the one property `Board`, `eventPreview` and `DaySwitcher` all rely on.

**The ten-Day ceiling explains its refusal.** `addDay` returns the draft unchanged past `MAX_DAYS` and the control is disabled, but the step also states why: `daysThemeLockOk` checks Day indexes 0–9 one by one, and a longer schedule needs a stated standings freeze and is set up by script (#1357). A refusal with no reason reads as a product preference.

**Switching card format is the one destructive move, and it is confirmed.** `one_card` *is* an Event with an empty `days[]`, so choosing it clears the schedule — the same single destructive change `applyOccasionDefaults` makes for the same reason: a one-card draft carrying Days fails `dayCountIssues` and no Day-authoring surface, all of them scoped to `daily_cards`, can reach the rows to delete them. The confirm is inline rather than a dialog, because the choice is reversible; switching back proposes the occasion's schedule *shape* — pools and tutorial flags, never unlock instants, which need a zone-aware clock and stay Step 4's. An occasion whose `schedule` is `null` proposes no Days at all rather than one invented one: that is what Wedding and Custom's "start empty, choose everything" mean, and the empty schedule is not silent either — `dayCountIssues` reports `no-days` and "Add a Day" sits directly underneath it.

**Repeated Prompt wording is advisory, not a gate.** Twenty-four identically worded Prompts clear `MIN_POOL` and deal a legal but miserable card, so `duplicatePromptTexts` says so — compared case-insensitively on the trimmed text, since that is the distinction a player would fail to notice. It does not block: a repeat breaks no downstream contract, it can be perfectly deliberate on a long schedule, and making it a `DraftIssueCode` would put it in the shared launch gate and the provisioner's own refusal, which is a much larger claim than "this card will be dull".
## Look (Step 4)

`#1377`'s contribution (a sub-issue of #792): the zone-aware unlock clock in `src/data/tzDate.ts` and the pure draft transforms in `src/data/draftLook.ts` that the Look step commits through `updateDraft`. The step body itself (the registry's `look` entry still renders `PlaceholderStep`) and the shared Daily-mix control are separate #792 sub-issues. Step 3 creates Days carrying only `index`, `pool` and `tutorial`; everything else on a Day is authored here (`date`, `unlockAt`, `place`, `placeEmoji`, `theme`, the two `tonight` slots, `freeText`), as are the Event's `defaultTheme` and `settings.easyMixRatio`.

**The clock.** `instantFromZonedTime(date, time, timeZone)` turns an ISO date plus an `HH:MM` wall-clock time in an IANA zone into epoch ms; `isoTimeInTz(at, timeZone)` is the display half beside `isoDateInTz`. Both refuse an unrecognised zone (`null` and `''`) instead of taking `isoDateInTz`'s host-zone fallback: an unlock instant computed in the organizer's own zone is the silent wrong-zone schedule #785 catalogues, and a refusal is the only answer a caller can act on.

**DST is resolved by `Temporal`'s default `'compatible'` rule**, so the answer matches the platform's own future one:

- **Gap (spring forward).** A wall time the zone skips resolves forward by the length of the gap, using the offset in force before the transition: 02:30 on 2026-03-08 in `America/Los_Angeles` becomes the instant the zone calls 03:30. The Day still opens on its own date, as close as possible to the time asked for and never before it, and the time input then reads 03:30 because the displayed time is derived from `unlockAt`.
- **Overlap (fall back).** A wall time the zone shows twice resolves to the earlier instant: 01:30 on 2026-11-01 in `America/Los_Angeles` is 01:30 PDT (08:30Z), not the second 01:30 PST an hour later. A Day opens the first time its clock reads the time it was given.
- Every other wall time has exactly one instant and round-trips exactly. The one way a resolved instant can land on another date is a zone that skips a whole calendar day (`Pacific/Apia` skipped 2011-12-30); `day-unlock-date-mismatch` reports that rather than the clock relabelling the Day.

**Date and time are written together, never separately.** `DraftDayDef` has no time field, so the time shown for a Day (`dayUnlockTime`) is its `unlockAt` read in the Event zone, or `defaultUnlockTime` for a Day not yet timed: the occasion's `unlockTime`, else `06:00` (the Bodega cadence; every occasion currently proposes 06:00). `setDayDate` re-dates a Day keeping that time, and `setDayUnlockTime` re-times it on its own date, refusing a Day with no valid date rather than inventing one. Both recompute `unlockAt` through the clock, and both refuse a malformed date or time and a zone `isSupportedTimezone` rejects, so `date` and `unlockAt` cannot name two different calendar days. A blank date is refused too: it can carry no instant, and the gate already reports an undated Day.

**Themes come from the Edition's list only.** `setDayTheme` and `setDefaultTheme` refuse anything `isEditionTheme` rejects, so an unregistered or off-Edition Theme is unrepresentable from this step; there is deliberately no Custom option. `proposedDayTheme` reads the occasion's `dayThemes` in Day order and repeats the tail past the end of the list (a four-Day weekend on three Edition Themes, as Bodega's two Sunday Days share one). `applyProposedDayThemes` fills only unthemed Days: it never overwrites a choice, including an off-Edition one left by an occasion re-pick, which `day-off-edition-theme` reports for the organizer to resolve.

**Place, tonight and Free Space.** `place`, `placeEmoji` and the `tonight` entries are stored as typed, because trimming per keystroke would eat the space between two words; the gate judges the trimmed value. `setDayTonight` always yields exactly two entries, padding a missing slot with `''` and dropping any past the second, because consumers join the pair or assume `length === 2`. `setDayFreeText` with a blank input removes the key rather than storing `''`: `dealBoard` and the locked-card preview read `day.freeText ?? FREE_TEXT`, so a present empty string would deal a blank centre Square.

**Daily mix.** `setEasyMixRatio` stores a ratio in 0–1 and refuses anything else, which `dealBoard` would clamp and `parseEventDraft` would refuse on the next save.

**The same two rules as Step 3.** Every successful transform returns through `normalizeDraft`, now exported from `draftSquares.ts` rather than restated, so a Look edit on a draft with a hole anywhere repairs it. Refusals and no-ops return the original draft by identity. Days are addressed by position, never by date.

**Not here.** Scoring Policy and standings-freeze authoring (the `domainTypes.d.ts` scope note, #551). Monotonic unlock order across the schedule. Re-anchoring unlock instants when the Event timezone changes after Look: the stored instants keep their meaning, so a zone edit in Basics shifts every Day's displayed time, and `day-unlock-date-mismatch` catches it only when the date moves too.

## Live preview strip

`#795`'s contribution: the Event under construction, rendered in **its own Theme** inside the Five Across wizard chrome — the wireframes' `.prevbar` on `#frame-setup-basics` / `#frame-setup-squares` / `#frame-setup-look`, with **Open ›** expanding to a full sample card. Consumes the draft store from #787 and the shell from #788 read-only; it is presentational and never calls `updateDraft`.

- **Mount point.** `WizardChrome` (`src/components/setup/WizardChrome.tsx`) mounts `PreviewStrip` (`src/components/setup/PreviewStrip.tsx`) itself, between `.wizard-body` and the Continue row — never an individual step's own `render` — so every step that shows it shares one instance. It appears on Basics, Squares and Look; Occasion has no draft content yet to preview, and Launch has its own full review checklist in the same footer slot.
- **The scoped Theme island (`src/theme/ThemeIsland.tsx`) is the new, reusable `src/theme/` piece.** It sets `data-theme` on its own wrapper node — never on `<html>`, never through `ThemeContext` — so the strip's swatch and the expanded card wear the draft's Theme while the surrounding wizard chrome keeps whatever the app-global theme already is. This is not new CSS: every `themes.css` token block is a bare `[data-theme='…']` attribute selector, and `Board.tsx`'s own `.board-area[data-theme]` "retint scope" (specs/theme-on-color-contrast.md Scope C) already proves the same mechanism works on a non-root element — `ThemeIsland` formalizes that pattern for reuse rather than introducing a second one. `ThemeIsland.test.tsx` pins the non-interference contract structurally: mounting an island under a `ThemeProvider` wearing a different Theme never moves `document.documentElement.dataset.theme`.
- **Theme/caption resolution (`src/data/draftPreview.ts`, pure — no React, no Firebase) reads from draft DATA, not from which step is current.** `previewTheme` resolves the most-specified themed Day (the last Day, by `index`, that already carries a `theme`), else the draft's own `defaultTheme`, else the bound Edition's default. `previewCaption` mirrors the frames' own progression — generic, then a squares total, then the themed-Day line — but as a function of what the draft actually holds, so an organizer who fills Squares before finishing Basics sees the upgraded caption immediately rather than only once they reach the "matching" step.
- **The expanded preview deals a deterministic sample card from the draft's own pools using the REAL deal path.** `dealPreviewCard` calls `dealBoard` (`src/game/logic.ts`) with a fixed seed (`PREVIEW_SEED`), the draft's `settings.spicyRatio` / `settings.easyMixRatio` via `DealOptions`, and the selected Day's own pool — mirroring the live deal's own membership rule (`src/data/api.ts` `dealDayCard`): a `main` Day (including a `daily_cards` draft with no Days authored yet, which will eventually deal one) mixes both the main and easy pools in (so the easy-mix slider has something to show), `easy`/`closing` Days deal their own pool alone. A `one_card` draft deals from the main pool **alone** — never mixing easy in, matching `joinAndDeal`'s one-card path and `assignedPools` (`one_card` ⇒ `['main']`) — the one case that is NOT "the same way a main Day would" despite sharing that Day-less branch (CodeRabbit, PR #857). `dealBoard`'s own `MIN_POOL` throw is caught, not re-derived — the shortfall message the preview shows is `dealBoard`'s OWN wording, so it can never drift from the Squares gate it has to stay consistent with.
- **A `daily_cards` draft with more than one Day offers a Day tab per Day** (labelled by weekday, the same date-validity handling `draftValidation.ts`'s `isIsoDate` uses — a malformed date falls back to an ordinal rather than crashing the picker; two Days sharing one calendar date — a real, valid schedule shape, per "A Day is not a calendar date" above — get their weekday label disambiguated with the Day ordinal rather than rendering as two identical, indistinguishable tabs) inside the expanded sheet, defaulting to the same Day `previewTheme` already picked **only until the organizer taps a tab of their own**; an explicit pick outlives an unrelated draft edit, but the auto default keeps following the most-specified themed Day (Codex P2, PR #857) so it never gets stuck on whichever Day happened to be "current" before any Theme was assigned. Switching tabs re-deals from that Day's own pool; it does not touch the draft. The sheet's own title mirrors `draft.name` (falling back to "Live preview" while it is blank) — the acceptance criterion that a name edit is reflected in "the strip and any open preview" applies to the sheet's own chrome, not only the card underneath it.
- **Reuses existing board-rendering pieces rather than forking the grid.** The expanded card's markup — `.board-area`, `.bingo-head`, `.grid`, `.cell`, `.free-label`/`.free-prompt`, and `SquareText` for the auto-fit prompt text — is the same DOM/CSS contract `CachedCardFallback.tsx` already renders a read-only card with; no new grid CSS ships with this ticket, only a small, scoped block for the collapsed strip's own row (`.wizard-prevbar*`) and the sheet's Day tabs (`.wizard-preview-*`), reusing `.pill`/`.seg`/`.seg-btn`/`.sheet*` for everything else. The sheet itself bounds its own height (`max-height`/`overflow-y`) rather than assuming it always fits a viewport, since a full 5×5 board is taller than the wizard's other sheets.
- **Escape ownership is coordinated, not raced.** `WizardChrome` already owns one document-level Escape → Cancel listener for the whole time the wizard is mounted (bubble phase). The expanded sheet's own Escape handler listens on the CAPTURE phase and calls `stopPropagation`, so the SAME keypress closes the preview and never also reaches the chrome's cancel listener — the two Escape consumers agree on ownership by construction rather than by which one happens to run first.

## Deliberately not in this ticket

- **`DayDef.scoring` and `EventDoc.standingsFreezeAt` on the DRAFT.** ADR 0011's fields have now LANDED on the shared contract (#551, `src/domainTypes.d.ts`), and `scripts/seed-data/bodega-bay-2026.mjs` already writes both. `DraftDayDef`/`EventDraft` still carry **neither**, which is now a deferral of the wizard's authoring UI rather than of the vocabulary: an organizer cannot yet state a Scoring Policy or a freeze, so the launch provisioner writes neither and both Events fall back to the read-resolution (`scoringForDay`, `standingsFreezeAtFor`) that derives them from the closing pool. That fallback is exactly today's behaviour, so nothing is broken by the gap—but `finaleClosingPoolIssues` (`src/data/draftValidation.ts`) still requires the final Day to carry the closing pool, which means the wizard cannot express a competitive final morning. Teaching it to author both fields, behind a `DRAFT_SCHEMA_VERSION` bump, is the follow-up.
- **Slug format and reserved names** — #790's shared contract, which #545's Worker must also consume.
- **Turning `OccasionScheduleShape` into `unlockAt` instants** — needs a zone-aware clock; Step 4 (#792). The clock and the date/time transforms have since landed (#1377, § Look (Step 4)); the step body that calls them is still #792's.
- **Monotonic unlock times across the schedule.** A Day whose unlock precedes its predecessor's is a real defect, but it is a schedule-authoring concern with a schedule-authoring fix; #792 owns it.
- **Duplicate Prompt text within a pool.** Twenty-four identically worded Prompts clear `MIN_POOL` and deal a legal but miserable card. Not a `DraftIssue`, so not part of the shared launch gate; it landed in Step 3 (#791) as an advisory instead — see § Squares.
- **Server-side drafts.** Modelled as a seam only, per epic Decision 1.
- **Drafting with Claude** ([#1958](https://github.com/nathanjohnpayne/fiveacross/issues/1958)) is specified in [`specs/event-setup-assist.md`](event-setup-assist.md), `status: proposed`: its assists enter the draft only through `addPrompt` and the Look transforms and are judged by § Validation unchanged, so nothing in this contract moves for them.

## Acceptance criteria

- **Given** a draft with 62 total Prompts but 4 in the closing pool, **when** the per-pool validator runs, **then** it fails the closing pool by name and no total-based check would have. (Tests: "fails the closing pool BY NAME on a 62-total pack with 4 closing Prompts".)
- **Given** a draft whose final Day has no closing pool assigned, **when** validated, **then** the finale-presence predicate fails naming that Day. (Tests: "fails, naming the final Day, when it carries no closing pool"; "is not satisfied by a closing Day somewhere in the middle".)
- **Given** a draft saved at any step, **when** it is loaded again on the same device, **then** it resumes at that step with no field loss and holds no claimed Slug. (Tests: "resumes a saved draft at the same step with no field loss"; "rejects a blob that carries a claimed slug, event id or hostname".)
- **Given** an easy- or closing-pool Prompt entry, **when** constructing the draft type, **then** `spicy: true` is not representable. (Tests: "does not allow spicy on a curated Prompt at the type level" — a `@ts-expect-error` assertion checked by `tsc`; plus the parse and predicate layers.)
- **Given** a Day list of 11 Days, **when** validated, **then** the ten-Day ceiling fails. (Tests: "fails an eleven-Day schedule"; "passes exactly ten Days".)
- **Given** a first unlock that has already elapsed, or the `0` sentinel, **when** validated, **then** each fails under its own code. (Tests: "fails an already-elapsed first unlock…"; "reports the open-immediately sentinel separately from a past instant".)
- **Given** a stored draft at another schema version, **when** loaded, **then** it reads as a miss. (Tests: "reads a version-drifted blob as a miss, never as a mis-shaped draft".)
- **Given** any occasion, **when** the matrix is read, **then** its default and per-Day Themes are all registered by the Edition it binds. (Tests: "offers only Themes the bound Edition registers".)
- **Given** a timezone the read-side contract would rewrite—`UTC`, `Etc/UTC`, `GMT`, a bare offset, `PST`—**when** validated, **then** it fails rather than launching a schedule that runs in another zone. (Tests: "rejects a zone the read-side contract would silently rewrite".)
- **Given** a reversed or impossible date window, **when** validated, **then** it fails; a single-day window passes. (Tests: "rejects a reversed or impossible date window"; "accepts a single-day window".)
- **Given** a draft with an authored schedule, **when** a one-card occasion is re-picked, **then** the Days are cleared; **when** another daily-cards occasion is re-picked, **then** they are kept. (Tests: "drops an authored schedule when the re-picked occasion is one-card"; "keeps an authored schedule when the re-picked occasion still deals daily cards".)
- **Given** a draft in progress at Step 3, **when** the app reloads at that same `/setup/:draftId/:step` URL, **then** it resumes there with the entered data intact. (Tests: "resumes exactly at the step a draft was saved on when that step is still the first incomplete one".)
- **Given** Step N's validity predicate fails, **when** the organizer taps Continue, **then** the wizard does not advance and the step surfaces what is missing. (Tests: "does not advance past an incomplete step, and surfaces what is missing".)
- **Given** completed Steps 1–2, **when** the organizer taps the Step 1 indicator or the header's back-chevron, **then** they return with prior answers intact and can come forward again without re-entering data. (Tests: "tapping a done indicator returns to it with prior answers intact, and can come forward again"; "the header back-chevron steps back exactly one step, labelled with the previous step".)
- **Given** a deep link to a later step with earlier steps incomplete, **when** the wizard mounts, **then** it lands on the first incomplete step rather than rendering an inconsistent state. (Tests: "a link to a later step with earlier steps incomplete lands on the first incomplete step instead".)
- **Given** a non-empty draft, **when** Cancel is tapped, **then** a confirm intervenes before discard; an empty draft cancels without ceremony. (Tests: "discards an empty draft without ceremony"; "confirms before discarding a non-empty draft, and \"Keep editing\" leaves it untouched".)
- **Given** any field mutation, **when** it commits to the draft, **then** the device-local draft is persisted without an explicit save action. (Tests: "advances — and persists the new step without any explicit save — once the step is satisfied".)
- **Given** a seeded pack, **when** Step 3's counts render, **then** each assigned pool shows its own count against 24 with an independent verdict, and an unassigned pool reads as idle rather than passing. (Tests: "shows each assigned pool its own count against the minimum, with independent verdicts"; "reports an unassigned pool as idle rather than passing".)
- **Given** 62 total Prompts with 4 in the closing pool, **when** Step 3 renders, **then** the closing pool fails by name and no total is shown with a verdict. (Tests: "fails the closing pool BY NAME on a 62-total pack with 4 closing Prompts, never a passing total".)
- **Given** the add bar with pool = main, **then** a spicy toggle is offered defaulted off; **given** easy or closing, **then** no spicy control renders and the bar says "off · main only". (Tests: "offers the toggle for a main add, defaulted off, and records it"; "renders NO spicy control for easy or closing, and says why"; "drops a flag tapped before the pool changed, rather than letting it ride along".)
- **Given** a daily-cards schedule whose final Day lacks the closing pool, **when** Step 3 renders, **then** the blocking issue is anchored to that Day's row and the step names what it costs — no standings freeze, no podium, no Most-Loved Photo. (Tests: "names the consequence when the final Day carries no closing pool"; "anchors an out-of-place closing Day to its own row".)
- **Given** two Days on one date, **when** their rows are edited, **then** both survive as two Days. (Tests: "keeps two Days that share one date as two Days"; "supports two Days on one date rather than collapsing them".)
- **Given** the counts-✓ / warm-up control on an easy-pool Day, **when** it is set, **then** only `tutorial` changes; **given** the pool control, **when** it is set, **then** only `pool` changes. (Tests: "records tutorial: false on an easy-pool Day — the pool did not decide it"; "leaves tutorial alone when the pool changes".)
- **Given** ten Days, **when** the organizer tries to add an eleventh, **then** it is refused *with* the schedule-lock reason rather than silently. (Tests: "blocks an eleventh Day and explains the wizard ceiling rather than just refusing"; "refuses an eleventh Day rather than creating a schedule the wizard cannot launch".)
- **Given** an authored schedule, **when** One card is chosen, **then** a confirm intervenes and declining leaves the Days untouched. (Tests: "warns before clearing an authored schedule, and keeps it if the organizer declines"; "clears the schedule once confirmed, because a one-card Event IS an empty days[]".)
- **Given** a sparse pool, **when** Step 3 renders, **then** the gap is visible and removable and the count shows real entries. (Tests: "counts real entries, so a sparse pool cannot show a passing number"; "compacts holes out on any edit, so the draft can persist again".)
- **Given** no pack bound to the occasion, **when** Step 3 renders, **then** it says so rather than offering a seed with nothing behind it. (Tests: "states plainly that no pack is bound rather than offering a seed with nothing behind it"; "is empty until content ownership is decided, and every occasion agrees".)
- **Given** Step 2 onward, **when** the wizard renders, **then** the strip is present and wears the draft's current Day/default Theme while the wizard chrome outside it stays on the app-global theme — two token scopes in one document. (Tests: "mounts the strip on Basics/Squares/Look"; "does NOT mount the strip on Occasion/Launch"; ThemeIsland's "never touches the app-global theme… even when the island wears a DIFFERENT theme".)
- **Given** a draft change (name, theme, mix, prompt add/remove, free-space text), **when** it commits, **then** the strip and any open preview reflect it without a reload. (Tests: "re-renders the caption when the draft prop changes — no stale internal copy".)
- **Given** Open ›, **when** the preview expands, **then** it shows a full 5×5 card dealt by `dealBoard` from the draft's pools with the draft's mix ratio and free-space text, deterministic across re-opens. (Tests: "deals a full sample card on Open, and the same card again on a re-open (fixed seed)"; "mixes the easy pool into a main Day at a 100% easyMixRatio"; "falls back to the seed FREE_TEXT, and honors a Day's own freeText override".)
- **Given** a pool below the dealable minimum, **when** the preview would deal from it, **then** it states the shortfall (the SAME message `dealBoard`'s own `MIN_POOL` guard throws) instead of throwing or faking a card. (Tests: "reports a shortfall — never throws — when the assigned pool is below MIN_POOL"; "shows the shortfall message instead of throwing when the assigned pool is too thin".)
- **Given** the nested Theme island, **then** existing theme contrast tests and the app-global theming behavior are unaffected. (Tests: the full `src/theme/*.test.tsx` suite still passes unmodified; ThemeIsland's own "sets data-theme on its OWN node, not on document.documentElement" and "two sibling islands wear two different themes at once".)

## Test coverage

- `src/data/draftValidation.test.ts` (Vitest, pure—no Firebase): the per-pool minimum (the 62-total/4-closing case, exactly-on and one-short boundaries, unassigned pools ignored, legacy `embark`/`farewell` spellings normalized, the one-card main-only gate); the closing-pool finale (final Day by index not position, a mid-schedule closing Day not satisfying it, inert for one-card); the future first unlock (past, exactly-now, the `0` sentinel, missing, and Day 0 found out of array order); the ten-Day ceiling (11 fails, 10 passes, zero Days, one-card carrying Days); per-Day completeness (place and emoji, `tonight` counted by array length so a trailing blank fails, non-finite unlock times, unset vs unregistered vs off-Edition Theme, contiguous indexes, and both `tutorial`/`pool` combinations accepted); Event completeness (every empty field named, a required and recognized occasion, the unsupported-timezone set, reversed and impossible date windows against an accepted single-day one, and an off-Edition default Theme); the spicy-outside-main predicate and its type-level counterpart; and `validateEventDraft` reporting every independent failure at once.
- `src/data/eventDraft.test.ts` (Vitest, in-memory `Storage` fake—no Firebase): `createEventDraft` (step, version, no slug/eventId/hostname, timezone suggestion, distinct ids, every setting stated); `parseEventDraft` (JSON round-trip, version drift both directions, forbidden claimed-slug keys, spicy on a curated Prompt rejected and on a main Prompt accepted, malformed Day/step/settings, the `0` sentinel stored rather than judged); and the store's create/resume/discard lifecycle (resume at step with no field loss, `updatedAt` stamped while `createdAt` is left alone, list ordering with unreadable and foreign keys skipped, listing proven non-destructive for retired-version, corrupt, garbage-version and concurrently-written blobs alike, idempotent discard, `randomUUID` invoked through its `Crypto` receiver (asserted with a stub that enforces the `this`-binding, because the unit environment's `crypto` shim does *not*—the real Web IDL method throws "Illegal invocation" when extracted and called bare, so without the stub no test could catch it), misses for unknown ids, a hostile `Storage` that throws on `length` and `key()` degrading to no persistence, and the version re-stamp on save).
- `src/data/occasions.test.ts` (Vitest, pure): the six occasions in frame order with their frame copy, the Edition bindings, every `starterPackId` still `null`, Themes scoped to the bound Edition, `dayThemes` empty exactly when `schedule` is `null`, no schedule on a one-card occasion, the ten-Day ceiling and `HH:MM` unlock format, every schedule closing with the closing pool, `tutorial` independent of `pool` in both directions, All-ages and no-daily-email defaults, `occasionById` never falling back to another occasion, and `applyOccasionDefaults` (commits, copies rather than aliases the shared settings, builds no Days, mutates nothing, replaces the Edition cleanly on a re-pick, and clears an authored schedule for a one-card occasion while keeping it for a daily-cards one).
- `src/components/setup/wizardSteps.test.ts` (Vitest, pure — no React, no Firebase): step order/labels/tickets and their indexes; `stepForIssue` classifying every `DraftIssueCode` (including the `event-missing-field` field-level disambiguation between occasion/basics/look); `issuesForStep`/`isStepComplete` (a bare draft incomplete on occasion alone, a fully launchable draft clearing every step including launch, a pool-shortfall blocking only squares, an out-of-range setting blocking only look, and launch completeness tracking the FULL gate rather than an independent empty bucket); `firstIncompleteStep` (landing on occasion for a new draft, advancing as fields are answered, landing on launch once everything clears, and skipping a complete step to the next incomplete one); and `draftHasContent` (empty for a brand-new draft including its seeded timezone/settings, true the moment any real field is answered).
- `src/data/draftSquares.test.ts` (Vitest, pure — no React, no Firebase): the card-format switch (clearing the schedule for one card, proposing the occasion's shape with no unlock instants borrowed, proposing nothing for a `schedule: null` occasion, and returning the same draft on a no-op); the ten-Day ceiling in all three directions; add/remove Day (the appended Day leaving the closing pool where it was and both finale predicates firing, renumbering after a removal, removing by stored position across an earlier hole, and compacting a hole out); `pool`/`tutorial` independence in both directions and two Days sharing one date; Prompt CRUD (spicy carried on a main add and dropped for curated, trimmed storage against the 80-character persisted bound, blank adds and blank renames refused, rename/re-flag/remove by position); sparse pools (real-entry counts, stored-position addressing, compaction on edit, and the per-pool verdict not throwing over a hole); `seedPack` replacing every pool and copying rather than aliasing the shared table; the 62-total/4-closing case end to end; `duplicatePromptTexts` (one report per repeat, case- and space-insensitive, advisory rather than blocking, holes stepped over); and every daily-cards occasion building a contiguous schedule that ends on the closing pool.
- `src/data/starterPacks.test.ts` (Vitest, pure): the registry empty and every occasion's `starterPackId` still `null`; absent/unknown/non-string ids reading as no pack; and `seedPromptsFromPack` (fresh objects at every level, main-pool `spicy` carried verbatim, a curated `spicy` unable to survive seeding even from an untyped pack, and a non-boolean main `spicy` reported rather than coerced).
- `src/components/setup/StepSquares.test.tsx` (Vitest + RTL/jsdom — no Firebase, no store): the live per-pool counter's three states and the 62-total/4-closing trap with no total rendered as a verdict; the sparse-pool gap row and its repair; spicy scoped to main (offered and recorded, absent with "off · main only" for curated, cleared when the pool changes, correctable on an existing row); Prompt CRUD through the UI and the 80-character cap; the duplicate advisory not blocking; the honest no-pack empty state; the Days & pools rows (`tutorial` and `pool` each moving only their own field, two Days on one date, the finale consequence and the out-of-place closing Day each anchored to their own row, adding a Day leaving the closing pool alone, removal renumbering, and the ten-Day refusal carrying its reason); and the card-format confirm in both directions.
- `src/components/setup/SetupWizard.test.tsx` (Vitest + RTL/jsdom, in-memory `Storage` stub — no Firebase; `../../data/hostnames` stubbed to `checkSlugAvailability` resolving `'available'`, since #790's real Basics step now mounts here): the bare `/setup` route creating and immediately persisting a fresh draft on Step 1; Continue refusing to advance an unsatisfied step and surfacing its issues, and advancing (with the new step persisted with no explicit save) once satisfied; the deep-link landing rule redirecting a too-far-ahead request to the first incomplete step, resuming exactly at a saved step that is still first-incomplete, and starting fresh on an unknown draftId; back-navigation via a done indicator pill and via the header's one-step-back chevron, both preserving prior answers; Cancel discarding an empty draft without ceremony, confirming before discarding a non-empty one (with "Keep editing" leaving it untouched), and Escape reaching the same confirm from a non-Step-1 step; and the "Save draft (local)" button's own save-and-flash, hidden on the terminal Launch step.
- `src/components/setup/OccasionStep.test.tsx` (Vitest + RTL/jsdom, no Firebase, no router — the component takes `{ draft, updateDraft }` directly): the six rows rendered in `OCCASIONS`' own frame order with their frame copy; a first pick (`draft.occasion === null`) committing `applyOccasionDefaults` immediately, with no confirm dialog; the selected row's Edition pill naming the bound Edition and no other row carrying one; Custom committing with no starter pack and no schedule/day-Theme proposal beyond the matrix's own platform floor; the re-selection guard opening `OccasionChangeConfirm` (never committing) when a DIFFERENT occasion is picked while one is already selected, "Keep `<current>`" leaving the draft untouched, and "Switch" committing exactly `applyOccasionDefaults` would; the confirm naming the schedule-clearing consequence when the incoming occasion is one-card and the draft holds Days, and omitting it otherwise; and re-picking the SAME already-selected occasion being a TRUE no-op — no confirm dialog and, critically, no `updateDraft` call at all, so a later step's hand-edits to card format/claim mode/default Theme/settings survive it. Also covers the three resumed/imported-draft edge cases: an unset `occasion` whose OTHER fields (`claimMode`, here) are hand-edited away from `createEventDraft`'s baseline still opening the confirm rather than committing over it; a stored occasion id `OCCASIONS` no longer recognizes still opening `OccasionChangeConfirm` with its `from === null` copy ("Apply `<to>`?" / "Keep current answers" / "Apply `<to>`") rather than skipping the dialog just because `current` can't resolve; and a same-occasion click repairing a stale occasion/edition mismatch with a SCOPED `edition`-only patch — proven via the actual `updateDraft` call, since the pill's own rendering never reads `draft.edition` — on a draft whose `claimMode` and `cardFormat` are hand-edited away from what a full recommit would set, specifically to prove they survive the repair. Also covers an unset `occasion` whose ONLY non-baseline field is `edition` still confirming first; the confirm naming every field `applyOccasionDefaults` can overwrite, including card format; and Escape closing the confirm exactly like "Keep current" rather than leaving it open. `src/components/setup/SetupWizard.test.tsx` additionally covers the cross-component proof Escape's fix actually needs: Escape while `OccasionChangeConfirm` is open closes ONLY that dialog and never stacks the unrelated discard-draft confirm on top of it.
- `src/theme/ThemeIsland.test.tsx` (Vitest + RTL/jsdom): `data-theme` lands on the island's own node, never on `document.documentElement`; the app-global theme (`ThemeProvider`) is untouched when a nested island wears a DIFFERENT theme; two sibling islands carrying two different themes at once; the two themes used resolve to genuinely different token sets in `themes.css` (parsed, not executed — the same convention `w1-themes.test.tsx`/`theme-on-color-contrast.test.tsx` use); and children/arbitrary DOM attributes pass through unchanged.
- `src/data/draftPreview.test.ts` (Vitest, pure — no React, no Firebase): `squaresTotal` (sums real entries across pools, skips holes, zero for a bare draft); `previewDays` (holes dropped, sorted by index not array position); `previewDayForTheme`/`previewTheme` (null with no themed Day, the LAST themed Day by index, and the Day → `defaultTheme` → Edition-default fallback chain); `previewDayLabel` (a real ISO date's weekday, and the `2026-02-30`-rolls-over-not-`NaN` case falling back to an ordinal, mirroring `draftValidation.ts`'s `isIsoDate` round-trip); `previewCaption`'s three tiers (generic, squares total, themed-Day line); and `dealPreviewCard` (a full 25-cell deal with the free space at center, deterministic across calls, the shortfall message on a too-thin pool instead of a thrown error, a closing Day dealing its own pool unstratified, a main Day mixing in the easy pool at a 100% `easyMixRatio`, the `day: null` one-card path, and the `FREE_TEXT` fallback vs. a Day's own `freeText` override).
- `src/components/setup/WizardChrome.test.tsx` (Vitest + RTL/jsdom): the strip mounts on Basics/Squares/Look and NOT on Occasion/Launch, and sits between the step body and the Continue row; and the Escape-ownership handoff — an open preview's Escape closes ONLY the preview and never calls the chrome's `onRequestCancel`, while Escape with no preview open still reaches the chrome's own cancel listener as before.
- `src/components/setup/PreviewStrip.test.tsx` (Vitest + RTL/jsdom, `userEvent`): the collapsed strip's generic/squares-total captions and Theme swatch, with `document.documentElement` untouched; re-rendering from a new `draft` prop with no stale internal copy; Open › dealing a full sample card and re-opening to the identical card; the shortfall state instead of a thrown error; Escape closing the sheet and restoring focus to the Open trigger; a multi-Day draft's Day tabs (defaulting to the last themed Day, switching Days re-deals from that Day's own pool); a `one_card` draft rendering no Day-meta line; same-weekday Day tabs getting distinct, unambiguous labels; the default Day re-anchoring to a newly-themed LATER Day while the sheet stays open and no tab has been explicitly picked; and the sheet title mirroring `draft.name` (with the generic fallback while it is blank).
- `src/components/setup/StepBasics.test.tsx` (#790; Vitest + RTL/jsdom, `../../data/hostnames` mocked whole — no Firebase; a stateful `Harness` component stands in for the shell's own `updateDraft`, since `StepBasics`'s mount effect can call it synchronously before a hand-rolled `rerender` reference would be assigned): `slugify` (folding, length-cap without a trailing hyphen); name/date/timezone fields writing straight through `updateDraft`, the unsupported-timezone hint appearing and clearing, and "Use this device's zone"; address auto-generation from the name and "editable once" (a manual edit survives a later name change); live format + availability checking — a reserved label and a malformed candidate both refused WITHOUT a network read, a well-formed candidate failing closed while its check is in flight and committing only once confirmed `'available'`, `'taken'` and `'check-failed'` both failing closed identically, a superseded (stale) response never overwriting a newer commit, and the optimistic-trust pair (an already-committed candidate reads `'available'` immediately on mount, then a background re-check downgrades and clears it the moment it learns the address is gone); both hostnames previewing canonical-first, with the alternate line absent for an Edition with none; and marking mode / audience segmented controls toggling `claimMode` / `settings.forceAdult` with `aria-pressed` reflecting the selection.
- `src/data/hostnames.test.ts` — `checkSlugAvailability` additions: `'available'` for a missing document, `'taken'` for one in ANY status (active/disabled/archived) and for a malformed one alike, `'check-failed'` rather than a thrown rejection on a network failure, and the hostname lowercased before the read.
- `src/editions.test.ts` — `alternateNamespaceApex` additions: `null` for `fiveacross`, for `gcb` and for an unrecognized Edition id, `vacaybingo.com` for `vacay`; that inherited `Object.prototype` keys (`constructor`, `toString`, `__proto__`) also read as `null` rather than a nonsense apex; and that the table names only apexes the router actually serves.
- `src/slug.test.ts` — carried over verbatim from the shared #545 contract (`src/slug.ts`); not re-authored by this ticket.
- `src/components/EasyMixSlider.test.tsx` (#1376; Vitest + RTL/jsdom, no Firebase, no Admin shell — the shared component mounts with a plain `value`/`onChange` pair): the bubble and `aria-valuetext` reading "50% · 12 of 24 squares" at 50%; an off-grid stored ratio snapping to the 5% grid and an out-of-range one clamping to 0..100; `onChange` firing once on release with the snapped ratio (not while dragging, and deduped on a repeat release); no call on an untouched release, key-up or blur, nor a rewrite of a stored off-grid ratio; an assistive-tech change committing on blur; the five detent labels and datalist options; and an external value change re-syncing the thumb and bubble without a write.
- `src/data/tzDate.test.ts` (#1377; Vitest, pure): `isWallClockTime` (`00:00`–`23:59` only); `instantFromZonedTime` converting in the named zone rather than the host (including half- and three-quarter-hour zones), midnight and 23:59 round-tripping onto their own date, the spring-forward gap (an ordinary time on the transition date exact, a skipped time moved forward by the gap, every quarter hour of the date staying on it, in `America/Los_Angeles`, `Europe/Rome` and `Australia/Sydney`), the fall-back overlap (a repeated time taking the earlier instant, every quarter hour round-tripping exactly), a leap day, and `null` for a malformed date, time or zone; `isoTimeInTz` (24-hour with midnight as `00`, seconds truncated, `''` rather than the host zone for an unknown zone or unformattable instant).
- `src/data/draftLook.test.ts` (#1377; Vitest, pure—no React, no Firebase): `defaultUnlockTime` (occasion, then `06:00`); `setDayDate`/`setDayUnlockTime` storing an `unlockAt` that formats back to the same date and time in the draft zone, keeping a Day's time across a re-date, both DST transition dates, and refusing by identity a malformed date or time, an undated Day, an unsupported zone and a missing Day; Theme transforms refusing anything outside `themesForEdition(draft.edition)`; the `dayThemes` tail-repeat proposal filling only unthemed Days; place and emoji stored as typed; `tonight` always exactly two entries; a cleared Free Space removing the key rather than storing `''`; `setEasyMixRatio` refusing values outside 0–1; a Look edit repairing holes in `days` and prompts through `normalizeDraft`; and a schedule filled entirely through these transforms leaving `dayCompletenessIssues` and `firstUnlockIssues` (future `now`) with nothing to report.
- `src/data/draftLastCall.test.ts` (#1378; Vitest, pure — no React, no Firebase): the forward and backward branches, `null` for no finale / no Days, `null` while any Day's unlock is unset, sparse-array holes skipped, no mutation or re-sorting of the input, an empty window (`1e25` and `Number.MAX_VALUE` freezes) reported as `fires: false` with `lastCallAt` uncorrected, and a source scan that the module imports nothing from `functions/` (the build cannot prove it: the module is tree-shaken out of `dist` until Look consumes it).
- `src/draft-last-call-parity.test.ts` (#1378; Vitest, pure — imports `finaleTimes` and `LAST_CALL_LEAD_MS` from `functions/src/unlockDay.ts` in the TEST only): the mirror's `lastCallAt`/`standingsFreezeAt` equal `finaleTimes`' over a fixture table (the original ten-Day shape, a Bodega-style same-date closing tail, a closing Day exactly 12h and 12h + 1ms after its predecessor, a one-Day schedule, the legacy `farewell` spelling, out-of-order storage, no closing Day, configured freezes between/after/before the Days and over a ceremonial Day, unusable 0/negative freezes ignored, a 0-sentinel ceremonial Day) and a ±3ms boundary sweep around the 12h lead; `null` exactly where `finaleTimes` is; the lead constant pinned; and the empty-window class (a `Number.MAX_VALUE` freeze, where `freeze − 12h` rounds back to the freeze) reporting `fires: false`.
