eleven / messenger Public
π€ 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 +
#feeddiv (#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.
The verbs seam
The state seam unblocked modules that read; the remaining clusters also act β they open threads, reload the list, raise modals. Those verbs can't be imported from shell.js (it's the entry module; importing it back is a cycle), and injecting them per-module scales into hook soup.
So the same trick again: web/static/shellverbs.js exports one empty registry object, and shell.js populates it in a single Object.assign at boot. Modules call verbs.openThread(id). Registration only β no logic in the seam, nothing imported by it, no cycles. When a verb's implementation later moves into its own module, the registry entry moves with it and no caller churns. (searchui.js's wireSearch predates this seam and stays as is.)
Rules for a slice
- Verbatim move β refactor in a separate PR from relocation, never both.
node --checkevery touched file;npm test;go build(go:embed).- One PR per slice, squash-merged, then deployed to the pinned session canary and exercised before the next slice starts.
- Live tenants get traced, not assumed: a "dead" section can house live functions (
openMentionMenu,typeaheadItems) and vice versa. - CLAUDE.md's frontend module list gains one line per new module.
Outcome (2026-07-15)
The ladder completed over two overnight runs: 13 modules out of shell.js (12,057 β ~7,650 lines), the state seam (shellstate.js) and the behaviour seam (shellverbs.js, ~43 registered verbs) both in place, every slice deployed to a pinned canary and verified before the next.
Two lessons paid for in bug reports, now structural:
- A verbatim slice's failure mode is the dropped identifier β reads that worked in the old module throw
ReferenceErrorin the new one, at runtime only, on paths tests don't walk. Call-site audits are not enough: bare references (if (embed),sealedBranding.name) crash the same way.test/module-refs.test.mjsnow scans every extracted module for unresolved identifiers and runs innpm test. Write that checker before the first slice of any future effort like this. - The boot smoke (
tools/boot-smoke.mjs) walks sign-in β boot β send β search β picker headlessly and fails on any page error; each escape became a new step. Run it against a--cleandev server for anything touching the shell's boot or module graph.
Remaining ladder
All done (see Outcome). What deliberately stays in shell.js: boot, keys, data loading, the WebSocket dispatch, and the render plumbing the verbs registry fronts. Possible future work, none owed: verb implementations gaining module homes (the registry entry moves with them), and searchui's pre-seam wireSearch migrating to verbs.*.