What changed, and why

The game resolves each turn on a d20, then tilts that roll by a small craft modifier (−2..+2) the Message Judge awards. The on-screen die — the hero DiceRoller on /play — had two problems. It was drawn as a rounded square, so its shape said nothing about the 1–20 range it stands for. And its face showed the craft-tilted (effective) roll while also printing the modifier just below it — counting the tilt twice. A natural 20 dragged down to a Success by a −2 showed "18", quietly hiding the 20 you actually rolled.

This change does two things, both confined to one component (plus its test):

1. Shape — the square box becomes an inline SVG d20 silhouette. 2. Face = the natural roll — the die shows the value it physically landed on, and the craft modifier rides alongside as the ✒±n complement. The outcome colour, the crit flourish, and the die's size still follow the effective band, so only the printed number moved.

Blast radius is small: DiceRoller.vue and DiceRoller.spec.ts. No store, API, or rules code changed — the data the die needs was already there.

Natural vs effective: the two numbers a roll carries

A resolved turn carries two roll values on its message: diceRoll, the effective roll the bands judged (natural + craft, clamped to 1–20), and naturalRoll, the die "as it actually landed, before the craft modifier." rollModifier is the tilt between them. The component now reads both: it keeps roll (effective) for the outcome/size logic, and adds natural for the face.

natural joins the Roll shape; latestRoll sources it from m.naturalRoll ?? m.diceRoll.

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

The face shows the natural roll

Four sites used to feed the effective roll to the face; each now uses natural. The face ref seeds from it, the loading→landed watcher sets it when a new roll lands, the hydration watcher keeps it honest if a roll appears without a loading transition, and faceLabel falls back through it. faceFontSize steps a two-digit number down so it stays inside the shape.

Every path that decides the printed number now reads natural.

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

The shape: a d20 silhouette in SVG

The template swaps the <div> box for an inline SVG: a bold outer hexagon (die-body) — the icosahedral silhouette — over faint internal facets (die-facets: the front-face triangle plus spokes) that give it depth. The number is an SVG <text>, still tagged data-testid="dice-face" so the existing test contract holds, and only rendered once landed.

Hexagon + facets + the centred number, all inside one 100×100 viewBox.

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

The styling leans entirely on currentColor. The hexagon's stroke and a 7% body tint, and the facets' faint strokes, all inherit the color the outcome classes (face-success, face-failure, …) already set — so the whole existing colour system drives the new shape unchanged.

currentColor carries the outcome ink; the crit glow follows the silhouette via a drop-shadow filter.

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

Tests that pin the behavior

Two new tests assert exactly what the old code got wrong — they would fail on the previous behavior. The first: a natural 12 with +1 (effective 13, Success) must print 12, not 13, and show the +1 complement. The second is the sharp one: a natural 20 with −2 (effective 18) must print 20, must not be flagged critical (crit follows the effective band), and must show the −2.

Discriminating cases: face=natural, crit=effective, modifier=complement.

