Skip to content

refactor(dashboard): extract the tile gesture controller and a pure repaint plan #589

Description

@BorisTyshkevich

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/dashboard.ts is 3,232 lines, of which renderDashboard (dashboard.ts:538–3232) is ONE closure containing 46 nested functions over 34 shared mutable let bindings — nothing inside is unit-testable in isolation, which is why the module's DOM-driven test file has grown to 6,494 lines. Extract two pieces of that closure into standalone, independently testable modules.

Context

The architecture around renderDashboard is already well-factored: pure math lives in core/tile-reorder.js and core/dashboard-autoscroll.js, and dashboard/layouts/* plus dashboard/application/* are properly layered with an enforced boundary test. This issue is extraction of already-identifiable seams out of the one oversized closure, not a rewrite of the surrounding architecture.

The shared mutable lets cluster cleanly enough to extract: only currentDoc (27 references) is genuinely cross-cutting across the whole closure; the rest belong to one of the two extractions below.

Two extractions

  1. Drag/resize gesture controller (~500 lines total):

    • wireTileDrag (dashboard.ts:1684–1998, 315 lines — the single largest function in the repo)
    • wireGridResize (dashboard.ts:1568–1682)
    • the gesture-flag state cluster: gestureActive, clickSuppressCard, reorderModifierHeld, autoScroll, scrollHost, owedScrollTop (dashboard.ts:1355–1419)

    src/ui/dashboard-tile-gestures.ts, exposing createTileGestureController({ runCommand, ... }) returning { wireTileDrag, wireGridResize, dispose }. Touches roughly 6 locals from the parent closure; the pure reorder/autoscroll math stays in core/ where it already lives — this extraction moves only the DOM/gesture wiring around it.

  2. Repaint arbitration: the ~220-line effect() callback at dashboard.ts:2834–3053 hand-maintains five memo signatures — lastLayoutSig (:2621), lastGridSig (:2729), barSig (:2789), lastOptionsSig (:2799), lastVariablePersistSig (:2833) — to decide what to repaint. Extract a pure function

    dashboardRepaintPlan(prev, next) => { rebuildBar, republishFlow, repaintGrid, persistVars }

    into src/dashboard/application/ (alongside the existing pure layout/application modules), leaving the effect callback as a thin caller that applies the plan. Include as an explicit test case the invariant currently documented only in a comment at dashboard.ts:2846–2858: never rebuild the variable bar on optionsRev alone — it would eat in-progress typing. That invariant deserves a real unit test, not just a comment.

Optional third extraction (do if time allows, file separately if not)

Tile chrome (dashboard.ts:1999–2348, ~350 lines: showTileMenu, runTileAction, removeTile, duplicateTile, widenTile — already backed by pure modules) → src/ui/dashboard-tile-chrome.ts.

Tests

  • dashboard-tile-gestures.ts: createTileGestureController unit-tested directly (drag start/move/end sequences, resize sequences, gesture-flag transitions) without needing the full renderDashboard DOM harness — this is the concrete payoff, replacing DOM-driven assertions with direct calls where possible.
  • dashboardRepaintPlan(prev, next): pure unit tests at 100/100/100/100 (it's a core/application-style pure function) covering each of the five memo signatures independently and in combination, including the optionsRev-must-not-rebuild-bar invariant as an explicit named test case.
  • Existing dashboard.ts test file shrinks correspondingly as gesture and repaint-decision assertions move to the new modules' direct unit tests instead of full-render DOM simulation; net test-file size should decrease even though total assertions don't.
  • The existing pure-module boundary test (that enforces core/dashboard/layouts/dashboard/application don't import DOM) is extended to cover the new dashboard/application/ repaint-plan module.
  • npm test (coverage gate) and tsc --noEmit pass.

Acceptance criteria

  1. wireTileDrag and wireGridResize no longer live inside renderDashboard's closure; they're callable and testable via createTileGestureController in dashboard-tile-gestures.ts.
  2. Repaint decisions (rebuildBar/republishFlow/repaintGrid/persistVars) are computed by a pure, directly-unit-tested dashboardRepaintPlan function, not inline inside the effect() callback.
  3. The optionsRev-must-not-rebuild-variable-bar invariant has a real test, not just a comment.
  4. renderDashboard's closure shrinks by roughly 500–700 lines (gesture controller + repaint plan, plus tile chrome if done).
  5. dashboard.ts's DOM-driven test file shrinks as a consequence of assertions moving to direct unit tests on the new modules.
  6. npm test (coverage gate) and tsc --noEmit pass.

Non-goals

  • Rewriting or redesigning drag/resize/repaint behavior — this is structural extraction of existing logic, no functional change.
  • Splitting dashboard-viewer-session.ts — already tracked by Split dashboard-viewer-session.ts: a 1857-line file whose 1354-line closure holds every concern #453; cross-reference it, don't duplicate the work here.
  • Extracting currentDoc or other genuinely cross-cutting closure state into a shared service — it stays in renderDashboard since it's used across both extractions and more.
  • The optional tile-chrome extraction is not required for this issue to be considered done; it may be filed/landed separately.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    refactorRestructuring without user-facing behavior changetech-debt

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions