eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

πŸ€– 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.

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

  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.

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 ReferenceError in 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.mjs now scans every extracted module for unresolved identifiers and runs in npm 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 --clean dev 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.*.