What changed, and why

The opening objective screen is the first thing a new, signed-out visitor sees. It was quietly telling them the wrong thing: that they had to sign in to play.

The culprit was a block titled "Sign in for the full archive" — a bold heading over two full-width Google/Discord buttons — sitting between the list of objectives and the Begin button. Visually it looked like a step you had to clear. It isn't. Anonymous players get three starter objectives (listStarterObjectives) and can play them with no account at all; signing in only widens the slate and unlocks AI compose. The one real gate is server-side, in server/api/objective.get.ts — never this screen.

Three small moves fix it, plus one shared CSS cap that had been making every sign-in button in the app stretch edge-to-edge. Nothing about the signed-in briefing (full slate, steer dials, compose) changes.

The gate that wasn't

Two edits to the anonymous briefing. First, the subtitle now says the quiet part out loud — you can start right now, no account. Second, the sign-in nudge moves out from between the list and Begin down to below Begin, so the choose → Begin flow is unbroken and sign-in reads as an optional footer.

The nudge itself is reframed to match. The bold ew-fg heading becomes a quiet ew-label eyebrow (the same 11px uppercase used by the steer fieldset's legend), and the copy leads with reassurance — "The three above are yours to play right now" — before the upsell. It now sits exactly where the steer dials sit for a signed-in player: a secondary, optional row, not a barrier.

The anonymous subtitle, and the relocated + reframed nudge (now after the Begin action).

components/MissionSelect.vue · 507 lines
components/MissionSelect.vue507 lines · Vue
⋯ 81 lines hidden (lines 1–81)
1<template>
2 <!-- Mission briefing: choose (or compose) the run's grand objective, as a ledger. -->
3 <div data-testid="mission-select" class="ew-fade-in max-w-4xl mx-auto">
4 <!-- Replay (issue #89): seeded from a shared run's objective. A focused view — this
5 one objective, ready to play — not the full browse/compose ledger. -->
6 <template v-if="replay">
7 <div class="mb-7">
8 <p class="ew-label">Replay a shared objective</p>
9 <h2
10 class="ew-serif ew-fg text-2xl sm:text-3xl font-semibold mt-1.5 leading-tight"
11 >
12 Take on this very objective
13 </h2>
14 <RemixCreditLine :credit="replayCredit" class="mt-2" />
15 </div>
16 
17 <div
18 data-testid="replay-objective"
19 class="border-t border-b ew-line py-4 flex items-start gap-3 ew-tint"
20 :style="{ borderLeft: '2px solid var(--ew-accent)' }"
21 >
22 <span
23 class="w-6 shrink-0 text-center text-lg leading-none select-none"
24 aria-hidden="true"
25 >{{ replay.objective.icon }}</span
26 >
27 <span class="min-w-0 flex-1">
28 <span class="flex items-center gap-2 flex-wrap">
29 <h3 class="ew-fg font-semibold">{{ replay.objective.title }}</h3>
30 <span v-if="replay.objective.era" class="ew-label">{{
31 replay.objective.era
32 }}</span>
33 </span>
34 <!-- whitespace-pre-line: the brief is newline-separated "• " bullet lines (#82). -->
35 <span
36 class="block ew-muted text-sm mt-0.5 leading-relaxed whitespace-pre-line"
37 >{{ replay.objective.description }}</span
38 >
39 </span>
40 </div>
41 
42 <div
43 class="mt-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"
44 >
45 <button
46 type="button"
47 data-testid="replay-exit"
48 class="ew-tap ew-hover-fg text-xs ew-mono uppercase tracking-wide"
49 @click="exitReplay"
50 >
51 ← Choose a different objective
52 </button>
53 <button
54 type="button"
55 data-testid="begin-button"
56 class="ew-btn ew-btn--primary"
57 :disabled="beginning"
58 @click="begin"
59 >
60 {{ beginning ? "Charting the timeline…" : "Play this objective" }}
61 <span aria-hidden="true"></span>
62 </button>
63 </div>
64 <p
65 v-if="beginError"
66 data-testid="begin-error"
67 class="text-xs ew-bad mt-2"
68 >
69 {{ beginError }}
70 </p>
71 </template>
72 
73 <!-- Normal briefing: choose a curated objective, or compose a fresh one. -->
74 <template v-else>
75 <div class="mb-7">
76 <p class="ew-label">Choose your crusade</p>
77 <h2
78 class="ew-serif ew-fg text-2xl sm:text-3xl font-semibold mt-1.5 leading-tight"
79 >
80 What will you set right?
81 </h2>
82 <p v-if="canCompose" class="ew-muted text-sm mt-2 max-w-xl">
83 Five messages. Anyone in history. One world to remake. Pick the cause
84 that will define your run — or have a fresh one composed.
85 </p>
86 <p v-else class="ew-muted text-sm mt-2 max-w-xl">
87 Five messages. Anyone in history. One world to remake. Pick one of the
88 three below and start right now — no account needed.
89 </p>
90 </div>
⋯ 172 lines hidden (lines 91–262)
91 
92 <!-- Objectives as compact hairline rows: a one-line hook keeps the whole list
93 scannable at a glance (you see many at once, not two). The full brief unfolds
94 only on the chosen row, so the page stays short until you commit to reading
95 one. A freshly composed objective leads. -->
96 <div
97 role="radiogroup"
98 aria-label="Choose an objective"
99 class="border-t ew-line"
100 >
101 <button
102 v-for="obj in options"
103 :key="(obj === fresh ? 'fresh:' : 'curated:') + obj.title"
104 type="button"
105 data-testid="objective-option"
106 role="radio"
107 :aria-checked="isSelected(obj)"
108 class="w-full text-left flex items-start gap-3 pr-2 pl-3 border-b ew-line transition ew-hover"
109 :class="isSelected(obj) ? 'ew-tint py-3.5' : 'py-2.5'"
110 :style="{
111 borderLeft: `2px solid ${isSelected(obj) ? 'var(--ew-accent)' : 'transparent'}`,
112 }"
113 @click="select(obj)"
114 >
115 <span
116 class="ew-dot mt-1.5"
117 :class="isSelected(obj) ? 'ew-dot--accent' : 'ew-dot--hollow'"
118 aria-hidden="true"
119 />
120 <span
121 class="w-6 shrink-0 text-center text-lg leading-none select-none"
122 aria-hidden="true"
123 >{{ obj.icon }}</span
124 >
125 <span class="min-w-0 flex-1">
126 <span class="flex items-center gap-2 flex-wrap">
127 <h3 class="ew-fg font-semibold">{{ obj.title }}</h3>
128 <span v-if="obj.era" class="ew-label">{{ obj.era }}</span>
129 <span
130 v-if="obj === fresh"
131 data-testid="fresh-badge"
132 class="ew-accent text-[10px] uppercase tracking-wider"
133 >
134 ✨ freshly composed
135 </span>
136 </span>
137 <!-- Collapsed: a single-line hook (truncate is the backstop; hookFor already
138 trims to ~70 chars). Chosen: the full brief, whitespace-pre-line for the
139 newline-separated "• " bullet lines (issue #82), faded in as it unfolds. -->
140 <span
141 v-if="!isSelected(obj)"
142 data-testid="objective-hook"
143 class="block ew-muted text-sm mt-0.5 leading-snug truncate"
144 >{{ hookFor(obj) }}</span
145 >
146 <span
147 v-else
148 data-testid="objective-brief"
149 class="ew-fade-in block ew-muted text-sm mt-1 leading-relaxed whitespace-pre-line"
150 >{{ obj.description }}</span
151 >
152 </span>
153 </button>
154 </div>
155 
156 <!-- Steer a fresh composition: two closed-enum dials, set before composing.
157 "No preference" leaves an axis to the model. Closed enums only (no free
158 text), so an out-of-bounds objective can't be steered into existence. -->
159 <fieldset v-if="canCompose" class="mt-6 pt-4 border-t ew-line">
160 <legend class="ew-label">
161 Steer a fresh composition <span class="ew-faint">· optional</span>
162 </legend>
163 <div class="mt-2 flex flex-col sm:flex-row gap-3 sm:gap-4">
164 <label class="flex-1 min-w-0 text-sm">
165 <span class="ew-label block mb-1">Era</span>
166 <select
167 v-model="steerEra"
168 data-testid="steer-era"
169 class="ew-select text-sm"
170 aria-label="Steer the era"
171 >
172 <option value="">No preference</option>
173 <option v-for="era in OBJECTIVE_ERAS" :key="era" :value="era">
174 {{ era }}
175 </option>
176 </select>
177 </label>
178 <label class="flex-1 min-w-0 text-sm">
179 <span class="ew-label block mb-1">Theme</span>
180 <select
181 v-model="steerTheme"
182 data-testid="steer-theme"
183 class="ew-select text-sm"
184 aria-label="Steer the theme"
185 >
186 <option value="">No preference</option>
187 <option
188 v-for="theme in OBJECTIVE_THEMES"
189 :key="theme"
190 :value="theme"
191 >
192 {{ theme }}
193 </option>
194 </select>
195 </label>
196 </div>
197 </fieldset>
198 
199 <!-- Actions: compose a fresh objective · begin the run -->
200 <div
201 class="mt-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"
202 >
203 <div v-if="canCompose" class="flex flex-col items-start gap-1">
204 <button
205 type="button"
206 data-testid="compose-objective"
207 class="ew-btn text-sm"
208 :disabled="composing"
209 @click="rollFresh"
210 >
211 <span aria-hidden="true"></span>
212 {{
213 composing
214 ? "Composing a fresh fate…"
215 : "Compose a fresh objective with AI"
216 }}
217 </button>
218 <p
219 v-if="activeSteer"
220 data-testid="active-steer"
221 class="text-xs ew-muted"
222 >
223 Steering: {{ activeSteer }}
224 </p>
225 <p
226 v-if="composeError"
227 data-testid="compose-error"
228 class="text-xs ew-bad"
229 >
230 {{ composeError }}
231 </p>
232 <!-- Quiet degrade (#127): a steered compose fell back to curated. Non-blocking
233 and muted (not the ew-bad error tone) — the player has a valid objective
234 ready; this just tells the truth that the steer was dropped. -->
235 <p
236 v-if="composeNotice"
237 data-testid="compose-degraded"
238 class="text-xs ew-muted"
239 >
240 {{ composeNotice }}
241 </p>
242 </div>
243 
244 <button
245 type="button"
246 data-testid="begin-button"
247 class="ew-btn ew-btn--primary sm:ml-auto"
248 :disabled="!selected || beginning"
249 @click="begin"
250 >
251 {{ beginning ? "Charting the timeline…" : "Begin rewriting history" }}
252 <span aria-hidden="true"></span>
253 </button>
254 <p
255 v-if="beginError"
256 data-testid="begin-error"
257 class="text-xs ew-bad mt-2"
258 >
259 {{ beginError }}
260 </p>
261 </div>
262 
263 <!-- Sign-in nudge (anonymous only), an optional footer BELOW Begin — never a
264 gate before it. The three starter objectives are fully playable signed-out
265 (listStarterObjectives); signing in only widens the slate and unlocks AI
266 compose (the real gate is server/api/objective.get.ts). Framed quiet: an
267 ew-label eyebrow like the steer fieldset's legend, reassurance first, then the
268 capped-width provider buttons — so it reads as "unlock more", not "sign in to
269 play". -->
270 <div
271 v-if="showSignInNudge"
272 data-testid="objective-signin-nudge"
273 class="mt-6 pt-4 border-t ew-line"
274 >
275 <p class="ew-label">Unlock the full archive</p>
276 <p class="ew-muted text-sm mt-1.5 mb-3 max-w-xl">
277 The three above are yours to play right now. Sign in to open the full
278 slate of objectives — and compose your own with AI.
279 </p>
280 <SignInForm />
281 </div>
⋯ 226 lines hidden (lines 282–507)
282 </template>
283 
284 <!-- Paywall: the free run is spent. The run-packs modal opens automatically on
285 the refused commit; this persistent notice lets the player reopen it if they
286 dismissed it. -->
287 <div
288 v-if="gameStore.outOfRuns"
289 data-testid="paywall"
290 class="mt-4 pt-4 border-t ew-line"
291 >
292 <p class="text-sm ew-fg font-semibold">
293 You're out of runs — your free run is spent.
294 </p>
295 <p class="ew-muted text-xs mt-0.5">
296 Grab a pack to keep rewriting history. One-time, never expires.
297 </p>
298 <button
299 type="button"
300 data-testid="open-packs"
301 class="ew-btn ew-btn--primary mt-3"
302 @click="economyStore.openBuyModal()"
303 >
304 See run packs <span aria-hidden="true"></span>
305 </button>
306 </div>
307 
308 <!-- Capacity: the global spend cap paused new runs (in-progress runs continue). -->
309 <p
310 v-else-if="gameStore.atCapacity"
311 data-testid="at-capacity"
312 class="text-sm ew-bad mt-3"
313 >
314 The timeline is at capacity right now — please try again shortly.
315 </p>
316 
317 <!-- Compliance disclosure, shown at the start of every run (Anthropic Usage
318 Policy: AI disclosure at session start; outputs may be inaccurate; an
319 adults-only posture). -->
320 <p
321 data-testid="ai-disclosure"
322 class="ew-faint text-xs leading-relaxed mt-8 pt-4 border-t ew-line"
323 >
324 The historical figures here are played by AI, not real people, and every
325 reply is AI-generated
326 <span class="ew-fg"
327 >alternate history — dramatized fiction, not historical fact</span
328 >; don't rely on it as accurate. Intended for adults (18+).
329 </p>
330 </div>
331</template>
332 
333<script setup lang="ts">
334/**
335 * MissionSelect — the opening briefing. Curated objectives + a live-composed one, as
336 * borderless hairline rows; selection uses identity, not titles. Nothing commits
337 * until Begin, so a freshly composed objective can be previewed and reconsidered.
338 */
339import { ref, shallowRef, computed } from "vue";
340import { useGameStore } from "~/stores/game";
341import { useEconomyStore } from "~/stores/economy";
342import type { RemixCredit } from "~/utils/run-snapshot";
343import {
344 listCuratedObjectives,
345 listStarterObjectives,
346 OBJECTIVE_ERAS,
347 OBJECTIVE_THEMES,
348 type GameObjective,
349 type ObjectiveEra,
350 type ObjectiveTheme,
351} from "~/utils/objectives";
352 
353const gameStore = useGameStore();
354const economyStore = useEconomyStore();
355 
356// Anonymous (not-signed-in) players get a fixed three-objective starter sampler and no
357// AI compose; signing in unlocks the full curated slate + custom composition.
358// `accountAnonymous` comes from /api/balance (true only for a real anonymous Supabase
359// session, not the device-fallback path); the compose route enforces the same gate
360// server-side. Balance loads async on the play shell, so hold the restricted view until
361// it's known (runsRemaining stays null until then) — otherwise the full slate + compose
362// would flash for an anonymous visitor (the common case) then collapse. Failing toward
363// restricted is safe: the server is the real gate, and a signed-in player only ever
364// gains options once the balance resolves.
365const balanceLoaded = computed(() => economyStore.runsRemaining !== null);
366const canCompose = computed(
367 () => balanceLoaded.value && !economyStore.accountAnonymous,
368);
369const showSignInNudge = computed(
370 () => balanceLoaded.value && economyStore.accountAnonymous,
371);
372 
373// Replay mode (issue #89): set when seeded from a shared run's objective. The briefing
374// shows just that objective, ready to play; Begin commits it verbatim (no generation).
375const replay = computed(() => gameStore.replayState);
376// The "remixed from" line above the seeded objective. The title is omitted (the objective
377// is shown right below), so it reads "Remixed from <name>" / "…an anonymous run", linking
378// back to the source when still shared.
379const replayCredit = computed<RemixCredit>(() => ({
380 attribution: replay.value?.sourceAttribution ?? null,
381 objectiveTitle: "",
382 sourceToken: replay.value?.sourceToken ?? null,
383}));
384 
385// Anonymous players see only the starter sampler; signed-in players get the full curated
386// pool. Held to the sampler until the account is known signed-in (canCompose), so the
387// full slate never flashes for the common anonymous visitor. Same object references
388// either way, so identity-based selection (isSelected) still holds.
389const curated = computed(() =>
390 canCompose.value ? listCuratedObjectives() : listStarterObjectives(),
391);
392// shallowRef, not ref: selection is by object identity (isSelected uses ===), and the
393// curated options are plain objects. A deep ref would return a *reactive proxy* on
394// `.value`, so `selected.value === obj` (raw) would be false — the row would highlight
395// on hover but its radio dot / aria-checked would never flip. shallowRef stores the
396// value as-is, keeping identity intact.
397const fresh = shallowRef<GameObjective | null>(null);
398const selected = shallowRef<GameObjective | null>(null);
399// Titles composed this session, fed back as the avoid-list so each reroll lands
400// somewhere new (#95). Resets naturally: MissionSelect is mounted only while there's
401// no objective (v-if in play.vue), so a new run remounts it with an empty list.
402const seenTitles = ref<string[]>([]);
403const composing = ref(false);
404// Hard-failure surface: no objective came back at all (red, ew-bad).
405const composeError = ref<string | null>(null);
406// Quiet-degrade surface (#127): a steered compose fell back to curated, so the
407// steer was dropped. Distinct from composeError — the player still has a valid
408// objective; this notice is muted and informational, not an error.
409const composeNotice = ref<string | null>(null);
410const beginning = ref(false);
411 
412// Composition steer (closed enums; '' = no preference, left to the model).
413const steerEra = ref<ObjectiveEra | "">("");
414const steerTheme = ref<ObjectiveTheme | "">("");
415 
416const options = computed(() =>
417 fresh.value ? [fresh.value, ...curated.value] : curated.value,
418);
419 
420// A plain-language echo of the active steer, surfaced by the compose button.
421const activeSteer = computed(
422 () => [steerEra.value, steerTheme.value].filter(Boolean).join(" · ") || null,
423);
424 
425// A one-line hook for the collapsed row — the first brief line, bullet marker
426// stripped, trimmed to ~70 chars at a word boundary. The full description (the "• "
427// bullet list, #82) is only revealed on the chosen row, keeping the list scannable.
428function hookFor(obj: GameObjective): string {
429 const first =
430 (obj.description || "")
431 .split("\n")
432 .map((l) => l.replace(/^[•\-\s]+/, "").trim())
433 .find(Boolean) || "";
434 if (first.length <= 72) return first;
435 const cut = first.slice(0, 70);
436 const lastSpace = cut.lastIndexOf(" ");
437 return (lastSpace > 40 ? cut.slice(0, lastSpace) : cut).trimEnd() + "…";
439 
440function isSelected(obj: GameObjective) {
441 return selected.value === obj;
443 
444function select(obj: GameObjective) {
445 selected.value = obj;
447 
448async function rollFresh() {
449 composing.value = true;
450 composeError.value = null;
451 composeNotice.value = null;
452 const { objective, fellBackToCurated } = await gameStore.fetchAIObjective(
453 {
454 era: steerEra.value || undefined,
455 theme: steerTheme.value || undefined,
456 },
457 seenTitles.value,
458 );
459 composing.value = false;
460 
461 if (objective) {
462 fresh.value = objective;
463 selected.value = objective; // auto-select the fresh one so Begin is one tap away
464 // Remember it so the next reroll avoids it (a curated fallback lands here too,
465 // so even a fallback won't be re-served).
466 if (!seenTitles.value.includes(objective.title))
467 seenTitles.value.push(objective.title);
468 // The steer was set but generation fell back to curated (#127): say so quietly,
469 // so a dropped steer doesn't read as "steering is broken".
470 if (fellBackToCurated)
471 composeNotice.value =
472 "Couldn't compose a fresh objective right now — here's a curated one.";
473 } else {
474 composeError.value =
475 "The archives went quiet — pick one above, or try composing again.";
476 }
478 
479// Leave replay mode for the full browse/compose briefing — this run becomes an original.
480function exitReplay() {
481 gameStore.clearReplayState();
482 selected.value = null;
484 
485const beginError = ref<string | null>(null);
486 
487async function begin() {
488 // In replay mode the seeded objective IS the choice (no browse list rendered);
489 // otherwise it's whatever the player selected. Either way chooseObjective commits
490 // it verbatim — replay runs no generation, just reuses the captured objective.
491 const objective = replay.value?.objective ?? selected.value;
492 if (!objective || beginning.value) return;
493 beginError.value = null;
494 beginning.value = true;
495 // Charges one run from the balance. If out of runs, chooseObjective raises the
496 // paywall (gameStore.outOfRuns) and opens the run-packs modal; no run starts.
497 const started = await gameStore.chooseObjective(objective);
498 beginning.value = false;
499 // A false return that ISN'T the paywall (outOfRuns) or capacity gate is a real
500 // failure — ensureRunId/run-commit threw and set gameStore.error. Surface it here,
501 // or the button just flashes 'Charting the timeline…' and reverts silently (#233).
502 if (!started && !gameStore.outOfRuns && !gameStore.atCapacity) {
503 beginError.value =
504 gameStore.error || "Could not start the run. Please try again.";
505 }
507</script>

Sign-in buttons at a real width

SignInForm is shared across four surfaces: the objective briefing, the run-packs modal, the balance popover, and settings. Its buttons were width: 100%, so in any wide host they stretched the full container width — off-brand for provider buttons, and part of what made the nudge feel like a mandatory banner.

The fix is one rule: cap the form column at 22rem. The buttons keep width: 100% and simply fill that capped column, so they render at a standard sign-in width everywhere. Because it's a max, a narrow host like the balance popover still fills naturally.

The class hook on the root, and the single capping rule that the width:100% buttons fill.

components/SignInForm.vue · 280 lines
components/SignInForm.vue280 lines · Vue
1<template>
2 <div data-testid="signin-form" class="signin-form">
3 <p v-if="hint" class="ew-faint text-xs leading-relaxed mb-2">{{ hint }}</p>
⋯ 179 lines hidden (lines 4–182)
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,
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 }
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/* Cap the form to a standard sign-in column so the provider buttons read as real
186 sign-in buttons, not a full-bleed banner. In a wide host (the objective briefing,
187 settings) an edge-to-edge OAuth button is off-brand and makes an optional sign-in
188 feel mandatory; narrower hosts (the balance popover) still fill, since this is a
189 max. The buttons keep width:100% and simply fill this capped column. */
190.signin-form {
191 max-width: 22rem;
193.signin-btn {
194 display: flex;
195 align-items: center;
196 justify-content: center;
197 gap: 10px;
198 width: 100%;
199 height: 40px;
⋯ 81 lines hidden (lines 200–280)
200 padding: 0 12px;
201 border-radius: 4px;
202 border: 1px solid transparent;
203 font-family: "Roboto", "Helvetica Neue", Arial, system-ui, sans-serif;
204 font-size: 14px;
205 font-weight: 500;
206 letter-spacing: 0.25px;
207 cursor: pointer;
208 transition:
209 background-color var(--ew-dur-1) var(--ew-ease),
210 border-color var(--ew-dur-1) var(--ew-ease),
211 box-shadow var(--ew-dur-1) var(--ew-ease),
212 transform var(--ew-dur-1) var(--ew-ease);
214.signin-btn__icon {
215 flex: 0 0 auto;
217/* The clickwrap checkbox rides the accent so it reads as the one action here. */
218.signin-accept input {
219 accent-color: var(--ew-accent);
221.signin-btn:active:not(:disabled) {
222 transform: translateY(0.5px) scale(0.985);
224.signin-btn:disabled {
225 opacity: 0.5;
226 cursor: not-allowed;
228.signin-btn:focus-visible {
229 outline: 2px solid var(--ew-accent);
230 outline-offset: 2px;
232 
233/* Google — light theme. */
234.signin-btn--google {
235 background: #ffffff;
236 border-color: #747775;
237 color: #1f1f1f;
239.signin-btn--google:hover:not(:disabled) {
240 /* Google's "state layer": a faint blue wash plus a soft elevation shadow. */
241 background: #f7fafe;
242 box-shadow:
243 0 1px 2px rgba(60, 64, 67, 0.3),
244 0 1px 3px 1px rgba(60, 64, 67, 0.15);
246/* Google — dark theme, used under everwhen's dark mode (.dark on <html>). */
247.dark .signin-btn--google {
248 background: #131314;
249 border-color: #8e918f;
250 color: #e3e3e3;
252.dark .signin-btn--google:hover:not(:disabled) {
253 background: #1e1f20;
254 box-shadow: 0 1px 3px 1px rgba(0, 0, 0, 0.45);
256 
257/* Discord — Blurple brand button; the mark + label are white on either ground. */
258.signin-btn--discord {
259 background: #5865f2;
260 border-color: #5865f2;
261 color: #ffffff;
263.signin-btn--discord:hover:not(:disabled) {
264 background: #4752c4;
265 border-color: #4752c4;
267.signin-btn--discord:active:not(:disabled) {
268 background: #3c45a5;
269 border-color: #3c45a5;
271 
272@media (prefers-reduced-motion: reduce) {
273 .signin-btn {
274 transition: none;
275 }
276 .signin-btn:active:not(:disabled) {
277 transform: none;
278 }
280</style>

Pinning the intent

The corrected order is the whole point, so a test pins it. It mounts the anonymous briefing and asserts the sign-in nudge appears after the Begin button in document order, and that the "no account needed" reassurance is present. This fails on the pre-fix layout, where the nudge rendered before the actions row — so a future refactor can't silently slide it back into a gate.

Document-order + reassurance assertions — a regression guard, not coverage padding.

tests/unit/components/MissionSelect.spec.ts · 489 lines
tests/unit/components/MissionSelect.spec.ts489 lines · TypeScript
⋯ 71 lines hidden (lines 1–71)
1import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2import { mount, flushPromises } from "@vue/test-utils";
3import { setActivePinia, createPinia } from "pinia";
4import MissionSelect from "../../../components/MissionSelect.vue";
5import { useGameStore } from "../../../stores/game";
6import { useEconomyStore } from "../../../stores/economy";
7import {
8 listCuratedObjectives,
9 listStarterObjectives,
10} from "../../../server/utils/objectives";
11 
12const FRESH = {
13 title: "Chart the Southern Stars",
14 description: "Map the unknown skies a century early and redraw every voyage.",
15 era: "Age of sail",
16 icon: "🌌",
17};
18 
19describe("MissionSelect", () => {
20 let gameStore: ReturnType<typeof useGameStore>;
21 let economyStore: ReturnType<typeof useEconomyStore>;
22 let fetchMock: ReturnType<typeof vi.fn>;
23 
24 beforeEach(() => {
25 setActivePinia(createPinia());
26 gameStore = useGameStore();
27 economyStore = useEconomyStore();
28 // Default precondition: a signed-in account with a loaded balance, so the briefing
29 // offers the full curated slate + AI compose. The anonymous-gate cases flip
30 // accountAnonymous; the pre-load case clears runsRemaining (null until /api/balance
31 // resolves, which the component treats as "anonymity not yet known").
32 economyStore.runsRemaining = 5;
33 economyStore.accountAnonymous = false;
34 fetchMock = vi.fn();
35 vi.stubGlobal("$fetch", fetchMock);
36 });
37 
38 afterEach(() => {
39 vi.unstubAllGlobals();
40 vi.restoreAllMocks();
41 });
42 
43 it("offers every curated objective to choose from (signed in)", () => {
44 const wrapper = mount(MissionSelect);
45 const options = wrapper.findAll('[data-testid="objective-option"]');
46 expect(options.length).toBe(listCuratedObjectives().length);
47 });
48 
49 it("anonymous: offers only the 3 starter objectives, the sign-in nudge, and no compose", () => {
50 economyStore.accountAnonymous = true; // balance already loaded (runsRemaining set)
51 const wrapper = mount(MissionSelect);
52 
53 const options = wrapper.findAll('[data-testid="objective-option"]');
54 expect(options.length).toBe(listStarterObjectives().length);
55 expect(options.length).toBe(3);
56 // The shown objectives ARE the starter sampler, in order.
57 expect(options.map((o) => o.find("h3").text())).toEqual(
58 listStarterObjectives().map((obj) => obj.title),
59 );
60 // No custom rolling: the compose button and its steer dials are gone.
61 expect(wrapper.find('[data-testid="compose-objective"]').exists()).toBe(
62 false,
63 );
64 expect(wrapper.find('[data-testid="steer-era"]').exists()).toBe(false);
65 // The sign-in nudge (with the OAuth form) takes their place.
66 expect(
67 wrapper.find('[data-testid="objective-signin-nudge"]').exists(),
68 ).toBe(true);
69 expect(wrapper.find('[data-testid="signin-form"]').exists()).toBe(true);
70 });
71 
72 it("anonymous: keeps sign-in an optional footer BELOW Begin, not a gate before it", () => {
73 // The whole point of the anonymous briefing: you can play the three starters with
74 // no account. So the choose → Begin flow must be unbroken, with the sign-in nudge
75 // sitting AFTER Begin as an "unlock more" footer — never wedged between the list and
76 // Begin, where it reads as a required step. Pre-fix, the nudge rendered before the
77 // actions row; this pins the corrected order so it can't silently regress.
78 economyStore.accountAnonymous = true;
79 const wrapper = mount(MissionSelect);
80 const html = wrapper.html();
81 const beginAt = html.indexOf('data-testid="begin-button"');
82 const nudgeAt = html.indexOf('data-testid="objective-signin-nudge"');
83 expect(beginAt).toBeGreaterThan(-1);
84 expect(nudgeAt).toBeGreaterThan(beginAt);
85 // And the briefing tells an anonymous visitor up front they need no account to play.
86 expect(wrapper.text()).toContain("no account needed");
87 });
88 
⋯ 401 lines hidden (lines 89–489)
89 it("anonymous: can still pick a starter objective and Begin the run", async () => {
90 economyStore.accountAnonymous = true;
91 fetchMock.mockImplementation((url: string) =>
92 url === "/api/run"
93 ? Promise.resolve({ runId: "r-1" })
94 : Promise.resolve({}),
95 );
96 const wrapper = mount(MissionSelect);
97 
98 await wrapper
99 .findAll('[data-testid="objective-option"]')[0]
100 .trigger("click");
101 await wrapper.find('[data-testid="begin-button"]').trigger("click");
102 await flushPromises();
103 
104 expect(gameStore.currentObjective?.title).toBe(
105 listStarterObjectives()[0].title,
106 );
107 });
108 
109 it("before balance loads: holds the restricted view (3 objectives, no compose, no nudge)", () => {
110 // runsRemaining null = /api/balance hasn't resolved, so anonymity is unknown. The
111 // briefing must NOT flash the full slate + compose (the common visitor is anonymous),
112 // nor a sign-in nudge at a would-be signed-in player. Restricted, quietly.
113 economyStore.runsRemaining = null;
114 const wrapper = mount(MissionSelect);
115 
116 expect(wrapper.findAll('[data-testid="objective-option"]').length).toBe(3);
117 expect(wrapper.find('[data-testid="compose-objective"]').exists()).toBe(
118 false,
119 );
120 expect(
121 wrapper.find('[data-testid="objective-signin-nudge"]').exists(),
122 ).toBe(false);
123 });
124 
125 it("renders the chosen brief newline-aware so bullet lines do not collapse (issue #82)", async () => {
126 const wrapper = mount(MissionSelect);
127 const firstOption = wrapper.findAll('[data-testid="objective-option"]')[0];
128 // The full brief unfolds only on the chosen row (compact list + expand), so
129 // select it first; the grounded briefs are multi-line "• " bullets and this
130 // class keeps them on separate rows at mission select (the issue's render site).
131 await firstOption.trigger("click");
132 const brief = wrapper
133 .findAll('[data-testid="objective-option"]')[0]
134 .find(".whitespace-pre-line");
135 expect(brief.exists()).toBe(true);
136 expect(brief.text()).toContain("• ");
137 });
138 
139 it("keeps the list compact: a one-line hook per row, the full brief only when chosen", async () => {
140 const wrapper = mount(MissionSelect);
141 const options = wrapper.findAll('[data-testid="objective-option"]');
142 // Nothing chosen yet — every row is a scannable single-line hook, no full brief.
143 expect(wrapper.findAll('[data-testid="objective-hook"]').length).toBe(
144 options.length,
145 );
146 expect(wrapper.findAll('[data-testid="objective-brief"]').length).toBe(0);
147 
148 await options[0].trigger("click");
149 // The chosen row swaps its hook for the brief; the rest stay collapsed hooks.
150 const after = wrapper.findAll('[data-testid="objective-option"]');
151 expect(after[0].find('[data-testid="objective-brief"]').exists()).toBe(
152 true,
153 );
154 expect(after[0].find('[data-testid="objective-hook"]').exists()).toBe(
155 false,
156 );
157 expect(after[1].find('[data-testid="objective-hook"]').exists()).toBe(true);
158 expect(after[1].find('[data-testid="objective-brief"]').exists()).toBe(
159 false,
160 );
161 // The hook is a short single line, not the full multi-bullet brief.
162 const hook = after[1].find('[data-testid="objective-hook"]');
163 expect(hook.text().length).toBeLessThanOrEqual(72);
164 expect(hook.text()).not.toContain("• ");
165 });
166 
167 it("disables Begin until an objective is selected", async () => {
168 const wrapper = mount(MissionSelect);
169 expect(
170 wrapper.find('[data-testid="begin-button"]').attributes("disabled"),
171 ).toBeDefined();
172 
173 await wrapper
174 .findAll('[data-testid="objective-option"]')[0]
175 .trigger("click");
176 expect(
177 wrapper.find('[data-testid="begin-button"]').attributes("disabled"),
178 ).toBeUndefined();
179 });
180 
181 it("marks the clicked objective as selected — aria-checked + the radio dot", async () => {
182 // Regression: a plain ref returns a reactive PROXY on .value, so the identity
183 // check `selected.value === obj` failed for the raw curated objects — the row
184 // tinted on hover but the radio never registered. selection must visibly stick.
185 const wrapper = mount(MissionSelect);
186 const options = wrapper.findAll('[data-testid="objective-option"]');
187 expect(options[1].attributes("aria-checked")).toBe("false");
188 
189 await options[1].trigger("click");
190 
191 expect(options[1].attributes("aria-checked")).toBe("true");
192 expect(options[0].attributes("aria-checked")).toBe("false");
193 expect(options[1].find(".ew-dot").classes()).toContain("ew-dot--accent");
194 expect(options[0].find(".ew-dot").classes()).toContain("ew-dot--hollow");
195 });
196 
197 it("commits the chosen curated objective on Begin", async () => {
198 const curated = listCuratedObjectives();
199 // begin() now fails closed: /api/run must mint, then run-commit must succeed.
200 fetchMock.mockImplementation((url: string) =>
201 url === "/api/run"
202 ? Promise.resolve({ runId: "r-1" })
203 : Promise.resolve({}),
204 );
205 const wrapper = mount(MissionSelect);
206 
207 await wrapper
208 .findAll('[data-testid="objective-option"]')[0]
209 .trigger("click");
210 await wrapper.find('[data-testid="begin-button"]').trigger("click");
211 await flushPromises(); // begin() commits the run (async) before the objective lands
212 
213 expect(gameStore.currentObjective?.title).toBe(curated[0].title);
214 expect(gameStore.objectiveProgress).toBe(0);
215 });
216 
217 it("surfaces a run-commit failure at Begin instead of reverting silently (#233)", async () => {
218 // /api/run mints, but the commit fails with a plain server error — not the paywall
219 // or capacity gate — so chooseObjective sets gameStore.error and returns false.
220 fetchMock.mockImplementation((url: string) =>
221 url === "/api/run"
222 ? Promise.resolve({ runId: "r-1" })
223 : Promise.reject(new Error("boom")),
224 );
225 const wrapper = mount(MissionSelect);
226 
227 await wrapper
228 .findAll('[data-testid="objective-option"]')[0]
229 .trigger("click");
230 await wrapper.find('[data-testid="begin-button"]').trigger("click");
231 await flushPromises();
232 
233 // The run did not start, and the failure is now VISIBLE (it was silent before #233).
234 expect(gameStore.currentObjective).toBeNull();
235 const err = wrapper.find('[data-testid="begin-error"]');
236 expect(err.exists()).toBe(true);
237 expect(err.text()).toContain("Could not start the run");
238 // The button reverted from its 'Charting the timeline…' loading label.
239 expect(wrapper.find('[data-testid="begin-button"]').text()).toContain(
240 "Begin rewriting history",
241 );
242 });
243 
244 it("composes a fresh objective, features it, and begins with it", async () => {
245 fetchMock.mockImplementation((url: string) => {
246 if (url === "/api/objective")
247 return Promise.resolve({ success: true, objective: FRESH });
248 if (url === "/api/run") return Promise.resolve({ runId: "r-1" });
249 return Promise.resolve({}); // run-commit
250 });
251 const wrapper = mount(MissionSelect);
252 const before = wrapper.findAll('[data-testid="objective-option"]').length;
253 
254 await wrapper.find('[data-testid="compose-objective"]').trigger("click");
255 await flushPromises();
256 
257 expect(fetchMock).toHaveBeenCalledWith(
258 "/api/objective",
259 expect.objectContaining({
260 method: "GET",
261 headers: expect.objectContaining({ "x-run-id": expect.any(String) }),
262 }),
263 );
264 expect(wrapper.findAll('[data-testid="objective-option"]').length).toBe(
265 before + 1,
266 );
267 expect(wrapper.find('[data-testid="fresh-badge"]').exists()).toBe(true);
268 
269 // The fresh objective auto-selects, so Begin commits it directly.
270 await wrapper.find('[data-testid="begin-button"]').trigger("click");
271 await flushPromises(); // begin() commits the run (async) before the objective lands
272 expect(gameStore.currentObjective?.title).toBe(FRESH.title);
273 });
274 
275 it("renders era + theme steer pickers, defaulting to no preference", () => {
276 const wrapper = mount(MissionSelect);
277 const era = wrapper.find('[data-testid="steer-era"]');
278 const theme = wrapper.find('[data-testid="steer-theme"]');
279 expect(era.exists()).toBe(true);
280 expect(theme.exists()).toBe(true);
281 expect((era.element as HTMLSelectElement).value).toBe("");
282 expect((theme.element as HTMLSelectElement).value).toBe("");
283 // Nothing surfaced until an axis is picked.
284 expect(wrapper.find('[data-testid="active-steer"]').exists()).toBe(false);
285 });
286 
287 it("forwards the chosen era + theme as query params when composing, and surfaces them", async () => {
288 fetchMock.mockImplementation((url: string) => {
289 if (url === "/api/objective")
290 return Promise.resolve({ success: true, objective: FRESH });
291 if (url === "/api/run") return Promise.resolve({ runId: "r-1" });
292 return Promise.resolve({});
293 });
294 const wrapper = mount(MissionSelect);
295 await wrapper.find('[data-testid="steer-era"]').setValue("Antiquity");
296 await wrapper
297 .find('[data-testid="steer-theme"]')
298 .setValue("Science & Medicine");
299 
300 // The active steer is surfaced once set.
301 expect(wrapper.find('[data-testid="active-steer"]').text()).toContain(
302 "Antiquity · Science & Medicine",
303 );
304 
305 await wrapper.find('[data-testid="compose-objective"]').trigger("click");
306 await flushPromises();
307 
308 expect(fetchMock).toHaveBeenCalledWith(
309 "/api/objective",
310 expect.objectContaining({
311 method: "GET",
312 query: { era: "Antiquity", theme: "Science & Medicine" },
313 }),
314 );
315 });
316 
317 it('omits an axis left at "no preference"', async () => {
318 fetchMock.mockImplementation((url: string) => {
319 if (url === "/api/objective")
320 return Promise.resolve({ success: true, objective: FRESH });
321 if (url === "/api/run") return Promise.resolve({ runId: "r-1" });
322 return Promise.resolve({});
323 });
324 const wrapper = mount(MissionSelect);
325 await wrapper.find('[data-testid="steer-era"]').setValue("Medieval");
326 
327 await wrapper.find('[data-testid="compose-objective"]').trigger("click");
328 await flushPromises();
329 
330 expect(fetchMock).toHaveBeenCalledWith(
331 "/api/objective",
332 expect.objectContaining({
333 query: { era: "Medieval" },
334 }),
335 );
336 });
337 
338 it("shows an error and leaves the run unstarted when composing fails", async () => {
339 fetchMock.mockResolvedValue({ success: false });
340 const wrapper = mount(MissionSelect);
341 
342 await wrapper.find('[data-testid="compose-objective"]').trigger("click");
343 await flushPromises();
344 
345 expect(wrapper.find('[data-testid="compose-error"]').exists()).toBe(true);
346 expect(gameStore.currentObjective).toBeNull();
347 });
348 
349 it("shows a quiet notice (not an error) when a steered compose falls back to curated (#127)", async () => {
350 // The server returns a valid objective AND flags the dropped steer; the
351 // briefing surfaces a muted, non-blocking line — never the red compose-error.
352 fetchMock.mockImplementation((url: string) => {
353 if (url === "/api/objective")
354 return Promise.resolve({
355 success: true,
356 objective: FRESH,
357 fellBackToCurated: true,
358 });
359 if (url === "/api/run") return Promise.resolve({ runId: "r-1" });
360 return Promise.resolve({});
361 });
362 const wrapper = mount(MissionSelect);
363 await wrapper.find('[data-testid="steer-era"]').setValue("Antiquity");
364 
365 await wrapper.find('[data-testid="compose-objective"]').trigger("click");
366 await flushPromises();
367 
368 // A usable objective still landed and auto-selected — the notice is non-blocking.
369 expect(wrapper.find('[data-testid="fresh-badge"]').exists()).toBe(true);
370 expect(
371 wrapper.find('[data-testid="begin-button"]').attributes("disabled"),
372 ).toBeUndefined();
373 // The quiet notice shows (muted tone); the red error does NOT.
374 const notice = wrapper.find('[data-testid="compose-degraded"]');
375 expect(notice.exists()).toBe(true);
376 expect(notice.text()).toContain("curated one");
377 expect(notice.classes()).toContain("ew-muted");
378 expect(wrapper.find('[data-testid="compose-error"]').exists()).toBe(false);
379 });
380 
381 it("shows no dropped-steer notice on a normal successful compose (#127)", async () => {
382 // No flag (or false) → the common path stays silent, no noise.
383 fetchMock.mockImplementation((url: string) => {
384 if (url === "/api/objective")
385 return Promise.resolve({ success: true, objective: FRESH });
386 if (url === "/api/run") return Promise.resolve({ runId: "r-1" });
387 return Promise.resolve({});
388 });
389 const wrapper = mount(MissionSelect);
390 
391 await wrapper.find('[data-testid="compose-objective"]').trigger("click");
392 await flushPromises();
393 
394 expect(wrapper.find('[data-testid="fresh-badge"]').exists()).toBe(true);
395 expect(wrapper.find('[data-testid="compose-degraded"]').exists()).toBe(
396 false,
397 );
398 });
399 
400 it("shows the paywall and does not start the run when out of runs (402)", async () => {
401 // Begin succeeds (run minted), then the commit is refused 402 → paywall.
402 fetchMock.mockImplementation((url: string) => {
403 if (url === "/api/run") return Promise.resolve({ runId: "r-1" });
404 if (url === "/api/run-commit") return Promise.reject({ statusCode: 402 });
405 return Promise.resolve({});
406 });
407 const wrapper = mount(MissionSelect);
408 
409 await wrapper
410 .findAll('[data-testid="objective-option"]')[0]
411 .trigger("click");
412 await wrapper.find('[data-testid="begin-button"]').trigger("click");
413 await flushPromises();
414 
415 expect(wrapper.find('[data-testid="paywall"]').exists()).toBe(true);
416 expect(gameStore.currentObjective).toBeNull();
417 });
418 
419 it("shows the at-capacity notice and does not start when the spend cap is hit (503)", async () => {
420 fetchMock.mockImplementation((url: string) => {
421 if (url === "/api/run") return Promise.resolve({ runId: "r-1" });
422 if (url === "/api/run-commit")
423 return Promise.reject({ statusCode: 503, data: { atCapacity: true } });
424 return Promise.resolve({});
425 });
426 const wrapper = mount(MissionSelect);
427 
428 await wrapper
429 .findAll('[data-testid="objective-option"]')[0]
430 .trigger("click");
431 await wrapper.find('[data-testid="begin-button"]').trigger("click");
432 await flushPromises();
433 
434 expect(wrapper.find('[data-testid="at-capacity"]').exists()).toBe(true);
435 expect(wrapper.find('[data-testid="paywall"]').exists()).toBe(false);
436 expect(gameStore.currentObjective).toBeNull();
437 });
438 
439 it("opens the run-packs modal automatically when out of runs (402)", async () => {
440 // The buy buttons now live in RunPacksModal; the refused commit opens it and
441 // loads the catalog. The paywall keeps a "See run packs" button to reopen it.
442 fetchMock.mockImplementation((url: string) => {
443 if (url === "/api/run") return Promise.resolve({ runId: "r-1" });
444 if (url === "/api/run-commit") return Promise.reject({ statusCode: 402 });
445 if (url === "/api/packs")
446 return Promise.resolve({
447 packs: [
448 { id: "courier", label: "Courier", priceCents: 500, runs: 7 },
449 ],
450 });
451 return Promise.resolve({});
452 });
453 const wrapper = mount(MissionSelect);
454 
455 await wrapper
456 .findAll('[data-testid="objective-option"]')[0]
457 .trigger("click");
458 await wrapper.find('[data-testid="begin-button"]').trigger("click");
459 await flushPromises();
460 
461 expect(wrapper.find('[data-testid="open-packs"]').exists()).toBe(true);
462 expect(economyStore.buyModalOpen).toBe(true);
463 expect(economyStore.packs).toEqual([
464 { id: "courier", label: "Courier", priceCents: 500, runs: 7 },
465 ]);
466 });
467 
468 it("reopens the run-packs modal from the paywall button", async () => {
469 fetchMock.mockImplementation((url: string) => {
470 if (url === "/api/run") return Promise.resolve({ runId: "r-1" });
471 if (url === "/api/run-commit") return Promise.reject({ statusCode: 402 });
472 if (url === "/api/packs") return Promise.resolve({ packs: [] });
473 return Promise.resolve({});
474 });
475 const wrapper = mount(MissionSelect);
476 await wrapper
477 .findAll('[data-testid="objective-option"]')[0]
478 .trigger("click");
479 await wrapper.find('[data-testid="begin-button"]').trigger("click");
480 await flushPromises();
481 
482 // Dismiss, then reopen via the persistent paywall button.
483 economyStore.closeBuyModal();
484 expect(economyStore.buyModalOpen).toBe(false);
485 await wrapper.find('[data-testid="open-packs"]').trigger("click");
486 await flushPromises();
487 expect(economyStore.buyModalOpen).toBe(true);
488 });
489});

Result

Driven in the real app across 375–1280, light and dark. The three starters are plainly playable now; Begin follows the list directly and lights terracotta once an objective is chosen; the sign-in footer is quiet and its buttons are a normal width.

Anonymous, desktop (light) — the primary fix: reassurance in the subtitle, Begin right after the list, a quiet 'Unlock the full archive' footer with standard-width buttons.
Anonymous, desktop (light) — the primary fix: reassurance in the subtitle, Begin right after the list, a quiet 'Unlock the full archive' footer with standard-width buttons.
An objective selected — the brief unfolds, the accent border + dot register the choice, and Begin lights terracotta as the clear primary action.
An objective selected — the brief unfolds, the accent border + dot register the choice, and Begin lights terracotta as the clear primary action.
Anonymous, desktop (dark) — Google's dark-theme button, Discord blurple, both correctly sized; no dark-mode regression.
Anonymous, desktop (dark) — Google's dark-theme button, Discord blurple, both correctly sized; no dark-mode regression.
Anonymous, mobile (375) — objectives stack, Begin is a full-width tap target, and the buttons fill the narrow column.
Anonymous, mobile (375) — objectives stack, Begin is a full-width tap target, and the buttons fill the narrow column.