| 1 | # π€ Breaking shell.js into modules β the ladder and the shellstate seam |
| 2 | |
| 3 | shell.js (the chat page host) started 2026-07-14 at ~12,000 lines. It is being |
| 4 | split into plain ES modules β no build step, verbatim moves, one PR per slice, |
| 5 | each verified on a pinned canary before the next. This doc records the method |
| 6 | and the one real design decision (the state seam), so any session can continue |
| 7 | the ladder without re-deriving it. |
| 8 | |
| 9 | ## Done |
| 10 | |
| 11 | - **Dead code first** (never move what you can delete): the legacy typing |
| 12 | pipeline (#911), the feed-cache cluster + `#feed` div (#919), the hidden |
| 13 | legacy composer + its staging chain (#931). The "retired original chat" |
| 14 | CLAUDE.md used to fence off is now fully gone. |
| 15 | - **Leaf extractions** (sections with little/no shared state): |
| 16 | `theme.js` (#913), `lightbox.js` (#928 β deduplicated the byte-identical |
| 17 | copy chat.js carried), `media.js` (#933). |
| 18 | |
| 19 | ## The shellstate seam |
| 20 | |
| 21 | Seven mutable bindings are read all over the shell (~700 references): |
| 22 | `session`, `threads`, `threadKeys`, `activeId`, `ws`, `roster`, `myUserId`. |
| 23 | They are why the remaining sections can't just move: an ES-module import can |
| 24 | *read* a live binding but never assign it. |
| 25 | |
| 26 | Measured (2026-07-14): only **~14 sites assign a whole binding**; everything |
| 27 | else reads it or mutates properties (`threadKeys[id] = k`), both of which work |
| 28 | unchanged through an imported live binding. |
| 29 | |
| 30 | So the seam is `web/static/shellstate.js`: |
| 31 | |
| 32 | - It **owns** the seven bindings: `export let activeId = null;` etc. |
| 33 | - Whole-binding writes go through exported setters (`setActiveId(v)`, |
| 34 | `setThreads(v)`, β¦) β one setter per binding, assignment-only, no logic. |
| 35 | - Every reader β shell.js and each extracted module β imports the bindings |
| 36 | **under their existing names**; the ~700 reads don't change. Property |
| 37 | mutations on the imported objects/maps stay as they are. |
| 38 | - Nothing else lives in shellstate.js. It is a place for shared *state*, not |
| 39 | shared *behaviour*; helpers keep moving into their own modules. |
| 40 | |
| 41 | ## The verbs seam |
| 42 | |
| 43 | The state seam unblocked modules that *read*; the remaining clusters also |
| 44 | *act* β they open threads, reload the list, raise modals. Those verbs can't be |
| 45 | imported from shell.js (it's the entry module; importing it back is a cycle), |
| 46 | and injecting them per-module scales into hook soup. |
| 47 | |
| 48 | So the same trick again: `web/static/shellverbs.js` exports one empty |
| 49 | registry object, and shell.js populates it in a single `Object.assign` at |
| 50 | boot. Modules call `verbs.openThread(id)`. Registration only β no logic in |
| 51 | the seam, nothing imported by it, no cycles. When a verb's implementation |
| 52 | later moves into its own module, the registry entry moves with it and no |
| 53 | caller churns. (searchui.js's `wireSearch` predates this seam and stays as |
| 54 | is.) |
| 55 | |
| 56 | ## Rules for a slice |
| 57 | |
| 58 | 1. Verbatim move β refactor in a separate PR from relocation, never both. |
| 59 | 2. `node --check` every touched file; `npm test`; `go build` (go:embed). |
| 60 | 3. One PR per slice, squash-merged, then deployed to the pinned session |
| 61 | canary and exercised before the next slice starts. |
| 62 | 4. Live tenants get traced, not assumed: a "dead" section can house live |
| 63 | functions (`openMentionMenu`, `typeaheadItems`) and vice versa. |
| 64 | 5. CLAUDE.md's frontend module list gains one line per new module. |
| 65 | |
| 66 | ## Outcome (2026-07-15) |
| 67 | |
| 68 | The ladder completed over two overnight runs: 13 modules out of shell.js |
| 69 | (12,057 β ~7,650 lines), the state seam (`shellstate.js`) and the behaviour |
| 70 | seam (`shellverbs.js`, ~43 registered verbs) both in place, every slice |
| 71 | deployed to a pinned canary and verified before the next. |
| 72 | |
| 73 | Two lessons paid for in bug reports, now structural: |
| 74 | |
| 75 | - **A verbatim slice's failure mode is the dropped identifier** β reads that |
| 76 | worked in the old module throw `ReferenceError` in the new one, at runtime |
| 77 | only, on paths tests don't walk. Call-site audits are not enough: bare |
| 78 | *references* (`if (embed)`, `sealedBranding.name`) crash the same way. |
| 79 | `test/module-refs.test.mjs` now scans every extracted module for unresolved |
| 80 | identifiers and runs in `npm test`. **Write that checker before the first |
| 81 | slice of any future effort like this.** |
| 82 | - **The boot smoke** (`tools/boot-smoke.mjs`) walks sign-in β boot β send β |
| 83 | search β picker headlessly and fails on any page error; each escape became |
| 84 | a new step. Run it against a `--clean` dev server for anything touching the |
| 85 | shell's boot or module graph. |
| 86 | |
| 87 | ## Remaining ladder |
| 88 | |
| 89 | All done (see Outcome). What deliberately stays in shell.js: boot, keys, |
| 90 | data loading, the WebSocket dispatch, and the render plumbing the verbs |
| 91 | registry fronts. Possible future work, none owed: verb implementations |
| 92 | gaining module homes (the registry entry moves with them), and searchui's |
| 93 | pre-seam `wireSearch` migrating to `verbs.*`. |
| 94 | |