eleven / messenger Public

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>
Paul Campbell 3c1d7ebc0bb8332701460174352967c910177929 parent 55b297b
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.