docs: the shell.js module ladder + the shellstate seam design (#936)
Records the method (dead code first, verbatim slices, canary-verified) and the one real design decision: shellstate.js owns the seven core bindings as export let + assignment-only setters — reads keep their names via live bindings (~700 sites unchanged), only the ~14 whole-binding writes convert.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+58
−0
docs/shell-modules.md+58 −0
diff --git a/docs/shell-modules.md b/docs/shell-modules.md| new file mode 100644 |
| index 0000000..5d4b489 |
| --- /dev/null |
| +++ b/docs/shell-modules.md |
| @@ -0,0 +1,58 @@ |
| +# 🤖 Breaking shell.js into modules — the ladder and the shellstate seam |
| + |
| +shell.js (the chat page host) started 2026-07-14 at ~12,000 lines. It is being |
| +split into plain ES modules — no build step, verbatim moves, one PR per slice, |
| +each verified on a pinned canary before the next. This doc records the method |
| +and the one real design decision (the state seam), so any session can continue |
| +the ladder without re-deriving it. |
| + |
| +## Done |
| + |
| +- **Dead code first** (never move what you can delete): the legacy typing |
| + pipeline (#911), the feed-cache cluster + `#feed` div (#919), the hidden |
| + legacy composer + its staging chain (#931). The "retired original chat" |
| + CLAUDE.md used to fence off is now fully gone. |
| +- **Leaf extractions** (sections with little/no shared state): |
| + `theme.js` (#913), `lightbox.js` (#928 — deduplicated the byte-identical |
| + copy chat.js carried), `media.js` (#933). |
| + |
| +## The shellstate seam |
| + |
| +Seven mutable bindings are read all over the shell (~700 references): |
| +`session`, `threads`, `threadKeys`, `activeId`, `ws`, `roster`, `myUserId`. |
| +They are why the remaining sections can't just move: an ES-module import can |
| +*read* a live binding but never assign it. |
| + |
| +Measured (2026-07-14): only **~14 sites assign a whole binding**; everything |
| +else reads it or mutates properties (`threadKeys[id] = k`), both of which work |
| +unchanged through an imported live binding. |
| + |
| +So the seam is `web/static/shellstate.js`: |
| + |
| +- It **owns** the seven bindings: `export let activeId = null;` etc. |
| +- Whole-binding writes go through exported setters (`setActiveId(v)`, |
| + `setThreads(v)`, …) — one setter per binding, assignment-only, no logic. |
| +- Every reader — shell.js and each extracted module — imports the bindings |
| + **under their existing names**; the ~700 reads don't change. Property |
| + mutations on the imported objects/maps stay as they are. |
| +- Nothing else lives in shellstate.js. It is a place for shared *state*, not |
| + shared *behaviour*; helpers keep moving into their own modules. |
| + |
| +## Rules for a slice |
| + |
| +1. Verbatim move — refactor in a separate PR from relocation, never both. |
| +2. `node --check` every touched file; `npm test`; `go build` (go:embed). |
| +3. One PR per slice, squash-merged, then deployed to the pinned session |
| + canary and exercised before the next slice starts. |
| +4. Live tenants get traced, not assumed: a "dead" section can house live |
| + functions (`openMentionMenu`, `typeaheadItems`) and vice versa. |
| +5. CLAUDE.md's frontend module list gains one line per new module. |
| + |
| +## Remaining ladder |
| + |
| +`shellstate.js` conversion, then one PR each: moderation (#578 cluster), |
| +message search, group actions, the new-chat picker, add-a-device, |
| +avatars + profile, and the onboarding cluster (one-Eleven flows + quick |
| +forms + flow bridge — traced as one cohesive unit, not separable). The |
| +core that stays in shell.js at the end: boot, keys, data loading, and the |
| +WebSocket dispatch. |