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
⋯ 63 lines hidden (lines 1–63)
⋯ 216 lines hidden (lines 98–313)
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
⋯ 99 lines hidden (lines 1–99)
⋯ 12 lines hidden (lines 129–140)
⋯ 169 lines hidden (lines 145–313)
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
⋯ 10 lines hidden (lines 1–10)
⋯ 283 lines hidden (lines 31–313)
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
⋯ 218 lines hidden (lines 1–218)
⋯ 34 lines hidden (lines 235–268)
⋯ 30 lines hidden (lines 284–313)
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.