What this is, and why it exists

A ground-up rebuild of the game's presentation layer under one thesis: the UI is not a website about a magical manuscript — the UI is the manuscript, and the app is the desk it rests on. The prior contrast pass on this branch had made thirty-four individually-correct fixes and played worse; the diagnosis (committed as docs/design/illuminated-codex-problem-book.md) traced the failure to the design system itself — a hairlines-only "Spine Console" manifesto that outlawed the material tools the codex fiction needs.

The replacement language was designed before it was coded: full-fidelity HTML mockups (committed under docs/design/atelier/), two competing art directions blind-judged by four panels, a synthesis spec (docs/design/illuminated-codex-language.md), a twelve-surface rebuild, and a five-persona playtest round whose twenty-three adjudicated fixes are all in. No store, server, or mechanics code changed; every test-id and aria contract survived. lint 0 errors · vitest 1430/1430 · typecheck clean · e2e 38/38.

Materials and inks — the token system

main.css opens with the manifesto's replacement and the material system: paper (three tones + rules), ink (five voices: reading ink, quiet, faint, rubrication, ceremonial gold), wax, and the desk. Dark mode is a re-lighting — lamplit vellum, ember rubric, near-black desk — expressed entirely through the same tokens. Legacy --ew-* names alias onto the new materials so the legal/settings/steward surfaces keep working untouched.

Materials, inks, and the aliases; then the candlelit dark block.

