Skip to content

refactor(#587): a side-panel registry so adding a navigation panel is a one-file change (phase 2) - #600

Merged
BorisTyshkevich merged 15 commits into
mainfrom
refactor/side-panel-registry-587
Aug 4, 2026
Merged

refactor(#587): a side-panel registry so adding a navigation panel is a one-file change (phase 2)#600
BorisTyshkevich merged 15 commits into
mainfrom
refactor/side-panel-registry-587

Conversation

@BorisTyshkevich

Copy link
Copy Markdown
Collaborator

What & why

Part of #593 (phase 2 of 8). Implements #587: replaces the hard-composed left sidebar with a
side-panel registry, so adding a navigation panel is a registry-plus-module change instead of
touching seven files.

Adapts the proven nav-sections.ts design salvaged from #487 phase 2 (branch
feat/nav-section-registry-487p2, PR #573, removed from main by the 2026-08-03 force-reset)
rather than re-deriving it — retaining all four decisions #587's AC6 names: icon-as-factory, a
separate accessibleLabel, pane-scoped exposure, and a persisted-key bridge decoded at the
state-load boundary.

Shape

Two modules, split by CLAUDE.md hard rule 2 rather than by preference:

  • src/core/side-panels.ts — one as const satisfies manifest (SIDE_PANELS: id, pane,
    persisted key). Every id/pane/key union elsewhere derives from it via typeof, so there is no
    second authority to drift. Also holds decodeSidePanelKey, the fail-closed load-boundary decoder.
    Pure: no DOM, no globals.
  • src/ui/side-panel-registry.ts — the DOM-owning half: persistent per-panel hosts built once
    and never rebuilt, a mount-once/activate-per-transition lifecycle (MountedSidePanel), pane-scoped
    showPanel, and one tab-row renderer shared by both panes.

app-shell.ts, sidebar-upper.ts and saved-history.ts now address panels only through the
registry. sidebar-upper.ts still builds the two upper bodies but no longer owns their tab-row
vocabulary (renderUpperRoleTabs is deleted); saved-history.ts no longer builds the lower tab row
at all. upperRole is now Signal<UpperPanelId>, derived from the manifest instead of a closed
hand-written union.

Behaviour-relevant changes

  • state.sidePanel decodes its stored value fail-closed at load. Before this PR there was no
    bridge at all — a raw, unvalidated localStorage read — so an unrecognised stored value silently
    painted the History body with neither tab visually active. This is the 'library' ↔ 'saved'
    bridge, and refactor(state): fail-closed decoders for persisted domain records #591 (phase 3) must not re-implement it.
  • Downgrade-safe by construction: the registry id 'library' is never itself persisted (Library
    writes exactly 'saved'), so a revert of this PR needs no data rollback. Asserted at the raw
    saveStr seam, not only on state.sidePanel.value.
  • app-preferences.ts's save is now generic over a PreferenceValues map, so
    prefs.save('sidePanel', 'library') is a compile error rather than a runtime discipline.
  • workbench-session.ts drops sidePanel from WorkbenchStateSlice entirely and renames
    WorkbenchHooks.renderSavedHistoryonRunComplete; the "only repaint History if it's active"
    decision moves out of the service module and into the registry.
  • AppDom loses savedList/savedSearch/savedTabsRow.
  • No visual redesign: all three .side-count adornments (Databases, Dashboards, Library) keep their
    existing visibility rules, via a generic tabAdornment capability rather than a shell branch.

Two acceptance criteria landed in an adapted form (deliberate, recorded in ADR-0004)

  1. AC5's "(one file)" is two files. src/application/ may not import src/ui/ — and
    check-boundaries.mjs:12 counts import type too — while pure logic belongs in src/core/.
    AC5's actual prohibition is met in full: adding a panel touches neither app-shell.ts,
    app-preferences.ts, state.ts, nor workbench-session.ts.
  2. Mount-once-per-shell, not once per activation. The issue's Tests wording ("mount/teardown runs
    exactly once per activation") contradicts the persistent-host decision AC6 makes binding. AC6 wins;
    switching panels changes visibility only and never destroys DOM.

AC4 landed literally — the generic save<K> above is what makes the persisted-value type derived
rather than hand-maintained.

Review history

Two independent plan reviews before any code was written; both found real defects, and one blocked the
original plan. The material correction: under the originally-planned "adopt the upper pane's hosts and
delegate exposure" design, adding an upper panel would still have required editing state.ts
(upperRole was a closed union) with labels and icons left in sidebar-upper.ts — breaking AC1 and
AC5. So both panes became genuinely registry-driven rather than half of one.

A post-implementation review then found a user-visible bug a fully green gate could not see:
showPanel was single-pass, so a History → Library switch rendered Library before History's
deactivate cleared the shared state.libraryFilter — Library painted filtered by text the user
never typed into it. The pinned "clears the filter when switching tabs" test only covered the
direction that worked by luck of registration order. showPanel is now two-pass
(deactivate-siblings-then-activate-target), matching the close-before-mount invariant #586 established
for the docked inspector, with a mirror test and an explicit ordering test that fails if it regresses.

Three other review findings fixed: a downgrade-safety test whose @ts-expect-error line still
executed the forbidden saveStr(…, 'library') write and never inspected the arguments;
activation-freshness proven only against injected fake panels, not the real Library/History defs; and
three comments asserting a "17 call sites" figure the implementation had made wrong (the real count is
10, recounted and corrected).

Filed separately

Checklist

  • npm test passes (the per-file coverage gate is non-negotiable)
  • Tests added/updated in the same change as the code
  • npm run build succeeds (single-file dist/sql.html)
  • Layers kept honest: pure logic in src/core/, network in src/net/ (injected fetch), DOM in src/ui/
  • No new runtime dependency (or it's a deliberate, justified addition — see CONTRIBUTING)
  • README / CHANGELOG.md ([Unreleased]) updated if behavior or the deployed surface changed
  • Reconciled affected tracked work (roadmap Roadmap to 1.0.0 #68, the issue body, ADR/CHANGELOG) if this change reshaped it

Full gate run explicitly (.npmrc sets ignore-scripts=true, so a green npm test alone does not
imply pretest ran): check:schemas, check:examples, check:arch, check:types, npm test,
npm run build — plus playwright --project=chromium --project=webkit.

Part of #593

BorisTyshkevich and others added 7 commits August 4, 2026 12:31
`core/side-panels.ts` is the one place a panel's id/pane/persisted-key is
declared (`SIDE_PANELS`, an `as const satisfies` manifest); every id/key
union elsewhere is derived from it via `typeof`, and the load-boundary
decoder (`decodeSidePanelKey`) fails closed to the Library panel for any
unrecognized `asb:sidePanel` value, including the registry's own id
`'library'` (never a persisted value).

`ui/side-panel-registry.ts` is the generic, DOM-owning half: persistent
per-panel hosts built once and never rebuilt, a mount-once/activate-per-
transition lifecycle (`MountedSidePanel`), pane-scoped `showPanel`, and one
tab-row renderer shared by both the upper and lower sidebar panes. Neither
module is wired into the app shell yet — that lands in the next commit,
alongside the migration of `state.ts`/`saved-history.ts`/`app-shell.ts`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
…ell, and the workbench

One atomic commit (R2.11 step 3) — narrowing state.sidePanel/state.upperRole,
the shell composition, saved-history.ts's panel conversion, the workbench's
onRunComplete hook, and every typed fixture all land together, since none of
them is independently green (e.g. narrowing sidePanel's type breaks
saved-history.ts's old switchTo(panel: string) mid-migration).

- state.ts: `sidePanel: Signal<SidePanelKey>` (decoded fail-closed via
  `decodeSidePanelKey` at the load boundary — this IS the 'library' <-> 'saved'
  bridge #587's context section describes; #591 must not re-do it, and the
  CHANGELOG/ADR entries say so). `upperRole: Signal<UpperPanelId>`, derived
  from the manifest rather than a hand-written union.
- app-preferences.ts: `PreferenceValues` makes `save` generic over its key
  (#587 AC4 met literally — `prefs.save('sidePanel', 'library')` is now a
  compile error, not just a runtime discipline).
- sidebar-upper.ts: drops the tab-row vocabulary it used to own
  (`renderUpperRoleTabs`, the `UpperRole` tab meta) — `databasesPanelDef`/
  `dashboardsPanelDef` hand the same label/icon/count facts to the registry
  instead. Still builds the two upper bodies (schema search+list wiring stays
  in app-shell.ts; the Dashboard search+tree wiring stays here) and now also
  owns `dashboardsPanelDef`'s `deactivate` (cancels a pending tree click) and
  `render` (repaints the tree) hooks.
- saved-history.ts: no longer builds a tab row or owns `app.dom.savedList`/
  `savedSearch`/`savedTabsRow` (deleted from `AppDom`). `libraryPanelDef`/
  `historyPanelDef` each build one persistent search+list host via
  `mountLowerPanel`, guarded by `ownsTheList` (`!host.hidden`) so a stale
  hidden panel's leftover search input can't rewrite the shared
  `state.libraryFilter` or repaint the other panel's list — shipped with its
  own regression test (sabotage-checked: deleting the guard turns it red).
  `renderSavedHistory(app)` survives as a thin compatibility export
  (`app.shell?.sidePanels.refreshActiveSidePanels()`), absorbing all but two
  of its 17 former call sites with zero further edits, and staying a safe
  no-op both before any shell mounts and after one is disposed (#587 R2.5).
- app-shell.ts: both the upper and lower tab rows render through the same
  generic `renderSidePanelTabs`, and `AppShellHandle.sidePanels` exposes the
  registry (with `refreshActiveSidePanels` wrapped to also repaint the
  Library tab's live count, since a star/delete/rename doesn't bump any
  signal the reactive effect depends on). `mainRow` keeps #586's
  `inspectorResize`/`inspectorHost` children unchanged.
- workbench-session.ts: `WorkbenchStateSlice` drops `sidePanel` entirely (#587
  AC3's stronger clause — this service now knows no panel id exists at all);
  `WorkbenchHooks.onRunComplete` replaces `renderSavedHistory` and fires
  unconditionally on a clean run, dispatch to the active panel handled
  entirely by the hook's own wiring in app.ts.
- app.ts: `app.shell` mirrors the `ensureShell`/`disposeShell` lifecycle so
  the registry is reachable (or safely null) from any module holding `app`;
  `app.recordHistory` and the workbench's `onRunComplete` hook both delegate
  to `app.shell?.sidePanels.notifyRunComplete()`.
- fake-app.ts (#587 R2.10): the `dom` fixture is now its own
  `satisfies AppDom` literal (`defaultDom`) built BEFORE spreading, so a field
  `AppDom` drops (as the three deleted here would have) is a compile error
  next time, not a silent rot only an `rg` sweep would catch.

Adapted acceptance criteria (recorded here, detailed in the phase report):
mount-once-per-shell lifecycle (AC6 wins over the issue's literal "per
activation" Tests wording, which contradicts persistent hosts); the
two-file registry split (`core/side-panels.ts` + `ui/side-panel-registry.ts`,
AC5's "one file" parenthetical).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
…, fix the e2e fixture

`.side-panel-host` mirrors `.upper-role-host`'s existing flex/hidden contract
(flex:1, min-height:0, column layout; hidden -> display:none) for the
Library/History hosts `side-panel-registry.ts` builds generically. Deliberately
NOT a rename of `.upper-role-host` itself — that class's hosts are addressed
directly by tests/e2e/dashboard-tree.spec.js (9 references to
`.upper-role-host[data-role=...]`), and reusing it verbatim for the lower pane
too would risk an unrelated selector collision for zero benefit over a second,
identically-shaped class.

tests/e2e/dashboard-membership.html no longer hands `renderSavedHistory` three
ad-hoc divs via `app.dom.savedTabsRow`/`savedSearch`/`savedList` (fields
`AppDom` no longer has) — it builds the same two-panel registry app-shell.ts
does and hands it hosts of its own choosing, since a `mount(host)` registry
owns which host a panel paints into and cannot honour externally-supplied
ones.

Verified in a real browser: `npx playwright test --project=chromium
--project=webkit` is 414 passed / 4 skipped / 0 failed, including
dashboard-membership.spec.js, sidebar-tabs-narrow.spec.js, and every
dashboard-tree.spec.js case that addresses `.upper-role-host`/`.upper-role-tabs`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
Sabotage-checked (see the phase report): commenting out app-shell.ts's
registry.dispose() call in mountAppShell's dispose() turns this red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
…legs

R2.10: an injected-fake-panel test alone proves a generic builder accepts
injected data, not that adding a REAL panel avoids app-shell.ts/
app-preferences.ts/state.ts/workbench-session.ts (types are erased at
runtime). Two more legs, alongside the runtime one already in
side-panel-registry.test.ts:

- Compile-time: app-preferences.test.ts's `@ts-expect-error` on
  `prefs.save('sidePanel', 'library')` — sabotage-checked by widening
  `AppPreferencesStateSlice['sidePanel']` back to `string`, which makes
  `tsc --noEmit` (a repo gate) report "Unused '@ts-expect-error' directive",
  then restored.
- Source-contract: side-panel-source-contract.test.ts reads app.ts,
  workbench-session.ts, app-preferences.ts, and state.ts (comments stripped)
  and asserts none of them contain a panel-id string compare or a hard-coded
  tab label. Sabotage-checked both named scenarios: reintroducing
  `sidePanel === 'history'`-shaped code into workbench-session.ts, and a
  hard-coded `'Databases'` literal into state.ts — both turned it red, then
  restored.

tests/types/node-fs-url.d.ts is a minimal ambient declaration for
`node:fs`/`node:url`/`node:path` (the repo carries no `@types/node`
devDependency — ADR-0002 is dev-time-only strict TS over the browser-shipped
source; tests/types/node-crypto.d.ts is the existing precedent for this
pattern).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
Dense-style [Unreleased] entry (the #586 entry is the model) covering the
manifest/registry split, the 'library' <-> 'saved' load-boundary bridge (so
#591 does not redo it), the generic-save AC4 compile-time contract, and the
WorkbenchHooks.onRunComplete rename. ADR-0004 gets a short addendum recording
that its shell-primitive investment now includes this second delivered
primitive, plus how AC4 (met literally) and AC5 (two files, not one) were
adapted and why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
…uard

Four review findings, all verified against the code before accepting:

- `showPanel` was a single pass over `entries`, so a switch rendered the
  incoming panel before an outgoing sibling's `deactivate` had run. Because
  `library` is registered before `history`, a History -> Library switch painted
  Library while `state.libraryFilter` still held History's search text: the
  Library search box showed leftover text and its list reported no matches for
  a query the user never typed there. Library -> History worked only by luck of
  registration order, and the pinned "clears the filter when switching tabs"
  test covered just that lucky direction. `showPanel` is now two-pass —
  deactivate every pane sibling, then activate/render the target — which is the
  same close-before-mount invariant #586 established for the docked inspector.
  Adds the missing mirror test plus an ordering test that fails if the two-pass
  structure is reverted.

- The downgrade-safety test performed the very write it claimed to forbid:
  `@ts-expect-error prefs.save('sidePanel', 'library')` suppresses only the
  TYPE error, so it still called `saveStr(KEYS.sidePanel, 'library')`, and the
  test's only assertion counted calls without inspecting arguments. The
  compile-time trap now lives in a function that is never invoked (tsc still
  checks an uncalled body), and the raw storage seam is asserted negatively.

- Activation freshness was proven only for injected fake panels. Adds two
  real-panel tests asserting rendered DOM: history recorded while History is
  hidden appears the moment it activates, and likewise for a Library mutation.

- Three comments claimed `renderSavedHistory` has "17 call sites" — a
  pre-implementation estimate the implementation invalidated. Recounted with
  `rg`: 10. Corrected in all three places.

Also fixes a self-contradiction in the ADR-0004 addendum, which said two
acceptance criteria were adapted and then described AC4 as met literally. AC4
did land literally; the two adapted items are AC5's two-file split and the
mount-once lifecycle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF

Copy link
Copy Markdown
Collaborator Author

ChatGPT review pass 1

Reviewed head: c830a9647fa7c1f32d267c8ade8dee1390eb9bc6

Major — adding a panel still requires editing app-shell.ts, and the source-contract test cannot catch it

src/ui/app-shell.ts:157-167 still imports every concrete panel definition and assembles the production registry with an explicit four-entry array:

const registry = buildSidePanelRegistry([
  databasesPanelDef(app, upper.databasesHost),
  dashboardsPanelDef(app, upper.dashboardsHost),
  libraryPanelDef(app),
  historyPanelDef(app),
]);

The adjacent comment even says a fifth panel means “adding one more def here.” That directly contradicts #587 AC5 and this PR's stated adaptation, both of which specifically prohibit touching app-shell.ts when adding a panel.

The new guard does not guard this invariant: tests/unit/side-panel-source-contract.test.ts:38-64 scans workbench-session.ts, app-preferences.ts, state.ts, and app.ts, but never scans app-shell.ts. It therefore stays green while the exact forbidden dependency is present.

Fix: move concrete production registration behind a stable registry-owned factory/configuration seam, so app-shell.ts calls one invariant API rather than importing each panel definition. Then make the source-contract test inspect app-shell.ts and fail on concrete panel-def imports or panel-specific registration vocabulary.

Major — UpperPanelId and LowerPanelId are hand-maintained allowlists, not manifest-derived unions

src/core/side-panels.ts:52-54 contains a second authority for pane membership:

export type SidePanelId = (typeof SIDE_PANELS)[number]['id'];
export type UpperPanelId = Extract<SidePanelId, 'databases' | 'dashboards'>;
export type LowerPanelId = Extract<SidePanelId, 'library' | 'history'>;

Adding a new manifest row does not add its id to either pane union. A new upper panel will still be rejected by state.upperRole; a new lower panel will still be rejected by sidePanelKeyFor/lowerIdForKey call sites until this literal list is edited. This contradicts the comments and PR claim that every id/pane union derives from SIDE_PANELS with no second authority.

tests/unit/side-panels.test.ts:24-46 does not expose the defect. Its “extended manifest” cases only pass a copied runtime array into lowerPanelIdsOf/sidePanelKeysOf; they never test the actual UpperPanelId or LowerPanelId types used by production code.

Fix: derive pane ids from the row union itself, for example:

type PanelSpec = (typeof SIDE_PANELS)[number];
type PanelIdInPane<P extends SidePanelPane> =
  Extract<PanelSpec, { pane: P }>['id'];

export type UpperPanelId = PanelIdInPane<'upper'>;
export type LowerPanelId = PanelIdInPane<'lower'>;

Add a compile-time assertion tied to the live manifest row union; the current fake-array runtime tests are not evidence that these production unions grow.

Minor — accessibleLabel is retained in the contract but discarded by the renderer

SidePanelDef/SidePanelEntry require and document accessibleLabel (src/ui/side-panel-registry.ts:54-58,87), but renderSidePanelTabs at :215-226 never puts it on the button. The test at tests/unit/side-panel-registry.test.ts:281-294 explicitly treats the structure as “accessibleLabel-independent” and only checks visible text and aria-pressed, despite #587 requiring expected accessible labels.

The four current visible labels happen to provide adequate accessible names, so this is not presently a broken-name bug. It is still a dead promised capability: the first panel whose visible label is insufficient will silently lose the separate accessible name.

Fix: set aria-label: entry.accessibleLabel in the generic renderer and assert the exact labels, or remove the field and revise the acceptance/design claim if it is intentionally unused.

Verification notes

I inspected the canonical 25-file PR diff, the exact-head files, base behavior, issue #587, and the relevant head history. The exact-head GitHub Actions CI run is green. I could not execute local focused tests because this review runtime could not resolve github.com for a clone; the findings above are direct source/contract contradictions and are not invalidated by the existing green gate.

Verdict: request changes. The first two findings defeat the central “one source of truth / no app-shell.ts edit” acceptance claim and are currently protected by tests that cannot fail on those regressions.

BorisTyshkevich and others added 3 commits August 4, 2026 14:33
…ssibleLabel

PR #600 review findings 1 and 3.

Finding 1 (MAJOR): app-shell.ts imported and listed all four concrete panel
defs directly, which is exactly what AC5 forbids ("adding a panel must not
touch app-shell.ts") — the file's own comment softened this to "never
touching this file's composition below", and side-panel-source-contract.test.ts
never checked app-shell.ts at all, so the violation stayed green. Moves the
production wiring into side-panel-registry.ts's new buildProductionSidePanelRegistry,
which is now the one place the four real defs are listed; app-shell.ts calls
it with only the two upper hosts it built and `app`, naming no concrete panel
def or id. Extracted the registry's type-only interfaces (MountedSidePanel/
SidePanelDef/SidePanelEntry/SidePanelRegistry) into a co-located
side-panel-registry.types.ts, since sidebar-upper.ts/saved-history.ts need
those types while side-panel-registry.ts now needs their concrete *PanelDef
factories at runtime — pointing both edges through side-panel-registry.ts
would be a real module-graph cycle. side-panel-registry.ts re-exports the
types verbatim so no existing importer changes. Extended
side-panel-source-contract.test.ts with a check that app-shell.ts names no
concrete panel-def symbol or panel-id literal.

Finding 3 (MINOR): renderSidePanelTabs never applied SidePanelDef's required,
documented accessibleLabel — only aria-pressed and visible text reached the
DOM, and the registry test explicitly dodged this ("accessibleLabel-independent
structure"). Now emits aria-label on each tab button; the test asserts the
exact strings (all four values satisfy WCAG 2.5.3 — each accessible name
contains its visible label).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
…column

PR #600 review finding 2 (MAJOR): UpperPanelId/LowerPanelId were hand-written
literal unions (Extract<SidePanelId, 'databases' | 'dashboards'>, etc.) — a
second authority listing the same ids by hand, so a new SIDE_PANELS row never
extended either automatically. The "extended manifest" tests only exercise
runtime helpers over copied arrays, never these two types, so the drift had
no test catching it.

Both types now derive from SIDE_PANELS's own `pane` column via a PanelIdInPane<P>
helper (Extract over the manifest's precise element-union type, narrowed by
`pane`), so a row's pane assignment is the only thing that decides which
union it joins.

Added tests/types/side-panels.test-d.ts — a compile-time-only assertion file
(tsconfig.json's `include` covers tests/types/**/*.ts, so `tsc --noEmit`
type-checks it; vitest's include glob is scoped to tests/unit/**/*.test.{js,ts}
so it never executes, matching the existing tests/types/state.test-d.ts
precedent) with two never-called functions whose bodies pin, against the live
manifest type: (1) every SidePanelId is assignable to UpperPanelId |
LowerPanelId (coverage), and (2) Extract<UpperPanelId, LowerPanelId> is never
(disjointness).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
…ked test

The previous commit's tests/types/side-panels.test-d.ts header claimed the
"uncalled function body is still type-checked" idiom was already relied on
elsewhere by this repo's check:arch/ADR-0002 tooling. Checked: build/check-
boundaries.mjs and tests/types/state.test-d.ts's own assertType helper (which
IS called, with real values) don't do this — no such precedent exists.
Rewords the comment to describe only what this file itself does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF

Copy link
Copy Markdown
Collaborator Author

ChatGPT review pass 2

Previously reviewed head: c830a9647fa7c1f32d267c8ade8dee1390eb9bc6

Reviewed head for this pass: c80b120afee7b6e3ef3b98c096a7938be74ff4d6

Major — pass-1 finding 1 was moved, not closed: a new upper panel is registered but never mounted into the shell DOM

buildProductionSidePanelRegistry is now the single production registration point (src/ui/side-panel-registry.ts:137-147), and the concrete *PanelDef imports did leave app-shell.ts. That part of the fix is real.

The acceptance failure remains one step later, though. src/ui/app-shell.ts:167-170 still builds the upper pane with exactly the two named legacy hosts:

const registry = buildProductionSidePanelRegistry(app, upper);
const schemaPane = h('div', ...,
  app.dom.upperRoleTabs, upper.databasesHost, upper.dashboardsHost);

By contrast, the lower pane immediately derives every host from registry.entries (app-shell.ts:175-176). If a fifth upper panel is added to buildProductionSidePanelRegistry, the generic registry will create/register its host, upperEntries will render its tab, and showPanel will unhide it — but the host is detached because schemaPane never appends it. Selecting that new tab therefore hides the old visible sibling and exposes a node that is not in the document.

The extended source-contract check does not constrain this. tests/unit/side-panel-source-contract.test.ts:66-80 rejects the four exact *PanelDef symbols and quoted id literals, but it deliberately misses the panel-specific .databasesHost / .dashboardsHost composition that causes the failure. Its title says the shell names no concrete panel, while those two concrete host names remain in executable composition code.

Fix: build the upper pane from registry entries exactly like the lower pane:

const upperHosts = registry.entries
  .filter((entry) => entry.pane === 'upper')
  .map((entry) => entry.host);

Append ...upperHosts, and add an app-shell integration assertion that every registered upper and lower entry host is connected. That test will fail when a newly registered host is omitted from composition; the current source regex cannot.

Major — the aria-label fix hides all three live counts from the accessible name

renderSidePanelTabs now sets a static aria-label on every button (src/ui/side-panel-registry.ts:157-169). The Databases, Dashboards, and Library definitions append dynamic .side-count descendants (src/ui/sidebar-upper.ts:94-126, src/ui/saved-history.ts:134-140).

For a button, an explicit aria-label replaces the name derived from descendant content. Before this fix, the accessible name included the visible count (Databases · 3, Dashboards · 0, Library · 12). At this head, assistive technology gets only Open Databases navigation, Open Dashboards navigation, or Open Library navigation; the count node remains visible but is hidden from the accessible name. WAI-ARIA's accessible-name guidance explicitly warns that aria-label on a button hides descendant content.

The tests split these concerns and therefore miss the regression: one test asserts the static aria-label, while another only asserts that the .side-count node exists in the DOM (tests/unit/side-panel-registry.test.ts:280-335). Neither checks the computed accessible name with an adornment present.

Fix: keep the dynamic adornment in the accessible name — for example with aria-labelledby referencing the visible label and count, or by composing the dynamic count into the explicit accessible name. Add a test where a counted tab's computed/declared accessible name includes both its label and · N.

Minor — the new type test cannot detect a regression back to the old hand-written unions at the current manifest

The production fix at src/core/side-panels.ts:67-69 is correct: UpperPanelId and LowerPanelId now derive from each row's pane.

But tests/types/side-panels.test-d.ts:29-45 only proves the resulting current unions cover and partition the four current ids. Replacing the derived aliases with the old literals today produces the exact same types, so check:types remains green. The reported sabotage added a fifth manifest row at the same time; that proves the assertion catches an already-drifted allowlist after expansion, not that it catches the derivation itself being reverted.

This makes the comment in src/core/side-panels.ts:58-66 — that the type test “pins this against regressing back to hand-written literals” — stronger than the test actually is.

Fix: add a source/AST contract that rejects literal panel-id allowlists in these aliases, or narrow the comments to the actual guarantee: coverage and disjointness of the current manifest-derived result.

Minor — one moved-wiring comment is stale

src/ui/saved-history.ts:7-8 still says app-shell.ts hands libraryPanelDef / historyPanelDef to buildSidePanelRegistry. At this head the shell does neither; buildProductionSidePanelRegistry owns that wiring.

Reassessment of the remaining pass-1 and full-PR areas

  • Pass-1 finding 2: code fix closed; pane id aliases are genuinely manifest-derived. The regression-test limitation above is separate.
  • Pass-1 finding 3: dead contract surface closed mechanically, but the implementation introduces the count accessibility regression above.
  • The .types.ts extraction removes the direct runtime registry↔panel-module cycle. The repository still has acknowledged type-only circular edges through app.types.ts / app-shell.ts, but those imports erase and I found no new runtime initialization defect from this change.
  • Rechecked the two-pass showPanel ordering, initial/already-active paths, real-panel activation freshness, ownsTheList, downgrade storage values, shared libraryFilter, visual .side-count rules, and run-complete dispatch. No additional confirmed defect found in those paths.
  • Exact-head GitHub Actions CI is green. I could not execute local focused tests because this runtime still cannot resolve github.com for a clone.

Verdict: request changes. The upper-pane composition still violates the central AC5 extensibility claim, and the accessibility fix removes existing count information for screen-reader users.

BorisTyshkevich and others added 2 commits August 4, 2026 15:02
…rrect side-panels.ts derivation comment

app-shell.ts composed schemaPane by naming upper.databasesHost/
upper.dashboardsHost directly, so a future upper panel registered only in
buildProductionSidePanelRegistry would get a tab-row entry but no route
into the document — the same host-by-name bug the lower pane's own
registry.entries-derived composition already avoided. Derive schemaPane's
children from registry.entries the same way, and add a runtime guard
(every registered entry's host must be document-connected after mount) plus
a source-level check rejecting a literal .databasesHost/.dashboardsHost
accessor in app-shell.ts.

Also correct side-panels.ts's own comment: tests/types/side-panels.test-d.ts
only proves UpperPanelId/LowerPanelId cover and partition TODAY'S manifest —
for the current four-row SIDE_PANELS, the derived unions and the old
hand-written Extract<SidePanelId, 'databases' | 'dashboards'> literals
produce identical types, so that test alone cannot catch a plain revert to
hand-written literals. Add the missing backstop: a source-level check in
side-panel-source-contract.test.ts that side-panels.ts declares no type
alias containing a literal panel-id string.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
… tab buttons

renderSidePanelTabs set aria-label={entry.accessibleLabel} on every tab
button, but the button's descendants already render the visible label plus
tabAdornment() (the live .side-count badge, e.g. "· 3"). An explicit
aria-label on a button REPLACES its content-derived accessible name, so
screen-reader users went from hearing "Databases · 3" to "Open Databases
navigation" — the live count silently dropped out of the accessible name.
That was a regression introduced by the previous pass's fix for a "dead
contract surface" finding, not a genuine improvement.

Remove the aria-label so the accessible name is content-derived again.
accessibleLabel stays on SidePanelDef/SidePanelEntry (#587 AC6) — its doc
comment now states its real consumer (a future icon-only presentation with
no visible text to derive a name from) and explicitly warns against
emitting it on a tab that already has a visible label plus a count. The
four exact accessibleLabel strings are still pinned, but at the
registry-meta level rather than on the DOM, and a new regression test
asserts a counted tab has no aria-label and that its text content includes
the count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF

Copy link
Copy Markdown
Collaborator Author

ChatGPT review pass 3

Previously reviewed head: c80b120afee7b6e3ef3b98c096a7938be74ff4d6

Reviewed head: 8eb774b12c050c67b887e1b2215b037d901a15f6

Pass-2 fix reassessment

  • Upper-host composition: closed in production. app-shell.ts now derives one upperEntries view from registry.entries, uses it both to append every upper host and to render the upper tab row, and the new host.isConnected test exercises every currently registered host in both panes.
  • Counted-tab accessible names: closed. renderSidePanelTabs no longer emits aria-label; the visible label and live .side-count remain descendant content, and the regression test asserts both the absence of the override and the count text.
  • Pane-union derivation: the production aliases are correctly derived from the manifest. The new source-level backstop still has a concrete syntax hole described below.

Major — the manifest is not actually an exhaustive authority for the production registry

src/core/side-panels.ts:37-42 declares the canonical id/pane/order manifest, and comments/types describe registry entries as being in manifest order. But src/ui/side-panel-registry.ts:137-148 does not build or validate production entries from SIDE_PANELS; it manually lists four factories in its own order. Each SidePanelDef also repeats an independently chosen id and pane (src/ui/side-panel-registry.types.ts:48-51). SIDE_PANELS is imported by the registry module only for re-export, not to constrain production construction.

The production test at tests/unit/side-panel-registry.test.ts:356-374 hard-codes the same four expected ids instead of comparing the registry's {id, pane} sequence with SIDE_PANELS. Therefore adding a manifest row but forgetting its production factory remains green. A wrong pane on a definition also compiles and is not checked against the manifest.

This is not only theoretical metadata drift. For a new lower row, decodeSidePanelKey and lowerIdForKey can accept/map its persisted key from the manifest while buildProductionSidePanelRegistry still omits that id; app-shell.ts then reaches registry.showPanel(id), whose entry() throws because the production map does not contain it. An upper omission silently produces no tab or connected host.

Fix: make the production factory exhaustive by id and derive its order from SIDE_PANELS—for example, an exhaustive Record<SidePanelId, factory> mapped in manifest order, using or validating the manifest pane. At minimum, add a test asserting the production registry's complete {id, pane} list equals SIDE_PANELS and that ids are unique.

Minor — the new literal-union source guard misses equivalent double-quoted aliases

tests/unit/side-panel-source-contract.test.ts:117-126 extracts type aliases but rejects only substrings such as 'databases'. This direct regression remains green:

export type UpperPanelId =
  Extract<SidePanelId, "databases" | "dashboards">;

The repository's scripts contain no lint/format gate that enforces single quotes, so the test does not fully enforce its stated “no literal panel-id string” contract. The same check should recognize both quote styles, or use an AST-level assertion. The adjacent comment also says multiline aliases escape the regex, but its negated character classes actually span newlines; that description should be corrected if the regex remains.

Full-PR verification

I re-walked the two-pass showPanel ordering, persistent-host refresh behavior, inactive-host ownsTheList guard, shared-filter clearing in both directions, downgrade-safe 'library'/'saved' storage bridge, three count visibility rules, clean-run dispatch, module/type-only seam, and disposal paths. I found no other confirmed regression at this head.

The exact-head GitHub Actions ci run completed successfully. I did not independently execute the caller-reported local and Playwright gates in this review environment.

Verdict: request changes; not yet mergeable. The current four entries happen to match, but the central future-panel contract is still unenforced, and the new source guard can still pass the exact class of literal-union regression it claims to catch.

BorisTyshkevich and others added 3 commits August 4, 2026 15:31
…fest

buildProductionSidePanelRegistry hard-coded four factory calls in an array
independent of SIDE_PANELS, so a manifest row added without extending that
array (or a def whose pane disagreed with its own manifest row) stayed
green — including a real runtime crash path through decodeSidePanelKey ->
showPanel -> registry `entry()`'s "unknown panel id" throw. Replace the
array with an exhaustive Record<SidePanelId, factory> (a missing key is now
a tsc error) and build the def list by mapping over SIDE_PANELS itself, so
order comes from the manifest. Add a parity test asserting the registry's
full {id, pane} sequence equals SIDE_PANELS' own sequence exactly, since the
exhaustive record proves every id has a factory but not that each factory
agrees with its manifest row.

SidePanelDef keeps its own id/pane fields rather than deriving them from the
manifest: buildSidePanelRegistry's generic seam (the AC5 runtime proof, the
dashboard-membership.html e2e fixture) takes an arbitrary injected def list
that isn't tied to SIDE_PANELS at all, so those fields can't be dropped
without narrowing that seam.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
The literal-panel-id allowlist check in side-panel-source-contract.test.ts
only checked for single-quoted literals (`'${id}'`), so a hand-written
Extract<SidePanelId, "databases" | "dashboards">-style regression using
double quotes stayed green. Check all three quoting styles TypeScript
allows for a string literal type: single quotes, double quotes, and
backticks.

Also correct that test's comment claiming "a multi-line type alias would
slip past this pattern" — false: the pattern's [^=]*/[^;]* are negated
character classes, which do match newlines, so a multi-line alias is
matched whole (verified against a literal sample). The pattern's real
blind spot is a generic parameter list with a default type argument (e.g.
`type Foo<T = SidePanelId> = ...`): the optional `<[^=]*>` group forbids
`=` inside the angle brackets, so the whole statement fails to match and
is silently dropped from consideration, rather than being matched but
unflagged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
Review round 4. Manifest uniqueness was assumed by every lookup and enforced
by nothing:

- `Record<SidePanelId, factory>` cannot catch a duplicate, because a TypeScript
  union collapses duplicates — a second manifest row reusing an existing id
  needs no additional key.
- The manifest-parity test cannot catch one either: a duplicate appears on BOTH
  sides of its comparison, so the sequences still match and it stays green. Its
  comment claimed the exact-equality check proved "no duplicates", which was
  false; corrected.

A duplicate id breaks the registry concretely rather than harmlessly: `byId`
keeps only the LAST entry; the initial normalize loop leaves BOTH hosts visible,
since each one's id equals its pane's default active id; and `showPanel` skips
every candidate whose id equals its target, so it can never hide the shadowed
sibling — a permanently double-rendered pane. Duplicate persisted keys are the
same shape of problem: `sidePanelKeyFor`, `decodeSidePanelKey` and
`lowerIdForKey` are all first-match `PANELS.find(...)`, so one row would become
unreachable.

`buildSidePanelRegistry` now throws on a duplicate def id at construction. That
check belongs there, not in the manifest, because the same seam accepts
arbitrary INJECTED defs (the AC5 fake-panel proof, the e2e fixture) which are
not manifest-backed at all. Three manifest invariants are asserted directly:
ids unique, defined persisted keys unique, and a persisted key present for
exactly the lower-pane rows — the last being what makes `sidePanelKeyFor`'s
non-null assertion sound.

The uniqueness assertions read `persistedKey` through `'persistedKey' in spec`
rather than off the union directly: `SIDE_PANELS` is `as const`, so its element
type is a union whose upper-pane members have no such property, and reading it
directly is a TS2339 error — the same reason side-panels.ts keeps a typed
`PANELS` view for its own lookups.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
@BorisTyshkevich
BorisTyshkevich merged commit b0ba89b into main Aug 4, 2026
8 checks passed
BorisTyshkevich added a commit that referenced this pull request Aug 4, 2026
… exact-head certification

Replace the review-heavy ship cycle with the redesigned one after PR #600
needed four review rounds whose findings were mostly unenforced invariants:

- plan step now requires a risk classification and, for medium/high risk,
  an invariant map (enforcement + proof + sabotage case per claim) and a
  root-cause circuit breaker that stops patch-chains during review;
- review budgets: at most one plan review and one targeted internal review,
  chosen not stacked; ChatGPT certification stops at the first clean
  exact-head pass (three passes are a failure ceiling, not a ritual), and
  nothing may be pushed after certification;
- attended mode keeps the human merge gate as the authority over contested
  findings; unattended keeps the strict automatic-merge proof;
- the local gate is now the explicit check chain (ignore-scripts=true means
  a green `npm test` alone was never the gate);
- repo-specific lessons move to references/repo-footguns.md instead of
  being dropped or inlined.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant