What changed, and why

The warm-ledger look was doing its job too well: text ink passed AA everywhere, but the structure around it sat below perceptual thresholds. Hairlines measured 1.26:1 against the paper, the card tint 1.10:1, the message-pip rings 1.79:1 — and in dark mode the positive and negative semantic inks were isoluminant at 1.04:1, with the "bad" ink reading as the terracotta accent (1.16:1). Wins looked like losses, and the run's scoreboard was the faintest thing on screen.

This PR is a CSS-and-template pass over 30 files. No store, server, or mechanics code changes: every number the game computes is untouched. The changes cluster into a token retune, a set of shared primitives, per-surface legibility fixes, one real rendering bug (the Record overlay's seal), and a few charter-compatible amplifications of the dramatic beats. All of it stays inside the design charter: hairlines, whitespace, and type carry structure; one terracotta accent; semantic color rides dots and signed numbers.

load-bearing pairbefore (light / dark)after (light / dark)
hairline on page (interactive --ew-edge)1.26 / 1.303.25 / 4.06
track ground (--ew-gauge)1.26 / 1.303.44 / 4.43
ok vs bad ink1.42 / 1.041.66 / 1.96
accent vs bad1.83 / 1.162.15 / 1.24 + hue split
unspent pip ring1.79 / 1.884.55 / 5.68
WCAG ratios, computed from the tokens (sRGB).

The token retune

Everything flows from assets/css/main.css. Three semantic inks move: light --ew-bad deepens to #6f231b, and dark --ew-ok/--ew-bad split by luminance (#90ca7e / #d95c4e) so a loss no longer wears the accent's hue at the accent's brightness. Five tokens are new, each because one value couldn't serve two masters: --ew-edge separates interactive boundaries from decorative hairlines, --ew-gauge gives every track a visible denominator, --ew-raise and --ew-raise-line lift floating overlays a full surface step off the page, and --ew-scrim replaces the two hardcoded black modal backdrops with a warm umber wash. An eight-step --ew-fs-* ladder replaces roughly twenty drifting font sizes.

The retuned :root and .dark blocks, plus the type ladder.

assets/css/main.css · 738 lines
assets/css/main.css738 lines · CSS
⋯ 16 lines hidden (lines 1–16)
1/**
2 * Main CSS — Everwhen.
3 * Tailwind + Nuxt UI, then the "Spine Console" design tokens: an editorial-minimal
4 * read-model aesthetic. Structure comes from hairlines + whitespace + type, never
5 * from cards / shadows / gradient fills. Semantic color lives in 9px dots + signed
6 * numbers, not box fills.
7 */
8 
9@import "tailwindcss";
10@import "@nuxt/ui";
11 
12/* ── Design tokens ─────────────────────────────────────────────────────── */
13/* Warm "ledger / letterpress" palette: aged paper, sepia ink, hairlines like faint
14 rules, and a single terracotta accent (sealing-wax) for everything interactive.
15 Semantic color is warmed too so a swing reads as ink, never an error state — the
16 accent (bright terracotta) stays distinct from negative valence (deep oxblood). */
17:root {
18 --ew-fg: #2b241c; /* sepia ink — ~12:1 on the cream ground */
19 --ew-mut: #685c49; /* readable quiet — ~5.6:1 */
20 --ew-faint: #7d6f57; /* the contrast floor for readable text — ~4.6:1 */
21 --ew-hair: #c9b99c; /* decorative-only (dot rings, ticks); never load-bearing */
22 --ew-line: #e6dcc8; /* sepia hairline — fold rules and section underlines */
23 --ew-edge: #97876a; /* interactive boundary — buttons, inputs, chips (~3.3:1);
24 structural, unlike the decorative --ew-hair/--ew-line */
25 --ew-gauge: color-mix(
26 in srgb,
27 var(--ew-faint) 85%,
28 var(--ew-bg)
29 ); /* the ground of every track / rail / pip row (~3.4:1) — a gauge whose
30 denominator is invisible reads as no gauge at all */
31 --ew-bg: #faf6ee; /* warm paper */
32 --ew-tint: #f1ebdd; /* a shade deeper paper */
33 --ew-raise: var(
34 --ew-tint
35 ); /* floating-overlay surface (coach mark, notices) */
36 --ew-raise-line: var(--ew-hair); /* its edge */
37 --ew-scrim: color-mix(
38 in srgb,
39 var(--ew-desk-edge) 55%,
40 var(--ew-fg) 45%
41 ); /* warm umber modal backdrop — never raw black */
42 --ew-accent: #b1532e; /* terracotta / sealing wax — the one interactive accent */
43 --ew-ok: #466632; /* muted olive-forest */
44 --ew-warn: #8a5a16; /* gold-ochre — pre-send risk levers only, never outcomes */
45 --ew-bad: #6f231b; /* deep oxblood (distinct from the brighter accent) */
46 
47 /* The /play "desk" — the surface the codex manuscript rests on. A warm parchment
48 vignette (center → edge) so light mode reads warm-and-light instead of a dark
49 well; the cream panel still lifts off it via its own hairline + drop shadow.
50 --ew-desk-ink is the one ink that stays legible directly ON the desk (the /play
51 footer links), flipped per theme so it never needs a :global(html.dark) rule. */
52 --ew-desk: #ddccad; /* desk center — warm parchment-tan */
53 --ew-desk-edge: #bda471; /* desk vignette edge — deeper aged tan (never black) */
54 --ew-desk-ink: #423528; /* readable ink on the desk (footer) — ≥4.5:1 everywhere */
55 --ew-desk-ink-strong: var(--ew-fg); /* hover / emphasis on the desk */
56 --ew-desk-ink-shadow: none; /* dark ink on the light desk needs no lift */
⋯ 5 lines hidden (lines 57–61)
57 
58 --ew-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
59 --ew-serif:
60 "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, ui-serif, serif;
61 
62 /* ── Type ladder ───────────────────────────────────────────────────── */
63 /* Eight steps; every font-size lands on one (two exemptions, commented at
64 their sites: the bespoke 46px grade numeral, and the pointer:coarse 16px
65 input floor — the iOS zoom threshold, never round down). */
66 --ew-fs-micro: 10px; /* dot legends, folio page numbers */
67 --ew-fs-label: 11px; /* the uppercase section label */
68 --ew-fs-meta: 12px; /* datelines, counters, equation terms */
69 --ew-fs-note: 13px; /* hints, teaching reads, instructional prose */
70 --ew-fs-body: 15px; /* body ink */
71 --ew-fs-lede: 18px; /* ledes and pull-lines */
72 --ew-fs-title: 22px; /* leaf headings, headline numerals */
73 --ew-fs-display: 28px; /* the big verdict words */
74 
⋯ 16 lines hidden (lines 75–90)
75 /* ── Motion vocabulary ─────────────────────────────────────────────── */
76 /* One timing language so every animation feels cut from the same cloth:
77 three durations (micro / standard / expressive) and two curves — a gentle
78 settle and the struck-token overshoot the dice & seal already use. */
79 --ew-dur-1: 140ms; /* micro — hovers, presses, color/position shifts */
80 --ew-dur-2: 280ms; /* standard — reveals, fades, fills */
81 --ew-dur-3: 520ms; /* expressive — the dice, the chronicle, node arrivals */
82 --ew-ease: cubic-bezier(0.2, 0.7, 0.3, 1); /* settle (ease-out) */
83 --ew-ease-spring: cubic-bezier(
84 0.2,
85 1.4,
86 0.4,
87 1
88 ); /* the struck-token overshoot */
90 
91.dark {
92 --ew-fg: #ece4d6; /* warm off-white ink */
93 --ew-mut: #b3a892;
94 --ew-faint: #978c77; /* ~6:1 on the warm near-black */
95 --ew-hair: #4a4133;
96 --ew-line: #2f2820;
97 --ew-edge: #80735c; /* interactive boundary (~4:1) */
98 --ew-bg: #14110d; /* warm near-black (brown undertone) */
99 --ew-tint: #1c1813;
100 --ew-raise: #2e2b25; /* floating overlays lift a full step off the page */
101 --ew-raise-line: #5c523f;
102 --ew-scrim: color-mix(in srgb, var(--ew-bg) 55%, var(--ew-desk-edge) 45%);
103 --ew-accent: #db7a4f; /* lifted terracotta */
104 --ew-ok: #90ca7e; /* separable from --ew-bad by luminance, not hue alone */
105 --ew-warn: #d8a34d;
106 --ew-bad: #d95c4e; /* blood red — darker than the burnt-orange accent */
107 
108 /* Dark desk: a warm near-black well (not dead #000) so the lifted card reads
109 against it; light warm ink for the footer, with a soft shadow to seat it. */
110 --ew-desk: #0c0906;
111 --ew-desk-edge: #040302;
112 --ew-desk-ink: #d7cbb2;
113 --ew-desk-ink-strong: #f6efe2;
114 --ew-desk-ink-shadow: 0 1px 2px rgba(0, 0, 0, 0.55);
116 
⋯ 622 lines hidden (lines 117–738)
117body {
118 background: var(--ew-bg);
119 color: var(--ew-fg);
120 font-family:
121 system-ui,
122 -apple-system,
123 "Segoe UI",
124 Roboto,
125 sans-serif;
126 font-size: 15px;
127 line-height: 1.55;
129 
130/* Browser-level chrome, warmed to match the page: text selection + scrollbars. */
131::selection {
132 background: color-mix(in srgb, var(--ew-accent) 24%, transparent);
134* {
135 scrollbar-width: thin;
136 scrollbar-color: var(--ew-hair) transparent;
138::-webkit-scrollbar {
139 width: 11px;
140 height: 11px;
142::-webkit-scrollbar-track {
143 background: transparent;
145::-webkit-scrollbar-thumb {
146 background: var(--ew-hair);
147 border-radius: 999px;
148 border: 3px solid var(--ew-bg);
150::-webkit-scrollbar-thumb:hover {
151 background: var(--ew-faint);
153 
154/* ── Token helper classes (pair with Tailwind layout utilities) ─────────── */
155.ew-fg {
156 color: var(--ew-fg);
158.ew-muted {
159 color: var(--ew-mut);
161.ew-hair-c {
162 color: var(--ew-hair);
164.ew-hover:hover:not(:disabled) {
165 background: var(--ew-tint);
167.ew-hover-fg {
168 color: var(--ew-mut);
170.ew-hover-fg:hover {
171 color: var(--ew-fg);
173/* First-turn quieting of the empty read-model, so the eye lands on the dock.
174 0.68 keeps muted ink at ~3:1 — clearly quieter, never sub-legible. */
175.ew-dim {
176 opacity: 0.68;
177 transition: opacity var(--ew-dur-2) var(--ew-ease);
179@media (prefers-reduced-motion: reduce) {
180 .ew-dim {
181 transition: none;
182 }
184.ew-faint {
185 color: var(--ew-faint);
187.ew-accent {
188 color: var(--ew-accent);
190.ew-bg {
191 background: var(--ew-bg);
193.ew-tint {
194 background: var(--ew-tint);
196.ew-mono {
197 font-family: var(--ew-mono);
199 
200.ew-ok {
201 color: var(--ew-ok);
203.ew-warn {
204 color: var(--ew-warn);
206.ew-bad {
207 color: var(--ew-bad);
209 
210/* Pair with Tailwind border / border-t / border-b / border-r / border-l. */
211.ew-line {
212 border-color: var(--ew-line);
214/* The interactive-boundary counterpart: chips, pickers, pressable rows. */
215.ew-edge {
216 border-color: var(--ew-edge);
218 
219/* A "section" header = an 11px uppercase muted label + a 1px underline. The card. */
220/* A text-style modifier only — does NOT force display, so `flex`/`hidden`/`md:inline`
221 utilities on the same element still win (a prior `display:block` here silently
222 broke the spine header's flex layout and the objective era-tag's responsive hide). */
223.ew-label {
224 font-size: 11px;
225 text-transform: uppercase;
226 letter-spacing: 0.1em;
227 font-weight: 600;
228 color: var(--ew-mut);
230.ew-label--rule {
231 display: block;
232 padding-bottom: 6px;
233 border-bottom: 1px solid var(--ew-line);
234 margin-bottom: 12px;
236 
237/* 9px status / valence dots — the symbol that replaces every colored fill. */
238.ew-dot {
239 display: inline-block;
240 width: 9px;
241 height: 9px;
242 border-radius: 50%;
243 flex: none;
245.ew-dot--ok {
246 background: var(--ew-ok);
248.ew-dot--warn {
249 background: var(--ew-warn);
251.ew-dot--bad {
252 background: var(--ew-bad);
254.ew-dot--mut {
255 background: var(--ew-faint);
257.ew-dot--accent {
258 background: var(--ew-accent);
260.ew-dot--hollow {
261 background: transparent;
262 border: 2px solid var(--ew-faint);
264 
265/* One serif voice — titles + Chronicle prose ONLY (never UI labels or numerals). */
266.ew-serif {
267 font-family: var(--ew-serif);
269 
270/* Inputs: 1px edge rule, ~2px radius, accent focus outline. No shadow, no chrome. */
271.ew-input {
272 width: 100%;
273 background: var(--ew-bg);
274 color: var(--ew-fg);
275 border: 1px solid var(--ew-edge);
276 border-radius: 2px;
277 padding: 9px 11px;
278 font: inherit;
279 /* Engraved double-rule — the composer reads as a framed note (echoes the die). */
280 box-shadow:
281 inset 0 0 0 3px var(--ew-bg),
282 inset 0 0 0 4px var(--ew-edge);
284.ew-input::placeholder {
285 color: var(--ew-faint);
287.ew-input:focus,
288.ew-input:focus-visible {
289 outline: 2px solid var(--ew-accent);
290 outline-offset: -1px;
291 border-color: transparent;
293 
294/* Selects: the input hairline without the composer's engraved double-rule, native
295 OS caret kept (theme-aware, accessible). Used by the objective steer dials. */
296.ew-select {
297 width: 100%;
298 background: var(--ew-bg);
299 color: var(--ew-fg);
300 border: 1px solid var(--ew-edge);
301 border-radius: 2px;
302 padding: 8px 11px;
303 font: inherit;
304 cursor: pointer;
306.ew-select:focus,
307.ew-select:focus-visible {
308 outline: 2px solid var(--ew-accent);
309 outline-offset: -1px;
310 border-color: transparent;
312 
313/* Buttons: text-forward, edge-ruled; primary fills with the one accent. */
314.ew-btn {
315 font: inherit;
316 font-weight: 600;
317 color: var(--ew-fg);
318 background: transparent;
319 border: 1px solid var(--ew-edge);
320 border-radius: 2px;
321 padding: 6px 14px;
322 cursor: pointer;
323 transition:
324 border-color var(--ew-dur-1) var(--ew-ease),
325 color var(--ew-dur-1) var(--ew-ease),
326 background var(--ew-dur-1) var(--ew-ease),
327 opacity var(--ew-dur-1) var(--ew-ease),
328 transform var(--ew-dur-1) var(--ew-ease);
330.ew-btn:hover:not(:disabled) {
331 border-color: var(--ew-fg);
333.ew-btn:active:not(:disabled) {
334 transform: translateY(0.5px) scale(0.975);
336.ew-btn:focus-visible {
337 outline: 2px solid var(--ew-accent);
338 outline-offset: 1px;
340.ew-btn:disabled {
341 opacity: 0.5;
342 cursor: not-allowed;
344.ew-btn--primary {
345 color: #fff;
346 background: var(--ew-accent);
347 border-color: var(--ew-accent);
349.dark .ew-btn--primary {
350 color: #0d0d0d;
352.ew-btn--primary:hover:not(:disabled) {
353 filter: brightness(1.08);
354 border-color: var(--ew-accent);
356/* Disabled primary reads as a flat, inert neutral — NOT a dimmed accent — so the
357 enabled→disabled jump is unmistakable and the resting CTA never looks faded. */
358.ew-btn--primary:disabled {
359 color: var(--ew-faint);
360 background: transparent;
361 border-color: var(--ew-line);
362 opacity: 1;
364 
365/* A real, on-brand focus ring on every bespoke interactive element (the .ew-input
366 handles its own). One accent; never the suppressed default. */
367button:focus-visible,
368a:focus-visible,
369summary:focus-visible,
370[role="button"]:focus-visible,
371[tabindex]:focus-visible {
372 outline: 2px solid var(--ew-accent);
373 outline-offset: 2px;
374 border-radius: 3px;
376 
377/* Invisible 44px hit area for the editorial-tiny icon controls (padding, not size). */
378.ew-tap {
379 display: inline-flex;
380 align-items: center;
381 justify-content: center;
382 min-width: 40px;
383 min-height: 40px;
385 
386/* Ruled "fill-in" field — a single rule like a blank on a ledger form / letter.
387 Used for the short text inputs; the multi-line composer keeps a boxed surface. */
388.ew-field {
389 width: 100%;
390 background: transparent;
391 color: var(--ew-fg);
392 border: 0;
393 border-bottom: 2px solid var(--ew-edge);
394 border-radius: 0;
395 padding: 6px 2px;
396 font: inherit;
397 transition:
398 border-color var(--ew-dur-1) var(--ew-ease),
399 background-color var(--ew-dur-1) var(--ew-ease);
401.ew-field::placeholder {
402 color: var(--ew-faint);
404.ew-field:focus,
405.ew-field:focus-visible {
406 outline: none;
407 border-bottom-color: var(--ew-accent);
408 background: var(--ew-tint);
410.ew-field:disabled {
411 color: var(--ew-faint);
412 border-bottom-color: var(--ew-line); /* edge→line drop reads as paused */
413 cursor: not-allowed;
415 
416/* Mobile Safari zooms the viewport when a focused text control's font-size is
417 under 16px (both .ew-field and .ew-input inherit the body's 15px). Pin them to
418 16px on touch devices so tapping a field no longer zooms the page in — without
419 touching the viewport meta (disabling pinch-zoom would be an a11y regression).
420 Desktop keeps the smaller editorial size. */
421@media (pointer: coarse) {
422 .ew-field,
423 .ew-input {
424 font-size: 16px;
425 }
427 
428/* A mounted card-stock surface — a filing card with an engraved inner rule. */
429.ew-card {
430 background: var(--ew-tint);
431 border: 1px solid var(--ew-line);
432 border-radius: 3px;
433 box-shadow:
434 inset 0 0 0 3px var(--ew-tint),
435 inset 0 0 0 4px var(--ew-line);
437 
438/* An illuminated initial for the Chronicle prose — the one ornament a hand-written
439 history book earns. Applies only to the first paragraph of a telling. */
440[data-testid="chronicle-body"] p:first-of-type::first-letter,
441[data-testid="end-chronicle-body"] p:first-of-type::first-letter {
442 font-family: var(--ew-serif);
443 font-size: 2.8em;
444 line-height: 0.78;
445 float: left;
446 margin: 0.04em 0.09em 0 0;
447 color: var(--ew-accent);
448 font-weight: 600;
450 
451/* The lifetime slider as a timeline playhead: a thin ruled track + a terracotta
452 marker bar (not a default round handle). */
453input[type="range"] {
454 -webkit-appearance: none;
455 appearance: none;
456 width: 100%;
457 height: 18px;
458 background: transparent;
459 cursor: pointer;
461input[type="range"]::-webkit-slider-runnable-track {
462 height: 2px;
463 background: var(--ew-gauge);
465input[type="range"]::-moz-range-track {
466 height: 2px;
467 background: var(--ew-gauge);
469input[type="range"]:disabled {
470 opacity: 0.5;
471 cursor: not-allowed;
473input[type="range"]::-webkit-slider-thumb {
474 -webkit-appearance: none;
475 appearance: none;
476 width: 5px;
477 height: 16px;
478 margin-top: -7px;
479 background: var(--ew-accent);
480 border: 0;
481 border-radius: 1.5px;
483input[type="range"]::-moz-range-thumb {
484 width: 5px;
485 height: 16px;
486 background: var(--ew-accent);
487 border: 0;
488 border-radius: 1.5px;
490input[type="range"]:focus-visible {
491 outline: 2px solid var(--ew-accent);
492 outline-offset: 4px;
494 
495/* Thin tracks: the only "bars" (progress, char ammo). Hairline, never a pill.
496 The empty track is the gauge's denominator — it must read on its own. */
497.ew-track {
498 height: 6px;
499 background: var(--ew-gauge);
500 border-radius: 999px;
501 overflow: hidden;
503.ew-track > .ew-fill {
504 height: 100%;
505 background: var(--ew-fg);
506 border-radius: 999px;
507 /* A paper keyline between fill and track, so every heat-ramp hue separates
508 from the gauge ground regardless of its own luminance. */
509 box-shadow: inset 0 0 0 1px var(--ew-bg);
510 transition: width 0.4s var(--ew-ease);
512 
513/* Collapsible rails (progressive disclosure) — ▸/▾ markers, hairline-topped. */
514.ew-rail {
515 border-top: 1px solid var(--ew-line);
516 padding-top: 10px;
518.ew-summary {
519 cursor: pointer;
520 list-style: none;
521 display: flex;
522 align-items: center;
523 gap: 8px;
525.ew-summary::-webkit-details-marker {
526 display: none;
528.ew-summary::before {
529 content: "\25B8";
530 color: var(--ew-faint);
531 font-size: 11px;
532 line-height: 1;
534details[open] > .ew-summary::before {
535 content: "\25BE";
537/* A forced-open rail (desktop Chronicle): the summary is a static label, not a toggle. */
538.ew-summary--static {
539 cursor: default;
540 pointer-events: none;
542.ew-summary--static::before {
543 display: none;
545 
546/* ── Motion & state primitives ─────────────────────────────────────────── */
547 
548/* Tactile press — non-button interactive surfaces (chips, nodes, tabs) give a
549 little under the finger, the same as .ew-btn. Pair with the press target. */
550.ew-press {
551 transition:
552 transform var(--ew-dur-1) var(--ew-ease),
553 background var(--ew-dur-1) var(--ew-ease),
554 border-color var(--ew-dur-1) var(--ew-ease),
555 color var(--ew-dur-1) var(--ew-ease);
557.ew-press:active:not(:disabled) {
558 transform: scale(0.97);
560.ew-press:disabled {
561 opacity: 0.6;
562 cursor: not-allowed;
564 
565/* The one spinner — a struck ring, for every "consulting the record…" wait.
566 Sizes to its font (em), inherits ink, so it drops into any text run. */
567.ew-spinner {
568 display: inline-block;
569 width: 0.9em;
570 height: 0.9em;
571 border: 2px solid currentColor;
572 border-bottom-color: transparent;
573 border-radius: 50%;
574 animation: ew-spin 0.7s linear infinite;
575 vertical-align: -0.12em;
577@keyframes ew-spin {
578 to {
579 transform: rotate(360deg);
580 }
582 
583/* Pending shimmer — a slow ink wash over a block the system is still composing
584 (skeleton lines while the chronicler writes). */
585.ew-shimmer {
586 background: linear-gradient(
587 90deg,
588 var(--ew-line) 20%,
589 var(--ew-hair) 40%,
590 var(--ew-line) 60%
591 );
592 background-size: 200% 100%;
593 animation: ew-shimmer 1.5s ease-in-out infinite;
594 border-radius: 2px;
596@keyframes ew-shimmer {
597 from {
598 background-position: 200% 0;
599 }
600 to {
601 background-position: -200% 0;
602 }
604 
605/* Waiting mode — a thin indeterminate sweep under the masthead while a turn is
606 being resolved: the whole board reads as "history is being rewritten." */
607.ew-loading-bar {
608 position: relative;
609 height: 2px;
610 overflow: hidden;
611 background: var(--ew-line);
613.ew-loading-bar::after {
614 content: "";
615 position: absolute;
616 inset: 0;
617 background: var(--ew-accent);
618 transform-origin: left;
619 animation: ew-load-sweep 1.15s var(--ew-ease) infinite;
621@keyframes ew-load-sweep {
622 0% {
623 transform: translateX(-100%) scaleX(0.35);
624 }
625 55% {
626 transform: translateX(40%) scaleX(0.6);
627 }
628 100% {
629 transform: translateX(160%) scaleX(0.35);
630 }
632 
633/* A quiet inline hint — teaches without nagging; a quiet aside led by a small
634 accent pointer. Muted, not faint: teaching that can't be read teaches nothing. */
635.ew-hint {
636 font-size: var(--ew-fs-note);
637 line-height: 1.4;
638 color: var(--ew-mut);
640.ew-hint::before {
641 content: "\203A\00a0\00a0";
642 color: var(--ew-accent);
643 font-weight: 700;
645 
646/* Sentence-length instructional copy — plain register, never the letterspaced
647 caps label voice (that voice reads as decoration and gets skipped). */
648.ew-prompt {
649 font-size: var(--ew-fs-note);
650 color: var(--ew-mut);
651 text-transform: none;
652 letter-spacing: normal;
654 
655/* Blocked / moderation notice — the honest surface. An oxblood ledger rule and
656 tag, body in readable quiet; never a wash, never the generic error red. */
657.ew-blocked {
658 border-left: 2px solid var(--ew-bad);
659 padding-left: 8px;
660 color: var(--ew-mut);
662.ew-blocked .ew-blocked-tag {
663 font-weight: 700;
664 text-transform: uppercase;
665 letter-spacing: 0.04em;
666 color: var(--ew-bad);
668 
669/* Entrances. panel-in is transform-only so it never fights .ew-dim's opacity, and
670 it replays on display:none→block (the phone tab switch). fade-in is the soft
671 reveal for whole views (board, end screen). */
672.ew-panel {
673 animation: ew-panel-in var(--ew-dur-2) var(--ew-ease);
675@keyframes ew-panel-in {
676 from {
677 transform: translateY(8px);
678 }
679 to {
680 transform: translateY(0);
681 }
683.ew-fade-in {
684 animation: ew-fade-in var(--ew-dur-3) var(--ew-ease) both;
686@keyframes ew-fade-in {
687 from {
688 opacity: 0;
689 transform: translateY(6px);
690 }
691 to {
692 opacity: 1;
693 transform: none;
694 }
696 
697/* Value-change pop — the headline % gives a quick struck-token bump when it moves
698 (the text stays in lockstep with the store; this is purely the flourish). */
699.pct-pop {
700 animation: ew-pct-pop var(--ew-dur-2) var(--ew-ease-spring);
702@keyframes ew-pct-pop {
703 0% {
704 transform: scale(1);
705 }
706 35% {
707 transform: scale(1.14);
708 }
709 100% {
710 transform: scale(1);
711 }
713 
714/* One place that honors reduced-motion for every shared primitive above. */
715@media (prefers-reduced-motion: reduce) {
716 .ew-spinner {
717 animation-duration: 1.4s;
718 }
719 .ew-shimmer,
720 .ew-loading-bar::after,
721 .ew-panel,
722 .ew-fade-in,
723 .pct-pop {
724 animation: none;
725 }
726 .ew-fade-in {
727 opacity: 1;
728 transform: none;
729 }
730 .ew-press:active,
731 .ew-btn:active:not(:disabled) {
732 transform: none;
733 }
734 /* The bar fills jump rather than slide (the width tween is motion too). */
735 .ew-track > .ew-fill {
736 transition: none;
737 }

Shared primitives

The primitive classes pick the new tokens up once, so thirty call sites inherit the fix. .ew-track grounds on --ew-gauge, and its fill gains a 1px paper keyline so any heat-ramp hue separates from the track regardless of its own luminance. .ew-dot--hollow becomes a 2px --ew-faint ring (the mission radio, the picker chips). .ew-dim, the deliberate first-turn quieting, eases from 0.5 to 0.68 — still quiet, no longer sub-legible. Two registers are new: .ew-prompt for sentence-length instructional copy that was being set in the 11px caps-label voice, and .ew-blocked/.ew-blocked-tag, the moderation notice idiom that replaces the app's only off-token color (Tailwind red-300, which measured 1.76:1 on the light paper).

The gauge-grounded track with its keyline; the hint, prompt, and blocked registers.

assets/css/main.css · 738 lines
assets/css/main.css738 lines · CSS
⋯ 492 lines hidden (lines 1–492)
1/**
2 * Main CSS — Everwhen.
3 * Tailwind + Nuxt UI, then the "Spine Console" design tokens: an editorial-minimal
4 * read-model aesthetic. Structure comes from hairlines + whitespace + type, never
5 * from cards / shadows / gradient fills. Semantic color lives in 9px dots + signed
6 * numbers, not box fills.
7 */
8 
9@import "tailwindcss";
10@import "@nuxt/ui";
11 
12/* ── Design tokens ─────────────────────────────────────────────────────── */
13/* Warm "ledger / letterpress" palette: aged paper, sepia ink, hairlines like faint
14 rules, and a single terracotta accent (sealing-wax) for everything interactive.
15 Semantic color is warmed too so a swing reads as ink, never an error state — the
16 accent (bright terracotta) stays distinct from negative valence (deep oxblood). */
17:root {
18 --ew-fg: #2b241c; /* sepia ink — ~12:1 on the cream ground */
19 --ew-mut: #685c49; /* readable quiet — ~5.6:1 */
20 --ew-faint: #7d6f57; /* the contrast floor for readable text — ~4.6:1 */
21 --ew-hair: #c9b99c; /* decorative-only (dot rings, ticks); never load-bearing */
22 --ew-line: #e6dcc8; /* sepia hairline — fold rules and section underlines */
23 --ew-edge: #97876a; /* interactive boundary — buttons, inputs, chips (~3.3:1);
24 structural, unlike the decorative --ew-hair/--ew-line */
25 --ew-gauge: color-mix(
26 in srgb,
27 var(--ew-faint) 85%,
28 var(--ew-bg)
29 ); /* the ground of every track / rail / pip row (~3.4:1) — a gauge whose
30 denominator is invisible reads as no gauge at all */
31 --ew-bg: #faf6ee; /* warm paper */
32 --ew-tint: #f1ebdd; /* a shade deeper paper */
33 --ew-raise: var(
34 --ew-tint
35 ); /* floating-overlay surface (coach mark, notices) */
36 --ew-raise-line: var(--ew-hair); /* its edge */
37 --ew-scrim: color-mix(
38 in srgb,
39 var(--ew-desk-edge) 55%,
40 var(--ew-fg) 45%
41 ); /* warm umber modal backdrop — never raw black */
42 --ew-accent: #b1532e; /* terracotta / sealing wax — the one interactive accent */
43 --ew-ok: #466632; /* muted olive-forest */
44 --ew-warn: #8a5a16; /* gold-ochre — pre-send risk levers only, never outcomes */
45 --ew-bad: #6f231b; /* deep oxblood (distinct from the brighter accent) */
46 
47 /* The /play "desk" — the surface the codex manuscript rests on. A warm parchment
48 vignette (center → edge) so light mode reads warm-and-light instead of a dark
49 well; the cream panel still lifts off it via its own hairline + drop shadow.
50 --ew-desk-ink is the one ink that stays legible directly ON the desk (the /play
51 footer links), flipped per theme so it never needs a :global(html.dark) rule. */
52 --ew-desk: #ddccad; /* desk center — warm parchment-tan */
53 --ew-desk-edge: #bda471; /* desk vignette edge — deeper aged tan (never black) */
54 --ew-desk-ink: #423528; /* readable ink on the desk (footer) — ≥4.5:1 everywhere */
55 --ew-desk-ink-strong: var(--ew-fg); /* hover / emphasis on the desk */
56 --ew-desk-ink-shadow: none; /* dark ink on the light desk needs no lift */
57 
58 --ew-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
59 --ew-serif:
60 "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, ui-serif, serif;
61 
62 /* ── Type ladder ───────────────────────────────────────────────────── */
63 /* Eight steps; every font-size lands on one (two exemptions, commented at
64 their sites: the bespoke 46px grade numeral, and the pointer:coarse 16px
65 input floor — the iOS zoom threshold, never round down). */
66 --ew-fs-micro: 10px; /* dot legends, folio page numbers */
67 --ew-fs-label: 11px; /* the uppercase section label */
68 --ew-fs-meta: 12px; /* datelines, counters, equation terms */
69 --ew-fs-note: 13px; /* hints, teaching reads, instructional prose */
70 --ew-fs-body: 15px; /* body ink */
71 --ew-fs-lede: 18px; /* ledes and pull-lines */
72 --ew-fs-title: 22px; /* leaf headings, headline numerals */
73 --ew-fs-display: 28px; /* the big verdict words */
74 
75 /* ── Motion vocabulary ─────────────────────────────────────────────── */
76 /* One timing language so every animation feels cut from the same cloth:
77 three durations (micro / standard / expressive) and two curves — a gentle
78 settle and the struck-token overshoot the dice & seal already use. */
79 --ew-dur-1: 140ms; /* micro — hovers, presses, color/position shifts */
80 --ew-dur-2: 280ms; /* standard — reveals, fades, fills */
81 --ew-dur-3: 520ms; /* expressive — the dice, the chronicle, node arrivals */
82 --ew-ease: cubic-bezier(0.2, 0.7, 0.3, 1); /* settle (ease-out) */
83 --ew-ease-spring: cubic-bezier(
84 0.2,
85 1.4,
86 0.4,
87 1
88 ); /* the struck-token overshoot */
90 
91.dark {
92 --ew-fg: #ece4d6; /* warm off-white ink */
93 --ew-mut: #b3a892;
94 --ew-faint: #978c77; /* ~6:1 on the warm near-black */
95 --ew-hair: #4a4133;
96 --ew-line: #2f2820;
97 --ew-edge: #80735c; /* interactive boundary (~4:1) */
98 --ew-bg: #14110d; /* warm near-black (brown undertone) */
99 --ew-tint: #1c1813;
100 --ew-raise: #2e2b25; /* floating overlays lift a full step off the page */
101 --ew-raise-line: #5c523f;
102 --ew-scrim: color-mix(in srgb, var(--ew-bg) 55%, var(--ew-desk-edge) 45%);
103 --ew-accent: #db7a4f; /* lifted terracotta */
104 --ew-ok: #90ca7e; /* separable from --ew-bad by luminance, not hue alone */
105 --ew-warn: #d8a34d;
106 --ew-bad: #d95c4e; /* blood red — darker than the burnt-orange accent */
107 
108 /* Dark desk: a warm near-black well (not dead #000) so the lifted card reads
109 against it; light warm ink for the footer, with a soft shadow to seat it. */
110 --ew-desk: #0c0906;
111 --ew-desk-edge: #040302;
112 --ew-desk-ink: #d7cbb2;
113 --ew-desk-ink-strong: #f6efe2;
114 --ew-desk-ink-shadow: 0 1px 2px rgba(0, 0, 0, 0.55);
116 
117body {
118 background: var(--ew-bg);
119 color: var(--ew-fg);
120 font-family:
121 system-ui,
122 -apple-system,
123 "Segoe UI",
124 Roboto,
125 sans-serif;
126 font-size: 15px;
127 line-height: 1.55;
129 
130/* Browser-level chrome, warmed to match the page: text selection + scrollbars. */
131::selection {
132 background: color-mix(in srgb, var(--ew-accent) 24%, transparent);
134* {
135 scrollbar-width: thin;
136 scrollbar-color: var(--ew-hair) transparent;
138::-webkit-scrollbar {
139 width: 11px;
140 height: 11px;
142::-webkit-scrollbar-track {
143 background: transparent;
145::-webkit-scrollbar-thumb {
146 background: var(--ew-hair);
147 border-radius: 999px;
148 border: 3px solid var(--ew-bg);
150::-webkit-scrollbar-thumb:hover {
151 background: var(--ew-faint);
153 
154/* ── Token helper classes (pair with Tailwind layout utilities) ─────────── */
155.ew-fg {
156 color: var(--ew-fg);
158.ew-muted {
159 color: var(--ew-mut);
161.ew-hair-c {
162 color: var(--ew-hair);
164.ew-hover:hover:not(:disabled) {
165 background: var(--ew-tint);
167.ew-hover-fg {
168 color: var(--ew-mut);
170.ew-hover-fg:hover {
171 color: var(--ew-fg);
173/* First-turn quieting of the empty read-model, so the eye lands on the dock.
174 0.68 keeps muted ink at ~3:1 — clearly quieter, never sub-legible. */
175.ew-dim {
176 opacity: 0.68;
177 transition: opacity var(--ew-dur-2) var(--ew-ease);
179@media (prefers-reduced-motion: reduce) {
180 .ew-dim {
181 transition: none;
182 }
184.ew-faint {
185 color: var(--ew-faint);
187.ew-accent {
188 color: var(--ew-accent);
190.ew-bg {
191 background: var(--ew-bg);
193.ew-tint {
194 background: var(--ew-tint);
196.ew-mono {
197 font-family: var(--ew-mono);
199 
200.ew-ok {
201 color: var(--ew-ok);
203.ew-warn {
204 color: var(--ew-warn);
206.ew-bad {
207 color: var(--ew-bad);
209 
210/* Pair with Tailwind border / border-t / border-b / border-r / border-l. */
211.ew-line {
212 border-color: var(--ew-line);
214/* The interactive-boundary counterpart: chips, pickers, pressable rows. */
215.ew-edge {
216 border-color: var(--ew-edge);
218 
219/* A "section" header = an 11px uppercase muted label + a 1px underline. The card. */
220/* A text-style modifier only — does NOT force display, so `flex`/`hidden`/`md:inline`
221 utilities on the same element still win (a prior `display:block` here silently
222 broke the spine header's flex layout and the objective era-tag's responsive hide). */
223.ew-label {
224 font-size: 11px;
225 text-transform: uppercase;
226 letter-spacing: 0.1em;
227 font-weight: 600;
228 color: var(--ew-mut);
230.ew-label--rule {
231 display: block;
232 padding-bottom: 6px;
233 border-bottom: 1px solid var(--ew-line);
234 margin-bottom: 12px;
236 
237/* 9px status / valence dots — the symbol that replaces every colored fill. */
238.ew-dot {
239 display: inline-block;
240 width: 9px;
241 height: 9px;
242 border-radius: 50%;
243 flex: none;
245.ew-dot--ok {
246 background: var(--ew-ok);
248.ew-dot--warn {
249 background: var(--ew-warn);
251.ew-dot--bad {
252 background: var(--ew-bad);
254.ew-dot--mut {
255 background: var(--ew-faint);
257.ew-dot--accent {
258 background: var(--ew-accent);
260.ew-dot--hollow {
261 background: transparent;
262 border: 2px solid var(--ew-faint);
264 
265/* One serif voice — titles + Chronicle prose ONLY (never UI labels or numerals). */
266.ew-serif {
267 font-family: var(--ew-serif);
269 
270/* Inputs: 1px edge rule, ~2px radius, accent focus outline. No shadow, no chrome. */
271.ew-input {
272 width: 100%;
273 background: var(--ew-bg);
274 color: var(--ew-fg);
275 border: 1px solid var(--ew-edge);
276 border-radius: 2px;
277 padding: 9px 11px;
278 font: inherit;
279 /* Engraved double-rule — the composer reads as a framed note (echoes the die). */
280 box-shadow:
281 inset 0 0 0 3px var(--ew-bg),
282 inset 0 0 0 4px var(--ew-edge);
284.ew-input::placeholder {
285 color: var(--ew-faint);
287.ew-input:focus,
288.ew-input:focus-visible {
289 outline: 2px solid var(--ew-accent);
290 outline-offset: -1px;
291 border-color: transparent;
293 
294/* Selects: the input hairline without the composer's engraved double-rule, native
295 OS caret kept (theme-aware, accessible). Used by the objective steer dials. */
296.ew-select {
297 width: 100%;
298 background: var(--ew-bg);
299 color: var(--ew-fg);
300 border: 1px solid var(--ew-edge);
301 border-radius: 2px;
302 padding: 8px 11px;
303 font: inherit;
304 cursor: pointer;
306.ew-select:focus,
307.ew-select:focus-visible {
308 outline: 2px solid var(--ew-accent);
309 outline-offset: -1px;
310 border-color: transparent;
312 
313/* Buttons: text-forward, edge-ruled; primary fills with the one accent. */
314.ew-btn {
315 font: inherit;
316 font-weight: 600;
317 color: var(--ew-fg);
318 background: transparent;
319 border: 1px solid var(--ew-edge);
320 border-radius: 2px;
321 padding: 6px 14px;
322 cursor: pointer;
323 transition:
324 border-color var(--ew-dur-1) var(--ew-ease),
325 color var(--ew-dur-1) var(--ew-ease),
326 background var(--ew-dur-1) var(--ew-ease),
327 opacity var(--ew-dur-1) var(--ew-ease),
328 transform var(--ew-dur-1) var(--ew-ease);
330.ew-btn:hover:not(:disabled) {
331 border-color: var(--ew-fg);
333.ew-btn:active:not(:disabled) {
334 transform: translateY(0.5px) scale(0.975);
336.ew-btn:focus-visible {
337 outline: 2px solid var(--ew-accent);
338 outline-offset: 1px;
340.ew-btn:disabled {
341 opacity: 0.5;
342 cursor: not-allowed;
344.ew-btn--primary {
345 color: #fff;
346 background: var(--ew-accent);
347 border-color: var(--ew-accent);
349.dark .ew-btn--primary {
350 color: #0d0d0d;
352.ew-btn--primary:hover:not(:disabled) {
353 filter: brightness(1.08);
354 border-color: var(--ew-accent);
356/* Disabled primary reads as a flat, inert neutral — NOT a dimmed accent — so the
357 enabled→disabled jump is unmistakable and the resting CTA never looks faded. */
358.ew-btn--primary:disabled {
359 color: var(--ew-faint);
360 background: transparent;
361 border-color: var(--ew-line);
362 opacity: 1;
364 
365/* A real, on-brand focus ring on every bespoke interactive element (the .ew-input
366 handles its own). One accent; never the suppressed default. */
367button:focus-visible,
368a:focus-visible,
369summary:focus-visible,
370[role="button"]:focus-visible,
371[tabindex]:focus-visible {
372 outline: 2px solid var(--ew-accent);
373 outline-offset: 2px;
374 border-radius: 3px;
376 
377/* Invisible 44px hit area for the editorial-tiny icon controls (padding, not size). */
378.ew-tap {
379 display: inline-flex;
380 align-items: center;
381 justify-content: center;
382 min-width: 40px;
383 min-height: 40px;
385 
386/* Ruled "fill-in" field — a single rule like a blank on a ledger form / letter.
387 Used for the short text inputs; the multi-line composer keeps a boxed surface. */
388.ew-field {
389 width: 100%;
390 background: transparent;
391 color: var(--ew-fg);
392 border: 0;
393 border-bottom: 2px solid var(--ew-edge);
394 border-radius: 0;
395 padding: 6px 2px;
396 font: inherit;
397 transition:
398 border-color var(--ew-dur-1) var(--ew-ease),
399 background-color var(--ew-dur-1) var(--ew-ease);
401.ew-field::placeholder {
402 color: var(--ew-faint);
404.ew-field:focus,
405.ew-field:focus-visible {
406 outline: none;
407 border-bottom-color: var(--ew-accent);
408 background: var(--ew-tint);
410.ew-field:disabled {
411 color: var(--ew-faint);
412 border-bottom-color: var(--ew-line); /* edge→line drop reads as paused */
413 cursor: not-allowed;
415 
416/* Mobile Safari zooms the viewport when a focused text control's font-size is
417 under 16px (both .ew-field and .ew-input inherit the body's 15px). Pin them to
418 16px on touch devices so tapping a field no longer zooms the page in — without
419 touching the viewport meta (disabling pinch-zoom would be an a11y regression).
420 Desktop keeps the smaller editorial size. */
421@media (pointer: coarse) {
422 .ew-field,
423 .ew-input {
424 font-size: 16px;
425 }
427 
428/* A mounted card-stock surface — a filing card with an engraved inner rule. */
429.ew-card {
430 background: var(--ew-tint);
431 border: 1px solid var(--ew-line);
432 border-radius: 3px;
433 box-shadow:
434 inset 0 0 0 3px var(--ew-tint),
435 inset 0 0 0 4px var(--ew-line);
437 
438/* An illuminated initial for the Chronicle prose — the one ornament a hand-written
439 history book earns. Applies only to the first paragraph of a telling. */
440[data-testid="chronicle-body"] p:first-of-type::first-letter,
441[data-testid="end-chronicle-body"] p:first-of-type::first-letter {
442 font-family: var(--ew-serif);
443 font-size: 2.8em;
444 line-height: 0.78;
445 float: left;
446 margin: 0.04em 0.09em 0 0;
447 color: var(--ew-accent);
448 font-weight: 600;
450 
451/* The lifetime slider as a timeline playhead: a thin ruled track + a terracotta
452 marker bar (not a default round handle). */
453input[type="range"] {
454 -webkit-appearance: none;
455 appearance: none;
456 width: 100%;
457 height: 18px;
458 background: transparent;
459 cursor: pointer;
461input[type="range"]::-webkit-slider-runnable-track {
462 height: 2px;
463 background: var(--ew-gauge);
465input[type="range"]::-moz-range-track {
466 height: 2px;
467 background: var(--ew-gauge);
469input[type="range"]:disabled {
470 opacity: 0.5;
471 cursor: not-allowed;
473input[type="range"]::-webkit-slider-thumb {
474 -webkit-appearance: none;
475 appearance: none;
476 width: 5px;
477 height: 16px;
478 margin-top: -7px;
479 background: var(--ew-accent);
480 border: 0;
481 border-radius: 1.5px;
483input[type="range"]::-moz-range-thumb {
484 width: 5px;
485 height: 16px;
486 background: var(--ew-accent);
487 border: 0;
488 border-radius: 1.5px;
490input[type="range"]:focus-visible {
491 outline: 2px solid var(--ew-accent);
492 outline-offset: 4px;
494 
495/* Thin tracks: the only "bars" (progress, char ammo). Hairline, never a pill.
496 The empty track is the gauge's denominator — it must read on its own. */
497.ew-track {
498 height: 6px;
499 background: var(--ew-gauge);
500 border-radius: 999px;
501 overflow: hidden;
503.ew-track > .ew-fill {
504 height: 100%;
505 background: var(--ew-fg);
506 border-radius: 999px;
507 /* A paper keyline between fill and track, so every heat-ramp hue separates
508 from the gauge ground regardless of its own luminance. */
509 box-shadow: inset 0 0 0 1px var(--ew-bg);
510 transition: width 0.4s var(--ew-ease);
512 
⋯ 117 lines hidden (lines 513–629)
513/* Collapsible rails (progressive disclosure) — ▸/▾ markers, hairline-topped. */
514.ew-rail {
515 border-top: 1px solid var(--ew-line);
516 padding-top: 10px;
518.ew-summary {
519 cursor: pointer;
520 list-style: none;
521 display: flex;
522 align-items: center;
523 gap: 8px;
525.ew-summary::-webkit-details-marker {
526 display: none;
528.ew-summary::before {
529 content: "\25B8";
530 color: var(--ew-faint);
531 font-size: 11px;
532 line-height: 1;
534details[open] > .ew-summary::before {
535 content: "\25BE";
537/* A forced-open rail (desktop Chronicle): the summary is a static label, not a toggle. */
538.ew-summary--static {
539 cursor: default;
540 pointer-events: none;
542.ew-summary--static::before {
543 display: none;
545 
546/* ── Motion & state primitives ─────────────────────────────────────────── */
547 
548/* Tactile press — non-button interactive surfaces (chips, nodes, tabs) give a
549 little under the finger, the same as .ew-btn. Pair with the press target. */
550.ew-press {
551 transition:
552 transform var(--ew-dur-1) var(--ew-ease),
553 background var(--ew-dur-1) var(--ew-ease),
554 border-color var(--ew-dur-1) var(--ew-ease),
555 color var(--ew-dur-1) var(--ew-ease);
557.ew-press:active:not(:disabled) {
558 transform: scale(0.97);
560.ew-press:disabled {
561 opacity: 0.6;
562 cursor: not-allowed;
564 
565/* The one spinner — a struck ring, for every "consulting the record…" wait.
566 Sizes to its font (em), inherits ink, so it drops into any text run. */
567.ew-spinner {
568 display: inline-block;
569 width: 0.9em;
570 height: 0.9em;
571 border: 2px solid currentColor;
572 border-bottom-color: transparent;
573 border-radius: 50%;
574 animation: ew-spin 0.7s linear infinite;
575 vertical-align: -0.12em;
577@keyframes ew-spin {
578 to {
579 transform: rotate(360deg);
580 }
582 
583/* Pending shimmer — a slow ink wash over a block the system is still composing
584 (skeleton lines while the chronicler writes). */
585.ew-shimmer {
586 background: linear-gradient(
587 90deg,
588 var(--ew-line) 20%,
589 var(--ew-hair) 40%,
590 var(--ew-line) 60%
591 );
592 background-size: 200% 100%;
593 animation: ew-shimmer 1.5s ease-in-out infinite;
594 border-radius: 2px;
596@keyframes ew-shimmer {
597 from {
598 background-position: 200% 0;
599 }
600 to {
601 background-position: -200% 0;
602 }
604 
605/* Waiting mode — a thin indeterminate sweep under the masthead while a turn is
606 being resolved: the whole board reads as "history is being rewritten." */
607.ew-loading-bar {
608 position: relative;
609 height: 2px;
610 overflow: hidden;
611 background: var(--ew-line);
613.ew-loading-bar::after {
614 content: "";
615 position: absolute;
616 inset: 0;
617 background: var(--ew-accent);
618 transform-origin: left;
619 animation: ew-load-sweep 1.15s var(--ew-ease) infinite;
621@keyframes ew-load-sweep {
622 0% {
623 transform: translateX(-100%) scaleX(0.35);
624 }
625 55% {
626 transform: translateX(40%) scaleX(0.6);
627 }
628 100% {
629 transform: translateX(160%) scaleX(0.35);
630 }
632 
633/* A quiet inline hint — teaches without nagging; a quiet aside led by a small
634 accent pointer. Muted, not faint: teaching that can't be read teaches nothing. */
635.ew-hint {
636 font-size: var(--ew-fs-note);
637 line-height: 1.4;
638 color: var(--ew-mut);
640.ew-hint::before {
641 content: "\203A\00a0\00a0";
642 color: var(--ew-accent);
643 font-weight: 700;
645 
646/* Sentence-length instructional copy — plain register, never the letterspaced
647 caps label voice (that voice reads as decoration and gets skipped). */
648.ew-prompt {
649 font-size: var(--ew-fs-note);
650 color: var(--ew-mut);
651 text-transform: none;
652 letter-spacing: normal;
654 
655/* Blocked / moderation notice — the honest surface. An oxblood ledger rule and
656 tag, body in readable quiet; never a wash, never the generic error red. */
657.ew-blocked {
658 border-left: 2px solid var(--ew-bad);
659 padding-left: 8px;
660 color: var(--ew-mut);
662.ew-blocked .ew-blocked-tag {
663 font-weight: 700;
664 text-transform: uppercase;
665 letter-spacing: 0.04em;
666 color: var(--ew-bad);
668 
669/* Entrances. panel-in is transform-only so it never fights .ew-dim's opacity, and
670 it replays on display:none→block (the phone tab switch). fade-in is the soft
671 reveal for whole views (board, end screen). */
⋯ 67 lines hidden (lines 672–738)
672.ew-panel {
673 animation: ew-panel-in var(--ew-dur-2) var(--ew-ease);
675@keyframes ew-panel-in {
676 from {
677 transform: translateY(8px);
678 }
679 to {
680 transform: translateY(0);
681 }
683.ew-fade-in {
684 animation: ew-fade-in var(--ew-dur-3) var(--ew-ease) both;
686@keyframes ew-fade-in {
687 from {
688 opacity: 0;
689 transform: translateY(6px);
690 }
691 to {
692 opacity: 1;
693 transform: none;
694 }
696 
697/* Value-change pop — the headline % gives a quick struck-token bump when it moves
698 (the text stays in lockstep with the store; this is purely the flourish). */
699.pct-pop {
700 animation: ew-pct-pop var(--ew-dur-2) var(--ew-ease-spring);
702@keyframes ew-pct-pop {
703 0% {
704 transform: scale(1);
705 }
706 35% {
707 transform: scale(1.14);
708 }
709 100% {
710 transform: scale(1);
711 }
713 
714/* One place that honors reduced-motion for every shared primitive above. */
715@media (prefers-reduced-motion: reduce) {
716 .ew-spinner {
717 animation-duration: 1.4s;
718 }
719 .ew-shimmer,
720 .ew-loading-bar::after,
721 .ew-panel,
722 .ew-fade-in,
723 .pct-pop {
724 animation: none;
725 }
726 .ew-fade-in {
727 opacity: 1;
728 transform: none;
729 }
730 .ew-press:active,
731 .ew-btn:active:not(:disabled) {
732 transform: none;
733 }
734 /* The bar fills jump rather than slide (the width tween is motion too). */
735 .ew-track > .ew-fill {
736 transition: none;
737 }

The Record overlay bug — two layers deep

The one genuine rendering bug this pass found: the summonable Record — the player's only always-available view of past and current game state — was being buried under a viewport-sized seal.

Layer one, production: CodexSeal.vue sized its own root at width:100%; height:100% in scoped CSS. Every caller sizes that same element with an equal-specificity scoped class (the record header's 26px .rec-seal, the ripple stamp, the sending letter-seal), and the tie resolved in built-CSS order — sometimes in CodexSeal's favor. The default sizing now sits behind :where(), which has zero specificity, so a caller's size class deterministically wins.

Layer two, dev: the component opened with a template comment. Vue keeps template comments in dev builds, which made the component multi-root — and Vue silently drops attribute fallthrough on multi-root components, so the caller's class="rec-seal" never landed on the svg at all. The comment moved into the script docblock (same fix in RunsBalance.vue, which takes a class from the masthead).

Zero-specificity sizing, the --seal-fill re-ink hook, and the multi-root warning.

components/codex/CodexSeal.vue · 70 lines
components/codex/CodexSeal.vue70 lines · Vue
⋯ 35 lines hidden (lines 1–35)
1<template>
2 <svg class="codex-seal" viewBox="-52 -52 104 104" aria-hidden="true">
3 <g class="s-disc">
4 <circle r="41" />
5 <circle cx="41" cy="0" r="7.9" />
6 <circle cx="37.46" cy="16.68" r="7.9" />
7 <circle cx="27.43" cy="30.47" r="7.9" />
8 <circle cx="12.67" cy="38.99" r="7.9" />
9 <circle cx="-4.29" cy="40.78" r="7.9" />
10 <circle cx="-20.5" cy="35.51" r="7.9" />
11 <circle cx="-33.17" cy="24.1" r="7.9" />
12 <circle cx="-40.1" cy="8.52" r="7.9" />
13 <circle cx="-40.1" cy="-8.52" r="7.9" />
14 <circle cx="-33.17" cy="-24.1" r="7.9" />
15 <circle cx="-20.5" cy="-35.51" r="7.9" />
16 <circle cx="-4.29" cy="-40.78" r="7.9" />
17 <circle cx="12.67" cy="-38.99" r="7.9" />
18 <circle cx="27.43" cy="-30.47" r="7.9" />
19 <circle cx="37.46" cy="-16.68" r="7.9" />
20 </g>
21 <g class="s-cut" fill="none" stroke-linecap="round">
22 <circle r="27" stroke-width="4" />
23 <line x1="-27" y1="0" x2="27" y2="0" stroke-width="3" />
24 <line x1="-23.4" y1="-13.5" x2="23.4" y2="13.5" stroke-width="4" />
25 </g>
26 <g class="s-star">
27 <path
28 d="M0,-8.4 L2.9,-2.9 L8.4,0 L2.9,2.9 L0,8.4 L-2.9,2.9 L-8.4,0 L-2.9,-2.9 Z"
29 transform="translate(0,-27)"
30 />
31 <circle r="4.8" />
32 </g>
33 </svg>
34</template>
35 
36<script setup lang="ts">
37/** The brand astrolabe wax seal, sized by its box. Decorative; callers give the
38 * accessible name. Reused as the "Seal & send" face, the dispatch in transit,
39 * the stamp that lands a change on the Spine, and the end screen's verdict.
40 * Pure presentational. The disc fills with the accent (or --seal-fill); the
41 * astrolabe cuts to the page bg, so it reads as struck wax in light and dark.
42 * NOTE: the template must stay comment-free — a leading template comment makes
43 * the component multi-root in dev, and Vue then drops the caller's class
44 * fallthrough (the .rec-seal/.stamp size classes silently never land). */
45</script>
46 
47<style scoped>
48.codex-seal {
49 display: block;
51/* Zero-specificity sizing default so a caller's own scoped size class (the
52 record header's 26px .rec-seal, the ripple stamp, the sending letter-seal)
53 deterministically wins the cascade — an equal-specificity tie here once
54 rendered the record's seal viewport-sized, burying the overlay. */
55:where(.codex-seal) {
56 width: 100%;
57 height: 100%;
59.s-disc {
60 /* --seal-fill lets a caller re-ink the wax (the disabled send seal, the
61 defeat seal) without a filter that would also kill its drop shadow. */
62 fill: var(--seal-fill, var(--ew-accent));
64.s-cut {
65 stroke: var(--ew-bg);
67.s-star {
68 fill: var(--ew-bg);
70</style>

The climax states its number

"History bends" is the leaf the whole turn builds toward, and its one number — how far this dispatch bent the timeline — had no durable statement. The valence-colored delta chip faded to nothing in 1.25 seconds, timed exactly while the eye was on the type-on headline in the opposite column; what remained was a 12.5px faint-mono equation. The big percentage beside it is the cumulative total, which only coincides with the swing on turn one.

The gauge column now leads with a persistent, sign-classed swing line under a "THIS DISPATCH" label. The equation lifts to readable ink with its final term in the swing's valence (mirrored in the ledger), the chip's dwell stretches to 2.6s with a hold, and the headline and stamp scale up to own the beat.

The persistent swing statement and the one valence ink that drives it.

components/codex/LeafRipple.vue · 297 lines
components/codex/LeafRipple.vue297 lines · Vue
⋯ 35 lines hidden (lines 1–35)
1<template>
2 <!-- VI · History bends. The conductor fires `commitRipple` on entry: the swing
3 applies, the momentum advances, and the change is sealed onto the Spine —
4 so the reused ProgressTracker/MomentumMeter animate here, and the wax seal
5 stamps as the node lands. -->
6 <LeafFrame numeral="VI" name="History bends">
7 <div class="ripple-grid">
8 <div class="headline-col" :style="`--reveal-after:${HEADLINE_TYPE_MS}ms`">
9 <!-- Until the Timeline frame lands, the move is still being weighed (never a
10 blank headline or a stale prior one — sourced from pendingTurn below). -->
11 <div class="headline">
12 <TypeOn
13 v-if="node?.headline"
14 :text="node.headline"
15 :duration-ms="HEADLINE_TYPE_MS"
16 />
17 <span v-else class="weighing" data-testid="ripple-weighing">
18 <span class="weighing-label">The timeline weighs your move</span>
19 <span class="ink-dots" aria-hidden="true"><i /><i /><i /></span>
20 </span>
21 </div>
22 <div v-if="node?.detail" class="detail">{{ node.detail }}</div>
23 <!-- base → final, with the levers that bent it disclosed inline (the same
24 breakdown the thread shows): ⏳ a far reach diffused it, ✒ craft scaled it,
25 ⚡ the wager widened it, ⚑ the stake doubled it — so the codex leaf never
26 shows a swing it can't explain ("+22 → +9%, how?"). -->
27 <div v-if="node" data-testid="swing-equation" class="equation ew-mono">
28 <span>{{
29 "(" + signed(node.baseProgressChange ?? node.progressChange)
30 }}</span>
31 <SymbolHint
32 v-for="tok in equationTokens(node)"
33 :key="tok.kind"
34 :text="tok.hint"
35 >
36 <span :data-testid="`equation-${tok.kind}`">{{ tok.label }}</span>
37 </SymbolHint>
38 <span :class="swingInk">{{
39 "→ " + signed(node.progressChange) + "%)"
40 }}</span>
41 </div>
42 <div class="seal-row" :class="{ landed }">
43 <CodexSeal class="stamp" :class="{ drop: landed }" />
44 <span class="ew-muted node-line"
45 ><span class="ew-mono">{{ node?.era }}</span> ·
46 {{ node?.figureName }} sealed into the record</span
47 >
48 </div>
49 </div>
50 <div class="gauge-col" :class="{ pending: !node }">
51 <!-- The turn's own swing, stated durably and in its valence ink — the
52 floating delta chip alone dies mid-type-on and the cumulative %
53 beside it is NOT this turn's result. -->
54 <div v-if="node" class="swing-line" data-testid="ripple-swing">
55 <span class="ew-label">This dispatch</span>
56 <span class="swing-value ew-mono" :class="swingInk">{{
57 signed(node.progressChange) + "%"
58 }}</span>
59 </div>
60 <ProgressTracker />
61 <MomentumMeter />
62 </div>
63 </div>
64 </LeafFrame>
65</template>
66 
67<script setup lang="ts">
68import { computed, ref, watch } from "vue";
69import { useGameStore } from "~/stores/game";
70import { equationTokens } from "~/utils/swing-equation";
⋯ 24 lines hidden (lines 71–94)
71import LeafFrame from "./LeafFrame.vue";
72import CodexSeal from "./CodexSeal.vue";
73import TypeOn from "./TypeOn.vue";
74 
75// The headline writes itself in over this long (handed to TypeOn). The detail +
76// equation hold, then settle in just as it lands — their reveal delay is bound to this
77// same value via the --reveal-after custom property, so the two can't drift. They're
78// the consequence of the change, so they arrive once it's named, not pre-formed.
79const HEADLINE_TYPE_MS = 800;
80 
81const gameStore = useGameStore();
82// During the reveal the CURRENT turn's ripple lives in pendingTurn (null until the
83// `ripple` frame lands → the weighing beat); once committed it's the last ledger
84// event. Sourcing from pendingTurn first means a fast-reached leaf shows a clean
85// pending state, never a blank or a STALE prior-turn headline.
86const node = computed(() =>
87 gameStore.pendingTurn
88 ? gameStore.pendingTurn.ripple
89 : (gameStore.timelineEvents[gameStore.timelineEvents.length - 1] ?? null),
90);
91const landed = ref(false);
92watch(
93 node,
94 (n) => {
95 if (n) landed.value = true;
96 },
97 { immediate: true },
98);
99const signed = (n: number) => (n >= 0 ? "+" : "") + n;
100// One valence ink for every signed statement of this turn's swing.
101const swingInk = computed(() => {
102 const delta = node.value?.progressChange ?? 0;
103 return delta > 0 ? "ew-ok" : delta < 0 ? "ew-bad" : "ew-muted";
104});
105</script>
106 
107<style scoped>
⋯ 190 lines hidden (lines 108–297)
108.ripple-grid {
109 display: grid;
110 grid-template-columns: 1.25fr 1fr;
111 gap: 30px;
112 flex: 1 1 auto;
113 min-height: 0;
115.headline-col {
116 display: flex;
117 flex-direction: column;
118 justify-content: center;
120.headline {
121 font-family: var(--ew-serif);
122 /* The climax beat owns the largest type on any leaf. */
123 font-size: clamp(26px, 4vw, 34px);
124 line-height: 1.25;
126/* The weighing beat — shown until the Timeline frame lands. */
127.weighing {
128 display: inline-flex;
129 align-items: center;
130 gap: 11px;
131 font-style: italic;
132 color: var(--ew-mut);
134.ink-dots {
135 display: inline-flex;
136 gap: 5px;
138.ink-dots i {
139 width: 6px;
140 height: 6px;
141 border-radius: 50%;
142 background: var(--ew-accent);
143 opacity: 0.3;
144 animation: ink-pulse 1.15s ease-in-out infinite;
146.ink-dots i:nth-child(2) {
147 animation-delay: 0.19s;
149.ink-dots i:nth-child(3) {
150 animation-delay: 0.38s;
152@keyframes ink-pulse {
153 0%,
154 100% {
155 opacity: 0.25;
156 transform: translateY(0);
157 }
158 50% {
159 opacity: 0.9;
160 transform: translateY(-2px);
161 }
163/* The detail + equation hold (the animation's backwards fill keeps them at opacity 0
164 through the delay, so nothing reflows), then rise in just as the headline lands —
165 the delay is bound to the headline's type duration via --reveal-after, the equation
166 a beat behind the detail. A transition, not a type-on: the body simply arrives. */
167.detail {
168 font-size: var(--ew-fs-note);
169 color: var(--ew-mut);
170 margin-top: 8px;
171 animation: reveal-rise var(--ew-dur-3) var(--ew-ease)
172 var(--reveal-after, 800ms) both;
174.equation {
175 margin-top: 16px;
176 font-size: var(--ew-fs-meta);
177 color: var(--ew-mut);
178 display: inline-flex;
179 align-items: center;
180 flex-wrap: wrap;
181 gap: 4px 8px;
182 padding: 7px 12px;
183 border: 1px solid var(--ew-edge);
184 border-radius: 3px;
185 align-self: flex-start;
186 animation: reveal-rise var(--ew-dur-3) var(--ew-ease)
187 calc(var(--reveal-after, 800ms) + 90ms) both;
189@keyframes reveal-rise {
190 from {
191 opacity: 0;
192 transform: translateY(6px);
193 }
194 to {
195 opacity: 1;
196 transform: none;
197 }
199.seal-row {
200 display: flex;
201 align-items: center;
202 gap: 12px;
203 margin-top: 18px;
204 opacity: 0;
205 transition: opacity var(--ew-dur-2) var(--ew-ease);
207.seal-row.landed {
208 opacity: 1;
210.stamp {
211 /* A deliberate hero, not an accident — sized to be the beat's one wax mark. */
212 width: 52px;
213 height: 52px;
214 flex: none;
216.stamp.drop {
217 animation: seal-stamp 520ms var(--ew-ease-spring);
219.node-line {
220 font-size: var(--ew-fs-meta);
222.node-line .ew-mono {
223 color: var(--ew-mut);
225.gauge-col {
226 display: flex;
227 flex-direction: column;
228 justify-content: center;
229 gap: 16px;
230 border-left: 1px solid var(--ew-line);
231 padding-left: 26px;
232 transition: opacity var(--ew-dur-2) var(--ew-ease);
234.swing-line {
235 display: flex;
236 flex-direction: column;
237 gap: 2px;
239.swing-value {
240 font-size: var(--ew-fs-title);
241 font-weight: 800;
242 line-height: 1.1;
244/* Dim the gauge while the swing is still being computed, so its current cumulative %
245 never reads as the (not-yet-known) result; it lifts the instant the node lands.
246 0.75 keeps the live numbers readable for however long the weighing takes. */
247.gauge-col.pending {
248 opacity: 0.75;
250@keyframes seal-stamp {
251 0% {
252 transform: scale(1);
253 }
254 30% {
255 transform: scale(0.9);
256 }
257 60% {
258 transform: scale(1.06);
259 }
260 100% {
261 transform: scale(1);
262 }
264@media (prefers-reduced-motion: reduce) {
265 .seal-row {
266 transition: none;
267 }
268 .stamp.drop {
269 animation: none;
270 }
271 .ink-dots i {
272 animation: none;
273 opacity: 0.5;
274 transform: none;
275 }
276 .gauge-col {
277 transition: none;
278 }
279 /* Headline shows at once too (TypeOn snaps), so the body must not lag behind it. */
280 .detail,
281 .equation {
282 animation: none;
283 }
285@media (max-width: 760px) {
286 .ripple-grid {
287 grid-template-columns: 1fr;
288 gap: 14px;
289 }
290 .gauge-col {
291 border-left: 0;
292 padding-left: 0;
293 border-top: 1px solid var(--ew-line);
294 padding-top: 14px;
295 }
297</style>

Read-length chip dwell; crack ink off the gauge's own base color.

components/ProgressTracker.vue · 303 lines
components/ProgressTracker.vue303 lines · Vue
⋯ 92 lines hidden (lines 1–92)
1<template>
2 <div data-testid="progress-tracker">
3 <div class="flex items-baseline justify-between mb-1.5">
4 <span class="ew-label">Timeline shift</span>
5 <span class="relative inline-flex items-baseline gap-1">
6 <span
7 data-testid="progress-percentage"
8 class="ew-mono text-3xl font-extrabold leading-none"
9 :class="[textClass, { 'pct-pop': popping }]"
10 >
11 {{ gameStore.objectiveProgress }}%
12 </span>
13 <span class="ew-mono text-xs ew-faint">→ 100%</span>
14 <span
15 v-if="delta"
16 :key="delta.id"
17 data-testid="progress-delta"
18 class="delta-chip ew-mono"
19 :class="delta.value > 0 ? 'delta-pos' : 'delta-neg'"
20 >
21 {{ delta.value > 0 ? "+" : "" }}{{ delta.value }}%
22 </span>
23 </span>
24 </div>
25 
26 <div data-testid="progress-bar" class="ew-track" :class="trackClass">
27 <div
28 class="ew-fill"
29 :style="{
30 width: gameStore.objectiveProgress + '%',
31 background: fillColor,
32 }"
33 />
34 </div>
35 
36 <div class="flex items-baseline justify-between gap-2 mt-1.5 flex-wrap">
37 <p
38 data-testid="progress-status"
39 class="text-xs transition-colors duration-300"
40 :class="statusClass"
41 >
42 {{ statusText }}
43 </p>
44 <p
45 v-if="showLedger"
46 data-testid="progress-pace"
47 class="ew-mono text-[11px] ew-faint tabular-nums text-right ml-auto"
48 >
49 {{ need }}% to go · {{ gameStore.remainingMessages }}
50 {{ gameStore.remainingMessages === 1 ? "message" : "messages" }}
51 left<template v-if="paceNote">
52 · <span :class="paceClass">{{ paceNote }}</span></template
53 >
54 </p>
55 </div>
56 
57 <!-- The gauge's most important per-turn change, spoken to assistive tech (the visible
58 %/delta/status are not otherwise announced). -->
59 <span class="sr-only" aria-live="polite">{{ liveMessage }}</span>
60 </div>
61</template>
62 
63<script setup lang="ts">
64/**
65 * ProgressTracker — the TIMELINE SHIFT gauge: a big mono %, a 6px hairline track,
66 * and a status line. The drama is the transient ±delta chip that flies off the % on
67 * every change (animation preserved verbatim).
68 */
69import { ref, computed, watch, nextTick, onUnmounted } from "vue";
70import { useGameStore } from "~/stores/game";
71import { MAX_PROGRESS_SWING } from "~/utils/game-config";
72import { SWING_BANDS } from "~/utils/swing-bands";
73import { DiceOutcome } from "~/utils/dice";
74 
75const gameStore = useGameStore();
76 
77const delta = ref<{ id: number; value: number } | null>(null);
78// The headline % gives a struck-token bump whenever it moves (CSS-only flourish; the
79// rendered number is always the live store value, so it never lags behind a change).
80const popping = ref(false);
81// A polite spoken summary of each shift for screen-reader users.
82const liveMessage = ref("");
83let clearTimer: ReturnType<typeof setTimeout> | null = null;
84let popTimer: ReturnType<typeof setTimeout> | null = null;
85 
86watch(
87 () => gameStore.objectiveProgress,
88 (now, was) => {
89 const change = now - was;
90 if (!change) return;
91 delta.value = { id: Date.now() + Math.random(), value: change };
92 liveMessage.value = `Timeline shift ${now} percent, ${change > 0 ? "up" : "down"} ${Math.abs(change)}. ${statusText.value}`;
93 if (clearTimer) clearTimeout(clearTimer);
94 // Read-length dwell: the chip must survive the eye's trip back from the
95 // type-on headline (it used to die in 1.25s, mid-glance).
96 clearTimer = setTimeout(() => {
97 delta.value = null;
98 }, 2700);
99 // Restart the bump cleanly even if it was mid-play (false → render → true).
⋯ 31 lines hidden (lines 100–130)
100 popping.value = false;
101 if (popTimer) clearTimeout(popTimer);
102 void nextTick(() => {
103 popping.value = true;
104 popTimer = setTimeout(() => {
105 popping.value = false;
106 }, 360);
107 });
108 },
109);
110 
111onUnmounted(() => {
112 if (clearTimer) clearTimeout(clearTimer);
113 if (popTimer) clearTimeout(popTimer);
114});
115 
116// The gauge crescendos with its own copy. Each stage matches a line of statusText, so
117// the meter HEATS as history bends — a faint stir, ochre, then terracotta — and then
118// snaps to a locked, glowing olive the instant the new timeline holds at 100%. Red
119// stays reserved for negative swings (the delta chip); this ramp is the rising heat.
120type Stage = "none" | "crack" | "bend" | "hold" | "brink" | "complete";
121const stage = computed<Stage>(() => {
122 const p = gameStore.objectiveProgress;
123 if (p >= 100) return "complete";
124 if (p >= 75) return "brink";
125 if (p >= 50) return "hold";
126 if (p >= 25) return "bend";
127 if (p > 0) return "crack";
128 return "none";
129});
130 
131const fillColor = computed(
132 () =>
133 ({
134 none: "var(--ew-line)",
135 crack: "var(--ew-mut)",
136 bend: "var(--ew-warn)",
137 hold: "var(--ew-accent)",
138 brink: "var(--ew-accent)",
139 complete: "var(--ew-ok)",
140 })[stage.value],
141);
142 
⋯ 116 lines hidden (lines 143–258)
143// The track itself glows as the gauge nears the goal (a pulsing halo on the brink,
144// a steady one once history holds) — the box-shadow escapes the track's clipped fill.
145const trackClass = computed(() => ({
146 "track-brink": stage.value === "brink",
147 "track-complete": stage.value === "complete",
148}));
149 
150// The big % and the status line escalate in lockstep with the fill.
151const textClass = computed(() =>
152 stage.value === "complete"
153 ? "ew-ok"
154 : stage.value === "brink"
155 ? "ew-accent"
156 : "ew-fg",
157);
158const statusClass = computed(() => {
159 if (gameStore.isLoading) return "ew-accent font-medium";
160 return stage.value === "complete"
161 ? "ew-ok font-semibold"
162 : stage.value === "brink"
163 ? "ew-accent font-medium"
164 : "ew-muted";
165});
166 
167const statusText = computed(() => {
168 // While a turn resolves, the gauge leans in instead of reporting the old, stale
169 // state — so the board never says "unchanged" at the very moment you've acted.
170 if (gameStore.isLoading) return "The timeline trembles — weighing your move…";
171 const p = gameStore.objectiveProgress;
172 if (p === 0) {
173 // A 0% floored AFTER a setback is not a fresh, untouched run — the ledger
174 // tells them apart. A turn that backfires (or nets to nothing) and snaps
175 // back to the start must never read as a no-op (#128).
176 return gameStore.timelineEvents.length > 0
177 ? "History resists — the timeline holds against you"
178 : "History stands unchanged…";
179 }
180 if (p < 25) return "The first cracks appear";
181 if (p < 50) return "History is bending";
182 if (p < 75) return "The new timeline takes hold";
183 if (p < 100) return "On the brink of a new history";
184 return "History rewritten!";
185});
186 
187// The win condition, made plain: how far to the goal and how many messages remain.
188const showLedger = computed(() => gameStore.timelineEvents.length > 0);
189const need = computed(() => Math.max(0, 100 - gameStore.objectiveProgress));
190 
191// The pace, told honestly. With one message left and the win past the per-turn
192// fuse, the note points at the one move that still reaches it (the staked last
193// stand, ±100 — no position is ever mathematically dead). With more messages
194// left a win is always within stacked-crit reach, so the honest pressure signal
195// is the required average: above the Success band's ceiling means critical luck.
196const paceNote = computed(() => {
197 const remaining = gameStore.remainingMessages;
198 if (gameStore.gameStatus !== "playing" || remaining <= 0 || need.value === 0)
199 return "";
200 const perMessage = Math.ceil(need.value / remaining);
201 const ceiling = SWING_BANDS[DiceOutcome.SUCCESS].max;
202 if (remaining === 1 && need.value > MAX_PROGRESS_SWING)
203 return "the last stand can reach it";
204 if (perMessage > ceiling) {
205 // Tease the stake BEFORE the final turn (#137): escaping a staked finale
206 // would take the remaining non-final dispatches to close the gap to within
207 // the per-turn fuse. When even better-than-Success on every one of them
208 // wouldn't get there, the last stand is the likely endgame — name it now so
209 // it isn't an ambush. (Strictly a subset of critical territory.) With a
210 // 50-point fuse and a 100-point meter this clears only at two messages left
211 // — one turn of warning; teasing earlier, when severely behind with more to
212 // play, is the separate "earlier stake" proposal the issue notes, not this.
213 if (
214 remaining > 1 &&
215 (need.value - MAX_PROGRESS_SWING) / (remaining - 1) > ceiling
216 )
217 return `need +${perMessage} apiece — your final dispatch can be staked`;
218 return `need +${perMessage} apiece — critical territory`;
219 }
220 return `need +${perMessage} apiece`;
221});
222const paceClass = computed(() =>
223 paceNote.value.includes("critical") ||
224 paceNote.value.includes("last stand") ||
225 paceNote.value.includes("staked")
226 ? "ew-warn font-semibold"
227 : "ew-faint",
228);
229</script>
230 
231<style scoped>
232/* Crescendo glow — a halo on the track element (its own box-shadow isn't clipped by
233 the fill's overflow). Pulses on the brink, locks steady once history holds. */
234.ew-track {
235 transition: box-shadow var(--ew-dur-2) var(--ew-ease);
237.track-brink {
238 box-shadow: 0 0 10px color-mix(in srgb, var(--ew-accent) 45%, transparent);
239 animation: track-pulse 1.7s ease-in-out infinite;
241.track-complete {
242 box-shadow: 0 0 14px color-mix(in srgb, var(--ew-ok) 60%, transparent);
244@keyframes track-pulse {
245 0%,
246 100% {
247 box-shadow: 0 0 6px color-mix(in srgb, var(--ew-accent) 30%, transparent);
248 }
249 50% {
250 box-shadow: 0 0 13px color-mix(in srgb, var(--ew-accent) 68%, transparent);
251 }
253@media (prefers-reduced-motion: reduce) {
254 .track-brink {
255 animation: none;
256 }
258 
259.delta-chip {
260 position: absolute;
261 right: 0;
262 top: -0.35rem;
263 font-size: 0.8rem;
264 font-weight: 800;
265 white-space: nowrap;
266 pointer-events: none;
267 animation: delta-float 2.6s ease-out forwards;
269.delta-pos {
270 color: var(--ew-ok);
272.delta-neg {
273 color: var(--ew-bad);
275 
276@keyframes delta-float {
277 0% {
278 opacity: 0;
279 transform: translateY(6px) scale(0.9);
280 }
281 10% {
282 opacity: 1;
283 transform: translateY(-2px) scale(1.06);
284 }
285 /* Hold legible through the read, then let go. */
286 78% {
287 opacity: 1;
288 transform: translateY(-4px) scale(1);
289 }
290 100% {
291 opacity: 0;
⋯ 12 lines hidden (lines 292–303)
292 transform: translateY(-20px) scale(1);
293 }
295 
296@media (prefers-reduced-motion: reduce) {
297 .delta-chip {
298 animation: none;
299 opacity: 1;
300 transform: translateY(-18px);
301 }
303</style>

The scoreboard reads at a glance

The masthead is the persistent instrument panel, and during the reveal leaves its pips are the only message-ammo display on screen. The track grounds on --ew-gauge; unspent pips get a 1.5px --ew-faint ring and spent pips fill with quiet ink — informational, not an affordance, so the accent stays reserved for the interactive. The record toggle grows from an unlabeled 15px glyph among three siblings into a labeled 40px pill. And dark mode's --codex-card unifies with the page: the old lightened mix split the masthead strip from the leaves, which is what sank every hairline on it to ~1:1.

The labeled record pill, the dark surface unification, and the pip rings.

pages/play.vue · 1286 lines
pages/play.vue1286 lines · Vue
⋯ 83 lines hidden (lines 1–83)
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 <!-- The record is the game's memory — a labeled pill, not one more
84 anonymous glyph in the utility row. -->
85 <button
86 type="button"
87 class="record-pill"
88 :class="{ on: recordOpen }"
89 data-testid="codex-record-toggle"
90 title="The record"
91 aria-label="Open the record"
92 @click="toggleRecord"
93 >
94<span class="record-word">record</span>
95 </button>
96 <button
97 type="button"
98 class="icon-btn mute-btn"
99 :class="{ muted: audio.muted }"
⋯ 598 lines hidden (lines 100–697)
100 data-testid="mute-toggle"
101 :aria-pressed="!audio.muted"
102 :title="audio.muted ? 'Sound off' : 'Sound on'"
103 :aria-label="
104 audio.muted ? 'Unmute sound effects' : 'Mute sound effects'
105 "
106 @click="toggleMute"
107 >
108
109 </button>
110 <button
111 type="button"
112 class="icon-btn"
113 :aria-label="
114 isDark ? 'Switch to light mode' : 'Switch to dark mode'
115 "
116 @click="toggleDark"
117 >
118
119 </button>
120 <template v-if="gameStore.currentObjective">
121 <span v-if="resetArmed" class="reset-confirm">
122 <span class="ew-warn">abandon this history?</span>
123 <button
124 type="button"
125 class="icon-btn ew-warn"
126 data-testid="reset-confirm"
127 aria-label="Yes — abandon this history"
128 @click="performReset"
129 >
130
131 </button>
132 <button
133 type="button"
134 class="icon-btn"
135 data-testid="reset-cancel"
136 aria-label="Keep playing"
137 @click="disarmReset"
138 >
139
140 </button>
141 </span>
142 <button
143 v-else
144 type="button"
145 class="icon-btn"
146 data-testid="reset-button"
147 title="New timeline"
148 aria-label="New timeline"
149 @click="handleReset"
150 >
151
152 </button>
153 </template>
154 </div>
155 </div>
156 <div v-if="gameStore.currentObjective" class="mast-strip">
157 <div class="prog-thread">
158 <span class="pct ew-mono" data-testid="masthead-progress"
159 >{{ Math.round(gameStore.objectiveProgress) }}%</span
160 >
161 <div class="ew-track" :class="trackClass">
162 <div
163 class="ew-fill"
164 :style="{ width: gameStore.objectiveProgress + '%' }"
165 />
166 </div>
167 <span class="prog-need ew-faint">{{ needLine }}</span>
168 </div>
169 <div class="pips" data-testid="messages-counter" aria-hidden="true">
170 <span
171 v-for="i in TOTAL_MESSAGES"
172 :key="i"
173 class="pip"
174 :class="{ spent: i <= spent }"
175 />
176 </div>
177 </div>
178 <!-- Persistent AI/fiction/adults-only disclosure (ported from the board's
179 footer). Shown during a run on every viewport (the codex foot is hidden
180 on phones, so this rides in the running head instead). -->
181 <p
182 v-if="gameStore.currentObjective"
183 data-testid="footer-disclosure"
184 class="mast-disclosure"
185 >
186 Figures are AI, not real people · replies are AI-generated fiction,
187 not historical fact · for adults (18+)
188 </p>
189 <div
190 v-if="conductor.isSending.value"
191 class="sending-bar"
192 role="status"
193 aria-label="Sending the dispatch"
194 />
195 </header>
196 
197 <!-- The leaf stage — pages turn with a quick settle (within-turn reveal
198 beats) or a 3D page-turn (a new page begins: mission/chronicle →
199 dispatch, end → mission). -->
200 <div id="stage" class="stage" @click="onStageClick">
201 <Transition :name="transitionName">
202 <KeepAlive :include="['LeafDispatch']">
203 <component
204 :is="currentLeaf"
205 v-if="conductor.phase.value !== 'end'"
206 :key="conductor.phase.value"
207 />
208 </KeepAlive>
209 </Transition>
210 <!-- A temporal sheen sweeps each turned page (re-keyed per phase to replay). -->
211 <div
212 :key="'sheen-' + conductor.phase.value"
213 class="stage-sheen"
214 aria-hidden="true"
215 />
216 <EndGameScreen
217 v-if="conductor.phase.value === 'end'"
218 @play-again="performReset"
219 />
220 <RecordOverlay :open="recordOpen" @close="recordOpen = false" />
221 <!-- a11y: a polite live region narrates each reveal beat as the player turns
222 to it, so a screen-reader follows without watching the animation. -->
223 <div class="sr-only" role="status" aria-live="polite">
224 {{ announce }}
225 </div>
226 </div>
227 
228 <!-- Page progression — the primary interaction (explicit-continue model). The
229 reveal plays, then waits here; the player turns the page when ready, with
230 Back to revisit. Shown only on reveal pages (the loader + compose pages
231 drive themselves), hidden under the record overlay. Tap-anywhere + →/Enter
232 stay as accelerators. -->
233 <div v-if="isRevealLeaf && !recordOpen" class="leaf-progress">
234 <button
235 v-if="conductor.canBack.value"
236 type="button"
237 class="prog-btn back"
238 data-testid="codex-back"
239 @click="conductor.back()"
240 >
241 ‹ Back
242 </button>
243 <span v-else aria-hidden="true" />
244 <button
245 type="button"
246 class="prog-btn continue"
247 data-testid="codex-continue"
248 @click="conductor.advance()"
249 >
250 {{ continueLabel }}
251 </button>
252 </div>
253 </div>
254 
255 <SiteFooter variant="desk" full />
256 
257 <!-- Fixed-position overlays — mounted at the desk level (OUTSIDE the
258 overflow-hidden codex, which would clip them): the run-packs sales modal,
259 the one-time fiction notice, the guided first-run coach-mark, and the
260 dev panel. -->
261 <RunPacksModal />
262 <ClientOnly><FictionNotice /></ClientOnly>
263 <ClientOnly><CoachMark /></ClientOnly>
264 <ClientOnly><DevPanel /></ClientOnly>
265 </div>
266</template>
267 
268<script setup lang="ts">
269import { ref, computed, onMounted, onUnmounted, watch } from "vue";
270import { useGameStore } from "~/stores/game";
271import { useChronicleStore } from "~/stores/chronicle";
272import { useEconomyStore } from "~/stores/economy";
273import { useAudioStore } from "~/stores/audio";
274import { useCoachingStore } from "~/stores/coaching";
275import { useLegalStore } from "~/stores/legal";
276import { useGameSoundscape } from "~/composables/useGameSoundscape";
277import {
278 useCodexConductor,
279 CODEX_CONDUCTOR,
280} from "~/composables/useCodexConductor";
281import { CODEX_SOUNDSCAPE } from "~/components/codex/keys";
282import { TOTAL_MESSAGES, MAX_PROGRESS } from "~/utils/game-config";
283import {
284 readAuthReturnParams,
285 hasAuthReturn,
286 finalizeAuthReturn,
287} from "~/utils/auth-return";
288import LeafMission from "~/components/codex/LeafMission.vue";
289import LeafDispatch from "~/components/codex/LeafDispatch.vue";
290import LeafSending from "~/components/codex/LeafSending.vue";
291import LeafJudgment from "~/components/codex/LeafJudgment.vue";
292import LeafDice from "~/components/codex/LeafDice.vue";
293import LeafReply from "~/components/codex/LeafReply.vue";
294import LeafRipple from "~/components/codex/LeafRipple.vue";
295import LeafChronicle from "~/components/codex/LeafChronicle.vue";
296import RecordOverlay from "~/components/codex/RecordOverlay.vue";
297 
298const gameStore = useGameStore();
299const chronicleStore = useChronicleStore();
300const economyStore = useEconomyStore();
301const audio = useAudioStore();
302const soundscape = useGameSoundscape(); // mounted ONCE here — the store-bridge cues
303const conductor = useCodexConductor();
304 
305// Share the conductor + soundscape down to the leaves.
306provide(CODEX_CONDUCTOR, conductor);
307provide(CODEX_SOUNDSCAPE, soundscape);
308 
309const LEAVES = {
310 mission: LeafMission,
311 dispatch: LeafDispatch,
312 sending: LeafSending,
313 judgment: LeafJudgment,
314 dice: LeafDice,
315 reply: LeafReply,
316 ripple: LeafRipple,
317 chronicle: LeafChronicle,
318} as const;
319const currentLeaf = computed(
320 () => LEAVES[conductor.phase.value as keyof typeof LEAVES] ?? null,
321);
322 
323// Leaf transition: a quick settle for the within-turn reveal beats, a 3D page-turn
324// when a NEW page begins (mission/chronicle → dispatch, end → mission). flush:'pre'
325// sets the name before <Transition> reads it for the pending phase change.
326const transitionName = ref<"codex-leaf" | "codex-turn">("codex-leaf");
327watch(
328 () => conductor.phase.value,
329 (to, from) => {
330 const boundary =
331 (to === "dispatch" && (from === "mission" || from === "chronicle")) ||
332 to === "mission";
333 transitionName.value = boundary ? "codex-turn" : "codex-leaf";
334 },
335 { flush: "pre" },
336);
337 
338const spent = computed(() => TOTAL_MESSAGES - gameStore.remainingMessages);
339const trackClass = computed(() => {
340 const p = gameStore.objectiveProgress;
341 return p >= MAX_PROGRESS ? "win" : p >= 75 ? "brink" : "";
342});
343const needLine = computed(() => {
344 const p = Math.round(gameStore.objectiveProgress);
345 return p >= 100
346 ? "history rewritten"
347 : p === 0
348 ? "a history unwritten"
349 : `need +${100 - p}% to win`;
350});
351 
352// ── The record overlay ────────────────────────────────────────────────────
353const recordOpen = ref(false);
354function toggleRecord() {
355 recordOpen.value = !recordOpen.value;
357 
358// A tap anywhere on a reveal leaf turns the page (skips the dwell). Not while the
359// record is open, not on the compose/mission/end pages, and never when the tap
360// landed on a control (so buttons/inputs still work).
361function onStageClick(e: MouseEvent) {
362 if (recordOpen.value) return;
363 const p = conductor.phase.value;
364 if (p === "dispatch" || p === "mission" || p === "sending" || p === "end")
365 return;
366 const t = e.target as HTMLElement;
367 if (t.closest('button, a, input, textarea, select, summary, [role="button"]'))
368 return;
369 // Selecting prose to copy ends in a click on the stage when the drag is released —
370 // don't read that release as a page-turn. A non-collapsed selection means the player
371 // is highlighting (the reply, the chronicle, the ripple), not asking to advance.
372 const sel = typeof window !== "undefined" ? window.getSelection() : null;
373 if (sel && !sel.isCollapsed && sel.toString().trim()) return;
374 conductor.advance();
376 
377// ── Reveal a11y: keyboard parity + screen-reader announcements ─────────────
378const REVEAL_LEAVES = ["judgment", "dice", "reply", "ripple", "chronicle"];
379const isRevealLeaf = computed(() =>
380 REVEAL_LEAVES.includes(conductor.phase.value),
381);
382// The reveal pages own the bottom of the screen with the Continue/Back footer. Flag
383// the body while one shows so the shared CoachMark keeps its card clear of that
384// footer (else its bottom-anchored fallback covers the primary Continue control).
385watch(
386 isRevealLeaf,
387 (v) => {
388 if (typeof document !== "undefined")
389 document.body.classList.toggle("codex-reveal", v);
390 },
391 { immediate: true },
392);
393// The Continue affordance hands the pen back on the last reveal page of a turn.
394const continueLabel = computed(() =>
395 conductor.phase.value === "chronicle" ? "Next dispatch" : "Continue",
396);
397// This turn's resolved AI message (held in pendingTurn during the animated
398// reveal, else the latest committed one) — the source for the announcements.
399const turnMsg = computed(() => gameStore.revealMessage);
400// A polite live region narrates each reveal beat so a screen-reader user follows
401// the auto-advancing flow without watching the animation.
402const announce = computed(() => {
403 const p = conductor.phase.value;
404 const m = turnMsg.value;
405 const node = gameStore.timelineEvents[gameStore.timelineEvents.length - 1];
406 if (p === "judgment" && m?.craft)
407 return `The Judge grades your craft: ${m.craft}${m.rollModifier ? `, ${m.rollModifier > 0 ? "+" : ""}${m.rollModifier} to the roll of fate` : ""}.`;
408 if (p === "dice" && m?.diceRoll != null)
409 return `The dice of fate land on ${m.diceRoll}: ${m.diceOutcome}.`;
410 if (p === "reply" && m) return `${m.figureName || "History"} answers.`;
411 if (p === "ripple" && node)
412 return `History bends. ${node.headline} ${node.progressChange >= 0 ? "Up" : "Down"} ${Math.abs(node.progressChange)} percent — now ${Math.round(gameStore.objectiveProgress)} percent toward the objective.`;
413 if (p === "chronicle")
414 return chronicleStore.chronicle
415 ? `The history, retold: ${chronicleStore.chronicle.title}.`
416 : "The history is being retold.";
417 if (p === "end")
418 return gameStore.gameStatus === "victory"
419 ? "History rewritten — victory."
420 : "The run is over.";
421 return "";
422});
423 
424// Keyboard parity for tap-to-advance: → / Enter / Space turn a reveal page;
425// Escape closes the record. Never hijack a key while a control or the composer
426// holds focus (so typing + buttons keep working).
427function onKeydown(e: KeyboardEvent) {
428 if (recordOpen.value) {
429 if (e.key === "Escape") {
430 recordOpen.value = false;
431 e.preventDefault();
432 }
433 return;
434 }
435 if (!isRevealLeaf.value) return;
436 const ae = document.activeElement as HTMLElement | null;
437 if (
438 ae &&
439 ae.closest(
440 'input, textarea, select, button, a, summary, [role="button"], [contenteditable="true"]',
441 )
442 )
443 return;
444 if (
445 e.key === "ArrowRight" ||
446 e.key === "Enter" ||
447 e.key === " " ||
448 e.key === "Spacebar"
449 ) {
450 e.preventDefault();
451 conductor.advance();
452 }
454onMounted(() => window.addEventListener("keydown", onKeydown));
455onUnmounted(() => {
456 window.removeEventListener("keydown", onKeydown);
457 if (typeof document !== "undefined")
458 document.body.classList.remove("codex-reveal");
459});
460 
461// ── Controls (ported from play.vue) ───────────────────────────────────────
462const colorMode = useColorMode();
463const isDark = computed(() => colorMode.value === "dark");
464function toggleDark() {
465 colorMode.preference = colorMode.value === "dark" ? "light" : "dark";
467 
468function toggleMute() {
469 audio.toggleMute();
470 if (!audio.muted) {
471 soundscape.unlock();
472 soundscape.play("uiTick");
473 }
475 
476// New-timeline reset: two-step confirm once a run is underway, one tap when there's
477// nothing to lose. resetGame bumps the epoch → the conductor snaps back to mission.
478const resetArmed = ref(false);
479let resetTimer: ReturnType<typeof setTimeout> | null = null;
480function handleReset() {
481 // One tap only when there's nothing to lose: no resolved turns AND no contact
482 // chosen (a typed first dispatch is work too — a chosen figure marks it dirty).
483 const composerDirty = !!gameStore.activeFigureName?.trim();
484 if (gameStore.timelineEvents.length === 0 && !composerDirty) {
485 performReset();
486 return;
487 }
488 resetArmed.value = true;
489 if (resetTimer) clearTimeout(resetTimer);
490 resetTimer = setTimeout(() => {
491 resetArmed.value = false;
492 }, 3500);
494function disarmReset() {
495 resetArmed.value = false;
496 if (resetTimer) clearTimeout(resetTimer);
498function performReset() {
499 disarmReset();
500 recordOpen.value = false;
501 gameStore.resetGame();
503 
504// ── Guided first run (issue #60) — adapted to the leaf flow ────────────────
505// The shell is the single orchestration site; the coaching store stays
506// game-agnostic. Beats anchor to live controls on the current leaf. The
507// roll-swing beat keys off the Dice leaf's ARRIVAL — `timelineEvents` now lands
508// later (at the Ripple leaf's commitRipple), so the old length-watch would skip
509// the dice anchor.
510const coaching = useCoachingStore();
511const legal = useLegalStore();
512const autoStartCtx = () => ({
513 hasObjective: !!gameStore.currentObjective,
514 isPlaying: gameStore.gameStatus === "playing",
515 isFirstTurn: gameStore.timelineEvents.length === 0,
516});
517const tourBaseTurns = ref(0);
518watch(
519 () => gameStore.currentObjective,
520 (obj) => {
521 if (obj && !legal.shouldShowNotice) coaching.maybeAutoStart(autoStartCtx());
522 else if (!obj) coaching.stop();
523 },
524);
525// The one-time fiction notice owns the first beat (#330): while it's up the tour
526// holds, and its dismissal re-offers the auto-start it deferred.
527watch(
528 () => legal.shouldShowNotice,
529 (show, was) => {
530 if (was && !show) coaching.maybeAutoStart(autoStartCtx());
531 },
532);
533watch(
534 () => coaching.isRunning,
535 (running) => {
536 if (running) tourBaseTurns.value = gameStore.timelineEvents.length;
537 },
538);
539// Picking who & when clears the first beat (a real grounded contact + a chosen year).
540watch(
541 () => !!gameStore.figureGrounding?.resolved && gameStore.contactWhen != null,
542 (ready) => {
543 if (ready) coaching.advanceFrom("who-when");
544 },
545);
546// The roll & swing beat fires when the Dice leaf is reached.
547watch(
548 () => conductor.phase.value,
549 (p) => {
550 if (coaching.isRunning && (p === "dice" || p === "ripple"))
551 coaching.advanceTo("roll-swing");
552 },
553);
554// A second resolved turn means the loop has plainly landed — an overstaying tour bows out.
555watch(
556 () => gameStore.timelineEvents.length,
557 (n) => {
558 if (coaching.isRunning && n >= tourBaseTurns.value + 2) coaching.complete();
559 },
560);
561 
562// ── Run lifecycle (ported from play.vue) ──────────────────────────────────
563const route = useRoute();
564const router = useRouter();
565const supabase = useSupabaseClient();
566// Readable companion to the httpOnly ew_ref marker — grabbed in setup (a Nuxt composable
567// can't be called after an await in onMounted). Non-empty ⇒ a referral claim is pending.
568const referralPending = useCookie<string | null>("ew_ref_pending");
569onMounted(async () => {
570 audio.hydrate();
571 coaching.hydrate();
572 legal.hydrate();
573 const authReturn = readAuthReturnParams(route.query);
574 if (hasAuthReturn(authReturn)) {
575 const result = await finalizeAuthReturn(authReturn, {
576 settle: async () => {
577 const { data } = await supabase.auth.getSession();
578 return Boolean(data.session && !data.session.user.is_anonymous);
579 },
580 });
581 if (result.status === "error") {
582 console.warn(
583 "[auth] sign-in link could not be finalized:",
584 authReturn.error,
585 authReturn.errorDescription,
586 );
587 }
588 // A signed-in result needs no special-case claim here: the unified self-healing
589 // referral claim below fires for any pending referral once the balance load confirms a
590 // non-anonymous session.
591 await economyStore.loadBalance();
592 router.replace({ query: {} });
593 } else {
594 const purchase = route.query.purchase;
595 if (purchase === "success" || purchase === "cancel") {
596 await economyStore.notePurchaseReturn(purchase);
597 router.replace({ query: {} });
598 if (purchase === "success") {
599 const before = economyStore.runsRemaining;
600 for (let i = 0; i < 4; i++) {
601 await new Promise((r) => setTimeout(r, 1500));
602 await economyStore.loadBalance();
603 if (economyStore.runsRemaining !== before) break;
604 }
605 }
606 } else {
607 await economyStore.loadBalance();
608 }
609 }
610 
611 // Self-healing referral claim (#96): a referred visitor's marker is claimed once they're
612 // signed in. Fire whenever a claim is still pending — the readable hint the /r/ middleware
613 // set beside the httpOnly marker (REFERRAL_PENDING_HINT / "ew_ref_pending") — and the
614 // session is non-anonymous, so a transient failure or a tab closed mid-flow simply retries
615 // on the next /play load rather than stranding the referee's welcome bonus. Idempotent
616 // server-side; the hint clears once the claim reaches a terminal outcome.
617 if (!economyStore.accountAnonymous && referralPending.value) {
618 await economyStore.claimReferral();
619 await economyStore.loadBalance();
620 }
621 // Flush a clickwrap tick parked across the OAuth redirect (#330) — only once a
622 // non-anonymous session exists, so the record keys on the final account id
623 // (email-less OAuth identities still flush). A failed or refused record keeps
624 // the tick parked for a retry later in this tab; the server refuses anonymous
625 // subjects, so a stale gate here can't record junk.
626 if (!economyStore.accountAnonymous) void legal.flushPendingAcceptance();
627 // Resume an in-progress run onto the first dispatch (the conductor lands there).
628 if (!gameStore.currentObjective) await gameStore.resumeDraft();
629 if (!legal.shouldShowNotice) coaching.maybeAutoStart(autoStartCtx());
630});
631 
632useHead({
633 title: "Everwhen — Rewrite History",
634 meta: [
635 {
636 name: "description",
637 content:
638 "Send 160-character messages to anyone in history. A D20 and an AI decide how the timeline bends.",
639 },
640 ],
641});
642</script>
643 
644<style scoped>
645.desk {
646 position: fixed;
647 inset: 0;
648 display: flex;
649 flex-direction: column;
650 align-items: center;
651 justify-content: center;
652 gap: 14px;
653 padding: 20px;
654 /* Warm parchment vignette from the shared desk tokens — flips light/dark on its
655 own (see --ew-desk* in main.css), so no :global(html.dark) desk override here. */
656 background: radial-gradient(
657 120% 110% at 50% 36%,
658 var(--ew-desk) 0%,
659 color-mix(in srgb, var(--ew-desk) 42%, var(--ew-desk-edge)) 62%,
660 var(--ew-desk-edge) 128%
661 );
663.codex {
664 position: relative;
665 width: min(95vw, 1180px);
666 height: min(92vh, 800px);
667 background: var(--codex-card, var(--ew-bg));
668 border: 1px solid var(--codex-card-border, var(--ew-line));
669 border-radius: 4px;
670 box-shadow: var(
671 --codex-card-shadow,
672 0 20px 54px -18px rgba(40, 28, 14, 0.6),
673 0 44px 96px -40px rgba(20, 12, 4, 0.66)
674 );
675 display: flex;
676 flex-direction: column;
677 overflow: hidden;
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 the darker desk so the foreground surface
695 reads clearly against the background (the desk itself flips via the --ew-desk*
696 tokens in main.css). Card + grain stay on inherited custom props — never a
697 property on a :global() selector (see grain). */
698:global(html.dark) {
699 --codex-grain-op: 0.05;
700 --codex-grain-blend: screen;
701 /* One dark surface for the whole manuscript: the leaves paint --ew-bg, so a
702 lightened card here split the masthead strip from the page and sank every
703 hairline on it to ~1:1. Lift off the desk comes from the border + shadow. */
704 --codex-card: var(--ew-bg);
705 --codex-card-border: color-mix(in srgb, var(--ew-line) 50%, #f0e6d4);
706 --codex-card-shadow:
707 inset 0 1px 0 color-mix(in srgb, #f0e6d4 8%, transparent),
⋯ 297 lines hidden (lines 708–1004)
708 0 24px 66px -16px rgba(0, 0, 0, 0.78),
709 0 56px 120px -44px rgba(0, 0, 0, 0.86);
711 
712.masthead {
713 position: relative;
714 z-index: 3;
715 flex: none;
716 padding: 11px 20px 0;
718.mast-row {
719 display: flex;
720 align-items: center;
721 gap: 14px;
723.mast-objective {
724 flex: 1 1 auto;
725 min-width: 0;
727.mast-spacer {
728 flex: 1 1 auto;
730.mast-controls {
731 display: flex;
732 align-items: center;
733 gap: 4px;
735.reset-confirm {
736 display: inline-flex;
737 align-items: center;
738 gap: 4px;
739 font-size: 11px;
741.icon-btn {
742 appearance: none;
743 border: 0;
744 background: transparent;
745 color: var(--ew-faint);
746 width: 32px;
747 height: 32px;
748 border-radius: 4px;
749 cursor: pointer;
750 line-height: 1;
751 font-size: 15px;
752 display: inline-flex;
753 align-items: center;
754 justify-content: center;
755 transition:
756 color var(--ew-dur-1) var(--ew-ease),
757 background var(--ew-dur-1) var(--ew-ease);
759.icon-btn:hover {
760 color: var(--ew-fg);
761 background: var(--ew-tint);
763.icon-btn.on {
764 color: var(--ew-accent);
766.record-pill {
767 appearance: none;
768 display: inline-flex;
769 align-items: center;
770 justify-content: center;
771 gap: 5px;
772 min-height: 40px;
773 padding: 3px 12px;
774 border: 1px solid var(--ew-edge);
775 border-radius: 999px;
776 background: transparent;
777 color: var(--ew-mut);
778 font-size: var(--ew-fs-label);
779 font-weight: 600;
780 text-transform: uppercase;
781 letter-spacing: 0.08em;
782 cursor: pointer;
783 line-height: 1;
784 transition:
785 color var(--ew-dur-1) var(--ew-ease),
786 border-color var(--ew-dur-1) var(--ew-ease),
787 background var(--ew-dur-1) var(--ew-ease);
789.record-pill:hover {
790 color: var(--ew-fg);
791 border-color: var(--ew-fg);
792 background: var(--ew-tint);
794.record-pill.on {
795 color: var(--ew-accent);
796 border-color: var(--ew-accent);
798@media (max-width: 480px) {
799 .record-word {
800 display: none;
801 }
802 .record-pill {
803 min-width: 40px;
804 padding: 3px 8px;
805 font-size: var(--ew-fs-body);
806 }
808 
809/* The mute control keeps a single clean ♪ in both states; when muted, a crisp
810 hairline slash crosses it — a legible "no", where the old combining-overlay
811 glyph just thickened the note. The slash carries a background-colored casing
812 (box-shadow) so it reads as a separate stroke cutting the note, not part of
813 it; the casing tracks the codex card, and the hover tint, so the gap stays
814 clean in both themes. */
815.mute-btn {
816 position: relative;
817 --mute-cut: var(--codex-card, var(--ew-bg));
819.mute-btn:hover {
820 --mute-cut: var(--ew-tint);
822.mute-btn.muted::after {
823 content: "";
824 position: absolute;
825 left: 50%;
826 top: 50%;
827 width: 18px;
828 height: 1.5px;
829 background: currentColor;
830 box-shadow: 0 0 0 1px var(--mute-cut);
831 transform: translate(-50%, -50%) rotate(-45deg);
833 
834/* Reflow the running head below desktop. One line can't hold the wordmark, the
835 objective strip, AND the run gauge + controls: at phone widths the controls
836 overprinted the objective title (near-illegible at 375px), and at tablet widths
837 the title truncated mid-word while "· rewrite history" wrapped. Drop the
838 objective to its own full-width row so it always reads in full; the wordmark
839 keeps the controls company on the row above. The squeeze (with the era shown)
840 reaches ~1024px, so reflow up to there — desktop keeps the single row. */
841@media (max-width: 1024px) {
842 .mast-row {
843 flex-wrap: wrap;
844 row-gap: 8px;
845 }
846 .mast-controls {
847 order: 1;
848 margin-left: auto;
849 }
850 .mast-objective {
851 order: 2;
852 flex-basis: 100%;
853 }
855 
856/* Post-checkout banner — a letterpress strip fixed at the top of the desk. */
857.purchase-banner {
858 position: fixed;
859 top: 12px;
860 left: 50%;
861 transform: translateX(-50%);
862 z-index: 60;
863 display: flex;
864 align-items: center;
865 gap: 9px;
866 max-width: min(92vw, 560px);
867 padding: 9px 12px;
868 font-size: 13px;
869 color: var(--ew-fg);
870 background: var(--ew-bg);
871 border: 1px solid var(--ew-line);
872 border-radius: 4px;
873 box-shadow: 0 10px 28px -10px rgba(20, 12, 4, 0.5);
875.purchase-banner.success {
876 background: var(--ew-tint);
877 border-color: color-mix(in srgb, var(--ew-ok) 40%, var(--ew-line));
879.purchase-banner .pn-text {
880 min-width: 0;
882.purchase-banner .pn-close {
883 appearance: none;
884 border: 0;
885 background: transparent;
886 color: var(--ew-faint);
887 cursor: pointer;
888 line-height: 1;
889 margin-left: auto;
890 flex: none;
892.purchase-banner .pn-close:hover {
893 color: var(--ew-fg);
895 
896/* Blocked-dispatch banner — the moderation block, surfaced like the purchase strip:
897 fixed over the desk so it's unmissable and never shifts the codex. Oxblood-keyed
898 with a left rule, distinct from the neutral purchase notice. */
899.blocked-banner {
900 position: fixed;
901 top: 12px;
902 left: 50%;
903 transform: translateX(-50%);
904 z-index: 61;
905 display: flex;
906 align-items: flex-start;
907 gap: 9px;
908 max-width: min(92vw, 560px);
909 padding: 9px 12px;
910 font-size: 13px;
911 line-height: 1.4;
912 color: var(--ew-fg);
913 background: var(--ew-bg);
914 border: 1px solid color-mix(in srgb, var(--ew-bad) 55%, var(--ew-line));
915 border-left: 3px solid var(--ew-bad);
916 border-radius: 4px;
917 box-shadow: 0 10px 28px -10px rgba(20, 12, 4, 0.5);
919.blocked-banner .bb-tag {
920 flex: none;
921 font-weight: 700;
922 text-transform: uppercase;
923 letter-spacing: 0.04em;
924 color: var(--ew-bad);
926.blocked-banner .bb-text {
927 min-width: 0;
929.blocked-banner .bb-hint {
930 color: var(--ew-mut);
932.blocked-banner .bb-close {
933 appearance: none;
934 border: 0;
935 background: transparent;
936 color: var(--ew-faint);
937 cursor: pointer;
938 line-height: 1;
939 margin-left: auto;
940 flex: none;
942.blocked-banner .bb-close:hover {
943 color: var(--ew-fg);
945 
946/* Persistent disclosure — a slim, muted line in the running head, on every viewport. */
947.mast-disclosure {
948 margin-top: 7px;
949 font-size: var(--ew-fs-label);
950 line-height: 1.3;
951 color: var(--ew-faint);
953 
954.mast-strip {
955 display: flex;
956 align-items: center;
957 gap: 16px;
958 margin-top: 9px;
959 padding-bottom: 9px;
960 border-bottom: 1px solid var(--ew-line);
962.prog-thread {
963 flex: 1 1 auto;
964 display: flex;
965 align-items: center;
966 gap: 10px;
967 min-width: 0;
969.pct {
970 font-size: var(--ew-fs-note);
971 font-weight: 700;
972 color: var(--ew-fg);
973 min-width: 34px;
975.ew-track {
976 position: relative;
977 flex: 1 1 auto;
978 height: 5px;
979 background: var(--ew-gauge);
980 border-radius: 999px;
981 overflow: hidden;
983.ew-track .ew-fill {
984 position: absolute;
985 left: 0;
986 top: 0;
987 bottom: 0;
988 background: var(--ew-fg);
989 border-radius: 999px;
990 box-shadow: inset 0 0 0 1px var(--ew-bg);
991 transition:
992 width var(--ew-dur-3) var(--ew-ease),
993 background var(--ew-dur-2) var(--ew-ease);
995.ew-track.brink .ew-fill {
996 background: var(--ew-accent);
997 box-shadow: 0 0 10px color-mix(in srgb, var(--ew-accent) 60%, transparent);
999.ew-track.win .ew-fill {
1000 background: var(--ew-ok);
1001 box-shadow: 0 0 10px color-mix(in srgb, var(--ew-ok) 60%, transparent);
1003.prog-need {
1004 font-size: var(--ew-fs-label);
1005 color: var(--ew-mut);
1006 white-space: nowrap;
1008.pips {
1009 display: flex;
1010 align-items: center;
1011 gap: 6px;
1013.pip {
1014 width: 9px;
1015 height: 9px;
1016 border-radius: 50%;
1017 border: 1.5px solid var(--ew-faint);
1018 background: transparent;
1019 transition:
1020 background var(--ew-dur-2) var(--ew-ease),
1021 border-color var(--ew-dur-2) var(--ew-ease);
1023/* Spent pips fill with quiet ink — informational, not an affordance; the
1024 accent stays reserved for the interactive. */
1025.pip.spent {
1026 background: var(--ew-mut);
1027 border-color: var(--ew-mut);
1029.sending-bar {
1030 position: relative;
1031 height: 2px;
1032 overflow: hidden;
⋯ 254 lines hidden (lines 1033–1286)
1033 background: var(--ew-line);
1035.sending-bar::after {
1036 content: "";
1037 position: absolute;
1038 inset: 0;
1039 background: var(--ew-accent);
1040 transform-origin: left;
1041 animation: send-sweep 1.15s var(--ew-ease) infinite;
1043@keyframes send-sweep {
1044 0% {
1045 transform: translateX(-100%) scaleX(0.35);
1047 55% {
1048 transform: translateX(40%) scaleX(0.6);
1050 100% {
1051 transform: translateX(160%) scaleX(0.35);
1055.stage {
1056 position: relative;
1057 z-index: 1;
1058 flex: 1 1 auto;
1059 min-height: 0;
1060 perspective: 2200px;
1061 perspective-origin: 0% 50%;
1063/* The temporal sheen that sweeps each turned page (re-keyed per phase to replay). */
1064.stage-sheen {
1065 position: absolute;
1066 inset: 0;
1067 pointer-events: none;
1068 z-index: 6;
1069 opacity: 0;
1070 background: linear-gradient(
1071 105deg,
1072 transparent 34%,
1073 color-mix(in srgb, var(--ew-accent) 13%, transparent) 50%,
1074 transparent 66%
1075 );
1076 background-size: 280% 100%;
1077 background-position: 180% 0;
1078 animation: codex-sheen var(--ew-dur-3) var(--ew-ease);
1080@keyframes codex-sheen {
1081 0% {
1082 background-position: 180% 0;
1083 opacity: 0.7;
1085 100% {
1086 background-position: -120% 0;
1087 opacity: 0;
1090.sr-only {
1091 position: absolute;
1092 width: 1px;
1093 height: 1px;
1094 padding: 0;
1095 margin: -1px;
1096 overflow: hidden;
1097 clip: rect(0, 0, 0, 0);
1098 white-space: nowrap;
1099 border: 0;
1101/* Page-progression footer — Back (revisit) + Continue (turn the page). flex:none
1102 below the stage, so it never overlaps the leaf body. */
1103.leaf-progress {
1104 position: relative;
1105 z-index: 3;
1106 flex: none;
1107 display: flex;
1108 align-items: center;
1109 justify-content: space-between;
1110 gap: 12px;
1111 padding: 10px 20px 12px;
1112 border-top: 1px solid var(--ew-line);
1114.prog-btn {
1115 appearance: none;
1116 cursor: pointer;
1117 font: inherit;
1118 line-height: 1;
1119 min-height: 38px;
1120 padding: 9px 16px;
1121 border-radius: 3px;
1122 transition:
1123 color var(--ew-dur-1) var(--ew-ease),
1124 background var(--ew-dur-1) var(--ew-ease),
1125 border-color var(--ew-dur-1) var(--ew-ease);
1127.prog-btn.back {
1128 border: 1px solid var(--ew-line);
1129 background: transparent;
1130 color: var(--ew-mut);
1132.prog-btn.back:hover {
1133 color: var(--ew-fg);
1134 border-color: var(--ew-hair);
1136/* Continue text = the page bg colour, so it reads on the accent in light AND dark
1137 (avoids a :global(html.dark) override — see the grain note above). */
1138.prog-btn.continue {
1139 border: 1px solid var(--ew-accent);
1140 background: var(--ew-accent);
1141 color: var(--ew-bg);
1142 font-weight: 600;
1143 letter-spacing: 0.01em;
1145.prog-btn.continue:hover {
1146 background: color-mix(in srgb, var(--ew-accent) 86%, var(--ew-fg));
1148@media (max-width: 760px) {
1149 .leaf-progress {
1150 padding: 9px 16px 11px;
1152 .prog-btn {
1153 min-height: 44px;
1154 padding: 11px 18px;
1157/* Phone: go full-bleed — a centered card wastes a small screen. The codex fills
1158 the viewport (masthead · stage · progression footer), so the info-dense leaves
1159 get real room instead of a cramped column. */
1160@media (max-width: 640px) {
1161 .desk {
1162 padding: 0;
1163 gap: 0;
1165 .codex {
1166 width: 100vw;
1167 height: 100vh;
1168 height: 100dvh;
1169 max-width: none;
1170 border: 0;
1171 border-radius: 0;
1172 box-shadow: none;
1173 background: var(--ew-bg);
1175 .masthead {
1176 padding: 9px 13px 0;
1178 .mast-row {
1179 gap: 9px;
1181 .mast-controls {
1182 gap: 2px;
1184 .mast-strip {
1185 gap: 12px;
1186 margin-top: 8px;
1187 padding-bottom: 8px;
1189 .leaf-progress {
1190 padding: 10px 14px calc(11px + env(safe-area-inset-bottom));
1192 .prog-btn.continue {
1193 flex: 1 1 auto;
1194 text-align: center;
1197@media (prefers-reduced-motion: reduce) {
1198 .ew-track .ew-fill {
1199 transition: none;
1201 .sending-bar::after {
1202 animation: none;
1203 opacity: 0.6;
1205 .stage-sheen {
1206 animation: none;
1207 opacity: 0;
1210</style>
1212<!-- Leaf transition classes target the leaf components' ROOT elements, which do
1213 NOT carry this page's scope id — so they must be global. Prefixed (codex-leaf /
1214 codex-turn) to avoid collisions. -->
1215<style>
1216/* settle — within-turn reveal beats (III→VII): a quick fade + lift cross-fade.
1217 Overlapping (never mode=out-in, which would add a wait); both leaves go absolute
1218 during the swap so they cross-fade in place. */
1219.codex-leaf-enter-active,
1220.codex-leaf-leave-active {
1221 position: absolute;
1222 inset: 0;
1224.codex-leaf-enter-active {
1225 transition:
1226 opacity var(--ew-dur-2) var(--ew-ease),
1227 transform var(--ew-dur-2) var(--ew-ease);
1229.codex-leaf-leave-active {
1230 transition:
1231 opacity var(--ew-dur-1) var(--ew-ease),
1232 transform var(--ew-dur-1) var(--ew-ease);
1234.codex-leaf-enter-from {
1235 opacity: 0;
1236 transform: translateY(10px) scale(0.994);
1238.codex-leaf-leave-to {
1239 opacity: 0;
1240 transform: translateY(-8px) scale(0.99);
1243/* page-turn — a NEW page begins: the outgoing leaf turns away around the spine
1244 while the fresh page settles in. */
1245.codex-turn-enter-active,
1246.codex-turn-leave-active {
1247 position: absolute;
1248 inset: 0;
1249 transform-origin: left center;
1250 backface-visibility: hidden;
1252.codex-turn-leave-active {
1253 transition:
1254 transform 560ms var(--ew-ease),
1255 opacity 560ms var(--ew-ease);
1256 box-shadow: 40px 0 60px -20px rgba(20, 12, 4, 0.45);
1258.codex-turn-leave-to {
1259 transform: rotateY(-110deg);
1260 opacity: 0.2;
1262.codex-turn-enter-active {
1263 transition:
1264 opacity 380ms var(--ew-ease) 150ms,
1265 transform 380ms var(--ew-ease) 150ms;
1267.codex-turn-enter-from {
1268 opacity: 0;
1269 transform: translateY(8px);
1272@media (prefers-reduced-motion: reduce) {
1273 .codex-leaf-enter-active,
1274 .codex-leaf-leave-active,
1275 .codex-turn-enter-active,
1276 .codex-turn-leave-active {
1277 transition: opacity 120ms linear !important;
1279 .codex-leaf-enter-from,
1280 .codex-leaf-leave-to,
1281 .codex-turn-enter-from,
1282 .codex-turn-leave-to {
1283 transform: none !important;
1286</style>

Honest inks: outcomes, verdicts, and the die

Ochre had been doing double duty as "pre-send caution" and "Failure outcome" — a real loss under-read as advice. The rule is now single-purpose: ochre marks pre-send risk levers only; every outcome that costs you reads oxblood. That sweeps the die face, the outcome line, the dice legend, and both craft-class maps (reckless is oxblood everywhere; vague reads quiet — its tilt carries the sign).

The dice verdict also learned to explain itself. The die face keeps the natural roll (settled decision), but the line beneath now reconciles: 12 ✒+2 → 14 · Success — face 12 beside "Success" on a 14-band read as a contradiction until the tilt is spelled out. The verdict word itself speaks at display scale in the outcome ink, and the legend lights the struck band while the other four rows recede.

The two-tier verdict and the outcome-ink map, Failure now oxblood.

components/DiceRoller.vue · 465 lines
components/DiceRoller.vue465 lines · Vue
⋯ 62 lines hidden (lines 1–62)
1<template>
2 <!-- The D20 as a live focal point: it shakes while fate is undecided, then lands
3 on the NATURAL roll (the face the die actually shows) with an outcome color and
4 a flourish on a critical band. The craft modifier is never folded into the face —
5 it rides alongside as the ✒ complement below, so a 12 ✒+1 reads honestly as the
6 Success it became, and a 20 ✒−2 still shows the 20 you actually rolled. -->
7 <div
8 data-testid="dice-roller"
9 :data-state="state"
10 :data-crit="crit || undefined"
11 class="flex flex-col items-center gap-2 select-none"
12 >
13 <div class="relative">
14 <div class="die" :class="dieClasses" aria-hidden="true">
15 <svg
16 class="die-svg"
17 viewBox="0 0 100 100"
18 preserveAspectRatio="xMidYMid meet"
19 >
20 <!-- Bold outer silhouette: the icosahedral (d20) hexagon. -->
21 <polygon
22 class="die-body"
23 points="26,8.4 74,8.4 98,50 74,91.6 26,91.6 2,50"
24 />
25 <!-- Faint internal facets: the front face triangle + spokes give it depth. -->
26 <g class="die-facets">
27 <polygon points="50,22 28,64 72,64" />
28 <path d="M50,22 L26,8.4" />
29 <path d="M50,22 L74,8.4" />
30 <path d="M28,64 L2,50" />
31 <path d="M28,64 L26,91.6" />
32 <path d="M72,64 L98,50" />
33 <path d="M72,64 L74,91.6" />
34 </g>
35 <text
36 v-if="faceLabel"
37 data-testid="dice-face"
38 class="die-num"
39 x="50"
40 y="51"
41 :font-size="faceFontSize"
42 >
43 {{ faceLabel }}
44 </text>
45 </svg>
46 </div>
47 <span
48 v-if="crit && justLanded"
49 class="crit-ring"
50 :class="crit === 'success' ? 'ring-success' : 'ring-fail'"
51 aria-hidden="true"
52 />
53 </div>
54 
55 <div class="min-h-14 flex flex-col items-center justify-center gap-0.5">
56 <span
57 v-if="state === 'rolling'"
58 class="text-xs uppercase tracking-wider ew-accent"
59 :class="{ 'animate-pulse': !reducedMotion }"
60 >
61 The dice of fate are cast…
62 </span>
63 <span
64 v-else-if="state === 'landed'"
65 data-testid="dice-roller-outcome"
66 class="flex flex-col items-center gap-0.5"
67 :class="outcomeTextClass"
68 >
69 <!-- The verdict speaks at display scale; beneath it the math
70 self-reconciles — face 12 beside "Success" on a 14-band reads as a
71 contradiction until the tilt is spelled out. -->
72 <span class="verdict-word ew-serif">{{ latestRoll?.outcome }}</span>
73 <span
74 v-if="latestRoll?.modifier || crit"
75 class="verdict-math ew-mono font-semibold"
76 >
77 <template v-if="latestRoll?.modifier"
78 >{{ latestRoll?.natural }}{{ signedModifier }}
79 {{ latestRoll?.roll }}</template
80 ><template v-if="crit"
81 ><template v-if="latestRoll?.modifier"> · </template
82 >{{ crit === "success" ? "CRITICAL!" : "CRITICAL FAIL" }}</template
83 >
84 </span>
85 </span>
86 <span v-else class="text-xs ew-faint">A D20 awaits your move</span>
87 </div>
88 
89 <span class="sr-only" aria-live="polite">{{ ariaText }}</span>
90 </div>
⋯ 164 lines hidden (lines 91–254)
91</template>
92 
93<script setup lang="ts">
94/**
95 * DiceRoller — the live drama of the roll. Reads the most recent resolved roll from
96 * the store (no extra state) and the global loading flag: it shakes while a turn is
97 * being resolved, then "lands" on the rolled number when the result arrives.
98 *
99 * The face shows the NATURAL roll — the value the die physically landed on — never the
100 * craft-tilted total. The Judge's modifier is shown as a complement (the ✒ below), and
101 * the outcome color/size still reflect the EFFECTIVE band (what the rules actually judged).
102 */
103import { ref, computed, watch, onUnmounted } from "vue";
104import { useGameStore } from "~/stores/game";
105import { usePrefersReducedMotion } from "~/composables/usePrefersReducedMotion";
106 
107const gameStore = useGameStore();
108const reducedMotion = usePrefersReducedMotion();
109 
110interface Roll {
111 /** The effective (craft-tilted) roll — what the bands judged. Drives outcome/size. */
112 roll: number;
113 /** The die as it actually landed, before the craft modifier — what the face shows. */
114 natural: number;
115 outcome: string;
116 modifier: number;
117 index: number;
119 
120/** The most recent resolved roll across every figure, or null before the first.
121 * `index` identifies the message that carries it, so the landing pop fires only
122 * for a genuinely NEW roll (a failed, refunded send must not replay the old one).
123 * `natural` falls back to the effective roll for legacy messages that predate the
124 * craft-of-message split (and for test fixtures that send only `diceRoll`). */
125const latestRoll = computed<Roll | null>(() => {
126 const history = gameStore.messageHistory;
127 for (let i = history.length - 1; i >= 0; i--) {
128 const m = history[i];
129 if (m.sender === "ai" && typeof m.diceRoll === "number") {
130 return {
131 roll: m.diceRoll,
132 natural: m.naturalRoll ?? m.diceRoll,
133 outcome: m.diceOutcome ?? "",
134 modifier: m.rollModifier ?? 0,
135 index: i,
136 };
137 }
138 }
139 return null;
140});
141 
142const state = computed<"idle" | "rolling" | "landed">(() =>
143 gameStore.isLoading ? "rolling" : latestRoll.value ? "landed" : "idle",
144);
145 
146const face = ref<number | null>(latestRoll.value?.natural ?? null);
147const justLanded = ref(false);
148let landTimer: ReturnType<typeof setTimeout> | null = null;
149let lastLandedIndex = latestRoll.value?.index ?? -1;
150 
151/** The loading→resolved edge is the moment the die lands — but only when a NEW
152 * roll actually arrived. A failed/refunded send also flips loading off, and the
153 * die must simply stop shaking, not replay the previous roll's landing. */
154watch(
155 () => gameStore.isLoading,
156 (loading, was) => {
157 if (loading || !was) return;
158 const latest = latestRoll.value;
159 if (!latest || latest.index === lastLandedIndex) return;
160 lastLandedIndex = latest.index;
161 face.value = latest.natural;
162 if (reducedMotion.value) return;
163 justLanded.value = true;
164 if (landTimer) clearTimeout(landTimer);
165 // A crit holds its tint a beat longer — the one earned flourish. Judged by
166 // OUTCOME (the craft-tilted band), so the celebration always matches the verdict.
167 const dwell = crit.value ? 1150 : 700;
168 landTimer = setTimeout(() => {
169 justLanded.value = false;
170 }, dwell);
171 },
172);
173 
174// Keep the face honest if a roll appears without a loading transition (hydration),
175// and re-arm the landing pop when the history is wiped by a reset.
176watch(latestRoll, (r) => {
177 if (!gameStore.isLoading) face.value = r?.natural ?? null;
178 if (!r) lastLandedIndex = -1;
179});
180 
181onUnmounted(() => {
182 if (landTimer) clearTimeout(landTimer);
183});
184 
185const crit = computed<"success" | "fail" | null>(() => {
186 if (state.value !== "landed" || !latestRoll.value) return null;
187 if (latestRoll.value.outcome === "Critical Success") return "success";
188 if (latestRoll.value.outcome === "Critical Failure") return "fail";
189 return null;
190});
191 
192// The face carries the NATURAL roll once landed; empty (just the shaking shell) while idle
193// or rolling, so the silhouette and motion — not a placeholder glyph — tell the state.
194const faceLabel = computed(() =>
195 state.value === "landed"
196 ? String(face.value ?? latestRoll.value?.natural ?? "")
197 : "",
198);
199 
200// A lone digit can stand large in the front facet; a two-digit roll needs to step down to fit.
201const faceFontSize = computed(() => (faceLabel.value.length >= 2 ? 30 : 40));
202 
203const outcomeTextClass = computed(() => outcomeText(latestRoll.value?.outcome));
204 
205/**
206 * The roll's dramatic weight — the landed die's size scales with it, so a crit reads
207 * as a visibly bigger event than a neutral and the roll lands as a beat, not a number.
208 * (Outcome tier is the cleanest proxy for "how big is this moment".)
209 */
210const magnitude = computed<"crit" | "strong" | "soft">(() => {
211 const o = latestRoll.value?.outcome;
212 if (o === "Critical Success" || o === "Critical Failure") return "crit";
213 if (o === "Success" || o === "Failure") return "strong";
214 return "soft";
215});
216 
217const dieClasses = computed(() => [
218 state.value,
219 justLanded.value ? "just-landed" : "",
220 crit.value ? `crit-${crit.value}` : "",
221 state.value === "landed" ? outcomeDie(latestRoll.value?.outcome) : "",
222 state.value === "landed" ? `mag-${magnitude.value}` : "",
223]);
224 
225/** The Judge's roll modifier as a signed label ("+2" / "-3"), or '' when this roll
226 * carried none — shared by the outcome line and the screen-reader text. */
227const signedModifier = computed(() => {
228 const m = latestRoll.value?.modifier;
229 return m ? `${m > 0 ? "+" : ""}${m}` : "";
230});
231 
232const ariaText = computed(() =>
233 state.value === "rolling"
234 ? "Rolling the dice of fate"
235 : latestRoll.value
236 ? `Rolled ${latestRoll.value.natural} of 20${latestRoll.value.modifier ? ` with a craft ${signedModifier.value} (effective ${latestRoll.value.roll})` : ""}: ${latestRoll.value.outcome}`
237 : "",
238);
239 
240function outcomeDie(outcome?: string): string {
241 switch (outcome) {
242 case "Critical Success":
243 return "face-crit-success";
244 case "Success":
245 return "face-success";
246 case "Neutral":
247 return "face-neutral";
248 case "Failure":
249 return "face-failure";
250 case "Critical Failure":
251 return "face-crit-failure";
252 default:
253 return "face-neutral";
254 }
256// Outcome ink, matched to the die-face tokens (olive ok · oxblood bad)
257// so the label and the struck face read in one warm voice — not a vivid second palette.
258function outcomeText(outcome?: string): string {
259 switch (outcome) {
260 case "Critical Success":
261 return "ew-ok";
262 case "Success":
263 return "ew-ok";
264 case "Neutral":
265 return "ew-muted";
266 case "Failure":
267 return "ew-bad";
268 case "Critical Failure":
269 return "ew-bad";
270 default:
271 return "ew-muted";
272 }
274</script>
275 
⋯ 190 lines hidden (lines 276–465)
276<style scoped>
277.verdict-word {
278 font-size: var(--ew-fs-display);
279 line-height: 1.1;
281.verdict-math {
282 font-size: var(--ew-fs-body);
284.die {
285 position: relative;
286 display: block;
287 width: 4.8rem;
288 height: 4.8rem;
289 color: var(--ew-faint);
290 transition:
291 color var(--ew-dur-2) var(--ew-ease),
292 width var(--ew-dur-2) var(--ew-ease-spring),
293 height var(--ew-dur-2) var(--ew-ease-spring);
295 
296.die-svg {
297 width: 100%;
298 height: 100%;
299 display: block;
300 /* let the stroke and the crit drop-shadow bleed past the viewBox edges */
301 overflow: visible;
303 
304/* Bold silhouette, paper-tinted body. currentColor carries the outcome ink. */
305.die-body {
306 fill: color-mix(in srgb, currentColor 7%, var(--ew-tint));
307 stroke: currentColor;
308 stroke-width: 3;
309 stroke-linejoin: round;
310 transition:
311 fill var(--ew-dur-2) var(--ew-ease),
312 stroke var(--ew-dur-2) var(--ew-ease);
314 
315/* Faint engraved facets — the front face + spokes that make it read as a solid die. */
316.die-facets > * {
317 fill: none;
318 stroke: currentColor;
319 stroke-width: 1.4;
320 stroke-linejoin: round;
321 opacity: 0.4;
323 
324.die-num {
325 fill: currentColor;
326 font-weight: 800;
327 text-anchor: middle;
328 dominant-baseline: central;
330 
331/* Magnitude — the landed die settles INTO a size that matches the roll's weight, so
332 a crit is unmistakably a bigger event than a neutral. Sized (not transform-scaled)
333 so the land/pop/jolt animations compose cleanly on top. */
334.die.landed.mag-soft {
335 width: 4.35rem;
336 height: 4.35rem;
338.die.landed.mag-strong {
339 width: 5.35rem;
340 height: 5.35rem;
342.die.landed.mag-crit {
343 width: 6.3rem;
344 height: 6.3rem;
346 
347/* Idle + rolling base */
348.die.idle {
349 color: var(--ew-faint);
351.die.rolling {
352 color: var(--ew-accent);
353 animation: dice-shake var(--ew-dur-3) linear infinite;
355 
356/* Landed pop */
357.die.just-landed {
358 animation: dice-pop var(--ew-dur-3) var(--ew-ease-spring);
360 
361/* Outcome faces (landed): the outcome carried by stroke + ink + body tint. */
362.face-crit-success {
363 color: var(--ew-ok);
365.face-success {
366 color: var(--ew-ok);
368.face-neutral {
369 color: var(--ew-mut);
371.face-failure {
372 color: var(--ew-bad);
374.face-crit-failure {
375 color: var(--ew-bad);
377 
378/* Crit flourishes — the glow follows the die's silhouette (drop-shadow on the shape,
379 not a box-shadow on the bounding box). */
380.die.crit-success .die-svg {
381 filter: drop-shadow(0 0 4px currentColor)
382 drop-shadow(0 0 14px color-mix(in srgb, currentColor 45%, transparent));
384.die.crit-fail {
385 animation:
386 dice-pop var(--ew-dur-3) var(--ew-ease-spring),
387 dice-jolt var(--ew-dur-3) 0.1s var(--ew-ease);
389.crit-ring {
390 position: absolute;
391 inset: -8px;
392 border-radius: 50%;
393 border: 2px solid;
394 animation: crit-ring 0.7s var(--ew-ease) forwards;
396.ring-success {
397 border-color: var(--ew-ok);
399.ring-fail {
400 border-color: var(--ew-bad);
402 
403@keyframes dice-shake {
404 0%,
405 100% {
406 transform: rotate(0) translateY(0);
407 }
408 25% {
409 transform: rotate(9deg) translateY(-3px);
410 }
411 50% {
412 transform: rotate(-7deg) translateY(2px);
413 }
414 75% {
415 transform: rotate(6deg) translateY(-2px);
416 }
418@keyframes dice-pop {
419 0% {
420 transform: scale(0.6) rotate(-12deg);
421 }
422 60% {
423 transform: scale(1.18);
424 }
425 100% {
426 transform: scale(1);
427 }
429@keyframes dice-jolt {
430 0%,
431 100% {
432 transform: translateX(0);
433 }
434 20% {
435 transform: translateX(-6px);
436 }
437 40% {
438 transform: translateX(6px);
439 }
440 60% {
441 transform: translateX(-4px);
442 }
443 80% {
444 transform: translateX(4px);
445 }
447@keyframes crit-ring {
448 from {
449 opacity: 0.85;
450 transform: scale(0.8);
451 }
452 to {
453 opacity: 0;
454 transform: scale(1.5);
455 }
457 
458@media (prefers-reduced-motion: reduce) {
459 .die,
460 .crit-ring {
461 animation: none !important;
462 transition: none !important;
463 }
465</style>

The struck legend band, and the die scaled to centerpiece size on its own leaf only.

components/codex/LeafDice.vue · 182 lines
components/codex/LeafDice.vue182 lines · Vue
⋯ 8 lines hidden (lines 1–8)
1<template>
2 <!-- IV · The centerpiece. The DiceRoller mounts here in its rolling state (the
3 turn is still `isLoading`); the conductor lands it via `commitReveal` after
4 a wind-up, so the fresh component can't dedupe its own land away. -->
5 <LeafFrame numeral="IV" name="The dice of fate">
6 <div class="center-col">
7 <p class="cue ew-faint">{{ cue }}</p>
8 <div class="die-host"><DiceRoller /></div>
9 <div class="legend" data-testid="roll-legend">
10 <div class="ew-faint legend-title">how fate is weighed</div>
11 <ul class="ew-mono">
12 <!-- Once the roll lands, the struck band reads in its full outcome ink
13 (with its dot); the others recede — the legend answers "which fate
14 was drawn" at a glance instead of five equal rows. -->
15 <li
16 v-for="row in rollLegend"
17 :key="row.label"
18 class="legend-row"
19 :class="
20 landedOutcome
21 ? row.label === landedOutcome
22 ? 'legend-hit'
23 : 'ew-faint'
24 : 'ew-muted'
25 "
26 >
27 <span
28 ><span
29 v-if="row.label === landedOutcome"
30 class="ew-dot legend-dot"
31 :class="row.dot"
32 aria-hidden="true"
33 />{{ row.range }} · {{ row.label }}</span
34 ><span :class="row.label === landedOutcome ? row.cls : ''">{{
35 row.swing
36 }}</span>
37 </li>
38 </ul>
39 <p class="ew-faint note">
40 ✒ craft tilts the roll (±2) · ⚡ anachronism widens the result · ⚑ a
41 staked final dispatch doubles everything
42 </p>
43 </div>
44 </div>
45 </LeafFrame>
46</template>
⋯ 90 lines hidden (lines 47–136)
47 
48<script setup lang="ts">
49import { computed } from "vue";
50import { useGameStore } from "~/stores/game";
51import { SWING_BANDS } from "~/utils/swing-bands";
52import {
53 DiceOutcome,
54 CRIT_FAIL_MAX,
55 FAILURE_MAX,
56 NEUTRAL_MAX,
57 CRIT_SUCCESS_MIN,
58} from "~/utils/dice";
59import LeafFrame from "./LeafFrame.vue";
60 
61const gameStore = useGameStore();
62const reveal = computed(() => gameStore.revealMessage);
63// The struck band, once the die has landed (null while it still tumbles).
64const landedOutcome = computed(() =>
65 gameStore.isLoading ? null : (reveal.value?.diceOutcome ?? null),
66);
67const cue = computed(() => {
68 if (reveal.value?.staked)
69 return "a staked throw — the swing doubles, both ways";
70 const mod = reveal.value?.rollModifier ?? 0;
71 return mod
72 ? `craft has tilted the die ${mod > 0 ? "+" : ""}${mod}`
73 : "how fate is weighed, disclosed below";
74});
75 
76const fmtSwing = (b: { min: number; max: number }) =>
77 `${b.min >= 0 ? "+" : ""}${b.min}${b.max >= 0 ? "+" : ""}${b.max}%`;
78const rollLegend = [
79 {
80 range: `${CRIT_SUCCESS_MIN}–20`,
81 label: "Critical Success",
82 swing: fmtSwing(SWING_BANDS[DiceOutcome.CRITICAL_SUCCESS]),
83 cls: "ew-ok",
84 dot: "ew-dot--ok",
85 },
86 {
87 range: `${NEUTRAL_MAX + 1}${CRIT_SUCCESS_MIN - 1}`,
88 label: "Success",
89 swing: fmtSwing(SWING_BANDS[DiceOutcome.SUCCESS]),
90 cls: "ew-ok",
91 dot: "ew-dot--ok",
92 },
93 {
94 range: `${FAILURE_MAX + 1}${NEUTRAL_MAX}`,
95 label: "Neutral",
96 swing: fmtSwing(SWING_BANDS[DiceOutcome.NEUTRAL]),
97 cls: "ew-muted",
98 dot: "ew-dot--mut",
99 },
100 {
101 range: `${CRIT_FAIL_MAX + 1}${FAILURE_MAX}`,
102 label: "Failure",
103 swing: fmtSwing(SWING_BANDS[DiceOutcome.FAILURE]),
104 cls: "ew-bad",
105 dot: "ew-dot--bad",
106 },
107 {
108 range: `1–${CRIT_FAIL_MAX}`,
109 label: "Critical Failure",
110 swing: fmtSwing(SWING_BANDS[DiceOutcome.CRITICAL_FAILURE]),
111 cls: "ew-bad",
112 dot: "ew-dot--bad",
113 },
114];
115</script>
116 
117<style scoped>
118.center-col {
119 flex: 1 1 auto;
120 min-height: 0;
121 display: flex;
122 flex-direction: column;
123 align-items: center;
124 justify-content: center;
125 text-align: center;
126 gap: 16px;
128.cue {
129 font-size: var(--ew-fs-meta);
131.die-host {
132 display: flex;
133 align-items: center;
134 justify-content: center;
135 min-height: 220px;
137/* The centerpiece earns centerpiece scale on its own leaf (the resting 4.8rem
138 die reads as an icon); the landed mag-* steps scale with it, crit largest.
139 Other DiceRoller mounts keep the component's own sizes. */
140.die-host :deep(.die) {
141 width: 6.5rem;
142 height: 6.5rem;
144.die-host :deep(.die.landed.mag-soft) {
145 width: 5.9rem;
146 height: 5.9rem;
148.die-host :deep(.die.landed.mag-strong) {
149 width: 7.25rem;
150 height: 7.25rem;
152.die-host :deep(.die.landed.mag-crit) {
153 width: 8.5rem;
154 height: 8.5rem;
⋯ 27 lines hidden (lines 156–182)
156.legend {
157 font-size: var(--ew-fs-note);
158 max-width: 340px;
160.legend ul {
161 list-style: none;
162 margin: 6px 0 0;
163 padding: 0;
165.legend-row {
166 display: flex;
167 justify-content: space-between;
168 gap: 12px;
169 transition: color var(--ew-dur-2) var(--ew-ease);
171.legend-row.legend-hit {
172 color: var(--ew-fg);
173 font-weight: 600;
175.legend-dot {
176 margin-right: 6px;
178.note {
179 margin-top: 6px;
180 line-height: 1.4;
182</style>

The judgment tilt, and masterful's tier

The craft chip on the judgment leaf displayed both +2 and −2 in the accent — the scoped rule's color out-specified any utility recolor, so the sign never read. The color now binds in the template (ew-ok / ew-bad by sign) and the scoped rule deliberately carries none. The statement itself re-tiers: a 22px signed number with an 11px caps tail, sitting under the 46px serif grade.

Masterful — the top grade — was visually identical to sharp. It now earns an illuminated first letter and a one-shot expanding ring on reveal; the reduced-motion path skips the ring and lets the static accent initial carry the tier alone.

Sign-classed tilt, the masterful flourish, and its reduced-motion story.

components/codex/LeafJudgment.vue · 161 lines
components/codex/LeafJudgment.vue161 lines · Vue
⋯ 11 lines hidden (lines 1–11)
1<template>
2 <!-- III · The Judge weighs craft — objective-blind, BEFORE the roll. The grade
3 is read from `pendingTurn` (the reveal isn't committed to history until the
4 Dice leaf), falling back to the latest committed message under the inline
5 (reduced-motion / SSR) path. -->
6 <LeafFrame numeral="III" name="The craft is weighed">
7 <div class="center-col">
8 <p class="quill">
9 ✒ The Judge reads your hand — blind to your aim, weighing only how well
10 you wrote.
11 </p>
12 <template v-if="craft">
13 <div
14 class="grade"
15 :class="[craftClass(craft), { masterful: craft === 'masterful' }]"
16 aria-live="polite"
17 >
18 {{ CRAFT_LABEL[craft] }}
19 <span
20 v-if="craft === 'masterful'"
21 class="grade-ring"
22 aria-hidden="true"
23 />
24 </div>
25 <p v-if="craftReason" class="lede reason">{{ craftReason }}</p>
26 <div
27 class="tilt"
28 :class="[
29 { none: !rollModifier },
30 rollModifier > 0 ? 'ew-ok' : rollModifier < 0 ? 'ew-bad' : '',
31 ]"
32 >
33 <template v-if="rollModifier">
34 <span class="tilt-value">{{ signed(rollModifier) }}</span>
35 <span class="tilt-tail">to the roll of fate</span>
36 </template>
37 <template v-else>no tilt — the roll stands on its own</template>
38 </div>
39 </template>
40 </div>
41 </LeafFrame>
42</template>
⋯ 55 lines hidden (lines 43–97)
43 
44<script setup lang="ts">
45import { computed } from "vue";
46import { useGameStore } from "~/stores/game";
47import { CRAFT_LABEL, type Craft } from "~/utils/craft";
48import LeafFrame from "./LeafFrame.vue";
49 
50const gameStore = useGameStore();
51const reveal = computed(() => gameStore.revealMessage);
52const craft = computed<Craft | undefined>(() => reveal.value?.craft);
53const craftReason = computed(() => reveal.value?.craftReason);
54const rollModifier = computed(() => reveal.value?.rollModifier ?? 0);
55 
56function craftClass(c?: Craft): string {
57 // Ochre is reserved for pre-send risk levers; a verdict is an outcome —
58 // reckless reads oxblood, vague reads quiet (its tilt carries the sign).
59 return c === "masterful" || c === "sharp"
60 ? "ew-ok"
61 : c === "reckless"
62 ? "ew-bad"
63 : "ew-muted";
65const signed = (n: number) => (n > 0 ? "+" : "") + n;
66</script>
67 
68<style scoped>
69.center-col {
70 flex: 1 1 auto;
71 min-height: 0;
72 display: flex;
73 flex-direction: column;
74 align-items: center;
75 justify-content: center;
76 text-align: center;
77 gap: 16px;
79.quill {
80 font-family: var(--ew-serif);
81 font-style: italic;
82 font-size: var(--ew-fs-body);
83 color: var(--ew-faint);
84 max-width: 44ch;
86.grade {
87 position: relative;
88 font-family: var(--ew-serif);
89 /* bespoke verdict size — deliberately above the ladder's display step */
90 font-size: 46px;
91 line-height: 1;
93@media (min-width: 1024px) {
94 .grade {
95 font-size: 56px;
96 }
98/* Masterful alone earns flourishes: an illuminated initial plus a one-shot
99 ring echo (the crit-ring pattern from DiceRoller, local copy) — sharp and
100 below stay plain, so the top tier reads as a tier apart. */
101.grade.masterful::first-letter {
102 color: var(--ew-accent);
104.grade-ring {
105 position: absolute;
106 inset: -10px -18px;
107 border-radius: 999px;
108 border: 2px solid var(--ew-accent);
109 animation: grade-ring 0.7s var(--ew-ease) forwards;
111@keyframes grade-ring {
112 from {
113 opacity: 0.85;
114 transform: scale(0.8);
115 }
116 to {
117 opacity: 0;
118 transform: scale(1.5);
119 }
⋯ 8 lines hidden (lines 121–128)
121.lede {
122 font-family: var(--ew-serif);
123 color: var(--ew-mut);
124 font-size: var(--ew-fs-body);
126.reason {
127 max-width: 32ch;
129/* Ink comes from the template (ew-ok / ew-bad by tilt sign), never here —
130 a scoped color would out-specify those utilities. */
131.tilt {
132 display: inline-flex;
133 align-items: baseline;
134 gap: 7px;
135 font-family: var(--ew-mono);
136 font-size: var(--ew-fs-meta);
137 padding: 4px 12px;
138 border: 1px solid var(--ew-edge);
139 border-radius: 999px;
141.tilt.none {
142 color: var(--ew-faint);
144.tilt-value {
145 font-size: var(--ew-fs-title);
146 font-weight: 600;
148.tilt-tail {
149 font-size: var(--ew-fs-label);
150 text-transform: uppercase;
151 letter-spacing: 0.09em;
152 color: var(--ew-mut);
154 
155@media (prefers-reduced-motion: reduce) {
156 /* Ring settles invisible anyway; the static accent initial carries the tier. */
157 .grade-ring {
158 display: none;
159 }
⋯ 1 line hidden (lines 161–161)
161</style>

Teaching surfaces and blocked notices

The dispatch leaf's whole teaching layer — wager, chain, stake, seal cue, turn counter — was one undifferentiated 10–11px faint voice. It lifts to a 13px note register with quiet-ink bodies; the turn counter warns in ochre on the final dispatch (a pre-send risk, so ochre is correct there). The disabled send seal adopts the flat-inert idiom through --seal-fill instead of ghosting, and failed-send errors get an oxblood rule so infra failure stops dressing like decoration.

The four hardcoded red-300 moderation notices in the picker and the Archive move onto .ew-blocked — same layout, token inks, honest at 10:1 instead of invisible at 1.76:1.

The disabled seal: re-inked wax at full opacity, never a ghost; sealing pulses terracotta.

components/codex/LeafDispatch.vue · 914 lines
components/codex/LeafDispatch.vue914 lines · Vue
⋯ 777 lines hidden (lines 1–777)
1<template>
2 <!-- I · Your dispatch — reframed as a LETTER you address, write, and seal (rather
3 than a form of fields). The figure + year are the letterhead (To / dateline);
4 the 160 chars are the letter body; the wager (⚡) + chain (⏳) reads are
5 marginalia by the wax seal; Send is pressing the seal. The recipient picker
6 (with all its grounding nuance), the Archive, and the suggestions are
7 SUMMONABLE references, not always-on fields. Ports play.vue's compose
8 components whole — no mechanic changes — and is kept alive across the reveal
9 leaves (see the shell) so a blocked/failed send returns here with the words
10 intact. -->
11 <LeafFrame numeral="I" name="Your dispatch">
12 <template #sub>
13 <span
14 class="count"
15 :class="gameStore.remainingMessages === 1 ? 'ew-warn' : 'ew-faint'"
16 >Dispatch {{ dispatchOrdinal }} of {{ TOTAL_MESSAGES }}</span
17 >
18 </template>
19 
20 <div class="letter">
21 <!-- Letterhead: the addressee + dateline, as a letter's head. It doubles as the
22 summary of the address fold — open it to choose/refine who &amp; when (the
23 dossier, the Archive study, and the era suggestions all live inside, so
24 they're summoned, not always on). It auto-collapses once the letter is
25 addressed, so the body leads. -->
26 <details class="address" :open="addressOpen" @toggle="onAddressToggle">
27 <summary class="letterhead" data-testid="address-toggle">
28 <span class="lh-fields">
29 <span class="lh-to">
30 <span class="ew-label">To</span>
31 <span
32 class="lh-name ew-serif"
33 :class="{ unset: !addresseeName }"
34 >{{ addresseeName || "whom in history?" }}</span
35 >
36 </span>
37 <span class="lh-date" :class="{ nudge: nudgeAddress }">
38 <span class="lh-year" :class="{ unset: !dateline }">{{
39 dateline || "in what year?"
40 }}</span>
41 <span v-if="contactAge != null" class="ew-faint lh-age">
42 · age {{ contactAge }}</span
43 >
44 </span>
45 </span>
46 <span
47 class="lh-edit"
48 :class="{ open: addressOpen }"
49 aria-hidden="true"
50 >{{
51 addressOpen ? "▴ done addressing" : "✎ change recipient & date ▾"
52 }}</span
53 >
54 </summary>
55 <div class="address-body">
56 <FigurePicker
57 v-model="figure"
58 :disabled="!gameStore.canSendMessage"
59 />
60 </div>
61 </details>
62 
63 <hr class="rule" aria-hidden="true" />
64 
65 <!-- The letter body: a soft salutation (once addressed) over the 160-char composer. -->
66 <div class="body">
67 <p v-if="salutation" class="salutation ew-serif ew-muted">
68 {{ salutation }}
69 </p>
70 <MessageInput ref="messageInputRef" />
71 </div>
72 
73 <!-- The foot of the letter: marginalia (the ⚡ wager + ⏳ chain reads, the last
74 stand) gathered beside the wax seal you press to send. -->
75 <div class="foot">
76 <div class="marginalia">
77 <span
78 v-if="wagerRead"
79 data-testid="wager-chip"
80 class="read"
81 :class="wagerRead.tier === 'in-period' ? 'ew-muted' : 'ew-warn'"
82 >
83 <span aria-hidden="true">{{ wagerRead.pips }}</span>
84 {{ wagerRead.text }}
85 </span>
86 <span v-else data-testid="wager-line" class="read ew-warn"
87 ><span aria-hidden="true"></span> Reaching beyond their era widens
88 the swing — gains gently, losses hard.</span
89 >
90 
91 <!-- The Archive lives here, with the reach it informs: a quiet affordance
92 that unfolds the lookup inline (its "known since" read drives the wager
93 above). Replaces the fold that used to sit orphaned below the seal. -->
94 <button
95 type="button"
96 data-testid="archive-toggle"
97 class="archive-toggle"
98 :class="{ open: lookupOpen }"
99 :aria-expanded="lookupOpen ? 'true' : 'false'"
100 @click="lookupOpen = !lookupOpen"
101 >
102 <span aria-hidden="true" class="at-icon">🔎</span>
103 <span class="at-text">{{
104 lookupOpen ? "Hide the archive" : "Look up an idea"
105 }}</span>
106 <span aria-hidden="true" class="at-caret">{{
107 lookupOpen ? "▾" : "▸"
108 }}</span>
109 </button>
110 <div v-if="lookupOpen" class="archive-inline">
111 <ArchiveLookup bare />
112 </div>
113 
114 <span
115 v-if="chainRead"
116 data-testid="chain-chip"
117 class="read"
118 :class="
119 chainRead.tier === 'at-hinge'
120 ? 'ew-ok'
121 : chainRead.tier === 'diffuse'
122 ? 'ew-warn'
123 : 'ew-muted'
124 "
125 >
126 <span aria-hidden="true"></span> {{ chainRead.text }}
127 </span>
128 
129 <span
130 v-if="chainRead && chainRead.bonus > 1"
131 data-testid="chain-bonus-chip"
132 class="read ew-ok"
133 >
134 <span aria-hidden="true"></span> {{ chainRead.bonusText }}
135 </span>
136 
137 <div
138 v-if="gameStore.canStake"
139 data-testid="stake-control"
140 class="stake"
141 :class="{ armed: stakeArmed }"
142 >
143 <input
144 id="stake-toggle"
145 v-model="stakeArmed"
146 type="checkbox"
147 class="stake-box"
148 :style="{ accentColor: 'var(--ew-accent)' }"
149 />
150 <label for="stake-toggle" class="stake-label">
151 <span class="ew-warn stake-head"
152 ><span aria-hidden="true"></span> Stake the timeline — the last
153 stand</span
154 >
155 <span class="ew-muted stake-body"
156 >Your final swing
157 <span class="ew-warn">doubles, both ways</span>: a hit ≈
158 {{ stakeRead?.hit }}, a miss ≈ {{ stakeRead?.miss }}. You need
159 +{{ stakeRead?.need }}% to win.</span
160 >
161 </label>
162 </div>
163 <div
164 v-else-if="finalReach"
165 data-testid="final-reach"
166 class="final-reach"
167 >
168 <span class="ew-accent" style="font-weight: 600"
169 ><span aria-hidden="true"></span> Final dispatch</span
170 >
171 <span class="ew-muted">
172 — no stake needed: you need +{{ finalReach.need }}%, still inside
173 one dispatch's reach.</span
174 >
175 </div>
176 </div>
177 
178 <div class="seal-foot">
179 <span class="ew-muted seal-cue"
180 >Press the seal to send — one message can bend the whole
181 timeline</span
182 >
183 <button
184 type="button"
185 data-testid="send-button"
186 class="seal-btn"
187 :class="{ staked: stakeArmed, sending: gameStore.isLoading }"
188 :disabled="sealDisabled"
189 aria-label="Send message"
190 @click="onSeal"
191 >
192 <span class="seal-wax"><CodexSeal class="seal-face" /></span>
193 <span class="seal-word ew-mono">{{ sealWord }}</span>
194 </button>
195 </div>
196 
197 <div
198 v-if="gameStore.error"
199 data-testid="error-alert"
200 role="status"
201 aria-live="polite"
202 class="notice"
203 >
204 <span class="ew-label">notice</span>
205 <span class="ew-muted">{{ gameStore.error }}</span>
206 <button
207 type="button"
208 data-testid="error-close"
209 class="ew-hover-fg dismiss"
210 aria-label="Dismiss notice"
211 @click="gameStore.setError(null)"
212 >
213
214 </button>
215 </div>
216 <!-- A content-moderation block is surfaced as a fixed top banner in the
217 shell (pages/play.vue) — unmissable from any leaf — rather than a strip at
218 the foot of this letter, where it was easy to miss after the page turned
219 back. The composer below keeps the player's words for the next attempt. -->
220 </div>
221 </div>
222 </LeafFrame>
223</template>
224 
225<script setup lang="ts">
226import {
227 ref,
228 computed,
229 watch,
230 nextTick,
231 inject,
232 onMounted,
233 onActivated,
234} from "vue";
235import { useGameStore, formatContactYear } from "~/stores/game";
236import { useArchiveStore } from "~/stores/archive";
237import { CODEX_CONDUCTOR } from "~/composables/useCodexConductor";
238import { CODEX_SOUNDSCAPE } from "./keys";
239import { parseKnownSinceYear, wagerTier } from "~/utils/known-since";
240import { CHAIN_HINT } from "~/utils/causal-chain";
241import { ANACHRONISM_LABEL } from "~/utils/anachronism";
242import { SWING_BANDS, STAKE_MULTIPLIER } from "~/utils/swing-bands";
243import { MAX_PROGRESS, TOTAL_MESSAGES } from "~/utils/game-config";
244import { DiceOutcome } from "~/utils/dice";
245import LeafFrame from "./LeafFrame.vue";
246import CodexSeal from "./CodexSeal.vue";
247 
248defineOptions({ name: "LeafDispatch" });
249 
250const gameStore = useGameStore();
251const archiveStore = useArchiveStore();
252const conductor = inject(CODEX_CONDUCTOR)!;
253const soundscape = inject(CODEX_SOUNDSCAPE, null);
254 
255const figure = ref("");
256watch(figure, (name) => gameStore.setActiveFigure((name || "").trim()));
257 
258const messageInputRef = ref<{ message: string; isValid: boolean } | null>(null);
259const messageInputValid = computed(
260 () => messageInputRef.value?.isValid ?? false,
261);
262const canSend = computed(
263 () =>
264 messageInputValid.value &&
265 figure.value.trim().length > 0 &&
266 gameStore.canContact,
267);
268 
269// The wax seal is the only send control (the e2e locates it by the 'Send message'
270// accessible name): disabled by messages-left / validation / contact, with a small
271// state word struck under the seal.
272const sealDisabled = computed(
273 () => !gameStore.canSendMessage || !canSend.value,
274);
275const sealWord = computed(() =>
276 gameStore.isRateLimited ? "wait" : gameStore.isLoading ? "sealing…" : "send",
277);
278 
279const dispatchOrdinal = computed(
280 () => TOTAL_MESSAGES - gameStore.remainingMessages + 1,
281);
282 
283// --- The letterhead: addressee + dateline, read from the same grounding state the
284// picker manages (display only — every mechanic still computes off the store). ---
285const addresseeName = computed(() =>
286 gameStore.figureGrounding?.resolved
287 ? gameStore.figureGrounding.name
288 : figure.value.trim(),
289);
290const dateline = computed(() =>
291 gameStore.contactWhen != null ? formatContactYear(gameStore.contactWhen) : "",
292);
293const contactAge = computed(() => gameStore.contactAge);
294// A soft salutation once a real recipient is grounded — the first name (or a mononym
295// whole). Pure letter flavor; absent until the letter is addressed.
296const salutation = computed(() => {
297 const g = gameStore.figureGrounding;
298 if (!g?.resolved || !g.name) return "";
299 const first = g.name.split(/\s+/)[0];
300 return first ? `Dear ${first},` : "";
301});
302 
303// The pre-send wager chip: Archive known-since × chosen contact year → ⚡ tier.
304const wagerRead = computed(() => {
305 const lookup = archiveStore.archiveResult;
306 const when = gameStore.contactWhen;
307 if (!lookup?.knownSince || when == null) return null;
308 const knownYear = parseKnownSinceYear(lookup.knownSince);
309 if (knownYear == null) return null;
310 const tier = wagerTier(knownYear, when);
311 const pips =
312 tier === "impossible"
313 ? "⚡⚡⚡"
314 : tier === "far-ahead"
315 ? "⚡⚡"
316 : tier === "ahead"
317 ? "⚡"
318 : "✓";
319 const text =
320 tier === "in-period"
321 ? `${lookup.topic}: already known by ${formatContactYear(when)} — steady ground`
322 : `${lookup.topic}: reads ≈ ${ANACHRONISM_LABEL[tier].toLowerCase()} for ${formatContactYear(when)} — a wider swing, losses harder than gains`;
323 return { tier, pips, text };
324});
325 
326// The pre-send chain chips: the ⏳ DECAY read (how far the moment lands from the nearest
327// foothold) and, when you have built a chain here, the ⛓ BONUS read (how much your prior
328// nearby changes will compound this gain) — the reward twin, legible before the roll.
329const chainRead = computed(() => {
330 const s = gameStore.causalChainRead;
331 if (!s) return null;
332 const bonus = s.bonus ?? 1;
333 const stones = s.depth ?? 0;
334 return {
335 tier: s.tier,
336 text: CHAIN_HINT[s.tier],
337 bonus,
338 bonusText: `chain ×${Number(bonus.toFixed(2))}${stones} ${stones === 1 ? "foothold" : "footholds"} compound your gain`,
339 };
340});
341 
342const stakeArmed = ref(false);
343watch(stakeArmed, (armed) => {
344 if (armed) soundscape?.play("stake");
345});
346 
347const fmtSwing = (b: { min: number; max: number }) =>
348 `${b.min >= 0 ? "+" : ""}${b.min}${b.max >= 0 ? "+" : ""}${b.max}%`;
349const stakeRead = computed(() => {
350 if (!gameStore.canStake) return null;
351 const dbl = (n: number) => n * STAKE_MULTIPLIER;
352 return {
353 need: MAX_PROGRESS - gameStore.objectiveProgress,
354 hit: fmtSwing({
355 min: dbl(SWING_BANDS[DiceOutcome.SUCCESS].min),
356 max: dbl(SWING_BANDS[DiceOutcome.CRITICAL_SUCCESS].max),
357 }),
358 miss: fmtSwing({
359 min: dbl(SWING_BANDS[DiceOutcome.CRITICAL_FAILURE].min),
360 max: dbl(SWING_BANDS[DiceOutcome.FAILURE].max),
361 }),
362 };
363});
364 
365const finalReach = computed(() => {
366 if (
367 gameStore.gameStatus !== "playing" ||
368 gameStore.remainingMessages !== 1 ||
369 gameStore.canStake
370 )
371 return null;
372 const need = MAX_PROGRESS - gameStore.objectiveProgress;
373 return need > 0 ? { need } : null;
374});
375 
376async function onSeal() {
377 const text = (messageInputRef.value?.message ?? "").trim();
378 const target = figure.value.trim();
379 if (
380 !text ||
381 !messageInputRef.value?.isValid ||
382 !target ||
383 !gameStore.canContact
384 )
385 return;
386 // A blocked/failed send returns false; the conductor lands us back here and the
387 // composer keeps the words (we only clear on a real resolve).
388 const resolved = await conductor.seal(text, target, {
389 stake: stakeArmed.value,
390 });
391 if (resolved) {
392 stakeArmed.value = false;
393 if (messageInputRef.value) messageInputRef.value.message = "";
394 figure.value = "";
395 resetAddress(); // next dispatch opens fresh on the letterhead
396 }
398 
399// A new run / reset (epoch bump) wipes the composer; a failed send (no epoch bump)
400// keeps it.
401watch(
402 () => gameStore.runEpoch,
403 () => {
404 figure.value = "";
405 if (messageInputRef.value) messageInputRef.value.message = "";
406 stakeArmed.value = false;
407 resetAddress();
408 },
409);
410 
411// --- The address fold: open while you address the letter, then auto-collapses to
412// the letterhead once a contactable recipient + year is set, so attention moves to
413// the body. Manual toggling takes over (we stop auto-managing it for this dispatch).
414const addressOpen = ref(true);
415const addressTouched = ref(false);
416const autoCollapsed = ref(false);
417function onAddressToggle(e: Event) {
418 const open = (e.target as HTMLDetailsElement).open;
419 // A programmatic collapse (auto) also fires toggle — don't treat that as the user
420 // taking manual control. Only a divergence from our intended state is a real touch.
421 if (open !== addressOpen.value) addressTouched.value = true;
422 addressOpen.value = open;
424function resetAddress() {
425 addressOpen.value = true;
426 addressTouched.value = false;
427 autoCollapsed.value = false;
429// Auto-collapse once, when the letter becomes addressable (a contactable figure with
430// a year). Skipped if the player has taken manual control of the fold this dispatch.
431watch(
432 () => gameStore.canContact,
433 (ok) => {
434 if (
435 ok &&
436 addressOpen.value &&
437 !addressTouched.value &&
438 !autoCollapsed.value
439 ) {
440 addressOpen.value = false;
441 autoCollapsed.value = true;
442 nextTick(() =>
443 (
444 document.querySelector(
445 '[data-testid="message-input"] textarea',
446 ) as HTMLElement | null
447 )?.focus(),
448 );
449 }
450 },
451);
452 
453// A gentle "pick your moment" nudge. The year is auto-set to mid-lifetime when a
454// figure grounds, then the fold auto-collapses to the letterhead — so the dateline
455// can read as fixed decoration. While it sits at that untouched default, the dateline
456// breathes in the accent, flagging that choosing a deliberate year is the next step.
457// It drops the instant the player opens the address fold (they've found the
458// when-control) and resets each dispatch via resetAddress().
459const nudgeAddress = computed(
460 () => autoCollapsed.value && !addressOpen.value && !addressTouched.value,
461);
462 
463// The Archive lookup unfolds inline beneath the wager line — the reach it informs —
464// collapsed by default so the letter body leads.
465const lookupOpen = ref(false);
466 
467// Focus the recipient field when the dispatch leads with an unaddressed letterhead;
468// once addressed (fold collapsed), there's nothing to focus there.
469function focusFigure() {
470 if (!addressOpen.value) return;
471 nextTick(() =>
472 (
473 document.querySelector(
474 '[data-testid="figure-input"]',
475 ) as HTMLElement | null
476 )?.focus(),
477 );
479onMounted(focusFigure);
480onActivated(focusFigure);
481</script>
482 
483<style scoped>
484.count {
485 font-size: var(--ew-fs-note);
487 
488/* The letter sheet: one column, generous measure, reading top to bottom. It
489 scrolls down when tall but never sideways — a lone overflow-y would make the
490 browser compute overflow-x to auto, so pin it to hidden. The padding keeps the
491 clip box clear of the wax seal's hover/staked shadow and the letterhead's hover
492 bleed, which would otherwise shear off against the edge. */
493.letter {
494 box-sizing: border-box;
495 display: flex;
496 flex-direction: column;
497 gap: 16px;
498 flex: 1 1 auto;
499 min-height: 0;
500 overflow-x: hidden;
501 overflow-y: auto;
502 padding: 6px 16px 16px;
503 max-width: 680px;
504 width: 100%;
505 margin: 0 auto;
507 
508/* Letterhead — the address fold's summary. Reads as a letter's head: the recipient
509 in serif, the dateline to the right, an "address"/"done" affordance trailing. */
510.address {
511 min-width: 0;
513.letterhead {
514 display: flex;
515 align-items: baseline;
516 justify-content: space-between;
517 gap: 12px;
518 flex-wrap: wrap;
519 cursor: pointer;
520 list-style: none;
521 user-select: none;
522 padding: 5px 8px;
523 margin: -3px -8px 2px;
524 border-radius: 5px;
525 transition: background var(--ew-dur-1) var(--ew-ease);
527.letterhead:hover {
528 background: color-mix(in srgb, var(--ew-accent) 6%, transparent);
530.letterhead::-webkit-details-marker {
531 display: none;
533.letterhead::marker {
534 content: "";
536.lh-fields {
537 display: flex;
538 align-items: baseline;
539 gap: 18px;
540 flex-wrap: wrap;
541 min-width: 0;
543.lh-to {
544 display: inline-flex;
545 align-items: baseline;
546 gap: 8px;
547 min-width: 0;
549.lh-name {
550 font-size: 24px;
551 font-weight: 600;
552 line-height: 1.1;
553 color: var(--ew-fg);
555.lh-name.unset {
556 color: var(--ew-faint);
557 font-style: italic;
558 font-weight: 500;
560.lh-date {
561 font-size: 13px;
563.lh-year {
564 color: var(--ew-fg);
565 font-variant-numeric: tabular-nums;
567.lh-year.unset {
568 color: var(--ew-faint);
569 font-style: italic;
571.lh-age {
572 font-size: 12px;
574/* The year reads as editable (dotted underline); the edit control is a real pill —
575 so it's obvious the letterhead opens to choose who, slide the year, read the dossier. */
576.lh-year:not(.unset) {
577 text-decoration: underline dotted;
578 text-underline-offset: 3px;
579 text-decoration-color: var(--ew-line);
581.letterhead:hover .lh-year:not(.unset) {
582 text-decoration-color: var(--ew-accent);
584/* The "pick your moment" nudge (nudgeAddress): while the year sits at its auto-picked
585 default, the dateline breathes between its rest colour and the accent, with a faint
586 accent halo — an invitation, not an alarm. Slow (~2s) and self-stopping (the class
587 drops when the fold reopens). 0%/100% stops mix from the accent at 0% so the halo
588 fades cleanly to/from transparent rather than through grey. */
589.lh-date.nudge {
590 border-radius: 6px;
591 padding: 1px 6px;
592 margin: -1px -6px;
593 animation: lh-date-breathe 2.1s var(--ew-ease) infinite;
595.lh-date.nudge .lh-year {
596 animation: lh-year-breathe 2.1s var(--ew-ease) infinite;
598@keyframes lh-date-breathe {
599 0%,
600 100% {
601 background: color-mix(in srgb, var(--ew-accent) 0%, transparent);
602 box-shadow: 0 0 0 0 color-mix(in srgb, var(--ew-accent) 0%, transparent);
603 }
604 50% {
605 background: color-mix(in srgb, var(--ew-accent) 10%, transparent);
606 box-shadow: 0 0 0 4px color-mix(in srgb, var(--ew-accent) 13%, transparent);
607 }
609@keyframes lh-year-breathe {
610 0%,
611 100% {
612 color: var(--ew-fg);
613 text-decoration-color: var(--ew-line);
614 }
615 50% {
616 color: var(--ew-accent);
617 text-decoration-color: var(--ew-accent);
618 }
620.lh-edit {
621 font-size: 11px;
622 white-space: nowrap;
623 display: inline-flex;
624 align-items: center;
625 border: 1px solid var(--ew-line);
626 border-radius: 999px;
627 padding: 3px 10px;
628 color: var(--ew-mut);
629 transition:
630 border-color var(--ew-dur-1) var(--ew-ease),
631 color var(--ew-dur-1) var(--ew-ease),
632 background var(--ew-dur-1) var(--ew-ease);
634.letterhead:hover .lh-edit {
635 border-color: var(--ew-accent);
636 color: var(--ew-accent);
637 background: var(--ew-bg);
639.lh-edit.open {
640 border-color: var(--ew-accent);
641 color: var(--ew-accent);
643.address-body {
644 padding-top: 6px;
646 
647.rule {
648 border: 0;
649 border-top: 1px solid var(--ew-line);
650 margin: 0;
652 
653.body {
654 display: flex;
655 flex-direction: column;
656 gap: 10px;
658.salutation {
659 font-size: var(--ew-fs-body);
661 
662/* The foot — marginalia gathered by the seal. */
663.foot {
664 display: flex;
665 flex-direction: column;
666 gap: 12px;
668.marginalia {
669 display: flex;
670 flex-direction: column;
671 gap: 8px;
673.read {
674 font-size: var(--ew-fs-note);
675 line-height: 1.4;
677 
678.stake {
679 display: flex;
680 align-items: flex-start;
681 gap: 8px;
682 border: 1px solid var(--ew-line);
683 border-radius: 2px;
684 padding: 8px 10px;
686.stake.armed {
687 border-color: var(--ew-accent);
688 background: color-mix(in srgb, var(--ew-accent) 7%, transparent);
690.stake-box {
691 margin-top: 2px;
693.stake-label {
694 font-size: var(--ew-fs-note);
695 line-height: 1.4;
696 cursor: pointer;
698.stake-head {
699 font-weight: 600;
700 text-transform: uppercase;
701 letter-spacing: 0.04em;
703.stake-body {
704 display: block;
706.final-reach {
707 border: 1px solid var(--ew-line);
708 border-radius: 2px;
709 padding: 8px 10px;
710 font-size: var(--ew-fs-note);
711 line-height: 1.4;
713 
714/* The seal foot: the cue and the wax seal you press to send. */
715.seal-foot {
716 display: flex;
717 align-items: center;
718 justify-content: space-between;
719 gap: 14px;
720 flex-wrap: wrap;
721 border-top: 1px solid var(--ew-line);
722 padding-top: 12px;
724.seal-cue {
725 font-size: var(--ew-fs-label);
726 max-width: 32ch;
728 
729/* The send control IS the wax seal: the brand sigil struck into wax, pressable.
730 States — ready / disabled / sending / staked (the last stand). */
731.seal-btn {
732 display: inline-flex;
733 flex-direction: column;
734 align-items: center;
735 gap: 5px;
736 margin-left: auto;
737 background: none;
738 border: 0;
739 padding: 2px;
740 cursor: pointer;
741 -webkit-tap-highlight-color: transparent;
743.seal-wax {
744 width: 52px;
745 height: 52px;
746 border-radius: 50%;
747 display: grid;
748 place-items: center;
749 filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3));
750 transition:
751 transform var(--ew-dur-1) var(--ew-ease-spring),
752 filter var(--ew-dur-1) var(--ew-ease),
753 box-shadow var(--ew-dur-1) var(--ew-ease);
755.seal-face {
756 width: 100%;
757 height: 100%;
759.seal-word {
760 font-size: var(--ew-fs-label);
761 letter-spacing: 0.09em;
762 text-transform: uppercase;
763 color: var(--ew-mut);
764 transition: color var(--ew-dur-1) var(--ew-ease);
766.seal-btn:not(:disabled):hover .seal-wax {
767 transform: translateY(-1px) scale(1.04);
768 filter: drop-shadow(0 3px 6px rgba(0, 0, 0, 0.32));
770.seal-btn:not(:disabled):hover .seal-word {
771 color: var(--ew-accent);
773.seal-btn:not(:disabled):active .seal-wax {
774 transform: translateY(1px) scale(0.95);
775 filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.3));
777.seal-btn:disabled {
778 cursor: not-allowed;
780/* Disabled = flat inert neutral wax (the primary-button settlement extended to
781 the seal): re-inked, full opacity — never a sub-3:1 ghost. filter:none also
782 drops the base drop-shadow, part of the flat read. */
783.seal-btn:disabled .seal-wax {
784 --seal-fill: var(--ew-faint);
785 filter: none;
787.seal-btn:disabled .seal-word {
788 opacity: 1;
789 color: var(--ew-faint);
791/* Sealing wins over the disabled re-ink (same specificity, later in source). */
792.seal-btn.sending .seal-wax {
793 --seal-fill: var(--ew-accent);
794 animation: seal-pulse 1.1s var(--ew-ease) infinite;
796.seal-btn.staked .seal-wax {
797 box-shadow:
798 0 0 0 3px color-mix(in srgb, var(--ew-accent) 40%, transparent),
799 0 0 11px color-mix(in srgb, var(--ew-accent) 30%, transparent);
⋯ 114 lines hidden (lines 801–914)
801@keyframes seal-pulse {
802 0%,
803 100% {
804 transform: scale(1);
805 }
806 50% {
807 transform: scale(1.06);
808 }
810 
811.notice {
812 display: flex;
813 align-items: center;
814 gap: 8px;
815 font-size: var(--ew-fs-meta);
816 border-left: 2px solid var(--ew-bad);
817 padding: 2px 0 2px 8px;
819.notice .ew-label {
820 color: var(--ew-bad);
822.dismiss {
823 margin-left: auto;
824 background: none;
825 border: 0;
826 cursor: pointer;
827 color: inherit;
829 
830/* The inline Archive: a quiet affordance on the wager line, unfolding the lookup
831 right beneath it (indented like a margin note against the reach it informs). */
832.archive-toggle {
833 align-self: flex-start;
834 display: inline-flex;
835 align-items: center;
836 gap: 6px;
837 background: color-mix(in srgb, var(--ew-accent) 5%, transparent);
838 border: 1px solid var(--ew-edge);
839 border-radius: 999px;
840 padding: 4px 11px;
841 margin: 1px 0;
842 cursor: pointer;
843 font-size: var(--ew-fs-label);
844 font-weight: 500;
845 color: var(--ew-mut);
846 transition:
847 border-color var(--ew-dur-1) var(--ew-ease),
848 color var(--ew-dur-1) var(--ew-ease),
849 background var(--ew-dur-1) var(--ew-ease);
851.archive-toggle:hover {
852 border-color: var(--ew-accent);
853 color: var(--ew-accent);
854 background: color-mix(in srgb, var(--ew-accent) 11%, transparent);
856.archive-toggle.open {
857 border-color: var(--ew-accent);
858 color: var(--ew-accent);
860.at-icon {
861 font-size: var(--ew-fs-label);
863.at-caret {
864 font-size: var(--ew-fs-micro);
865 opacity: 0.75;
867.archive-inline {
868 border-left: 2px solid var(--ew-line);
869 padding-left: 10px;
870 margin: 4px 0 4px 2px;
872 
873@media (prefers-reduced-motion: reduce) {
874 .seal-wax,
875 .seal-btn:hover .seal-wax,
876 .seal-btn:active .seal-wax {
877 transition: none;
878 transform: none;
879 }
880 .seal-btn.sending .seal-wax {
881 animation: none;
882 }
883 /* No breathing under reduced motion — hold a static accent emphasis on the dateline
884 so the "pick a year" cue still reads, without animation. */
885 .lh-date.nudge {
886 animation: none;
887 background: color-mix(in srgb, var(--ew-accent) 9%, transparent);
888 }
889 .lh-date.nudge .lh-year {
890 animation: none;
891 color: var(--ew-accent);
892 text-decoration-color: var(--ew-accent);
893 }
895 
896@media (max-width: 640px) {
897 .letter {
898 gap: 14px;
899 }
900 .lh-name {
901 font-size: 21px;
902 }
903 .archive-toggle {
904 padding: 7px 13px;
905 }
906 .seal-cue {
907 max-width: 100%;
908 }
909 .seal-wax {
910 width: 56px;
911 height: 56px;
912 }
914</style>

One of the four blocked notices on the new token idiom.

components/FigurePicker.vue · 878 lines
components/FigurePicker.vue878 lines · Vue
⋯ 183 lines hidden (lines 1–183)
1<template>
2 <div data-testid="figure-picker" class="space-y-3">
3 <div ref="searchBoxRef" class="relative" @focusout="onSearchFocusOut">
4 <label for="figure-input" class="ew-prompt block mb-1"
5 >Who in history will you reach?</label
6 >
7 <input
8 id="figure-input"
9 ref="searchInputRef"
10 v-model="figure"
11 data-testid="figure-input"
12 type="text"
13 class="ew-field ew-mono text-sm"
14 placeholder="Anyone, any era — Cleopatra, Tesla, Genghis Khan…"
15 :disabled="disabled"
16 role="combobox"
17 aria-autocomplete="list"
18 :aria-expanded="listboxOpen ? 'true' : 'false'"
19 :aria-controls="listboxOpen ? 'figure-search-listbox' : undefined"
20 :aria-activedescendant="
21 activeIndex >= 0 ? `figure-search-option-${activeIndex}` : undefined
22 "
23 autocomplete="off"
24 @keydown="onSearchKeydown"
25 />
26 <span class="sr-only" role="status">{{ searchStatus }}</span>
27 <!-- Name autocomplete: Wikipedia title search, filtered to people (#74) so the
28 picks are all real, contactable figures (the description line still tells
29 one Cleopatra from another). Picking one fills the input; grounding fires
30 exactly as if it had been typed. -->
31 <ul
32 v-if="listboxOpen"
33 id="figure-search-listbox"
34 data-testid="figure-search-listbox"
35 role="listbox"
36 aria-label="Matching names from the record"
37 class="search-pop absolute left-0 right-0 top-full mt-1 z-20 border ew-line rounded-sm ew-bg max-h-64 overflow-y-auto"
38 >
39 <li
40 v-for="(r, i) in searchResults"
41 :id="`figure-search-option-${i}`"
42 :key="r.name"
43 role="option"
44 :aria-selected="i === activeIndex ? 'true' : 'false'"
45 data-testid="figure-search-option"
46 class="flex items-center gap-2 px-2.5 py-1.5 cursor-pointer text-sm"
47 :class="i === activeIndex ? 'ew-tint' : 'ew-hover'"
48 @mousedown.prevent
49 @click="choose(r.name)"
50 @mousemove="activeIndex = i"
51 >
52 <img
53 v-if="r.thumbnail"
54 :src="r.thumbnail"
55 alt=""
56 class="h-7 w-7 shrink-0 rounded object-cover"
57 aria-hidden="true"
58 />
59 <span
60 v-else
61 class="h-7 w-7 shrink-0 rounded ew-tint"
62 aria-hidden="true"
63 />
64 <span class="min-w-0">
65 <span class="ew-fg font-medium block truncate">{{ r.name }}</span>
66 <span
67 v-if="r.description"
68 class="ew-faint text-[11px] block truncate"
69 >{{ r.description }}</span
70 >
71 </span>
72 </li>
73 </ul>
74 <!-- The search couldn't reach Wikipedia after its retries: a visible, retryable
75 hint so a rate-limit blip degrades honestly instead of an empty box that
76 reads as "no matches" (issue #58). The sr-only role=status span above is the
77 sole live region — this surface is NOT one, to avoid a double announcement. -->
78 <div
79 v-else-if="searchOpen && searchUnavailable"
80 data-testid="figure-search-unavailable"
81 class="search-pop absolute left-0 right-0 top-full mt-1 z-20 border ew-line rounded-sm ew-bg px-2.5 py-2 text-sm ew-muted flex items-center justify-between gap-2"
82 >
83 <span>Couldn't reach the record — a brief network hiccup.</span>
84 <button
85 type="button"
86 data-testid="figure-search-retry"
87 class="ew-accent text-[11px] font-medium hover:underline shrink-0"
88 @mousedown.prevent
89 @click="retrySearch"
90 >
91 Retry
92 </button>
93 </div>
94 </div>
95 
96 <!-- Grounding: who they were, and when you reach them -->
97 <div
98 v-if="groundingLoading"
99 data-testid="grounding-loading"
100 class="flex items-center gap-2 text-xs ew-faint"
101 >
102 <span class="ew-spinner" aria-hidden="true" />
103 Consulting the records…
104 </div>
105 
106 <div
107 v-else-if="grounding?.resolved"
108 data-testid="figure-dossier"
109 class="ew-card p-3"
110 >
111 <div class="flex gap-3">
112 <img
113 v-if="grounding.thumbnail"
114 :src="grounding.thumbnail"
115 :alt="grounding.name"
116 class="h-12 w-12 shrink-0 rounded object-cover"
117 />
118 <div class="min-w-0 flex-1">
119 <div class="flex flex-wrap items-center gap-x-2 gap-y-0.5">
120 <span
121 data-testid="dossier-name"
122 class="ew-fg font-semibold text-sm"
123 >{{ grounding.name }}</span
124 >
125 <span
126 v-if="lifespan"
127 data-testid="dossier-lifespan"
128 class="ew-mono text-[11px] ew-faint"
129 >{{ lifespan }}</span
130 >
131 <a
132 v-if="grounding.wikiUrl"
133 :href="grounding.wikiUrl"
134 target="_blank"
135 rel="noopener noreferrer"
136 class="ew-accent text-[11px]"
137 >Wikipedia ↗</a
138 >
139 </div>
140 <p v-if="grounding.description" class="ew-muted text-xs mt-0.5">
141 {{ grounding.description }}
142 </p>
143 
144 <!-- Ambiguous name (#186): several real people share it. We auto-picked the
145 most-notable (or the best fit for this objective) above; if that's not who
146 you meant, these same-name people are one tap away. Picking one re-grounds
147 the dossier — and re-aims the contact, since the send carries the resolved
148 canonical name. Shown even when the default is blocked (living/undatable),
149 so the override is the way to reach the deceased one you actually wanted. -->
150 <div
151 v-if="candidates.length"
152 data-testid="figure-candidates"
153 class="mt-2 border-t ew-line pt-2"
154 >
155 <span class="ew-prompt block mb-1">Not who you meant?</span>
156 <div class="flex flex-col gap-1">
157 <button
158 v-for="c in candidates"
159 :key="c.name"
160 type="button"
161 data-testid="candidate-chip"
162 class="ew-press ew-hover border ew-edge rounded-sm px-2.5 py-1.5 text-left"
163 :disabled="disabled"
164 @click="select(c.name)"
165 >
166 <span class="text-sm ew-fg font-medium">{{ c.name }}</span>
167 <span
168 v-if="candidateLifespan(c)"
169 class="ml-1.5 ew-mono text-[11px] ew-faint"
170 >{{ candidateLifespan(c) }}</span
171 >
172 <span
173 v-if="c.description"
174 class="block text-[11px] leading-snug ew-faint"
175 >{{ c.description }}</span
176 >
177 </button>
178 </div>
179 </div>
180 
181 <!-- Deceased-only floor (#72): a living (or undatable) figure can't be
182 reached. Show an honest block in place of the when-control; the send is
183 gated by canContact, and the server re-checks authoritatively. -->
184 <p
185 v-if="contactBlocked"
186 data-testid="contact-blocked"
187 role="alert"
188 class="mt-2.5 border-t ew-line pt-2.5 text-[11px] py-1 ew-blocked"
189 >
190 <span class="ew-blocked-tag">⚠ blocked</span>
191 {{ contactBlockReason }}
192 </p>
193 
⋯ 685 lines hidden (lines 194–878)
194 <!-- When do you reach them? A promoted, lifetime-bounded playhead: the
195 chosen year + live age lead, big and in the accent, so the moment you're
196 writing into is the loudest thing in the dossier (not a buried micro-row). -->
197 <div
198 v-else-if="hasLifespan"
199 data-testid="when-control"
200 class="mt-2.5 border-t ew-line pt-2.5"
201 >
202 <div class="flex items-baseline justify-between gap-2">
203 <span id="when-label" class="ew-label">Reach them in</span>
204 <span
205 data-testid="when-display"
206 class="ew-mono ew-fg text-lg font-bold leading-none"
207 >
208 {{ whenDisplay
209 }}<span
210 v-if="contactAge != null"
211 data-testid="when-age"
212 class="ew-faint text-xs font-normal"
213 >
214 · age {{ contactAge }}</span
215 >
216 </span>
217 </div>
218 <input
219 type="range"
220 data-testid="when-slider"
221 class="w-full mt-2"
222 :style="{ accentColor: 'var(--ew-accent)' }"
223 :min="range.min"
224 :max="range.max"
225 :value="contactWhen ?? range.min"
226 :disabled="disabled"
227 aria-labelledby="when-label"
228 :aria-valuetext="whenValueText"
229 @input="onWhenInput"
230 />
231 <div
232 class="flex items-center justify-between text-[11px] ew-faint ew-mono"
233 >
234 <span>{{ grounding.born?.display }} · born</span>
235 <span>{{
236 grounding.died?.display
237 ? grounding.died.display + " · died"
238 : "present"
239 }}</span>
240 </div>
241 
242 <!-- Pin the moment (issue #32): an optional month/day refinement of the
243 chosen year. Display + prompt flavor only — the year stays the value
244 every mechanic computes with, so the wager, world-brief, and liveness
245 gates are untouched by whatever is pinned here. -->
246 <div class="mt-1.5">
247 <button
248 v-if="!momentOpen"
249 type="button"
250 data-testid="pin-moment"
251 class="ew-accent text-[11px] font-medium hover:underline disabled:opacity-50"
252 :disabled="disabled"
253 @click="momentOpen = true"
254 >
255 <span aria-hidden="true">📍</span>
256 {{ contactMoment ? "adjust the moment" : "pin the moment" }}
257 </button>
258 <div v-else data-testid="moment-picker">
259 <div
260 class="flex flex-wrap gap-1"
261 role="group"
262 aria-label="Month of the contact"
263 >
264 <button
265 v-for="(m, i) in MONTH_NAMES"
266 :key="m"
267 type="button"
268 data-testid="month-chip"
269 class="ew-press border ew-edge rounded-sm px-1.5 py-0.5 text-[11px]"
270 :class="
271 contactMoment?.month === i + 1
272 ? 'ew-tint ew-fg'
273 : 'ew-muted ew-hover'
274 "
275 :aria-pressed="
276 contactMoment?.month === i + 1 ? 'true' : 'false'
277 "
278 :disabled="disabled"
279 @click="pickMonth(i + 1)"
280 >
281 {{ m.slice(0, 3) }}
282 </button>
283 </div>
284 <div class="flex items-center gap-1.5 mt-1">
285 <template v-if="contactMoment">
286 <label for="moment-day" class="ew-label">day</label>
287 <input
288 id="moment-day"
289 data-testid="moment-day"
290 type="number"
291 inputmode="numeric"
292 class="ew-field ew-mono text-xs w-16 px-1.5 py-0.5"
293 min="1"
294 :max="maxDay"
295 :value="contactMoment.day ?? ''"
296 :disabled="disabled"
297 placeholder="—"
298 @input="onDayInput"
299 />
300 </template>
301 <button
302 type="button"
303 data-testid="unpin-moment"
304 class="ew-faint text-[11px] hover:underline ml-auto"
305 :disabled="disabled"
306 @click="unpinMoment"
307 >
308 {{ contactMoment ? "unpin" : "close" }}
309 </button>
310 </div>
311 </div>
312 </div>
313 </div>
314 
315 <!-- Even the AI bridge couldn't date them: say so honestly instead of a
316 mysteriously absent control. The copy claims only the lookup outcome
317 (it may be a transient outage, not a dateless record). No manual year
318 here — the contact year prices the anachronism wager, so it stays
319 grounded or unset. -->
320 <p
321 v-else
322 data-testid="when-unknown"
323 class="mt-2.5 border-t ew-line pt-2.5 text-[11px] italic ew-faint"
324 >
325 No birth year could be found for them — your message will find them
326 in their own time.
327 </p>
328 
329 <!-- The Archive: study who you're reaching, at the chosen moment -->
330 <div
331 v-if="!contactBlocked"
332 data-testid="archive"
333 class="mt-2 border-t ew-line pt-2"
334 >
335 <button
336 v-if="!studyShown"
337 type="button"
338 data-testid="study-button"
339 class="ew-accent text-[11px] font-medium hover:underline disabled:opacity-50"
340 :disabled="studyLoading || disabled"
341 @click="studyThem"
342 >
343 <span aria-hidden="true">🔎</span>
344 {{
345 studyLoading ? "The Archivist consults the record…" : studyLabel
346 }}
347 </button>
348 
349 <div
350 v-else
351 data-testid="figure-study"
352 class="space-y-1 text-[11px] leading-snug ew-muted"
353 >
354 <p class="italic ew-fg">{{ figureStudy?.atThisMoment }}</p>
355 <p v-if="figureStudy?.grasp.length">
356 <span class="ew-faint">grasps</span>
357 {{ figureStudy?.grasp.join(" · ") }}
358 </p>
359 <p v-if="figureStudy?.reaching.length">
360 <span class="ew-faint">reaching</span>
361 {{ figureStudy?.reaching.join(" · ") }}
362 </p>
363 <p v-if="figureStudy?.cannotYetKnow" class="ew-warn">
364 <span class="ew-faint">beyond them</span>
365 {{ figureStudy?.cannotYetKnow }}
366 </p>
367 <button
368 v-if="yearMoved"
369 type="button"
370 data-testid="restudy-button"
371 class="ew-accent font-medium hover:underline disabled:opacity-50"
372 :disabled="studyLoading || disabled"
373 @click="studyThem"
374 >
375 <span aria-hidden="true">🔎</span>
376 {{ studyLoading ? "Consulting…" : restudyLabel }}
377 </button>
378 </div>
379 
380 <p
381 v-if="studyNotice"
382 data-testid="study-blocked"
383 role="alert"
384 aria-live="assertive"
385 class="mt-1 text-[11px] py-1 ew-blocked"
386 >
387 <span class="ew-blocked-tag">⚠ blocked</span>
388 {{ studyNotice }}
389 </p>
390 </div>
391 </div>
392 </div>
393 </div>
394 
395 <div
396 v-else-if="grounding && !grounding.resolved"
397 data-testid="figure-unresolved"
398 class="text-xs italic ew-faint"
399 >
400 <template v-if="grounding.transient"
401 >Couldn't reach the record for "{{ activeName }}" — try again in a
402 moment.</template
403 >
404 <template v-else
405 >No record found for "{{ activeName }}" — reach for a real historical
406 figure (pick a match as you type, or refine the name).</template
407 >
408 </div>
409 
410 <!-- Figures already contacted this run -->
411 <div v-if="contacted.length" class="flex flex-wrap items-center gap-1.5">
412 <span class="ew-label">contacts</span>
413 <button
414 v-for="f in contacted"
415 :key="f.name"
416 type="button"
417 data-testid="contact-chip"
418 class="ew-press text-xs px-2 py-0.5 border ew-edge rounded-sm inline-flex items-center gap-1.5"
419 :class="chipClass(f.name)"
420 @click="select(f.name)"
421 >
422 <span
423 class="ew-dot"
424 :class="f.name === figure ? 'ew-dot--accent' : 'ew-dot--hollow'"
425 aria-hidden="true"
426 />{{ f.name }}
427 </button>
428 </div>
429 
430 <!-- Era-relevant figures for this objective (the on-ramp); you can still type
431 anyone. Open on desktop; collapsed by default on a phone (a tap reveals it)
432 so the dossier + composer lead instead of a wall of suggestions. -->
433 <details
434 class="suggestions-fold"
435 :open="suggestionsOpen"
436 @toggle="onSuggestionsToggle"
437 >
438 <summary class="suggestions-summary">
439 <span
440 class="ew-prompt"
441 :class="{ 'sugg-loading': suggestionsPending }"
442 aria-live="polite"
443 >{{ suggestionsLabel }}</span
444 >
445 <span class="fold-caret" aria-hidden="true"></span>
446 </summary>
447 <div class="space-y-1.5 pt-1.5">
448 <p
449 v-if="suggestionsNotice"
450 data-testid="suggestions-blocked"
451 role="alert"
452 aria-live="assertive"
453 class="text-[11px] py-1 ew-blocked"
454 >
455 <span class="ew-blocked-tag">⚠ blocked</span>
456 {{ suggestionsNotice }}
457 </p>
458 <!-- While the era-aware agent works: quiet skeleton chips — never clickable
459 generic defaults masquerading as objective-relevant picks. The famous-
460 names fallback appears only AFTER the agent has genuinely come up empty,
461 labeled honestly as what it is. -->
462 <div
463 v-if="suggestionsPending"
464 data-testid="suggestion-skeletons"
465 class="flex flex-col gap-1"
466 aria-hidden="true"
467 >
468 <div
469 v-for="i in 3"
470 :key="i"
471 class="border ew-line rounded-sm px-2.5 py-2"
472 >
473 <span class="skeleton-line w-32" />
474 <span class="skeleton-line w-48 mt-1.5" />
475 </div>
476 </div>
477 <div v-else class="flex flex-col gap-1">
478 <button
479 v-for="(s, i) in displaySuggestions"
480 :key="s.name"
481 type="button"
482 data-testid="suggestion-chip"
483 class="ew-press sugg-chip border ew-edge rounded-sm px-2.5 py-1.5 text-left"
484 :style="{ '--sugg-i': i }"
485 :class="suggestionClass(s.name)"
486 @click="select(s.name)"
487 >
488 <span class="text-sm ew-fg font-medium">{{ s.name }}</span>
489 <span
490 v-if="s.lifespan"
491 class="ml-1.5 ew-mono text-[11px] ew-faint"
492 >{{ s.lifespan }}</span
493 >
494 <!-- The blurb re-narrates against the altered timeline after a landed change
495 (issue #131); RewriteText plays the "history rewritten" flourish whenever
496 this text changes. Keyed by name (above), so a kept chip animates its
497 rewrite while a brand-new chip just appears. -->
498 <RewriteText
499 v-if="s.reason"
500 :text="s.reason"
501 data-testid="suggestion-reason"
502 class="block text-[11px] leading-snug ew-faint"
503 />
504 </button>
505 <!-- Every suggested figure has already been contacted: the list is "who to try
506 next", so when it empties say so and point back to free typing, rather than
507 leaving a dead header over nothing (issue #130). -->
508 <p
509 v-if="!displaySuggestions.length"
510 data-testid="suggestions-exhausted"
511 class="text-[11px] ew-faint italic px-0.5"
512 >
513 You've reached everyone suggested here — type any name to keep
514 going.
515 </p>
516 </div>
517 </div>
518 </details>
519 </div>
520</template>
521 
522<script setup lang="ts">
523/**
524 * FigurePicker — choose whom (free-form name, suggestion, or prior contact) and, once
525 * grounded, when to reach them (a lifetime-bounded slider with a live age). The
526 * Archive's "study them" brief discloses who they are at that moment. Re-skin only —
527 * every store binding (grounding, contactWhen, study, suggestions) is unchanged.
528 */
529import { computed, watch, onMounted, nextTick } from "vue";
530import { useGameStore } from "~/stores/game";
531import { useArchiveStore } from "~/stores/archive";
532import { useSuggestionsStore } from "~/stores/suggestions";
533import { useFigureSearch } from "~/composables/useFigureSearch";
534import {
535 formatContactMoment,
536 MONTH_NAMES,
537 MONTH_MAX_DAY,
538} from "~/utils/contact-moment";
539import RewriteText from "~/components/RewriteText.vue";
540import type { FigureSuggestion } from "~/server/utils/figure-suggester";
541import type { FigureCandidate } from "~/server/utils/figure-grounding";
542 
543const figure = defineModel<string>({ default: "" });
544defineProps<{ disabled?: boolean }>();
545 
546const gameStore = useGameStore();
547// The Archive + suggestions slices are satellite stores now (#235).
548const archiveStore = useArchiveStore();
549const suggestionsStore = useSuggestionsStore();
550const contacted = computed(() => gameStore.figures);
551const figureSuggestions = computed(() => suggestionsStore.figureSuggestions);
552const suggestionsLoading = computed(() => suggestionsStore.suggestionsLoading);
553const suggestionsNotice = computed(() => suggestionsStore.suggestionsNotice);
554const grounding = computed(() => gameStore.figureGrounding);
555const groundingLoading = computed(() => gameStore.groundingLoading);
556const contactWhen = computed(() => gameStore.contactWhen);
557const contactAge = computed(() => gameStore.contactAge);
558const activeName = computed(() => figure.value.trim());
559 
560const hasLifespan = computed(
561 () => !!(grounding.value?.resolved && grounding.value.born),
562);
563// Deceased-only floor (#72): a resolved figure with no confirmed death is living
564// (or undatable) and can't be reached — show a block instead of the when-control.
565const contactBlocked = computed(
566 () => !!(grounding.value?.resolved && !grounding.value.died),
567);
568const contactBlockReason = computed(() => {
569 const g = grounding.value;
570 if (!contactBlocked.value || !g) return "";
571 return g.born
572 ? `${g.name} is still living — Everwhen only reaches figures from history.`
573 : `${g.name} couldn't be dated — Everwhen can only reach figures it can place in history.`;
574});
575const lifespan = computed(() => {
576 const g = grounding.value;
577 if (!g?.born) return "";
578 if (g.died) return `${g.born.display}${g.died.display}`;
579 return g.living ? `${g.born.display} – present` : g.born.display;
580});
581// Ambiguous-name override candidates (#186): the other same-name people the grounding
582// floor surfaced, most-notable first. Picking one routes through `choose` (re-grounds).
583const candidates = computed<FigureCandidate[]>(
584 () => grounding.value?.candidates ?? [],
585);
586function candidateLifespan(c: FigureCandidate): string {
587 if (!c.born) return "";
588 return c.died ? `${c.born.display}${c.died.display}` : c.born.display;
590const range = computed(() => ({
591 min: grounding.value?.born?.signed ?? 0,
592 max: grounding.value?.died?.signed ?? new Date().getFullYear(),
593}));
594const contactMoment = computed(() => gameStore.contactMoment);
595const whenDisplay = computed(() =>
596 contactWhen.value != null
597 ? formatContactMoment(contactWhen.value, contactMoment.value)
598 : "",
599);
600// What AT announces for the slider: the human year + live age (e.g. "44 BC · age 25"),
601// not the raw signed integer the value attribute carries.
602const whenValueText = computed(() =>
603 contactWhen.value == null
604 ? ""
605 : whenDisplay.value +
606 (contactAge.value != null ? ` · age ${contactAge.value}` : ""),
607);
608 
609function onWhenInput(e: Event) {
610 gameStore.setContactWhen(Number((e.target as HTMLInputElement).value));
612 
613// --- Pin the moment (issue #32): month chips + optional day, year-canonical ---
614const momentOpen = ref(false);
615const maxDay = computed(() =>
616 contactMoment.value ? MONTH_MAX_DAY[contactMoment.value.month - 1] : 31,
617);
618function pickMonth(month: number) {
619 const current = gameStore.contactMoment;
620 if (current?.month === month) return;
621 // Switching months keeps the day where it stays valid, clamps where it doesn't.
622 const day = current?.day
623 ? Math.min(current.day, MONTH_MAX_DAY[month - 1])
624 : undefined;
625 gameStore.setContactMoment(day ? { month, day } : { month });
627function onDayInput(e: Event) {
628 const current = gameStore.contactMoment;
629 if (!current) return;
630 const raw = Number((e.target as HTMLInputElement).value);
631 if (!Number.isInteger(raw) || raw < 1) {
632 gameStore.setContactMoment({ month: current.month });
633 return;
634 }
635 gameStore.setContactMoment({
636 month: current.month,
637 day: Math.min(raw, MONTH_MAX_DAY[current.month - 1]),
638 });
640function unpinMoment() {
641 gameStore.setContactMoment(null);
642 momentOpen.value = false;
644 
645// The Archive: study the grounded figure in-game, at the chosen moment.
646const figureStudy = computed(() => archiveStore.figureStudy);
647const studyLoading = computed(() => archiveStore.studyLoading);
648const studyNotice = computed(() => archiveStore.studyNotice);
649const studyShown = computed(
650 () => !!figureStudy.value && archiveStore.studyFor === grounding.value?.name,
651);
652const yearMoved = computed(
653 () => studyShown.value && archiveStore.studyWhen !== contactWhen.value,
654);
655const studyLabel = computed(() =>
656 contactWhen.value != null
657 ? `Study them in ${whenDisplay.value}`
658 : "Study them",
659);
660const restudyLabel = computed(() =>
661 contactWhen.value != null ? `Re-study in ${whenDisplay.value}` : "Re-study",
662);
663function studyThem() {
664 archiveStore.studyActiveFigure();
666 
667const LOCAL_DEFAULT: FigureSuggestion[] = [
668 { name: "Cleopatra", reason: "" },
669 { name: "Nikola Tesla", reason: "" },
670 { name: "Genghis Khan", reason: "" },
671 { name: "Marie Curie", reason: "" },
672 { name: "Leonardo da Vinci", reason: "" },
673];
674// The suggestion list is "who to try next": figures already messaged this run drop
675// off it, so it stops re-proposing people you just contacted (issue #130). They stay
676// one tap away in the contacts row above. Exact-name match, as registerFigure dedups.
677const displaySuggestions = computed(() => {
678 const base = figureSuggestions.value.length
679 ? figureSuggestions.value
680 : LOCAL_DEFAULT;
681 const contactedNames = new Set(contacted.value.map((f) => f.name));
682 return base.filter((s) => !contactedNames.has(s.name));
683});
684// Pending = in flight, OR simply not yet asked for this objective — the latter
685// covers the first painted frame (loadSuggestions fires onMounted, after render),
686// which would otherwise flash the clickable famous-names fallback for one frame.
687// The store records suggestionsFor even on failure, so a genuine miss still
688// settles into the honest fallback rather than skeletons forever.
689const suggestionsPending = computed(
690 () =>
691 !figureSuggestions.value.length &&
692 (suggestionsLoading.value ||
693 (!!gameStore.currentObjective &&
694 suggestionsStore.suggestionsFor !== gameStore.currentObjective.title)),
695);
696const suggestionsLabel = computed(() =>
697 suggestionsPending.value
698 ? "✦ searching history for figures who could bend this…"
699 : figureSuggestions.value.length
700 ? "figures who might matter here"
701 : "some famous names to start with",
702);
703 
704// Grounding is SELECTION-DRIVEN (#197): typing only builds + relevance-sorts the
705// autocomplete list (and clears any stale dossier); the lookup that COMMITS the addressee
706// — the dossier, the when-slider, the "TO …" letterhead, the fold's auto-collapse — fires
707// only on a deliberate pick. So an ambiguous name (Pliny → Elder/Younger) is offered in
708// the list rather than auto-committed mid-type; the player chooses which one.
709function groundChosen(name: string) {
710 // nextTick: run AFTER the figure-watch below clears the prior dossier, so this fresh
711 // lookup's seq isn't invalidated by clearGrounding's bump.
712 nextTick(() => gameStore.groundActiveFigure((name || "").trim()));
714 
715// Name autocomplete (a combobox over Wikipedia title search) — the whole search state
716// machine lives in useFigureSearch; the picker binds its state + handlers. Every
717// deliberate pick (option, Enter, suggestion / contact / candidate chip) routes through
718// `choose`, which fills the input and calls groundChosen — the one place grounding fires.
719const {
720 searchBoxRef,
721 searchInputRef,
722 searchResults,
723 searchOpen,
724 searchUnavailable,
725 activeIndex,
726 listboxOpen,
727 searchStatus,
728 choose,
729 retrySearch,
730 onSearchKeydown,
731 onSearchFocusOut,
732} = useFigureSearch(figure, groundChosen);
733 
734function select(name: string) {
735 choose(name);
737 
738function chipClass(name: string) {
739 return name === figure.value ? "ew-tint ew-fg" : "ew-muted ew-hover";
741function suggestionClass(name: string) {
742 return name === figure.value ? "ew-tint" : "ew-hover";
744 
745// Typing clears the prior dossier the instant the name changes — so stale facts/year
746// never linger and a send mid-edit goes out free-form (#73) — but never grounds. The
747// lookup is selection-driven (groundChosen, above), so the autocomplete list stays open
748// while you review and finish typing instead of auto-committing a guess mid-type (#197).
749watch(figure, () => gameStore.clearGrounding());
750 
751// Suggestions disclosure: open on desktop, collapsed by default on a phone (the
752// dossier + composer lead; a tap reveals the on-ramp). @toggle syncs manual toggles.
753const suggestionsOpen = ref(true);
754function onSuggestionsToggle(e: Event) {
755 suggestionsOpen.value = (e.target as HTMLDetailsElement).open;
757 
758// Load era-relevant suggestions for the current objective when the board appears,
759// and collapse them on a phone so they don't wall off the composer below.
760onMounted(() => {
761 suggestionsStore.loadSuggestions();
762 if (
763 typeof window !== "undefined" &&
764 window.matchMedia("(max-width: 640px)").matches
765 )
766 suggestionsOpen.value = false;
767});
768</script>
769 
770<style scoped>
771/* Quiet placeholder bars while the suggester works — letterpress tint, a gentle
772 pulse, stilled under reduced motion. Never a clickable fake. */
773.skeleton-line {
774 display: block;
775 height: 10px;
776 border-radius: 2px;
777 background: linear-gradient(
778 90deg,
779 color-mix(in srgb, var(--ew-line) 45%, transparent) 0%,
780 color-mix(in srgb, var(--ew-accent) 28%, var(--ew-tint)) 50%,
781 color-mix(in srgb, var(--ew-line) 45%, transparent) 100%
782 );
783 background-size: 220% 100%;
784 animation: skeleton-shimmer 1.25s linear infinite;
786@keyframes skeleton-shimmer {
787 0% {
788 background-position: 220% 0;
789 }
790 100% {
791 background-position: -220% 0;
792 }
794/* The loading label reads as active work, not a quiet header. */
795.sugg-loading {
796 color: var(--ew-accent);
797 animation: sugg-label-pulse 1.6s var(--ew-ease) infinite;
799@keyframes sugg-label-pulse {
800 0%,
801 100% {
802 opacity: 0.7;
803 }
804 50% {
805 opacity: 1;
806 }
808/* Suggestions populate in, staggered, the moment they land. A fresh chip (new key)
809 animates; a kept chip (a #131 re-narration) doesn't remount, so it stays put. */
810.sugg-chip {
811 animation: sugg-chip-in var(--ew-dur-2) var(--ew-ease) backwards;
812 animation-delay: calc(var(--sugg-i, 0) * 55ms);
814@keyframes sugg-chip-in {
815 from {
816 opacity: 0;
817 transform: translateY(5px);
818 }
819 to {
820 opacity: 1;
821 transform: none;
822 }
824@media (prefers-reduced-motion: reduce) {
825 .skeleton-line {
826 animation: none;
827 background: var(--ew-tint);
828 }
829 .sugg-loading,
830 .sugg-chip {
831 animation: none;
832 }
834 
835/* The autocomplete pop floats over the dossier on warm paper, not chrome. */
836.search-pop {
837 box-shadow: 0 6px 18px color-mix(in srgb, var(--ew-fg) 10%, transparent);
839 
840/* Suggestions disclosure — reads as a plain label + open list on desktop; a
841 tappable fold (with a caret) on a phone so it doesn't wall off the composer
842 below. Open state is JS-driven (suggestionsOpen): true on desktop, false on a
843 phone. */
844.suggestions-summary {
845 display: flex;
846 align-items: center;
847 gap: 6px;
848 cursor: pointer;
849 list-style: none;
850 user-select: none;
851 padding: 4px 0;
853.suggestions-summary::-webkit-details-marker {
854 display: none;
856.suggestions-summary::marker {
857 content: "";
859.fold-caret {
860 margin-left: auto;
861 color: var(--ew-faint);
862 font-size: var(--ew-fs-label);
863 display: none;
864 transition: transform var(--ew-dur-1) var(--ew-ease);
866@media (max-width: 640px) {
867 .suggestions-summary {
868 padding: 9px 0;
869 min-height: 24px;
870 }
871 .fold-caret {
872 display: inline-block;
873 }
874 .suggestions-fold[open] > .suggestions-summary .fold-caret {
875 transform: rotate(180deg);
876 }
878</style>

Measure, overlays, and the end of the run

Desktop leaves shared no measure: phone-scale type floated in an ~1180px folio. LeafFrame now centers a 760px measure at ≥1024px and steps the folio type up; the Chronicle and the figure's reply set their prose at book measure (70ch / 60ch), and the reply's action — the consequence — reads in full ink instead of muted. The sending corridor doubles its stroke weight and letter size so the held-breath beat has presence. Floating overlays (coach mark, fiction notice, run packs) sit on the new raise surface with warm scrims. And the end screen strikes the brand seal itself: glowing terracotta on a win, a cold faint seal that failed to take on a defeat.

The shared desktop measure.

components/codex/LeafFrame.vue · 157 lines
components/codex/LeafFrame.vue157 lines · Vue
⋯ 116 lines hidden (lines 1–116)
1<template>
2 <!-- One leaf of the manuscript: a folio header (numeral + act name) over a focused
3 body. Each leaf mounts fresh per turn, so the entrance + temporal sheen
4 replay on every page-turn (the conductor swaps leaves by phase). -->
5 <section class="codex-leaf">
6 <span class="sheen" aria-hidden="true" />
7 <header v-if="numeral || name" class="folio">
8 <span class="numeral">{{ numeral }}</span>
9 <h2 class="act-name">{{ name }}</h2>
10 <span v-if="$slots.sub" class="act-sub"><slot name="sub" /></span>
11 </header>
12 <div class="leaf-body"><slot /></div>
13 </section>
14</template>
15 
16<script setup lang="ts">
17defineProps<{ numeral?: string; name?: string }>();
18</script>
19 
20<style scoped>
21.codex-leaf {
22 position: absolute;
23 inset: 0;
24 display: flex;
25 flex-direction: column;
26 padding: 26px 40px 22px 70px;
27 background: var(--ew-bg);
28 animation: leaf-in var(--ew-dur-3) var(--ew-ease);
30.sheen {
31 position: absolute;
32 inset: 0;
33 pointer-events: none;
34 z-index: 5;
35 background: linear-gradient(
36 105deg,
37 transparent 30%,
38 color-mix(in srgb, var(--ew-accent) 16%, transparent) 50%,
39 transparent 70%
40 );
41 background-size: 280% 100%;
42 background-position: 180% 0;
43 opacity: 0;
44 animation: sheen-sweep var(--ew-dur-3) var(--ew-ease);
46.folio {
47 display: flex;
48 align-items: baseline;
49 gap: 12px;
50 flex: none;
51 margin-bottom: 14px;
53.numeral {
54 font-family: var(--ew-serif);
55 font-style: italic;
56 font-size: var(--ew-fs-note);
57 color: var(--ew-faint);
58 min-width: 34px;
60.act-name {
61 margin: 0;
62 font-family: var(--ew-serif);
63 font-weight: 400;
64 font-size: var(--ew-fs-title);
65 color: var(--ew-fg);
67.act-sub {
68 margin-left: auto;
70.leaf-body {
71 flex: 1 1 auto;
72 min-height: 0;
73 display: flex;
74 flex-direction: column;
76/* While the guided first-run coach shows its bottom sheet — a phone always, and any
77 width where the card can't fit beside its control — make this leaf scroll and
78 reserve room beneath, so the taught control lifts clear of the card (via
79 useCoachAnchor's scroll-into-view) instead of hiding under it. Gated on the body
80 flag CoachMark sets, so it's inert unless the sheet is actually up (anchored
81 desktop placement leaves the leaf untouched). */
82:global(body.has-bottom-coach) .leaf-body {
83 overflow-y: auto;
84 overflow-x: hidden;
85 -webkit-overflow-scrolling: touch;
86 padding-bottom: 220px;
88@keyframes leaf-in {
89 from {
90 opacity: 0;
91 transform: translateY(10px) scale(0.992);
92 }
93 to {
94 opacity: 1;
95 transform: none;
96 }
98@keyframes sheen-sweep {
99 0% {
100 background-position: 180% 0;
101 opacity: 0.9;
102 }
103 100% {
104 background-position: -120% 0;
105 opacity: 0;
106 }
108@media (prefers-reduced-motion: reduce) {
109 .codex-leaf {
110 animation: none;
111 }
112 .sheen {
113 animation: none;
114 display: none;
115 }
117/* Desktop: a shared measure so phone-scale type never floats alone in an
118 ~1180px folio — every leaf reads as a set page, not a corner of one. */
119@media (min-width: 1024px) {
120 .act-name {
121 font-size: 24px;
122 }
123 .numeral {
124 font-size: 14px;
125 }
126 .leaf-body,
127 .folio {
128 max-width: 760px;
129 margin-inline: auto;
130 width: 100%;
131 }
133@media (max-width: 760px) {
⋯ 24 lines hidden (lines 134–157)
134 .codex-leaf {
135 padding: 18px 18px 16px 34px;
136 }
137 .act-name {
138 font-size: 17px;
139 }
141@media (max-width: 640px) {
142 .codex-leaf {
143 padding: 14px 15px 12px 22px;
144 }
145 .folio {
146 margin-bottom: 11px;
147 }
148 /* Tall pages (the Dispatch compose) scroll within the leaf on a phone. Pin
149 overflow-x to hidden: a lone overflow-y makes the browser compute overflow-x
150 to auto, which lets the page drift left-right too. We only ever scroll down. */
151 .leaf-body {
152 overflow-y: auto;
153 overflow-x: hidden;
154 -webkit-overflow-scrolling: touch;
155 }
157</style>

CodexSeal as the verdict emblem; defeat re-inks via --seal-fill, no glow.

components/EndGameScreen.vue · 614 lines
components/EndGameScreen.vue614 lines · Vue
⋯ 17 lines hidden (lines 1–17)
1<template>
2 <div
3 v-if="
4 gameStore.gameStatus === 'victory' || gameStore.gameStatus === 'defeat'
5 "
6 ref="dialogRef"
7 data-testid="end-game-screen"
8 role="dialog"
9 aria-modal="true"
10 :aria-labelledby="headingId"
11 class="end-screen fixed inset-0 z-50 overflow-y-auto"
12 :class="isVictory ? 'end--win' : 'end--loss'"
13 @keydown="onKeydown"
14 >
15 <div class="max-w-4xl mx-auto px-5 py-8">
16 <!-- Verdict masthead: a struck seal, the verdict, the objective + final %. The
17 win is illuminated (glowing terracotta seal, serif verdict in ink); the loss
18 is somber (a flat, cold seal and a muted verdict) — the two read apart instantly. -->
19 <div class="text-center border-b ew-line pb-6">
20 <span
21 class="verdict-seal"
22 :class="isVictory ? 'is-win' : 'is-loss'"
23 aria-hidden="true"
24 >
25 <CodexSeal />
26 </span>
27 <h2
28 v-if="isVictory"
⋯ 548 lines hidden (lines 29–576)
29 :id="headingId"
30 ref="headingRef"
31 tabindex="-1"
32 data-testid="victory-message"
33 class="ew-serif text-3xl sm:text-5xl font-bold ew-fg mt-4 outline-none"
34 >
35 History Rewritten
36 </h2>
37 <h2
38 v-else
39 :id="headingId"
40 ref="headingRef"
41 tabindex="-1"
42 data-testid="defeat-message"
43 class="ew-serif text-3xl sm:text-5xl font-bold ew-muted mt-4 outline-none"
44 >
45 The Timeline Held
46 </h2>
47 <p
48 class="text-lg mt-2.5 flex items-center justify-center gap-2 flex-wrap"
49 >
50 <span class="ew-muted inline-flex items-center gap-1.5">
51 <span aria-hidden="true">{{
52 gameStore.currentObjective?.icon
53 }}</span>
54 <strong data-testid="objective-title" class="font-semibold">{{
55 gameStore.currentObjective?.title || "Historical Mission"
56 }}</strong>
57 </span>
58 <span class="ew-hair-c" aria-hidden="true">·</span>
59 <span
60 class="ew-mono font-bold"
61 :class="isVictory ? 'ew-ok' : 'ew-bad'"
62 >
63 <span data-testid="final-progress"
64 >{{ gameStore.objectiveProgress }}%</span
65 >
66 rewritten
67 </span>
68 </p>
69 
70 <RemixCreditLine
71 v-if="remixCredit"
72 :credit="remixCredit"
73 class="mt-2.5"
74 />
75 
76 <p
77 v-if="!isVictory"
78 data-testid="defeat-explanation"
79 class="ew-muted text-sm mt-3 max-w-prose mx-auto"
80 >
81 Your five messages are spent. What you changed remains — but it was
82 not enough, and the objective slipped away.
83 </p>
84 
85 <div
86 v-if="victoryEfficiency"
87 data-testid="efficiency-display"
88 class="mt-3 ew-mono text-sm"
89 >
90 <span class="ew-ok font-semibold">{{ victoryPhrase }}</span>
91 <span class="ew-muted">
92 · {{ victoryEfficiency.messagesUsed }}/{{
93 TOTAL_MESSAGES
94 }}
95 used<span v-if="victoryEfficiency.messagesSaved > 0">
96 · {{ victoryEfficiency.messagesSaved }} saved</span
97 ></span
98 >
99 <span v-if="victoryEfficiency.isEarlyVictory" class="ew-ok">
100 · 🌟 to spare</span
101 >
102 </div>
103 </div>
104 
105 <!-- The Chronicle's final telling — the epilogue. While the terminal telling is
106 still being written (epiloguePending), we show the "final account…" wait, NOT
107 the prior turn's stale, pre-ending telling. When it lands it cross-fades in
108 (mirroring Chronicle.vue's rewrite reveal); a failed final refresh clears
109 pending and falls back to the prior telling, so the panel never hangs. -->
110 <div
111 v-if="chronicle || epiloguePending"
112 data-testid="end-chronicle"
113 class="mt-6"
114 >
115 <span class="ew-label ew-label--rule"
116 ><span aria-hidden="true">📖</span> The Chronicle</span
117 >
118 <Transition :name="epilogueTransition" mode="out-in">
119 <!-- The final account being WRITTEN — the SAME titled article as the settled
120 epilogue below, parsed from the live stream so it already wears its final
121 shape (full width, titled heading, separated paragraphs); an ink cursor
122 marks the writing tip. Nothing relayouts when the telling lands. Mirrors
123 Chronicle.vue's live view via the shared parser in utils/chronicle.ts. -->
124 <article
125 v-if="epiloguePending && streamingNow"
126 key="epilogue-streaming"
127 data-testid="end-chronicle-streaming"
128 aria-busy="true"
129 >
130 <h4
131 data-testid="end-chronicle-title"
132 class="ew-serif text-xl font-semibold ew-fg mb-2.5 leading-tight"
133 >
134 {{ liveEpilogue.title
135 }}<span
136 v-if="!liveEpilogue.paragraphs.length"
137 class="ink-cursor"
138 aria-hidden="true"
139 />
140 </h4>
141 <p
142 v-for="(para, i) in liveEpilogue.paragraphs"
143 :key="i"
144 class="ew-serif ew-muted text-[15px] leading-relaxed mb-2.5 last:mb-0"
145 >
146 {{ para
147 }}<span
148 v-if="i === liveEpilogue.paragraphs.length - 1"
149 class="ink-cursor"
150 aria-hidden="true"
151 />
152 </p>
153 </article>
154 <div
155 v-else-if="epiloguePending"
156 key="epilogue-loading"
157 data-testid="end-chronicle-loading"
158 class="ew-faint italic text-sm"
159 >
160 The chronicler sets down the final account…
161 </div>
162 <article
163 v-else-if="chronicle"
164 key="epilogue-body"
165 data-testid="end-chronicle-body"
166 >
167 <h4
168 data-testid="end-chronicle-title"
169 class="ew-serif text-xl font-semibold ew-fg mb-2.5 leading-tight"
170 >
171 {{ chronicle.title }}
172 </h4>
173 <p
174 v-for="(para, i) in chronicle.paragraphs"
175 :key="i"
176 class="ew-serif ew-muted text-[15px] leading-relaxed mb-2.5 last:mb-0"
177 >
178 {{ para }}
179 </p>
180 </article>
181 </Transition>
182 </div>
183 
184 <!-- The words you sent — the keepsake: your actual messages into the past -->
185 <div v-if="dispatches.length" data-testid="dispatches" class="mt-7">
186 <span class="ew-label ew-label--rule">The words you sent</span>
187 <ul class="space-y-3.5">
188 <li v-for="(d, i) in dispatches" :key="i">
189 <p class="ew-mono text-xs ew-muted">
190 to {{ d.figure }}<span v-if="d.era"> · {{ d.era }}</span>
191 </p>
192 <blockquote
193 class="ew-serif ew-fg text-base leading-relaxed border-l-2 ew-line pl-3 mt-1"
194 >
195 "{{ d.text }}"
196 </blockquote>
197 </li>
198 </ul>
199 </div>
200 
201 <!-- Stats — derived from the real resolved turns -->
202 <div data-testid="game-summary" class="mt-7">
203 <span class="ew-label ew-label--rule">How it played out</span>
204 <div class="flex flex-wrap gap-10">
205 <!-- "Peaked at X%" — shown only when the run slipped from its high point
206 (the peak sits above the floored final %). The case it exists for is a
207 staked last dispatch that crit-fails to 0% from a real peak, where the
208 final % alone reads "as if you did nothing" (#129). A clean win never
209 shows it: its peak IS the final 100%. -->
210 <div v-if="showPeak" data-testid="peak-progress">
211 <div class="ew-mono text-3xl font-extrabold ew-accent leading-none">
212 {{ gameStore.peakProgress }}%
213 </div>
214 <div class="ew-label mt-1">peaked at</div>
215 </div>
216 <div data-testid="message-efficiency">
217 <div class="ew-mono text-3xl font-extrabold ew-fg leading-none">
218 {{ messagesUsed }}
219 </div>
220 <div class="ew-label mt-1">
221 {{ messagesUsed === 1 ? "message" : "messages" }} sent
222 </div>
223 </div>
224 <div v-if="gameSummary.bestRoll" data-testid="best-roll">
225 <div class="ew-mono text-3xl font-extrabold ew-ok leading-none">
226 {{ gameSummary.bestRoll.diceRoll }}
227 </div>
228 <div class="ew-label mt-1">
229 {{ messagesUsed >= 2 ? "best roll" : "the roll that did it" }} ·
230 {{ gameSummary.bestRoll.diceOutcome }}
231 </div>
232 </div>
233 <div
234 v-if="gameSummary.worstRoll && messagesUsed >= 2"
235 data-testid="worst-roll"
236 >
237 <div class="ew-mono text-3xl font-extrabold ew-bad leading-none">
238 {{ gameSummary.worstRoll.diceRoll }}
239 </div>
240 <div class="ew-label mt-1">
241 worst roll · {{ gameSummary.worstRoll.diceOutcome }}
242 </div>
243 </div>
244 <!-- The run's luck read (#129's sibling, issue #165): how the DICE themselves
245 ran — the natural d20s, before any craft tilt — against their 10.5 mean.
246 It names a variance-driven loss as cold dice, not bad skill. Shown once
247 there are ≥2 rolls to read "overall" (a lone roll is already "the roll
248 that did it" above). The number borrows the same red/green/ink the
249 best/worst stats use; the tooltip carries the below-median detail. -->
250 <div
251 v-if="gameSummary.luck"
252 data-testid="luck-read"
253 :title="luckTitle"
254 >
255 <div
256 class="ew-mono text-3xl font-extrabold leading-none"
257 :class="luckClass"
258 >
259 {{ gameSummary.luck.averageRoll.toFixed(1) }}
260 </div>
261 <div class="ew-label mt-1">
262 avg vs {{ gameSummary.luck.expectedRoll }} · {{ luckPhrase }}
263 </div>
264 </div>
265 </div>
266 </div>
267 
268 <!-- The history you wrote — the Spine itself, exactly as it filled in during
269 the run. Read-only here: no live "▷ now" present marker, no "the timeline
270 trembles…" empty state. Same component the player scrolled and tapped on
271 the board, so the verdict's history reads as richly as it did in play —
272 valence dots, ⚡/⚑ badges, the roll, and the Δ equation all carry over. -->
273 <div v-if="timeline.length > 0" class="mt-6">
274 <TimelineLedger readonly />
275 </div>
276 
277 <!-- Share this run (issue #88) — once the snapshot is durably saved. Proud of the
278 rewrite? Post it: the public page, the unfurl, the share card. -->
279 <div v-if="canShare" class="mt-8">
280 <ShareControls :run-id="shareRunId" :share-text="shareText" />
281 </div>
282 
283 <div class="mt-9 border-t ew-line pt-6 flex items-center gap-3 flex-wrap">
284 <button
285 ref="playAgainRef"
286 type="button"
287 data-testid="play-again-button"
288 class="ew-btn ew-btn--primary"
289 @click="playAgain"
290 >
291 Try a new timeline <span aria-hidden="true"></span>
292 </button>
293 <!-- The finished run just joined your saved-runs history, so the end screen
294 offers an obvious way to reach it: a secondary button beside the primary
295 "Try a new timeline" (the button-styled /runs link mirrors the
296 retrospective's "Back to your runs", pages/runs/[id].vue), carrying the ◷
297 glyph and "Your runs" wording from the account popover (RunsBalance.vue). -->
298 <NuxtLink
299 to="/runs"
300 data-testid="end-your-runs-link"
301 class="ew-btn inline-flex items-center gap-1.5"
302 >
303 <span aria-hidden="true"></span> Your runs
304 </NuxtLink>
305 <span class="ew-faint text-xs inline-flex items-center gap-1.5">
306 <span
307 class="ew-dot"
308 :class="isVictory ? 'ew-dot--ok' : 'ew-dot--bad'"
309 aria-hidden="true"
310 />
311 this timeline is written.
312 </span>
313 </div>
314 </div>
315 </div>
316</template>
317 
318<script setup lang="ts">
319/**
320 * EndGameScreen — the mission debrief. A full-bleed takeover (not a modal card):
321 * verdict line, the Chronicle epilogue + big-number stat trio, and the history you
322 * wrote. All stats derive from the real resolved turns.
323 */
324import { ref, computed, watch, nextTick } from "vue";
325import { TOTAL_MESSAGES } from "~/utils/game-config";
326import { useGameStore } from "~/stores/game";
327import { useChronicleStore } from "~/stores/chronicle";
328import { usePrefersReducedMotion } from "~/composables/usePrefersReducedMotion";
329import { buildShareText, type RemixCredit } from "~/utils/run-snapshot";
330import { parseLiveChronicle } from "~/utils/chronicle";
331 
332const gameStore = useGameStore();
333const chronicleStore = useChronicleStore();
334 
335// When this run is a replay (issue #89), the "remixed from" credit, from the live seed —
336// shown on the end screen the moment the replay resolves. The title is omitted (the
337// objective is already in the masthead above), so it reads "Remixed from <source>".
338const remixCredit = computed<RemixCredit | null>(() =>
339 gameStore.replayState
340 ? {
341 attribution: gameStore.replayState.sourceAttribution,
342 objectiveTitle: "",
343 sourceToken: gameStore.replayState.sourceToken,
344 }
345 : null,
346);
347const reduced = usePrefersReducedMotion();
348 
349const headingId = `end-game-heading-${Math.random().toString(36).slice(2, 11)}`;
350 
351// Modal focus management: it's a true takeover (aria-modal), so when it opens we
352// move focus to the VERDICT heading (tabindex=-1: announced, focusable, outside
353// the Tab cycle), trap Tab inside, and restore focus when the run ends. Focusing
354// the heading — not the bottom Play Again button — also means the screen opens at
355// the top: the verdict and the Chronicle epilogue are the first things seen, not
356// scrolled past below the fold.
357const dialogRef = ref<HTMLElement | null>(null);
358const headingRef = ref<HTMLElement | null>(null);
359const playAgainRef = ref<HTMLElement | null>(null);
360let prevFocus: HTMLElement | null = null;
361 
362watch(
363 () => gameStore.gameStatus,
364 async (s) => {
365 if (typeof document === "undefined") return;
366 if (s === "victory" || s === "defeat") {
367 prevFocus = document.activeElement as HTMLElement | null;
368 await nextTick();
369 if (dialogRef.value) dialogRef.value.scrollTop = 0;
370 headingRef.value?.focus();
371 } else if (prevFocus) {
372 prevFocus.focus?.();
373 prevFocus = null;
374 }
375 },
376);
377 
378function onKeydown(e: KeyboardEvent) {
379 if (e.key !== "Tab" || !dialogRef.value) return;
380 const focusables = dialogRef.value.querySelectorAll<HTMLElement>(
381 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])',
382 );
383 if (!focusables.length) return;
384 const first = focusables[0];
385 const last = focusables[focusables.length - 1];
386 const active = document.activeElement;
387 if (e.shiftKey && active === first) {
388 e.preventDefault();
389 last.focus();
390 } else if (!e.shiftKey && active === last) {
391 e.preventDefault();
392 first.focus();
393 }
395 
396const isVictory = computed(() => gameStore.gameStatus === "victory");
397const victoryEfficiency = computed(() => gameStore.getVictoryEfficiency());
398 
399// The verdict never deflates: spending every message is reframed as "Hard-won," not
400// the limp "Standard." Only the laudatory tiers keep their rating as an adjective.
401const victoryPhrase = computed(() => {
402 const rating = victoryEfficiency.value?.efficiencyRating;
403 if (!rating) return "Victory";
404 return rating === "Standard" ? "Hard-won victory" : `${rating} victory`;
405});
406const messagesUsed = computed(
407 () => TOTAL_MESSAGES - gameStore.remainingMessages,
408);
409const gameSummary = computed(() => gameStore.gameSummary);
410 
411// The luck read's styling + phrasing (#165). Cold dice borrow the loss red, hot dice
412// the win green, an even run the neutral ink — the same at-a-glance colour language
413// as the best/worst-roll stats beside it. The phrasing stays honest: it names "the
414// dice," never the player's craft. The tooltip adds the below-median detail the
415// compact label leaves out.
416const luckClass = computed(() => {
417 const v = gameSummary.value.luck?.verdict;
418 return v === "cold" ? "ew-bad" : v === "hot" ? "ew-ok" : "ew-fg";
419});
420const luckPhrase = computed(() => {
421 const v = gameSummary.value.luck?.verdict;
422 return v === "cold"
423 ? "the dice ran cold"
424 : v === "hot"
425 ? "the dice ran hot"
426 : "the dice ran even";
427});
428const luckTitle = computed(() => {
429 const l = gameSummary.value.luck;
430 if (!l) return undefined;
431 return `The dice ran ${l.verdict}: your rolls averaged ${l.averageRoll.toFixed(1)} vs ${l.expectedRoll} expected, ${l.belowMedianCount} of ${l.rollCount} below the median.`;
432});
433 
434// "Peaked at X%" surfaces only when the run lost ground from its high-water mark —
435// the peak above the final %. The case it answers is a staked last dispatch that
436// crit-fails to 0% from a real peak, where the floored final % alone would read "as
437// if the player did nothing" (#129). A clean win never shows it (peak === final 100%).
438const showPeak = computed(
439 () => gameStore.peakProgress > gameStore.objectiveProgress,
440);
441 
442// Share affordance (issue #88): only for a run that's been durably saved (so there's a
443// server-side record to make public). `canShare` true ⟹ runId is a real saved uuid.
444const canShare = computed(
445 () => gameStore.runSnapshotSaved && !!gameStore.runId,
446);
447const shareRunId = computed(() => gameStore.runId ?? "");
448const shareText = computed(() =>
449 buildShareText(
450 gameStore.currentObjective?.title ?? "",
451 isVictory.value ? "victory" : "defeat",
452 gameStore.objectiveProgress,
453 ),
454);
455const timeline = computed(() => gameStore.timelineEvents);
456const chronicle = computed(() => chronicleStore.chronicle);
457// The epilogue (terminal telling) is still being written — show the wait, never the
458// prior turn's stale telling. The cross-fade mirrors Chronicle.vue and is dropped
459// under prefers-reduced-motion (the empty name disables the Transition's classes).
460const epiloguePending = computed(() => chronicleStore.epiloguePending);
461const epilogueTransition = computed(() => (reduced.value ? "" : "epilogue"));
462// The epilogue is being written right now — show the live buffer (only when its
463// stream narrates the live ledger, never a superseded prior stream).
464const streamingNow = computed(
465 () =>
466 chronicleStore.chronicleStreaming &&
467 chronicleStore.chronicleStreamLen === gameStore.timelineEvents.length &&
468 chronicleStore.chronicleStream.length > 0,
469);
470// The epilogue mid-stream, parsed into the settled {title, paragraphs} shape so the
471// live account already wears its final form — full width, titled heading, separated
472// paragraphs — and nothing relayouts when the terminal telling lands. Same parser and
473// intent as Chronicle.vue's live view (utils/chronicle.ts).
474const liveEpilogue = computed(() =>
475 parseLiveChronicle(chronicleStore.chronicleStream),
476);
477 
478// The keepsake: the player's verbatim messages, paired with whom/when they reached.
479const dispatches = computed(() => {
480 const users = gameStore.messageHistory.filter((m) => m.sender === "user");
481 const events = gameStore.timelineEvents;
482 return users.map((m, i) => ({
483 text: m.text,
484 figure: m.figureName || events[i]?.figureName || "the past",
485 era: events[i]?.era || "",
486 }));
487});
488 
489// The page owns the FULL reset (store + figure input + composer), so Play Again
490// emits instead of resetting the store directly — the two reset paths used to
491// diverge here, leaving the previous run's figure stranded in the input.
492const emit = defineEmits<{ "play-again": [] }>();
493const playAgain = () => {
494 emit("play-again");
495};
496</script>
497 
498<style scoped>
499/* The epilogue reveal — the terminal telling cross-fades in over the "final account…"
500 wait, the same gentle opacity fade Chronicle.vue uses for a rewrite. Gated by the
501 transition NAME (empty under prefers-reduced-motion), so reduced-motion gets an
502 instant swap with no classes attached. */
503.epilogue-enter-active,
504.epilogue-leave-active {
505 transition: opacity var(--ew-dur-3) var(--ew-ease);
507.epilogue-enter-from,
508.epilogue-leave-to {
509 opacity: 0;
511 
512/* The epilogue being written — an ink cursor at the writing tip (the prose itself
513 reuses the settled telling's serif article, so it needs no separate type styling). */
514.ink-cursor {
515 display: inline-block;
516 width: 2px;
517 height: 1.05em;
518 margin-left: 1px;
519 vertical-align: text-bottom;
520 background: var(--ew-accent);
521 animation: ink-blink 1s steps(2, start) infinite;
523@keyframes ink-blink {
524 0%,
525 100% {
526 opacity: 1;
527 }
528 50% {
529 opacity: 0;
530 }
532@media (prefers-reduced-motion: reduce) {
533 .ink-cursor {
534 animation: none;
535 opacity: 0.7;
536 }
538 
539/* Win and loss share the page frame but not its mood. The win is illuminated — a warm
540 wash blooms from the top; the loss is a colder, dimmer ground that has settled back. */
541.end-screen {
542 background: var(--ew-bg);
543 animation: end-in var(--ew-dur-3) var(--ew-ease) both;
545@keyframes end-in {
546 from {
547 opacity: 0;
548 }
549 to {
550 opacity: 1;
551 }
553@media (prefers-reduced-motion: reduce) {
554 .end-screen {
555 animation: none;
556 }
558.end--win {
559 background:
560 radial-gradient(
561 135% 70% at 50% -12%,
562 color-mix(in srgb, var(--ew-accent) 13%, transparent),
563 transparent 68%
564 ),
565 var(--ew-bg);
567.end--loss {
568 background:
569 radial-gradient(
570 135% 70% at 50% -12%,
571 color-mix(in srgb, var(--ew-bad) 6%, transparent),
572 transparent 60%
573 ),
574 var(--ew-bg);
576 
577/* The struck seal — the verdict's emblem. The win seal glows like fresh sealing wax;
578 the loss seal is a cold, flat stamp. */
579.verdict-seal {
580 display: inline-block;
581 width: 64px;
582 height: 64px;
583 border-radius: 50%;
585.verdict-seal.is-win {
586 box-shadow:
587 0 0 0 5px color-mix(in srgb, var(--ew-accent) 12%, transparent),
588 0 0 30px color-mix(in srgb, var(--ew-accent) 32%, transparent);
589 animation: seal-strike 0.6s var(--ew-ease-spring) both;
591/* The seal that failed to take: re-inked cold via --seal-fill, no glow. */
592.verdict-seal.is-loss {
593 --seal-fill: var(--ew-faint);
595 
596@keyframes seal-strike {
597 0% {
⋯ 17 lines hidden (lines 598–614)
598 opacity: 0;
599 transform: scale(0.5) rotate(-12deg);
600 }
601 60% {
602 transform: scale(1.12) rotate(3deg);
603 }
604 100% {
605 opacity: 1;
606 transform: scale(1) rotate(0);
607 }
609@media (prefers-reduced-motion: reduce) {
610 .verdict-seal.is-win {
611 animation: none;
612 }
614</style>