assets/css/main.css · 968 lines
assets/css/main.css968 lines · CSS
⋯ 25 lines hidden (lines 1–25)
1/**
2 * Main CSS — Everwhen · THE ILLUMINATED CODEX
3 *
4 * The UI is not a website about a magical manuscript. The UI IS the manuscript,
5 * and the app is the desk it rests on. Materials over chrome: paper has fiber,
6 * light exists, wax has relief. One accent — rubrication (red ink marks what
7 * matters; the wax seal is its material twin). Gold is ceremonial, at most once
8 * per screen, never interactive. Every glyph is drawn in the seal's hand
9 * (components/codex/marks/) — no OS emoji, no dingbat fonts.
10 *
11 * The material test for any effect: does it describe paper, ink, wax, or
12 * light? If not, it doesn't ship.
13 *
14 * Full language: docs/design/illuminated-codex-language.md (spec) and
15 * docs/design/illuminated-codex-problem-book.md (the diagnosis it answers).
16 *
17 * Two class families:
18 * .cx-* — the codex dialects (game surfaces).
19 * .ew-* — legacy helpers kept for legal/settings/steward surfaces; they now
20 * resolve to codex materials via the alias tokens below.
21 */
22 
23@import "tailwindcss";
24@import "@nuxt/ui";
25 
26/* ── Materials & inks ────────────────────────────────────────────────────── */
27:root {
28 /* paper */
29 --ew-paper-hi: #faf4e6;
30 --ew-paper: #f4ecd8;
31 --ew-paper-lo: #e8dcc0;
32 --ew-rule: #cbb894; /* drawn rules */
33 --ew-rule-feint: #ddd0b0; /* baseline feints, hairline folds */
34 
35 /* ink */
36 --ew-ink: #2a2118;
37 --ew-ink-quiet: #5f5240;
38 --ew-ink-faint: #857358;
39 --ew-rubric: #96291c; /* THE accent — rubrication */
40 --ew-gold: #a97e1c; /* ceremonial only */
41 --ew-ok: #43601f;
42 --ew-bad: #6d1a10;
43 
44 /* wax */
45 --ew-wax: #a84b28;
46 --ew-wax-hi: #c96f43;
47 --ew-wax-lo: #7c3116;
48 
49 /* the desk */
50 --ew-desk: #2e2013;
51 --ew-desk-hi: #4a3620;
52 --ew-desk-ink: #cdbb96; /* legible directly on the desk */
53 /* stacked page-edge inks (pre-resolved: color-mix inside box-shadow
54 shorthands poisons chromium's rasterizer — resolve mixes here, in
55 custom-property values, never inline in a shadow list) */
56 --ew-page-edge-1: var(--ew-paper-lo);
57 --ew-page-edge-2: color-mix(in srgb, var(--ew-paper-lo) 78%, black);
58 --ew-page-edge-3: color-mix(in srgb, var(--ew-paper-lo) 58%, black);
59 --ew-rubric-halo: color-mix(in srgb, var(--ew-rubric) 14%, transparent);
60 /* leaf texture + lighting, themed HERE (tokens only): styling a
61 :global(html.dark) descendant from scoped CSS once compiled down to a bare
62 html.dark rule and screen-blended the ROOT over the white canvas — the
63 whole app rendered white in chromium. Theme via inherited custom
64 properties, never global selectors (see CLAUDE.md scoped-CSS traps). */
65 --ew-tex-blend: multiply;
66 --ew-tex-op-laid: 1;
67 --ew-tex-op-mottle: 0.35;
68 --ew-leaf-shadow:
69 2px 2px 0 0 var(--ew-page-edge-1), 4px 4px 0 0 var(--ew-page-edge-2),
70 6px 6px 0 0 var(--ew-page-edge-3), 0 24px 70px -18px rgba(0, 0, 0, 0.65),
71 0 60px 130px -40px rgba(0, 0, 0, 0.5);
72 --ew-scrim: color-mix(in srgb, var(--ew-desk) 60%, black);
73 
74 /* faces */
75 --ew-serif:
76 "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, ui-serif, serif;
77 --ew-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
78 
79 /* type scale (the ladder survives; display/title retuned to the spec) */
80 --ew-fs-micro: 10px;
81 --ew-fs-label: 11px;
82 --ew-fs-meta: 12.5px; /* scribe mono */
83 --ew-fs-note: 13.5px; /* glosses, footnotes */
84 --ew-fs-body: 15px;
85 --ew-fs-lede: 18px;
86 --ew-fs-title: 30px;
87 --ew-fs-display: 44px;
88 
89 /* motion */
90 --ew-dur-1: 140ms; /* micro */
91 --ew-dur-2: 280ms; /* standard */
92 --ew-dur-3: 520ms; /* expressive */
93 --ew-beat: 900ms; /* dramatic — climaxes only */
94 --ew-ease: cubic-bezier(0.2, 0.7, 0.3, 1);
95 --ew-ease-spring: cubic-bezier(0.2, 1.4, 0.4, 1);
96 --ew-ease-strike: cubic-bezier(0.5, 0, 0.2, 1.35); /* anticipation → strike */
97 
98 /* ── legacy aliases (legal / settings / steward surfaces) ─────────────── */
99 --ew-fg: var(--ew-ink);
100 --ew-mut: var(--ew-ink-quiet);
101 --ew-faint: var(--ew-ink-faint);
102 --ew-hair: var(--ew-rule);
103 --ew-line: var(--ew-rule-feint);
⋯ 1 line hidden (lines 104–104)
104 --ew-edge: #97876a;
105 --ew-gauge: color-mix(in srgb, var(--ew-ink-faint) 85%, var(--ew-paper));
106 --ew-bg: var(--ew-paper);
107 --ew-tint: color-mix(in srgb, var(--ew-paper) 82%, var(--ew-paper-lo));
108 --ew-raise: var(--ew-paper-hi);
109 --ew-raise-line: var(--ew-rule);
110 --ew-accent: var(--ew-rubric);
111 --ew-warn: #8a5a16;
112 --ew-desk-edge: color-mix(in srgb, var(--ew-desk) 70%, black);
113 --ew-desk-ink-strong: #f6efe2;
114 --ew-desk-ink-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
116 
117/* Night is a re-lighting, not a palette swap: the same page by candlelight.
118 The paper darkens to lamplit vellum, the rubric warms to ember, the desk
119 falls to near-black, and the leaf carries an inset warm glow (the candle). */
120.dark {
121 --ew-paper-hi: #322917;
122 --ew-paper: #241d12;
123 --ew-paper-lo: #170f08;
124 --ew-rule: #4d4128;
125 --ew-rule-feint: #352c1b;
126 
127 --ew-ink: #e6dbc4;
128 --ew-ink-quiet: #b3a688;
129 --ew-ink-faint: #8d7f64;
130 --ew-rubric: #d96b4a;
131 --ew-gold: #cfa143;
132 --ew-ok: #9dc178;
133 --ew-bad: #e0705c;
134 
135 --ew-wax: #b85731;
136 --ew-wax-hi: #e08a58;
137 --ew-wax-lo: #6e2c12;
138 
139 --ew-desk: #0d0906;
140 --ew-desk-hi: #1f1509;
⋯ 828 lines hidden (lines 141–968)
141 --ew-desk-ink: #b5a583;
142 
143 --ew-edge: #80735c;
144 --ew-warn: #d8a34d;
145 
146 /* night: textures lighten instead of darken; the candle glows in the leaf */
147 --ew-tex-blend: screen;
148 --ew-tex-op-laid: 0.5;
149 --ew-tex-op-mottle: 0.18;
150 --ew-leaf-shadow:
151 2px 2px 0 0 #2b2214, 4px 4px 0 0 #201a0f, 6px 6px 0 0 #16110a,
152 0 24px 70px -18px rgba(0, 0, 0, 0.9),
153 inset 0 0 140px -20px rgba(255, 170, 80, 0.16);
155 
156/* ── Base ────────────────────────────────────────────────────────────────── */
157body {
158 background: var(--ew-paper);
159 color: var(--ew-ink);
160 /* the manuscript's voice is the app's voice */
161 font-family: var(--ew-serif);
162 font-size: var(--ew-fs-body);
163 line-height: 1.55;
165 
166::selection {
167 background: color-mix(in srgb, var(--ew-rubric) 22%, transparent);
169* {
170 scrollbar-width: thin;
171 scrollbar-color: var(--ew-rule) transparent;
173::-webkit-scrollbar {
174 width: 11px;
175 height: 11px;
177::-webkit-scrollbar-track {
178 background: transparent;
180::-webkit-scrollbar-thumb {
181 background: var(--ew-rule);
182 border-radius: 999px;
183 border: 3px solid var(--ew-paper);
185::-webkit-scrollbar-thumb:hover {
186 background: var(--ew-ink-faint);
188 
189/* Focus: rubric ink, everywhere. */
190button:focus-visible,
191a:focus-visible,
192summary:focus-visible,
193[role="button"]:focus-visible,
194[tabindex]:focus-visible {
195 outline: 2px solid var(--ew-rubric);
196 outline-offset: 2px;
197 border-radius: 3px;
199 
200/* ══ THE CODEX DIALECTS ═══════════════════════════════════════════════════
201 Five voices, and only five: rubric caps · glosses · inked actions ·
202 instruments (drawn per-component in SVG on these tokens) · tables. */
203 
204/* 1 · Rubric caps — running heads, kickers, table titles, the seal word.
205 Serif, uppercase, letterspaced. Never bold-sans. */
206.cx-caps {
207 font-family: var(--ew-serif);
208 font-size: 12.5px;
209 letter-spacing: 0.22em;
210 text-transform: uppercase;
211 color: var(--ew-ink-quiet);
213.cx-caps--rubric {
214 color: var(--ew-rubric);
216.cx-caps--small {
217 font-size: var(--ew-fs-label);
218 letter-spacing: 0.18em;
220 
221/* 2 · Glosses — every secondary read lives in the margin voice: italic serif,
222 paraph- or dagger-marked, quiet ink with rubric emphasis spans. */
223.cx-gloss {
224 position: relative;
225 padding-left: 20px;
226 font-size: var(--ew-fs-note);
227 line-height: 1.5;
228 font-style: italic;
229 color: var(--ew-ink-quiet);
231.cx-gloss .codex-mark {
232 position: absolute;
233 left: 0;
234 top: 3px;
235 color: var(--ew-rubric);
237.cx-gloss b,
238.cx-gloss strong {
239 font-style: normal;
240 font-weight: 600;
241 color: var(--ew-ink);
243.cx-gloss .cx-em {
244 color: var(--ew-rubric);
245 font-style: normal;
246 font-weight: 600;
248.cx-gloss .cx-quiet {
249 color: var(--ew-ink-faint);
251 
252/* 3 · Inked actions — every interactive that is not the seal: rubric caps with
253 a drawn mark; underline arrives on hover; press settles like a stamp. */
254.cx-action {
255 appearance: none;
256 background: none;
257 border: 0;
258 padding: 0;
259 display: inline-flex;
260 align-items: center;
261 gap: 7px;
262 font-family: var(--ew-serif);
263 font-size: 12.5px;
264 letter-spacing: 0.16em;
265 text-transform: uppercase;
266 font-weight: 600;
267 color: var(--ew-rubric);
268 cursor: pointer;
269 text-decoration: none;
270 transition:
271 color var(--ew-dur-1) var(--ew-ease),
272 transform var(--ew-dur-1) var(--ew-ease);
274.cx-action:hover {
275 text-decoration: underline;
276 text-underline-offset: 4px;
277 text-decoration-thickness: 1px;
279.cx-action:active {
280 transform: translateY(0.5px);
282.cx-action:disabled {
283 color: var(--ew-ink-faint);
284 cursor: not-allowed;
285 text-decoration: none;
287.cx-action--quiet {
288 color: var(--ew-ink-faint);
289 font-style: italic;
290 text-transform: none;
291 letter-spacing: 0;
292 font-weight: 400;
293 font-size: var(--ew-fs-note);
295.cx-action--quiet:hover {
296 color: var(--ew-ink-quiet);
298 
299/* 4 · Instruments — quantities are engraved scales with an index, drawn in
300 component SVG on these inks. The shared primitive here is only the
301 inkwell rule (characters / simple fills). */
302.cx-inkrule {
303 height: 3px;
304 background: var(--ew-rule-feint);
305 position: relative;
307.cx-inkrule > i {
308 position: absolute;
309 inset: 0;
310 right: auto;
311 background: var(--ew-ink-quiet);
312 transition: width var(--ew-dur-2) var(--ew-ease);
314 
315/* 5 · Tables — disclosed rules. Serif rows, scribe-mono figures, the struck
316 row in full ink with its valence name. */
317.cx-table {
318 width: 100%;
319 border-collapse: collapse;
320 font-size: 14.5px;
322.cx-table td {
323 padding: 6px 4px;
324 border-top: 1px solid var(--ew-rule-feint);
325 color: var(--ew-ink-faint);
327.cx-table tr:first-child td {
328 border-top: 0;
330.cx-table .cx-mono {
331 font-family: var(--ew-mono);
332 font-size: var(--ew-fs-meta);
334.cx-table tr.struck td {
335 color: var(--ew-ink);
336 font-weight: 600;
337 border-top-color: var(--ew-rule);
339.cx-table tr.struck + tr td {
340 border-top-color: var(--ew-rule);
342 
343/* Scribe mono — the one non-serif register: equations, of-N counters, ranges. */
344.cx-mono {
345 font-family: var(--ew-mono);
346 font-size: var(--ew-fs-meta);
348 
349/* Footnotes — the dagger apparatus. */
350.cx-footnote {
351 font-size: var(--ew-fs-note);
352 color: var(--ew-ink-faint);
353 font-style: italic;
355.cx-footnote .codex-mark,
356.cx-fnmark {
357 color: var(--ew-rubric);
359.cx-fnmark {
360 font-style: normal;
361 font-size: 0.8em;
362 vertical-align: super;
363 line-height: 0;
364 margin-left: 2px;
366 
367/* ── Writing on the page (the core verb) ─────────────────────────────────── */
368/* Ruled feints drawn on the paper itself; the caret is the player's pen. */
369.cx-writing {
370 width: 100%;
371 background: transparent;
372 border: 0;
373 resize: none;
374 color: var(--ew-ink);
375 caret-color: var(--ew-rubric);
376 font-family: var(--ew-serif);
377 font-size: 21px;
378 line-height: 44px;
379 background-image: repeating-linear-gradient(
380 to bottom,
381 transparent 0 43px,
382 var(--ew-rule-feint) 43px 44px
383 );
384 background-origin: content-box;
385 outline: none;
386 padding: 0;
388.cx-writing::placeholder {
389 color: var(--ew-ink-faint);
390 font-style: italic;
392/* iOS zoom floor: never under 16px on touch. */
393@media (pointer: coarse) {
394 .cx-writing {
395 font-size: 19px;
396 line-height: 40px;
397 background-image: repeating-linear-gradient(
398 to bottom,
399 transparent 0 39px,
400 var(--ew-rule-feint) 39px 40px
401 );
402 }
404 
405/* ── Ink behaviours (motion) ─────────────────────────────────────────────── */
406/* Rules draw in from their anchor. */
407.cx-rule-in {
408 transform-origin: left center;
409 animation: cx-rule-in var(--ew-dur-3) var(--ew-ease) both;
411@keyframes cx-rule-in {
412 from {
413 transform: scaleX(0);
414 }
415 to {
416 transform: scaleX(1);
417 }
419/* Rubric words ink themselves: blur resolves to a sharp stroke. */
420.cx-ink-in {
421 animation: cx-ink-in var(--ew-dur-3) var(--ew-ease) both;
423@keyframes cx-ink-in {
424 from {
425 opacity: 0;
426 filter: blur(2px);
427 }
428 to {
429 opacity: 1;
430 filter: blur(0);
431 }
433/* The stamp strike: drop, squash, settle. For wax and verdicts. */
434.cx-strike {
435 animation: cx-strike var(--ew-dur-3) var(--ew-ease-spring) both;
437@keyframes cx-strike {
438 0% {
439 opacity: 0;
440 transform: scale(1.35);
441 }
442 55% {
443 opacity: 1;
444 transform: scale(0.94);
445 }
446 100% {
447 opacity: 1;
448 transform: scale(1);
449 }
451/* Page content arrives like a settled sheet. */
452.cx-settle {
453 animation: cx-settle var(--ew-dur-3) var(--ew-ease) both;
455@keyframes cx-settle {
456 from {
457 opacity: 0;
458 transform: translateY(8px);
459 }
460 to {
461 opacity: 1;
462 transform: none;
463 }
465/* The one spinner survives as an inked ring. */
466.ew-spinner {
467 display: inline-block;
468 width: 0.9em;
469 height: 0.9em;
470 border: 2px solid currentColor;
471 border-bottom-color: transparent;
472 border-radius: 50%;
473 animation: ew-spin 0.7s linear infinite;
474 vertical-align: -0.12em;
476@keyframes ew-spin {
477 to {
478 transform: rotate(360deg);
479 }
481/* Pending shimmer — an ink wash while the chronicler writes. */
482.ew-shimmer {
483 background: linear-gradient(
484 90deg,
485 var(--ew-rule-feint) 20%,
486 var(--ew-rule) 40%,
487 var(--ew-rule-feint) 60%
488 );
489 background-size: 200% 100%;
490 animation: ew-shimmer 1.5s ease-in-out infinite;
491 border-radius: 2px;
493@keyframes ew-shimmer {
494 from {
495 background-position: 200% 0;
496 }
497 to {
498 background-position: -200% 0;
499 }
501 
502/* ── Legacy helpers (legal · settings · steward · not the game surface) ──── */
503.ew-fg {
504 color: var(--ew-fg);
506.ew-muted {
507 color: var(--ew-mut);
509.ew-hair-c {
510 color: var(--ew-hair);
512.ew-faint {
513 color: var(--ew-faint);
515.ew-accent {
516 color: var(--ew-accent);
518.ew-bg {
519 background: var(--ew-bg);
521.ew-tint {
522 background: var(--ew-tint);
524.ew-mono {
525 font-family: var(--ew-mono);
527.ew-ok {
528 color: var(--ew-ok);
530.ew-warn {
531 color: var(--ew-warn);
533.ew-bad {
534 color: var(--ew-bad);
536.ew-serif {
537 font-family: var(--ew-serif);
539.ew-line {
540 border-color: var(--ew-line);
542.ew-edge {
543 border-color: var(--ew-edge);
545.ew-hover:hover:not(:disabled) {
546 background: var(--ew-tint);
548.ew-hover-fg {
549 color: var(--ew-mut);
551.ew-hover-fg:hover {
552 color: var(--ew-fg);
554.ew-dim {
555 opacity: 0.68;
556 transition: opacity var(--ew-dur-2) var(--ew-ease);
558.ew-label {
559 font-size: var(--ew-fs-label);
560 text-transform: uppercase;
561 letter-spacing: 0.1em;
562 font-weight: 600;
563 color: var(--ew-mut);
565.ew-label--rule {
566 display: block;
567 padding-bottom: 6px;
568 border-bottom: 1px solid var(--ew-line);
569 margin-bottom: 12px;
571.ew-prompt {
572 font-size: var(--ew-fs-note);
573 color: var(--ew-mut);
574 text-transform: none;
575 letter-spacing: normal;
577.ew-hint {
578 font-size: var(--ew-fs-note);
579 line-height: 1.4;
580 color: var(--ew-mut);
582.ew-hint::before {
583 content: "\203A\00a0\00a0";
584 color: var(--ew-accent);
585 font-weight: 700;
587.ew-blocked {
588 border-left: 2px solid var(--ew-bad);
589 padding-left: 8px;
590 color: var(--ew-mut);
592.ew-blocked .ew-blocked-tag {
593 font-weight: 700;
594 text-transform: uppercase;
595 letter-spacing: 0.04em;
596 color: var(--ew-bad);
598.ew-dot {
599 display: inline-block;
600 width: 9px;
601 height: 9px;
602 border-radius: 50%;
603 flex: none;
605.ew-dot--ok {
606 background: var(--ew-ok);
608.ew-dot--warn {
609 background: var(--ew-warn);
611.ew-dot--bad {
612 background: var(--ew-bad);
614.ew-dot--mut {
615 background: var(--ew-faint);
617.ew-dot--accent {
618 background: var(--ew-accent);
620.ew-dot--hollow {
621 background: transparent;
622 border: 2px solid var(--ew-faint);
624.ew-input {
625 width: 100%;
626 background: var(--ew-bg);
627 color: var(--ew-fg);
628 border: 1px solid var(--ew-edge);
629 border-radius: 2px;
630 padding: 9px 11px;
631 font: inherit;
633.ew-input::placeholder {
634 color: var(--ew-faint);
636.ew-input:focus,
637.ew-input:focus-visible {
638 outline: 2px solid var(--ew-accent);
639 outline-offset: -1px;
640 border-color: transparent;
642.ew-select {
643 width: 100%;
644 background: var(--ew-bg);
645 color: var(--ew-fg);
646 border: 1px solid var(--ew-edge);
647 border-radius: 2px;
648 padding: 8px 11px;
649 font: inherit;
650 cursor: pointer;
652.ew-select:focus,
653.ew-select:focus-visible {
654 outline: 2px solid var(--ew-accent);
655 outline-offset: -1px;
656 border-color: transparent;
658.ew-btn {
659 font: inherit;
660 font-weight: 600;
661 color: var(--ew-fg);
662 background: transparent;
663 border: 1px solid var(--ew-edge);
664 border-radius: 2px;
665 padding: 6px 14px;
666 cursor: pointer;
667 transition:
668 border-color var(--ew-dur-1) var(--ew-ease),
669 color var(--ew-dur-1) var(--ew-ease),
670 background var(--ew-dur-1) var(--ew-ease),
671 opacity var(--ew-dur-1) var(--ew-ease),
672 transform var(--ew-dur-1) var(--ew-ease);
674.ew-btn:hover:not(:disabled) {
675 border-color: var(--ew-fg);
677.ew-btn:active:not(:disabled) {
678 transform: translateY(0.5px) scale(0.975);
680.ew-btn:disabled {
681 opacity: 0.5;
682 cursor: not-allowed;
684.ew-btn--primary {
685 color: #fff;
686 background: var(--ew-accent);
687 border-color: var(--ew-accent);
689.dark .ew-btn--primary {
690 color: #0d0d0d;
692.ew-btn--primary:hover:not(:disabled) {
693 filter: brightness(1.08);
694 border-color: var(--ew-accent);
696.ew-btn--primary:disabled {
697 color: var(--ew-faint);
698 background: transparent;
699 border-color: var(--ew-line);
700 opacity: 1;
702.ew-field {
703 width: 100%;
704 background: transparent;
705 color: var(--ew-fg);
706 border: 0;
707 border-bottom: 2px solid var(--ew-edge);
708 border-radius: 0;
709 padding: 6px 2px;
710 font: inherit;
711 transition:
712 border-color var(--ew-dur-1) var(--ew-ease),
713 background-color var(--ew-dur-1) var(--ew-ease);
715.ew-field::placeholder {
716 color: var(--ew-faint);
718.ew-field:focus,
719.ew-field:focus-visible {
720 outline: none;
721 border-bottom-color: var(--ew-accent);
722 background: var(--ew-tint);
724.ew-field:disabled {
725 color: var(--ew-faint);
726 border-bottom-color: var(--ew-line);
727 cursor: not-allowed;
729@media (pointer: coarse) {
730 .ew-field,
731 .ew-input {
732 font-size: 16px;
733 }
735.ew-card {
736 background: var(--ew-tint);
737 border: 1px solid var(--ew-line);
738 border-radius: 3px;
740.ew-tap {
741 display: inline-flex;
742 align-items: center;
743 justify-content: center;
744 min-width: 40px;
745 min-height: 40px;
747input[type="range"] {
748 -webkit-appearance: none;
749 appearance: none;
750 width: 100%;
751 height: 18px;
752 background: transparent;
753 cursor: pointer;
755input[type="range"]::-webkit-slider-runnable-track {
756 height: 2px;
757 background: var(--ew-gauge);
759input[type="range"]::-moz-range-track {
760 height: 2px;
761 background: var(--ew-gauge);
763input[type="range"]::-webkit-slider-thumb {
764 -webkit-appearance: none;
765 appearance: none;
766 width: 5px;
767 height: 16px;
768 margin-top: -7px;
769 background: var(--ew-accent);
770 border: 0;
771 border-radius: 1.5px;
773input[type="range"]::-moz-range-thumb {
774 width: 5px;
775 height: 16px;
776 background: var(--ew-accent);
777 border: 0;
778 border-radius: 1.5px;
780input[type="range"]:focus-visible {
781 outline: 2px solid var(--ew-accent);
782 outline-offset: 4px;
784input[type="range"]:disabled {
785 opacity: 0.5;
786 cursor: not-allowed;
788.ew-track {
789 height: 6px;
790 background: var(--ew-gauge);
791 border-radius: 999px;
792 overflow: hidden;
794.ew-track > .ew-fill {
795 height: 100%;
796 background: var(--ew-fg);
797 border-radius: 999px;
798 box-shadow: inset 0 0 0 1px var(--ew-bg);
799 transition: width 0.4s var(--ew-ease);
801.ew-rail {
802 border-top: 1px solid var(--ew-line);
803 padding-top: 10px;
805.ew-summary {
806 cursor: pointer;
807 list-style: none;
808 display: flex;
809 align-items: center;
810 gap: 8px;
812.ew-summary::-webkit-details-marker {
813 display: none;
815.ew-summary::before {
816 content: "\25B8";
817 color: var(--ew-faint);
818 font-size: 11px;
819 line-height: 1;
821details[open] > .ew-summary::before {
822 content: "\25BE";
824.ew-summary--static {
825 cursor: default;
826 pointer-events: none;
828.ew-summary--static::before {
829 display: none;
831.ew-press {
832 transition:
833 transform var(--ew-dur-1) var(--ew-ease),
834 background var(--ew-dur-1) var(--ew-ease),
835 border-color var(--ew-dur-1) var(--ew-ease),
836 color var(--ew-dur-1) var(--ew-ease);
838.ew-press:active:not(:disabled) {
839 transform: scale(0.97);
841.ew-press:disabled {
842 opacity: 0.6;
843 cursor: not-allowed;
845.ew-loading-bar {
846 position: relative;
847 height: 2px;
848 overflow: hidden;
849 background: var(--ew-line);
851.ew-loading-bar::after {
852 content: "";
853 position: absolute;
854 inset: 0;
855 background: var(--ew-accent);
856 transform-origin: left;
857 animation: ew-load-sweep 1.15s var(--ew-ease) infinite;
859@keyframes ew-load-sweep {
860 0% {
861 transform: translateX(-100%) scaleX(0.35);
862 }
863 55% {
864 transform: translateX(40%) scaleX(0.6);
865 }
866 100% {
867 transform: translateX(160%) scaleX(0.35);
868 }
870.ew-panel {
871 animation: ew-panel-in var(--ew-dur-2) var(--ew-ease);
873@keyframes ew-panel-in {
874 from {
875 transform: translateY(8px);
876 }
877 to {
878 transform: translateY(0);
879 }
881.ew-fade-in {
882 animation: ew-fade-in var(--ew-dur-3) var(--ew-ease) both;
884@keyframes ew-fade-in {
885 from {
886 opacity: 0;
887 transform: translateY(6px);
888 }
889 to {
890 opacity: 1;
891 transform: none;
892 }
894.pct-pop {
895 animation: ew-pct-pop var(--ew-dur-2) var(--ew-ease-spring);
897@keyframes ew-pct-pop {
898 0% {
899 transform: scale(1);
900 }
901 35% {
902 transform: scale(1.14);
903 }
904 100% {
905 transform: scale(1);
906 }
908/* The chronicle's illuminated initial — the one earned ornament. */
909[data-testid="chronicle-body"] p:first-of-type::first-letter,
910[data-testid="end-chronicle-body"] p:first-of-type::first-letter {
911 font-family: var(--ew-serif);
912 font-size: 2.8em;
913 line-height: 0.78;
914 float: left;
915 margin: 0.04em 0.09em 0 0;
916 color: var(--ew-rubric);
917 font-weight: 600;
919/* A floated cap needs a paragraph guaranteed ≥3 lines to wrap it (spec §2) —
920 shorter, it strands the prose in the cap's indent. Surfaces gate it with
921 .cx-cap-raise (a char-count proxy on the opening paragraph), falling back
922 to the sanctioned raised initial: same rubric ink, in the line. */
923.cx-cap-raise p:first-of-type::first-letter {
924 float: none;
925 font-size: 1.24em;
926 line-height: inherit;
927 margin: 0;
929 
930/* ── Reduced motion: the same staging, translated ────────────────────────── */
931@media (prefers-reduced-motion: reduce) {
932 .ew-dim {
933 transition: none;
934 }
935 .ew-spinner {
936 animation-duration: 1.4s;
937 }
938 .ew-shimmer,
939 .ew-loading-bar::after,
940 .ew-panel,
941 .ew-fade-in,
942 .pct-pop {
943 animation: none;
944 }
945 .ew-fade-in {
946 opacity: 1;
947 transform: none;
948 }
949 .cx-rule-in,
950 .cx-ink-in,
951 .cx-settle,
952 .cx-strike {
953 animation: none;
954 }
955 .cx-strike {
956 opacity: 1;
957 transform: none;
958 }
959 .ew-press:active:not(:disabled),
960 .ew-btn:active:not(:disabled),
961 .cx-action:active {
962 transform: none;
963 }
964 .ew-track > .ew-fill,
965 .cx-inkrule > i {
966 transition: none;
967 }

The five dialects

Every component idiom in the game now descends from five voices: rubric caps (running heads, kickers, table titles), margin glosses (every secondary read — italic serif, mark-opened), inked actions (every interactive that is not the seal), engraved instruments (quantities as drawn scales with an index), and disclosed tables. The player's writing itself is .cx-writing: a real textarea whose chrome is ruled feints drawn on the paper, with a rubric caret.

Rubric caps, glosses, inked actions; then the ruled writing surface.

assets/css/main.css · 968 lines
assets/css/main.css968 lines · CSS
⋯ 199 lines hidden (lines 1–199)
1/**
2 * Main CSS — Everwhen · THE ILLUMINATED CODEX
3 *
4 * The UI is not a website about a magical manuscript. The UI IS the manuscript,
5 * and the app is the desk it rests on. Materials over chrome: paper has fiber,
6 * light exists, wax has relief. One accent — rubrication (red ink marks what
7 * matters; the wax seal is its material twin). Gold is ceremonial, at most once
8 * per screen, never interactive. Every glyph is drawn in the seal's hand
9 * (components/codex/marks/) — no OS emoji, no dingbat fonts.
10 *
11 * The material test for any effect: does it describe paper, ink, wax, or
12 * light? If not, it doesn't ship.
13 *
14 * Full language: docs/design/illuminated-codex-language.md (spec) and
15 * docs/design/illuminated-codex-problem-book.md (the diagnosis it answers).
16 *
17 * Two class families:
18 * .cx-* — the codex dialects (game surfaces).
19 * .ew-* — legacy helpers kept for legal/settings/steward surfaces; they now
20 * resolve to codex materials via the alias tokens below.
21 */
22 
23@import "tailwindcss";
24@import "@nuxt/ui";
25 
26/* ── Materials & inks ────────────────────────────────────────────────────── */
27:root {
28 /* paper */
29 --ew-paper-hi: #faf4e6;
30 --ew-paper: #f4ecd8;
31 --ew-paper-lo: #e8dcc0;
32 --ew-rule: #cbb894; /* drawn rules */
33 --ew-rule-feint: #ddd0b0; /* baseline feints, hairline folds */
34 
35 /* ink */
36 --ew-ink: #2a2118;
37 --ew-ink-quiet: #5f5240;
38 --ew-ink-faint: #857358;
39 --ew-rubric: #96291c; /* THE accent — rubrication */
40 --ew-gold: #a97e1c; /* ceremonial only */
41 --ew-ok: #43601f;
42 --ew-bad: #6d1a10;
43 
44 /* wax */
45 --ew-wax: #a84b28;
46 --ew-wax-hi: #c96f43;
47 --ew-wax-lo: #7c3116;
48 
49 /* the desk */
50 --ew-desk: #2e2013;
51 --ew-desk-hi: #4a3620;
52 --ew-desk-ink: #cdbb96; /* legible directly on the desk */
53 /* stacked page-edge inks (pre-resolved: color-mix inside box-shadow
54 shorthands poisons chromium's rasterizer — resolve mixes here, in
55 custom-property values, never inline in a shadow list) */
56 --ew-page-edge-1: var(--ew-paper-lo);
57 --ew-page-edge-2: color-mix(in srgb, var(--ew-paper-lo) 78%, black);
58 --ew-page-edge-3: color-mix(in srgb, var(--ew-paper-lo) 58%, black);
59 --ew-rubric-halo: color-mix(in srgb, var(--ew-rubric) 14%, transparent);
60 /* leaf texture + lighting, themed HERE (tokens only): styling a
61 :global(html.dark) descendant from scoped CSS once compiled down to a bare
62 html.dark rule and screen-blended the ROOT over the white canvas — the
63 whole app rendered white in chromium. Theme via inherited custom
64 properties, never global selectors (see CLAUDE.md scoped-CSS traps). */
65 --ew-tex-blend: multiply;
66 --ew-tex-op-laid: 1;
67 --ew-tex-op-mottle: 0.35;
68 --ew-leaf-shadow:
69 2px 2px 0 0 var(--ew-page-edge-1), 4px 4px 0 0 var(--ew-page-edge-2),
70 6px 6px 0 0 var(--ew-page-edge-3), 0 24px 70px -18px rgba(0, 0, 0, 0.65),
71 0 60px 130px -40px rgba(0, 0, 0, 0.5);
72 --ew-scrim: color-mix(in srgb, var(--ew-desk) 60%, black);
73 
74 /* faces */
75 --ew-serif:
76 "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, ui-serif, serif;
77 --ew-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
78 
79 /* type scale (the ladder survives; display/title retuned to the spec) */
80 --ew-fs-micro: 10px;
81 --ew-fs-label: 11px;
82 --ew-fs-meta: 12.5px; /* scribe mono */
83 --ew-fs-note: 13.5px; /* glosses, footnotes */
84 --ew-fs-body: 15px;
85 --ew-fs-lede: 18px;
86 --ew-fs-title: 30px;
87 --ew-fs-display: 44px;
88 
89 /* motion */
90 --ew-dur-1: 140ms; /* micro */
91 --ew-dur-2: 280ms; /* standard */
92 --ew-dur-3: 520ms; /* expressive */
93 --ew-beat: 900ms; /* dramatic — climaxes only */
94 --ew-ease: cubic-bezier(0.2, 0.7, 0.3, 1);
95 --ew-ease-spring: cubic-bezier(0.2, 1.4, 0.4, 1);
96 --ew-ease-strike: cubic-bezier(0.5, 0, 0.2, 1.35); /* anticipation → strike */
97 
98 /* ── legacy aliases (legal / settings / steward surfaces) ─────────────── */
99 --ew-fg: var(--ew-ink);
100 --ew-mut: var(--ew-ink-quiet);
101 --ew-faint: var(--ew-ink-faint);
102 --ew-hair: var(--ew-rule);
103 --ew-line: var(--ew-rule-feint);
104 --ew-edge: #97876a;
105 --ew-gauge: color-mix(in srgb, var(--ew-ink-faint) 85%, var(--ew-paper));
106 --ew-bg: var(--ew-paper);
107 --ew-tint: color-mix(in srgb, var(--ew-paper) 82%, var(--ew-paper-lo));
108 --ew-raise: var(--ew-paper-hi);
109 --ew-raise-line: var(--ew-rule);
110 --ew-accent: var(--ew-rubric);
111 --ew-warn: #8a5a16;
112 --ew-desk-edge: color-mix(in srgb, var(--ew-desk) 70%, black);
113 --ew-desk-ink-strong: #f6efe2;
114 --ew-desk-ink-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
116 
117/* Night is a re-lighting, not a palette swap: the same page by candlelight.
118 The paper darkens to lamplit vellum, the rubric warms to ember, the desk
119 falls to near-black, and the leaf carries an inset warm glow (the candle). */
120.dark {
121 --ew-paper-hi: #322917;
122 --ew-paper: #241d12;
123 --ew-paper-lo: #170f08;
124 --ew-rule: #4d4128;
125 --ew-rule-feint: #352c1b;
126 
127 --ew-ink: #e6dbc4;
128 --ew-ink-quiet: #b3a688;
129 --ew-ink-faint: #8d7f64;
130 --ew-rubric: #d96b4a;
131 --ew-gold: #cfa143;
132 --ew-ok: #9dc178;
133 --ew-bad: #e0705c;
134 
135 --ew-wax: #b85731;
136 --ew-wax-hi: #e08a58;
137 --ew-wax-lo: #6e2c12;
138 
139 --ew-desk: #0d0906;
140 --ew-desk-hi: #1f1509;
141 --ew-desk-ink: #b5a583;
142 
143 --ew-edge: #80735c;
144 --ew-warn: #d8a34d;
145 
146 /* night: textures lighten instead of darken; the candle glows in the leaf */
147 --ew-tex-blend: screen;
148 --ew-tex-op-laid: 0.5;
149 --ew-tex-op-mottle: 0.18;
150 --ew-leaf-shadow:
151 2px 2px 0 0 #2b2214, 4px 4px 0 0 #201a0f, 6px 6px 0 0 #16110a,
152 0 24px 70px -18px rgba(0, 0, 0, 0.9),
153 inset 0 0 140px -20px rgba(255, 170, 80, 0.16);
155 
156/* ── Base ────────────────────────────────────────────────────────────────── */
157body {
158 background: var(--ew-paper);
159 color: var(--ew-ink);
160 /* the manuscript's voice is the app's voice */
161 font-family: var(--ew-serif);
162 font-size: var(--ew-fs-body);
163 line-height: 1.55;
165 
166::selection {
167 background: color-mix(in srgb, var(--ew-rubric) 22%, transparent);
169* {
170 scrollbar-width: thin;
171 scrollbar-color: var(--ew-rule) transparent;
173::-webkit-scrollbar {
174 width: 11px;
175 height: 11px;
177::-webkit-scrollbar-track {
178 background: transparent;
180::-webkit-scrollbar-thumb {
181 background: var(--ew-rule);
182 border-radius: 999px;
183 border: 3px solid var(--ew-paper);
185::-webkit-scrollbar-thumb:hover {
186 background: var(--ew-ink-faint);
188 
189/* Focus: rubric ink, everywhere. */
190button:focus-visible,
191a:focus-visible,
192summary:focus-visible,
193[role="button"]:focus-visible,
194[tabindex]:focus-visible {
195 outline: 2px solid var(--ew-rubric);
196 outline-offset: 2px;
197 border-radius: 3px;
199 
200/* ══ THE CODEX DIALECTS ═══════════════════════════════════════════════════
201 Five voices, and only five: rubric caps · glosses · inked actions ·
202 instruments (drawn per-component in SVG on these tokens) · tables. */
203 
204/* 1 · Rubric caps — running heads, kickers, table titles, the seal word.
205 Serif, uppercase, letterspaced. Never bold-sans. */
206.cx-caps {
207 font-family: var(--ew-serif);
208 font-size: 12.5px;
209 letter-spacing: 0.22em;
210 text-transform: uppercase;
211 color: var(--ew-ink-quiet);
213.cx-caps--rubric {
214 color: var(--ew-rubric);
216.cx-caps--small {
217 font-size: var(--ew-fs-label);
218 letter-spacing: 0.18em;
220 
221/* 2 · Glosses — every secondary read lives in the margin voice: italic serif,
222 paraph- or dagger-marked, quiet ink with rubric emphasis spans. */
223.cx-gloss {
224 position: relative;
225 padding-left: 20px;
226 font-size: var(--ew-fs-note);
227 line-height: 1.5;
228 font-style: italic;
229 color: var(--ew-ink-quiet);
231.cx-gloss .codex-mark {
232 position: absolute;
233 left: 0;
234 top: 3px;
235 color: var(--ew-rubric);
237.cx-gloss b,
238.cx-gloss strong {
239 font-style: normal;
240 font-weight: 600;
241 color: var(--ew-ink);
243.cx-gloss .cx-em {
244 color: var(--ew-rubric);
245 font-style: normal;
246 font-weight: 600;
248.cx-gloss .cx-quiet {
249 color: var(--ew-ink-faint);
251 
252/* 3 · Inked actions — every interactive that is not the seal: rubric caps with
253 a drawn mark; underline arrives on hover; press settles like a stamp. */
254.cx-action {
255 appearance: none;
256 background: none;
257 border: 0;
258 padding: 0;
259 display: inline-flex;
260 align-items: center;
261 gap: 7px;
262 font-family: var(--ew-serif);
263 font-size: 12.5px;
264 letter-spacing: 0.16em;
265 text-transform: uppercase;
266 font-weight: 600;
267 color: var(--ew-rubric);
268 cursor: pointer;
269 text-decoration: none;
270 transition:
271 color var(--ew-dur-1) var(--ew-ease),
272 transform var(--ew-dur-1) var(--ew-ease);
274.cx-action:hover {
275 text-decoration: underline;
276 text-underline-offset: 4px;
277 text-decoration-thickness: 1px;
279.cx-action:active {
280 transform: translateY(0.5px);
282.cx-action:disabled {
283 color: var(--ew-ink-faint);
284 cursor: not-allowed;
285 text-decoration: none;
⋯ 79 lines hidden (lines 286–364)
287.cx-action--quiet {
288 color: var(--ew-ink-faint);
289 font-style: italic;
290 text-transform: none;
291 letter-spacing: 0;
292 font-weight: 400;
293 font-size: var(--ew-fs-note);
295.cx-action--quiet:hover {
296 color: var(--ew-ink-quiet);
298 
299/* 4 · Instruments — quantities are engraved scales with an index, drawn in
300 component SVG on these inks. The shared primitive here is only the
301 inkwell rule (characters / simple fills). */
302.cx-inkrule {
303 height: 3px;
304 background: var(--ew-rule-feint);
305 position: relative;
307.cx-inkrule > i {
308 position: absolute;
309 inset: 0;
310 right: auto;
311 background: var(--ew-ink-quiet);
312 transition: width var(--ew-dur-2) var(--ew-ease);
314 
315/* 5 · Tables — disclosed rules. Serif rows, scribe-mono figures, the struck
316 row in full ink with its valence name. */
317.cx-table {
318 width: 100%;
319 border-collapse: collapse;
320 font-size: 14.5px;
322.cx-table td {
323 padding: 6px 4px;
324 border-top: 1px solid var(--ew-rule-feint);
325 color: var(--ew-ink-faint);
327.cx-table tr:first-child td {
328 border-top: 0;
330.cx-table .cx-mono {
331 font-family: var(--ew-mono);
332 font-size: var(--ew-fs-meta);
334.cx-table tr.struck td {
335 color: var(--ew-ink);
336 font-weight: 600;
337 border-top-color: var(--ew-rule);
339.cx-table tr.struck + tr td {
340 border-top-color: var(--ew-rule);
342 
343/* Scribe mono — the one non-serif register: equations, of-N counters, ranges. */
344.cx-mono {
345 font-family: var(--ew-mono);
346 font-size: var(--ew-fs-meta);
348 
349/* Footnotes — the dagger apparatus. */
350.cx-footnote {
351 font-size: var(--ew-fs-note);
352 color: var(--ew-ink-faint);
353 font-style: italic;
355.cx-footnote .codex-mark,
356.cx-fnmark {
357 color: var(--ew-rubric);
359.cx-fnmark {
360 font-style: normal;
361 font-size: 0.8em;
362 vertical-align: super;
363 line-height: 0;
364 margin-left: 2px;
366 
367/* ── Writing on the page (the core verb) ─────────────────────────────────── */
368/* Ruled feints drawn on the paper itself; the caret is the player's pen. */
369.cx-writing {
370 width: 100%;
371 background: transparent;
372 border: 0;
373 resize: none;
374 color: var(--ew-ink);
375 caret-color: var(--ew-rubric);
376 font-family: var(--ew-serif);
377 font-size: 21px;
378 line-height: 44px;
379 background-image: repeating-linear-gradient(
380 to bottom,
381 transparent 0 43px,
382 var(--ew-rule-feint) 43px 44px
383 );
384 background-origin: content-box;
385 outline: none;
386 padding: 0;
388.cx-writing::placeholder {
389 color: var(--ew-ink-faint);
390 font-style: italic;
392/* iOS zoom floor: never under 16px on touch. */
393@media (pointer: coarse) {
394 .cx-writing {
395 font-size: 19px;
396 line-height: 40px;
397 background-image: repeating-linear-gradient(
398 to bottom,
399 transparent 0 39px,
400 var(--ew-rule-feint) 39px 40px
⋯ 568 lines hidden (lines 401–968)
401 );
402 }
404 
405/* ── Ink behaviours (motion) ─────────────────────────────────────────────── */
406/* Rules draw in from their anchor. */
407.cx-rule-in {
408 transform-origin: left center;
409 animation: cx-rule-in var(--ew-dur-3) var(--ew-ease) both;
411@keyframes cx-rule-in {
412 from {
413 transform: scaleX(0);
414 }
415 to {
416 transform: scaleX(1);
417 }
419/* Rubric words ink themselves: blur resolves to a sharp stroke. */
420.cx-ink-in {
421 animation: cx-ink-in var(--ew-dur-3) var(--ew-ease) both;
423@keyframes cx-ink-in {
424 from {
425 opacity: 0;
426 filter: blur(2px);
427 }
428 to {
429 opacity: 1;
430 filter: blur(0);
431 }
433/* The stamp strike: drop, squash, settle. For wax and verdicts. */
434.cx-strike {
435 animation: cx-strike var(--ew-dur-3) var(--ew-ease-spring) both;
437@keyframes cx-strike {
438 0% {
439 opacity: 0;
440 transform: scale(1.35);
441 }
442 55% {
443 opacity: 1;
444 transform: scale(0.94);
445 }
446 100% {
447 opacity: 1;
448 transform: scale(1);
449 }
451/* Page content arrives like a settled sheet. */
452.cx-settle {
453 animation: cx-settle var(--ew-dur-3) var(--ew-ease) both;
455@keyframes cx-settle {
456 from {
457 opacity: 0;
458 transform: translateY(8px);
459 }
460 to {
461 opacity: 1;
462 transform: none;
463 }
465/* The one spinner survives as an inked ring. */
466.ew-spinner {
467 display: inline-block;
468 width: 0.9em;
469 height: 0.9em;
470 border: 2px solid currentColor;
471 border-bottom-color: transparent;
472 border-radius: 50%;
473 animation: ew-spin 0.7s linear infinite;
474 vertical-align: -0.12em;
476@keyframes ew-spin {
477 to {
478 transform: rotate(360deg);
479 }
481/* Pending shimmer — an ink wash while the chronicler writes. */
482.ew-shimmer {
483 background: linear-gradient(
484 90deg,
485 var(--ew-rule-feint) 20%,
486 var(--ew-rule) 40%,
487 var(--ew-rule-feint) 60%
488 );
489 background-size: 200% 100%;
490 animation: ew-shimmer 1.5s ease-in-out infinite;
491 border-radius: 2px;
493@keyframes ew-shimmer {
494 from {
495 background-position: 200% 0;
496 }
497 to {
498 background-position: -200% 0;
499 }
501 
502/* ── Legacy helpers (legal · settings · steward · not the game surface) ──── */
503.ew-fg {
504 color: var(--ew-fg);
506.ew-muted {
507 color: var(--ew-mut);
509.ew-hair-c {
510 color: var(--ew-hair);
512.ew-faint {
513 color: var(--ew-faint);
515.ew-accent {
516 color: var(--ew-accent);
518.ew-bg {
519 background: var(--ew-bg);
521.ew-tint {
522 background: var(--ew-tint);
524.ew-mono {
525 font-family: var(--ew-mono);
527.ew-ok {
528 color: var(--ew-ok);
530.ew-warn {
531 color: var(--ew-warn);
533.ew-bad {
534 color: var(--ew-bad);
536.ew-serif {
537 font-family: var(--ew-serif);
539.ew-line {
540 border-color: var(--ew-line);
542.ew-edge {
543 border-color: var(--ew-edge);
545.ew-hover:hover:not(:disabled) {
546 background: var(--ew-tint);
548.ew-hover-fg {
549 color: var(--ew-mut);
551.ew-hover-fg:hover {
552 color: var(--ew-fg);
554.ew-dim {
555 opacity: 0.68;
556 transition: opacity var(--ew-dur-2) var(--ew-ease);
558.ew-label {
559 font-size: var(--ew-fs-label);
560 text-transform: uppercase;
561 letter-spacing: 0.1em;
562 font-weight: 600;
563 color: var(--ew-mut);
565.ew-label--rule {
566 display: block;
567 padding-bottom: 6px;
568 border-bottom: 1px solid var(--ew-line);
569 margin-bottom: 12px;
571.ew-prompt {
572 font-size: var(--ew-fs-note);
573 color: var(--ew-mut);
574 text-transform: none;
575 letter-spacing: normal;
577.ew-hint {
578 font-size: var(--ew-fs-note);
579 line-height: 1.4;
580 color: var(--ew-mut);
582.ew-hint::before {
583 content: "\203A\00a0\00a0";
584 color: var(--ew-accent);
585 font-weight: 700;
587.ew-blocked {
588 border-left: 2px solid var(--ew-bad);
589 padding-left: 8px;
590 color: var(--ew-mut);
592.ew-blocked .ew-blocked-tag {
593 font-weight: 700;
594 text-transform: uppercase;
595 letter-spacing: 0.04em;
596 color: var(--ew-bad);
598.ew-dot {
599 display: inline-block;
600 width: 9px;
601 height: 9px;
602 border-radius: 50%;
603 flex: none;
605.ew-dot--ok {
606 background: var(--ew-ok);
608.ew-dot--warn {
609 background: var(--ew-warn);
611.ew-dot--bad {
612 background: var(--ew-bad);
614.ew-dot--mut {
615 background: var(--ew-faint);
617.ew-dot--accent {
618 background: var(--ew-accent);
620.ew-dot--hollow {
621 background: transparent;
622 border: 2px solid var(--ew-faint);
624.ew-input {
625 width: 100%;
626 background: var(--ew-bg);
627 color: var(--ew-fg);
628 border: 1px solid var(--ew-edge);
629 border-radius: 2px;
630 padding: 9px 11px;
631 font: inherit;
633.ew-input::placeholder {
634 color: var(--ew-faint);
636.ew-input:focus,
637.ew-input:focus-visible {
638 outline: 2px solid var(--ew-accent);
639 outline-offset: -1px;
640 border-color: transparent;
642.ew-select {
643 width: 100%;
644 background: var(--ew-bg);
645 color: var(--ew-fg);
646 border: 1px solid var(--ew-edge);
647 border-radius: 2px;
648 padding: 8px 11px;
649 font: inherit;
650 cursor: pointer;
652.ew-select:focus,
653.ew-select:focus-visible {
654 outline: 2px solid var(--ew-accent);
655 outline-offset: -1px;
656 border-color: transparent;
658.ew-btn {
659 font: inherit;
660 font-weight: 600;
661 color: var(--ew-fg);
662 background: transparent;
663 border: 1px solid var(--ew-edge);
664 border-radius: 2px;
665 padding: 6px 14px;
666 cursor: pointer;
667 transition:
668 border-color var(--ew-dur-1) var(--ew-ease),
669 color var(--ew-dur-1) var(--ew-ease),
670 background var(--ew-dur-1) var(--ew-ease),
671 opacity var(--ew-dur-1) var(--ew-ease),
672 transform var(--ew-dur-1) var(--ew-ease);
674.ew-btn:hover:not(:disabled) {
675 border-color: var(--ew-fg);
677.ew-btn:active:not(:disabled) {
678 transform: translateY(0.5px) scale(0.975);
680.ew-btn:disabled {
681 opacity: 0.5;
682 cursor: not-allowed;
684.ew-btn--primary {
685 color: #fff;
686 background: var(--ew-accent);
687 border-color: var(--ew-accent);
689.dark .ew-btn--primary {
690 color: #0d0d0d;
692.ew-btn--primary:hover:not(:disabled) {
693 filter: brightness(1.08);
694 border-color: var(--ew-accent);
696.ew-btn--primary:disabled {
697 color: var(--ew-faint);
698 background: transparent;
699 border-color: var(--ew-line);
700 opacity: 1;
702.ew-field {
703 width: 100%;
704 background: transparent;
705 color: var(--ew-fg);
706 border: 0;
707 border-bottom: 2px solid var(--ew-edge);
708 border-radius: 0;
709 padding: 6px 2px;
710 font: inherit;
711 transition:
712 border-color var(--ew-dur-1) var(--ew-ease),
713 background-color var(--ew-dur-1) var(--ew-ease);
715.ew-field::placeholder {
716 color: var(--ew-faint);
718.ew-field:focus,
719.ew-field:focus-visible {
720 outline: none;
721 border-bottom-color: var(--ew-accent);
722 background: var(--ew-tint);
724.ew-field:disabled {
725 color: var(--ew-faint);
726 border-bottom-color: var(--ew-line);
727 cursor: not-allowed;
729@media (pointer: coarse) {
730 .ew-field,
731 .ew-input {
732 font-size: 16px;
733 }
735.ew-card {
736 background: var(--ew-tint);
737 border: 1px solid var(--ew-line);
738 border-radius: 3px;
740.ew-tap {
741 display: inline-flex;
742 align-items: center;
743 justify-content: center;
744 min-width: 40px;
745 min-height: 40px;
747input[type="range"] {
748 -webkit-appearance: none;
749 appearance: none;
750 width: 100%;
751 height: 18px;
752 background: transparent;
753 cursor: pointer;
755input[type="range"]::-webkit-slider-runnable-track {
756 height: 2px;
757 background: var(--ew-gauge);
759input[type="range"]::-moz-range-track {
760 height: 2px;
761 background: var(--ew-gauge);
763input[type="range"]::-webkit-slider-thumb {
764 -webkit-appearance: none;
765 appearance: none;
766 width: 5px;
767 height: 16px;
768 margin-top: -7px;
769 background: var(--ew-accent);
770 border: 0;
771 border-radius: 1.5px;
773input[type="range"]::-moz-range-thumb {
774 width: 5px;
775 height: 16px;
776 background: var(--ew-accent);
777 border: 0;
778 border-radius: 1.5px;
780input[type="range"]:focus-visible {
781 outline: 2px solid var(--ew-accent);
782 outline-offset: 4px;
784input[type="range"]:disabled {
785 opacity: 0.5;
786 cursor: not-allowed;
788.ew-track {
789 height: 6px;
790 background: var(--ew-gauge);
791 border-radius: 999px;
792 overflow: hidden;
794.ew-track > .ew-fill {
795 height: 100%;
796 background: var(--ew-fg);
797 border-radius: 999px;
798 box-shadow: inset 0 0 0 1px var(--ew-bg);
799 transition: width 0.4s var(--ew-ease);
801.ew-rail {
802 border-top: 1px solid var(--ew-line);
803 padding-top: 10px;
805.ew-summary {
806 cursor: pointer;
807 list-style: none;
808 display: flex;
809 align-items: center;
810 gap: 8px;
812.ew-summary::-webkit-details-marker {
813 display: none;
815.ew-summary::before {
816 content: "\25B8";
817 color: var(--ew-faint);
818 font-size: 11px;
819 line-height: 1;
821details[open] > .ew-summary::before {
822 content: "\25BE";
824.ew-summary--static {
825 cursor: default;
826 pointer-events: none;
828.ew-summary--static::before {
829 display: none;
831.ew-press {
832 transition:
833 transform var(--ew-dur-1) var(--ew-ease),
834 background var(--ew-dur-1) var(--ew-ease),
835 border-color var(--ew-dur-1) var(--ew-ease),
836 color var(--ew-dur-1) var(--ew-ease);
838.ew-press:active:not(:disabled) {
839 transform: scale(0.97);
841.ew-press:disabled {
842 opacity: 0.6;
843 cursor: not-allowed;
845.ew-loading-bar {
846 position: relative;
847 height: 2px;
848 overflow: hidden;
849 background: var(--ew-line);
851.ew-loading-bar::after {
852 content: "";
853 position: absolute;
854 inset: 0;
855 background: var(--ew-accent);
856 transform-origin: left;
857 animation: ew-load-sweep 1.15s var(--ew-ease) infinite;
859@keyframes ew-load-sweep {
860 0% {
861 transform: translateX(-100%) scaleX(0.35);
862 }
863 55% {
864 transform: translateX(40%) scaleX(0.6);
865 }
866 100% {
867 transform: translateX(160%) scaleX(0.35);
868 }
870.ew-panel {
871 animation: ew-panel-in var(--ew-dur-2) var(--ew-ease);
873@keyframes ew-panel-in {
874 from {
875 transform: translateY(8px);
876 }
877 to {
878 transform: translateY(0);
879 }
881.ew-fade-in {
882 animation: ew-fade-in var(--ew-dur-3) var(--ew-ease) both;
884@keyframes ew-fade-in {
885 from {
886 opacity: 0;
887 transform: translateY(6px);
888 }
889 to {
890 opacity: 1;
891 transform: none;
892 }
894.pct-pop {
895 animation: ew-pct-pop var(--ew-dur-2) var(--ew-ease-spring);
897@keyframes ew-pct-pop {
898 0% {
899 transform: scale(1);
900 }
901 35% {
902 transform: scale(1.14);
903 }
904 100% {
905 transform: scale(1);
906 }
908/* The chronicle's illuminated initial — the one earned ornament. */
909[data-testid="chronicle-body"] p:first-of-type::first-letter,
910[data-testid="end-chronicle-body"] p:first-of-type::first-letter {
911 font-family: var(--ew-serif);
912 font-size: 2.8em;
913 line-height: 0.78;
914 float: left;
915 margin: 0.04em 0.09em 0 0;
916 color: var(--ew-rubric);
917 font-weight: 600;
919/* A floated cap needs a paragraph guaranteed ≥3 lines to wrap it (spec §2) —
920 shorter, it strands the prose in the cap's indent. Surfaces gate it with
921 .cx-cap-raise (a char-count proxy on the opening paragraph), falling back
922 to the sanctioned raised initial: same rubric ink, in the line. */
923.cx-cap-raise p:first-of-type::first-letter {
924 float: none;
925 font-size: 1.24em;
926 line-height: inherit;
927 margin: 0;
929 
930/* ── Reduced motion: the same staging, translated ────────────────────────── */
931@media (prefers-reduced-motion: reduce) {
932 .ew-dim {
933 transition: none;
934 }
935 .ew-spinner {
936 animation-duration: 1.4s;
937 }
938 .ew-shimmer,
939 .ew-loading-bar::after,
940 .ew-panel,
941 .ew-fade-in,
942 .pct-pop {
943 animation: none;
944 }
945 .ew-fade-in {
946 opacity: 1;
947 transform: none;
948 }
949 .cx-rule-in,
950 .cx-ink-in,
951 .cx-settle,
952 .cx-strike {
953 animation: none;
954 }
955 .cx-strike {
956 opacity: 1;
957 transform: none;
958 }
959 .ew-press:active:not(:disabled),
960 .ew-btn:active:not(:disabled),
961 .cx-action:active {
962 transform: none;
963 }
964 .ew-track > .ew-fill,
965 .cx-inkrule > i {
966 transition: none;
967 }

The marks — no glyph the OS rendered for us

Twenty-three drawn marks — manicule, paraph, hourglass, lens, quill, die, leaf-corner, bookmark, candle, daggers, fleuron, laurel, jewel and kin — one hand, one stroke weight, currentColor. Every emoji and dingbat on a game surface died in this PR; objective icons became engraved roundels drawn in the mission index.

The registry's head: the pointing hand, the paraph, the hourglass.

components/codex/CodexMark.vue · 191 lines
components/codex/CodexMark.vue191 lines · Vue
⋯ 21 lines hidden (lines 1–21)
1<template>
2 <svg
3 class="codex-mark"
4 viewBox="0 0 16 16"
5 aria-hidden="true"
6 :style="{ width: size, height: size }"
7 >
8 <g
9 v-for="(p, i) in mark"
10 :key="i"
11 :fill="p.f ? 'currentColor' : 'none'"
12 :stroke="p.f ? 'none' : 'currentColor'"
13 :stroke-width="p.w ?? 1.4"
14 stroke-linecap="round"
15 stroke-linejoin="round"
16 >
17 <path :d="p.d" :opacity="p.o" />
18 </g>
19 </svg>
20</template>
21 
22<script setup lang="ts">
23/** The game's marks — one drawn set, single-stroke sepia, in the seal's hand.
24 * 16px grid, stroke 1.4, round caps. Every glyph the game surface shows comes
25 * from here: no OS emoji, no dingbat fonts (the world stays one hand's work,
26 * and identical on every platform). Marks inherit `currentColor`; interactive
27 * parents carry the aria-label — the svg itself is decorative.
28 * NOTE: template stays comment-free (a leading template comment makes the
29 * component multi-root in dev and Vue drops class fallthrough). */
30import { computed } from "vue";
31 
32type Stroke = { d: string; f?: boolean; w?: number; o?: number };
33 
34const MARKS: Record<string, Stroke[]> = {
35 /* the pointing hand — marks what matters (solid, like metal type) */
36 manicule: [
37 {
38 d: "M1.5 5.6 L4.4 5.6 L4.4 10.4 L1.5 10.4 Z M4.4 6.2 L9.6 6.2 L9.6 4.9 C9.6 4.1 10.7 3.9 11 4.7 L11.9 7 L14.8 7.4 C15.5 7.5 15.5 8.4 14.8 8.5 L10.4 9.2 C10 10.6 8.9 11.2 7.5 11.2 L4.4 11.2 Z",
39 f: true,
40 },
41 ],
42 /* opens a gloss */
43 paraph: [
44 {
45 d: "M11.2 2.5 C6 2.5 3.6 4.9 3.6 7.6 C3.6 10 5.5 11.4 7.4 11.4 L7.4 13.5",
46 },
47 { d: "M11.2 2.5 L11.2 13.5" },
48 ],
49 /* time, the chain of eras */
50 hourglass: [
51 { d: "M4.6 2.5 L11.4 2.5 M4.6 13.5 L11.4 13.5" },
52 {
53 d: "M5.5 2.5 C5.5 6 8 7 8 8 C8 9 5.5 10 5.5 13.5 M10.5 2.5 C10.5 6 8 7 8 8 C8 9 10.5 10 10.5 13.5",
54 w: 1.3,
55 },
56 { d: "M8 11.2 L8 12.4", w: 1.8 },
57 ],
58 /* the archive */
59 lens: [
60 {
⋯ 131 lines hidden (lines 61–191)
61 d: "M12.8 6.8 A5.4 5.4 0 1 1 2 6.8 A5.4 5.4 0 1 1 12.8 6.8 Z M11.2 10.6 L14.6 14",
62 },
63 ],
64 /* craft, the judge's pen */
65 quill: [
66 {
67 d: "M13.8 2.2 C10 2.6 6.6 5.4 4.8 10.6 M13.8 2.2 C13.6 6.4 11 9.8 5.6 11.2 M4.8 10.6 L3.4 13.4 M3.4 13.4 L4.6 12.9",
68 w: 1.3,
69 },
70 { d: "M8.4 5 C9.2 6.6 10.6 7.4 12.2 7.4", w: 0.9, o: 0.6 },
71 ],
72 /* fate */
73 die: [
74 { d: "M8 1.6 L13.6 4.8 L13.6 11.2 L8 14.4 L2.4 11.2 L2.4 4.8 Z" },
75 { d: "M8 4.6 L11 9.6 L5 9.6 Z", w: 1.2 },
76 {
77 d: "M8 1.6 L8 4.6 M13.6 4.8 L11 9.6 M2.4 4.8 L5 9.6 M8 14.4 L8 9.6",
78 w: 0.9,
79 o: 0.65,
80 },
81 ],
82 /* turn the leaf */
83 "leaf-corner": [
84 { d: "M3 2.5 L13 2.5 L13 8 L8.5 13.5 L3 13.5 Z" },
85 { d: "M13 8 C10.5 8.6 9 10.5 8.5 13.5", w: 1.2 },
86 ],
87 /* the record */
88 bookmark: [{ d: "M4.5 2 L11.5 2 L11.5 14 L8 11.2 L4.5 14 Z" }],
89 /* sound */
90 note: [
91 { d: "M9.4 11.6 A2.2 2.2 0 1 1 9.39 11.55 Z", f: true },
92 { d: "M11.4 11.6 L11.4 2.6 C12.6 3.6 13.6 4.6 13 6.8", w: 1.3 },
93 { d: "M9.4 11.6 L11.4 11.6", w: 1.3 },
94 ],
95 /* theme — a candle: the codex is read by its light */
96 candle: [
97 { d: "M5.5 7.5 L10.5 7.5 L10.2 13.5 L5.8 13.5 Z" },
98 { d: "M8 5.8 C9.4 4.6 9.2 3.2 8 2 C6.8 3.2 6.6 4.6 8 5.8 Z", w: 1.2 },
99 ],
100 /* begin again */
101 reset: [
102 { d: "M12.9 8 A4.9 4.9 0 1 1 9.8 3.5" },
103 { d: "M9.4 1.4 L10.1 3.7 L7.8 4.4", w: 1.2 },
104 ],
105 close: [{ d: "M4 4 L12 12 M12 4 L4 12" }],
106 dagger: [
107 { d: "M8 2.2 L8 13.8 M5.6 4.8 L10.4 4.8" },
108 { d: "M8 12 L7 13.8 L9 13.8 Z", f: true },
109 ],
110 "double-dagger": [{ d: "M8 2.2 L8 13.8 M5.6 4.6 L10.4 4.6 M5.6 9 L10.4 9" }],
111 /* the ornament */
112 fleuron: [
113 { d: "M8 2.6 C10.4 5 10.4 8.4 8 10.8 C5.6 8.4 5.6 5 8 2.6 Z", f: true },
114 { d: "M8 10.8 L8 13.6", w: 1.2 },
115 ],
116 /* victory */
117 laurel: [
118 {
119 d: "M5.2 3 C3.2 6.6 3.6 10.4 7 13 M10.8 3 C12.8 6.6 12.4 10.4 9 13",
120 w: 1.3,
121 },
122 {
123 d: "M4.2 5.8 C5.4 6.3 6.3 6.1 7 5.3 C5.9 4.9 4.9 5 4.2 5.8 Z M4.4 9 C5.6 9.5 6.5 9.3 7.2 8.5 C6.1 8.1 5.1 8.2 4.4 9 Z M11.8 5.8 C10.6 6.3 9.7 6.1 9 5.3 C10.1 4.9 11.1 5 11.8 5.8 Z M11.6 9 C10.4 9.5 9.5 9.3 8.8 8.5 C9.9 8.1 10.9 8.2 11.6 9 Z",
124 f: true,
125 },
126 ],
127 /* defeat — the bar sinister */
128 bar: [
129 { d: "M8 1.8 A6.2 6.2 0 1 1 7.99 1.8 Z" },
130 { d: "M3.8 12.2 L12.2 3.8", w: 1.6 },
131 ],
132 /* the stake */
133 flag: [{ d: "M5 2 L5 14" }, { d: "M5 2.8 L12.6 5 L5 7.2 Z", f: true }],
134 /* pin a moment — a scribe's pin laid across the page */
135 pin: [
136 { d: "M13.6 4.4 A1.9 1.9 0 1 1 13.59 4.35 Z", f: true },
137 { d: "M10.5 5.9 L3.2 13.2" },
138 ],
139 /* anachronism — the wager against time (solid, like the manicule) */
140 bolt: [
141 {
142 d: "M9.4 1.6 L4.2 8.8 L7.3 8.8 L6.2 14.4 L11.8 6.7 L8.7 6.7 Z",
143 f: true,
144 },
145 ],
146 /* the causal chain */
147 chain: [
148 { d: "M6.8 9.2 A2.6 2.6 0 1 1 3.2 5.6 L4.6 4.2 A2.6 2.6 0 0 1 8.2 7.8" },
149 {
150 d: "M9.2 6.8 A2.6 2.6 0 1 1 12.8 10.4 L11.4 11.8 A2.6 2.6 0 0 1 7.8 8.2",
151 },
152 ],
153 /* momentum */
154 jewel: [
155 { d: "M8 2.8 L13.2 8 L8 13.2 L2.8 8 Z" },
156 { d: "M8 5.6 L10.4 8 L8 10.4 L5.6 8 Z", w: 1, o: 0.7 },
157 ],
158 /* the figure writes back */
159 scroll: [
160 { d: "M4 3.4 C4 2.4 4.8 2 5.6 2 L13 2 C12 2.6 12 3.4 12 4.4 L12 11.4" },
161 {
162 d: "M12 11.4 C12 13 11 14 9.4 14 L4.6 14 C3.4 14 2.6 13 2.6 11.8 C2.6 10.8 3.4 10.2 4.2 10.2 L4.2 3.4",
163 },
164 ],
165 /* attention, honestly marked */
166 notabene: [
167 { d: "M8 2.2 L14.2 13 L1.8 13 Z" },
168 { d: "M8 6 L8 9.4 M8 11 L8 11.4", w: 1.6 },
169 ],
170 /* the account — the key to your own ledger */
171 key: [
172 { d: "M7.4 8 A2.7 2.7 0 1 1 7.39 7.93 Z" },
173 { d: "M7.4 8 L13.8 8" },
174 { d: "M11 8 L11 10.4 M13.8 8 L13.8 11", w: 1.3 },
175 ],
176};
177 
178const props = withDefaults(
179 defineProps<{ name: keyof typeof MARKS | string; size?: string }>(),
180 { size: "1em" },
181);
182const mark = computed(() => MARKS[props.name] ?? MARKS.fleuron);
183</script>
184 
185<style scoped>
186.codex-mark {
187 display: inline-block;
188 vertical-align: -0.14em;
189 flex: none;
191</style>

The shell — desk, leaf, ribbon, turnline

pages/play.vue renders the environment: the desk (radial pool of light, leather grain), the leaf (laid-paper texture, fibrous mottle, stacked under-pages, candle glow at night), the running head band with the app's utilities as quiet margin marks, the wax ribbon bookmark that carries the run's five pips and its timeline figure, and the turnline whose primary verb is now "Turn the leaf ›" beside the page's own curling corner. All conductor, soundscape, coaching, and overlay logic is untouched.

The folio's materials; the ribbon instrument.

pages/play.vue · 1231 lines
pages/play.vue1231 lines · Vue
⋯ 733 lines hidden (lines 1–733)
1<template>
2 <!--
3 THE ILLUMINATED CODEX. The desk is the app; the leaf is the game. The
4 conductor (provided once here) owns the phase; each leaf is a focused page;
5 the store's two-phase actions land the SFX-bearing writes at leaf arrival,
6 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: a credited confirmation, or a gentle no-charge on
12 cancel. A slip laid on the desk over the codex; self-dismisses. -->
13 <div
14 v-if="economyStore.purchaseNotice"
15 data-testid="purchase-notice"
16 role="status"
17 aria-live="polite"
18 class="desk-slip purchase-banner"
19 :class="economyStore.purchaseNotice"
20 >
21 <CodexMark
22 :name="economyStore.purchaseNotice === 'success' ? 'laurel' : 'close'"
23 size="15px"
24 />
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="slip-close"
34 aria-label="Dismiss"
35 @click="economyStore.clearPurchaseNotice()"
36 >
37 <CodexMark name="close" size="13px" />
38 </button>
39 </div>
40 <!-- Blocked-dispatch banner: a moderation block refunds the turn and keeps
41 the player's words. Fixed over the desk so the block is unmissable from
42 any leaf and never shifts the codex. -->
43 <div
44 v-if="gameStore.moderationNotice"
45 data-testid="moderation-alert"
46 role="alert"
47 aria-live="assertive"
48 class="desk-slip blocked-banner"
49 >
50 <span class="bb-tag"
51 ><CodexMark name="notabene" size="13px" /> blocked</span
52 >
53 <span class="bb-text"
54 >{{ gameStore.moderationNotice }}
55 <span class="bb-hint"
56 >Your words are kept — edit and send again.</span
57 ></span
58 >
59 <button
60 type="button"
61 data-testid="moderation-close"
62 class="slip-close"
63 aria-label="Dismiss"
64 @click="gameStore.clearModerationNotice()"
65 >
66 <CodexMark name="close" size="13px" />
67 </button>
68 </div>
69 
70 <div class="codex">
71 <!-- The running head — the page's own head band (hidden during the End
72 takeover, which carries its own verdict heading). The utilities sit
73 at the outer corner as quiet margin marks: the app annotates the
74 page's margin, it never prints on the page. -->
75 <header v-if="conductor.phase.value !== 'end'" class="running-head-band">
76 <div class="running-head">
77 <span class="rh-left"><GameTitle /></span>
78 <span class="rh-centre">
79 <ObjectiveDisplay v-if="gameStore.currentObjective" />
80 </span>
81 <span class="rh-rail">
82 <RunsBalance class="shrink-0" />
83 <button
84 type="button"
85 class="margin-mark"
86 :class="{ on: recordOpen }"
87 data-testid="codex-record-toggle"
88 title="The record"
89 aria-label="Open the record"
90 @click="toggleRecord"
91 >
92 <CodexMark name="bookmark" size="15px" /><span
93 class="mark-word"
94 aria-hidden="true"
95 >record</span
96 >
97 </button>
98 <button
99 type="button"
100 class="margin-mark"
101 :class="{ muted: audio.muted }"
102 data-testid="mute-toggle"
103 :aria-pressed="!audio.muted"
104 :title="audio.muted ? 'Sound off' : 'Sound on'"
105 :aria-label="
106 audio.muted ? 'Unmute sound effects' : 'Mute sound effects'
107 "
108 @click="toggleMute"
109 >
110 <CodexMark name="note" size="15px" />
111 </button>
112 <button
113 type="button"
114 class="margin-mark"
115 :aria-label="
116 isDark ? 'Switch to light mode' : 'Switch to dark mode'
117 "
118 :title="isDark ? 'Daylight' : 'Candlelight'"
119 @click="toggleDark"
120 >
121 <CodexMark name="candle" size="15px" />
122 </button>
123 <template v-if="gameStore.currentObjective">
124 <span v-if="resetArmed" class="reset-confirm">
125 <span class="rc-ask">abandon this history?</span>
126 <button
127 type="button"
128 class="margin-mark rc-yes"
129 data-testid="reset-confirm"
130 aria-label="Yes — abandon this history"
131 @click="performReset"
132 >
133 yes
134 </button>
135 <button
136 type="button"
137 class="margin-mark"
138 data-testid="reset-cancel"
139 aria-label="Keep playing"
140 @click="disarmReset"
141 >
142 no
143 </button>
144 </span>
145 <button
146 v-else
147 type="button"
148 class="margin-mark"
149 data-testid="reset-button"
150 title="New timeline"
151 aria-label="New timeline"
152 @click="handleReset"
153 >
154 <CodexMark name="reset" size="15px" />
155 </button>
156 </template>
157 </span>
158 </div>
159 <div class="headpiece" aria-hidden="true">
160 <svg viewBox="0 0 1000 14" preserveAspectRatio="none">
161 <line
162 x1="0"
163 y1="4"
164 x2="452"
165 y2="4"
166 stroke="currentColor"
167 stroke-width="1"
168 />
169 <line
170 x1="548"
171 y1="4"
172 x2="1000"
173 y2="4"
174 stroke="currentColor"
175 stroke-width="1"
176 />
177 <line
178 x1="0"
179 y1="8"
180 x2="452"
181 y2="8"
182 stroke="currentColor"
183 stroke-width=".5"
184 opacity=".6"
185 />
186 <line
187 x1="548"
188 y1="8"
189 x2="1000"
190 y2="8"
191 stroke="currentColor"
192 stroke-width=".5"
193 opacity=".6"
194 />
195 <g
196 class="hp-fleuron"
197 fill="currentColor"
198 transform="translate(500,6)"
199 >
200 <path d="M0,-6 C4,-2.5 4,2.5 0,6 C-4,2.5 -4,-2.5 0,-6 Z" />
201 <path
202 d="M-16,0 C-13,-2 -11,-2 -8,0 C-11,2 -13,2 -16,0 Z"
203 opacity=".8"
204 />
205 <path d="M16,0 C13,-2 11,-2 8,0 C11,2 13,2 16,0 Z" opacity=".8" />
206 </g>
207 </svg>
208 </div>
209 </header>
210 
211 <!-- The ribbon bookmark — the run's one persistent instrument: five
212 dispatch pips and the timeline figure, a silk ribbon over the leaf. -->
213 <div
214 v-if="conductor.phase.value !== 'end' && gameStore.currentObjective"
215 class="ribbon"
216 :class="trackClass"
217 :title="needLine"
218 >
219 <span
220 class="ribbon-pips"
221 data-testid="messages-counter"
222 aria-hidden="true"
223 >
224 <span
225 v-for="i in TOTAL_MESSAGES"
226 :key="i"
227 class="pip"
228 :class="{ spent: i <= spent }"
229 />
230 </span>
231 <span class="ribbon-pct" data-testid="masthead-progress"
232 >{{ Math.round(gameStore.objectiveProgress) }}%</span
233 >
234 </div>
235 
236 <!-- The leaf stage — pages turn with a quick settle (within-turn reveal
237 beats) or a 3D page-turn (a new page begins). -->
238 <div id="stage" class="stage" @click="onStageClick">
239 <Transition :name="transitionName">
240 <KeepAlive :include="['LeafDispatch']">
241 <component
242 :is="currentLeaf"
243 v-if="conductor.phase.value !== 'end'"
244 :key="conductor.phase.value"
245 />
246 </KeepAlive>
247 </Transition>
248 <EndGameScreen
249 v-if="conductor.phase.value === 'end'"
250 @play-again="performReset"
251 />
252 <RecordOverlay :open="recordOpen" @close="recordOpen = false" />
253 <!-- a11y: a polite live region narrates each reveal beat. -->
254 <div class="sr-only" role="status" aria-live="polite">
255 {{ announce }}
256 </div>
257 </div>
258 
259 <!-- The turnline — the explicit-continue model as the book's own verb.
260 Tap-anywhere + arrow/Enter stay as accelerators. -->
261 <div v-if="isRevealLeaf && !recordOpen" class="turnline">
262 <button
263 v-if="conductor.canBack.value"
264 type="button"
265 class="cx-action cx-action--quiet"
266 data-testid="codex-back"
267 @click="conductor.back()"
268 >
269 ‹ the leaf before
270 </button>
271 <span v-else aria-hidden="true" />
272 <button
273 type="button"
274 class="cx-action turn-next"
275 data-testid="codex-continue"
276 @click="conductor.advance()"
277 >
278 {{ continueLabel }}
279 </button>
280 </div>
281 <!-- The page's own corner, curling under the turnline. -->
282 <div
283 v-if="isRevealLeaf && !recordOpen"
284 class="page-corner"
285 aria-hidden="true"
286 >
287 <svg viewBox="0 0 88 88">
288 <defs>
289 <!-- the curled-back face by candlelight: paper-hi catching the
290 lamp, sinking slightly toward the fold (dark only — light
291 keeps the flat paper-lo underside via .pc-fill) -->
292 <linearGradient id="pc-face" x1="0" y1="0" x2="1" y2="1">
293 <stop offset="0" class="pc-stop-fold" />
294 <stop offset="1" class="pc-stop-corner" />
295 </linearGradient>
296 </defs>
297 <path d="M0 88 L88 88 L88 0 C60 8 20 40 0 88 Z" class="pc-fill" />
298 <path
299 d="M88 88 L88 0 C60 8 20 40 0 88 Z"
300 fill="none"
301 class="pc-edge"
302 stroke-width="1"
303 />
304 </svg>
305 </div>
306 </div>
307 
308 <!-- Persistent AI/fiction/adults-only disclosure — printed on the desk,
309 never on the page. Present on every viewport during a run. -->
310 <p
311 v-if="gameStore.currentObjective && conductor.phase.value !== 'end'"
312 data-testid="footer-disclosure"
313 class="desk-disclosure"
314 >
315 Figures are AI, not real people · replies are AI-generated fiction, not
316 historical fact · for adults (18+)
317 </p>
318 
319 <SiteFooter variant="desk" full />
320 
321 <!-- Fixed-position overlays — mounted at the desk level (OUTSIDE the
322 overflow-hidden codex, which would clip them). -->
323 <RunPacksModal />
324 <ClientOnly><FictionNotice /></ClientOnly>
325 <ClientOnly><CoachMark /></ClientOnly>
326 <ClientOnly><DevPanel /></ClientOnly>
327 </div>
328</template>
329 
330<script setup lang="ts">
331import { ref, computed, onMounted, onUnmounted, watch } from "vue";
332import { useGameStore } from "~/stores/game";
333import { useChronicleStore } from "~/stores/chronicle";
334import { useEconomyStore } from "~/stores/economy";
335import { useAudioStore } from "~/stores/audio";
336import { useCoachingStore } from "~/stores/coaching";
337import { useLegalStore } from "~/stores/legal";
338import { useGameSoundscape } from "~/composables/useGameSoundscape";
339import {
340 useCodexConductor,
341 CODEX_CONDUCTOR,
342} from "~/composables/useCodexConductor";
343import { CODEX_SOUNDSCAPE } from "~/components/codex/keys";
344import { TOTAL_MESSAGES, MAX_PROGRESS } from "~/utils/game-config";
345import {
346 readAuthReturnParams,
347 hasAuthReturn,
348 finalizeAuthReturn,
349} from "~/utils/auth-return";
350import LeafMission from "~/components/codex/LeafMission.vue";
351import LeafDispatch from "~/components/codex/LeafDispatch.vue";
352import LeafSending from "~/components/codex/LeafSending.vue";
353import LeafJudgment from "~/components/codex/LeafJudgment.vue";
354import LeafDice from "~/components/codex/LeafDice.vue";
355import LeafReply from "~/components/codex/LeafReply.vue";
356import LeafRipple from "~/components/codex/LeafRipple.vue";
357import LeafChronicle from "~/components/codex/LeafChronicle.vue";
358import RecordOverlay from "~/components/codex/RecordOverlay.vue";
359 
360const gameStore = useGameStore();
361const chronicleStore = useChronicleStore();
362const economyStore = useEconomyStore();
363const audio = useAudioStore();
364const soundscape = useGameSoundscape(); // mounted ONCE here — the store-bridge cues
365const conductor = useCodexConductor();
366 
367// Share the conductor + soundscape down to the leaves.
368provide(CODEX_CONDUCTOR, conductor);
369provide(CODEX_SOUNDSCAPE, soundscape);
370 
371const LEAVES = {
372 mission: LeafMission,
373 dispatch: LeafDispatch,
374 sending: LeafSending,
375 judgment: LeafJudgment,
376 dice: LeafDice,
377 reply: LeafReply,
378 ripple: LeafRipple,
379 chronicle: LeafChronicle,
380} as const;
381const currentLeaf = computed(
382 () => LEAVES[conductor.phase.value as keyof typeof LEAVES] ?? null,
383);
384 
385// Leaf transition: a quick settle for the within-turn reveal beats, a 3D page-turn
386// when a NEW page begins (mission/chronicle → dispatch, end → mission). flush:'pre'
387// sets the name before <Transition> reads it for the pending phase change.
388const transitionName = ref<"codex-leaf" | "codex-turn">("codex-leaf");
389watch(
390 () => conductor.phase.value,
391 (to, from) => {
392 const boundary =
393 (to === "dispatch" && (from === "mission" || from === "chronicle")) ||
394 to === "mission";
395 transitionName.value = boundary ? "codex-turn" : "codex-leaf";
396 },
397 { flush: "pre" },
398);
399 
400const spent = computed(() => TOTAL_MESSAGES - gameStore.remainingMessages);
401const trackClass = computed(() => {
402 const p = gameStore.objectiveProgress;
403 return p >= MAX_PROGRESS ? "win" : p >= 75 ? "brink" : "";
404});
405const needLine = computed(() => {
406 const p = Math.round(gameStore.objectiveProgress);
407 return p >= 100
408 ? "history rewritten"
409 : p === 0
410 ? "a history unwritten"
411 : `need +${100 - p}% to win`;
412});
413 
414// ── The record overlay ────────────────────────────────────────────────────
415const recordOpen = ref(false);
416function toggleRecord() {
417 recordOpen.value = !recordOpen.value;
419 
420// A tap anywhere on a reveal leaf turns the page (skips the dwell). Not while the
421// record is open, not on the compose/mission/end pages, and never when the tap
422// landed on a control (so buttons/inputs still work).
423function onStageClick(e: MouseEvent) {
424 if (recordOpen.value) return;
425 const p = conductor.phase.value;
426 if (p === "dispatch" || p === "mission" || p === "sending" || p === "end")
427 return;
428 const t = e.target as HTMLElement;
429 if (t.closest('button, a, input, textarea, select, summary, [role="button"]'))
430 return;
431 // Selecting prose to copy ends in a click on the stage when the drag is released —
432 // don't read that release as a page-turn. A non-collapsed selection means the player
433 // is highlighting (the reply, the chronicle, the ripple), not asking to advance.
434 const sel = typeof window !== "undefined" ? window.getSelection() : null;
435 if (sel && !sel.isCollapsed && sel.toString().trim()) return;
436 conductor.advance();
438 
439// ── Reveal a11y: keyboard parity + screen-reader announcements ─────────────
440const REVEAL_LEAVES = ["judgment", "dice", "reply", "ripple", "chronicle"];
441const isRevealLeaf = computed(() =>
442 REVEAL_LEAVES.includes(conductor.phase.value),
443);
444// The reveal pages own the bottom of the screen with the Continue/Back footer. Flag
445// the body while one shows so the shared CoachMark keeps its card clear of that
446// footer (else its bottom-anchored fallback covers the primary Continue control).
447watch(
448 isRevealLeaf,
449 (v) => {
450 if (typeof document !== "undefined")
451 document.body.classList.toggle("codex-reveal", v);
452 },
453 { immediate: true },
454);
455// The Continue affordance hands the pen back on the last reveal page of a turn.
456// The codex verb ("Turn the leaf"), not web chrome ("Continue") — the chronicle
457// boundary keeps "Next dispatch".
458const continueLabel = computed(() =>
459 conductor.phase.value === "chronicle" ? "Next dispatch" : "Turn the leaf",
460);
461// This turn's resolved AI message (held in pendingTurn during the animated
462// reveal, else the latest committed one) — the source for the announcements.
463const turnMsg = computed(() => gameStore.revealMessage);
464// A polite live region narrates each reveal beat so a screen-reader user follows
465// the auto-advancing flow without watching the animation.
466const announce = computed(() => {
467 const p = conductor.phase.value;
468 const m = turnMsg.value;
469 const node = gameStore.timelineEvents[gameStore.timelineEvents.length - 1];
470 if (p === "judgment" && m?.craft)
471 return `The Judge grades your craft: ${m.craft}${m.rollModifier ? `, ${m.rollModifier > 0 ? "+" : ""}${m.rollModifier} to the roll of fate` : ""}.`;
472 if (p === "dice" && m?.diceRoll != null)
473 return `The dice of fate land on ${m.diceRoll}: ${m.diceOutcome}.`;
474 if (p === "reply" && m) return `${m.figureName || "History"} answers.`;
475 if (p === "ripple" && node)
476 return `History bends. ${node.headline} ${node.progressChange >= 0 ? "Up" : "Down"} ${Math.abs(node.progressChange)} percent — now ${Math.round(gameStore.objectiveProgress)} percent toward the objective.`;
477 if (p === "chronicle")
478 return chronicleStore.chronicle
479 ? `The history, retold: ${chronicleStore.chronicle.title}.`
480 : "The history is being retold.";
481 if (p === "end")
482 return gameStore.gameStatus === "victory"
483 ? "History rewritten — victory."
484 : "The run is over.";
485 return "";
486});
487 
488// Keyboard parity for tap-to-advance: → / Enter / Space turn a reveal page;
489// Escape closes the record. Never hijack a key while a control or the composer
490// holds focus (so typing + buttons keep working).
491function onKeydown(e: KeyboardEvent) {
492 if (recordOpen.value) {
493 if (e.key === "Escape") {
494 recordOpen.value = false;
495 e.preventDefault();
496 }
497 return;
498 }
499 if (!isRevealLeaf.value) return;
500 const ae = document.activeElement as HTMLElement | null;
501 if (
502 ae &&
503 ae.closest(
504 'input, textarea, select, button, a, summary, [role="button"], [contenteditable="true"]',
505 )
506 )
507 return;
508 if (
509 e.key === "ArrowRight" ||
510 e.key === "Enter" ||
511 e.key === " " ||
512 e.key === "Spacebar"
513 ) {
514 e.preventDefault();
515 conductor.advance();
516 }
518onMounted(() => window.addEventListener("keydown", onKeydown));
519onUnmounted(() => {
520 window.removeEventListener("keydown", onKeydown);
521 if (typeof document !== "undefined")
522 document.body.classList.remove("codex-reveal");
523});
524 
525// ── Controls (ported from play.vue) ───────────────────────────────────────
526const colorMode = useColorMode();
527const isDark = computed(() => colorMode.value === "dark");
528function toggleDark() {
529 colorMode.preference = colorMode.value === "dark" ? "light" : "dark";
531 
532function toggleMute() {
533 audio.toggleMute();
534 if (!audio.muted) {
535 soundscape.unlock();
536 soundscape.play("uiTick");
537 }
539 
540// New-timeline reset: two-step confirm once a run is underway, one tap when there's
541// nothing to lose. resetGame bumps the epoch → the conductor snaps back to mission.
542const resetArmed = ref(false);
543let resetTimer: ReturnType<typeof setTimeout> | null = null;
544function handleReset() {
545 // One tap only when there's nothing to lose: no resolved turns AND no contact
546 // chosen (a typed first dispatch is work too — a chosen figure marks it dirty).
547 const composerDirty = !!gameStore.activeFigureName?.trim();
548 if (gameStore.timelineEvents.length === 0 && !composerDirty) {
549 performReset();
550 return;
551 }
552 resetArmed.value = true;
553 if (resetTimer) clearTimeout(resetTimer);
554 resetTimer = setTimeout(() => {
555 resetArmed.value = false;
556 }, 3500);
558function disarmReset() {
559 resetArmed.value = false;
560 if (resetTimer) clearTimeout(resetTimer);
562function performReset() {
563 disarmReset();
564 recordOpen.value = false;
565 gameStore.resetGame();
567 
568// ── Guided first run (issue #60) — adapted to the leaf flow ────────────────
569// The shell is the single orchestration site; the coaching store stays
570// game-agnostic. Beats anchor to live controls on the current leaf. The
571// roll-swing beat keys off the Dice leaf's ARRIVAL — `timelineEvents` now lands
572// later (at the Ripple leaf's commitRipple), so the old length-watch would skip
573// the dice anchor.
574const coaching = useCoachingStore();
575const legal = useLegalStore();
576const autoStartCtx = () => ({
577 hasObjective: !!gameStore.currentObjective,
578 isPlaying: gameStore.gameStatus === "playing",
579 isFirstTurn: gameStore.timelineEvents.length === 0,
580});
581const tourBaseTurns = ref(0);
582watch(
583 () => gameStore.currentObjective,
584 (obj) => {
585 if (obj && !legal.shouldShowNotice) coaching.maybeAutoStart(autoStartCtx());
586 else if (!obj) coaching.stop();
587 },
588);
589// The one-time fiction notice owns the first beat (#330): while it's up the tour
590// holds, and its dismissal re-offers the auto-start it deferred.
591watch(
592 () => legal.shouldShowNotice,
593 (show, was) => {
594 if (was && !show) coaching.maybeAutoStart(autoStartCtx());
595 },
596);
597watch(
598 () => coaching.isRunning,
599 (running) => {
600 if (running) tourBaseTurns.value = gameStore.timelineEvents.length;
601 },
602);
603// Picking who & when clears the first beat (a real grounded contact + a chosen year).
604watch(
605 () => !!gameStore.figureGrounding?.resolved && gameStore.contactWhen != null,
606 (ready) => {
607 if (ready) coaching.advanceFrom("who-when");
608 },
609);
610// The roll & swing beat fires when the Dice leaf is reached.
611watch(
612 () => conductor.phase.value,
613 (p) => {
614 if (coaching.isRunning && (p === "dice" || p === "ripple"))
615 coaching.advanceTo("roll-swing");
616 },
617);
618// A second resolved turn means the loop has plainly landed — an overstaying tour bows out.
619watch(
620 () => gameStore.timelineEvents.length,
621 (n) => {
622 if (coaching.isRunning && n >= tourBaseTurns.value + 2) coaching.complete();
623 },
624);
625 
626// ── Run lifecycle (ported from play.vue) ──────────────────────────────────
627const route = useRoute();
628const router = useRouter();
629const supabase = useSupabaseClient();
630// Readable companion to the httpOnly ew_ref marker — grabbed in setup (a Nuxt composable
631// can't be called after an await in onMounted). Non-empty ⇒ a referral claim is pending.
632const referralPending = useCookie<string | null>("ew_ref_pending");
633onMounted(async () => {
634 audio.hydrate();
635 coaching.hydrate();
636 legal.hydrate();
637 const authReturn = readAuthReturnParams(route.query);
638 if (hasAuthReturn(authReturn)) {
639 const result = await finalizeAuthReturn(authReturn, {
640 settle: async () => {
641 const { data } = await supabase.auth.getSession();
642 return Boolean(data.session && !data.session.user.is_anonymous);
643 },
644 });
645 if (result.status === "error") {
646 console.warn(
647 "[auth] sign-in link could not be finalized:",
648 authReturn.error,
649 authReturn.errorDescription,
650 );
651 }
652 // A signed-in result needs no special-case claim here: the unified self-healing
653 // referral claim below fires for any pending referral once the balance load confirms a
654 // non-anonymous session.
655 await economyStore.loadBalance();
656 router.replace({ query: {} });
657 } else {
658 const purchase = route.query.purchase;
659 if (purchase === "success" || purchase === "cancel") {
660 await economyStore.notePurchaseReturn(purchase);
661 router.replace({ query: {} });
662 if (purchase === "success") {
663 const before = economyStore.runsRemaining;
664 for (let i = 0; i < 4; i++) {
665 await new Promise((r) => setTimeout(r, 1500));
666 await economyStore.loadBalance();
667 if (economyStore.runsRemaining !== before) break;
668 }
669 }
670 } else {
671 await economyStore.loadBalance();
672 }
673 }
674 
675 // Self-healing referral claim (#96): a referred visitor's marker is claimed once they're
676 // signed in. Fire whenever a claim is still pending — the readable hint the /r/ middleware
677 // set beside the httpOnly marker (REFERRAL_PENDING_HINT / "ew_ref_pending") — and the
678 // session is non-anonymous, so a transient failure or a tab closed mid-flow simply retries
679 // on the next /play load rather than stranding the referee's welcome bonus. Idempotent
680 // server-side; the hint clears once the claim reaches a terminal outcome.
681 if (!economyStore.accountAnonymous && referralPending.value) {
682 await economyStore.claimReferral();
683 await economyStore.loadBalance();
684 }
685 // Flush a clickwrap tick parked across the OAuth redirect (#330) — only once a
686 // non-anonymous session exists, so the record keys on the final account id
687 // (email-less OAuth identities still flush). A failed or refused record keeps
688 // the tick parked for a retry later in this tab; the server refuses anonymous
689 // subjects, so a stale gate here can't record junk.
690 if (!economyStore.accountAnonymous) void legal.flushPendingAcceptance();
691 // Resume an in-progress run onto the first dispatch (the conductor lands there).
692 if (!gameStore.currentObjective) await gameStore.resumeDraft();
693 if (!legal.shouldShowNotice) coaching.maybeAutoStart(autoStartCtx());
694});
695 
696useHead({
697 title: "Everwhen — Rewrite History",
698 meta: [
699 {
700 name: "description",
701 content:
702 "Send 160-character messages to anyone in history. A D20 and an AI decide how the timeline bends.",
703 },
704 ],
705});
706</script>
707 
708<style scoped>
709/* ══ THE DESK — the environment the codex rests in ═══════════════════════ */
710.desk {
711 min-height: 100vh;
712 min-height: 100dvh;
713 display: flex;
714 flex-direction: column;
715 align-items: center;
716 justify-content: center;
717 position: relative;
718 background: radial-gradient(
719 120% 90% at 50% 38%,
720 var(--ew-desk-hi),
721 var(--ew-desk) 70%
722 );
724.desk::after {
725 /* leather grain — procedural, pointer-transparent */
726 content: "";
727 position: fixed;
728 inset: 0;
729 pointer-events: none;
730 opacity: 0.5;
731 background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240"><filter id="n"><feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/></filter><rect width="240" height="240" filter="url(%23n)"/></svg>');
733 
734/* ══ THE LEAF — a real folio with materials ══════════════════════════════ */
735.codex {
736 position: relative;
737 z-index: 1;
738 width: min(1180px, calc(100vw - 56px));
739 height: min(824px, calc(100vh - 96px));
740 display: flex;
741 flex-direction: column;
742 border-radius: 2px;
743 background: radial-gradient(
744 140% 110% at 50% 30%,
745 var(--ew-paper-hi),
746 var(--ew-paper) 55%,
747 var(--ew-paper-lo) 100%
748 );
749 /* the leaves beneath — the rest of the codex — then the desk's shadow
750 (dark carries the candle glow; themed in the token, never a global rule) */
751 box-shadow: var(--ew-leaf-shadow);
753/* laid-paper: fine laid lines + chain lines, then a fibrous mottle */
754.codex::before {
755 content: "";
756 position: absolute;
757 inset: 0;
758 pointer-events: none;
759 z-index: 0;
760 border-radius: 2px;
761 background:
762 repeating-linear-gradient(
763 0deg,
764 rgba(90, 70, 40, 0.028) 0 1px,
765 transparent 1px 4px
766 ),
767 repeating-linear-gradient(
768 90deg,
769 rgba(90, 70, 40, 0.02) 0 1px,
770 transparent 1px 88px
771 );
772 mix-blend-mode: var(--ew-tex-blend);
773 opacity: var(--ew-tex-op-laid);
775.codex::after {
776 content: "";
777 position: absolute;
778 inset: 0;
779 pointer-events: none;
780 z-index: 0;
781 opacity: var(--ew-tex-op-mottle);
782 border-radius: 2px;
783 background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="420" height="420"><filter id="m"><feTurbulence type="fractalNoise" baseFrequency="0.012 0.02" numOctaves="3" seed="7"/><feColorMatrix values="0 0 0 0 0.35 0 0 0 0 0.27 0 0 0 0 0.14 0 0 0 0.06 0"/></filter><rect width="420" height="420" filter="url(%23m)"/></svg>');
784 mix-blend-mode: var(--ew-tex-blend);
786 
⋯ 89 lines hidden (lines 787–875)
787/* ══ THE RUNNING HEAD ════════════════════════════════════════════════════ */
788.running-head-band {
789 position: relative;
790 z-index: 3;
791 flex: none;
792 padding: 26px 64px 0;
794.running-head {
795 display: grid;
796 grid-template-columns: 1fr auto 1fr;
797 align-items: center;
798 gap: 16px;
800.rh-left {
801 justify-self: start;
803.rh-centre {
804 justify-self: center;
805 min-width: 0;
807.rh-rail {
808 justify-self: end;
809 display: inline-flex;
810 align-items: center;
811 gap: 2px;
812 /* clear the ribbon hanging at the leaf's right edge */
813 padding-right: 64px;
815.headpiece {
816 margin-top: 12px;
817 color: var(--ew-ink-faint);
819.headpiece svg {
820 display: block;
821 width: 100%;
822 height: 14px;
824.hp-fleuron {
825 color: var(--ew-rubric);
827 
828/* the margin marks — the app annotating the page's outer margin */
829.margin-mark {
830 appearance: none;
831 background: none;
832 border: 0;
833 min-width: 34px;
834 min-height: 40px;
835 padding: 0 5px;
836 display: inline-flex;
837 align-items: center;
838 justify-content: center;
839 gap: 5px;
840 color: var(--ew-ink-faint);
841 font-family: var(--ew-serif);
842 font-size: var(--ew-fs-label);
843 letter-spacing: 0.14em;
844 text-transform: uppercase;
845 cursor: pointer;
846 transition: color var(--ew-dur-1) var(--ew-ease);
848.margin-mark:hover {
849 color: var(--ew-ink);
851.margin-mark.on {
852 color: var(--ew-rubric);
854.margin-mark.muted {
855 opacity: 0.55;
857.mark-word {
858 display: inline-block;
860.reset-confirm {
861 display: inline-flex;
862 align-items: center;
863 gap: 2px;
865.rc-ask {
866 font-style: italic;
867 font-size: var(--ew-fs-note);
868 color: var(--ew-rubric);
869 margin-right: 4px;
870 white-space: nowrap;
872.rc-yes {
873 color: var(--ew-rubric);
875 
876/* ══ THE RIBBON ══════════════════════════════════════════════════════════ */
877.ribbon {
878 position: absolute;
879 top: -8px;
880 right: 24px;
881 z-index: 4;
882 width: 42px;
883 padding: 14px 0 0;
884 min-height: 118px;
885 display: flex;
886 flex-direction: column;
887 align-items: center;
888 gap: 7px;
889 background: linear-gradient(
890 180deg,
891 var(--ew-wax-lo),
892 var(--ew-wax) 30%,
893 var(--ew-wax)
894 );
895 clip-path: polygon(0 0, 100% 0, 100% 100%, 50% calc(100% - 14px), 0 100%);
896 box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
898.ribbon.win {
899 background: linear-gradient(180deg, #6d5312, var(--ew-gold) 40%, #8a6a16);
901.ribbon-pips {
902 display: flex;
903 flex-direction: column;
904 gap: 7px;
906.pip {
907 width: 8px;
908 height: 8px;
909 border-radius: 50%;
910 border: 1.4px solid rgba(255, 240, 220, 0.85);
911 background: transparent;
913.pip.spent {
914 background: rgba(255, 240, 220, 0.9);
915 border-color: transparent;
917.ribbon-pct {
918 font-family: var(--ew-mono);
919 font-size: 11px;
920 color: #ffe9d2;
921 padding-bottom: 20px;
923 
924/* ══ THE STAGE ═══════════════════════════════════════════════════════════ */
925.stage {
926 position: relative;
927 flex: 1 1 auto;
928 min-height: 0;
929 perspective: 1600px;
930 z-index: 1;
932 
⋯ 299 lines hidden (lines 933–1231)
933/* ══ THE TURNLINE ════════════════════════════════════════════════════════ */
934.turnline {
935 position: relative;
936 z-index: 3;
937 flex: none;
938 display: flex;
939 justify-content: space-between;
940 align-items: flex-end;
941 padding: 8px 110px 18px 64px;
943.turn-next {
944 font-size: 14.5px;
946.page-corner {
947 position: absolute;
948 right: 0;
949 bottom: 0;
950 z-index: 2;
951 width: 84px;
952 height: 84px;
953 pointer-events: none;
955.page-corner svg {
956 display: block;
957 width: 100%;
958 height: 100%;
959 filter: drop-shadow(-3px -3px 4px rgba(0, 0, 0, 0.1));
961.pc-fill {
962 fill: var(--ew-paper-lo);
964/* In dark, paper-lo is near-black — the curl read as a hole burned in the
965 leaf. Candlelit, the curled-back face catches the lamp instead: warmed
966 paper-hi easing toward the fold, with a soft shadow beneath. */
967.dark .pc-fill {
968 fill: url(#pc-face);
970.pc-stop-fold {
971 stop-color: color-mix(in srgb, var(--ew-paper-hi) 72%, var(--ew-paper));
973.pc-stop-corner {
974 stop-color: var(--ew-paper-hi);
976.dark .page-corner svg {
977 filter: drop-shadow(-4px -4px 6px rgba(0, 0, 0, 0.5));
979.pc-edge {
980 stroke: var(--ew-rule);
982 
983/* ══ DESK FURNITURE ══════════════════════════════════════════════════════ */
984.desk-disclosure {
985 order: 10;
986 margin: 2px 0 6px;
987 font-size: var(--ew-fs-label);
988 font-style: italic;
989 letter-spacing: 0.04em;
990 color: var(--ew-desk-ink);
991 opacity: 0.85;
992 white-space: nowrap;
994 
995/* slips laid on the desk: purchase + moderation notices */
996.desk-slip {
997 position: fixed;
998 top: 12px;
999 left: 50%;
1000 transform: translateX(-50%);
1001 z-index: 60;
1002 display: flex;
1003 align-items: center;
1004 gap: 10px;
1005 max-width: min(680px, calc(100vw - 24px));
1006 padding: 10px 14px;
1007 border-radius: 2px;
1008 background: var(--ew-paper-hi);
1009 color: var(--ew-ink);
1010 font-size: var(--ew-fs-note);
1011 box-shadow:
1012 0 1px 0 var(--ew-rule-feint) inset,
1013 0 10px 30px rgba(0, 0, 0, 0.4);
1015.desk-slip.success .codex-mark {
1016 color: var(--ew-ok);
1018.blocked-banner {
1019 border-left: 3px solid var(--ew-bad);
1021.bb-tag {
1022 display: inline-flex;
1023 align-items: center;
1024 gap: 5px;
1025 font-weight: 700;
1026 text-transform: uppercase;
1027 letter-spacing: 0.08em;
1028 font-size: var(--ew-fs-label);
1029 color: var(--ew-bad);
1030 flex: none;
1032.bb-text {
1033 min-width: 0;
1035.bb-hint {
1036 display: block;
1037 font-style: italic;
1038 color: var(--ew-ink-quiet);
1039 font-size: var(--ew-fs-label);
1040 margin-top: 2px;
1042.slip-close {
1043 appearance: none;
1044 background: none;
1045 border: 0;
1046 margin-left: auto;
1047 color: var(--ew-ink-faint);
1048 cursor: pointer;
1049 min-width: 34px;
1050 min-height: 34px;
1051 display: grid;
1052 place-items: center;
1054.slip-close:hover {
1055 color: var(--ew-ink);
1058/* ══ RESPONSIVE ══════════════════════════════════════════════════════════ */
1059@media (max-width: 1023px) {
1060 .running-head-band {
1061 padding: 20px 28px 0;
1063 /* The tablet band's real recipe: the centre takes exactly the leftover
1064 width (minmax(0,1fr)) and its title truncates — the base auto track
1065 grows to max-content and overprinted the wordmark and the rail. */
1066 .running-head {
1067 grid-template-columns: auto minmax(0, 1fr) auto;
1069 .rh-centre {
1070 justify-self: stretch;
1071 overflow: hidden;
1072 display: flex;
1073 justify-content: center;
1075 .turnline {
1076 padding: 8px 96px 14px 28px;
1078 .mark-word {
1079 display: none;
1082@media (max-width: 640px) {
1083 /* the leaf IS the screen */
1084 .desk {
1085 display: block;
1087 .codex {
1088 width: 100vw;
1089 height: 100vh;
1090 height: 100dvh;
1091 border-radius: 0;
1092 box-shadow: none;
1094 .running-head-band {
1095 padding: max(10px, env(safe-area-inset-top)) 16px 0;
1097 .running-head {
1098 grid-template-columns: auto 1fr;
1099 gap: 8px;
1101 .rh-centre {
1102 display: none;
1104 .rh-rail {
1105 padding-right: 48px;
1107 .ribbon {
1108 right: 12px;
1109 top: -6px;
1110 width: 34px;
1111 min-height: 96px;
1112 padding-top: 10px;
1113 gap: 5px;
1115 .pip {
1116 width: 6px;
1117 height: 6px;
1119 .ribbon-pct {
1120 font-size: 10px;
1121 padding-bottom: 16px;
1123 .turnline {
1124 padding: 6px 84px 12px 16px;
1126 .page-corner {
1127 width: 64px;
1128 height: 64px;
1130 .desk-disclosure {
1131 position: static;
1132 transform: none;
1133 order: 9;
1134 text-align: center;
1135 padding: 6px 12px calc(6px + env(safe-area-inset-bottom));
1136 background: var(--ew-desk);
1137 width: 100%;
1138 white-space: normal;
1139 line-height: 1.4;
1143/* ══ REDUCED MOTION ══════════════════════════════════════════════════════ */
1144@media (prefers-reduced-motion: reduce) {
1145 .margin-mark,
1146 .cx-action {
1147 transition: none;
1150</style>
1152<style>
1153/* settle — within-turn reveal beats (III→VII): a quick fade + lift cross-fade.
1154 Overlapping (never mode=out-in, which would add a wait); both leaves go absolute
1155 during the swap so they cross-fade in place. */
1156.codex-leaf-enter-active,
1157.codex-leaf-leave-active {
1158 position: absolute;
1159 inset: 0;
1161/* Leaving leaves keep their entrance animation UNTOUCHED: Vue timed the leave
1162 on that animation's end event — silencing it stalls the page-turn forever.
1163 The e2e walk guards against the crossfade window by class, and the old page
1164 merely loses hit-testing while it exits. */
1165.codex-leaf-leave-active,
1166.codex-turn-leave-active {
1167 pointer-events: none;
1169.codex-leaf-enter-active {
1170 transition:
1171 opacity var(--ew-dur-2) var(--ew-ease),
1172 transform var(--ew-dur-2) var(--ew-ease);
1174.codex-leaf-leave-active {
1175 transition:
1176 opacity var(--ew-dur-1) var(--ew-ease),
1177 transform var(--ew-dur-1) var(--ew-ease);
1179.codex-leaf-enter-from {
1180 opacity: 0;
1181 transform: translateY(10px) scale(0.994);
1183.codex-leaf-leave-to {
1184 opacity: 0;
1185 transform: translateY(-8px) scale(0.99);
1188/* page-turn — a NEW page begins: the outgoing leaf turns away around the spine
1189 while the fresh page settles in. */
1190.codex-turn-enter-active,
1191.codex-turn-leave-active {
1192 position: absolute;
1193 inset: 0;
1194 transform-origin: left center;
1195 backface-visibility: hidden;
1197.codex-turn-leave-active {
1198 transition:
1199 transform 560ms var(--ew-ease),
1200 opacity 560ms var(--ew-ease);
1201 box-shadow: 40px 0 60px -20px rgba(20, 12, 4, 0.45);
1203.codex-turn-leave-to {
1204 transform: rotateY(-110deg);
1205 opacity: 0.2;
1207.codex-turn-enter-active {
1208 transition:
1209 opacity 380ms var(--ew-ease) 150ms,
1210 transform 380ms var(--ew-ease) 150ms;
1212.codex-turn-enter-from {
1213 opacity: 0;
1214 transform: translateY(8px);
1217@media (prefers-reduced-motion: reduce) {
1218 .codex-leaf-enter-active,
1219 .codex-leaf-leave-active,
1220 .codex-turn-enter-active,
1221 .codex-turn-leave-active {
1222 transition: opacity 120ms linear !important;
1224 .codex-leaf-enter-from,
1225 .codex-leaf-leave-to,
1226 .codex-turn-enter-from,
1227 .codex-turn-leave-to {
1228 transform: none !important;
1231</style>

The dispatch — a letter with a scholarly margin

The compose screen is letter anatomy: an ordinal salute, the addressee in rubric with dagger footnote marks that tie to the wager and chain glosses in a true margin column, writing on ruled lines, and a letterfoot where a matte pressed-wax seal sits on its pressed ring. The address fold keeps its auto-collapse mechanics; the ink instrument in the margin drains as the player writes (character-counter still lives there for the tests). The stake, when offered, is an ember gloss whose arming lights the seal's staked ring.

The letterhead fold and the seal station.

components/codex/LeafDispatch.vue · 1100 lines
components/codex/LeafDispatch.vue1100 lines · Vue
⋯ 5 lines hidden (lines 1–5)
1<template>
2 <LeafFrame numeral="I" name="Your dispatch">
3 <!-- The folio scoped per dispatch ("… · dispatch ii") — the leaf-within-turn
4 ordinal alone contradicted the salute from turn 2 on. -->
5 <template #sub>dispatch {{ dispatchRoman }}</template>
6 <div class="dispatch">
7 <div class="spread">
8 <div class="textblock">
9 <p class="salute">{{ saluteLine }}</p>
10 
11 <details
12 class="address"
13 :open="addressOpen"
14 @toggle="onAddressToggle"
15 >
16 <summary class="letterhead" data-testid="address-toggle">
17 <span class="addr-line">
18 <span class="addr-to">To</span>
19 <span class="addr-name" :class="{ unset: !addresseeName }">{{
20 addresseeName || "whom in history?"
21 }}</span
22 ><template v-if="addresseeName"
23 ><span class="cx-fnmark" aria-hidden="true"></span
24 ><span v-if="chainRead" class="cx-fnmark" aria-hidden="true"
25 ></span
26 ></template
27 >
28 </span>
29 <span class="addr-bio">
30 <template v-if="bioDescription"
31 >{{ bioDescription }} ·
32 </template>
33 <span class="lh-date" :class="{ nudge: nudgeAddress }">
34 <span class="lh-year" :class="{ unset: !dateline }">{{
35 dateline || "in what year?"
36 }}</span
37 ><span v-if="contactAge != null" class="lh-age">
38 · age {{ contactAge }}</span
39 >
40 </span>
41 </span>
42 <!-- While the fold is open the inputs are the invitation — no
43 completion verb over empty fields; the closed letterhead
44 keeps its quiet reopen cue. -->
45 <span
46 v-if="!addressOpen"
47 class="addr-edit cx-action"
48 aria-hidden="true"
49 >
50 <CodexMark name="quill" size="12px" />
51 change the addressee
52 </span>
53 </summary>
54 <div class="address-body">
55 <FigurePicker
56 v-model="figure"
57 :disabled="!gameStore.canSendMessage"
58 />
59 <!-- The concluding action, after the fields it concludes — shown
60 only once addressee + year hold values. -->
⋯ 21 lines hidden (lines 61–81)
61 <button
62 v-if="addressed"
63 type="button"
64 class="addr-done cx-action"
65 @click="closeAddress"
66 >
67 <CodexMark name="quill" size="12px" />
68 done addressing
69 </button>
70 </div>
71 </details>
72 
73 <div class="writing-block">
74 <p v-if="salutation" class="salutation">{{ salutation }}</p>
75 <MessageInput ref="messageInputRef" />
76 </div>
77 
78 <div class="letterfoot">
79 <p class="valediction">
80 Press the seal to send — one message can bend the whole timeline
81 <span class="vale-register"
82 >Wax, once cooled, cannot be unpressed.</span
83 >
84 </p>
85 <div class="seal-station">
86 <button
87 type="button"
88 data-testid="send-button"
89 class="seal-btn"
90 :class="{ staked: stakeArmed, sending: gameStore.isLoading }"
91 :disabled="sealDisabled"
92 aria-label="Send message"
93 @click="onSeal"
94 >
95 <span class="seal-wax"><CodexSeal class="seal-face" /></span>
96 <span class="seal-word cx-caps cx-caps--small">{{
97 sealWord
98 }}</span>
99 </button>
100 </div>
⋯ 1000 lines hidden (lines 101–1100)
101 </div>
102 
103 <div
104 v-if="gameStore.error"
105 data-testid="error-alert"
106 role="status"
107 aria-live="polite"
108 class="notice ew-blocked"
109 >
110 <span class="ew-blocked-tag notice-tag"
111 ><CodexMark name="notabene" size="12px" /> notice</span
112 >
113 <span class="notice-text">{{ gameStore.error }}</span>
114 <button
115 type="button"
116 data-testid="error-close"
117 class="dismiss"
118 aria-label="Dismiss notice"
119 @click="gameStore.setError(null)"
120 >
121 <CodexMark name="close" size="12px" />
122 </button>
123 </div>
124 <!-- A content-moderation block is surfaced as a fixed top banner in the
125 shell (pages/play.vue) — unmissable from any leaf — rather than a strip
126 at the foot of this letter, where it was easy to miss after the page
127 turned back. The composer keeps the player's words for the next try. -->
128 </div>
129 
130 <!-- The margin — the dagger apparatus: the † and ‡ on the addressee line
131 resolve to these glosses; the archive and the ink instrument follow. -->
132 <aside class="margin">
133 <div
134 v-if="wagerRead"
135 data-testid="wager-chip"
136 class="cx-gloss gloss"
137 :class="wagerRead.tier === 'in-period' ? 'ew-muted' : 'ew-warn'"
138 >
139 <CodexMark name="dagger" size="13px" />
140 {{ wagerRead.text }}
141 </div>
142 <div v-else data-testid="wager-line" class="cx-gloss gloss ew-warn">
143 <CodexMark name="dagger" size="13px" />
144 Reaching beyond their era widens the swing — gains gently, losses
145 hard.
146 </div>
147 
148 <div
149 v-if="chainRead"
150 data-testid="chain-chip"
151 class="cx-gloss gloss"
152 :class="
153 chainRead.tier === 'at-hinge'
154 ? 'ew-ok'
155 : chainRead.tier === 'diffuse'
156 ? 'ew-warn'
157 : 'ew-muted'
158 "
159 >
160 <CodexMark name="double-dagger" size="13px" />
161 {{ chainRead.text }}
162 </div>
163 
164 <div
165 v-if="chainRead && chainRead.bonus > 1"
166 data-testid="chain-bonus-chip"
167 class="cx-gloss gloss ew-ok"
168 >
169 <CodexMark name="chain" size="13px" />
170 {{ chainRead.bonusText }}
171 </div>
172 
173 <div
174 v-if="gameStore.canStake"
175 data-testid="stake-control"
176 class="stake"
177 :class="{ armed: stakeArmed }"
178 >
179 <input
180 id="stake-toggle"
181 v-model="stakeArmed"
182 type="checkbox"
183 class="stake-box"
184 />
185 <label for="stake-toggle" class="stake-label">
186 <span class="stake-head"
187 ><CodexMark name="flag" size="13px" /> Stake the timeline — the
188 last stand</span
189 >
190 <span class="stake-body"
191 >Your final swing
192 <span class="stake-em">doubles, both ways</span>: a hit ≈
193 {{ stakeRead?.hit }}, a miss ≈ {{ stakeRead?.miss }}. You need
194 +{{ stakeRead?.need }}% to win.</span
195 >
196 </label>
197 </div>
198 <div
199 v-else-if="finalReach"
200 data-testid="final-reach"
201 class="cx-gloss gloss"
202 >
203 <CodexMark name="manicule" size="13px" />
204 <b>Final dispatch</b> — no stake needed: you need +{{
205 finalReach.need
206 }}%, still inside one dispatch’s reach.
207 </div>
208 
209 <hr class="gloss-rule" aria-hidden="true" />
210 
211 <div class="archive">
212 <button
213 type="button"
214 data-testid="archive-toggle"
215 class="cx-action"
216 :aria-expanded="lookupOpen ? 'true' : 'false'"
217 @click="toggleLookup"
218 >
219 <CodexMark name="lens" size="14px" />
220 {{ lookupOpen ? "Close the archive" : "Consult the archive" }}
221 </button>
222 <p v-if="!lookupOpen" class="archive-hint">
223 look up an idea before you stake a message on it
224 </p>
225 <div v-else class="archive-inline">
226 <ArchiveLookup bare />
227 </div>
228 </div>
229 
230 <hr class="gloss-rule" aria-hidden="true" />
231 
232 <div class="inkwell">
233 <div class="ink-head">
234 <span class="cx-caps cx-caps--small">Ink remaining</span>
235 <span
236 id="character-counter"
237 data-testid="character-counter"
238 class="cx-mono ink-n"
239 :class="{ low: inkLow }"
240 aria-live="polite"
241 >{{ remainingCharacters }} of {{ MAX_MESSAGE_CHARS }}</span
242 >
243 </div>
244 <div class="cx-inkrule" aria-hidden="true">
245 <i :style="{ width: inkPercent + '%' }" />
246 </div>
247 </div>
248 </aside>
249 </div>
250 </div>
251 </LeafFrame>
252</template>
253 
254<script setup lang="ts">
255/**
256 * I · Your dispatch — a LETTER on the page (the A1 composition): salute line
257 * (the ordinal, in fiction) → addressee in rubric with the †/‡ footnote marks →
258 * biographical line → the ruled writing area (ink on the page, never a form
259 * control) → valediction + the seal station over its pressed ring. The margin
260 * column is the dagger apparatus: the wager (†) and chain (‡) glosses, the
261 * stake as an ember gloss, the archive, and the INK REMAINING instrument
262 * (which carries the #character-counter the textarea describes itself by).
263 * Ports the compose mechanics whole — picker grounding, seal gating, stake,
264 * epoch-guard — and is kept alive across the reveal leaves (see the shell) so
265 * a blocked/failed send returns here with the words intact.
266 */
267import {
268 ref,
269 computed,
270 watch,
271 nextTick,
272 inject,
273 onMounted,
274 onActivated,
275} from "vue";
276import { useGameStore, formatContactYear } from "~/stores/game";
277import { useArchiveStore } from "~/stores/archive";
278import { CODEX_CONDUCTOR } from "~/composables/useCodexConductor";
279import { CODEX_SOUNDSCAPE } from "./keys";
280import { parseKnownSinceYear, wagerTier } from "~/utils/known-since";
281import { smarten, bindYearEra } from "~/utils/text";
282import { prefersReducedMotionNow } from "~/utils/prefers-reduced-motion";
283import { CHAIN_HINT } from "~/utils/causal-chain";
284import { ANACHRONISM_LABEL } from "~/utils/anachronism";
285import { SWING_BANDS, STAKE_MULTIPLIER } from "~/utils/swing-bands";
286import {
287 MAX_PROGRESS,
288 TOTAL_MESSAGES,
289 MAX_MESSAGE_CHARS,
290} from "~/utils/game-config";
291import { DiceOutcome } from "~/utils/dice";
292import LeafFrame from "./LeafFrame.vue";
293import CodexSeal from "./CodexSeal.vue";
294import CodexMark from "./CodexMark.vue";
295 
296defineOptions({ name: "LeafDispatch" });
297 
298const gameStore = useGameStore();
299const archiveStore = useArchiveStore();
300const conductor = inject(CODEX_CONDUCTOR)!;
301const soundscape = inject(CODEX_SOUNDSCAPE, null);
302 
303const figure = ref("");
304watch(figure, (name) => gameStore.setActiveFigure((name || "").trim()));
305 
306const messageInputRef = ref<{ message: string; isValid: boolean } | null>(null);
307const messageInputValid = computed(
308 () => messageInputRef.value?.isValid ?? false,
309);
310const canSend = computed(
311 () =>
312 messageInputValid.value &&
313 figure.value.trim().length > 0 &&
314 gameStore.canContact,
315);
316 
317// The wax seal is the only send control (the e2e locates it by the 'Send message'
318// accessible name): disabled by messages-left / validation / contact, with a small
319// state word struck under the seal.
320const sealDisabled = computed(
321 () => !gameStore.canSendMessage || !canSend.value,
322);
323const sealWord = computed(() =>
324 gameStore.isRateLimited
325 ? "wait"
326 : gameStore.isLoading
327 ? "sealing…"
328 : "Seal & send",
329);
330 
331const dispatchOrdinal = computed(
332 () => TOTAL_MESSAGES - gameStore.remainingMessages + 1,
333);
334 
335// The folio's dispatch scope, in the folio's lowercase voice ("dispatch ii") —
336// fed to LeafFrame's sub slot so the leaf-within-turn folio line can't
337// contradict the salute ("The second of five dispatches…") from turn 2 on.
338const ROMAN_ORDINALS = ["i", "ii", "iii", "iv", "v", "vi", "vii"];
339const dispatchRoman = computed(
340 () =>
341 ROMAN_ORDINALS[dispatchOrdinal.value - 1] ?? String(dispatchOrdinal.value),
342);
343 
344// The salute — the ordinal line above the address, in fiction. Words are derived
345// from the same constants the pips count, so the copy can never drift from the
346// mechanics ("The first of five dispatches…").
347const ORDINAL_WORDS = [
348 "first",
349 "second",
350 "third",
351 "fourth",
352 "fifth",
353 "sixth",
354 "seventh",
355];
356const COUNT_WORDS: Record<number, string> = {
357 3: "three",
358 4: "four",
359 5: "five",
360 6: "six",
361 7: "seven",
362};
363const saluteLine = computed(() => {
364 const ord =
365 ORDINAL_WORDS[dispatchOrdinal.value - 1] ?? `${dispatchOrdinal.value}th`;
366 const total = COUNT_WORDS[TOTAL_MESSAGES] ?? String(TOTAL_MESSAGES);
367 return `The ${ord} of ${total} dispatches, to be carried backward through time —`;
368});
369 
370// --- The letterhead: addressee + biographical line, read from the same grounding
371// state the picker manages (display only — every mechanic computes off the store). ---
372// Model output is smartened at the render seam (straight apostrophes → ’); the
373// player's own typed fallback passes through untouched. Dateline years bind to
374// their era with a no-break space so "49 BC" can never split across a line.
375const addresseeName = computed(() =>
376 gameStore.figureGrounding?.resolved
377 ? smarten(gameStore.figureGrounding.name)
378 : figure.value.trim(),
379);
380const bioDescription = computed(() =>
381 gameStore.figureGrounding?.resolved
382 ? smarten(gameStore.figureGrounding.description ?? "")
383 : "",
384);
385const dateline = computed(() =>
386 gameStore.contactWhen != null
387 ? bindYearEra(formatContactYear(gameStore.contactWhen))
388 : "",
389);
390const contactAge = computed(() => gameStore.contactAge);
391// A soft salutation once a real recipient is grounded — the first name (or a mononym
392// whole). Pure letter flavor; absent until the letter is addressed.
393const salutation = computed(() => {
394 const g = gameStore.figureGrounding;
395 if (!g?.resolved || !g.name) return "";
396 const first = g.name.split(/\s+/)[0];
397 return first ? `Dear ${first},` : "";
398});
399 
400// The pre-send wager read (†): Archive known-since × chosen contact year → tier.
401const wagerRead = computed(() => {
402 const lookup = archiveStore.archiveResult;
403 const when = gameStore.contactWhen;
404 if (!lookup?.knownSince || when == null) return null;
405 const knownYear = parseKnownSinceYear(lookup.knownSince);
406 if (knownYear == null) return null;
407 const tier = wagerTier(knownYear, when);
408 const text =
409 tier === "in-period"
410 ? `${lookup.topic}: already known by ${formatContactYear(when)} — steady ground`
411 : `${lookup.topic}: reads ≈ ${ANACHRONISM_LABEL[tier].toLowerCase()} for ${formatContactYear(when)} — a wider swing, losses harder than gains`;
412 return { tier, text: bindYearEra(smarten(text)) };
413});
414 
415// The pre-send chain reads (‡): the DECAY read (how far the moment lands from the
416// nearest foothold) and, when you have built a chain here, the BONUS read (how much
417// your prior nearby changes will compound this gain) — the reward twin, legible
418// before the roll.
419const chainRead = computed(() => {
420 const s = gameStore.causalChainRead;
421 if (!s) return null;
422 const bonus = s.bonus ?? 1;
423 const stones = s.depth ?? 0;
424 return {
425 tier: s.tier,
426 text: CHAIN_HINT[s.tier],
427 bonus,
428 bonusText: `chain ×${Number(bonus.toFixed(2))}${stones} ${stones === 1 ? "foothold" : "footholds"} compound your gain`,
429 };
430});
431 
432const stakeArmed = ref(false);
433watch(stakeArmed, (armed) => {
434 if (armed) soundscape?.play("stake");
435});
436 
437const fmtSwing = (b: { min: number; max: number }) =>
438 `${b.min >= 0 ? "+" : ""}${b.min}${b.max >= 0 ? "+" : ""}${b.max}%`;
439const stakeRead = computed(() => {
440 if (!gameStore.canStake) return null;
441 const dbl = (n: number) => n * STAKE_MULTIPLIER;
442 return {
443 need: MAX_PROGRESS - gameStore.objectiveProgress,
444 hit: fmtSwing({
445 min: dbl(SWING_BANDS[DiceOutcome.SUCCESS].min),
446 max: dbl(SWING_BANDS[DiceOutcome.CRITICAL_SUCCESS].max),
447 }),
448 miss: fmtSwing({
449 min: dbl(SWING_BANDS[DiceOutcome.CRITICAL_FAILURE].min),
450 max: dbl(SWING_BANDS[DiceOutcome.FAILURE].max),
451 }),
452 };
453});
454 
455const finalReach = computed(() => {
456 if (
457 gameStore.gameStatus !== "playing" ||
458 gameStore.remainingMessages !== 1 ||
459 gameStore.canStake
460 )
461 return null;
462 const need = MAX_PROGRESS - gameStore.objectiveProgress;
463 return need > 0 ? { need } : null;
464});
465 
466// --- The ink instrument: the letter's character budget, read from the composer's
467// exposed message and shown in the margin (label + inkrule + scribe-mono counter).
468// The rule holds the ink REMAINING — full inkwell fresh, draining as the player
469// writes — so the fill agrees with its label and counter. The counter carries
470// #character-counter, which the textarea's aria-describedby points at, so the
471// accessible wiring survives the move to the margin. ---
472const remainingCharacters = computed(
473 () => MAX_MESSAGE_CHARS - (messageInputRef.value?.message.length ?? 0),
474);
475const inkPercent = computed(() =>
476 Math.max(0, (remainingCharacters.value / MAX_MESSAGE_CHARS) * 100),
477);
478const inkLow = computed(() => remainingCharacters.value <= 20);
479 
480async function onSeal() {
481 const text = (messageInputRef.value?.message ?? "").trim();
482 const target = figure.value.trim();
483 if (
484 !text ||
485 !messageInputRef.value?.isValid ||
486 !target ||
487 !gameStore.canContact
488 )
489 return;
490 // A blocked/failed send returns false; the conductor lands us back here and the
491 // composer keeps the words (we only clear on a real resolve).
492 const resolved = await conductor.seal(text, target, {
493 stake: stakeArmed.value,
494 });
495 if (resolved) {
496 stakeArmed.value = false;
497 if (messageInputRef.value) messageInputRef.value.message = "";
498 figure.value = "";
499 resetAddress(); // next dispatch opens fresh on the letterhead
500 }
502 
503// A new run / reset (epoch bump) wipes the composer; a failed send (no epoch bump)
504// keeps it.
505watch(
506 () => gameStore.runEpoch,
507 () => {
508 figure.value = "";
509 if (messageInputRef.value) messageInputRef.value.message = "";
510 stakeArmed.value = false;
511 resetAddress();
512 },
513);
514 
515// --- The address fold: open while you address the letter, then auto-collapses to
516// the letterhead once a contactable recipient + year is set, so attention moves to
517// the body. Manual toggling takes over (we stop auto-managing it for this dispatch).
518const addressOpen = ref(true);
519const addressTouched = ref(false);
520const autoCollapsed = ref(false);
521function onAddressToggle(e: Event) {
522 const open = (e.target as HTMLDetailsElement).open;
523 // A programmatic collapse (auto) also fires toggle — don't treat that as the user
524 // taking manual control. Only a divergence from our intended state is a real touch.
525 if (open !== addressOpen.value) addressTouched.value = true;
526 addressOpen.value = open;
528function resetAddress() {
529 addressOpen.value = true;
530 addressTouched.value = false;
531 autoCollapsed.value = false;
533// Auto-collapse once, when the letter becomes addressable (a contactable figure with
534// a year). Skipped if the player has taken manual control of the fold this dispatch.
535watch(
536 () => gameStore.canContact,
537 (ok) => {
538 if (
539 ok &&
540 addressOpen.value &&
541 !addressTouched.value &&
542 !autoCollapsed.value
543 ) {
544 addressOpen.value = false;
545 autoCollapsed.value = true;
546 nextTick(() =>
547 (
548 document.querySelector(
549 '[data-testid="message-input"] textarea',
550 ) as HTMLElement | null
551 )?.focus(),
552 );
553 }
554 },
555);
556 
557// A gentle "pick your moment" nudge. The year is auto-set to mid-lifetime when a
558// figure grounds, then the fold auto-collapses to the letterhead — so the dateline
559// can read as fixed decoration. While it sits at that untouched default, the year
560// inks itself in rubric on a slow breath, flagging that choosing a deliberate year
561// is the next step. It drops the instant the player opens the address fold (they've
562// found the when-control) and resets each dispatch via resetAddress().
563const nudgeAddress = computed(
564 () => autoCollapsed.value && !addressOpen.value && !addressTouched.value,
565);
566 
567// The explicit "done addressing" close, offered after the fields only once the
568// letter is actually addressed (a name + a year hold values) — a fresh open fold
569// shows no completion verb; the empty inputs are the invitation. Closing this way
570// is the player taking manual control, so the auto-collapse never fights it.
571const addressed = computed(() => !!addresseeName.value && !!dateline.value);
572function closeAddress() {
573 addressTouched.value = true;
574 addressOpen.value = false;
576 
577// The Archive lookup unfolds inline beneath its action in the margin — the reach it
578// informs — collapsed by default so the letter body leads.
579const lookupOpen = ref(false);
580// On a phone (≤640) the margin folds beneath the letter, so the unfolded lookup
581// can land below the fold and the tap looks like a no-op — bring the field into
582// view and hand it the pen. Desktop's in-margin unfold stays put.
583function toggleLookup() {
584 lookupOpen.value = !lookupOpen.value;
585 if (!lookupOpen.value) return;
586 if (
587 typeof window === "undefined" ||
588 !window.matchMedia?.("(max-width: 640px)").matches
589 )
590 return;
591 nextTick(() => {
592 const field = document.querySelector(
593 '[data-testid="lookup-input"]',
594 ) as HTMLElement | null;
595 if (!field) return;
596 field.scrollIntoView?.({
597 block: "center",
598 behavior: prefersReducedMotionNow() ? "auto" : "smooth",
599 });
600 field.focus({ preventScroll: true });
601 });
603 
604// Focus the recipient field when the dispatch leads with an unaddressed letterhead;
605// once addressed (fold collapsed), there's nothing to focus there.
606function focusFigure() {
607 if (!addressOpen.value) return;
608 nextTick(() =>
609 (
610 document.querySelector(
611 '[data-testid="figure-input"]',
612 ) as HTMLElement | null
613 )?.focus(),
614 );
616onMounted(focusFigure);
617onActivated(focusFigure);
618</script>
619 
620<style scoped>
621/* The letter scrolls inside the leaf when tall, never sideways (a lone
622 overflow-y would make the browser compute overflow-x to auto, so pin it). */
623.dispatch {
624 flex: 1 1 auto;
625 min-height: 0;
626 overflow-y: auto;
627 overflow-x: hidden;
629 
630/* The spread: one textblock + a true outer margin for the glosses. */
631.spread {
632 display: grid;
633 grid-template-columns: minmax(0, 1fr) 248px;
634 gap: 0 48px;
635 padding: 26px 6px 26px 88px;
637.textblock {
638 max-width: 620px;
639 width: 100%;
640 min-width: 0;
642 
643/* ── The letter opening ──────────────────────────────────────────────────── */
644.salute {
645 font-size: var(--ew-fs-body);
646 font-style: italic;
647 color: var(--ew-ink-quiet);
649 
650.address {
651 margin-top: 6px;
652 min-width: 0;
654.letterhead {
655 display: block;
656 cursor: pointer;
657 list-style: none;
658 user-select: none;
660.letterhead::-webkit-details-marker {
661 display: none;
663.letterhead::marker {
664 content: "";
666.addr-line {
667 display: block;
668 line-height: 1.2;
670.addr-to {
671 font-style: italic;
672 font-size: 19px;
673 color: var(--ew-ink-quiet);
674 margin-right: 8px;
676.addr-name {
677 font-size: var(--ew-fs-title);
678 font-weight: 600;
679 color: var(--ew-rubric);
681.addr-name.unset {
682 color: var(--ew-ink-faint);
683 font-style: italic;
684 font-weight: 400;
686.addr-line .cx-fnmark {
687 font-size: 15px;
689.addr-bio {
690 display: block;
691 margin-top: 5px;
692 font-size: var(--ew-fs-body);
693 font-style: italic;
694 color: var(--ew-ink-quiet);
696.lh-year {
697 color: var(--ew-ink);
698 font-weight: 600;
699 font-style: normal;
700 /* the year reads as adjustable: a feint dotted underline in the paper */
701 text-decoration: underline dotted;
702 text-underline-offset: 3px;
703 text-decoration-color: var(--ew-rule);
705.lh-year.unset {
706 color: var(--ew-ink-faint);
707 font-weight: 400;
708 font-style: italic;
709 text-decoration: none;
711.lh-age {
712 color: var(--ew-ink-faint);
714.letterhead:hover .lh-year:not(.unset) {
715 text-decoration-color: var(--ew-rubric);
717/* The "pick your moment" nudge (nudgeAddress): while the year sits at its
718 auto-picked default, it inks itself rubric on a slow breath — an invitation,
719 not an alarm. Drops the instant the fold reopens. */
720.lh-date.nudge .lh-year {
721 animation: year-breathe 2.1s var(--ew-ease) infinite;
723@keyframes year-breathe {
724 0%,
725 100% {
726 color: var(--ew-ink);
727 text-decoration-color: var(--ew-rule);
728 }
729 50% {
730 color: var(--ew-rubric);
731 text-decoration-color: var(--ew-rubric);
732 }
734.addr-edit {
735 margin-top: 10px;
736 font-size: var(--ew-fs-label);
738.letterhead:hover .addr-edit {
739 text-decoration: underline;
740 text-underline-offset: 4px;
741 text-decoration-thickness: 1px;
743.address-body {
744 padding: 14px 0 4px;
746.addr-done {
747 margin-top: 12px;
748 font-size: var(--ew-fs-label);
750 
751/* ── The writing — ink on ruled feints ───────────────────────────────────── */
752.writing-block {
753 margin-top: 34px;
755.salutation {
756 font-size: 21px;
757 line-height: 44px;
758 color: var(--ew-ink);
760 
761/* ── The letterfoot: valediction + the seal station ──────────────────────── */
762.letterfoot {
763 margin-top: 46px;
764 display: flex;
765 align-items: flex-end;
766 justify-content: space-between;
767 gap: 24px;
769.valediction {
770 font-style: italic;
771 font-size: var(--ew-fs-body);
772 color: var(--ew-ink-faint);
773 max-width: 34ch;
774 line-height: 1.5;
776.vale-register {
777 display: block;
778 margin-top: 4px;
780 
781.seal-station {
782 position: relative;
783 text-align: center;
784 flex: none;
785 padding: 6px 12px 0;
787/* the pressed ring where wax meets paper */
788.seal-station::before {
789 content: "";
790 position: absolute;
791 left: 50%;
792 top: 48px;
793 transform: translate(-50%, -50%);
794 width: 104px;
795 height: 104px;
796 border-radius: 50%;
797 box-shadow:
798 inset 1px 2px 3px color-mix(in srgb, var(--ew-ink) 15%, transparent),
799 inset -1px -1px 2px color-mix(in srgb, var(--ew-paper-hi) 85%, transparent);
800 pointer-events: none;
802 
803/* The send control IS the wax seal. States — ready / disabled (dry wax) /
804 sending (pulse) / staked (ember double ring). */
805.seal-btn {
806 position: relative;
807 display: inline-flex;
808 flex-direction: column;
809 align-items: center;
810 gap: 9px;
811 background: none;
812 border: 0;
813 padding: 2px;
814 cursor: pointer;
815 -webkit-tap-highlight-color: transparent;
817.seal-wax {
818 width: 84px;
819 height: 84px;
820 border-radius: 50%;
821 display: grid;
822 place-items: center;
823 transform: rotate(-4deg);
824 filter: drop-shadow(0 3px 5px rgba(0, 0, 0, 0.35));
825 transition:
826 transform var(--ew-dur-1) var(--ew-ease-spring),
827 filter var(--ew-dur-1) var(--ew-ease),
828 box-shadow var(--ew-dur-1) var(--ew-ease);
830.seal-face {
831 width: 100%;
832 height: 100%;
834.seal-word {
835 color: var(--ew-ink-quiet);
836 transition: color var(--ew-dur-1) var(--ew-ease);
838.seal-btn:not(:disabled):hover .seal-wax {
839 transform: rotate(-1deg) scale(1.04);
840 filter: drop-shadow(0 4px 7px rgba(0, 0, 0, 0.32));
842.seal-btn:not(:disabled):hover .seal-word {
843 color: var(--ew-rubric);
845.seal-btn:not(:disabled):active .seal-wax {
846 transform: rotate(-3deg) translateY(1px) scale(0.94);
847 filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.3));
849.seal-btn:disabled {
850 cursor: not-allowed;
852/* Disabled = dry wax: re-inked to the faint ink, flat (filter:none also drops
853 the drop shadow), relief quieted — never a sub-3:1 ghost. */
854.seal-btn:disabled .seal-wax {
855 --seal-fill: var(--ew-ink-faint);
856 --seal-shade: color-mix(in srgb, var(--ew-ink-faint) 70%, var(--ew-paper));
857 --seal-light: transparent;
858 filter: none;
860.seal-btn:disabled .seal-word {
861 color: var(--ew-ink-faint);
863/* Dark: dry wax sinks toward the candlelit page. ink-faint (#8d7f64) sits far
864 BRIGHTER than dark paper, so the light-mode re-ink made the disabled seal
865 outshine live wax — mix it ~35% into the paper instead, flat (shade = fill
866 kills the relief), the state word staying ink-faint for text contrast.
867 :not(.sending) keeps the sealing pulse's wax fill winning while disabled. */
868.dark .seal-btn:disabled:not(.sending) .seal-wax {
869 --seal-fill: color-mix(in srgb, var(--ew-ink-faint) 35%, var(--ew-paper));
870 --seal-shade: color-mix(in srgb, var(--ew-ink-faint) 35%, var(--ew-paper));
871 --seal-light: transparent;
873/* Sealing wins over the disabled re-ink (same specificity, later in source). */
874.seal-btn.sending .seal-wax {
875 --seal-fill: var(--ew-wax);
876 animation: seal-pulse 1.1s var(--ew-ease) infinite;
878/* The last stand, armed: the seal takes an ember double ring. */
879.seal-btn.staked .seal-wax {
880 box-shadow:
881 0 0 0 2px var(--ew-paper),
882 0 0 0 3.5px color-mix(in srgb, var(--ew-rubric) 75%, var(--ew-wax)),
883 0 0 0 7px color-mix(in srgb, var(--ew-rubric) 22%, transparent),
884 0 0 18px 5px color-mix(in srgb, var(--ew-rubric) 28%, transparent);
886/* The seal's focus halo is its own pressed ring. */
887.seal-btn:focus-visible {
888 outline: none;
890.seal-btn:focus-visible .seal-wax {
891 box-shadow:
892 0 0 0 2px var(--ew-paper),
893 0 0 0 4px var(--ew-rubric);
895@keyframes seal-pulse {
896 0%,
897 100% {
898 transform: rotate(-4deg) scale(1);
899 }
900 50% {
901 transform: rotate(-4deg) scale(1.06);
902 }
904 
905/* ── The notice — honest, marked nota bene ───────────────────────────────── */
906.notice {
907 display: flex;
908 align-items: flex-start;
909 gap: 8px;
910 margin-top: 14px;
911 font-size: var(--ew-fs-note);
913.notice-tag {
914 display: inline-flex;
915 align-items: center;
916 gap: 5px;
917 flex: none;
919.notice-text {
920 min-width: 0;
921 color: var(--ew-ink-quiet);
923.dismiss {
924 margin-left: auto;
925 background: none;
926 border: 0;
927 cursor: pointer;
928 color: var(--ew-ink-faint);
929 min-width: 34px;
930 min-height: 34px;
931 display: grid;
932 place-items: center;
934.dismiss:hover {
935 color: var(--ew-ink);
937 
938/* ── The margin — glosses, the stake, the archive, the ink instrument ────── */
939.margin {
940 min-width: 0;
941 padding-top: 10px;
943.gloss {
944 margin-bottom: 22px;
946/* Valence rides the marks, not the prose: the gloss text stays in the quiet
947 margin ink; the e2e-visible ew-* classes tint only the drawn mark. */
948.margin .gloss.ew-ok,
949.margin .gloss.ew-warn,
950.margin .gloss.ew-muted {
951 color: var(--ew-ink-quiet);
953.margin .gloss.ew-ok :deep(.codex-mark) {
954 color: var(--ew-ok);
956.margin .gloss.ew-muted :deep(.codex-mark) {
957 color: var(--ew-ink-faint);
959.gloss b {
960 font-style: normal;
961 font-weight: 600;
962 color: var(--ew-ink);
964.gloss-rule {
965 border: 0;
966 border-top: 1px solid var(--ew-rule-feint);
967 margin: 18px 0;
969 
970/* The stake — an ember gloss: wax-adjacent, rubric-ruled, never a form box. */
971.stake {
972 display: flex;
973 align-items: flex-start;
974 gap: 9px;
975 border-left: 2px solid var(--ew-rubric);
976 padding: 2px 0 2px 12px;
977 margin-bottom: 22px;
979.stake-box {
980 margin-top: 3px;
981 accent-color: var(--ew-rubric);
983.stake-label {
984 cursor: pointer;
985 font-size: var(--ew-fs-note);
986 line-height: 1.5;
988.stake-head {
989 display: inline-flex;
990 align-items: center;
991 gap: 6px;
992 font-family: var(--ew-serif);
993 font-size: var(--ew-fs-label);
994 letter-spacing: 0.14em;
995 text-transform: uppercase;
996 font-weight: 600;
997 color: var(--ew-rubric);
999.stake-body {
1000 display: block;
1001 margin-top: 3px;
1002 font-style: italic;
1003 color: var(--ew-ink-quiet);
1005.stake-em {
1006 font-style: normal;
1007 font-weight: 600;
1008 color: var(--ew-rubric);
1011.archive-hint {
1012 margin: 5px 0 0 21px;
1013 font-size: var(--ew-fs-note);
1014 font-style: italic;
1015 color: var(--ew-ink-faint);
1016 line-height: 1.45;
1018.archive-inline {
1019 margin-top: 10px;
1022.inkwell {
1023 padding-bottom: 6px;
1025.ink-head {
1026 display: flex;
1027 justify-content: space-between;
1028 align-items: baseline;
1029 gap: 8px;
1030 margin-bottom: 6px;
1032.ink-n {
1033 font-size: 11.5px;
1034 color: var(--ew-ink-faint);
1036.ink-n.low {
1037 color: var(--ew-rubric);
1038 font-weight: 700;
1041/* ── Responsive: the gloss column narrows, then folds beneath the letter ─── */
1042@media (max-width: 1023px) {
1043 .spread {
1044 grid-template-columns: minmax(0, 1fr) 180px;
1045 gap: 0 28px;
1046 padding: 16px 4px 20px 8px;
1049@media (max-width: 640px) {
1050 .spread {
1051 display: block;
1052 padding: 10px 2px 20px;
1054 .textblock {
1055 max-width: none;
1057 .addr-name {
1058 font-size: 24px;
1060 .writing-block {
1061 margin-top: 24px;
1063 .letterfoot {
1064 margin-top: 30px;
1066 .margin {
1067 border-top: 1px solid var(--ew-rule-feint);
1068 margin-top: 30px;
1069 padding-top: 18px;
1072@media (pointer: coarse) {
1073 .salutation {
1074 font-size: 19px;
1075 line-height: 40px;
1079/* ── Reduced motion: the same information, at rest ───────────────────────── */
1080@media (prefers-reduced-motion: reduce) {
1081 .seal-wax,
1082 .seal-word {
1083 transition: none;
1085 .seal-btn:not(:disabled):hover .seal-wax,
1086 .seal-btn:not(:disabled):active .seal-wax {
1087 transform: rotate(-4deg);
1089 .seal-btn.sending .seal-wax {
1090 animation: none;
1092 /* No breathing — hold a static rubric emphasis on the year so the "pick a
1093 year" cue still reads, without animation. */
1094 .lh-date.nudge .lh-year {
1095 animation: none;
1096 color: var(--ew-rubric);
1097 text-decoration-color: var(--ew-rubric);
1100</style>

History bends — the proclamation and the gauge

The climax leaf composes as one document: rubric kicker, drop-cap display headline, a rotated SEALED-AT stamp-mark, then the instruments. The swing gauge is the panel-endorsed steal from the competing 'bureau' direction, redrawn in page inks: loss and gain arcs with fine ticks, a rubric needle that sweeps from zero on a 900ms beat with an overshoot curve, a dotted trail, and the signed figure at display scale in valence ink. Beneath it, a hundred-mark ledger ruler carries the run's cumulative state, and the swing equation prints as a dagger footnote that reconciles (+18 · sharp ×1.2 → +22%) — or collapses to a plain statement when nothing scaled the turn.

The engraved dial: bands, ticks, needle, trail, and the valence figure.

components/codex/LeafRipple.vue · 953 lines
components/codex/LeafRipple.vue953 lines · Vue
⋯ 27 lines hidden (lines 1–27)
1<template>
2 <LeafFrame numeral="VI" name="History bends">
3 <div class="ripple-page" :style="`--reveal-after:${HEADLINE_TYPE_MS}ms`">
4 <div class="entry">
5 <div class="kicker cx-caps cx-caps--rubric cx-ink-in">
6 The timeline is rewritten
7 </div>
8 <div class="headline" :class="{ inked: !!node }">
9 <TypeOn
10 v-if="node?.headline"
11 :text="node.headline"
12 :duration-ms="HEADLINE_TYPE_MS"
13 />
14 <span v-else class="weighing" data-testid="ripple-weighing">
15 <span class="weighing-label">The timeline weighs your move</span>
16 <span class="ink-dots" aria-hidden="true"><i /><i /><i /></span>
17 </span>
18 </div>
19 <p v-if="node?.detail" class="detail">{{ node.detail }}</p>
20 <div class="dateline" :class="{ landed }">
21 <CodexSeal class="stamp" />
22 <span class="stamp-mark"
23 >Sealed at {{ node?.era }}{{ node?.figureName }}</span
24 >
25 </div>
26 </div>
27 
28 <div class="gauge-col" :class="{ pending: !node, landed }">
29 <div class="gauge">
30 <svg class="dial" viewBox="0 0 520 212" aria-hidden="true">
31 <path class="dial-frame" :d="dialArc(-ARC_SWEEP, ARC_SWEEP, 170)" />
32 <path class="band band-loss" :d="dialArc(-ARC_SWEEP, 0, 164)" />
33 <path class="band band-gain" :d="dialArc(0, ARC_SWEEP, 164)" />
34 <line
35 v-for="t in dialTicks"
36 :key="t.v"
37 class="tick"
38 :x1="t.x1"
39 :y1="t.y1"
40 :x2="t.x2"
41 :y2="t.y2"
42 :stroke-width="t.major ? 1.3 : 0.8"
43 />
44 <text
45 v-for="n in dialNumerals"
46 :key="n.v"
47 class="dial-num dial-num--full"
48 :x="n.x"
49 :y="n.y"
50 text-anchor="middle"
51 >
52 {{ n.label }}
53 </text>
54 <text
55 v-for="n in dialNumeralsCompact"
56 :key="'c' + n.v"
57 class="dial-num dial-num--compact"
58 :x="n.x"
59 :y="n.y"
60 text-anchor="middle"
61 >
62 {{ n.label }}
63 </text>
64 <text
65 class="dial-word word-loss"
66 x="96"
67 y="192"
68 text-anchor="middle"
69 >
70 LOSS
71 </text>
72 <text
73 class="dial-word word-gain"
74 x="424"
75 y="192"
76 text-anchor="middle"
77 >
78 GAIN
79 </text>
80 <text class="hub-word" x="260" y="132" text-anchor="middle">
81 HISTORY BENDS
82 </text>
83 <path
84 v-if="trailPath"
85 class="trail"
86 :class="swingInk"
87 :d="trailPath"
88 stroke="currentColor"
89 />
90 <g transform="translate(260, 198)">
91 <path
92 class="needle"
93 d="M -2.6 0 L 0 -132 L 2.6 0 Z"
94 :style="{ transform: `rotate(${needleDeg}deg)` }"
95 />
96 </g>
97 <circle class="hub" cx="260" cy="198" r="6.5" />
98 <circle class="hub-dot" cx="260" cy="198" r="2.2" />
99 </svg>
100 <div v-if="node" class="swing-figure">
101 <span
102 class="swing-value"
⋯ 851 lines hidden (lines 103–953)
103 :class="swingInk"
104 data-testid="ripple-swing"
105 >{{ signed(node.progressChange) + "%" }}</span
106 >
107 <span class="swing-chip cx-caps cx-caps--small" :class="swingInk">{{
108 swingWord
109 }}</span>
110 </div>
111 </div>
112 
113 <div class="ledger">
114 <svg class="ruler" viewBox="0 0 760 52" aria-hidden="true">
115 <line class="rule-main" x1="8" y1="30" x2="748" y2="30" />
116 <line class="rule-feint" x1="8" y1="33" x2="748" y2="33" />
117 <line
118 v-for="t in ledgerTicks"
119 :key="t.p"
120 class="tick"
121 :x1="t.x"
122 :y1="t.y1"
123 :x2="t.x"
124 y2="30"
125 :stroke-width="t.major ? 1.1 : 0.7"
126 />
127 <text
128 v-for="n in ledgerNumerals"
129 :key="n.p"
130 class="ruler-num"
131 :class="{ 'lab-minor': n.p !== 0 && n.p !== 100 }"
132 :x="n.x"
133 y="46"
134 text-anchor="middle"
135 >
136 {{ n.p }}
137 </text>
138 <line
139 class="span-done"
140 x1="8"
141 y1="30"
142 :x2="ledgerSpans.doneX"
143 y2="30"
144 />
145 <line
146 v-if="ledgerSpans.gain"
147 class="span-gain"
148 :x1="ledgerSpans.gain.x1"
149 y1="30"
150 :x2="ledgerSpans.gain.x2"
151 y2="30"
152 />
153 <line
154 v-if="ledgerSpans.lost"
155 class="span-lost"
156 :x1="ledgerSpans.lost.x1"
157 y1="30"
158 :x2="ledgerSpans.lost.x2"
159 y2="30"
160 />
161 <path
162 class="index"
163 :d="`M ${ledgerSpans.doneX} 24 l -4.5 -8 h 9 Z`"
164 />
165 <text
166 class="index-num"
167 :x="ledgerSpans.doneX"
168 y="12"
169 text-anchor="middle"
170 >
171 {{ nowPct }}
172 </text>
173 <g class="goal" transform="translate(748, 30)">
174 <circle r="4.5" class="goal-ring" />
175 <circle r="1.5" class="goal-dot" />
176 </g>
177 <text
178 v-if="nowPct < 76"
179 class="goal-word"
180 x="740"
181 y="14"
182 text-anchor="end"
183 >
184 history rewritten
185 </text>
186 </svg>
187 <p class="ledger-line">{{ ledgerLine }}</p>
188 <p
189 v-if="node"
190 class="equation cx-footnote"
191 data-testid="swing-equation"
192 >
193 <CodexMark name="dagger" size="12px" />
194 <span class="fn-lead">how the swing was weighed:</span>
195 <span class="eq-run">
196 <span>{{
197 "(" + signed(node.baseProgressChange ?? node.progressChange)
198 }}</span>
199 <SymbolHint
200 v-for="tok in scalerTokens"
201 :key="tok.kind"
202 :text="tok.hint"
203 >
204 <span :data-testid="`equation-${tok.kind}`">{{
205 tok.label
206 }}</span>
207 </SymbolHint>
208 <span :class="swingInk">{{
209 "→ " + signed(node.progressChange) + "%)"
210 }}</span>
211 </span>
212 </p>
213 </div>
214 
215 <div class="momentum-row">
216 <span class="cx-caps cx-caps--small">Momentum</span>
217 <span class="jewels" aria-hidden="true">
218 <svg
219 v-for="i in MOMENTUM_MAX"
220 :key="i"
221 class="jewel"
222 :class="{ lit: i <= momentum }"
223 viewBox="0 0 14 14"
224 >
225 <path d="M7 1.6 L12.4 7 L7 12.4 L1.6 7 Z" />
226 </svg>
227 </span>
228 <span class="mom-fact cx-mono" :class="{ live: momentum > 0 }"
229 >×{{ momFactor.toFixed(1) }} to gains</span
230 >
231 </div>
232 </div>
233 
234 <div class="sr-only" role="status" aria-live="polite">
235 {{ announceLine }}
236 </div>
237 </div>
238 </LeafFrame>
239</template>
240 
241<script setup lang="ts">
242/**
243 * VI · History bends — the turn's payoff: cause (your words) flowing into
244 * effect (history moving), one document, one melody. The conductor fires
245 * `commitRipple` on entry: the swing applies, momentum advances, and the
246 * change is sealed onto the record while this leaf reveals it.
247 *
248 * Composition (spec §6/§10): rubric kicker → display headline (TypeOn) +
249 * italic detail → wax dateline stamp → THE SWING GAUGE (an engraved half-arc
250 * instrument in page inks: loss arc left, gain right, a rubric needle that
251 * sweeps 0 → this turn's swing with a dotted trail, the signed figure at
252 * display scale beneath) → THE LEDGER RULER (the run's cumulative hundred-mark
253 * rule, the just-gained span in valence ink, the swing equation as a dagger
254 * footnote) → momentum jewels. The whole instruments block dims while the
255 * turn is still weighing, and every animation settles to store values —
256 * reduced motion renders the needle at rest at the final value.
257 *
258 * (Docs live here, not as a template comment: a leading template comment
259 * makes the component multi-root in dev and Vue drops class fallthrough.)
260 */
261import { computed, ref, watch } from "vue";
262import { useGameStore } from "~/stores/game";
263import { equationTokens } from "~/utils/swing-equation";
264import { MAX_PROGRESS, MAX_PROGRESS_SWING } from "~/utils/game-config";
265import { MOMENTUM_MAX, momentumFactor } from "~/utils/momentum";
266import LeafFrame from "./LeafFrame.vue";
267import CodexSeal from "./CodexSeal.vue";
268import CodexMark from "./CodexMark.vue";
269import TypeOn from "./TypeOn.vue";
270 
271// The headline writes itself in over this long (handed to TypeOn). The detail,
272// dateline, needle sweep, and footnote are the consequence of the change, so their
273// reveal delays are bound to this same value via the --reveal-after custom property —
274// they arrive once the change is named, and the two can't drift.
275const HEADLINE_TYPE_MS = 800;
276 
277const gameStore = useGameStore();
278// During the reveal the CURRENT turn's ripple lives in pendingTurn (null until the
279// `ripple` frame lands → the weighing beat); once committed it's the last ledger
280// event. Sourcing from pendingTurn first means a fast-reached leaf shows a clean
281// pending state, never a blank or a STALE prior-turn headline.
282const node = computed(() =>
283 gameStore.pendingTurn
284 ? gameStore.pendingTurn.ripple
285 : (gameStore.timelineEvents[gameStore.timelineEvents.length - 1] ?? null),
286);
287const landed = ref(false);
288// One polite line, spoken once per land (this leaf's single live region — the
289// shell narrates the beat separately in its own). The landed watch that fills
290// it lives below the ledger computeds it reads.
291const announceLine = ref("");
292const signed = (n: number) => (n >= 0 ? "+" : "") + n;
293// One valence ink for every signed statement of this turn's swing.
294const swingInk = computed(() => {
295 const delta = node.value?.progressChange ?? 0;
296 return delta > 0 ? "ink-ok" : delta < 0 ? "ink-bad" : "ink-held";
297});
298const swingWord = computed(() => {
299 const delta = node.value?.progressChange ?? 0;
300 return delta > 0 ? "gain" : delta < 0 ? "loss" : "held";
301});
302// The footnote's × tokens print only when the disclosed base truly differs from
303// the final swing. A missing base falls back to the final for display, and a
304// base that merely echoes the final proves nothing scaled — either way the
305// equation collapses to "(+N → +N%)" rather than printing a base a ×-token
306// then contradicts (the "+22 ✒ ×1.2 → +22%" wound).
307const scalerTokens = computed(() => {
308 const n = node.value;
309 if (!n) return [];
310 return n.baseProgressChange == null ||
311 n.baseProgressChange === n.progressChange
312 ? []
313 : equationTokens(n);
314});
315 
316/* ── The swing gauge (drawn geometry) ─────────────────────────────────────
317 Half-arc instrument: hub at (260,198), the scale spans ±ARC_SWEEP degrees
318 for ±MAX_PROGRESS_SWING points — the unstaked per-turn fuse. A staked
319 swing beyond the cap pins the needle at the cap; the figure beneath tells
320 the truth. */
321const ARC_SWEEP = 80; // degrees swept each side of vertical
322const ARC_CAP = MAX_PROGRESS_SWING;
323const DIAL_CX = 260;
324const DIAL_CY = 198;
325const rad = (deg: number) => (deg * Math.PI) / 180;
326const dialPt = (deg: number, r: number) => ({
327 x: +(DIAL_CX + r * Math.sin(rad(deg))).toFixed(1),
328 y: +(DIAL_CY - r * Math.cos(rad(deg))).toFixed(1),
329});
330const dialArc = (from: number, to: number, r: number) => {
331 const a = dialPt(from, r);
332 const b = dialPt(to, r);
333 return `M ${a.x} ${a.y} A ${r} ${r} 0 0 ${to >= from ? 1 : 0} ${b.x} ${b.y}`;
334};
335const degOf = (v: number) =>
336 (Math.max(-ARC_CAP, Math.min(ARC_CAP, v)) / ARC_CAP) * ARC_SWEEP;
337 
338// Fine ticks every 5 points, taller every 10 — engraved once (static scale).
339const dialTicks = (() => {
340 const out: {
341 v: number;
342 major: boolean;
343 x1: number;
344 y1: number;
345 x2: number;
346 y2: number;
347 }[] = [];
348 for (let v = -ARC_CAP; v <= ARC_CAP; v += 5) {
349 const major = v % 10 === 0;
350 const a = degOf(v);
351 const o = dialPt(a, 160);
352 const i = dialPt(a, major ? 149 : 154);
353 out.push({ v, major, x1: o.x, y1: o.y, x2: i.x, y2: i.y });
354 }
355 return out;
356})();
357const dialNumerals = (() => {
358 const out: { v: number; label: number; x: number; y: number }[] = [];
359 for (let v = -ARC_CAP; v <= ARC_CAP; v += 10) {
360 const p = dialPt(degOf(v), 138);
361 out.push({ v, label: Math.abs(v), x: p.x, y: +(p.y + 4).toFixed(1) });
362 }
363 return out;
364})();
365// ≤640 the arc keeps every engraved tick but sheds labels to the half- and
366// full-scale marks (±25, ±50) at a legible size — thin the labels, never the
367// engraving. Rendered as a second, CSS-swapped set (no resize listeners).
368const dialNumeralsCompact = [-ARC_CAP, -ARC_CAP / 2, ARC_CAP / 2, ARC_CAP].map(
369 (v) => {
370 const p = dialPt(degOf(v), 138);
371 return { v, label: Math.abs(v), x: p.x, y: +(p.y + 4).toFixed(1) };
372 },
373);
374 
375const needleDeg = computed(() => degOf(node.value?.progressChange ?? 0));
376// The dotted sweep trail — the needle's path from 0, kept visible at rest.
377const trailPath = computed(() => {
378 const delta = node.value?.progressChange ?? 0;
379 if (!delta) return null;
380 return dialArc(0, degOf(delta), 124);
381});
382 
383/* ── The ledger ruler (the run's cumulative state) ────────────────────────
384 A hundred-mark engraved rule: the done-span inked, the JUST-gained span in
385 valence ink (a loss shows the receded span as a struck feint past the
386 index). While the turn is uncommitted (pendingTurn still holds it) the
387 store's progress is pre-swing, so the shown "now" adds the landed change —
388 it settles onto the exact committed value. */
389const LEDGER_X0 = 8;
390const LEDGER_DX = 7.4;
391const ledgerXOf = (p: number) => +(LEDGER_X0 + p * LEDGER_DX).toFixed(1);
392const ledgerTicks = (() => {
393 const out: { p: number; major: boolean; x: number; y1: number }[] = [];
394 for (let p = 0; p <= 100; p++) {
395 const major = p % 10 === 0;
396 out.push({
397 p,
398 major,
399 x: ledgerXOf(p),
400 y1: 30 - (major ? 13 : p % 5 === 0 ? 9 : 6),
401 });
402 }
403 return out;
404})();
405const ledgerNumerals = ledgerTicks
406 .filter((t) => t.major)
407 .map((t) => ({ p: t.p, x: t.x }));
408 
409const clampPct = (p: number) => Math.max(0, Math.min(MAX_PROGRESS, p));
410const nowPct = computed(() => {
411 const change = node.value?.progressChange ?? 0;
412 return Math.round(
413 gameStore.pendingTurn
414 ? clampPct(gameStore.objectiveProgress + change)
415 : gameStore.objectiveProgress,
416 );
417});
418const ledgerSpans = computed(() => {
419 const change = node.value?.progressChange ?? 0;
420 const now = nowPct.value;
421 const prev = clampPct(now - change);
422 return {
423 doneX: ledgerXOf(now),
424 gain: change > 0 ? { x1: ledgerXOf(prev), x2: ledgerXOf(now) } : null,
425 lost: change < 0 ? { x1: ledgerXOf(now), x2: ledgerXOf(prev) } : null,
426 };
427});
428// The run's state in plain words, mechanically honest (the old needLine/pace).
429const ledgerLine = computed(() => {
430 const p = nowPct.value;
431 const n = gameStore.remainingMessages;
432 if (p >= MAX_PROGRESS)
433 return `${MAX_PROGRESS} of 100 rewritten · history rewritten`;
434 if (n <= 0) return `${p} of 100 rewritten · no dispatches remain`;
435 const apiece = Math.ceil((MAX_PROGRESS - p) / n);
436 return `${p} of 100 rewritten · ${n} ${n === 1 ? "dispatch remains" : "dispatches remain"} · need +${apiece} apiece`;
437});
438 
439// Momentum — the run's arc, read live from the store (as MomentumMeter did).
440const momentum = computed(() => gameStore.momentum);
441const momFactor = computed(() => momentumFactor(momentum.value));
442 
443// The landed watch — flips the reveal staging on and speaks the one polite
444// line, once per land (immediate, so a committed node at mount still lands).
445watch(
446 node,
447 (n) => {
448 if (n && !landed.value) {
449 landed.value = true;
450 announceLine.value = `history bends ${signed(n.progressChange)}%, now ${nowPct.value} of 100`;
451 }
452 },
453 { immediate: true },
454);
455</script>
456 
457<style scoped>
458.ripple-page {
459 flex: 1 1 auto;
460 min-height: 0;
461 width: 100%;
462 max-width: 760px;
463 margin: 0 auto;
464 display: flex;
465 flex-direction: column;
466 justify-content: center;
468 
469/* ── The proclamation entry ──────────────────────────────────────────────── */
470.kicker {
471 display: flex;
472 align-items: center;
473 gap: 14px;
475.kicker::before,
476.kicker::after {
477 content: "";
478 height: 1px;
479 flex: 0 0 84px;
480 background: color-mix(in srgb, var(--ew-rubric) 36%, transparent);
482.headline {
483 font-family: var(--ew-serif);
484 /* The climax beat owns the largest type on any leaf. */
485 font-size: clamp(30px, 4.2vw, 44px);
486 line-height: 1.14;
487 letter-spacing: -0.005em;
488 margin-top: 18px;
490.headline.inked::first-letter {
491 color: var(--ew-rubric);
492 font-size: 1.24em;
494/* The weighing beat — shown until the Timeline frame lands. */
495.weighing {
496 display: inline-flex;
497 align-items: center;
498 gap: 11px;
499 font-style: italic;
500 font-size: var(--ew-fs-title);
501 color: var(--ew-ink-quiet);
503.ink-dots {
504 display: inline-flex;
505 gap: 5px;
507.ink-dots i {
508 width: 6px;
509 height: 6px;
510 border-radius: 50%;
511 background: var(--ew-rubric);
512 opacity: 0.3;
513 animation: ink-pulse 1.15s ease-in-out infinite;
515.ink-dots i:nth-child(2) {
516 animation-delay: 0.19s;
518.ink-dots i:nth-child(3) {
519 animation-delay: 0.38s;
521@keyframes ink-pulse {
522 0%,
523 100% {
524 opacity: 0.25;
525 transform: translateY(0);
526 }
527 50% {
528 opacity: 0.9;
529 transform: translateY(-2px);
530 }
532/* The detail holds (the animation's backwards fill keeps it at opacity 0 through
533 the delay, so nothing reflows), then rises in just as the headline lands. */
534.detail {
535 margin-top: 12px;
536 font-size: var(--ew-fs-lede);
537 line-height: 1.55;
538 font-style: italic;
539 color: var(--ew-ink-quiet);
540 max-width: 58ch;
541 animation: reveal-rise var(--ew-dur-3) var(--ew-ease)
542 var(--reveal-after, 800ms) both;
544@keyframes reveal-rise {
545 from {
546 opacity: 0;
547 transform: translateY(6px);
548 }
549 to {
550 opacity: 1;
551 transform: none;
552 }
554/* The dateline — wax + the bordered rubric stamp-mark. Strikes as the
555 headline finishes naming the change (gated on the landed watch). */
556.dateline {
557 display: flex;
558 align-items: center;
559 gap: 14px;
560 margin-top: 18px;
561 opacity: 0;
563.dateline.landed {
564 animation: cx-strike var(--ew-dur-3) var(--ew-ease-spring)
565 calc(var(--reveal-after, 800ms) + 100ms) both;
567.stamp {
568 width: 44px;
569 height: 44px;
570 flex: none;
571 transform: rotate(6deg);
572 filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
574.stamp-mark {
575 font-family: var(--ew-serif);
576 font-size: var(--ew-fs-label);
577 letter-spacing: 0.18em;
578 text-transform: uppercase;
579 color: var(--ew-rubric);
580 border: 1px solid color-mix(in srgb, var(--ew-rubric) 55%, transparent);
581 padding: 5px 10px 4px;
582 transform: rotate(-1.6deg);
584 
585/* ── The instruments (dim until the node lands) ──────────────────────────── */
586.gauge-col {
587 margin-top: 26px;
588 transition: opacity var(--ew-dur-2) var(--ew-ease);
590/* Dim the instruments while the swing is still being computed, so the current
591 cumulative % never reads as the (not-yet-known) result; it lifts the instant
592 the node lands. 0.75 keeps the live numbers readable while it weighs. */
593.gauge-col.pending {
594 opacity: 0.75;
596 
597/* the swing gauge */
598.dial {
599 display: block;
600 width: min(100%, 470px);
601 height: auto;
602 margin: 0 auto;
604.dial-frame {
605 fill: none;
606 stroke: var(--ew-rule);
607 stroke-width: 1.25;
609.band {
610 fill: none;
611 stroke-width: 6;
612 stroke-linecap: round;
614.band-loss {
615 stroke: var(--ew-bad);
616 opacity: 0.32;
618.band-gain {
619 stroke: var(--ew-ok);
620 opacity: 0.42;
622.dial .tick {
623 stroke: var(--ew-ink-faint);
625.dial-num {
626 font-family: var(--ew-serif);
627 font-size: 12.5px;
628 fill: var(--ew-ink-faint);
630/* the phone-only endpoint labels sleep until the ≤640 block swaps the sets */
631.dial-num--compact {
632 display: none;
634.dial-word {
635 font-family: var(--ew-serif);
636 font-size: 11px;
637 letter-spacing: 0.2em;
639.word-loss {
640 fill: var(--ew-bad);
641 opacity: 0.8;
643.word-gain {
644 fill: var(--ew-ok);
645 opacity: 0.85;
647.hub-word {
648 font-family: var(--ew-serif);
649 font-size: 11px;
650 letter-spacing: 0.2em;
651 fill: var(--ew-ink-quiet);
653.trail {
654 fill: none;
655 stroke-width: 2.4;
656 stroke-linecap: round;
657 stroke-dasharray: 1 7;
658 opacity: 0;
660.gauge-col.landed .trail {
661 animation: trail-ink var(--ew-beat) var(--ew-ease)
662 calc(var(--reveal-after, 800ms) + 200ms) both;
664@keyframes trail-ink {
665 to {
666 opacity: 0.9;
667 }
669/* The rubric needle. Its resting transform is the store's final value; the
670 sweep animates FROM zero to that computed rest, so the data is never gated
671 on the animation (reduced motion simply shows the rest state). */
672.needle {
673 fill: var(--ew-rubric);
674 transform-box: fill-box;
675 transform-origin: 50% 100%;
677.gauge-col.landed .needle {
678 animation: needle-sweep var(--ew-beat) var(--ew-ease-strike)
679 var(--reveal-after, 800ms) both;
681@keyframes needle-sweep {
682 from {
683 transform: rotate(0deg);
684 }
686.hub {
687 fill: var(--ew-ink);
689.hub-dot {
690 fill: var(--ew-paper-hi);
692/* the signed figure — serif at display scale, in valence ink; inks in AFTER
693 the needle has swung (the loudest beat lands last). */
694.swing-figure {
695 display: flex;
696 align-items: baseline;
697 justify-content: center;
698 gap: 14px;
699 margin-top: 4px;
701.gauge-col.landed .swing-figure {
702 animation: cx-ink-in var(--ew-dur-3) var(--ew-ease)
703 calc(var(--reveal-after, 800ms) + 420ms) both;
705.swing-value {
706 font-family: var(--ew-serif);
707 font-size: clamp(44px, 5vw, 56px);
708 font-weight: 600;
709 line-height: 1;
710 letter-spacing: -0.01em;
712.swing-chip {
713 border: 1px solid currentColor;
714 padding: 3px 9px 2px;
715 transform: rotate(2deg) translateY(-6px);
717.ink-ok {
718 color: var(--ew-ok);
720.ink-bad {
721 color: var(--ew-bad);
723.ink-held {
724 color: var(--ew-ink);
726 
727/* the ledger ruler */
728.ledger {
729 margin-top: 18px;
731.ruler {
732 display: block;
733 width: 100%;
734 height: auto;
736.rule-main {
737 stroke: var(--ew-rule);
738 stroke-width: 1.25;
740.rule-feint {
741 stroke: var(--ew-rule-feint);
742 stroke-width: 1;
744.ruler .tick {
745 stroke: var(--ew-ink-faint);
747.ruler-num {
748 font-family: var(--ew-serif);
749 font-size: 10.5px;
750 fill: var(--ew-ink-faint);
752.span-done {
753 stroke: var(--ew-ink);
754 stroke-width: 3.5;
755 stroke-linecap: round;
757.span-gain {
758 stroke: var(--ew-ok);
759 stroke-width: 3.5;
760 stroke-linecap: round;
762.span-lost {
763 stroke: var(--ew-bad);
764 stroke-width: 2.5;
765 stroke-linecap: round;
766 stroke-dasharray: 2 4;
767 opacity: 0.8;
769.index {
770 fill: var(--ew-rubric);
772.index-num {
773 font-family: var(--ew-serif);
774 font-size: 12.5px;
775 font-weight: 600;
776 fill: var(--ew-ink);
778.goal-ring {
779 fill: none;
780 stroke: var(--ew-gold);
781 stroke-width: 1.5;
783.goal-dot {
784 fill: var(--ew-gold);
786.goal-word {
787 font-family: var(--ew-serif);
788 font-size: 11px;
789 font-style: italic;
790 fill: var(--ew-ink-faint);
792.ledger-line {
793 margin-top: 6px;
794 font-size: var(--ew-fs-note);
795 font-style: italic;
796 color: var(--ew-ink-quiet);
798/* the dagger footnote — the honest weighing, in the scribe's register */
799.equation {
800 margin-top: 12px;
801 padding-top: 10px;
802 border-top: 1px solid var(--ew-rule-feint);
803 display: flex;
804 flex-wrap: wrap;
805 align-items: baseline;
806 gap: 4px 8px;
807 animation: reveal-rise var(--ew-dur-3) var(--ew-ease)
808 calc(var(--reveal-after, 800ms) + 90ms) both;
810.eq-run {
811 font-family: var(--ew-mono);
812 font-size: var(--ew-fs-meta);
813 font-style: normal;
814 color: var(--ew-ink-quiet);
815 display: inline-flex;
816 flex-wrap: wrap;
817 align-items: baseline;
818 gap: 4px 8px;
820 
821/* momentum */
822.momentum-row {
823 margin-top: 18px;
824 display: flex;
825 align-items: center;
826 gap: 12px;
828.jewels {
829 display: inline-flex;
830 align-items: center;
831 gap: 6px;
833.jewel {
834 width: 11px;
835 height: 11px;
836 display: block;
838.jewel path {
839 fill: none;
840 stroke: var(--ew-ink-faint);
841 stroke-width: 1.3;
842 stroke-linejoin: round;
844.jewel.lit path {
845 fill: var(--ew-ok);
846 stroke: var(--ew-ok);
848.mom-fact {
849 color: var(--ew-ink-faint);
851.mom-fact.live {
852 color: var(--ew-ok);
854 
855/* ── Reduced motion: the same staging, translated — everything rests at the
856 store's final values (needle at rest, trail visible, no delays). ────── */
857@media (prefers-reduced-motion: reduce) {
858 .ink-dots i {
859 animation: none;
860 opacity: 0.5;
861 transform: none;
862 }
863 .detail,
864 .equation {
865 animation: none;
866 }
867 .dateline.landed {
868 animation: none;
869 opacity: 1;
870 }
871 .gauge-col {
872 transition: none;
873 }
874 .gauge-col.landed .needle,
875 .gauge-col.landed .trail,
876 .gauge-col.landed .swing-figure {
877 animation: none;
878 }
879 .trail {
880 opacity: 0.9;
881 }
883 
884/* ── Mobile: one column, the gauge scales to the page ────────────────────── */
885@media (max-width: 640px) {
886 .ripple-page {
887 justify-content: flex-start;
888 padding-top: 12px;
889 }
890 .kicker::before,
891 .kicker::after {
892 flex-basis: 34px;
893 }
894 .weighing {
895 font-size: 22px;
896 }
897 .detail {
898 font-size: 16px;
899 }
900 .gauge-col {
901 margin-top: 18px;
902 }
903 .swing-figure {
904 gap: 10px;
905 }
906 .momentum-row {
907 flex-wrap: wrap;
908 }
909 /* The engraving keeps its full tick density; only the LABELS thin and grow.
910 SVG text scales with the viewBox (the 760-wide ruler paints at ~340px,
911 the 520-wide dial at ~0.65), so these sizes land ≈9–10px on screen —
912 the readable floor — instead of ~5px fuzz. */
913 .dial-num--full {
914 display: none;
915 }
916 .dial-num--compact {
917 display: initial;
918 font-size: 16px;
919 }
920 .dial-word {
921 font-size: 14px;
922 }
923 .hub-word {
924 font-size: 15px;
925 }
926 .ruler {
927 /* the grown endpoint + index numerals may poke past the viewBox edge */
928 overflow: visible;
929 }
930 .ruler-num {
931 font-size: 20px;
932 transform: translateY(3px);
933 }
934 .ruler-num.lab-minor {
935 display: none;
936 }
937 /* the index flag and its numeral — the ruler's one live reading — enlarge */
938 .index {
939 transform: scale(1.35);
940 transform-box: fill-box;
941 transform-origin: 50% 100%;
942 }
943 .index-num {
944 font-size: 20px;
945 transform: translateY(-1px);
946 }
947 /* the plain-words ledger line beneath carries the goal; the micro-caption
948 at this scale was unreadable dressing */
949 .goal-word {
950 display: none;
951 }
953</style>

The dice of fate — instrument and rubric

The die is an engraved icosahedron (paper-tinted facets, ink strokes, serif numeral) that tumbles, hangs, and strikes; crits burst in radiating ink strokes rather than neon glow. Beneath it, the Table of Fates is a disclosed .cx-table whose struck band lights in its outcome ink with a marginal manicule — the gambler's rubric the diagnosis asked for. The verdict line self-reconciles the natural face with the craft tilt.

The fates table with its struck-band logic.

components/codex/LeafDice.vue · 270 lines
components/codex/LeafDice.vue270 lines · Vue
⋯ 25 lines hidden (lines 1–25)
1<template>
2 <LeafFrame numeral="IV" name="The dice of fate">
3 <div class="casting">
4 <p class="cue">
5 <template v-if="reveal?.staked"
6 >a staked throw — the swing doubles, both ways</template
7 >
8 <template v-else-if="mod"
9 >craft has tilted the die
10 <b class="cue-em">{{ signedMod }}</b></template
11 >
12 <template v-else>how fate is weighed, disclosed below</template>
13 </p>
14 
15 <div class="die-stage"><DiceRoller /></div>
16 
17 <p class="tilt-note" :class="{ 'cx-ink-in': tiltShown }">
18 <template v-if="tiltShown">
19 <CodexMark name="manicule" size="17px" class="man" />
20 the die shows <b>{{ natural }}</b
21 >; your craft carries it to <b>{{ effective }}</b>
22 <span class="fate" :class="fateClass">a {{ landedOutcome }}</span>
23 </template>
24 </p>
25 
26 <div class="fates" data-testid="roll-legend">
27 <div class="fates-title cx-caps">The table of fates</div>
28 <table class="cx-table fates-table" :class="{ landed: landedOutcome }">
29 <tbody>
30 <tr
31 v-for="row in rollLegend"
32 :key="row.label"
33 :class="{ struck: row.label === landedOutcome }"
34 >
35 <td class="range cx-mono">
36 <CodexMark
37 v-if="row.label === landedOutcome"
38 name="manicule"
39 size="15px"
40 class="row-man"
41 />{{ row.range }}
42 </td>
43 <td
44 class="fate-name"
45 :class="row.label === landedOutcome ? row.cls : ''"
46 >
47 {{ row.label }}
48 </td>
49 <td class="swing cx-mono">{{ row.swing }}</td>
50 </tr>
51 </tbody>
52 </table>
53 <p class="fates-note cx-footnote">
54 <CodexMark name="quill" size="12px" /> craft tilts the roll (±2) ·
55 <CodexMark name="notabene" size="12px" /> anachronism widens the
56 result · <CodexMark name="flag" size="12px" /> a staked final dispatch
57 doubles everything
58 </p>
59 </div>
60 </div>
⋯ 210 lines hidden (lines 61–270)
61 </LeafFrame>
62</template>
63 
64<script setup lang="ts">
65/** IV · The dice of fate — the throw: tumble, hang, strike. The DiceRoller
66 * mounts here in its rolling state (the turn is still `isLoading`); the
67 * conductor lands it via `commitReveal` after a wind-up, so the fresh
68 * component can't dedupe its own land away. The composition is the gambler's
69 * rubric: cue above, the engraved die at centerpiece scale, the tilt
70 * annotation reconciling natural → effective, and the Table of Fates with
71 * the struck band in full ink and a marginal manicule.
72 * (Doc lives here, not as a template comment: a leading template comment
73 * makes the component multi-root in dev and Vue drops class fallthrough.) */
74import { computed } from "vue";
75import { useGameStore } from "~/stores/game";
76import { SWING_BANDS } from "~/utils/swing-bands";
77import {
78 DiceOutcome,
79 CRIT_FAIL_MAX,
80 FAILURE_MAX,
81 NEUTRAL_MAX,
82 CRIT_SUCCESS_MIN,
83} from "~/utils/dice";
84import LeafFrame from "./LeafFrame.vue";
85 
86const gameStore = useGameStore();
87const reveal = computed(() => gameStore.revealMessage);
88// The struck band, once the die has landed (null while it still tumbles).
89const landedOutcome = computed(() =>
90 gameStore.isLoading ? null : (reveal.value?.diceOutcome ?? null),
91);
92const mod = computed(() => reveal.value?.rollModifier ?? 0);
93const signedMod = computed(() => `${mod.value > 0 ? "+" : ""}${mod.value}`);
94// The reconciliation: the face the die shows vs the roll the rules judged.
95const natural = computed(
96 () => reveal.value?.naturalRoll ?? reveal.value?.diceRoll,
97);
98const effective = computed(() => reveal.value?.diceRoll);
99// The tilt annotation appears once landed, when a craft modifier gives the
100// natural → effective gap something to reconcile.
101const tiltShown = computed(() => !!landedOutcome.value && !!mod.value);
102const fateClass = computed(
103 () => rollLegend.find((r) => r.label === landedOutcome.value)?.cls ?? "f-mut",
104);
105 
106const fmtSwing = (b: { min: number; max: number }) =>
107 `${b.min >= 0 ? "+" : ""}${b.min}${b.max >= 0 ? "+" : ""}${b.max}%`;
108const rollLegend = [
109 {
110 range: `${CRIT_SUCCESS_MIN}–20`,
111 label: "Critical Success",
112 swing: fmtSwing(SWING_BANDS[DiceOutcome.CRITICAL_SUCCESS]),
113 cls: "f-ok",
114 },
115 {
116 range: `${NEUTRAL_MAX + 1}${CRIT_SUCCESS_MIN - 1}`,
117 label: "Success",
118 swing: fmtSwing(SWING_BANDS[DiceOutcome.SUCCESS]),
119 cls: "f-ok",
120 },
121 {
122 range: `${FAILURE_MAX + 1}${NEUTRAL_MAX}`,
123 label: "Neutral",
124 swing: fmtSwing(SWING_BANDS[DiceOutcome.NEUTRAL]),
125 cls: "f-mut",
126 },
127 {
128 range: `${CRIT_FAIL_MAX + 1}${FAILURE_MAX}`,
129 label: "Failure",
130 swing: fmtSwing(SWING_BANDS[DiceOutcome.FAILURE]),
131 cls: "f-bad",
132 },
133 {
134 range: `1–${CRIT_FAIL_MAX}`,
135 label: "Critical Failure",
136 swing: fmtSwing(SWING_BANDS[DiceOutcome.CRITICAL_FAILURE]),
137 cls: "f-bad",
138 },
139];
140</script>
141 
142<style scoped>
143.casting {
144 flex: 1 1 auto;
145 min-height: 0;
146 display: flex;
147 flex-direction: column;
148 align-items: center;
149 justify-content: center;
150 text-align: center;
151 width: min(720px, 100%);
152 margin: 0 auto;
154.cue {
155 font-size: var(--ew-fs-body);
156 font-style: italic;
157 color: var(--ew-ink-quiet);
159.cue-em {
160 font-style: normal;
161 font-weight: 600;
162 color: var(--ew-rubric);
164 
165/* the die at centerpiece scale on its own leaf */
166.die-stage {
167 margin: 10px auto 0;
169.die-stage :deep(.die) {
170 width: 232px;
171 height: 232px;
173 
174/* the tilt: an inked annotation beside the die (space reserved so the land
175 doesn't shove the table) */
176.tilt-note {
177 min-height: 26px;
178 margin-top: 2px;
179 font-size: 17px;
180 font-style: italic;
181 color: var(--ew-ink-quiet);
183.tilt-note .man {
184 color: var(--ew-rubric);
185 margin-right: 6px;
186 vertical-align: -0.22em;
188.tilt-note b {
189 font-style: normal;
190 font-weight: 600;
191 color: var(--ew-rubric);
193.tilt-note .fate {
194 font-style: normal;
195 font-weight: 600;
197 
198/* ── the table of fates ─────────────────────────────────────────────────── */
199.fates {
200 width: min(520px, 100%);
201 margin: 14px auto 0;
202 text-align: left;
204.fates-title {
205 display: flex;
206 align-items: center;
207 justify-content: center;
208 gap: 12px;
209 text-align: center;
211.fates-title::before,
212.fates-title::after {
213 content: "";
214 height: 1px;
215 background: var(--ew-rule-feint);
216 flex: 0 0 56px;
218.fates-table {
219 margin-top: 10px;
221/* while the die still tumbles, the whole rubric reads at consulting weight;
222 once struck, the other bands recede to the table's faint default */
223.fates-table:not(.landed) td {
224 color: var(--ew-ink-quiet);
226.fates-table td.range {
227 position: relative;
228 width: 74px;
230.fates-table td.swing {
231 width: 108px;
232 text-align: right;
234/* the struck band's valence ink (over .cx-table's full-ink struck row) */
235.fates-table tr.struck td.f-ok {
236 color: var(--ew-ok);
238.fates-table tr.struck td.f-bad {
239 color: var(--ew-bad);
241.fates-table tr.struck td.f-mut {
242 color: var(--ew-ink-quiet);
244/* the marginal manicule pointing at the struck band */
245.row-man {
246 position: absolute;
247 left: -26px;
248 top: 50%;
249 transform: translateY(-50%);
250 color: var(--ew-rubric);
252.fates-note {
253 margin-top: 12px;
254 text-align: center;
255 line-height: 1.5;
257 
258@media (max-width: 640px) {
259 .die-stage :deep(.die) {
260 width: 188px;
261 height: 188px;
262 }
263 .fates {
264 width: 100%;
265 }
266 .row-man {
267 display: none;
268 }
270</style>

The record and the colophons

The record opens as an inserted quire — a second sheet with its own paper and shadow laid over the leaf, not a scrim ghost — carrying the Spine (an inked timeline with wax nodes in valence ink), the Chronicle, and the Thread. The end screens are the game's colophons: victory strikes the brand seal in gold-warmed wax between laurel marks over the epilogue and a printed table of the run's figures in serif numerals; defeat is the same page with dry wax, unlit, a step dimmer.

The quire's structure — Spine, Chronicle, Thread.

components/codex/RecordOverlay.vue · 338 lines
components/codex/RecordOverlay.vue338 lines · Vue
1<template>
2 <div
3 v-if="open"
4 ref="root"
5 class="record"
6 role="dialog"
7 aria-modal="true"
8 aria-label="The record — the timeline so far"
9 tabindex="-1"
10 data-testid="codex-record"
11 @click.self="emit('close')"
12 @keydown="onKeydown"
13 >
14 <div class="quire">
15 <header class="quire-head">
16 <CodexMark name="bookmark" size="16px" class="qh-mark" />
17 <span class="qh-title">The record</span>
18 <button
19 type="button"
20 class="rec-close"
21 aria-label="Close the record"
22 @click="emit('close')"
23 >
24 <CodexMark name="close" size="15px" />
25 </button>
26 </header>
27 <div class="quire-body">
28 <section class="quire-section">
29 <h3 class="section-head">
30 <span class="head-inner"
31 ><CodexMark name="hourglass" size="13px" class="head-mark" /><span
32 class="cx-caps"
33 >The Spine</span
34 ></span
35 >
36 </h3>
37 <TimelineLedger bare />
38 </section>
39 <section class="quire-section">
40 <h3 class="section-head">
41 <span class="head-inner"
42 ><CodexMark name="scroll" size="13px" class="head-mark" /><span
43 class="cx-caps"
44 >The Chronicle</span
45 ></span
46 >
47 </h3>
48 <Chronicle force-open bare />
49 </section>
50 <details class="quire-section thread-fold">
51 <summary class="section-head" data-testid="record-thread-toggle">
52 <span class="head-inner"
53 ><CodexMark name="quill" size="13px" class="head-mark" /><span
54 class="cx-caps"
55 >Thread</span
⋯ 283 lines hidden (lines 56–338)
56 ><svg class="fold-mark" viewBox="0 0 16 16" aria-hidden="true">
57 <path
58 d="M6 3.5 L11 8 L6 12.5"
59 fill="none"
60 stroke="currentColor"
61 stroke-width="1.4"
62 stroke-linecap="round"
63 stroke-linejoin="round"
64 /></svg
65 ></span>
66 </summary>
67 <MessageHistory />
68 </details>
69 </div>
70 </div>
71 </div>
72</template>
73 
74<script setup lang="ts">
75/**
76 * The record — an inserted quire. A second, smaller sheet laid over the leaf at
77 * full opacity with its own paper, shadow, and rule border (never a scrim
78 * ghost); the leaf beneath is simply under it, like paper — its own cast shadow
79 * and smaller footprint do the separating, with only a whisper of desk shadow
80 * behind. Summonable from any leaf (the bookmark margin-mark), so the page-by-page
81 * focus never hides past or current information. Reuses the live Spine, the
82 * Chronicle, and the conversation Thread (their public contracts untouched, so
83 * the shared /runs + /r read pages keep working). Dismiss returns to the leaf
84 * in place — the live game doesn't auto-run, so no pause is needed.
85 * (Doc lives here, not as a template comment: a leading template comment makes
86 * the component multi-root in dev and Vue drops class fallthrough.)
87 */
88import { ref, watch, nextTick } from "vue";
89const props = defineProps<{ open: boolean }>();
90const emit = defineEmits<{ close: [] }>();
91 
92// Move focus into the dialog on open and remember what had it; restore that on close so
93// dismissing the record (close mark / click-out / Escape) returns focus to the bookmark
94// toggle that opened it, instead of dropping to <body> when the button unmounts (#232).
95// Mirrors EndGameScreen's prev-focus + Tab-trap pattern.
96const root = ref<HTMLElement | null>(null);
97let prevFocus: HTMLElement | null = null;
98watch(
99 () => props.open,
100 async (o) => {
101 if (typeof document === "undefined") return;
102 if (o) {
103 prevFocus = document.activeElement as HTMLElement | null;
104 await nextTick();
105 root.value?.focus();
106 } else if (prevFocus) {
107 prevFocus.focus?.();
108 prevFocus = null;
109 }
110 },
111);
112 
113// aria-modal="true" promises AT that Tab stays inside the dialog, but without a trap it
114// walks out into the background running-head/leaf controls. Cycle first↔last within .record.
115function onKeydown(e: KeyboardEvent) {
116 if (e.key !== "Tab" || !root.value) return;
117 const focusables = root.value.querySelectorAll<HTMLElement>(
118 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])',
119 );
120 if (!focusables.length) return;
121 const first = focusables[0];
122 const last = focusables[focusables.length - 1];
123 const active = document.activeElement;
124 if (e.shiftKey && active === first) {
125 e.preventDefault();
126 last.focus();
127 } else if (!e.shiftKey && active === last) {
128 e.preventDefault();
129 first.focus();
130 }
132</script>
133 
134<style scoped>
135/* Behind the quire the leaf is simply UNDER it, like paper — a whisper of the
136 desk's shadow at most, never the rejected scrim wash. The quire's own cast
137 shadow and smaller footprint do the separating. */
138.record {
139 position: absolute;
140 inset: 0;
141 z-index: 8;
142 background: color-mix(in srgb, var(--ew-desk) 8%, transparent);
143 animation: rec-fade var(--ew-dur-2) var(--ew-ease);
145 
146/* The inserted quire: its own smaller sheet, full opacity, the leaf's paper. */
147.quire {
148 position: absolute;
149 left: 50%;
150 top: 50%;
151 transform: translate(-50%, -50%);
152 width: min(1000px, calc(100% - 64px));
153 height: 88%;
154 display: flex;
155 flex-direction: column;
156 border: 1px solid var(--ew-rule);
157 border-radius: 2px;
158 background: radial-gradient(
159 140% 110% at 50% 30%,
160 var(--ew-paper-hi),
161 var(--ew-paper) 55%,
162 var(--ew-paper-lo) 100%
163 );
164 box-shadow:
165 0 14px 40px -10px rgba(0, 0, 0, 0.45),
166 0 34px 80px -28px rgba(0, 0, 0, 0.4);
167 animation: quire-settle var(--ew-dur-3) var(--ew-ease) both;
169 
170/* Night: the inserted sheet sits a step LIGHTER than the leaf it covers — its
171 own candle key-light — and casts a warm-black shadow, so it lifts off the
172 page instead of dissolving into a hairline box at the same tonal value. */
173.dark .quire {
174 border-color: color-mix(in srgb, var(--ew-rule) 70%, var(--ew-ink-faint));
175 background: radial-gradient(
176 140% 110% at 50% 30%,
177 color-mix(in srgb, var(--ew-paper-hi) 86%, var(--ew-ink) 14%),
178 var(--ew-paper-hi) 55%,
179 var(--ew-paper) 100%
180 );
181 box-shadow:
182 0 14px 40px -10px rgba(14, 7, 2, 0.8),
183 0 34px 90px -24px rgba(10, 5, 1, 0.75),
184 inset 0 0 110px -30px rgba(255, 170, 80, 0.14);
186 
187.quire-head {
188 flex: none;
189 display: flex;
190 align-items: center;
191 gap: 10px;
192 padding: 16px 22px 12px 30px;
193 border-bottom: 1px solid var(--ew-rule-feint);
195.qh-mark {
196 color: var(--ew-rubric);
198.qh-title {
199 font-family: var(--ew-serif);
200 font-size: 22px;
201 color: var(--ew-ink);
203.rec-close {
204 appearance: none;
205 background: none;
206 border: 0;
207 margin-left: auto;
208 width: 40px;
209 height: 40px;
210 display: grid;
211 place-items: center;
212 color: var(--ew-ink-faint);
213 cursor: pointer;
214 transition: color var(--ew-dur-1) var(--ew-ease);
216.rec-close:hover {
217 color: var(--ew-ink);
219 
220/* One internal scroll for the whole quire. */
221.quire-body {
222 flex: 1 1 auto;
223 min-height: 0;
224 overflow-y: auto;
225 overflow-x: hidden;
226 padding: 12px 30px 26px;
228.quire-section {
229 margin-top: 18px;
231.quire-section:first-child {
232 margin-top: 6px;
234 
235/* Ribbon-marked heads: rubric-marked caps between drawn rules. */
236.section-head {
237 display: flex;
238 align-items: center;
239 gap: 12px;
240 margin: 0 0 12px;
241 font-weight: normal;
243.section-head::before,
244.section-head::after {
245 content: "";
246 flex: 1;
247 height: 1px;
248 background: var(--ew-rule-feint);
250.head-inner {
251 display: inline-flex;
252 align-items: center;
253 gap: 8px;
255.head-mark {
256 color: var(--ew-rubric);
258 
259/* The Thread fold — collapsed by default, its head the summary itself. */
260.thread-fold > summary {
261 cursor: pointer;
262 list-style: none;
264.thread-fold > summary::-webkit-details-marker {
265 display: none;
267.fold-mark {
268 width: 12px;
269 height: 12px;
270 color: var(--ew-ink-faint);
271 transition: transform var(--ew-dur-1) var(--ew-ease);
273.thread-fold[open] .fold-mark {
274 transform: rotate(90deg);
276 
277/* The Chronicle sizes to its content here — the quire body owns the scroll
278 (same override the retold-history leaf uses on the shared component). */
279.quire-body :deep([data-testid="chronicle"]) {
280 display: block;
281 height: auto;
282 border-top: 0;
283 padding-top: 0;
285.quire-body :deep([data-testid="chronicle"] > div) {
286 flex: none;
287 min-height: 0;
288 overflow: visible;
290 
291@keyframes rec-fade {
292 from {
293 opacity: 0;
294 }
295 to {
296 opacity: 1;
297 }
299@keyframes quire-settle {
300 from {
301 opacity: 0;
302 transform: translate(-50%, calc(-50% + 10px));
303 }
304 to {
305 opacity: 1;
306 transform: translate(-50%, -50%);
307 }
309@media (prefers-reduced-motion: reduce) {
310 .record,
311 .quire {
312 animation: none;
313 }
314 .fold-mark {
315 transition: none;
316 }
318 
319/* The quire IS the screen on a phone. */
320@media (max-width: 640px) {
321 .quire {
322 left: 0;
323 top: 0;
324 transform: none;
325 width: 100%;
326 height: 100%;
327 border: 0;
328 border-radius: 0;
329 animation: none;
330 }
331 .quire-head {
332 padding: 12px 12px 10px 16px;
333 }
334 .quire-body {
335 padding: 8px 16px 20px;
336 }
338</style>

What broke on the way, and what it taught

Three engineering finds worth keeping: the html.dark root-blend compile trap and the color-mix-in-shadow rasterizer poison (both now documented at their fixes in main.css), and a Vue transition subtlety — the page-turn's leave phase is timed by the leaf's entrance animation end event, so silencing that animation on leaving leaves stalls the turn forever. The e2e reveal-walk was instead hardened test-side: a settled-composer guard that treats any *-leave-active class as "the page is still turning".

The hardened walk: settle guard + an iteration budget sized to the turn.

tests/e2e/support/game.ts · 451 lines
tests/e2e/support/game.ts451 lines · TypeScript
⋯ 370 lines hidden (lines 1–370)
1import type { Page } from "@playwright/test";
2import { expect } from "@nuxt/test-utils/playwright";
3import type { TurnFixture } from "../../../utils/fixtures/turn-fixture";
4 
5/**
6 * Shared e2e helpers. The game is AI-backed, so real runs would be slow, costly,
7 * and non-deterministic — every spec mocks the two API routes (`page.route`) and
8 * drives the real UI flow against fixed responses. No OpenAI key is needed.
9 */
10 
11/**
12 * A scripted turn. Aliased to the canonical `TurnFixture` (issue #105) so the e2e
13 * mocks and dev mode's fixture lane share ONE shape and can't drift — the import is
14 * type-only (erased at build), so it adds no runtime coupling to the Playwright run.
15 */
16export type RippleFixture = TurnFixture;
17 
18/** Builds a /api/send-message payload in the exact shape the game store consumes. */
19function sendMessageResponse(f: RippleFixture = {}) {
20 const progressChange = f.progressChange ?? 20;
21 const era = f.era ?? "Alexandria, 48 BC";
22 const diceRoll = f.diceRoll ?? 14;
23 return {
24 success: true,
25 message: "Timeline updated",
26 data: {
27 userMessage: "mocked",
28 figure: {
29 name: f.figureName ?? "Cleopatra",
30 era,
31 descriptor: f.descriptor ?? "Queen of Egypt",
32 },
33 diceRoll,
34 diceOutcome: f.diceOutcome ?? "Success",
35 // Judge-of-craft fields: the default is an untilted die (sound, ±0),
36 // so specs that predate the judge keep their exact dice displays.
37 naturalRoll: f.naturalRoll ?? diceRoll,
38 rollModifier: f.rollModifier ?? 0,
39 craft: f.craft ?? "sound",
40 craftReason: f.craftReason ?? "",
41 staked: f.staked ?? false,
42 characterResponse: {
43 message: f.message ?? "A bold notion — I shall weigh it.",
44 action:
45 f.action ?? "Cleopatra dispatches envoys to Rome a decade early",
46 },
47 timeline: {
48 headline: f.headline ?? "Envoys redraw the map of power",
49 detail:
50 f.detail ??
51 "Egypt courts Rome ahead of schedule and alliances shift.",
52 era,
53 progressChange,
54 // Defaults baseProgressChange to the final swing. Since #135 the
55 // reveal's equation always renders (collapsing to (+N → +N%) on an
56 // unamplified turn); a fixture only changes its CONTENTS, not whether
57 // it appears.
58 baseProgressChange: f.baseProgressChange ?? progressChange,
59 valence:
60 f.valence ??
61 (progressChange > 0
62 ? "positive"
63 : progressChange < 0
64 ? "negative"
65 : "neutral"),
66 anachronism: f.anachronism ?? "in-period",
67 },
68 error: null,
69 },
70 };
72 
73/** Routes /api/send-message to a fixed ripple (or a per-call function for variation). */
74export async function mockSendMessage(
75 page: Page,
76 fixture: RippleFixture | (() => RippleFixture) = {},
77) {
78 await page.route("**/api/send-message", async (route) => {
79 const f = typeof fixture === "function" ? fixture() : fixture;
80 // Echo back the figure the player actually addressed (the store registers
81 // contacts from the response), unless the fixture pins one explicitly.
82 let requested: string | undefined;
83 try {
84 requested = (route.request().postDataJSON() as { figureName?: string })
85 ?.figureName;
86 } catch {
87 /* no/!json body */
88 }
89 await route.fulfill({
90 status: 200,
91 contentType: "application/json",
92 body: JSON.stringify(
93 sendMessageResponse({ ...f, figureName: f.figureName ?? requested }),
94 ),
95 });
96 });
98 
99/** Routes /api/objective to a fixed, freshly "composed" objective. */
100export async function mockObjective(
101 page: Page,
102 objective: { title: string; description: string; era: string; icon: string },
103) {
104 await page.route("**/api/objective", async (route) => {
105 await route.fulfill({
106 status: 200,
107 contentType: "application/json",
108 body: JSON.stringify({ success: true, objective }),
109 });
110 });
112 
113/** A resolved, DECEASED figure — the default contact fixture. #73 requires a
114 * grounded, deceased figure to send, so a deceased default keeps the happy-path
115 * specs sending without each re-registering grounding. (Cleopatra, 69–30 BC.) */
116const RESOLVED_FIGURE = {
117 resolved: true,
118 name: "Cleopatra",
119 description: "Queen of Egypt",
120 born: { signed: -69, display: "69 BC" },
121 died: { signed: -30, display: "30 BC" },
122 living: false,
123};
124 
125/**
126 * Routes /api/figure (the grounding lookup FigurePicker fires on selection) to a
127 * fixed result. Defaults to a resolved, deceased figure (RESOLVED_FIGURE) so the
128 * contact is sendable under the #73 require-grounding gate without every spec
129 * re-registering. Pass `{ resolved: false }` to exercise the unresolved/blocked path.
130 *
131 * NOTE: the route pattern must NOT swallow /api/figure-search (the autocomplete),
132 * which has its own mock below — hence the exact-or-query matcher.
133 */
134export async function mockFigure(
135 page: Page,
136 figure: Record<string, unknown> = RESOLVED_FIGURE,
137) {
138 await page.route(/\/api\/figure(\?|$)/, async (route) => {
139 await route.fulfill({
140 status: 200,
141 contentType: "application/json",
142 body: JSON.stringify({ name: "", resolved: false, ...figure }),
143 });
144 });
146 
147/**
148 * Routes /api/figure-search (the name-autocomplete combobox, fired while typing)
149 * to a fixed result set — default empty, so existing specs type without a
150 * dropdown appearing and never touch live Wikipedia.
151 */
152export async function mockFigureSearch(
153 page: Page,
154 results: Array<{
155 name: string;
156 description?: string;
157 thumbnail?: string;
158 }> = [],
159) {
160 await page.route("**/api/figure-search*", async (route) => {
161 await route.fulfill({
162 status: 200,
163 contentType: "application/json",
164 body: JSON.stringify({ results }),
165 });
166 });
168 
169const DEFAULT_SUGGESTIONS = [
170 {
171 name: "Wilhelm II",
172 reason:
173 "German Emperor who could restrain Vienna before the crisis spread.",
174 lifespan: "1859 – 1941",
175 },
176 {
177 name: "Edward Grey",
178 reason:
179 "Britain's foreign secretary — early mediation could cool the crisis.",
180 lifespan: "1862 – 1933",
181 },
182 {
183 name: "Nicholas II",
184 reason: "Russian Emperor; limiting mobilization could avert escalation.",
185 lifespan: "1868 – 1918",
186 },
187];
188 
189/**
190 * Routes /api/suggestions (the tool-using agent FigurePicker loads on mount) to a
191 * fixed set, so specs never trigger the live agent. Registered inside startCuratedRun
192 * because the load fires as the board mounts (and by chain-chip.spec, which sets
193 * up its own board to read a specific objective's anchor).
194 */
195export async function mockSuggestions(
196 page: Page,
197 suggestions = DEFAULT_SUGGESTIONS,
198) {
199 await page.route("**/api/suggestions", async (route) => {
200 await route.fulfill({
201 status: 200,
202 contentType: "application/json",
203 body: JSON.stringify({ success: true, suggestions, fallback: false }),
204 });
205 });
207 
208/**
209 * Routes /api/chronicle (the non-blocking living-chronicle refresh fired after each
210 * resolved turn) to a fixed telling, so specs stay offline + deterministic. The call
211 * is fire-and-forget, so most specs never assert on it — but it must be mocked or it
212 * would hit the live network mid-run.
213 */
214export async function mockChronicle(
215 page: Page,
216 chronicle: { title: string; paragraphs: string[] } = {
217 title: "The Account So Far",
218 paragraphs: [
219 "History bends under a stranger's words.",
220 "The old certainties waver.",
221 ],
222 },
223) {
224 await page.route("**/api/chronicle", async (route) => {
225 await route.fulfill({
226 status: 200,
227 contentType: "application/json",
228 body: JSON.stringify({ success: true, chronicle }),
229 });
230 });
232 
233/**
234 * Routes /api/research (the Archivist study, fired when the player clicks "Study
235 * them") to a fixed brief, so specs stay offline + deterministic.
236 */
237async function mockResearch(
238 page: Page,
239 study = {
240 atThisMoment: "At the height of their powers.",
241 grasp: ["the state of their art"],
242 reaching: ["the next problem before them"],
243 cannotYetKnow: "what the centuries after them would discover",
244 },
245) {
246 await page.route("**/api/research", async (route) => {
247 await route.fulfill({
248 status: 200,
249 contentType: "application/json",
250 body: JSON.stringify({ success: true, study }),
251 });
252 });
254 
255/** Routes /api/lookup (the Archive's freeform topic lookup) to a fixed result. */
256async function mockLookup(
257 page: Page,
258 lookup = {
259 topic: "Gunpowder",
260 summary: "An explosive mix of saltpeter, charcoal, and sulfur.",
261 requires: ["saltpeter", "charcoal", "sulfur"],
262 knownSince: "China, ~9th century",
263 },
264) {
265 await page.route("**/api/lookup", async (route) => {
266 await route.fulfill({
267 status: 200,
268 contentType: "application/json",
269 body: JSON.stringify({ success: true, lookup }),
270 });
271 });
273 
274/** From the mission briefing, choose the first curated objective and begin the run. */
275export async function startCuratedRun(page: Page) {
276 // FigurePicker grounds whoever you select and loads suggestions on mount; default
277 // both to deterministic, offline fixtures. (A later mockFigure call wins, since
278 // route handlers run last-registered-first; suggestions load on mount, so they're
279 // mocked here, before the board appears.) The chronicle refresh fires after the
280 // first turn resolves, and the Archive study fires on demand — mock both here too.
281 await mockFigure(page);
282 await mockFigureSearch(page);
283 await mockSuggestions(page);
284 await mockChronicle(page);
285 await mockResearch(page);
286 await mockLookup(page);
287 await expect(page.locator('[data-testid="mission-select"]')).toBeVisible();
288 await page.locator('[data-testid="objective-option"]').first().click();
289 await page.locator('[data-testid="begin-button"]').click();
290 // The board is now live.
291 await expect(page.locator('[data-testid="message-input"]')).toBeVisible();
293 
294/** Picks a figure by typing into the contact input (decoupled from the now
295 * objective-relevant suggestion chips). */
296export async function selectFigure(page: Page, name: string) {
297 // Grounding is selection-driven (#197): typing only builds the autocomplete list;
298 // the figure is grounded when you PICK from it. So stub the list to offer this name,
299 // type it, then click the option — the way a player actually addresses a contact.
300 // (Clear first so re-addressing the SAME figure across turns still re-fires.)
301 await mockFigureSearch(page, [{ name }]);
302 // A prior pick auto-collapses the fold (the figure is addressed); reopen it — and let
303 // any pending collapse settle — so the combobox is visible before we address again.
304 await ensureAddressOpen(page);
305 const combo = page
306 .locator('[data-testid="figure-picker"]')
307 .getByRole("combobox");
308 await combo.click();
309 await combo.fill(""); // clear any prior name (re-addressing across turns)
310 await combo.pressSequentially(name, { delay: 20 }); // real typing drives the autocomplete
311 await page
312 .locator('[data-testid="figure-search-option"]')
313 .filter({ hasText: name })
314 .first()
315 .click();
317 
318/**
319 * The send button (the wax seal). Located by its ARIA name rather than data-testid:
320 * the testid is a fallthrough attr on the send-control wrapper and doesn't reach the
321 * rendered <button>, but the static aria-label does (and survives the seal's changing
322 * state word, since aria-label fixes the accessible name to 'Send message').
323 */
324export function sendButton(page: Page) {
325 return page.getByRole("button", { name: "Send message" });
327 
328/** Fills the composer and sends, asserting the button is ready first. */
329export async function fillAndSend(page: Page, text: string) {
330 await page.locator('[data-testid="message-input"] textarea').fill(text);
331 const send = sendButton(page);
332 await expect(send).toBeEnabled();
333 await send.click();
335 
336// ── Reveal-flow helpers ──────────────────────────────────────────────
337// The game now lives on the page-by-page leaf flow at /play: a turn resolves onto the
338// Judgment page, the player turns each reveal page (explicit Continue), the change
339// commits at the Ripple page, and the full Spine + Chronicle + Thread are read from
340// the summonable ❖ Record overlay.
341 
342/** Turn the page — the explicit Continue control (auto-waits for the reveal page). */
343export async function turnPage(page: Page) {
344 await page.locator('[data-testid="codex-continue"]').click();
346 
347/** Open the ❖ Record overlay (the live Spine + Chronicle + conversation Thread).
348 * The Thread fold (the reused MessageHistory) is collapsed by default — open it so
349 * its reply / action / dice / craft / equation reads are present. */
350export async function openRecord(page: Page) {
351 await page.locator('[data-testid="codex-record-toggle"]').click();
352 const record = page.locator('[data-testid="codex-record"]');
353 await expect(record).toBeVisible();
354 // The Thread fold (the reused MessageHistory) is collapsed by default — open it
355 // so its reply / action / dice / craft / equation reads are present.
356 const thread = page.locator('[data-testid="record-thread-toggle"]');
357 await thread.click();
358 return record;
360 
361/** Dismiss the Record overlay (Escape). */
362export async function closeRecord(page: Page) {
363 await page.keyboard.press("Escape");
365 
366/**
367 * Walk the explicit-Continue reveal from the post-send Judgment page to the END of
368 * the turn — the next Dispatch (non-terminal) or the End screen (terminal). Returns
369 * which it landed on. Clicks Continue until one appears (no auto-advance now).
370 */
371export async function revealWalk(page: Page): Promise<"dispatch" | "end"> {
372 // 18 iterations: five Continue clicks plus the transition windows the
373 // settled-composer guard below deliberately waits out (520-560ms each).
374 for (let i = 0; i < 18; i++) {
375 if (
376 await page
377 .locator('[data-testid="end-game-screen"]')
378 .isVisible()
379 .catch(() => false)
380 )
381 return "end";
382 if (
383 (await page
384 .locator('[data-testid="message-input"]')
385 .isVisible()
386 .catch(() => false)) &&
387 // Mid page-turn the leaving (KeepAlive-cached) dispatch is still visibly
388 // crossfading under the incoming leaf — only a SETTLED composer means the
389 // turn is over (the leave classes exist for exactly the transition window).
390 (await page
391 .locator(".codex-leaf-leave-active, .codex-turn-leave-active")
392 .count()) === 0
393 ) {
394 // The explicit walk clicks through faster than the 1s send window, so a
395 // following send would be rate-limited — clear the window before returning.
396 await page.waitForTimeout(1100);
397 return "dispatch";
398 }
399 const cont = page.locator('[data-testid="codex-continue"]');
400 if (await cont.isVisible().catch(() => false))
401 await cont.click().catch(() => {});
402 else await page.waitForTimeout(250);
403 }
404 throw new Error(
405 "revealWalk: neither the next Dispatch nor the End screen appeared",
⋯ 46 lines hidden (lines 406–451)
406 );
408 
409/**
410 * The address fold (the letterhead) auto-collapses once a contactable figure is
411 * grounded, hiding the FigurePicker's dossier / when-slider / pin-moment. Reopen it
412 * so those are assertable again. (No-op if already open.)
413 */
414export async function ensureAddressOpen(page: Page) {
415 await page.waitForTimeout(700); // let grounding + the auto-collapse settle
416 const details = page.locator("details.address");
417 const isOpen = await details
418 .evaluate((d) => (d as HTMLDetailsElement).open)
419 .catch(() => false);
420 if (!isOpen) await page.locator('[data-testid="address-toggle"]').click();
421 await expect(details).toHaveJSProperty("open", true);
423 
424/** Count of spent message-pips in the masthead counter (it shows pips, not
425 * "N/5" text): spent = TOTAL − remaining. */
426export function spentPips(page: Page) {
427 return page.locator('[data-testid="messages-counter"] .pip.spent');
429 
430/** Suppress the one-time fiction notice (#330) — the disclosure dialog a fresh
431 * device sees on /play, which would sit over whatever a spec drives. MUST be
432 * called before goto. The notice's own coverage seeds nothing. */
433export async function suppressFictionNotice(page: Page) {
434 await page.addInitScript((k) => {
435 localStorage.setItem(
436 k,
437 JSON.stringify({ noticeAckAt: "2026-01-01T00:00:00.000Z" }),
438 );
439 }, "everwhen:legal:v1");
441 
442/** Suppress the guided first-run tour (its coach-mark can overlap the Continue
443 * control mid-reveal) AND the fiction notice that would otherwise cover the
444 * board first. MUST be called before goto. coaching.spec drives the tour
445 * itself, so it seeds only suppressFictionNotice. */
446export async function suppressTour(page: Page) {
447 await page.addInitScript((k) => {
448 localStorage.setItem(k, JSON.stringify({ status: "completed" }));
449 }, "everwhen:coaching:v1");
450 await suppressFictionNotice(page);

Verification and artifacts

Full battery on the production build: lint 0 errors, vitest 1430/1430 (specs updated faithfully where presentation moved — each noted in agent reports), typecheck clean, e2e 38/38 with all routes mocked. Eighty-eight screenshots per round across five viewport/theme configs drove three critique rounds; the before/after comparison page linked from the PR pairs the original build against the shipped codex. The process artifacts — problem book, design language, atelier mockups — are committed under docs/design/ so future surfaces can be built to the same hand.