What changed, and why
PR #329 published the Terms and Privacy pages, but a footer link is browsewrap — the weak form of assent. Nothing asked the player to agree, nothing recorded that they had, and a brand-new player's only "this is fiction" disclosure was the masthead one-liner. Issue #330 closes that: a clickwrap checkbox gates the OAuth sign-in buttons, the acceptance is recorded server-side with a version and timestamp, and a fresh device meets a one-time fiction/AI notice on /play.
Three moving parts, in order below: the checkbox and how its tick survives the OAuth redirect; the return leg that records it; and the notice dialog with the tour-deferral wiring. A migration (0012) adds the evidence table. The PR also finishes a truth sweep: eight files still described sign-in as a "magic link" that "upgrades the user in place" — the actual mechanism discards the anonymous id, and that fact is exactly why the recording works the way it does.
The clickwrap: a real checkbox, gating both buttons
The checkbox renders inside SignInForm, so all three sign-in surfaces (settings, the buy modal's gate, the runs popover) inherit it. The copy is the owner's locked line — 18+, Terms, Privacy, links opening in a new tab so the form isn't lost mid-sign-in. Both provider buttons carry :disabled="busy || !accepted" plus an aria-describedby pointing at the label, and start() re-checks the gate for any click that bypasses the attribute.
The checkbox block, and start(): park the tick, then begin the redirect; a failed start un-parks it.
components/SignInForm.vue · 272 lines
⋯ 3 lines hidden (lines 1–3)
⋯ 103 lines hidden (lines 43–145)
⋯ 100 lines hidden (lines 173–272)
Parking the tick: sessionStorage, deliberately
Acceptance can't be recorded before the redirect: signInWithOAuth lands a new player on a fresh account and a returning player on their existing one — the pre-redirect anonymous id is discarded either way. So the tick waits in the legal store and is flushed only once a signed-in session exists.
The flag lives in sessionStorage, not localStorage, and that choice is the store's load-bearing comment: sessionStorage survives the same-tab OAuth round-trip but dies with the tab. A tick abandoned at the provider can never be flushed days later under whoever signs in next on a shared browser — the adversarial review flagged that fabrication path against the localStorage draft, and this kills it. The cost is small because every future sign-in re-ticks the box anyway. The notice acknowledgment, by contrast, means "once per device, forever" and stays in localStorage.
The two-scope rationale, and the actions: park, un-park, flush-with-retry.
stores/legal.ts · 142 lines
⋯ 81 lines hidden (lines 23–103)
The return leg: flush and record
/play's mount already finalizes the OAuth return. After the balance settles — on every branch — the flush runs when the session is non-anonymous. The gate reads !accountAnonymous rather than email truthiness, so an email-less OAuth identity still records; and because the server refuses anonymous subjects anyway, a stale client gate can't write junk.
The tour-deferral watches, and the mount-path flush. The notice owns the first beat; its dismissal re-offers the tour auto-start it deferred.
pages/play.vue · 1221 lines
⋯ 514 lines hidden (lines 1–514)
⋯ 68 lines hidden (lines 530–597)
⋯ 609 lines hidden (lines 613–1221)
The record: route, store, migration
The route takes zero client inputs — subject from the session, version from TERMS_VERSION, timestamp from the database. Its identity stances are the review's second confirmed defect, fixed: the draft used currentSubject, whose catch-branch silently downgrades to the device id on a transient auth failure — the record would 200 under the wrong key and the client would clear its retry flag. Now a real auth failure 500s (the money-path stance), an anonymous session is refused with 409, and device-mode recording requires a pre-existing cookie so a cookieless curl loop can't mint junk rows. Every non-2xx leaves the client's flag parked for a later retry.
The handler: fail loud, refuse junk identities, stamp everything server-side.
server/api/terms-acceptance.post.ts · 63 lines
⋯ 25 lines hidden (lines 1–25)
The port, and the Supabase adapter's on-conflict-do-nothing upsert — the SQL form of first-write-wins.
server/utils/terms-acceptance-store.ts · 126 lines
⋯ 28 lines hidden (lines 1–28)
⋯ 28 lines hidden (lines 41–68)
⋯ 42 lines hidden (lines 85–126)
Idempotent DDL, RLS with no policy (service-key-only), PK (subject, version). Hand-apply to prod BEFORE this ships.
supabase/migrations/0012_terms_acceptances.sql · 19 lines
The fiction notice: one beat, any exit acknowledges
The dialog mirrors RunPacksModal's bespoke idiom — role="dialog", aria-modal, a Tab trap, Escape handled, the same scoped transition with its reduced-motion backstop. Focus moves to the Got-it button on open (the dialog is auto-shown; there is no opener to restore). Every exit — button, Escape, backdrop — funnels through acknowledge(): a disclosure is discharged by being shown, so no path out is a wall.
The template: kicker, title, the two disclosure paragraphs, quiet links to all three legal pages, one button.
components/FictionNotice.vue · 148 lines
⋯ 80 lines hidden (lines 69–148)
The version can't drift from the documents
The server stamps TERMS_VERSION, an ISO day. The legal pages render TERMS_LAST_UPDATED_TEXT. Both live side by side in site-meta.ts, and a test renders the ISO day through an explicit-UTC formatter and asserts it equals the display text — bump one without the other and CI fails.
The pair, with the bump-both instruction.
utils/site-meta.ts · 97 lines
⋯ 32 lines hidden (lines 1–32)
⋯ 56 lines hidden (lines 42–97)
The drift pin.
tests/unit/server/api/terms-acceptance.spec.ts · 46 lines
⋯ 30 lines hidden (lines 1–30)
Coverage, and how this was verified
Twenty-two new unit tests: the store's first-write-wins contract, the legal store's two storage scopes and flush-retry semantics, and a component spec proving the buttons stay dead until the tick and the flag parks and un-parks correctly (it runs in the nuxt vitest project, with mockNuxtImport replacing the real Supabase client). Three new Playwright tests drive the honest first visit — landing → Begin → notice → dismiss → briefing, dismissal persisting across visits — and the clickwrap gate on /settings. The existing e2e fleet is kept honest too: suppressTour now also seeds the notice acknowledgment, and the two specs that deliberately drive onboarding seed only what they must.
The three functional tests — expand to read whole.
tests/e2e/features/legal-onboarding.spec.ts · 97 lines
Gates: eslint clean on every changed file, format:check clean, typecheck clean, vitest 1372/1372 across 138 files, Playwright 38/38 against the production build. Booted live: the notice verified in light and dark at 1280 and 375, acknowledgment persisting across reload; the buttons observed disabled→enabled on tick. An independent adversarial review ran refute-first over the diff; both of its confirmed defects (the NUL byte that made the store file diff as binary, and the device-id downgrade) are fixed above, and its risks — sessionStorage parking, junk-row refusal, missing functional e2e — shaped the final form.