What changed, and why

Each resolved turn is tagged with terse, evocative symbols: ✒ Reckless, ⚡ Ahead of its time, ⏳ Reaching, ⚑ staked. They are not flavor — each one moves real numbers. Craft tilts the dice roll, anachronism widens the swing, the causal chain decays it, the stake doubles it. Until now a player had no way to learn any of that: no tooltip, no title, no glossary anywhere in the app.

This PR gives every in-run symbol a hover/focus tooltip that says what it means and how it bends the swing. The work splits cleanly into three moves, each below: the hint copy gets a home next to the mechanic it describes; a small component turns any inline symbol into an accessible hover trigger; and the two surfaces that render these symbols — the ledger and the thread — get wired to use it.

Hint copy lives beside the mechanic

The explanation for each symbol is data, not template strings. It lands in the same module as the number it describes, keyed by the same level type, so the two can't drift apart. Three new constants mirror the existing CHAIN_HINT (which the causal-chain module already shipped, and which this PR reuses unchanged).

CRAFT_HINT sits right under CRAFT_MODIFIER / CRAFT_LABEL — meaning plus the exact roll tilt.

utils/craft.ts · 48 lines
utils/craft.ts48 lines · TypeScript
⋯ 33 lines hidden (lines 1–33)
1/**
2 * Message craft — the Judge's grade of the player's words (roadmap slice 5).
3 *
4 * The Judge reads the dispatch BEFORE the dice are thrown and converts its craft
5 * into a small roll modifier: writing a sharper message visibly tilts fate. The
6 * dice still dominate any single turn (±2 at most); craft decides the campaign.
7 * Lives in utils/ because both sides read it: the server maps grade → modifier,
8 * the UI labels the grade in the turn reveal.
9 */
10export type Craft = 'masterful' | 'sharp' | 'sound' | 'vague' | 'reckless'
11 
12export const CRAFT_LEVELS: Craft[] = ['masterful', 'sharp', 'sound', 'vague', 'reckless']
13 
14/** Roll modifier per grade — combined with the 1–20 clamp in `applyCraftModifier`,
15 * a masterful dispatch cannot critically backfire, a reckless one cannot
16 * critically cascade. Both edges are deliberate, legible rewards. */
17export const CRAFT_MODIFIER: Record<Craft, number> = {
18 masterful: 2,
19 sharp: 1,
20 sound: 0,
21 vague: -1,
22 reckless: -2
24 
25/** Short player-facing label per grade (for the turn reveal / ledger). */
26export const CRAFT_LABEL: Record<Craft, string> = {
27 masterful: 'Masterful',
28 sharp: 'Sharp',
29 sound: 'Sound',
30 vague: 'Vague',
31 reckless: 'Reckless'
33 
34/** One-line hover hint per grade — what it means and how it tilts the roll.
35 * Lives beside the modifier so the copy and the number can never drift apart. */
36export const CRAFT_HINT: Record<Craft, string> = {
37 masterful: 'precise and period-fluent — adds 2 to the roll; cannot critically backfire',
38 sharp: 'a cut above competent — adds 1 to the roll',
39 sound: 'a clear, honest ask — leaves the roll unchanged',
40 vague: 'wishing more than working — subtracts 1 from the roll',
41 reckless: 'incoherent for the era — subtracts 2 from the roll; cannot critically land'
⋯ 6 lines hidden (lines 43–48)
43 
44/** Coerce an untrusted value to a known grade. Defaults to the neutral 'sound':
45 * a Judge outage must never tilt the roll either way. */
46export function toCraft(value: unknown): Craft {
47 return CRAFT_LEVELS.includes(value as Craft) ? (value as Craft) : 'sound'

ANACHRONISM_HINT, deliberately qualitative: the precise factors live just above it.

server/utils/anachronism.ts · 91 lines
server/utils/anachronism.ts91 lines · TypeScript
⋯ 56 lines hidden (lines 1–56)
1/**
2 * Anachronism risk-coupling (prototype).
3 *
4 * The wager at the heart of wielding future knowledge: the further beyond a figure's
5 * own era your message reaches, the WIDER the swing — bigger triumphs and bigger
6 * backfires. The Timeline Engine rates how anachronistic the player's nudge is for
7 * this figure + year; this pure factor then amplifies the magnitude of the resulting
8 * progress swing (in both directions). So research stops being a cheat code and
9 * becomes a calibration skill: how much truth can the past actually absorb?
10 *
11 * Deliberately a deterministic floor on top of the model's own judgment, in keeping
12 * with the project's "model proposes, a pure function tempers" pattern. The widening
13 * lives HERE only: the Timeline prompt rates the level but is told not to inflate its
14 * own swing for it, so the amplification is applied exactly once.
15 */
16import { MAX_PROGRESS_SWING } from '~/utils/game-config'
17 
18export type Anachronism = 'in-period' | 'ahead' | 'far-ahead' | 'impossible'
19 
20export const ANACHRONISM_LEVELS: Anachronism[] = ['in-period', 'ahead', 'far-ahead', 'impossible']
21 
22/**
23 * How much each level multiplies the swing — asymmetric by design. Gains amplify
24 * more gently than losses, so reaching further into the future is a real wager
25 * (priced in dread) rather than a strictly-dominant free lever: with a positive
26 * expected swing, a symmetric multiplier only ever helped. Research (the Archive)
27 * is how a player earns the right to take the bold tiers anyway.
28 */
29const GAIN_FACTOR: Record<Anachronism, number> = {
30 'in-period': 1,
31 'ahead': 1.15,
32 'far-ahead': 1.35,
33 'impossible': 1.6
35const LOSS_FACTOR: Record<Anachronism, number> = {
36 'in-period': 1,
37 'ahead': 1.25,
38 'far-ahead': 1.6,
39 'impossible': 2
41 
42/**
43 * Soft knee: beyond ±KNEE the excess is halved before the hard clamp, so big
44 * amplified turns land spread through the 40s with their differences intact
45 * instead of all pinning at exactly ±MAX_PROGRESS_SWING (which read as fake).
46 */
47const KNEE = 40
48 
49/** Short player-facing label per level (for the ledger badge / hints). */
50export const ANACHRONISM_LABEL: Record<Anachronism, string> = {
51 'in-period': 'In period',
52 'ahead': 'Ahead of its time',
53 'far-ahead': 'Far ahead of its time',
54 'impossible': 'Impossibly advanced'
56 
57/** One-line hover hint per level — what the wager is and which way it bends the
58 * swing. Qualitative on purpose (the exact factors live above): losses always
59 * cut deeper than gains lift, and the gap widens the further you reach. */
60export const ANACHRONISM_HINT: Record<Anachronism, string> = {
61 'in-period': 'native to the era — no added swing',
62 'ahead': 'ahead of the era but reachable — widens the swing both ways, losses a touch harder',
63 'far-ahead': 'generations early — widens the swing hard, losses harder than gains',
64 'impossible': 'barely receivable — the widest swing of all, losses doubled'
⋯ 26 lines hidden (lines 66–91)
66 
67/** Coerce an untrusted value to a known level; defaults to the safe 'in-period'. */
68export function toAnachronism(value: unknown): Anachronism {
69 return ANACHRONISM_LEVELS.includes(value as Anachronism) ? (value as Anachronism) : 'in-period'
71 
72/**
73 * Amplifies a base progress swing by the anachronism factor — gains gently,
74 * losses hard — soft-kneed above ±40 and re-clamped to ±MAX_PROGRESS_SWING.
75 * `in-period` is a no-op; bolder asks still widen the swing both ways, they
76 * just cut deeper than they lift.
77 */
78export function amplifyForAnachronism(progressChange: number, anachronism: Anachronism): number {
79 const plain = Math.max(-MAX_PROGRESS_SWING, Math.min(MAX_PROGRESS_SWING, Math.round(progressChange)))
80 // A true no-op: the knee shapes only genuinely AMPLIFIED swings.
81 if (anachronism === 'in-period' || !(anachronism in GAIN_FACTOR)) return plain
82 const table = progressChange >= 0 ? GAIN_FACTOR : LOSS_FACTOR
83 const amplified = progressChange * table[anachronism]
84 const magnitude = Math.abs(amplified)
85 const kneed = magnitude > KNEE ? KNEE + (magnitude - KNEE) / 2 : magnitude
86 const signed = amplified < 0 ? -kneed : kneed
87 const result = Math.max(-MAX_PROGRESS_SWING, Math.min(MAX_PROGRESS_SWING, Math.round(signed)))
88 // Monotonicity floor: amplification never pays less than no amplification
89 // (near the cap the knee could otherwise dip a bold gain under its plain value).
90 return progressChange >= 0 ? Math.max(result, plain) : Math.min(result, plain)

STAKE_HINT, next to STAKE_MULTIPLIER and applyStake.

utils/swing-bands.ts · 61 lines
utils/swing-bands.ts61 lines · TypeScript
⋯ 53 lines hidden (lines 1–53)
1import { DiceOutcome } from '~/utils/dice'
2 
3/**
4 * The D20 band → progress-swing table, named once. The Timeline Engine's prompt
5 * renders its calibration lines FROM this table, the response schema states the
6 * same ceiling, and the UI's roll legend reads it too — so the numbers the model
7 * is told, the numbers the code enforces, and the numbers the player is shown
8 * can't silently drift apart. A rebalance is one edit, here.
9 *
10 * Tuning intent (the run economy): five messages must reach 100. With sound play
11 * the expected swing is roughly +6/turn — losing, but honorably; craft (the
12 * Message Judge's roll modifier) and a well-calibrated anachronism wager push a
13 * thoughtful run to roughly +12..16/turn, where victory is earned rather than
14 * drawn from a lottery. Dice still dominate any single turn; judgment decides
15 * the campaign.
16 */
17export interface SwingBand {
18 /** Most negative end of the band's instructed range, in progress points. */
19 min: number
20 /** Most positive end of the band's instructed range, in progress points. */
21 max: number
23 
24export const SWING_BANDS: Record<DiceOutcome, SwingBand> = {
25 [DiceOutcome.CRITICAL_SUCCESS]: { min: 30, max: 45 },
26 [DiceOutcome.SUCCESS]: { min: 15, max: 30 },
27 [DiceOutcome.NEUTRAL]: { min: 0, max: 12 },
28 [DiceOutcome.FAILURE]: { min: -15, max: -5 },
29 [DiceOutcome.CRITICAL_FAILURE]: { min: -35, max: -15 }
31 
32/**
33 * The largest base magnitude any band allows — the integer range stated to the
34 * model (±45). Derived, not re-typed; the hard safety clamp in game-config
35 * (±MAX_PROGRESS_SWING) stays slightly wider so the anachronism amplifier has
36 * headroom above the bands.
37 */
38export const BAND_CEILING = Math.max(
39 ...Object.values(SWING_BANDS).flatMap(b => [Math.abs(b.min), Math.abs(b.max)])
41 
42/**
43 * The last stand: when the FINAL dispatch of a run can no longer win inside the
44 * normal fuse (the gap exceeds MAX_PROGRESS_SWING — the store's `canStake`),
45 * the player may STAKE THE TIMELINE — the resolved swing doubles, both ways,
46 * and escapes the per-turn cap up to the full meter. Structurally this means no
47 * run is ever mathematically dead while a message remains. Deliberately NOT
48 * offered from winnable positions: doubling never lowers win odds on a final
49 * throw, so an unrestricted stake would be a dominance trap, not a decision.
50 * The doubling itself is applied server-side after amplification.
51 */
52export const STAKE_MULTIPLIER = 2
53export const STAKED_SWING_CAP = 100
54 
55/** One-line hover hint for the ⚑ stake badge — the last-stand wager. */
56export const STAKE_HINT = 'the last stand, only on your final dispatch — doubles the swing, both ways'
⋯ 5 lines hidden (lines 57–61)
57 
58export function applyStake(progressChange: number): number {
59 const staked = Math.round(progressChange * STAKE_MULTIPLIER)
60 return Math.max(-STAKED_SWING_CAP, Math.min(STAKED_SWING_CAP, staked))

SymbolHint: one focusable trigger

One small component carries the interaction so the call sites stay quiet. It wraps an inline symbol in a Nuxt UI UTooltip (the UApp root already provides the reka-ui tooltip context) and renders a single focusable span as the trigger.

The whole component: a UTooltip around a tabindex=0 span; the slotted badge keeps its own color and testid.

components/SymbolHint.vue · 34 lines
components/SymbolHint.vue34 lines · Vue
1<template>
2 <UTooltip :text="text" :delay-duration="200" :arrow="true">
3 <span class="symbol-hint" tabindex="0">
4 <slot />
5 </span>
6 </UTooltip>
7</template>
8 
9<script setup lang="ts">
10/**
11 * SymbolHint — wraps an inline turn-reveal symbol (✒ craft · ⚡ anachronism ·
12 * ⏳ chain · ⚑ stake) in a hover/focus tooltip that says what it means and how
13 * it bends the swing. The trigger is a single focusable span so the hint is
14 * reachable by keyboard and announced by screen readers (reka-ui wires it as
15 * the trigger's description), not hover alone; a faint dotted underline marks
16 * it as worth a look. The badge's own color/testid stay on the slotted child.
17 */
18defineProps<{ text: string }>()
19</script>
⋯ 15 lines hidden (lines 20–34)
20 
21<style scoped>
22.symbol-hint {
23 cursor: help;
24 text-decoration: underline dotted;
25 /* Quiet, ink-toned dotted rule — present enough to invite a hover, not loud. */
26 text-decoration-color: color-mix(in srgb, currentColor 35%, transparent);
27 text-underline-offset: 3px;
28 border-radius: 3px;
30.symbol-hint:focus-visible {
31 outline: 2px solid var(--rv-accent);
32 outline-offset: 2px;
34</style>

Wiring the ledger: spine and node-detail

The timeline ledger shows symbols in two places, and they need different treatment. The node-detail strip's badges become SymbolHints. The spine nodes can't: each node is already a role="button", and a focusable tooltip trigger nested inside it would be a focusable-in-button trap. So the spine keeps its aria-label and gains a native title instead — a hover hint with no nesting.

Spine glyphs get :title (44-46); node-detail craft/anachronism/stake badges get wrapped (71-86); anachronismHint helper (126).

components/TimelineLedger.vue · 195 lines
components/TimelineLedger.vue195 lines · Vue
⋯ 43 lines hidden (lines 1–43)
1<template>
2 <!-- THE SPINE: history as a horizontal era-axis you travel left→right. -->
3 <div data-testid="timeline-ledger">
4 <div class="border-b rv-line pb-1.5 mb-3">
5 <div class="flex items-center justify-between gap-3">
6 <span class="rv-label">The Spine</span>
7 <span class="rv-mono rv-faint text-[11px] normal-case tracking-normal">
8 {{ events.length }} {{ events.length === 1 ? 'change' : 'changes' }}<template v-if="anachCount"> · ⚡{{ anachCount }}</template><span v-if="events.length" class="ml-2">scroll →</span>
9 </span>
10 </div>
11 <p class="rv-faint text-[11px] mt-0.5">the timeline you're rewriting — every change, in the order you made it<template v-if="events.length"> · <span class="rv-accent">tap any node to inspect</span></template></p>
12 </div>
13 
14 <!-- Empty state — but the instant a first move is resolving, it leans in rather
15 than still reading "unwritten" (the board never contradicts the act). -->
16 <div v-if="events.length === 0" data-testid="timeline-empty" class="text-center py-12">
17 <template v-if="gameStore.isLoading">
18 <div class="text-4xl mb-3 opacity-50 rv-accent" aria-hidden="true"></div>
19 <p class="rv-serif rv-fg text-lg">The timeline trembles…</p>
20 <p class="rv-faint text-xs mt-1">Your message ripples into the past.</p>
21 </template>
22 <template v-else>
23 <div class="text-4xl mb-3 opacity-40" aria-hidden="true">📜</div>
24 <p class="rv-serif rv-fg text-lg">History is still unwritten.</p>
25 <p class="rv-faint text-xs mt-1">Send your first message to bend the timeline.</p>
26 </template>
27 </div>
28 
29 <!-- The axis -->
30 <div v-else class="spine-scroll overflow-x-auto px-1 pb-2">
31 <TransitionGroup name="timeline" tag="ol" class="flex items-start min-w-full" aria-label="Timeline of changes">
32 <li v-for="event in events" :key="event.id" data-testid="timeline-event"
33 class="spine-node rv-press" :class="{ 'is-selected': event.id === selectedId }"
34 role="button" tabindex="0" @click="select(event.id)" @keydown.enter="select(event.id)" @keydown.space.prevent="select(event.id)">
35 <span class="sr-only">{{ event.headline }}{{ event.detail }} ({{ signed(event.progressChange) }})</span>
36 <span class="block text-center rv-mono text-[11px] rv-faint">{{ event.era || '—' }}</span>
37 <span class="dot-wrap" aria-hidden="true">
38 <span class="connector" />
39 <span class="rv-dot" :class="event.id === selectedId ? 'rv-dot--accent' : dotClass(event.valence)" />
40 </span>
41 <span class="block text-center rv-mono text-xs rv-fg truncate px-1">{{ event.figureName }}</span>
42 <span class="block text-center rv-mono text-[11px] mt-0.5 tabular-nums">
43 <span class="rv-faint">🎲</span>{{ event.diceRoll }}
44 <span class="delta-cell" :class="deltaClass(event.progressChange)">{{ signed(event.progressChange) }}</span><span
45 v-if="isAnachronistic(event.anachronism)" class="rv-warn ml-0.5" :aria-label="anachronismLabel(event.anachronism)" :title="anachronismHint(event.anachronism)">{{ anachPips(event.anachronism) }}</span><span
46 v-if="event.staked" class="rv-warn ml-0.5" aria-label="Staked — the last stand" :title="STAKE_HINT"></span>
⋯ 24 lines hidden (lines 47–70)
47 </span>
48 </li>
49 
50 <!-- the live present -->
51 <li key="now" class="spine-now" aria-hidden="true">
52 <span class="block text-center rv-mono text-[11px] rv-accent font-semibold">▷ now</span>
53 <span class="dot-wrap"><span class="connector connector--half" /></span>
54 <span class="block text-center rv-faint text-[11px]">your move</span>
55 </li>
56 </TransitionGroup>
57 </div>
58 
59 <!-- Node-detail strip: the selected change in full (defaults to the newest).
60 The consequence headline leads — the second of the board's two focal peaks. -->
61 <div v-if="selected" data-testid="node-detail" class="border-t rv-line pt-2.5 mt-2">
62 <p class="rv-serif rv-fg text-lg lg:text-xl font-semibold leading-snug">{{ selected.headline }}</p>
63 <p class="rv-muted text-xs lg:text-sm mt-1 leading-relaxed max-w-2xl">{{ selected.detail }}</p>
64 <div class="flex items-center gap-x-2 gap-y-1 flex-wrap text-[11px] rv-mono mt-1.5 tabular-nums">
65 <span class="inline-flex items-center gap-1.5">
66 <span class="rv-dot" :class="dotClass(selected.valence)" aria-hidden="true" />
67 <span class="rv-faint">{{ selected.era || '—' }} · {{ selected.figureName }}</span>
68 </span>
69 <span class="rv-hair-c">·</span>
70 <span class="rv-muted">🎲 {{ selected.diceRoll }} · {{ selected.diceOutcome }}</span>
71 <span v-if="selected.craft && selected.craft !== 'sound'" class="rv-hair-c">·</span>
72 <SymbolHint v-if="selected.craft && selected.craft !== 'sound'" :text="CRAFT_HINT[selected.craft]">
73 <span data-testid="craft-badge"
74 class="font-semibold" :class="selected.craft === 'masterful' || selected.craft === 'sharp' ? 'rv-ok' : 'rv-warn'">{{ CRAFT_LABEL[selected.craft] }}</span>
75 </SymbolHint>
76 <span class="rv-hair-c">·</span>
77 <span class="font-bold" :class="deltaClass(selected.progressChange)">{{ signed(selected.progressChange) }}%</span>
78 <!-- The wager's arithmetic, in the open: base → amplified/staked final. -->
79 <span v-if="selected.baseProgressChange !== undefined && selected.baseProgressChange !== selected.progressChange"
80 data-testid="swing-equation" class="rv-faint">({{ signed(selected.baseProgressChange) }}{{ signed(selected.progressChange) }})</span>
81 <SymbolHint v-if="isAnachronistic(selected.anachronism)" :text="anachronismHint(selected.anachronism)">
82 <span data-testid="anachronism-badge" class="rv-warn font-semibold ml-1">{{ anachronismLabel(selected.anachronism) }}</span>
83 </SymbolHint>
84 <SymbolHint v-if="selected.staked" :text="STAKE_HINT">
85 <span data-testid="staked-badge" class="rv-warn font-semibold ml-1">⚑ staked</span>
86 </SymbolHint>
⋯ 39 lines hidden (lines 87–125)
87 </div>
88 </div>
89 </div>
90</template>
91 
92<script setup lang="ts">
93/**
94 * TimelineLedger — the Spine. Each resolved turn is a node on a horizontal era-axis
95 * (valence dot + mono year/figure/roll/Δ + ⚡), newest on the right with a ▷now marker;
96 * clicking a node snaps its full payload into the node-detail strip, which defaults to
97 * the newest change so the latest turn is always visible. Pure glyphs on the axis; the
98 * only prose lives in the strip. (Re-skin only — the store data is unchanged.)
99 */
100import { computed, ref, watch } from 'vue'
101import { useGameStore } from '~/stores/game'
102import type { Valence } from '~/stores/game'
103import { ANACHRONISM_HINT, ANACHRONISM_LABEL, type Anachronism } from '~/server/utils/anachronism'
104import { CRAFT_HINT, CRAFT_LABEL } from '~/utils/craft'
105import { STAKE_HINT } from '~/utils/swing-bands'
106 
107const gameStore = useGameStore()
108const events = computed(() => gameStore.timelineEvents)
109 
110const selectedId = ref<string | null>(null)
111// Default the detail strip to the newest change, and snap to it whenever one lands.
112watch(() => events.value.length, () => {
113 const last = events.value[events.value.length - 1]
114 if (last) selectedId.value = last.id
115}, { immediate: true })
116 
117const selected = computed(() =>
118 events.value.find(e => e.id === selectedId.value) ?? events.value[events.value.length - 1] ?? null
120const anachCount = computed(() => events.value.filter(e => isAnachronistic(e.anachronism)).length)
121 
122function select(id: string) { selectedId.value = id }
123function signed(n: number) { return `${n > 0 ? '+' : ''}${n}` }
124function isAnachronistic(a?: Anachronism) { return !!a && a !== 'in-period' }
125function anachronismLabel(a?: Anachronism) { return a ? ANACHRONISM_LABEL[a] : '' }
126function anachronismHint(a?: Anachronism) { return a ? ANACHRONISM_HINT[a] : '' }
⋯ 69 lines hidden (lines 127–195)
127/** A quantified, glanceable anachronism tier on the spine node: 1–3 amber pips. */
128function anachPips(a?: Anachronism) {
129 return a === 'impossible' ? '⚡⚡⚡' : a === 'far-ahead' ? '⚡⚡' : a === 'ahead' ? '⚡' : ''
131 
132function dotClass(v: Valence) {
133 return v === 'positive' ? 'rv-dot--ok' : v === 'negative' ? 'rv-dot--bad' : 'rv-dot--mut'
135function deltaClass(change: number) {
136 if (change > 0) return 'rv-ok'
137 if (change < 0) return 'rv-bad'
138 return 'rv-muted'
140</script>
141 
142<style scoped>
143.spine-node,
144.spine-now {
145 flex: 0 0 auto;
146 min-width: 92px;
147 padding: 2px 4px;
148 cursor: pointer;
149 border: none;
150 background: transparent;
151 scroll-snap-align: center;
153.spine-scroll { scroll-snap-type: x proximity; -webkit-overflow-scrolling: touch; }
154/* Reserve a stable sign slot so a column of +/− deltas stacks rule-straight. */
155.delta-cell { display: inline-block; min-width: 1.9em; text-align: left; }
156.spine-now { cursor: default; }
157.spine-node:focus-visible { outline: 2px solid var(--rv-accent); outline-offset: 2px; border-radius: 3px; }
158 
159/* The axis line is drawn by a full-width connector behind each node's dot. */
160.dot-wrap {
161 position: relative;
162 display: flex;
163 justify-content: center;
164 align-items: center;
165 height: 16px;
166 margin: 3px 0;
168.connector {
169 position: absolute;
170 top: 50%;
171 left: 0;
172 right: 0;
173 height: 1px;
174 background: var(--rv-line);
175 transform: translateY(-50%);
177.connector--half { right: 50%; }
178.dot-wrap > .rv-dot { position: relative; z-index: 1; }
179.spine-node.is-selected .dot-wrap > .rv-dot {
180 width: 12px;
181 height: 12px;
182 box-shadow: 0 0 0 3px var(--rv-bg), 0 0 0 4px var(--rv-accent);
184.spine-node:hover .dot-wrap > .rv-dot { transform: translateY(-1px); }
185 
186/* Preserved entry/move animation (now driving the right-edge node arrival). */
187.timeline-enter-active { transition: opacity var(--rv-dur-3) var(--rv-ease), transform var(--rv-dur-3) var(--rv-ease); }
188.timeline-enter-from { opacity: 0; transform: translateY(-10px); }
189.timeline-move { transition: transform var(--rv-dur-3) var(--rv-ease); }
190 
191@media (prefers-reduced-motion: reduce) {
192 .timeline-enter-active, .timeline-move { transition: none; }
193 .spine-node:hover .dot-wrap > .rv-dot { transform: none; }
195</style>

The thread: a flat equation becomes hinted tokens

The conversation thread shows the craft verdict and the swing equation. The verdict label is now wrapped (line 49). The equation was the one real refactor: it used to be a single joined string, which can't carry a per-symbol tooltip. It becomes a list of typed tokens, each rendered as its own hinted span.

Craft verdict wrapped (49); equation template renders base + hinted tokens + final (66); equationTokens builds the typed list (152-168).

components/MessageHistory.vue · 200 lines
components/MessageHistory.vue200 lines · Vue
⋯ 47 lines hidden (lines 1–47)
1<template>
2 <!-- Conversation with the currently-selected figure (the THREAD rail). -->
3 <div data-testid="message-history"
4 class="message-history-container overflow-y-auto rv-bg max-h-[340px] md:max-h-none md:overflow-visible"
5 aria-label="Conversation history" aria-live="polite" role="log">
6 <!-- Empty state -->
7 <div v-if="thread.length === 0 && !gameStore.isLoading" data-testid="empty-state"
8 class="flex flex-col items-center justify-center p-8 text-center">
9 <div class="text-3xl mb-2 opacity-50" aria-hidden="true">💬</div>
10 <p class="rv-muted text-sm">
11 {{ activeFigure ? `No words exchanged with ${activeFigure} yet` : 'No one contacted yet' }}
12 </p>
13 <p class="rv-faint text-xs mt-0.5">
14 {{ activeFigure ? 'Send a message to begin.' : 'Choose a figure and send your first message.' }}
15 </p>
16 </div>
17 
18 <!-- Conversation -->
19 <ol v-else>
20 <li v-for="(message, index) in thread" :key="index" data-testid="message-item" :data-sender="message.sender"
21 class="message-item py-3 border-b rv-line last:border-b-0">
22 <!-- Player message -->
23 <div v-if="message.sender === 'user'" data-testid="user-message">
24 <p class="text-[11px] rv-faint uppercase tracking-wide mb-0.5">You → {{ message.figureName || activeFigure }}</p>
25 <p class="rv-fg text-sm">{{ message.text }}</p>
26 <p class="text-[11px] rv-faint mt-1 rv-mono" data-testid="message-timestamp">{{ formatTimestamp(message.timestamp) }}</p>
27 </div>
28 
29 <!-- Figure reply -->
30 <div v-else-if="message.sender === 'ai'" data-testid="ai-message">
31 <div class="flex items-center justify-between gap-2 mb-1">
32 <p class="text-sm font-semibold rv-fg flex items-center gap-1.5 min-w-0">
33 <span class="rv-dot" :class="outcomeDot(message.diceOutcome)" aria-hidden="true" />
34 <span class="truncate">{{ message.figureName || activeFigure || 'The past' }}</span>
35 </p>
36 <div v-if="message.diceRoll !== undefined" class="flex items-center gap-1.5 rv-mono text-xs shrink-0">
37 <span data-testid="dice-icon" aria-hidden="true">🎲</span>
38 <!-- Craft tilts fate visibly: natural roll ✒mod = effective. -->
39 <span v-if="message.rollModifier" data-testid="craft-roll" class="rv-faint">
40 {{ message.naturalRoll }} <span :class="message.rollModifier > 0 ? 'rv-ok' : 'rv-bad'">{{ message.rollModifier > 0 ? '+' : '' }}{{ message.rollModifier }}</span> =
41 </span>
42 <span data-testid="dice-result" class="font-bold rv-fg">{{ message.diceRoll }}</span>
43 <span data-testid="dice-outcome" class="font-semibold" :class="outcomeText(message.diceOutcome)">{{ message.diceOutcome }}</span>
44 </div>
45 </div>
46 
47 <!-- The Judge's verdict on the dispatch that caused all this. -->
48 <div v-if="message.craft && (message.craft !== 'sound' || message.craftReason)" data-testid="craft-verdict" class="text-[11px] mb-1.5">
49 <span class="rv-faint">✒ your dispatch · </span><SymbolHint :text="CRAFT_HINT[message.craft]"><span class="font-semibold" :class="craftClass(message.craft)">{{ CRAFT_LABEL[message.craft] }}</span></SymbolHint><span v-if="message.craftReason" class="rv-faint">{{ message.craftReason }}</span>
⋯ 16 lines hidden (lines 50–65)
50 </div>
51 
52 <p data-testid="character-message" class="rv-fg text-sm mb-2">{{ message.text }}</p>
53 
54 <div v-if="message.characterAction" data-testid="character-action" class="text-xs rv-muted border-l rv-line pl-2 mb-2">
55 <span class="rv-faint">acts · </span><span class="italic">{{ message.characterAction }}</span>
56 </div>
57 
58 <div v-if="message.timelineImpact" data-testid="timeline-impact" class="text-xs rv-muted border-l rv-line pl-2 mb-1">
59 <span class="rv-faint">ripple · </span>{{ message.timelineImpact }}
60 <span v-if="message.progressChange !== undefined" data-testid="progress-change"
61 class="rv-mono font-bold ml-1" :class="deltaText(message.progressChange)">
62 {{ message.progressChange > 0 ? '+' : '' }}{{ message.progressChange }}%
63 </span>
64 <!-- The wager's work, shown as an equation: base ⚡tier (⚑ staked) → final.
65 Each amplifier token carries its own hover hint for what it did. -->
66 <span v-if="showEquation(message)" data-testid="swing-equation" class="rv-mono rv-faint ml-1 whitespace-nowrap">({{ signed(message.baseProgressChange!) }}<template v-for="tok in equationTokens(message)" :key="tok.kind">{{ ' ' }}<SymbolHint :text="tok.hint"><span :data-testid="`equation-${tok.kind}`">{{ tok.label }}</span></SymbolHint></template>{{ ' → ' + signed(message.progressChange!) + '%' }})</span>
⋯ 85 lines hidden (lines 67–151)
67 </div>
68 
69 <p class="text-[11px] rv-faint rv-mono" data-testid="message-timestamp">{{ formatTimestamp(message.timestamp) }}</p>
70 </div>
71 </li>
72 
73 <!-- Loading indicator -->
74 <li v-if="gameStore.isLoading" data-testid="loading-indicator" class="py-3 flex items-center gap-2 rv-muted text-sm">
75 <span class="rv-spinner" aria-hidden="true" />
76 {{ activeFigure || 'The past' }} is weighing your words…
77 </li>
78 </ol>
79 </div>
80</template>
81 
82<script setup lang="ts">
83/**
84 * MessageHistory — the conversation thread with the active figure, as hairline rows
85 * led by an outcome dot. Figure-aware labels; each figure keeps an independent thread.
86 */
87import { computed } from 'vue'
88import { useGameStore, type Message } from '~/stores/game'
89import { ANACHRONISM_HINT, ANACHRONISM_LABEL } from '~/server/utils/anachronism'
90import { CHAIN_HINT, CHAIN_LABEL } from '~/utils/causal-chain'
91import { CRAFT_HINT, CRAFT_LABEL, type Craft } from '~/utils/craft'
92import { STAKE_HINT } from '~/utils/swing-bands'
93 
94const gameStore = useGameStore()
95 
96const activeFigure = computed(() => gameStore.activeFigureName)
97const thread = computed(() => gameStore.conversationWith(gameStore.activeFigureName))
98 
99const formatTimestamp = (timestamp: Date): string => {
100 return new Intl.DateTimeFormat('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(timestamp)
102 
103function outcomeDot(outcome?: string): string {
104 switch (outcome) {
105 case 'Critical Success':
106 case 'Success': return 'rv-dot--ok'
107 case 'Failure':
108 case 'Critical Failure': return 'rv-dot--bad'
109 default: return 'rv-dot--mut'
110 }
112function outcomeText(outcome?: string): string {
113 switch (outcome) {
114 case 'Critical Success':
115 case 'Success': return 'rv-ok'
116 case 'Failure':
117 case 'Critical Failure': return 'rv-bad'
118 default: return 'rv-muted'
119 }
121function deltaText(change?: number): string {
122 if (change === undefined || change === 0) return 'rv-muted'
123 return change > 0 ? 'rv-ok' : 'rv-bad'
125 
126function craftClass(craft?: Craft): string {
127 switch (craft) {
128 case 'masterful':
129 case 'sharp': return 'rv-ok'
130 case 'vague':
131 case 'reckless': return 'rv-warn'
132 default: return 'rv-muted'
133 }
135 
136// The swing equation appears only when something actually transformed the base
137// swing (wager amplification and/or the stake) — an in-period unstaked turn
138// keeps its single clean number.
139function showEquation(m: Message): boolean {
140 return m.baseProgressChange !== undefined
141 && m.progressChange !== undefined
142 && m.baseProgressChange !== m.progressChange
144function signed(n: number): string {
145 return `${n > 0 ? '+' : ''}${n}`
147 
148// The amplifier tokens between the base and final swing — anachronism, causal
149// chain, the stake — each rendered as a labeled glyph carrying its own hover
150// hint (what the token is and how it bent the swing). Mirrors the order the
151// server applies them; absent tokens drop out, matching the old flat string.
152interface EquationToken {
153 kind: 'anachronism' | 'chain' | 'staked'
154 label: string
155 hint: string
157function equationTokens(m: Message): EquationToken[] {
158 const tokens: EquationToken[] = []
159 if (m.anachronism && m.anachronism !== 'in-period') {
160 const pips = m.anachronism === 'impossible' ? '⚡⚡⚡' : m.anachronism === 'far-ahead' ? '⚡⚡' : '⚡'
161 tokens.push({ kind: 'anachronism', label: `${pips} ${ANACHRONISM_LABEL[m.anachronism].toLowerCase()}`, hint: ANACHRONISM_HINT[m.anachronism] })
162 }
163 if (m.causalChain && m.causalChain.tier !== 'at-hinge') {
164 tokens.push({ kind: 'chain', label: `${CHAIN_LABEL[m.causalChain.tier].toLowerCase()}`, hint: CHAIN_HINT[m.causalChain.tier] })
165 }
166 if (m.staked) tokens.push({ kind: 'staked', label: '⚑ staked', hint: STAKE_HINT })
167 return tokens
⋯ 32 lines hidden (lines 169–200)
169</script>
170 
171<style scoped>
172/* Phone keeps a fixed scroll box (the Story tab is one panel); the 340px cap and
173 md+ release live as utilities on the element (a scoped rule would out-specify the
174 Tailwind md: override). From md up the thread grows into the panel's own scroll. */
175.message-history-container {
176 min-height: 120px;
178 
179/* Staged reveal: a resolved turn writes itself in — the figure speaks, then acts,
180 then the ripple through history lands. Elements stay in the DOM (opacity only). */
181[data-testid="character-message"],
182[data-testid="character-action"],
183[data-testid="timeline-impact"] {
184 animation: reveal-up 0.45s var(--rv-ease) both;
186[data-testid="character-message"] { animation-delay: 0.08s; }
187[data-testid="character-action"] { animation-delay: 0.30s; }
188[data-testid="timeline-impact"] { animation-delay: 0.55s; }
189 
190@keyframes reveal-up {
191 from { opacity: 0; transform: translateY(8px); }
192 to { opacity: 1; transform: none; }
194 
195@media (prefers-reduced-motion: reduce) {
196 [data-testid="character-message"],
197 [data-testid="character-action"],
198 [data-testid="timeline-impact"] { animation: none; }
200</style>

Tests

The new layer is the hint constants, so the new tests are the discriminating ones: every craft and anachronism level, and the stake, must carry a non-empty hint. They mirror the existing "label for every grade" checks one block up.

One example; anachronism.spec.ts and swing-bands.spec.ts gain the parallel assertions.

tests/unit/utils/craft.spec.ts · 33 lines
tests/unit/utils/craft.spec.ts33 lines · TypeScript
⋯ 29 lines hidden (lines 1–29)
1import { describe, it, expect } from 'vitest'
2import { CRAFT_LEVELS, CRAFT_MODIFIER, CRAFT_LABEL, CRAFT_HINT, toCraft } from '../../../utils/craft'
3 
4describe('message craft (the Judge’s grade)', () => {
5 it('maps grades to the tuned roll modifiers', () => {
6 expect(CRAFT_MODIFIER).toEqual({
7 masterful: 2,
8 sharp: 1,
9 sound: 0,
10 vague: -1,
11 reckless: -2
12 })
13 })
14 
15 it('toCraft passes through every known grade', () => {
16 for (const level of CRAFT_LEVELS) expect(toCraft(level)).toBe(level)
17 })
18 
19 it('toCraft defaults garbage to the neutral sound — a judge outage never tilts the die', () => {
20 expect(toCraft('epic')).toBe('sound')
21 expect(toCraft(undefined)).toBe('sound')
22 expect(toCraft(3)).toBe('sound')
23 expect(CRAFT_MODIFIER[toCraft(null)]).toBe(0)
24 })
25 
26 it('has a player-facing label for every grade', () => {
27 for (const level of CRAFT_LEVELS) expect(CRAFT_LABEL[level]).toBeTruthy()
28 })
29 
30 it('has a hover hint for every grade', () => {
31 for (const level of CRAFT_LEVELS) expect(CRAFT_HINT[level]).toBeTruthy()
32 })
⋯ 1 line hidden (lines 33–33)
33})

Alongside these: nuxt typecheck is clean (it type-checks the templates against generated types, so the new SymbolHint usage and equationTokens typing are verified), and the Playwright gameplay suite stays green. Manual check confirmed the tooltip opens on both hover and keyboard focus.