What changed, and why
A figure's suggestion blurb — the one-line "why this figure matters" under a name in the contact picker — kept describing that figure's original fate even after the player had overturned it on the timeline ledger. From playtesting: after a node where Majorian survives past 461, the panel still called him someone whose "early death after only four years meant the West lost its last chance." It quietly contradicted the timeline the player was building.
Two facts caused it. Blurbs were fetched once per objective and never refreshed. And the suggester was handed only {title, description, era} — it never saw the ledger, so a blurb had no way to know what had changed.
This PR does three things: it feeds the landed ledger to the suggester so each blurb re-narrates against the altered history; it re-runs the suggester after each change (non-blocking, the suggestion-side twin of the living Chronicle); and when a blurb changes, it plays a "history rewritten" flourish — the old wording struck through and bled away while the new writes in over it.
The suggester's brief re-narrates against the timeline
The suggester is a small tool-using agent; the only thing it acts on is the brief it's handed. The fix folds the landed changes into that brief as the history that now stands, with one instruction: where a change has overturned a figure's known fate, say what's now true of them — never re-assert the outcome the change reversed. The framing is conditional, so a turn with no changes yet produces a brief byte-identical to the tuned original.
The conditional altered-timeline section, appended only when there are changes.
server/utils/figure-suggester.ts · 337 lines
⋯ 149 lines hidden (lines 1–149)
⋯ 170 lines hidden (lines 168–337)
The ledger crosses the boundary, bounded once
The ledger arrives from the client, so it's untrusted input reaching a model prompt. buildLedgerBrief is the one place it's coerced: cap the array first (so a crafted oversized body can't amplify CPU before the count cap), then drop headline-less entries, stamp the era, and keep the most recent few.
Cap-first, then coerce + bound each field — the established input discipline.
server/utils/figure-suggester.ts · 337 lines
⋯ 65 lines hidden (lines 1–65)
⋯ 250 lines hidden (lines 88–337)
The endpoint just calls the helper and threads the result through.
server/api/suggestions.post.ts · 35 lines
⋯ 17 lines hidden (lines 1–17)
A non-blocking refresh, like the Chronicle
refreshSuggestions is the suggestion-side twin of refreshChronicle: it re-runs the suggester with the current ledger, fired after a landed change and never awaited, so the turn reveal waits on neither. A failed, blocked, or curated-fallback refresh keeps the prior list — a populated panel never blanks, and a model outage never regresses good era-specific picks to generic names.
Keep-prior on every degraded path; the finally settles the shared loading flag.
stores/game.ts · 1533 lines
⋯ 772 lines hidden (lines 1–772)
⋯ 723 lines hidden (lines 811–1533)
Fired next to refreshChronicle, only on a turn that actually changed the world.
stores/game.ts · 1533 lines
⋯ 1169 lines hidden (lines 1–1169)
⋯ 355 lines hidden (lines 1179–1533)
The 'history rewritten' flourish
The animation is a self-contained component, RewriteText. Its trigger is purely the text prop changing — so callers need no diffing. A chip kept across a refresh (same :key) sees its blurb change and animates; a brand-new chip mounts fresh and just appears. The watch never fires on the initial value, and it guards on a prior value existing and reduced-motion.
Change-only trigger: stash the old text as a ghost, arm a self-clearing timer.
components/RewriteText.vue · 132 lines
⋯ 43 lines hidden (lines 1–43)
⋯ 77 lines hidden (lines 56–132)
The visual is a palimpsest — old ink showing under new. The old wording is struck through (every line, via text-decoration) and bled away beneath, while the new wording writes in left-to-right with the ink starting "wet" (the one accent colour) and drying to rest.
The new line writes in (clip-path); the old line strikes through and fades.
components/RewriteText.vue · 132 lines
⋯ 65 lines hidden (lines 1–65)
⋯ 33 lines hidden (lines 100–132)
Wiring it into the picker
One line changes in the picker: the blurb's <span> becomes a <RewriteText> carrying the same classes. The chip is keyed by name, so the same DOM node persists across a refresh and the component sees its text prop change — which is what makes only the blurbs that actually changed animate, while unchanged ones sit still.
The reason line, now a RewriteText; data-testid + classes fall through to its root.