tests/unit/components/DiceRoller.spec.ts · 96 lines
tests/unit/components/DiceRoller.spec.ts96 lines · TypeScript
⋯ 67 lines hidden (lines 1–67)
1import { describe, it, expect, beforeEach } from 'vitest'
2import { mount } from '@vue/test-utils'
3import { setActivePinia, createPinia } from 'pinia'
4import DiceRoller from '../../../components/DiceRoller.vue'
5import { useGameStore } from '../../../stores/game'
6import { DiceOutcome } from '../../../utils/dice'
7 
8/** Adds a resolved figure turn carrying a dice roll. */
9function resolvedRoll(diceRoll: number, diceOutcome: DiceOutcome) {
10 const store = useGameStore()
11 store.addAIMessageWithData({
12 text: 'A reply', sender: 'ai', figureName: 'Cleopatra', diceRoll, diceOutcome
13 })
15 
16describe('DiceRoller', () => {
17 let gameStore: ReturnType<typeof useGameStore>
18 
19 beforeEach(() => {
20 setActivePinia(createPinia())
21 gameStore = useGameStore()
22 })
23 
24 it('is idle before any roll', () => {
25 const die = mount(DiceRoller).find('[data-testid="dice-roller"]')
26 expect(die.attributes('data-state')).toBe('idle')
27 expect(die.text()).toContain('awaits')
28 })
29 
30 it('shows a rolling state while a turn is being resolved', () => {
31 gameStore.setLoading(true)
32 const die = mount(DiceRoller).find('[data-testid="dice-roller"]')
33 expect(die.attributes('data-state')).toBe('rolling')
34 expect(die.attributes('data-crit')).toBeUndefined()
35 })
36 
37 it('lands on the most recent roll with its outcome', () => {
38 resolvedRoll(18, DiceOutcome.SUCCESS)
39 const wrapper = mount(DiceRoller)
40 const die = wrapper.find('[data-testid="dice-roller"]')
41 expect(die.attributes('data-state')).toBe('landed')
42 expect(wrapper.find('[data-testid="dice-face"]').text()).toBe('18')
43 expect(wrapper.find('[data-testid="dice-roller-outcome"]').text()).toContain('Success')
44 expect(die.attributes('data-crit')).toBeUndefined()
45 })
46 
47 it('flags a natural 20 as a critical success', () => {
48 resolvedRoll(20, DiceOutcome.CRITICAL_SUCCESS)
49 const wrapper = mount(DiceRoller)
50 expect(wrapper.find('[data-testid="dice-roller"]').attributes('data-crit')).toBe('success')
51 expect(wrapper.find('[data-testid="dice-roller-outcome"]').text()).toContain('CRITICAL')
52 })
53 
54 it('flags a natural 1 as a critical failure', () => {
55 resolvedRoll(1, DiceOutcome.CRITICAL_FAILURE)
56 const wrapper = mount(DiceRoller)
57 expect(wrapper.find('[data-testid="dice-roller"]').attributes('data-crit')).toBe('fail')
58 expect(wrapper.find('[data-testid="dice-roller-outcome"]').text()).toContain('CRITICAL FAIL')
59 })
60 
61 it('reflects the latest roll across multiple turns', () => {
62 resolvedRoll(7, DiceOutcome.FAILURE)
63 resolvedRoll(15, DiceOutcome.SUCCESS)
64 const wrapper = mount(DiceRoller)
65 expect(wrapper.find('[data-testid="dice-face"]').text()).toBe('15')
66 })
67 
68 it('shows the NATURAL roll on the face, with the craft modifier as a complement', () => {
69 // Natural 12, craft +1 → effective 13 (Success). The face must read the 12 the
70 // die actually landed on; the +1 rides alongside as the ✒ complement, never
71 // folded into the number on the die.
72 gameStore.addAIMessageWithData({
73 text: 'A reply', sender: 'ai', figureName: 'Cleopatra',
74 diceRoll: 13, diceOutcome: DiceOutcome.SUCCESS, naturalRoll: 12, rollModifier: 1
75 })
76 const wrapper = mount(DiceRoller)
77 expect(wrapper.find('[data-testid="dice-face"]').text()).toBe('12')
78 const outcome = wrapper.find('[data-testid="dice-roller-outcome"]').text()
79 expect(outcome).toContain('Success')
80 expect(outcome).toContain('+1')
81 })
82 
83 it('shows a natural 20 dragged to a non-crit Success, the penalty as a complement', () => {
84 // Natural 20, craft −2 → effective 18 (a plain Success, not a crit). The face
85 // shows the 20 you rolled; the outcome/crit follow the EFFECTIVE band, so this
86 // is NOT flagged critical — and the −2 explains why.
87 gameStore.addAIMessageWithData({
88 text: 'A reply', sender: 'ai', figureName: 'Cleopatra',
89 diceRoll: 18, diceOutcome: DiceOutcome.SUCCESS, naturalRoll: 20, rollModifier: -2
90 })
91 const wrapper = mount(DiceRoller)
92 expect(wrapper.find('[data-testid="dice-face"]').text()).toBe('20')
93 expect(wrapper.find('[data-testid="dice-roller"]').attributes('data-crit')).toBeUndefined()
94 expect(wrapper.find('[data-testid="dice-roller-outcome"]').text()).toContain('-2')
95 })
⋯ 1 line hidden (lines 96–96)
96})