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
components/SignInForm.vue272 lines · Vue
⋯ 3 lines hidden (lines 1–3)
1<template>
2 <div data-testid="signin-form">
3 <p v-if="hint" class="ew-faint text-xs leading-relaxed mb-2">{{ hint }}</p>
4 <!-- The clickwrap (#330): a real checkbox, required before either provider
5 button arms. The tick is parked in the legal store and recorded
6 server-side once the OAuth return lands signed-in — the record must key
7 on the FINAL account id, which doesn't exist until then. Copy is the
8 owner's locked clickwrap line; the links open in a new tab so the form
9 (and the tick) aren't lost mid-sign-in. -->
10 <label
11 id="signin-accept-label"
12 class="signin-accept flex items-start gap-2 ew-faint text-xs leading-relaxed mb-2.5"
13 >
14 <input
15 v-model="accepted"
16 type="checkbox"
17 data-testid="signin-accept"
18 class="mt-0.5 shrink-0"
19 />
20 <span>
21 I'm 18 or older and I agree to the
22 <NuxtLink
23 to="/terms"
24 target="_blank"
25 rel="noopener"
26 class="underline ew-hover-fg"
27 >Terms of Service</NuxtLink
28 >
29 and
30 <NuxtLink
31 to="/privacy"
32 target="_blank"
33 rel="noopener"
34 class="underline ew-hover-fg"
35 >Privacy Policy</NuxtLink
36 >.
37 </span>
38 </label>
39 <div class="flex flex-col gap-2">
40 <!--
41 Google — the official "Sign in with Google" button (per
42 developers.google.com/identity/branding-guidelines): the 4-color G mark,
⋯ 103 lines hidden (lines 43–145)
43 a sans label, light theme on the cream ground and the dark theme under
44 everwhen's dark mode. Wording stays "Continue with…", an approved phrase.
45 -->
46 <button
47 type="button"
48 data-testid="signin-google"
49 class="signin-btn signin-btn--google"
50 :disabled="busy || !accepted"
51 aria-describedby="signin-accept-label"
52 @click="start('google')"
53 >
54 <svg
55 class="signin-btn__icon"
56 width="18"
57 height="18"
58 viewBox="0 0 48 48"
59 aria-hidden="true"
60 >
61 <path
62 fill="#EA4335"
63 d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"
64 />
65 <path
66 fill="#4285F4"
67 d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"
68 />
69 <path
70 fill="#FBBC05"
71 d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"
72 />
73 <path
74 fill="#34A853"
75 d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"
76 />
77 </svg>
78 <span class="signin-btn__label">Continue with Google</span>
79 </button>
80 
81 <!--
82 Discord — the official brand button (per discord.com/branding): Blurple
83 #5865F2 with the white Clyde mark and white label. One look in both
84 everwhen themes; brand color carries it on either ground.
85 -->
86 <button
87 type="button"
88 data-testid="signin-discord"
89 class="signin-btn signin-btn--discord"
90 :disabled="busy || !accepted"
91 aria-describedby="signin-accept-label"
92 @click="start('discord')"
93 >
94 <svg
95 class="signin-btn__icon"
96 width="21"
97 height="16"
98 viewBox="0 0 127.14 96.36"
99 fill="currentColor"
100 aria-hidden="true"
101 >
102 <path
103 d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"
104 />
105 </svg>
106 <span class="signin-btn__label">Continue with Discord</span>
107 </button>
108 </div>
109 <p v-if="error" data-testid="signin-error" class="text-xs ew-bad mt-1.5">
110 {{ error }}
111 </p>
112 </div>
113</template>
114 
115<script setup lang="ts">
116/**
117 * OAuth sign-in (Google / Discord), for new and returning players alike. The
118 * visitor is already an anonymous Supabase user; tapping a provider runs
119 * signInWithOAuth, which bounces to the provider and returns to /play, where
120 * utils/auth-return.ts finalizes the PKCE code. A NEW player lands on a fresh
121 * account (with its own free-run grant); a RETURNING player signs into theirs.
122 * The provider initiation is delegated to startOAuthSignIn (see utils/sign-in.ts).
123 *
124 * The two buttons follow each provider's official brand guidelines (Google's
125 * 4-color mark + light/dark themes; Discord's Blurple field + white mark), so
126 * the sign-in reads as the real thing. See the scoped styles below.
127 *
128 * The clickwrap (#330): both buttons stay disabled until the 18+/Terms/Privacy
129 * checkbox is ticked. The tick is parked in the legal store (it must survive
130 * the provider redirect) and un-parked if the redirect fails to start; the
131 * /play return leg flushes it to POST /api/terms-acceptance under the final
132 * account id.
133 */
134import { ref } from "vue";
135import { startOAuthSignIn, type OAuthProvider } from "~/utils/sign-in";
136import { useLegalStore } from "~/stores/legal";
137 
138defineProps<{ hint?: string }>();
139 
140const supabase = useSupabaseClient();
141const legal = useLegalStore();
142const busy = ref(false);
143const accepted = ref(false);
144const error = ref<string | null>(null);
145 
146async function start(provider: OAuthProvider) {
147 if (busy.value || !accepted.value) return;
148 busy.value = true;
149 error.value = null;
150 // Park the tick BEFORE the call: on success the browser navigates away, and
151 // the flag has to already be on disk to survive into the return leg.
152 legal.markPendingAccept();
153 try {
154 const redirectTo =
155 typeof window !== "undefined"
156 ? `${window.location.origin}/play`
157 : undefined;
158 const result = await startOAuthSignIn(provider, redirectTo, {
159 signInWithOAuth: (params) => supabase.auth.signInWithOAuth(params),
160 });
161 // On 'redirecting' the browser navigates away; only surface an error and re-enable.
162 if (result.status === "error") {
163 legal.clearPendingAccept();
164 error.value = result.message;
165 busy.value = false;
166 }
167 } catch {
168 legal.clearPendingAccept();
169 error.value = "Could not start sign-in. Please try again.";
170 busy.value = false;
171 }
⋯ 100 lines hidden (lines 173–272)
173</script>
174 
175<style scoped>
176/*
177 * Official provider sign-in buttons. The brand colors here are EXTERNAL
178 * constants (Google + Discord brand guidelines), deliberately not --ew- tokens:
179 * the parchment palette doesn't own them, and a sign-in button must read as the
180 * genuine article on any ground. The label uses the platform sans — Google
181 * specifies Roboto and Discord gg sans, both proprietary, so we approximate
182 * rather than ship a webfont's bytes (the same no-bundled-asset stance the rest
183 * of the app takes).
184 */
185.signin-btn {
186 display: flex;
187 align-items: center;
188 justify-content: center;
189 gap: 10px;
190 width: 100%;
191 height: 40px;
192 padding: 0 12px;
193 border-radius: 4px;
194 border: 1px solid transparent;
195 font-family: "Roboto", "Helvetica Neue", Arial, system-ui, sans-serif;
196 font-size: 14px;
197 font-weight: 500;
198 letter-spacing: 0.25px;
199 cursor: pointer;
200 transition:
201 background-color var(--ew-dur-1) var(--ew-ease),
202 border-color var(--ew-dur-1) var(--ew-ease),
203 box-shadow var(--ew-dur-1) var(--ew-ease),
204 transform var(--ew-dur-1) var(--ew-ease);
206.signin-btn__icon {
207 flex: 0 0 auto;
209/* The clickwrap checkbox rides the accent so it reads as the one action here. */
210.signin-accept input {
211 accent-color: var(--ew-accent);
213.signin-btn:active:not(:disabled) {
214 transform: translateY(0.5px) scale(0.985);
216.signin-btn:disabled {
217 opacity: 0.5;
218 cursor: not-allowed;
220.signin-btn:focus-visible {
221 outline: 2px solid var(--ew-accent);
222 outline-offset: 2px;
224 
225/* Google — light theme. */
226.signin-btn--google {
227 background: #ffffff;
228 border-color: #747775;
229 color: #1f1f1f;
231.signin-btn--google:hover:not(:disabled) {
232 /* Google's "state layer": a faint blue wash plus a soft elevation shadow. */
233 background: #f7fafe;
234 box-shadow:
235 0 1px 2px rgba(60, 64, 67, 0.3),
236 0 1px 3px 1px rgba(60, 64, 67, 0.15);
238/* Google — dark theme, used under everwhen's dark mode (.dark on <html>). */
239.dark .signin-btn--google {
240 background: #131314;
241 border-color: #8e918f;
242 color: #e3e3e3;
244.dark .signin-btn--google:hover:not(:disabled) {
245 background: #1e1f20;
246 box-shadow: 0 1px 3px 1px rgba(0, 0, 0, 0.45);
248 
249/* Discord — Blurple brand button; the mark + label are white on either ground. */
250.signin-btn--discord {
251 background: #5865f2;
252 border-color: #5865f2;
253 color: #ffffff;
255.signin-btn--discord:hover:not(:disabled) {
256 background: #4752c4;
257 border-color: #4752c4;
259.signin-btn--discord:active:not(:disabled) {
260 background: #3c45a5;
261 border-color: #3c45a5;
263 
264@media (prefers-reduced-motion: reduce) {
265 .signin-btn {
266 transition: none;
267 }
268 .signin-btn:active:not(:disabled) {
269 transform: none;
270 }
272</style>

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
stores/legal.ts142 lines · TypeScript
⋯ 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
pages/play.vue1221 lines · Vue
⋯ 514 lines hidden (lines 1–514)
1<template>
2 <!--
3 Everwhen as one illuminated manuscript whose pages (leaves) turn as a turn
4 progresses. The conductor (provided once here) owns the phase; each leaf is a
5 focused page; the store's two-phase actions land the SFX-bearing writes at
6 leaf arrival, so the soundscape stays in lockstep for free.
7 
8 This is THE game at /play; the classic board UI was retired here.
9 -->
10 <div class="desk">
11 <!-- Post-checkout banner (ported from the board): the missing feedback after
12 returning from Stripe — a credited confirmation, or a gentle no-charge on
13 cancel. Fixed over the desk so it never shifts the codex; self-dismisses. -->
14 <div
15 v-if="economyStore.purchaseNotice"
16 data-testid="purchase-notice"
17 role="status"
18 aria-live="polite"
19 class="purchase-banner"
20 :class="economyStore.purchaseNotice"
21 >
22 <span aria-hidden="true">{{
23 economyStore.purchaseNotice === "success" ? "✓" : "○"
24 }}</span>
25 <span class="pn-text">{{
26 economyStore.purchaseNotice === "success"
27 ? "Payment received — your runs have been added to your balance."
28 : "Checkout canceled — you weren't charged."
29 }}</span>
30 <button
31 type="button"
32 data-testid="purchase-notice-close"
33 class="pn-close"
34 aria-label="Dismiss"
35 @click="economyStore.clearPurchaseNotice()"
36 >
37
38 </button>
39 </div>
40 <!-- Blocked-dispatch banner: a content-moderation block refunds the turn and keeps
41 the player's words, but the old notice sat at the FOOT of the dispatch leaf —
42 easy to miss once the page turned back to it. Surface it here instead, fixed
43 over the desk like the purchase strip, so the block is unmissable from any leaf
44 and never shifts the codex. The conductor still lands on the dispatch leaf,
45 where the kept words wait to be edited and resent. -->
46 <div
47 v-if="gameStore.moderationNotice"
48 data-testid="moderation-alert"
49 role="alert"
50 aria-live="assertive"
51 class="blocked-banner"
52 >
53 <span class="bb-tag" aria-hidden="true">⚠ blocked</span>
54 <span class="bb-text"
55 >{{ gameStore.moderationNotice }}
56 <span class="bb-hint"
57 >Your words are kept — edit and send again.</span
58 ></span
59 >
60 <button
61 type="button"
62 data-testid="moderation-close"
63 class="bb-close"
64 aria-label="Dismiss"
65 @click="gameStore.clearModerationNotice()"
66 >
67
68 </button>
69 </div>
70 <div class="codex grain">
71 <!-- Masthead — the constant running head (hidden during the End takeover,
72 which carries its own verdict heading). -->
73 <header v-if="conductor.phase.value !== 'end'" class="masthead">
74 <div class="mast-row">
75 <GameTitle />
76 <div v-if="gameStore.currentObjective" class="mast-objective">
77 <ObjectiveDisplay />
78 </div>
79 <div v-else class="mast-spacer" />
80 <div class="mast-controls">
81 <!-- The runs gauge + account popover + the entry to the run-packs modal. -->
82 <RunsBalance class="shrink-0" />
83 <button
84 type="button"
85 class="icon-btn"
86 :class="{ on: recordOpen }"
87 data-testid="codex-record-toggle"
88 title="The record"
89 aria-label="Open the record"
90 @click="toggleRecord"
91 >
92
93 </button>
94 <button
95 type="button"
96 class="icon-btn"
97 data-testid="mute-toggle"
98 :aria-pressed="!audio.muted"
99 :title="audio.muted ? 'Sound off' : 'Sound on'"
100 :aria-label="
101 audio.muted ? 'Unmute sound effects' : 'Mute sound effects'
102 "
103 @click="toggleMute"
104 >
105 {{ audio.muted ? "♪̸" : "♪" }}
106 </button>
107 <button
108 type="button"
109 class="icon-btn"
110 :aria-label="
111 isDark ? 'Switch to light mode' : 'Switch to dark mode'
112 "
113 @click="toggleDark"
114 >
115
116 </button>
117 <template v-if="gameStore.currentObjective">
118 <span v-if="resetArmed" class="reset-confirm">
119 <span class="ew-warn">abandon this history?</span>
120 <button
121 type="button"
122 class="icon-btn ew-warn"
123 data-testid="reset-confirm"
124 aria-label="Yes — abandon this history"
125 @click="performReset"
126 >
127
128 </button>
129 <button
130 type="button"
131 class="icon-btn"
132 data-testid="reset-cancel"
133 aria-label="Keep playing"
134 @click="disarmReset"
135 >
136
137 </button>
138 </span>
139 <button
140 v-else
141 type="button"
142 class="icon-btn"
143 data-testid="reset-button"
144 title="New timeline"
145 aria-label="New timeline"
146 @click="handleReset"
147 >
148
149 </button>
150 </template>
151 </div>
152 </div>
153 <div v-if="gameStore.currentObjective" class="mast-strip">
154 <div class="prog-thread">
155 <span class="pct ew-mono" data-testid="masthead-progress"
156 >{{ Math.round(gameStore.objectiveProgress) }}%</span
157 >
158 <div class="ew-track" :class="trackClass">
159 <div
160 class="ew-fill"
161 :style="{ width: gameStore.objectiveProgress + '%' }"
162 />
163 </div>
164 <span class="prog-need ew-faint">{{ needLine }}</span>
165 </div>
166 <div class="pips" data-testid="messages-counter" aria-hidden="true">
167 <span
168 v-for="i in TOTAL_MESSAGES"
169 :key="i"
170 class="pip"
171 :class="{ spent: i <= spent }"
172 />
173 </div>
174 </div>
175 <!-- Persistent AI/fiction/adults-only disclosure (ported from the board's
176 footer). Shown during a run on every viewport (the codex foot is hidden
177 on phones, so this rides in the running head instead). -->
178 <p
179 v-if="gameStore.currentObjective"
180 data-testid="footer-disclosure"
181 class="mast-disclosure"
182 >
183 Figures are AI, not real people · replies are AI-generated fiction,
184 not historical fact · for adults (18+)
185 </p>
186 <div
187 v-if="conductor.isSending.value"
188 class="sending-bar"
189 role="status"
190 aria-label="Sending the dispatch"
191 />
192 </header>
193 
194 <!-- The leaf stage — pages turn with a quick settle (within-turn reveal
195 beats) or a 3D page-turn (a new page begins: mission/chronicle →
196 dispatch, end → mission). -->
197 <div id="stage" class="stage" @click="onStageClick">
198 <Transition :name="transitionName">
199 <KeepAlive :include="['LeafDispatch']">
200 <component
201 :is="currentLeaf"
202 v-if="conductor.phase.value !== 'end'"
203 :key="conductor.phase.value"
204 />
205 </KeepAlive>
206 </Transition>
207 <!-- A temporal sheen sweeps each turned page (re-keyed per phase to replay). -->
208 <div
209 :key="'sheen-' + conductor.phase.value"
210 class="stage-sheen"
211 aria-hidden="true"
212 />
213 <EndGameScreen
214 v-if="conductor.phase.value === 'end'"
215 @play-again="performReset"
216 />
217 <RecordOverlay :open="recordOpen" @close="recordOpen = false" />
218 <!-- a11y: a polite live region narrates each reveal beat as the player turns
219 to it, so a screen-reader follows without watching the animation. -->
220 <div class="sr-only" role="status" aria-live="polite">
221 {{ announce }}
222 </div>
223 </div>
224 
225 <!-- Page progression — the primary interaction (explicit-continue model). The
226 reveal plays, then waits here; the player turns the page when ready, with
227 Back to revisit. Shown only on reveal pages (the loader + compose pages
228 drive themselves), hidden under the record overlay. Tap-anywhere + →/Enter
229 stay as accelerators. -->
230 <div v-if="isRevealLeaf && !recordOpen" class="leaf-progress">
231 <button
232 v-if="conductor.canBack.value"
233 type="button"
234 class="prog-btn back"
235 data-testid="codex-back"
236 @click="conductor.back()"
237 >
238 ‹ Back
239 </button>
240 <span v-else aria-hidden="true" />
241 <button
242 type="button"
243 class="prog-btn continue"
244 data-testid="codex-continue"
245 @click="conductor.advance()"
246 >
247 {{ continueLabel }}
248 </button>
249 </div>
250 </div>
251 
252 <SiteFooter variant="desk" full />
253 
254 <!-- Fixed-position overlays — mounted at the desk level (OUTSIDE the
255 overflow-hidden codex, which would clip them): the run-packs sales modal,
256 the one-time fiction notice, the guided first-run coach-mark, and the
257 dev panel. -->
258 <RunPacksModal />
259 <ClientOnly><FictionNotice /></ClientOnly>
260 <ClientOnly><CoachMark /></ClientOnly>
261 <ClientOnly><DevPanel /></ClientOnly>
262 </div>
263</template>
264 
265<script setup lang="ts">
266import { ref, computed, onMounted, onUnmounted, watch } from "vue";
267import { useGameStore } from "~/stores/game";
268import { useChronicleStore } from "~/stores/chronicle";
269import { useEconomyStore } from "~/stores/economy";
270import { useAudioStore } from "~/stores/audio";
271import { useCoachingStore } from "~/stores/coaching";
272import { useLegalStore } from "~/stores/legal";
273import { useGameSoundscape } from "~/composables/useGameSoundscape";
274import {
275 useCodexConductor,
276 CODEX_CONDUCTOR,
277} from "~/composables/useCodexConductor";
278import { CODEX_SOUNDSCAPE } from "~/components/codex/keys";
279import { TOTAL_MESSAGES, MAX_PROGRESS } from "~/utils/game-config";
280import {
281 readAuthReturnParams,
282 hasAuthReturn,
283 finalizeAuthReturn,
284} from "~/utils/auth-return";
285import LeafMission from "~/components/codex/LeafMission.vue";
286import LeafDispatch from "~/components/codex/LeafDispatch.vue";
287import LeafSending from "~/components/codex/LeafSending.vue";
288import LeafJudgment from "~/components/codex/LeafJudgment.vue";
289import LeafDice from "~/components/codex/LeafDice.vue";
290import LeafReply from "~/components/codex/LeafReply.vue";
291import LeafRipple from "~/components/codex/LeafRipple.vue";
292import LeafChronicle from "~/components/codex/LeafChronicle.vue";
293import RecordOverlay from "~/components/codex/RecordOverlay.vue";
294 
295const gameStore = useGameStore();
296const chronicleStore = useChronicleStore();
297const economyStore = useEconomyStore();
298const audio = useAudioStore();
299const soundscape = useGameSoundscape(); // mounted ONCE here — the store-bridge cues
300const conductor = useCodexConductor();
301 
302// Share the conductor + soundscape down to the leaves.
303provide(CODEX_CONDUCTOR, conductor);
304provide(CODEX_SOUNDSCAPE, soundscape);
305 
306const LEAVES = {
307 mission: LeafMission,
308 dispatch: LeafDispatch,
309 sending: LeafSending,
310 judgment: LeafJudgment,
311 dice: LeafDice,
312 reply: LeafReply,
313 ripple: LeafRipple,
314 chronicle: LeafChronicle,
315} as const;
316const currentLeaf = computed(
317 () => LEAVES[conductor.phase.value as keyof typeof LEAVES] ?? null,
318);
319 
320// Leaf transition: a quick settle for the within-turn reveal beats, a 3D page-turn
321// when a NEW page begins (mission/chronicle → dispatch, end → mission). flush:'pre'
322// sets the name before <Transition> reads it for the pending phase change.
323const transitionName = ref<"codex-leaf" | "codex-turn">("codex-leaf");
324watch(
325 () => conductor.phase.value,
326 (to, from) => {
327 const boundary =
328 (to === "dispatch" && (from === "mission" || from === "chronicle")) ||
329 to === "mission";
330 transitionName.value = boundary ? "codex-turn" : "codex-leaf";
331 },
332 { flush: "pre" },
333);
334 
335const spent = computed(() => TOTAL_MESSAGES - gameStore.remainingMessages);
336const trackClass = computed(() => {
337 const p = gameStore.objectiveProgress;
338 return p >= MAX_PROGRESS ? "win" : p >= 75 ? "brink" : "";
339});
340const needLine = computed(() => {
341 const p = Math.round(gameStore.objectiveProgress);
342 return p >= 100
343 ? "history rewritten"
344 : p === 0
345 ? "a history unwritten"
346 : `need +${100 - p}% to win`;
347});
348 
349// ── The record overlay ────────────────────────────────────────────────────
350const recordOpen = ref(false);
351function toggleRecord() {
352 recordOpen.value = !recordOpen.value;
354 
355// A tap anywhere on a reveal leaf turns the page (skips the dwell). Not while the
356// record is open, not on the compose/mission/end pages, and never when the tap
357// landed on a control (so buttons/inputs still work).
358function onStageClick(e: MouseEvent) {
359 if (recordOpen.value) return;
360 const p = conductor.phase.value;
361 if (p === "dispatch" || p === "mission" || p === "sending" || p === "end")
362 return;
363 const t = e.target as HTMLElement;
364 if (t.closest('button, a, input, textarea, select, summary, [role="button"]'))
365 return;
366 // Selecting prose to copy ends in a click on the stage when the drag is released —
367 // don't read that release as a page-turn. A non-collapsed selection means the player
368 // is highlighting (the reply, the chronicle, the ripple), not asking to advance.
369 const sel = typeof window !== "undefined" ? window.getSelection() : null;
370 if (sel && !sel.isCollapsed && sel.toString().trim()) return;
371 conductor.advance();
373 
374// ── Reveal a11y: keyboard parity + screen-reader announcements ─────────────
375const REVEAL_LEAVES = ["judgment", "dice", "reply", "ripple", "chronicle"];
376const isRevealLeaf = computed(() =>
377 REVEAL_LEAVES.includes(conductor.phase.value),
378);
379// The reveal pages own the bottom of the screen with the Continue/Back footer. Flag
380// the body while one shows so the shared CoachMark keeps its card clear of that
381// footer (else its bottom-anchored fallback covers the primary Continue control).
382watch(
383 isRevealLeaf,
384 (v) => {
385 if (typeof document !== "undefined")
386 document.body.classList.toggle("codex-reveal", v);
387 },
388 { immediate: true },
389);
390// The Continue affordance hands the pen back on the last reveal page of a turn.
391const continueLabel = computed(() =>
392 conductor.phase.value === "chronicle" ? "Next dispatch" : "Continue",
393);
394// This turn's resolved AI message (held in pendingTurn during the animated
395// reveal, else the latest committed one) — the source for the announcements.
396const turnMsg = computed(() => gameStore.revealMessage);
397// A polite live region narrates each reveal beat so a screen-reader user follows
398// the auto-advancing flow without watching the animation.
399const announce = computed(() => {
400 const p = conductor.phase.value;
401 const m = turnMsg.value;
402 const node = gameStore.timelineEvents[gameStore.timelineEvents.length - 1];
403 if (p === "judgment" && m?.craft)
404 return `The Judge grades your craft: ${m.craft}${m.rollModifier ? `, ${m.rollModifier > 0 ? "+" : ""}${m.rollModifier} to the roll of fate` : ""}.`;
405 if (p === "dice" && m?.diceRoll != null)
406 return `The dice of fate land on ${m.diceRoll}: ${m.diceOutcome}.`;
407 if (p === "reply" && m) return `${m.figureName || "History"} answers.`;
408 if (p === "ripple" && node)
409 return `History bends. ${node.headline} ${node.progressChange >= 0 ? "Up" : "Down"} ${Math.abs(node.progressChange)} percent — now ${Math.round(gameStore.objectiveProgress)} percent toward the objective.`;
410 if (p === "chronicle")
411 return chronicleStore.chronicle
412 ? `The history, retold: ${chronicleStore.chronicle.title}.`
413 : "The history is being retold.";
414 if (p === "end")
415 return gameStore.gameStatus === "victory"
416 ? "History rewritten — victory."
417 : "The run is over.";
418 return "";
419});
420 
421// Keyboard parity for tap-to-advance: → / Enter / Space turn a reveal page;
422// Escape closes the record. Never hijack a key while a control or the composer
423// holds focus (so typing + buttons keep working).
424function onKeydown(e: KeyboardEvent) {
425 if (recordOpen.value) {
426 if (e.key === "Escape") {
427 recordOpen.value = false;
428 e.preventDefault();
429 }
430 return;
431 }
432 if (!isRevealLeaf.value) return;
433 const ae = document.activeElement as HTMLElement | null;
434 if (
435 ae &&
436 ae.closest(
437 'input, textarea, select, button, a, summary, [role="button"], [contenteditable="true"]',
438 )
439 )
440 return;
441 if (
442 e.key === "ArrowRight" ||
443 e.key === "Enter" ||
444 e.key === " " ||
445 e.key === "Spacebar"
446 ) {
447 e.preventDefault();
448 conductor.advance();
449 }
451onMounted(() => window.addEventListener("keydown", onKeydown));
452onUnmounted(() => {
453 window.removeEventListener("keydown", onKeydown);
454 if (typeof document !== "undefined")
455 document.body.classList.remove("codex-reveal");
456});
457 
458// ── Controls (ported from play.vue) ───────────────────────────────────────
459const colorMode = useColorMode();
460const isDark = computed(() => colorMode.value === "dark");
461function toggleDark() {
462 colorMode.preference = colorMode.value === "dark" ? "light" : "dark";
464 
465function toggleMute() {
466 audio.toggleMute();
467 if (!audio.muted) {
468 soundscape.unlock();
469 soundscape.play("uiTick");
470 }
472 
473// New-timeline reset: two-step confirm once a run is underway, one tap when there's
474// nothing to lose. resetGame bumps the epoch → the conductor snaps back to mission.
475const resetArmed = ref(false);
476let resetTimer: ReturnType<typeof setTimeout> | null = null;
477function handleReset() {
478 // One tap only when there's nothing to lose: no resolved turns AND no contact
479 // chosen (a typed first dispatch is work too — a chosen figure marks it dirty).
480 const composerDirty = !!gameStore.activeFigureName?.trim();
481 if (gameStore.timelineEvents.length === 0 && !composerDirty) {
482 performReset();
483 return;
484 }
485 resetArmed.value = true;
486 if (resetTimer) clearTimeout(resetTimer);
487 resetTimer = setTimeout(() => {
488 resetArmed.value = false;
489 }, 3500);
491function disarmReset() {
492 resetArmed.value = false;
493 if (resetTimer) clearTimeout(resetTimer);
495function performReset() {
496 disarmReset();
497 recordOpen.value = false;
498 gameStore.resetGame();
500 
501// ── Guided first run (issue #60) — adapted to the leaf flow ────────────────
502// The shell is the single orchestration site; the coaching store stays
503// game-agnostic. Beats anchor to live controls on the current leaf. The
504// roll-swing beat keys off the Dice leaf's ARRIVAL — `timelineEvents` now lands
505// later (at the Ripple leaf's commitRipple), so the old length-watch would skip
506// the dice anchor.
507const coaching = useCoachingStore();
508const legal = useLegalStore();
509const autoStartCtx = () => ({
510 hasObjective: !!gameStore.currentObjective,
511 isPlaying: gameStore.gameStatus === "playing",
512 isFirstTurn: gameStore.timelineEvents.length === 0,
513});
514const tourBaseTurns = ref(0);
515watch(
516 () => gameStore.currentObjective,
517 (obj) => {
518 if (obj && !legal.shouldShowNotice) coaching.maybeAutoStart(autoStartCtx());
519 else if (!obj) coaching.stop();
520 },
521);
522// The one-time fiction notice owns the first beat (#330): while it's up the tour
523// holds, and its dismissal re-offers the auto-start it deferred.
524watch(
525 () => legal.shouldShowNotice,
526 (show, was) => {
527 if (was && !show) coaching.maybeAutoStart(autoStartCtx());
528 },
529);
⋯ 68 lines hidden (lines 530–597)
530watch(
531 () => coaching.isRunning,
532 (running) => {
533 if (running) tourBaseTurns.value = gameStore.timelineEvents.length;
534 },
535);
536// Picking who & when clears the first beat (a real grounded contact + a chosen year).
537watch(
538 () => !!gameStore.figureGrounding?.resolved && gameStore.contactWhen != null,
539 (ready) => {
540 if (ready) coaching.advanceFrom("who-when");
541 },
542);
543// The roll & swing beat fires when the Dice leaf is reached.
544watch(
545 () => conductor.phase.value,
546 (p) => {
547 if (coaching.isRunning && (p === "dice" || p === "ripple"))
548 coaching.advanceTo("roll-swing");
549 },
550);
551// A second resolved turn means the loop has plainly landed — an overstaying tour bows out.
552watch(
553 () => gameStore.timelineEvents.length,
554 (n) => {
555 if (coaching.isRunning && n >= tourBaseTurns.value + 2) coaching.complete();
556 },
557);
558 
559// ── Run lifecycle (ported from play.vue) ──────────────────────────────────
560const route = useRoute();
561const router = useRouter();
562const supabase = useSupabaseClient();
563onMounted(async () => {
564 audio.hydrate();
565 coaching.hydrate();
566 legal.hydrate();
567 const authReturn = readAuthReturnParams(route.query);
568 if (hasAuthReturn(authReturn)) {
569 const result = await finalizeAuthReturn(authReturn, {
570 settle: async () => {
571 const { data } = await supabase.auth.getSession();
572 return Boolean(data.session && !data.session.user.is_anonymous);
573 },
574 });
575 if (result.status === "error") {
576 console.warn(
577 "[auth] sign-in link could not be finalized:",
578 authReturn.error,
579 authReturn.errorDescription,
580 );
581 } else if (result.status === "signed-in") {
582 await economyStore.claimReferral();
583 }
584 await economyStore.loadBalance();
585 router.replace({ query: {} });
586 } else {
587 const purchase = route.query.purchase;
588 if (purchase === "success" || purchase === "cancel") {
589 await economyStore.notePurchaseReturn(purchase);
590 router.replace({ query: {} });
591 if (purchase === "success") {
592 const before = economyStore.runsRemaining;
593 for (let i = 0; i < 4; i++) {
594 await new Promise((r) => setTimeout(r, 1500));
595 await economyStore.loadBalance();
596 if (economyStore.runsRemaining !== before) break;
597 }
598 }
599 } else {
600 await economyStore.loadBalance();
601 }
602 }
603 // Flush a clickwrap tick parked across the OAuth redirect (#330) — only once a
604 // non-anonymous session exists, so the record keys on the final account id
605 // (email-less OAuth identities still flush). A failed or refused record keeps
606 // the tick parked for a retry later in this tab; the server refuses anonymous
607 // subjects, so a stale gate here can't record junk.
608 if (!economyStore.accountAnonymous) void legal.flushPendingAcceptance();
609 // Resume an in-progress run onto the first dispatch (the conductor lands there).
610 if (!gameStore.currentObjective) await gameStore.resumeDraft();
611 if (!legal.shouldShowNotice) coaching.maybeAutoStart(autoStartCtx());
612});
⋯ 609 lines hidden (lines 613–1221)
613 
614useHead({
615 title: "Everwhen — Rewrite History",
616 meta: [
617 {
618 name: "description",
619 content:
620 "Send 160-character messages to anyone in history. A D20 and an AI decide how the timeline bends.",
621 },
622 ],
623});
624</script>
625 
626<style scoped>
627.desk {
628 position: fixed;
629 inset: 0;
630 display: flex;
631 flex-direction: column;
632 align-items: center;
633 justify-content: center;
634 gap: 14px;
635 padding: 20px;
636 background: var(
637 --codex-desk,
638 radial-gradient(
639 120% 110% at 50% 36%,
640 color-mix(in srgb, var(--ew-bg) 74%, #000) 0%,
641 color-mix(in srgb, var(--ew-bg) 50%, #000) 60%,
642 #000 130%
643 )
644 );
646.codex {
647 position: relative;
648 width: min(95vw, 1180px);
649 height: min(92vh, 800px);
650 background: var(--codex-card, var(--ew-bg));
651 border: 1px solid var(--codex-card-border, var(--ew-line));
652 border-radius: 4px;
653 box-shadow: var(
654 --codex-card-shadow,
655 0 20px 54px -18px rgba(40, 28, 14, 0.6),
656 0 44px 96px -40px rgba(20, 12, 4, 0.66)
657 );
658 display: flex;
659 flex-direction: column;
660 overflow: hidden;
662.codex::after {
663 content: "";
664 position: absolute;
665 top: 0;
666 bottom: 0;
667 left: 46px;
668 width: 1px;
669 background: linear-gradient(
670 var(--ew-line),
671 transparent 8%,
672 transparent 92%,
673 var(--ew-line)
674 );
675 opacity: 0.5;
676 pointer-events: none;
677 z-index: 2;
679.grain::before {
680 content: "";
681 position: absolute;
682 inset: 0;
683 pointer-events: none;
684 z-index: 0;
685 opacity: var(--codex-grain-op, 0.035);
686 mix-blend-mode: var(--codex-grain-blend, multiply);
687 background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/></filter><rect width='160' height='160' filter='url(%23n)'/></svg>");
689/* Theme the grain via INHERITED custom properties, never a property on a
690 :global() selector. `:global(html.dark) .grain::before { opacity }` leaks its
691 opacity onto <html> itself under Vue scoped CSS, fading the entire page to ~5%
692 (the dark-mode "blank/invisible" bug). Custom props are inherited + inert on
693 <html>; the scoped ::before reads them. */
694/* Dark mode: lift the (warm) card off a darker, cooler desk so the foreground
695 surface reads clearly against the background. Scoped to the codex frame via
696 inherited custom props — never a property on a :global() selector (see grain). */
697:global(html.dark) {
698 --codex-grain-op: 0.05;
699 --codex-grain-blend: screen;
700 --codex-desk: radial-gradient(
701 120% 110% at 50% 36%,
702 color-mix(in srgb, var(--ew-bg) 60%, #000) 0%,
703 color-mix(in srgb, var(--ew-bg) 26%, #000) 55%,
704 #000 120%
705 );
706 --codex-card: color-mix(in srgb, var(--ew-bg) 88%, #f0e6d4);
707 --codex-card-border: color-mix(in srgb, var(--ew-line) 50%, #f0e6d4);
708 --codex-card-shadow:
709 inset 0 1px 0 color-mix(in srgb, #f0e6d4 8%, transparent),
710 0 24px 66px -16px rgba(0, 0, 0, 0.78),
711 0 56px 120px -44px rgba(0, 0, 0, 0.86);
713 
714.masthead {
715 position: relative;
716 z-index: 3;
717 flex: none;
718 padding: 11px 20px 0;
720.mast-row {
721 display: flex;
722 align-items: center;
723 gap: 14px;
725.mast-objective {
726 flex: 1 1 auto;
727 min-width: 0;
729.mast-spacer {
730 flex: 1 1 auto;
732.mast-controls {
733 display: flex;
734 align-items: center;
735 gap: 4px;
737.reset-confirm {
738 display: inline-flex;
739 align-items: center;
740 gap: 4px;
741 font-size: 11px;
743.icon-btn {
744 appearance: none;
745 border: 0;
746 background: transparent;
747 color: var(--ew-faint);
748 width: 32px;
749 height: 32px;
750 border-radius: 4px;
751 cursor: pointer;
752 line-height: 1;
753 font-size: 15px;
754 display: inline-flex;
755 align-items: center;
756 justify-content: center;
757 transition:
758 color var(--ew-dur-1) var(--ew-ease),
759 background var(--ew-dur-1) var(--ew-ease);
761.icon-btn:hover {
762 color: var(--ew-fg);
763 background: var(--ew-tint);
765.icon-btn.on {
766 color: var(--ew-accent);
768 
769/* Reflow the running head below desktop. One line can't hold the wordmark, the
770 objective strip, AND the run gauge + controls: at phone widths the controls
771 overprinted the objective title (near-illegible at 375px), and at tablet widths
772 the title truncated mid-word while "· rewrite history" wrapped. Drop the
773 objective to its own full-width row so it always reads in full; the wordmark
774 keeps the controls company on the row above. The squeeze (with the era shown)
775 reaches ~1024px, so reflow up to there — desktop keeps the single row. */
776@media (max-width: 1024px) {
777 .mast-row {
778 flex-wrap: wrap;
779 row-gap: 8px;
780 }
781 .mast-controls {
782 order: 1;
783 margin-left: auto;
784 }
785 .mast-objective {
786 order: 2;
787 flex-basis: 100%;
788 }
790 
791/* Post-checkout banner — a letterpress strip fixed at the top of the desk. */
792.purchase-banner {
793 position: fixed;
794 top: 12px;
795 left: 50%;
796 transform: translateX(-50%);
797 z-index: 60;
798 display: flex;
799 align-items: center;
800 gap: 9px;
801 max-width: min(92vw, 560px);
802 padding: 9px 12px;
803 font-size: 13px;
804 color: var(--ew-fg);
805 background: var(--ew-bg);
806 border: 1px solid var(--ew-line);
807 border-radius: 4px;
808 box-shadow: 0 10px 28px -10px rgba(20, 12, 4, 0.5);
810.purchase-banner.success {
811 background: var(--ew-tint);
812 border-color: color-mix(in srgb, var(--ew-ok) 40%, var(--ew-line));
814.purchase-banner .pn-text {
815 min-width: 0;
817.purchase-banner .pn-close {
818 appearance: none;
819 border: 0;
820 background: transparent;
821 color: var(--ew-faint);
822 cursor: pointer;
823 line-height: 1;
824 margin-left: auto;
825 flex: none;
827.purchase-banner .pn-close:hover {
828 color: var(--ew-fg);
830 
831/* Blocked-dispatch banner — the moderation block, surfaced like the purchase strip:
832 fixed over the desk so it's unmissable and never shifts the codex. Oxblood-keyed
833 with a left rule, distinct from the neutral purchase notice. */
834.blocked-banner {
835 position: fixed;
836 top: 12px;
837 left: 50%;
838 transform: translateX(-50%);
839 z-index: 61;
840 display: flex;
841 align-items: flex-start;
842 gap: 9px;
843 max-width: min(92vw, 560px);
844 padding: 9px 12px;
845 font-size: 13px;
846 line-height: 1.4;
847 color: var(--ew-fg);
848 background: var(--ew-bg);
849 border: 1px solid color-mix(in srgb, var(--ew-bad) 55%, var(--ew-line));
850 border-left: 3px solid var(--ew-bad);
851 border-radius: 4px;
852 box-shadow: 0 10px 28px -10px rgba(20, 12, 4, 0.5);
854.blocked-banner .bb-tag {
855 flex: none;
856 font-weight: 700;
857 text-transform: uppercase;
858 letter-spacing: 0.04em;
859 color: var(--ew-bad);
861.blocked-banner .bb-text {
862 min-width: 0;
864.blocked-banner .bb-hint {
865 color: var(--ew-mut);
867.blocked-banner .bb-close {
868 appearance: none;
869 border: 0;
870 background: transparent;
871 color: var(--ew-faint);
872 cursor: pointer;
873 line-height: 1;
874 margin-left: auto;
875 flex: none;
877.blocked-banner .bb-close:hover {
878 color: var(--ew-fg);
880 
881/* Persistent disclosure — a slim, muted line in the running head, on every viewport. */
882.mast-disclosure {
883 margin-top: 7px;
884 font-size: 10px;
885 line-height: 1.3;
886 color: var(--ew-faint);
888 
889.mast-strip {
890 display: flex;
891 align-items: center;
892 gap: 16px;
893 margin-top: 9px;
894 padding-bottom: 9px;
895 border-bottom: 1px solid var(--ew-line);
897.prog-thread {
898 flex: 1 1 auto;
899 display: flex;
900 align-items: center;
901 gap: 10px;
902 min-width: 0;
904.pct {
905 font-size: 12px;
906 font-weight: 700;
907 color: var(--ew-fg);
908 min-width: 34px;
910.ew-track {
911 position: relative;
912 flex: 1 1 auto;
913 height: 5px;
914 background: var(--ew-line);
915 border-radius: 999px;
916 overflow: hidden;
918.ew-track .ew-fill {
919 position: absolute;
920 left: 0;
921 top: 0;
922 bottom: 0;
923 background: var(--ew-fg);
924 border-radius: 999px;
925 transition:
926 width var(--ew-dur-3) var(--ew-ease),
927 background var(--ew-dur-2) var(--ew-ease);
929.ew-track.brink .ew-fill {
930 background: var(--ew-accent);
931 box-shadow: 0 0 10px color-mix(in srgb, var(--ew-accent) 60%, transparent);
933.ew-track.win .ew-fill {
934 background: var(--ew-ok);
935 box-shadow: 0 0 10px color-mix(in srgb, var(--ew-ok) 60%, transparent);
937.prog-need {
938 font-size: 10px;
939 white-space: nowrap;
941.pips {
942 display: flex;
943 align-items: center;
944 gap: 6px;
946.pip {
947 width: 9px;
948 height: 9px;
949 border-radius: 50%;
950 border: 1.4px solid var(--ew-hair);
951 background: transparent;
952 transition:
953 background var(--ew-dur-2) var(--ew-ease),
954 border-color var(--ew-dur-2) var(--ew-ease);
956.pip.spent {
957 background: var(--ew-accent);
958 border-color: var(--ew-accent);
960.sending-bar {
961 position: relative;
962 height: 2px;
963 overflow: hidden;
964 background: var(--ew-line);
966.sending-bar::after {
967 content: "";
968 position: absolute;
969 inset: 0;
970 background: var(--ew-accent);
971 transform-origin: left;
972 animation: send-sweep 1.15s var(--ew-ease) infinite;
974@keyframes send-sweep {
975 0% {
976 transform: translateX(-100%) scaleX(0.35);
977 }
978 55% {
979 transform: translateX(40%) scaleX(0.6);
980 }
981 100% {
982 transform: translateX(160%) scaleX(0.35);
983 }
985 
986.stage {
987 position: relative;
988 z-index: 1;
989 flex: 1 1 auto;
990 min-height: 0;
991 perspective: 2200px;
992 perspective-origin: 0% 50%;
994/* The temporal sheen that sweeps each turned page (re-keyed per phase to replay). */
995.stage-sheen {
996 position: absolute;
997 inset: 0;
998 pointer-events: none;
999 z-index: 6;
1000 opacity: 0;
1001 background: linear-gradient(
1002 105deg,
1003 transparent 34%,
1004 color-mix(in srgb, var(--ew-accent) 13%, transparent) 50%,
1005 transparent 66%
1006 );
1007 background-size: 280% 100%;
1008 background-position: 180% 0;
1009 animation: codex-sheen var(--ew-dur-3) var(--ew-ease);
1011@keyframes codex-sheen {
1012 0% {
1013 background-position: 180% 0;
1014 opacity: 0.7;
1016 100% {
1017 background-position: -120% 0;
1018 opacity: 0;
1021.sr-only {
1022 position: absolute;
1023 width: 1px;
1024 height: 1px;
1025 padding: 0;
1026 margin: -1px;
1027 overflow: hidden;
1028 clip: rect(0, 0, 0, 0);
1029 white-space: nowrap;
1030 border: 0;
1032/* Page-progression footer — Back (revisit) + Continue (turn the page). flex:none
1033 below the stage, so it never overlaps the leaf body. */
1034.leaf-progress {
1035 position: relative;
1036 z-index: 3;
1037 flex: none;
1038 display: flex;
1039 align-items: center;
1040 justify-content: space-between;
1041 gap: 12px;
1042 padding: 10px 20px 12px;
1043 border-top: 1px solid var(--ew-line);
1045.prog-btn {
1046 appearance: none;
1047 cursor: pointer;
1048 font: inherit;
1049 line-height: 1;
1050 min-height: 38px;
1051 padding: 9px 16px;
1052 border-radius: 3px;
1053 transition:
1054 color var(--ew-dur-1) var(--ew-ease),
1055 background var(--ew-dur-1) var(--ew-ease),
1056 border-color var(--ew-dur-1) var(--ew-ease);
1058.prog-btn.back {
1059 border: 1px solid var(--ew-line);
1060 background: transparent;
1061 color: var(--ew-mut);
1063.prog-btn.back:hover {
1064 color: var(--ew-fg);
1065 border-color: var(--ew-hair);
1067/* Continue text = the page bg colour, so it reads on the accent in light AND dark
1068 (avoids a :global(html.dark) override — see the grain note above). */
1069.prog-btn.continue {
1070 border: 1px solid var(--ew-accent);
1071 background: var(--ew-accent);
1072 color: var(--ew-bg);
1073 font-weight: 600;
1074 letter-spacing: 0.01em;
1076.prog-btn.continue:hover {
1077 background: color-mix(in srgb, var(--ew-accent) 86%, var(--ew-fg));
1079@media (max-width: 760px) {
1080 .leaf-progress {
1081 padding: 9px 16px 11px;
1083 .prog-btn {
1084 min-height: 44px;
1085 padding: 11px 18px;
1088/* Phone: go full-bleed — a centered card wastes a small screen. The codex fills
1089 the viewport (masthead · stage · progression footer), so the info-dense leaves
1090 get real room instead of a cramped column. */
1091@media (max-width: 640px) {
1092 .desk {
1093 padding: 0;
1094 gap: 0;
1096 .codex {
1097 width: 100vw;
1098 height: 100vh;
1099 height: 100dvh;
1100 max-width: none;
1101 border: 0;
1102 border-radius: 0;
1103 box-shadow: none;
1104 background: var(--ew-bg);
1106 .codex::after {
1107 left: 18px;
1108 opacity: 0.3;
1110 .masthead {
1111 padding: 9px 13px 0;
1113 .mast-row {
1114 gap: 9px;
1116 .mast-controls {
1117 gap: 2px;
1119 .mast-strip {
1120 gap: 12px;
1121 margin-top: 8px;
1122 padding-bottom: 8px;
1124 .leaf-progress {
1125 padding: 10px 14px calc(11px + env(safe-area-inset-bottom));
1127 .prog-btn.continue {
1128 flex: 1 1 auto;
1129 text-align: center;
1132@media (prefers-reduced-motion: reduce) {
1133 .ew-track .ew-fill {
1134 transition: none;
1136 .sending-bar::after {
1137 animation: none;
1138 opacity: 0.6;
1140 .stage-sheen {
1141 animation: none;
1142 opacity: 0;
1145</style>
1147<!-- Leaf transition classes target the leaf components' ROOT elements, which do
1148 NOT carry this page's scope id — so they must be global. Prefixed (codex-leaf /
1149 codex-turn) to avoid collisions. -->
1150<style>
1151/* settle — within-turn reveal beats (III→VII): a quick fade + lift cross-fade.
1152 Overlapping (never mode=out-in, which would add a wait); both leaves go absolute
1153 during the swap so they cross-fade in place. */
1154.codex-leaf-enter-active,
1155.codex-leaf-leave-active {
1156 position: absolute;
1157 inset: 0;
1159.codex-leaf-enter-active {
1160 transition:
1161 opacity var(--ew-dur-2) var(--ew-ease),
1162 transform var(--ew-dur-2) var(--ew-ease);
1164.codex-leaf-leave-active {
1165 transition:
1166 opacity var(--ew-dur-1) var(--ew-ease),
1167 transform var(--ew-dur-1) var(--ew-ease);
1169.codex-leaf-enter-from {
1170 opacity: 0;
1171 transform: translateY(10px) scale(0.994);
1173.codex-leaf-leave-to {
1174 opacity: 0;
1175 transform: translateY(-8px) scale(0.99);
1178/* page-turn — a NEW page begins: the outgoing leaf turns away around the spine
1179 while the fresh page settles in. */
1180.codex-turn-enter-active,
1181.codex-turn-leave-active {
1182 position: absolute;
1183 inset: 0;
1184 transform-origin: left center;
1185 backface-visibility: hidden;
1187.codex-turn-leave-active {
1188 transition:
1189 transform 560ms var(--ew-ease),
1190 opacity 560ms var(--ew-ease);
1191 box-shadow: 40px 0 60px -20px rgba(20, 12, 4, 0.45);
1193.codex-turn-leave-to {
1194 transform: rotateY(-110deg);
1195 opacity: 0.2;
1197.codex-turn-enter-active {
1198 transition:
1199 opacity 380ms var(--ew-ease) 150ms,
1200 transform 380ms var(--ew-ease) 150ms;
1202.codex-turn-enter-from {
1203 opacity: 0;
1204 transform: translateY(8px);
1207@media (prefers-reduced-motion: reduce) {
1208 .codex-leaf-enter-active,
1209 .codex-leaf-leave-active,
1210 .codex-turn-enter-active,
1211 .codex-turn-leave-active {
1212 transition: opacity 120ms linear !important;
1214 .codex-leaf-enter-from,
1215 .codex-leaf-leave-to,
1216 .codex-turn-enter-from,
1217 .codex-turn-leave-to {
1218 transform: none !important;
1221</style>

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
server/api/terms-acceptance.post.ts63 lines · TypeScript
⋯ 25 lines hidden (lines 1–25)
1/**
2 * POST /api/terms-acceptance — record the sign-in clickwrap acceptance (#330).
3 *
4 * The client shows the "18+ and I agree" checkbox at every OAuth sign-in and
5 * fires this once the return leg lands signed-in, so the record keys on the
6 * FINAL account subject (never the discarded pre-redirect anonymous id). The
7 * request carries NO inputs: the server stamps the subject from the session,
8 * the version from TERMS_VERSION, and the timestamp itself — a client can
9 * trigger the record, never shape it.
10 *
11 * Identity stances — this is legal evidence, so bind it to something durable:
12 * - a REAL auth failure 500s rather than silently downgrading a signed-in
13 * player to their device id (the auth.ts money-path stance);
14 * - an ANONYMOUS session is refused (409): its id is discarded at sign-in
15 * (utils/sign-in.ts), so a record under it is junk;
16 * - keyless/device mode records only under a PRE-EXISTING device cookie —
17 * a cookieless drive-by POST can't mint junk rows (409).
18 * Every non-2xx leaves the client's retry flag parked (stores/legal.ts), so
19 * the record lands on a later visit; sign-in itself is never blocked.
20 */
21import { termsAcceptanceStore } from "~/server/utils/terms-acceptance-store";
22import { currentUser, type Account } from "~/server/utils/auth";
23import { existingDeviceId } from "~/server/utils/device";
24import { TERMS_VERSION } from "~/utils/site-meta";
25 
26export default defineEventHandler(async (event) => {
27 if (getMethod(event) !== "POST") {
28 throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
29 }
30 let user: Account | null;
31 try {
32 user = await currentUser(event);
33 } catch (error) {
34 console.error("terms-acceptance auth lookup failed:", error);
35 throw createError({
36 statusCode: 500,
37 statusMessage: "Internal Server Error",
38 });
39 }
40 if (user?.isAnonymous) {
41 throw createError({
42 statusCode: 409,
43 statusMessage: "Conflict: no signed-in account to record against",
44 });
45 }
46 const subject = user?.id ?? existingDeviceId(event);
47 if (!subject) {
48 throw createError({
49 statusCode: 409,
50 statusMessage: "Conflict: no identity to record against",
51 });
52 }
53 try {
54 await termsAcceptanceStore().recordAcceptance(subject, TERMS_VERSION);
55 } catch (error) {
56 console.error("Failed to record terms acceptance:", error);
57 throw createError({
58 statusCode: 500,
59 statusMessage: "Internal Server Error",
60 });
61 }
62 return { recorded: true, version: TERMS_VERSION };
63});

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
server/utils/terms-acceptance-store.ts126 lines · TypeScript
⋯ 28 lines hidden (lines 1–28)
1/**
2 * The terms-acceptance store — the sign-in clickwrap's server-side record (#330).
3 * POST /api/terms-acceptance stamps a row here on the OAuth return leg, keyed by
4 * the FINAL account subject (a sign-in lands on a fresh or returning account —
5 * never the discarded anonymous id; see utils/sign-in.ts), with the version the
6 * server itself reads from TERMS_VERSION. The first acceptance of a version is
7 * the legally meaningful one, so recording is first-write-wins per
8 * (subject, version): a re-accept is a no-op that keeps the original timestamp.
9 *
10 * Two adapters behind one port, chosen by config — the repo's env-degradation
11 * idiom (cf. runDraftStore, runSnapshotStore):
12 * - in-memory (default): no external dependency, so `npm run dev` and the unit
13 * tests need nothing. Records don't outlive the process, but the round-trip
14 * is real.
15 * - Supabase: used when SUPABASE_URL + SUPABASE_SECRET_KEY are configured.
16 * Inserts into `terms_acceptances` via the service (secret) key, which
17 * bypasses RLS — the table has no public policy, so it is server-only.
18 */
19import { createClient, type SupabaseClient } from "@supabase/supabase-js";
20import { supabaseConfig } from "./supabase";
21 
22export interface TermsAcceptance {
23 subject: string;
24 version: string;
25 /** ISO timestamp of the FIRST acceptance of this version by this subject. */
26 acceptedAt: string;
28 
29export interface TermsAcceptanceStore {
30 /** Record that `subject` accepted `version` now. First write wins: recording
31 * the same (subject, version) again is a no-op that keeps the original
32 * timestamp — the legally meaningful one. */
33 recordAcceptance(subject: string, version: string): Promise<void>;
34 /** The subject's acceptance of `version`, or null when they never accepted it.
35 * Exists for tests and future surfaces; no gameplay path reads it. */
36 getAcceptance(
37 subject: string,
38 version: string,
39 ): Promise<TermsAcceptance | null>;
⋯ 28 lines hidden (lines 41–68)
41 
42/** Dev/test default — keeps the first-write-wins round-trip real without any
43 * external dependency. */
44export class InMemoryTermsAcceptanceStore implements TermsAcceptanceStore {
45 private readonly records = new Map<string, TermsAcceptance>();
46 
47 private key(subject: string, version: string): string {
48 return `${subject}|${version}`;
49 }
50 
51 async recordAcceptance(subject: string, version: string): Promise<void> {
52 const key = this.key(subject, version);
53 if (this.records.has(key)) return;
54 this.records.set(key, {
55 subject,
56 version,
57 acceptedAt: new Date().toISOString(),
58 });
59 }
60 
61 async getAcceptance(
62 subject: string,
63 version: string,
64 ): Promise<TermsAcceptance | null> {
65 return this.records.get(this.key(subject, version)) ?? null;
66 }
68 
69/** Supabase adapter — mirrors the in-memory semantics guard-for-guard: the
70 * on-conflict-do-nothing upsert is the SQL form of first-write-wins. */
71export class SupabaseTermsAcceptanceStore implements TermsAcceptanceStore {
72 constructor(private readonly client: SupabaseClient) {}
73 
74 async recordAcceptance(subject: string, version: string): Promise<void> {
75 const { error } = await this.client
76 .from("terms_acceptances")
77 .upsert(
78 { subject, version },
79 { onConflict: "subject,version", ignoreDuplicates: true },
80 );
81 if (error)
82 throw new Error(`terms_acceptances record failed: ${error.message}`);
83 }
84 
⋯ 42 lines hidden (lines 85–126)
85 async getAcceptance(
86 subject: string,
87 version: string,
88 ): Promise<TermsAcceptance | null> {
89 const { data, error } = await this.client
90 .from("terms_acceptances")
91 .select("subject, version, accepted_at")
92 .eq("subject", subject)
93 .eq("version", version)
94 .maybeSingle();
95 if (error)
96 throw new Error(`terms_acceptances get failed: ${error.message}`);
97 return data
98 ? {
99 subject: data.subject as string,
100 version: data.version as string,
101 acceptedAt: data.accepted_at as string,
102 }
103 : null;
104 }
106 
107let cached: TermsAcceptanceStore | null = null;
108 
109/** The configured terms-acceptance store (memoized — reuses one Supabase client). */
110export function termsAcceptanceStore(): TermsAcceptanceStore {
111 if (cached) return cached;
112 const cfg = supabaseConfig();
113 cached = cfg
114 ? new SupabaseTermsAcceptanceStore(
115 createClient(cfg.url, cfg.secretKey, {
116 auth: { persistSession: false },
117 }),
118 )
119 : new InMemoryTermsAcceptanceStore();
120 return cached;
122 
123/** Test seam: clear the memoized store so the next call re-reads config. */
124export function resetTermsAcceptanceStore(): void {
125 cached = null;

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
supabase/migrations/0012_terms_acceptances.sql19 lines · Transact-SQL
1-- terms_acceptances: the sign-in clickwrap record (#330) — which account accepted
2-- which version of the Terms/Privacy, and when. The client shows the checkbox at
3-- every OAuth sign-in and the server stamps the row on the return leg, so the
4-- subject is the FINAL account id (signInWithOAuth lands on a fresh or returning
5-- account — never the discarded anonymous id; see utils/sign-in.ts). One row per
6-- (subject, version): the FIRST acceptance timestamp is the legally meaningful
7-- one, so re-accepts are inserted with on-conflict-do-nothing and keep it.
8-- In keyless/device-fallback mode the subject is the device id — better a
9-- device-keyed record than none.
10-- RLS on with no policy: service-key-only, like run_drafts / run_snapshots.
11 
12create table if not exists public.terms_acceptances (
13 subject text not null,
14 version text not null,
15 accepted_at timestamptz not null default now(),
16 primary key (subject, version)
17);
18 
19alter table public.terms_acceptances enable row level security;

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
components/FictionNotice.vue148 lines · Vue
1<template>
2 <!-- The one-time AI/fiction disclosure (#330): the amplified first-visit beat
3 behind the masthead's persistent one-liner. Shows once per device on /play,
4 before the tour; ANY dismissal (Got it, Escape, backdrop) acknowledges it —
5 a disclosure is discharged by being shown, so no path out is a wall.
6 Mirrors RunPacksModal's bespoke dialog idiom (not @nuxt/ui chrome). -->
7 <Transition name="ew-modal">
8 <div
9 v-if="legal.shouldShowNotice"
10 ref="dialogRef"
11 data-testid="fiction-notice"
12 role="dialog"
13 aria-modal="true"
14 aria-labelledby="fiction-notice-heading"
15 class="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-5"
16 @keydown="onKeydown"
17 >
18 <!-- Backdrop -->
19 <div class="absolute inset-0 ew-modal-backdrop" @click="acknowledge" />
20 
21 <!-- Panel -->
22 <div
23 class="relative ew-bg border ew-line w-full sm:max-w-md rounded-t-lg sm:rounded-lg shadow-xl max-h-[92vh] overflow-y-auto"
24 >
25 <div class="px-5 sm:px-6 py-5">
26 <p class="ew-label">Before you write</p>
27 <h2
28 id="fiction-notice-heading"
29 class="ew-serif ew-fg text-2xl font-semibold mt-1 leading-tight"
30 >
31 Heads up — this is fiction.
32 </h2>
33 <p class="ew-muted text-sm mt-3 leading-relaxed">
34 Everwhen uses AI to generate replies from imagined versions of
35 people and moments in history. Responses are made up: they can be
36 wrong, weird, or offensive, and they are not real history, real
37 quotes, or advice.
38 </p>
39 <p class="ew-muted text-sm mt-2.5 leading-relaxed">
40 Your messages are processed by AI and moderation services to play —
41 so don't include personal or sensitive info.
42 </p>
43 <p class="ew-faint text-xs mt-3">
44 More:
45 <NuxtLink to="/disclaimer" class="underline ew-hover-fg"
46 >Disclaimer</NuxtLink
47 >
48 ·
49 <NuxtLink to="/privacy" class="underline ew-hover-fg"
50 >Privacy Policy</NuxtLink
51 >
52 ·
53 <NuxtLink to="/terms" class="underline ew-hover-fg">Terms</NuxtLink>
54 </p>
55 <button
56 ref="gotItRef"
57 type="button"
58 data-testid="fiction-notice-ack"
59 class="ew-btn ew-btn--primary text-sm mt-5 w-full sm:w-auto"
60 @click="acknowledge"
61 >
62 Got it <span aria-hidden="true"></span>
63 </button>
64 </div>
65 </div>
66 </div>
67 </Transition>
68</template>
⋯ 80 lines hidden (lines 69–148)
69 
70<script setup lang="ts">
71/**
72 * FictionNotice — the one-time "this is fiction" dialog (#330). All state lives
73 * in the legal store (device-persisted); this component only renders it and
74 * funnels every dismissal through acknowledge(). Focus moves to the Got-it
75 * button on open (there is no opener control to restore — the dialog is
76 * auto-shown on mount); Tab is trapped while it's up.
77 */
78import { nextTick, ref, watch } from "vue";
79import { useLegalStore } from "~/stores/legal";
80 
81const legal = useLegalStore();
82const dialogRef = ref<HTMLElement | null>(null);
83const gotItRef = ref<HTMLButtonElement | null>(null);
84 
85function acknowledge() {
86 legal.ackNotice();
88 
89// Escape acknowledges; Tab is trapped within the panel so focus can't wander to
90// the page behind the backdrop (which aria-modal="true" declares inert).
91function onKeydown(e: KeyboardEvent) {
92 if (e.key === "Escape") {
93 acknowledge();
94 return;
95 }
96 if (e.key !== "Tab" || !dialogRef.value) return;
97 const focusables = Array.from(
98 dialogRef.value.querySelectorAll<HTMLElement>(
99 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
100 ),
101 ).filter((el) => !el.hasAttribute("disabled"));
102 if (!focusables.length) return;
103 const first = focusables[0];
104 const last = focusables[focusables.length - 1];
105 const active = document.activeElement;
106 if (e.shiftKey && active === first) {
107 e.preventDefault();
108 last.focus();
109 } else if (!e.shiftKey && active === last) {
110 e.preventDefault();
111 first.focus();
112 }
114 
115// Move focus into the dialog when it appears, for keyboard + SR users.
116watch(
117 () => legal.shouldShowNotice,
118 async (show) => {
119 if (!show) return;
120 await nextTick();
121 gotItRef.value?.focus();
122 },
123 { immediate: true },
124);
125</script>
126 
127<style scoped>
128/* The dialog's transition + backdrop, scoped per the house dialog idiom (cf.
129 RunPacksModal — scoped styles can't be shared across components). */
130.ew-modal-backdrop {
131 background: color-mix(in srgb, var(--ew-bg) 55%, #000 45%);
132 backdrop-filter: blur(2px);
134.ew-modal-enter-active,
135.ew-modal-leave-active {
136 transition: opacity var(--ew-dur-1) var(--ew-ease);
138.ew-modal-enter-from,
139.ew-modal-leave-to {
140 opacity: 0;
142@media (prefers-reduced-motion: reduce) {
143 .ew-modal-enter-active,
144 .ew-modal-leave-active {
145 transition: none;
146 }
148</style>

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
utils/site-meta.ts97 lines · TypeScript
⋯ 32 lines hidden (lines 1–32)
1/**
2 * Brand identity + the default social card — the single source of truth shared by the
3 * app shell (the site-wide default, `app.vue`) and the landing page (`pages/index.vue`),
4 * so the brand strings and the card's shape live in exactly one place. A second copy is
5 * one edit away from drifting (issue #118).
6 *
7 * The per-run share page (`pages/r/[token].vue`, issue #88) already sets its own complete
8 * og/twitter block pointed at the live per-run PNG; this module is the DEFAULT every other
9 * public page falls back to — the brand card (`public/og-default.png`).
10 *
11 * Absolute-URL rule: `og:image` / `og:url` MUST be absolute to unfurl — crawlers silently
12 * ignore relative URLs. So the helpers take an `origin` and return absolute URLs. Callers
13 * get the origin from `useRequestURL()` — the real SSR request origin the crawler hit, the
14 * window origin on the client — exactly as the share page already does.
15 */
16 
17/** The brand name. Used for `og:site_name` and as the schema.org game name. */
18export const SITE_NAME = "Everwhen";
19 
20/** The default browser title + og/twitter title for a page without its own. */
21export const SITE_TITLE = "Everwhen — Rewrite History";
22 
23/** The default meta + og/twitter description (the landing's hook copy). */
24export const SITE_DESCRIPTION =
25 "Send 160-character messages to anyone in history. A D20 and an AI decide how the timeline bends. Your first run is free.";
26 
27/**
28 * The support + legal contact address, shown in the site footer and on the policy
29 * pages (issue #234). One constant so the footer and the documents can't drift.
30 */
31export const SUPPORT_EMAIL = "hello@playeverwhen.com";
32 
33/**
34 * The version of the Terms/Privacy the sign-in clickwrap records (issue #330):
35 * the documents' "Last updated" date as an ISO day. TERMS_LAST_UPDATED_TEXT is
36 * the player-facing form the legal pages render. The pair moves together, here,
37 * so the version the server stamps into terms_acceptances can never drift from
38 * the date the documents display. Bump BOTH when the documents change.
39 */
40export const TERMS_VERSION = "2026-07-05";
41export const TERMS_LAST_UPDATED_TEXT = "July 5, 2026";
⋯ 56 lines hidden (lines 42–97)
42 
43/** og:locale — the brand ships in US English today. Pairs with `<html lang="en">`. */
44export const OG_LOCALE = "en_US";
45 
46/** The committed brand card (`public/og-default.png`), 1200x630. Relative — absolutized per request. */
47export const OG_DEFAULT_IMAGE_PATH = "/og-default.png";
48export const OG_IMAGE_WIDTH = 1200;
49export const OG_IMAGE_HEIGHT = 630;
50export const OG_IMAGE_TYPE = "image/png";
51 
52/**
53 * Accurate alt text for `og-default.png` — surfaced by some platforms and read by screen
54 * readers, so it describes what the card actually shows (a wax-seal astrolabe + wordmark).
55 */
56export const OG_IMAGE_ALT =
57 "The Everwhen brand card: a terracotta wax-seal astrolabe above the wordmark “Everwhen” and the line “Every message rewrites history.”";
58 
59/**
60 * Absolute URL for a public path against the request origin. Uses the URL parser so an
61 * extra/missing slash can't produce a malformed link. `toAbsolute(origin, '/og-default.png')`
62 * → `https://everwhen.example/og-default.png`; `toAbsolute(origin, '/')` → `https://everwhen.example/`.
63 */
64export function toAbsolute(origin: string, pathname: string): string {
65 return new URL(pathname, origin).href;
67 
68/**
69 * The site-wide JSON-LD (schema.org `VideoGame`) for Google rich results. Pure, constant
70 * brand data (no user input), so it's safe to inline as `application/ld+json`. Kept minimal
71 * and accurate — no `offers`, since the game is freemium, not free software. The return type
72 * is the inferred object literal (every key statically known), so a dropped/renamed field is
73 * a compile error, not a silent runtime gap.
74 */
75export function buildJsonLd(origin: string, image: string) {
76 return {
77 "@context": "https://schema.org",
78 "@type": "VideoGame",
79 name: SITE_NAME,
80 url: toAbsolute(origin, "/"),
81 image,
82 description: SITE_DESCRIPTION,
83 genre: "Strategy",
84 gamePlatform: "Web browser",
85 };
87 
88/**
89 * Serialize data as JSON safe to inline inside a `<script>` tag. `JSON.stringify` does NOT
90 * escape `<`, so a `</script>` in any value would break out of the script context; escaping
91 * every `<` to its Unicode-escape form closes that off regardless of input. Today the only
92 * dynamic value in the JSON-LD is the request origin, which the URL parser already forbids
93 * `<` in — this keeps the inline script safe even if that ever changes.
94 */
95export function inlineJson(data: unknown): string {
96 return JSON.stringify(data).replace(/</g, "\\u003c");

The drift pin.

tests/unit/server/api/terms-acceptance.spec.ts · 46 lines
tests/unit/server/api/terms-acceptance.spec.ts46 lines · TypeScript
⋯ 30 lines hidden (lines 1–30)
1import { describe, it, expect } from "vitest";
2import { existsSync } from "fs";
3import { resolve } from "path";
4import {
5 TERMS_VERSION,
6 TERMS_LAST_UPDATED_TEXT,
7} from "../../../../utils/site-meta";
8 
9/**
10 * The clickwrap record endpoint (#330). Driving the full Nitro handler (session →
11 * subject resolution) is e2e territory; here we cover the observable contract —
12 * the route exists by filename convention, the store it leans on exposes its
13 * surface, and the version constants the server stamps can't drift apart.
14 */
15describe("/api/terms-acceptance endpoint", () => {
16 it("exposes the POST route (filename convention)", () => {
17 expect(
18 existsSync(resolve(process.cwd(), "server/api/terms-acceptance.post.ts")),
19 ).toBe(true);
20 });
21 
22 it("exposes the acceptance store (record/get) the route leans on", async () => {
23 const { termsAcceptanceStore } =
24 await import("~/server/utils/terms-acceptance-store");
25 const store = termsAcceptanceStore();
26 expect(typeof store.recordAcceptance).toBe("function");
27 expect(typeof store.getAcceptance).toBe("function");
28 });
29});
30 
31describe("the recorded terms version", () => {
32 it("is an ISO day (what the server stamps into terms_acceptances)", () => {
33 expect(TERMS_VERSION).toMatch(/^\d{4}-\d{2}-\d{2}$/);
34 });
35 
36 it("matches the player-facing 'Last updated' date rendered on the legal pages", () => {
37 // The pair lives side by side in site-meta.ts and must move together: bump
38 // one without the other and this fails — the recorded version may never
39 // drift from the date the documents display.
40 const rendered = new Date(`${TERMS_VERSION}T00:00:00Z`).toLocaleDateString(
41 "en-US",
42 { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" },
43 );
44 expect(rendered).toBe(TERMS_LAST_UPDATED_TEXT);
45 });
46});

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
tests/e2e/features/legal-onboarding.spec.ts97 lines · TypeScript

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.