import PullQuote from '../components/PullQuote.astro'

The review's fourth and final pick found one more place two components had converged without ever actually sharing code. [Consolidating the duel recap](/posts/one-recap-two-modes) had already pulled PvE and PvP onto one component months earlier, but `GameRecap` and `DuelRecap` still each carried their own copy of the same underlying machine, submit-versus-results view state, the skip-then-changed-your-mind flow, the initials form, the log toggle, the midnight countdown, five hundred lines apiece doing the same job for two different modes.

Four candidate shells went up, and the sharpest question wasn't which one to pick but whether a shell should exist at all. A shell-free design pulled the state machine into a plain, DOM-free module, tested with no rendering step whatsoever, a genuine first among the four candidates. It cleared roughly eighty-five percent of the actual duplication. What it left behind was the surrounding markup and its CSS, the wrapper structure and spacing rules that stayed written out separately in every caller regardless of where the state lived. Deleting that shell and asking whether the duplication came back answered the question on its own: most of it would, just not the state.

<PullQuote>Delete the shell, and see what comes back.</PullQuote>

Among the three designs that kept a real shell, the axis was how much of each mode's markup got handed over as opaque snippets versus how much stayed data the shell could render itself. The most snippet-heavy of the three gave each mode six separate markup regions to fill in, flexible but wide enough that shared styling had to leak out to the page as global CSS to reach across snippet boundaries. The most data-heavy went the other way, one typed model and no snippets except a single deliberate escape hatch, but the model it produced needed to smuggle click handlers inside what was supposed to be plain data, and needed a second separate channel just for the initials text binding the model itself couldn't carry. [The lives-teaching reducer had already paid a version of that same cost](/posts/descriptors-not-strings) when a similar all-data shape won cleanly there, because that seam never had to carry a live text binding through it in the first place; here it did, and the cost came due.

The design that won made the snippet-versus-data call one region at a time instead of once for the whole shell. The header and the screen-reader summary render identically in every mode, so both stayed plain props; only the results-panel actions, the footer, and one daily-only extra earned an actual snippet, since those three are the only regions that are genuinely different markup, not just different data. Even the submit-versus-results state itself needed no per-mode branching to handle PvP correctly:

```typescript
const hasSubmitStep = $derived(onSubmitScore !== undefined);
const view = $derived.by((): 'submit' | 'results' =>
  !hasSubmitStep || submitted || skipped ? 'results' : 'submit'
);
```

PvP has no submission step of its own, so it simply never passes a submit callback, and the derived view lands on results without a single line written specifically for that mode.

Two small behavior changes rode along, both named plainly rather than left for review to catch: the screen-reader summary now renders even when the board snapshot image is missing, a strict improvement over the daily page's old behavior of hiding it in that case, and a header gap that had been a hardcoded two pixels became a real spacing token. `GameRecap` and `DuelRecap` both roughly halved in size, and none of the three pages that render them needed to change at all.