Part of the ADR-0004 vanilla-shell investment track (see docs/ADR-0004-ui-shell.md); scoped from the 2026-08-03 architecture review.
Goal
src/ui/app.ts is a god composition root: createApp spans lines 169–3169 (roughly 93% of the file), producing a 113-member App bag (src/ui/app.types.ts:242–677, imported by 27 modules) via 130 scattered app.X = ... assignments, typed only by an as App cast at :236. Decompose it along four seams so adding a stateful feature no longer requires touching four or five unrelated files.
Context
Rendering itself is NOT the problem — app.ts has 0 effect() calls, 9 event listeners, and 8 classList sites across 3,215 lines; the extraction discipline the codebase already follows worked. The cost driver is composition-root sprawl: adding a single stateful toolbar button today touches an AppDom field, the element build in workbench-shell.ts, a setXBtn updater in app.ts, an entry in WorkbenchShellDeps (workbench-shell.ts:98–102) AND the matching line in the 18-line forwarding literal at app.ts:3196–3214, plus an ActionsRegistry entry.
Four extractions
Ordered by risk — do 3 and 4 first (lowest risk); 1 and 2 have subtle ordering invariants (the surface-generation guards at app.ts:309–328 and the write-chain at :2324) and should lean on the existing cross-tab-consistency.test.ts coverage rather than being re-derived from scratch.
- Routing + main-surface navigation (
app.ts:2629–3046, ~420 lines) → src/application/surface-navigation.ts. Currently drags 12 App members that nothing but the router itself needs.
- Workspace persistence + cross-tab sync (
app.ts:2324–2628, ~305 lines: write serialization, BroadcastChannel, refresh scheduling, beforeunload guard, provisioning) → src/application/workspace-session.ts. Zero DOM, already env-seamed; removes 9 App members and 4 of the file's 9 addEventListener sites.
renderVarStrip + setRunBtn (app.ts:1008–1224, ~217 lines, the densest DOM in the file) → src/ui/variable-bar.ts, which already exists and is the natural home. Drags 8 imports, the VarStripCombo type, and 4 AppDom bookkeeping fields with it.
- Promote
anchoredPopover (app.ts:1719–1762 — a generic keyboard-owning overlay primitive currently living in the composition root even though popover.ts exists) into src/ui/popover.ts. Then extract the save cluster (app.ts:1763–2003: flashSaved, commitLinkedQuery, saveVariableTab, saveActiveQuery, reloadSavedVersion, openConflictChooser, openSavePopover) → src/ui/workbench/save-controller.ts. Removes the last 6 classList sites (updateSaveBtn).
- Replace the
as App cast (app.ts:236: const app = appBase as App;) with typed staged construction — sub-service factories that build and compose the final App object field-by-field, so a forgotten member assignment becomes a compile error instead of a runtime hole. Pairs naturally with extractions 1–4: as members move out to their own modules, the App interface (app.types.ts:242–677) should shrink toward narrow capability ports (modules depending on Pick<App, …>-shaped ports) rather than the current single 113-member bag.
Smaller cleanup (bundle with whichever extraction touches it first)
keyboardOwnerChannel is copy-pasted identically three times: file-menu.ts:86–92, library-assign-menu.ts:22–30, dashboard.ts:285–291. Hoist it to one shared module.
Tests
- Each extracted module ships with its own
tests/unit/<module>.test.js at the repo's coverage floors (100/95/90/100), not merely re-exported test coverage from app.ts's existing suite.
surface-navigation.ts: routing/navigation behavior covered independent of app.ts, including the ordering invariant around the surface-generation guards (app.ts:309–328 today).
workspace-session.ts: cross-tab write/read, BroadcastChannel, refresh scheduling, and beforeunload guard covered directly; existing cross-tab-consistency.test.ts continues to pass unmodified in behavior (may need import-path updates).
variable-bar.ts: renderVarStrip/setRunBtn behavior merges cleanly with the module's existing tests, no coverage regression on the file.
popover.ts: anchoredPopover promoted with tests; save-controller.ts covers the save cluster (flash/commit/save/reload/conflict/open) independent of app.ts.
keyboardOwnerChannel hoisted module has one test file instead of the behavior being asserted three times incidentally through file-menu/library-assign-menu/dashboard tests.
- Full
npm test coverage gate and tsc --noEmit pass after each extraction (can land as separate PRs/phases).
Acceptance criteria
app.ts shrinks toward ≈1,800 lines.
- The
App interface (app.types.ts:242–677) shrinks — members that only the router, workspace-session, variable-bar, or save-controller need move to their narrower module's own types, rather than living in the shared 113-member bag.
- All four extractions preserve current behavior with no functional change (pure refactor);
cross-tab-consistency.test.ts and existing app-level integration tests pass unmodified in intent.
keyboardOwnerChannel exists in exactly one place.
- Adding a new stateful toolbar button after this change touches fewer than the current 4–5 files (ideally the button's own module plus one registration point).
npm test (coverage gate) and tsc --noEmit pass at each step.
Non-goals
- Introducing a component framework, hooks, or a generic DI container — extractions stay in the existing pure-module/injected-seam style (ADR-0001/ADR-0004).
- Changing the
AppDom/WorkbenchShellDeps forwarding pattern wholesale; this issue only removes members that no longer need to flow through it.
- Any behavior or UX change — this is structural extraction only.
- Extracting every remaining
app.ts responsibility; four seams (plus the small keyboardOwnerChannel hoist) are the scope of this issue. Further decomposition can be filed separately if warranted.
Related
Part of the ADR-0004 vanilla-shell investment track (see
docs/ADR-0004-ui-shell.md); scoped from the 2026-08-03 architecture review.Goal
src/ui/app.tsis a god composition root:createAppspans lines 169–3169 (roughly 93% of the file), producing a 113-memberAppbag (src/ui/app.types.ts:242–677, imported by 27 modules) via 130 scatteredapp.X = ...assignments, typed only by anas Appcast at:236. Decompose it along four seams so adding a stateful feature no longer requires touching four or five unrelated files.Context
Rendering itself is NOT the problem —
app.tshas 0effect()calls, 9 event listeners, and 8classListsites across 3,215 lines; the extraction discipline the codebase already follows worked. The cost driver is composition-root sprawl: adding a single stateful toolbar button today touches anAppDomfield, the element build inworkbench-shell.ts, asetXBtnupdater inapp.ts, an entry inWorkbenchShellDeps(workbench-shell.ts:98–102) AND the matching line in the 18-line forwarding literal atapp.ts:3196–3214, plus anActionsRegistryentry.Four extractions
Ordered by risk — do 3 and 4 first (lowest risk); 1 and 2 have subtle ordering invariants (the surface-generation guards at
app.ts:309–328and the write-chain at:2324) and should lean on the existingcross-tab-consistency.test.tscoverage rather than being re-derived from scratch.app.ts:2629–3046, ~420 lines) →src/application/surface-navigation.ts. Currently drags 12Appmembers that nothing but the router itself needs.app.ts:2324–2628, ~305 lines: write serialization, BroadcastChannel, refresh scheduling,beforeunloadguard, provisioning) →src/application/workspace-session.ts. Zero DOM, already env-seamed; removes 9Appmembers and 4 of the file's 9addEventListenersites.renderVarStrip+setRunBtn(app.ts:1008–1224, ~217 lines, the densest DOM in the file) →src/ui/variable-bar.ts, which already exists and is the natural home. Drags 8 imports, theVarStripCombotype, and 4AppDombookkeeping fields with it.anchoredPopover(app.ts:1719–1762— a generic keyboard-owning overlay primitive currently living in the composition root even thoughpopover.tsexists) intosrc/ui/popover.ts. Then extract the save cluster (app.ts:1763–2003:flashSaved,commitLinkedQuery,saveVariableTab,saveActiveQuery,reloadSavedVersion,openConflictChooser,openSavePopover) →src/ui/workbench/save-controller.ts. Removes the last 6classListsites (updateSaveBtn).as Appcast (app.ts:236:const app = appBase as App;) with typed staged construction — sub-service factories that build and compose the finalAppobject field-by-field, so a forgotten member assignment becomes a compile error instead of a runtime hole. Pairs naturally with extractions 1–4: as members move out to their own modules, theAppinterface (app.types.ts:242–677) should shrink toward narrow capability ports (modules depending onPick<App, …>-shaped ports) rather than the current single 113-member bag.Smaller cleanup (bundle with whichever extraction touches it first)
keyboardOwnerChannelis copy-pasted identically three times:file-menu.ts:86–92,library-assign-menu.ts:22–30,dashboard.ts:285–291. Hoist it to one shared module.Tests
tests/unit/<module>.test.jsat the repo's coverage floors (100/95/90/100), not merely re-exported test coverage fromapp.ts's existing suite.surface-navigation.ts: routing/navigation behavior covered independent ofapp.ts, including the ordering invariant around the surface-generation guards (app.ts:309–328today).workspace-session.ts: cross-tab write/read, BroadcastChannel, refresh scheduling, andbeforeunloadguard covered directly; existingcross-tab-consistency.test.tscontinues to pass unmodified in behavior (may need import-path updates).variable-bar.ts:renderVarStrip/setRunBtnbehavior merges cleanly with the module's existing tests, no coverage regression on the file.popover.ts:anchoredPopoverpromoted with tests;save-controller.tscovers the save cluster (flash/commit/save/reload/conflict/open) independent ofapp.ts.keyboardOwnerChannelhoisted module has one test file instead of the behavior being asserted three times incidentally throughfile-menu/library-assign-menu/dashboardtests.npm testcoverage gate andtsc --noEmitpass after each extraction (can land as separate PRs/phases).Acceptance criteria
app.tsshrinks toward ≈1,800 lines.Appinterface (app.types.ts:242–677) shrinks — members that only the router, workspace-session, variable-bar, or save-controller need move to their narrower module's own types, rather than living in the shared 113-member bag.cross-tab-consistency.test.tsand existing app-level integration tests pass unmodified in intent.keyboardOwnerChannelexists in exactly one place.npm test(coverage gate) andtsc --noEmitpass at each step.Non-goals
AppDom/WorkbenchShellDepsforwarding pattern wholesale; this issue only removes members that no longer need to flow through it.app.tsresponsibility; four seams (plus the smallkeyboardOwnerChannelhoist) are the scope of this issue. Further decomposition can be filed separately if warranted.Related
docs/ADR-0004-ui-shell.md)