What changed, and why
The compose dock carries a pre-send "⏳" chip that forecasts the causal chain: how far, in years, your chosen moment lands from your nearest foothold (the objective's era, or a change you've already made). Land close and the message hits at full force; land in the deep past with nothing bridging it and the swing decays.
The chip only ever surfaced a caution. Its best tier — at-hinge, a foothold landing at full force — returned null, so the chip vanished. Doing the right thing showed nothing, and null carried two meanings at once: "you nailed the chain" and "the chain doesn't apply here" (an anchorless objective, or no footholds landed yet). A player on a perfect chain and a player off the grid saw the same blank. In playtesting the system read as "invisible".
This change surfaces at-hinge as a positive chip and lets null mean one thing. The blast radius is small: the chip's data already existed in the store; the fix is two lines of view logic plus tuned copy, pinned by a new end-to-end spec. The four files below.
The chip: surfaced for every tier, styled by stake
The view did the hiding, in two spots in pages/play.vue. The template now colors the chip by tier — at-hinge rv-ok (a win), diffuse rv-warn (a caution), the two middle tiers neutral — and the chainRead computed drops the tier === 'at-hinge' arm of its early-return, so every engaged tier reaches the chip. The only state that still returns null is the genuinely-absent one.
The chip's class is keyed by tier; chainRead returns null only when there is no status.
pages/play.vue · 620 lines
⋯ 169 lines hidden (lines 1–169)
⋯ 163 lines hidden (lines 178–340)
⋯ 270 lines hidden (lines 351–620)
That null now means exactly one thing, because it is a straight pass-through of the store getter. causalChainRead builds the footholds (the objective's anchor plus every dated change) and asks chainStatus, which returns null only for an ungrounded contact or an anchorless objective with nothing landed yet — never for at-hinge.
The chip's source. Null comes from chainStatus, not from a tier check.
stores/game.ts · 1463 lines
⋯ 424 lines hidden (lines 1–424)
⋯ 1025 lines hidden (lines 439–1463)
Copy that reads as a win — and the label left alone
A surfaced at-hinge chip needs copy that lands as a win, in the same shape as its siblings. The hint is tuned from "right at the hinge of events — full force" to "anchored at the hinge — lands at full force": the same "[state] — [consequence]" form as bridged ("lands strong"), reaching ("landing diluted"), and diffuse ("build a chain toward it first").
CHAIN_HINT['at-hinge'] tuned to a win; CHAIN_LABEL left untouched.
utils/causal-chain.ts · 129 lines
⋯ 60 lines hidden (lines 1–60)
⋯ 1 line hidden (lines 67–67)
⋯ 55 lines hidden (lines 75–129)
Pinning both outcomes
The fix lives in a render computed, so the discriminating test is an end-to-end one that drives the real compose dock with grounding mocked — making the chip's tier a pure function of the objective's anchor and the contact year. Archduke Franz Ferdinand (1863–1914) against the 1914-anchored "Prevent the Great War" sits inside the bridge-free radius, so the chain reads at-hinge; an anchorless objective has no foothold to measure against, so the chip is absent.
Two cases: at-hinge surfaces with rv-ok; an anchorless objective shows no chip.
tests/e2e/features/chain-chip.spec.ts · 61 lines
⋯ 15 lines hidden (lines 1–15)
⋯ 1 line hidden (lines 34–34)
⋯ 1 line hidden (lines 61–61)
The spec needs to stand up its own board on a specific objective, so it reuses the suggestion mock that startCuratedRun already registers. That helper was file-private; the one production-side change to the support file is to export it.
mockSuggestions promoted from file-private to exported.