# 🤖 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.*`.
