Skip to content

refactor: move the draft into a closure and widen the screen contexts - #68

Merged
philoserf merged 1 commit into
mainfrom
refactor/draft-into-closure
Sep 16, 2026
Merged

philoserf merged 1 commit into
mainfrom
refactor/draft-into-closure

Conversation

@philoserf

Copy link
Copy Markdown
Owner

Closes #42 and #46. Second and last PR of milestone 4.

The play screen owns its own state (#42, play half)

currentDraft, scenarioRecallOpen and lastSessionId were module-level lets — the app's only mutable state outside the store — because every renderPlay call was a fresh invocation that had to find the draft where the last one left it. renderPlay now creates that state itself and repaints its own subtree.

Four pieces of machinery existed only to work around that, and all four are gone:

  • ensureDraftFor and lastSessionId, which detected a session change by hand. A new letter is now a new renderPlay call, so there is nothing to detect.
  • The reset-before-onUpdate ordering, whose own comment warned that getting it backwards stranded the player on the done screen until they reloaded. Committing discards the whole closure — there is no ordering left to sequence, so the comment is deleted rather than preserved.
  • PlayCtx.repaint, which pointed at main.ts's full-tree render. The two channels are now named apart: main.ts's commit() is durable, the play screen's internal repaint() is volatile.
  • onUpdate — a general "mutate the session however you like" hook — narrowed to onCommit(paragraph). Committing is the screen's only store write.

The trap, handled deliberately. The render helpers take a per-invocation view, and state is one shared object per letter rather than a value copied into each render. That matters for exactly one thing: the roll button's 250ms shake timer reads v.state.draft when it fires, not a copy captured when its button was built. Capturing it would mean a repaint mid-shake lands the roll on a draft nothing will ever see — which is the bug the module-level let was accidentally avoiding.

Screens stop re-resolving what main.ts proved (#46)

PlayCtx and ScoreCtx carry the resolved character and skill. Seven characterById/skillById calls inside src/screens/ go, along with the fallbacks that invented five different answers to a question already answered: 'skill', '', 'The Correspondent', an internalError panel, and a thrown Error. toMarkdown takes the resolved pair instead of the whole CHARACTERS/SKILLS arrays, dropping its own find calls and its throw.

Verification

bun run check:ci clean, 77 tests. None of this has automated coverage, so it was driven in the browser:

Check Result
Recall toggle mid-step roll button is the same node — toggles in place, no repaint
Spend the skill repaints, button disappears, info reads Roll Heart (5 dice, skill applied)
Every phase transition 0 localStorage writes
Commit exactly 1 write
After commit "Dip your quill…" / Paragraph II of V — a fresh draft, not a re-shown PARAGRAPH_DONE
Reload mid-letter committed paragraph restored, Augmentation spent still latched, fresh draft at PICK_WORD
Widened context reference card reads The Courtier — Augmentation unspent from v.character/v.skill

That fifth row is the one worth naming: it's the failure the deleted comment warned about, and it's now structurally impossible rather than avoided by statement order.

Also

Two comments corrected while in the file — attachRollButton named the recall toggle as a repaint cause, which stopped being true when that toggle became an in-place mutation; and the recall toggle's own comment still claimed a repaint would rewrite localStorage, which stopped being true in milestone 3.

src/screens/play.ts 709 → 695 lines, no module-level state left.

THEORY.md and WALKTHROUGH.md untouched, per the standing rule.

Merging closes milestone 4.

**The play screen owns its own state (#42, play half).** `currentDraft`,
`scenarioRecallOpen` and `lastSessionId` were module-level `let`s — the app's
only mutable state outside the store — because every `renderPlay` call was a
fresh invocation that had to find the draft where the last one left it.
`renderPlay` now creates that state itself and repaints its own subtree.

Four pieces of machinery existed only to work around the old arrangement, and
all four are gone:

  - `ensureDraftFor` and `lastSessionId`, which detected a session change by
    hand. A new letter is now a new `renderPlay` call, so there is nothing to
    detect.
  - The reset-before-`onUpdate` ordering, whose comment warned that getting it
    backwards stranded the player until they reloaded. Committing discards the
    whole closure, so there is no ordering left to sequence.
  - `PlayCtx.repaint`, which pointed at `main.ts`'s full-tree render. The two
    channels are now named apart: `main.ts`'s `commit()` is durable, the play
    screen's internal `repaint()` is volatile.
  - `onUpdate`, a general "mutate the session however you like" hook, narrowed
    to `onCommit(paragraph)`. Committing is the screen's only store write.

The render helpers take a per-invocation view rather than the raw context.
`state` is one shared object per letter, deliberately: the roll button's 250ms
shake timer reads `v.state.draft` when it fires, not a copy captured when its
button was built, so a repaint mid-shake cannot land a roll on a draft nothing
will ever see.

**Screens stop re-resolving what `main.ts` proved (#46).** `PlayCtx` and
`ScoreCtx` carry the resolved `character` and `skill`. Seven `characterById`/
`skillById` calls inside `src/screens/` go, along with the fallbacks that
invented five different answers to a question already answered — `'skill'`,
`''`, `'The Correspondent'`, an `internalError` panel, and a thrown `Error`.
`toMarkdown` takes the resolved pair instead of the whole `CHARACTERS` and
`SKILLS` arrays, dropping its own `find` calls and its throw.

Two comments corrected while in the file: `attachRollButton` named the recall
toggle as a repaint cause, which stopped being true when that toggle became an
in-place mutation; and the recall toggle's own comment still claimed a repaint
would rewrite localStorage, which stopped being true in the previous milestone.

Verified in the browser, since none of this has automated coverage: a fresh
letter through a committed paragraph; the recall toggle leaving the roll button
node untouched; spending the skill repainting and reading "5 dice, skill
applied"; zero localStorage writes across every phase transition and exactly
one for the commit; the screen advancing to a fresh "Paragraph II of V" rather
than re-showing PARAGRAPH_DONE; and a mid-letter reload restoring the committed
paragraph with the skill still spent.

`src/screens/play.ts` 709 → 695 lines, with no module-level state left.

Closes #42
Closes #46

Co-Authored-By: Claude
@philoserf
philoserf merged commit dbee96d into main Sep 16, 2026
3 checks passed
@philoserf
philoserf deleted the refactor/draft-into-closure branch September 16, 2026 11:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Repaint and persistence share one channel, so every phase transition rewrites localStorage

1 participant