3 — The store, and who owns which state
Written: 2026-08-15 · Established by: reading src/store/index.ts, src/store/types.ts and
the six slice files at commit 93a9a67.
There is one Zustand store, composed from six slices, and it is the only place two subsystems ever meet. The viewer does not talk to the sidebar; both read the store. That is why the diagrams either side of this one — the load path and the write path — both pass through it.
The diagram draws only what each slice talks to outside the store. Which components read which slice is a fifteen-edge mesh that says less than the table below it, so it is a table.
| Slice | Owns | Read by | Reaches outside |
|---|---|---|---|
file-slice |
file, fileName, fileLoadId, history, historyRevision, densityFinding |
everything | localStorage — density limits |
view-slice |
selectedOpId, hiddenOpIds, soloOpId, reveal, frameRequest, findRequest, pointerTool, sidebarTab |
viewer, editor, panels | localStorage — viewer settings, export options, nav bindings |
geometry-slice |
geometryContext, importStatus, importError |
viewer, Geometry panel | IndexedDB, and the step-import worker |
stock-pick-slice |
stockPickSession |
viewer | — |
simulation-slice |
simulation, settings, checkpoints |
viewer, Simulation panel | the tri-dexel worker pool |
playback-slice |
playback or null |
viewer, playback bar | — |
The arrows above leave the store's border rather than an individual slice, which is a limit of the layout rather than a claim — the Reaches outside column is the precise version.
Four rules that explain the shapes above
One file-level undo stack, and CodeMirror's own history is off —
ADR-0011. history and historyRevision live in
file-slice, and every programmatic writer goes through applyTransform. Diagram 4 is that path.
fileLoadId increments only when the user opens a file. Every edit replaces file with a freshly
parsed object, so object identity cannot tell "opened a new program" from "re-parsed after an edit" —
and the viewer must refit the camera only for the former.
reveal, frameRequest and findRequest are one-shot commands, not state. Each is an object
carrying an incrementing id, and the scene and the editor key their effects on that object, so a
request applies exactly once. Pressing "Top" twice has to work both times, which is why the id
increments and the object is never reused.
CLAUDE.md's Key conventions still names this field cameraCommand, which no longer exists in
src/. Issue #355 covers correcting it — deriving this diagram is what found it.
Geometry is document-scoped but never derived from file. geometry-slice is deliberately
untouched by loadFile: an imported fixture or part body has to survive both a file reload and every
edit's re-parse. It is also the only slice with an asynchronous source, so src/store/index.ts
exports geometryHydrated as a promise for tests to await.
What is deliberately not in the store
Derived simulation state that would be expensive to clone. simulation-slice keeps the sweep's
disposable working state in one mutable container that never goes through Zustand's set
(ADR-0024 rule 7).
Each slice resets its own concerns on a new file. view-slice, playback-slice and
stock-pick-slice each export a resetForNewFile, rather than loadFile reaching across and
clearing fields it does not own. Issue #248 is why.