From 9f41eefb508d9adf63b9295fd971c1ef6d327756 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Mon, 3 Aug 2026 21:13:33 +0200 Subject: [PATCH 1/4] refactor(#586): SurfaceLifecycle primitive + docked right-inspector slot Replace six copy-pasted overlay lifecycles (cell-detail drawer, rows viewer, Reference pane) with one shared open/close/Escape/focus-restore primitive (src/ui/surface-lifecycle.ts), and give .main-row a real, shell-owned inspectorHost slot (app-shell.ts) as a layout sibling of queryHost/dashboardHost instead of three independent position:fixed overlays. inspector-host.ts owns the "one occupant at a time" singleton slot, mirroring dialog-shell.ts's existing openHandle pattern. Deletes isTopDrawer, the .cd-backdrop DOM probes/CSS, the 'docPane' splitter branch, and drawer.ts's per-surface stateKey plumbing. cellDrawerPx/docPanePx collapse into one rightInspectorPx preference with a compat read order and a single canonical write. Docked surfaces no longer acquire the modal keyboard owner (the pre-#586 modal cell drawer blocked every app shortcut while open), so app.ts's surface transition and sign-out teardown now close whichever surface currently occupies the shared dock, not just Reference. One deliberate behavior change: since the dock holds one occupant at a time, opening Cell while Rows is open now replaces Rows instead of stacking (tool registry/persistence is #488's scope, not this phase's). The one surviving non-docked case (a cell-detail drawer inside a real detached browser tab) keeps a self-contained overlay, renamed .cell-detail-overlay, still built on SurfaceLifecycle. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Da66KLYSmCey6Gi7RMFGcf --- CHANGELOG.md | 26 ++ src/application/app-preferences.ts | 9 +- src/state.ts | 52 ++-- src/styles.css | 106 ++++--- src/ui/app-shell.ts | 46 ++- src/ui/app.ts | 31 +- src/ui/app.types.ts | 6 + src/ui/dashboard.ts | 11 +- src/ui/doc-pane.ts | 133 +++++---- src/ui/drawer.ts | 93 +++--- src/ui/inspector-host.ts | 97 +++++++ src/ui/results.ts | 199 +++++++++---- src/ui/splitters.ts | 64 +++-- src/ui/surface-lifecycle.ts | 100 +++++++ tests/e2e/editor.html | 11 +- tests/helpers/fake-app.ts | 8 + tests/unit/app-preferences.test.ts | 3 +- tests/unit/app-shell.test.ts | 44 +++ tests/unit/app.test.ts | 9 + tests/unit/codemirror-adapter.test.ts | 13 +- tests/unit/dashboard.test.ts | 115 ++++---- tests/unit/doc-pane.test.ts | 90 +++++- tests/unit/drawer.test.ts | 68 +---- tests/unit/inspector-host.test.ts | 150 ++++++++++ tests/unit/results.test.ts | 394 ++++++++++++-------------- tests/unit/splitters.test.ts | 42 +-- tests/unit/state.test.ts | 37 ++- tests/unit/surface-lifecycle.test.ts | 163 +++++++++++ 28 files changed, 1445 insertions(+), 675 deletions(-) create mode 100644 src/ui/inspector-host.ts create mode 100644 src/ui/surface-lifecycle.ts create mode 100644 tests/unit/inspector-host.test.ts create mode 100644 tests/unit/surface-lifecycle.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aa06b645..f53ef934 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,32 @@ auto-generated per-PR notes; this file is the curated, human-readable history. as #586 (`SurfaceLifecycle` + docked right-inspector slot), #587 (side-panel registry), #588 (composition-root decomposition), and #589 (dashboard gesture/repaint extraction). +- **`SurfaceLifecycle` (`src/ui/surface-lifecycle.ts`) + a docked + `inspectorHost` slot** (#586, phase 1 of the #593 refactor umbrella). + `.main-row` (`app-shell.ts`) gains a real, shell-owned `inspectorHost` + + `inspectorResize` handle as layout siblings of `queryHost`/`dashboardHost` + — never a `position: fixed` overlay. The cell-detail drawer, rows viewer, + and Reference pane (`results.ts`/`doc-pane.ts`) all now dock into it + through the shared `SurfaceLifecycle` open/close/Escape/focus-restore + primitive and `inspector-host.ts`'s singleton-slot manager, replacing three + independent hand-rolled lifecycles (`isTopDrawer`, the `.cd-backdrop` DOM + probes/CSS, and the docs pane's own bespoke resize/keydown wiring — all + deleted). `cellDrawerPx`/`docPanePx` collapse into one `rightInspectorPx` + preference (compat read order: `rightInspectorPx` → `docPanePx` → + `cellDrawerPx` → 480px default; single canonical write). Docked surfaces + are now non-modal (no keyboard-owner acquisition — the pre-#586 modal cell + drawer blocked every app shortcut while open; this issue's docked model + fixes that), so `app.ts`'s Query↔Dashboard surface transition and + sign-out/connection-scope teardown now close whichever surface currently + occupies the shared dock (`closeInspector`), not just Reference. One + deliberate behavior change: since the dock holds only one occupant at a + time, opening Cell while Rows is showing now REPLACES Rows instead of + stacking a second panel on top of it (#488, the next phase, owns + tool-registry/tab persistence semantics; not in scope here). The one + surviving non-docked case — a cell-detail drawer opened inside a real + detached browser tab (`results.ts`'s Data Pane) — keeps a self-contained + modal overlay (renamed `.cell-detail-overlay`), still built on + `SurfaceLifecycle`. ### Changed - **The project wiki moved in-repo, as tracked `.wiki/`.** The maintainer/agent diff --git a/src/application/app-preferences.ts b/src/application/app-preferences.ts index f79e5b4d..ead84191 100644 --- a/src/application/app-preferences.ts +++ b/src/application/app-preferences.ts @@ -27,11 +27,12 @@ import { KEYS } from '../state.js'; * `save*` method on `App` (`saveJSON`/`saveVarValues`/`saveFilterActive`/…), * untouched by this service. */ export type PreferenceKey = - | 'theme' | 'sidebarPx' | 'editorPct' | 'sideSplitPct' | 'cellDrawerPx' + | 'theme' | 'sidebarPx' | 'editorPct' | 'sideSplitPct' | 'sidePanel' | 'resultRowLimit' - // #313 — the documentation pane's own persisted resize width, a sibling of - // cellDrawerPx (never shared with it — see splitters.ts's 'docPane' axis). - | 'docPanePx'; + // #586 — the single canonical docked right-inspector width, replacing the + // former cellDrawerPx/docPanePx pair (see splitters.ts's 'rightInspector' + // axis and state.ts's compat-read `rightInspectorPx` comment). + | 'rightInspectorPx'; /** The one state field this service reads/writes (`toggleTheme` only) — a * plain settable property, not a signal (matches `AppState.theme`). */ diff --git a/src/state.ts b/src/state.ts index 150de7f6..56e5e893 100644 --- a/src/state.ts +++ b/src/state.ts @@ -353,12 +353,19 @@ export interface AppState { sidebarPx: number; editorPct: number; sideSplitPct: number; - cellDrawerPx: number; - /** The docs pane's own persisted resize width (#313) — a sibling of - * `cellDrawerPx`, read/written only by the 'docPane' splitter axis - * (splitters.ts) and `attachDrawerResize`'s `stateKey: 'docPanePx'` option - * (drawer.ts); never shared with the cell-detail/rows-viewer drawer. */ - docPanePx: number; + /** + * The docked right-inspector's persisted width (#586) — one browser + * preference shared by every surface the shell mounts into `inspectorHost` + * (cell detail, rows viewer, Reference), replacing the two independent + * `cellDrawerPx`/`docPanePx` prefs each surface's own overlay used to read. + * Read/written only by the `'rightInspector'` splitter axis (splitters.ts) + * and app-shell.ts's own resize handle — never a per-surface key again. + * `createState`'s load is compatibility-ordered: a real `rightInspectorPx` + * wins, else a real `docPanePx`, else a real `cellDrawerPx` (both still + * read-only, never written again), else the default — so upgrading a + * browser that already had either old preference keeps it. + */ + rightInspectorPx: number; tabs: Signal; activeTabId: Signal; schema: Signal; @@ -478,6 +485,14 @@ export const KEYS = { sidebarPx: 'asb:sidebarPx', editorPct: 'asb:editorPct', sideSplitPct: 'asb:sideSplitPct', + /** #586 — the single canonical right-inspector width preference. Written + * only from app-shell.ts's shared resize handle / the detached cell-detail + * overlay's own drag handle (drawer.ts). */ + rightInspectorPx: 'asb:rightInspectorPx', + /** #586 — retained ONLY as compat-read sources for `rightInspectorPx` + * (`createState`'s load order below); never written again, and no longer + * `AppState` fields of their own. Their literal strings are still a + * persisted-data contract (#459) — do not rename them. */ cellDrawerPx: 'asb:cellDrawerPx', docPanePx: 'asb:docPanePx', sidePanel: 'asb:sidePanel', @@ -632,16 +647,21 @@ export function createState(read: StateReader = { loadJSON, loadStr }): AppState sidebarPx: clamp(parseInt(read.loadStr(KEYS.sidebarPx, '248'), 10), 180, 420), editorPct: num(KEYS.editorPct, 45, 15, 85), sideSplitPct: num(KEYS.sideSplitPct, 58, 25, 85), - // Cell-detail / rows-viewer drawer width (issue #101). The 92vw upper - // bound depends on the live viewport, not this load-time default, so only - // the floor is enforced here — clampDrawerWidth (splitters.js) applies the - // full [320, 92vw] clamp whenever the drawer is opened or resized. - cellDrawerPx: clamp(parseInt(read.loadStr(KEYS.cellDrawerPx, '560'), 10), 320, Infinity), - // The docs pane's own persisted width (#313) — same floor-only load-time - // clamp as cellDrawerPx above (clampDrawerWidth applies the full - // [320, 92vw] bound whenever the pane is opened/resized against the live - // viewport). - docPanePx: clamp(parseInt(read.loadStr(KEYS.docPanePx, '420'), 10), 320, Infinity), + // The docked right-inspector's width (#586). Compat read order: a real + // rightInspectorPx wins; else a real docPanePx (a pre-#586 Reference-pane + // width); else a real cellDrawerPx (a pre-#586 cell/rows drawer width); + // else the default (matches #488's RIGHT_INSPECTOR_DEFAULT_PX). The 92vw + // upper bound depends on the live viewport, not this load-time default, + // so only the floor is enforced here — clampDrawerWidth (splitters.ts) + // applies the full [320, 92vw] clamp whenever the inspector is opened or + // resized. + rightInspectorPx: clamp(parseInt( + read.loadStr(KEYS.rightInspectorPx, '') || + read.loadStr(KEYS.docPanePx, '') || + read.loadStr(KEYS.cellDrawerPx, '') || + '480', + 10, + ), 320, Infinity), // Reactive (signals): mutating these drives repaints via effects in // createApp — no manual refresh() list to keep in sync. Read/write through // `.value`. tabs/activeTabId drive renderTabs + the editor + the save button; diff --git a/src/styles.css b/src/styles.css index 4685b913..e86d63b0 100644 --- a/src/styles.css +++ b/src/styles.css @@ -862,6 +862,20 @@ h1, h2, h3, h4, h5, h6 { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; } .query-host[hidden], .dashboard-host[hidden] { display: none !important; } +/* #586: the docked right-inspector slot — a shell-owned layout SIBLING of + `.query-host`/`.dashboard-host` (never a `position: fixed` overlay), + replacing three independent body-mounted overlays (the cell-detail + drawer, the rows viewer, the Reference pane). Width is set inline + (app-shell.ts, from the persisted `rightInspectorPx` preference); folded + (`[hidden]`) consumes no layout width, same `[hidden]` override reasoning + as `.query-host`/`.dashboard-host` above. `.inspector-resize` is the + shared handle between the centre surface and the host (mirrors + `.col-resize` for the sidebar). */ +.inspector-host { + flex: 0 0 auto; display: flex; flex-direction: column; min-height: 0; + background: var(--bg-editor); border-left: 1px solid var(--border); +} +.inspector-host[hidden], .inspector-resize[hidden] { display: none !important; } .sidebar { display: flex; flex-direction: column; background: var(--bg-side); @@ -874,15 +888,21 @@ h1, h2, h3, h4, h5, h6 { the first consumer. */ container-type: inline-size; container-name: sidebar; } -.col-resize, .row-resize { +/* #586: `.inspector-resize` gets the SAME vertical-bar handle styling as + `.col-resize` (sidebar) via these grouped selectors — a DISTINCT class, + not a second class on the same element, so e2e specs' `page.locator + ('.col-resize')` (the sidebar's own handle) stays unambiguous rather than + resolving to two elements (a real regression only e2e caught — happy-dom + runs no real layout/selector-strictness check). */ +.col-resize, .row-resize, .inspector-resize { position: relative; flex-shrink: 0; z-index: 1; background: transparent; } -.col-resize { width: 7px; cursor: col-resize; } +.col-resize, .inspector-resize { width: 7px; cursor: col-resize; } .row-resize { height: 7px; cursor: row-resize; } -.col-resize::before, .row-resize::before, +.col-resize::before, .row-resize::before, .inspector-resize::before, .schema-detail-handle::before, .cd-resize-h::before { content: ''; position: absolute; pointer-events: none; background: var(--border); @@ -897,7 +917,7 @@ h1, h2, h3, h4, h5, h6 { states share one centre line. */ transition: transform 100ms ease, background-color 100ms ease; } -.col-resize::before, .cd-resize-h::before { +.col-resize::before, .inspector-resize::before, .cd-resize-h::before { top: 0; bottom: 0; left: 50%; width: 1px; transform: translateX(-50%) scaleX(1); } @@ -906,6 +926,7 @@ h1, h2, h3, h4, h5, h6 { height: 1px; transform: translateY(-50%) scaleY(1); } .col-resize:hover::before, .col-resize.dragging::before, +.inspector-resize:hover::before, .inspector-resize.dragging::before, .cd-resize-h:hover::before, .cd-resize-h.dragging::before { transform: translateX(-50%) scaleX(3); background: var(--accent); } @@ -2805,18 +2826,37 @@ table.res-table.fixed td .cell-val { max-width: 100%; } table.res-table tbody tr:hover td { background: var(--bg-hover); } table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } -/* Cell-detail drawer (click a result cell) */ -.cd-backdrop { position: fixed; inset: 0; z-index: 60; background: var(--scrim); display: flex; justify-content: flex-end; } -.cd-panel { - /* width is set inline (results.js attachDrawerResize) from the persisted - cellDrawerPx pref, clamped to [320, 92vw] — see clampDrawerWidth (#101) */ - height: 100%; position: relative; +/* Cell-detail / rows-viewer / Reference chrome (click a result cell; #101, + #313). #586 REWRITE: `.cd-panel`/`.docs-panel` used to each be their OWN + fixed-position overlay (`.cd-backdrop`'s flex-end wrapper for `.cd-panel`; + `.docs-panel` fixed to the viewport itself) — every docked surface now + fills the shared `.inspector-host` (app-shell.ts) as a normal flow child + instead, so both base rules below are DOCKED geometry (fill parent, no + shadow, no fixed position). `.cell-detail-overlay` (further down) restores + the OLD `.cd-backdrop` geometry for the one surviving non-docked case: a + cell-detail drawer opened inside a genuinely separate detached-tab + document (results.ts's `openCellDetail`, `opts.overlay`/`targetDoc`), + which has no shell/`inspectorHost` of its own to dock into. */ +.cd-panel, .docs-panel { + width: 100%; height: 100%; min-width: 0; min-height: 0; background: var(--bg-editor); - box-shadow: var(--shadow-drawer); display: flex; flex-direction: column; } -/* Left-edge drag handle that resizes the drawer (#101), straddling the panel's - border like table.res-table's .col-resize-h straddles a column's edge. */ +/* The one surviving non-docked case (see the block comment above): a real + modal backdrop, restoring `.cd-panel`'s pre-#586 floating geometry — + `attachDrawerResize` (drawer.ts) still sets its width inline from the + shared `rightInspectorPx` preference, clamped to [320, 92vw] + (clampDrawerWidth, #101). */ +.cell-detail-overlay { position: fixed; inset: 0; z-index: 60; background: var(--scrim); display: flex; justify-content: flex-end; } +.cell-detail-overlay .cd-panel { + width: auto; height: 100%; position: relative; + box-shadow: var(--shadow-drawer); +} +/* Left-edge drag handle that resizes the drawer (#101) — only ever appended + inside `.cell-detail-overlay .cd-panel` now (#586: every docked surface is + sized by app-shell.ts's own shared `.inspector-resize` handle instead), + straddling the panel's border like table.res-table's .col-resize-h + straddles a column's edge. */ .cd-resize-h { position: absolute; top: 0; left: 0; margin-left: -3px; z-index: 2; width: 6px; height: 100%; @@ -2838,19 +2878,10 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } [data-density='compact'] table.res-table td { padding: 4px 10px; } /* Documentation pane (#313): buildDrawerChrome's NON-modal chrome under its - own 'docs' prefix — persistent, no backdrop (unlike .cd-backdrop's cell - detail drawer), so it's positioned fixed to the viewport's right edge - itself rather than centered by a flex backdrop wrapper. Width is set - inline (attachDrawerResize's docPanePx stateKey) from the persisted - docPanePx pref, clamped to [320, 92vw] — see clampDrawerWidth (#101/#313). */ -.docs-panel { - position: fixed; top: 0; right: 0; bottom: 0; z-index: 55; - height: 100%; - background: var(--bg-editor); - border-left: 1px solid var(--border); - box-shadow: var(--shadow-drawer); - display: flex; flex-direction: column; -} + own 'docs' prefix — geometry comes entirely from the shared `.cd-panel, + .docs-panel` docked rule above now (#586: this pane was already + persistent/non-modal, so unifying it with the (formerly modal) cell + drawer's docked geometry was a pure simplification, no behavior change). */ .docs-head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--border); flex-shrink: 0; } .docs-title { flex: 1; min-width: 0; } .docs-title-text { font-weight: var(--fw-semibold); font-size: var(--text-body); color: var(--fg); } @@ -3165,8 +3196,12 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } .app-header .lib-name { max-width: 100%; padding: 0 3px; font-size: var(--text-label); } .app-header .hd-btn.user-btn { width: 26px; padding: 0 5px; } .app-header .hd-btn.user-btn .user-short { display: none; } - /* The desktop drawer handle owns this boundary. Restore a structural line - when mobile hides that non-touch resize affordance. */ + /* The desktop drawer handle owns this boundary (`.cd-resize-h`, hidden on + touch below). Restore a structural line on `.cd-panel` itself so the + surviving non-docked case (a cell-detail drawer opened inside a real + detached-tab document) still shows one — harmless/orthogonal on the + docked case too (`.inspector-host` already draws its own border-left, + so this never doubles a visible line there). */ .cd-panel { border-left: 1px solid var(--border); } /* ---- Bottom tab nav: one full-screen panel at a time ---- */ @@ -3277,7 +3312,7 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } /* No draggable splitters on touch — a hidden handle can't receive mousedown, so the splitter JS never wires up (nothing to disable in JS). */ - .col-resize, .row-resize, .side-split, + .col-resize, .row-resize, .side-split, .inspector-resize, .col-resize-h, .schema-detail-handle, .cd-resize-h { display: none !important; } /* Drag cue off (the schema rows drop `draggable` in mobile mode — schema.js). */ @@ -3295,10 +3330,15 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } .file-menu { width: auto; max-width: calc(100vw - 24px); } .cm-tooltip { max-width: calc(100vw - 16px); } .save-popover { max-width: calc(100vw - 16px); } - /* Cell-detail drawer → full-width, non-resizable (its handle is hidden above). */ - .cd-panel { width: 100vw !important; min-width: 0; } - /* Documentation pane (#313) → same full-width, non-resizable treatment. */ - .docs-panel { width: 100vw !important; min-width: 0; } + /* #586: the docked right-inspector becomes a full-screen, non-resizable + overlay when open on mobile (its handle is hidden above) — a mechanical + port of the pre-#586 per-surface `.cd-panel`/`.docs-panel` 100vw-fixed + mobile treatment onto the one shared host, not a redesign (mobile + presentation stays out of #586's scope). `[hidden]` still wins when + folded (the base rule above), so this only ever applies while open. */ + .inspector-host { + position: fixed; inset: 0; z-index: 60; width: 100vw !important; min-width: 0; + } } /* ── Dashboard (#149 D1 / #407 / #425) ────────────────────────────────────── diff --git a/src/ui/app-shell.ts b/src/ui/app-shell.ts index 506f7074..f2b052db 100644 --- a/src/ui/app-shell.ts +++ b/src/ui/app-shell.ts @@ -40,7 +40,7 @@ import { renderSavedHistory } from './saved-history.js'; import { renderLibraryTitle } from './file-menu.js'; import { applyConnectionStatus } from './app-header.js'; import type { DragCtx, DragRect, DragStartEvent, SplitterAxis } from './splitters.js'; -import { startDrag } from './splitters.js'; +import { startDrag, clampDrawerWidth } from './splitters.js'; import type { App } from './app.types.js'; import type { SchemaCatalogService } from '../application/schema-catalog-service.js'; import type { AppPreferences, PreferenceKey } from '../application/app-preferences.js'; @@ -143,17 +143,22 @@ export function mountAppShell(deps: AppShellDeps): AppShellHandle { const savedPane = h('div', { class: 'side-pane saved-pane', style: { flex: '1', minHeight: '0' } }, app.dom.savedTabsRow, app.dom.savedSearch, app.dom.savedList); const sidebar = h('div', { class: 'sidebar', style: { width: state.sidebarPx + 'px' } }); - // Only 'col' (sidebar width) and 'sideRow' (schema/saved split) run through - // this ctx — the editor/results 'row' splitter is workbench-shell's own, - // over elements this shell has no business touching (a Dashboard-only - // surface may one day mount here with neither `editorRegion` nor - // `resultsRegion` present at all). - const rectFor = (axis: SplitterAxis): DragRect => (axis === 'sideRow' ? sidebar.getBoundingClientRect() : {}); + // #586 — the docked right-inspector's own resize handle runs through this + // SAME ctx now (a third axis alongside 'col'/'sideRow'), sized against the + // live viewport width exactly like the former per-surface drawer handles + // (drawer.ts's attachDrawerResize) were — only shell-owned now, one handle + // for the one shared dock instead of one handle per surface. + const rectFor = (axis: SplitterAxis): DragRect => { + if (axis === 'sideRow') return sidebar.getBoundingClientRect(); + if (axis === 'rightInspector') return { width: (doc.defaultView || window).innerWidth }; + return {}; + }; const dragCtx: DragCtx = { state, rectFor, apply: (axis, value) => { if (axis === 'col') sidebar.style.width = value + 'px'; + else if (axis === 'rightInspector') inspectorHost.style.width = value + 'px'; else schemaPane.style.height = value + '%'; }, save: (name, value) => prefs.save(name as PreferenceKey, value), @@ -185,7 +190,32 @@ export function mountAppShell(deps: AppShellDeps): AppShellHandle { // toggles which of the two is exposed without rebuilding the sidebar (or the // query surface's own state) around them. const dashboardHost = h('div', { class: 'dashboard-host', hidden: true }); - const mainRow = h('div', { class: 'main-row' }, sidebar, sideHandle, queryHost, dashboardHost); + // #586 — the docked right-inspector slot: a shell-owned layout SIBLING of + // queryHost/dashboardHost (never a `position: fixed` overlay), replacing + // three independent body-mounted overlays (the cell-detail drawer, the + // rows viewer, the Reference pane). Content mounts here via + // `inspector-host.ts`'s `showInInspector`/`releaseInspector` — this shell + // owns only the host, the resize handle, and the fold (`hidden`) state; + // which surface currently occupies it is that module's job, not this + // one's. Starts folded (`hidden`) — nothing occupies it until a surface + // opens. `inspectorResize` sits between the centre surface and the host, + // like `sideHandle` does for the sidebar, driving the `'rightInspector'` + // splitter axis against the SAME `rightInspectorPx` preference every + // docked surface shares now (state.ts). + // clampDrawerWidth (not the raw persisted value): a monitor-to-monitor move + // can leave `rightInspectorPx` wider than 92vw of THIS window — the old + // per-surface drawers always re-clamped against the live viewport at open + // time (attachDrawerResize), and the docked host must too, or a narrow + // window opens with the panel wider than the screen the very first time. + const inspectorHost = app.dom.inspectorHost = h('div', { + class: 'inspector-host', hidden: true, + style: { width: clampDrawerWidth(state.rightInspectorPx, (doc.defaultView || window).innerWidth) + 'px' }, + }); + const inspectorResize = app.dom.inspectorResize = h('div', { + class: 'inspector-resize', hidden: true, + onmousedown: (e: DragStartEvent) => doStartDrag(e, 'rightInspector', dragCtx), + }); + const mainRow = h('div', { class: 'main-row' }, sidebar, sideHandle, queryHost, dashboardHost, inspectorResize, inspectorHost); // Mobile bottom-tab nav (#126): one full-screen panel at a time. CSS hides it // above the breakpoint; below it, `mainRow[data-mobile-view]` (set by the diff --git a/src/ui/app.ts b/src/ui/app.ts index b0df5c9f..fb3fd974 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -55,6 +55,7 @@ import type { SchemaLineageNode, DetachedGraphApp } from './explain-graph.js'; import { openDetailPane } from './schema-detail.js'; import type { NodeDetail, DetailNode } from './schema-detail.js'; import { openDocEntry, openDocDisambiguation, closeDocPane, isDocPaneOpen } from './doc-pane.js'; +import { closeInspector } from './inspector-host.js'; import { renderSavedHistory } from './saved-history.js'; import { applyFieldState, applyFieldWidth } from './var-field.js'; import { buildRelativeTimeField } from './relative-time-field.js'; @@ -245,7 +246,7 @@ export function createApp(env: CreateAppEnv = {}): App { // --- persistence ------------------------------------------------------- // The true-preference persist service (#276 Phase 4D) — theme/sidebarPx/ - // editorPct/sideSplitPct/cellDrawerPx/sidePanel/resultRowLimit, constructible + // editorPct/sideSplitPct/rightInspectorPx/sidePanel/resultRowLimit, constructible // without App/AppState/DOM. Consumers (saved-history.ts/splitters.ts) call `app.prefs.save(name, // value)` directly (#276 Phase 5 deleted the flat `App.savePref` delegate); // `toggleTheme` below composes `prefs.toggleTheme()` (the state-flip + @@ -711,7 +712,11 @@ export function createApp(env: CreateAppEnv = {}): App { // completion inert; query-bearing owners register their current ids. scope.register({ name: 'schema catalog', abort: () => catalog.invalidate() }); scope.register({ name: 'schema graph', abort: () => graph.suspend() }); - scope.register({ name: 'documentation pane', abort: () => closeDocPane(app) }); + // #586: whatever currently occupies the shared docked inspector (Cell, + // Rows, or Reference) — not just Reference — must not survive a + // connection-scope abort; `closeInspector` closes the current occupant + // generically, calling its own SurfaceLifecycle teardown. + scope.register({ name: 'docked inspector', abort: () => closeInspector(app) }); hideAuthenticationRequired(); }; app.requireAuthenticatedExecution = () => { @@ -761,9 +766,10 @@ export function createApp(env: CreateAppEnv = {}): App { exportService.cancelExport(); exportService.cancelExportScript(); catalog.invalidate(); - // #313: pane content must never survive a connection change — closed + // #313/#586: docked inspector content (Cell, Rows, or Reference — not + // just Reference) must never survive a connection change — closed // alongside the catalog reset, before the login screen renders. - closeDocPane(app); + closeInspector(app); conn.signOut(); // #425: explicit logout owns Dashboard teardown, the surface-generation // bump, and the main-surface reset through the full-screen login renderer. @@ -2159,12 +2165,17 @@ export function createApp(env: CreateAppEnv = {}): App { advanceSurfaceGeneration(); closeAnchoredPopovers(); disposeFileMenuOverlays(app); - // The doc pane mounts on `document.body`, so it would otherwise float over - // the surface that replaced the one it was opened from. (The cell-detail - // drawer is modal and traps the keyboard, so no surface control is reachable - // while it is open — and it owns a keyboard-owner release that only its own - // close path runs, which is why this does not reach in and remove it.) - closeDocPane(app); + // #586 REWRITE: this used to close ONLY the doc pane, on the reasoning + // that the cell-detail drawer/rows viewer were modal and keyboard-trapped + // — no surface control was reachable while either was open, so a surface + // transition could never happen underneath them. #586 docked all three + // (Cell, Rows, Reference) into ONE shell-owned `inspectorHost`, and none + // of them holds the modal keyboard owner anymore (a docked, non-modal + // panel must leave the rest of the app usable) — so the surface-switch + // control IS now reachable while any of them is open, and whichever one + // currently occupies the shared dock must be closed here, not just + // Reference. `closeInspector` is generic over the current occupant. + closeInspector(app); }; app.renderDashboard = () => { if (conn.isSignedIn() && !activeExecutionScope) app.resumeAuthenticatedExecution(); diff --git a/src/ui/app.types.ts b/src/ui/app.types.ts index 32942d94..b07a495e 100644 --- a/src/ui/app.types.ts +++ b/src/ui/app.types.ts @@ -115,6 +115,12 @@ export interface AppDom { dashboardSearchInput?: HTMLInputElement; qtabsInner?: HTMLElement; resultsRegion?: HTMLElement; + /** #586 — the shell-owned docked right-inspector slot (a layout sibling of + * `queryHost`/`dashboardHost` in app-shell.ts's `mainRow`) and its resize + * handle. Content mounts here via `inspector-host.ts`'s `showInInspector`/ + * `releaseInspector` — never `document.body` directly. */ + inspectorHost?: HTMLElement; + inspectorResize?: HTMLElement; runElapsedEl?: HTMLElement; savedList?: HTMLElement; savedSearch?: HTMLElement; diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index caae0ae4..959e2088 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -230,10 +230,13 @@ export interface DashboardApp { genId(): string; /** #303: persists the isolated per-dashboard variable store (`KEYS.dashFilters`). */ saveJSON(key: string, value: unknown): void; - /** #332: the shared cell-detail drawer's own resize persist (`openCellDetail` - * → `attachDrawerResize` reads `state.cellDrawerPx` + `prefs.save`). Declared - * here rather than relying purely on the `as ResultsApp` cast so a future - * narrower caller gets a compile-time signal, not a runtime crash. */ + /** #332: satisfies `ResultsApp`'s `prefs` member for the `as ResultsApp` + * cast `openCellDetail` is called through below. #586: the docked + * cell-detail path this surface always takes no longer calls + * `attachDrawerResize` (resize is shell-owned now, app-shell.ts), so + * `prefs.save` isn't actually exercised via that call anymore — kept here + * so a future narrower caller still gets a compile-time signal, not a + * runtime crash, rather than removing the field outright. */ prefs: Pick; } diff --git a/src/ui/doc-pane.ts b/src/ui/doc-pane.ts index f317d5de..c34098ec 100644 --- a/src/ui/doc-pane.ts +++ b/src/ui/doc-pane.ts @@ -5,17 +5,13 @@ // // Geometry/behavior (verbatim from #313's "Documentation pane" section): // - persistent, non-modal — no backdrop, no focus trap, the editor stays -// usable underneath it (unlike results.ts's cell-detail drawer, which -// composes buildDrawerChrome's SAME non-modal chrome with its own modal -// backdrop — see drawer.ts's header comment); +// usable underneath it; // - ONE pane instance per document — a new target replaces the current // content rather than opening a second pane; -// - bounded horizontal resize, via its OWN persisted width (`docPanePx`, -// state.ts) — never `cellDrawerPx` (the cell-detail drawer's width); // - `role="complementary"` with an accessible name; // - a close button, and Escape while focus is inside the pane — guarded so // it never ALSO fires shortcuts.ts's global Escape handling (see -// `ensurePane`'s keyHandler comment); +// `ensurePane`'s `openSurfaceLifecycle` call); // - closing restores focus to whatever triggered the open; // - distinct loading / found / missing / unavailable states, the last with // a Retry button — the catalog (schema-catalog-service.ts's `docEntry`) @@ -25,31 +21,41 @@ // `docEntry` again": a durable case re-resolves instantly from the // still-`unavailable` cache, a transient one gets a fresh attempt. // -// Deliberately NOT schema-detail.ts's bottom-docked fullscreen-graph pane -// geometry (#313: "Do not require the schema graph's bottom detail pane to -// share this geometry") — this is a right-side drawer built from -// buildDrawerChrome's non-modal chrome (drawer.ts) with a distinct 'docs' -// class prefix, so results.ts's `.cd-backdrop`-keyed `isTopDrawer` stays -// blind to it (there is no backdrop here at all). +// #586 REWRITE: this pane was ALREADY the best-behaved of the three (no +// backdrop, no modal keyboard trap, its own bounded resize) — the other two +// (results.ts's cell drawer/rows viewer) were the ones with the modal +// backdrop this header used to contrast itself against. #586 gave every +// surface ONE shared docked host (`app.dom.inspectorHost`, app-shell.ts) and +// ONE shared open/close/Escape/focus-restore primitive +// (`surface-lifecycle.ts`) instead of each hand-rolling its own — so this +// pane's own bespoke resize width (`docPanePx`) and bespoke keydown listener +// are gone: `ensurePane` now mounts through `inspector-host.ts`'s +// `showInInspector`, and Escape/focus-restore run through +// `openSurfaceLifecycle` (`escapePolicy: 'focus-inside'`, matching this +// pane's own pre-#586 behavior exactly — never a keyboard-owner acquisition, +// preserving its non-modal contract). Deliberately NOT schema-detail.ts's +// bottom-docked fullscreen-graph pane geometry (#313: "Do not require the +// schema graph's bottom detail pane to share this geometry"). import { h } from './dom.js'; import { Icon } from './icons.js'; -import { buildDrawerChrome, attachDrawerResize } from './drawer.js'; +import { buildDrawerChrome } from './drawer.js'; +import { openSurfaceLifecycle } from './surface-lifecycle.js'; +import type { SurfaceLifecycleHandle } from './surface-lifecycle.js'; +import { showInInspector, releaseInspector } from './inspector-host.js'; +import type { InspectorHostApp } from './inspector-host.js'; import { chLanguageExtension } from '../editor/ch-lang.js'; import type { CodeViewerFactory, CodeViewerHandle } from '../editor/code-viewer.types.js'; import type { AssembledReference } from '../core/completions.js'; import type { DocTarget, DocLookup, DocEntry, DocKind, DocSummary } from '../core/doc-types.js'; import { parseDocMarkdown, defaultDocLinkPolicy, latestDocUrlFromSource } from '../core/doc-markdown.js'; import { renderDocMarkdown } from './doc-markdown-view.js'; -import type { PreferenceKey } from '../application/app-preferences.js'; /** The narrow app surface this module reads — not the full ~50-member `App` * contract (app.types.ts). A real `App` satisfies this directly (its - * `state`/`prefs`/`catalog`/`CodeViewer` fields are strict supersets). */ -export interface DocPaneApp { + * `catalog`/`CodeViewer`/`dom` fields are strict supersets). */ +export interface DocPaneApp extends InspectorHostApp { document: Document; - state: { docPanePx: number }; - prefs: { save(name: PreferenceKey, value: unknown): void }; catalog: { docEntry(target: DocTarget): Promise>; /** #315 — name-only disambiguation across every kind sharing a name; @@ -113,17 +119,23 @@ type BackEntry = { kind: 'target'; target: DocTarget } | { kind: 'disambiguation interface PaneState { panel: HTMLElement; body: HTMLElement; - cancelResize: () => void; + /** This pane's `SurfaceLifecycle`-backed close() — `closeDocPane` funnels + * through it rather than tearing anything down itself now. */ + close: () => void; /** Bumped on every fresh lookup (open/retarget/retry) and on close — an * in-flight `docEntry` promise whose captured token no longer matches * this is stale and is dropped silently (never painted). */ token: number; + /** Read by the lifecycle's `returnFocusTo` resolver at CLOSE time (never + * captured once at open time) — every subsequent `openDocEntry`/ + * `openDocDisambiguation` call against the SAME still-open pane updates + * this, so focus always returns to whichever lookup most recently + * targeted it. */ initiator: Element | null; /** Every CodeViewer instance mounted into the current body content — * destroyed before the next render (retarget/state change) and on close, * so a stale CM6 view is never left listening/painted underneath. */ viewers: CodeViewerHandle[]; - keyHandler: (e: KeyboardEvent) => void; /** #314/#315 — the session-local back stack: each entry describes what was * ON SCREEN right before a related/alias/disambiguation navigation * replaced it (see `BackEntry`). Torn down wholesale with the rest of @@ -143,13 +155,6 @@ function destroyViewers(st: PaneState): void { st.viewers = []; } -/** - * Close (and fully tear down) the pane in `app.document`, if one is open — - * a no-op otherwise. Restores focus to whatever most recently triggered - * `openDocEntry`, when it's still connected and focusable. This is also the - * connection-change teardown hook: app.ts's `signOut` calls it alongside - * `catalog.invalidate()` so pane content never survives a reconnect/sign-out. - */ /** True when a documentation pane is currently open in `app.document` — * the global Escape shortcut (ui/shortcuts.ts) closes the pane FIRST, * before its cancel-running-query action, so Esc works from anywhere @@ -158,18 +163,17 @@ export function isDocPaneOpen(app: DocPaneApp): boolean { return panes.has(app.document); } +/** + * Close (and fully tear down) the pane in `app.document`, if one is open — + * a no-op otherwise. Restores focus to whatever most recently triggered + * `openDocEntry` (the `SurfaceLifecycle`'s `returnFocusTo` resolver), when + * it's still connected and focusable. This is also the connection-change + * teardown hook: app.ts's `signOut` calls it alongside `catalog.invalidate()` + * so pane content never survives a reconnect/sign-out. + */ export function closeDocPane(app: DocPaneApp): void { - const doc = app.document; - const st = panes.get(doc); - if (!st) return; - panes.delete(doc); - st.token++; // any lookup already in flight for this pane is now stale - st.cancelResize(); - destroyViewers(st); - doc.removeEventListener('keydown', st.keyHandler, true); - st.panel.remove(); - const initiator = st.initiator as (Element & { focus?: () => void }) | null; - if (initiator && initiator.isConnected && typeof initiator.focus === 'function') initiator.focus(); + const st = panes.get(app.document); + st?.close(); } function ensurePane(app: DocPaneApp, doc: Document): PaneState { @@ -177,42 +181,45 @@ function ensurePane(app: DocPaneApp, doc: Document): PaneState { if (existing) return existing; const body = h('div', { class: 'docs-body' }); - const close = (): void => closeDocPane(app); + let lifecycle: SurfaceLifecycleHandle; // assigned below, before close() can possibly fire const { panel } = buildDrawerChrome(doc, { classPrefix: 'docs', title: [h('span', { class: 'docs-title-text' }, 'Reference')], - onClose: close, + onClose: () => lifecycle.close(), }); panel.setAttribute('role', 'complementary'); panel.setAttribute('aria-label', 'Documentation'); panel.appendChild(body); - const cancelResize = attachDrawerResize(app, panel, doc, { - stateKey: 'docPanePx', axis: 'docPane', - }); - const st: PaneState = { - panel, body, cancelResize, token: 0, initiator: null, viewers: [], - keyHandler: () => {}, backStack: [], + panel, body, token: 0, initiator: null, viewers: [], backStack: [], + close: () => lifecycle.close(), }; - // Escape closes the pane ONLY while focus is inside it, and must never - // ALSO trigger shortcuts.ts's global `handleKeydown` (which cancels a - // running query on a plain Escape): preventDefault + stopPropagation, in - // the CAPTURE phase — matching results.ts's openCellDetail — so this - // fires before main.ts's bubble-phase global listener regardless of - // attachment order; `handleKeydown`'s own `if (e.defaultPrevented) return - // null` guard then skips it entirely. - st.keyHandler = (e: KeyboardEvent): void => { - if (e.key !== 'Escape') return; - if (!panel.contains(doc.activeElement)) return; - e.preventDefault(); - e.stopPropagation(); - close(); - }; - doc.addEventListener('keydown', st.keyHandler, true); - doc.body.appendChild(panel); - panes.set(doc, st); + lifecycle = openSurfaceLifecycle({ + document: doc, + // Escape closes the pane ONLY while focus is inside it — the pane is + // non-modal (no keyboard-owner acquisition, below), so it must never + // swallow an Escape meant for the editor/results elsewhere on the page. + escapePolicy: 'focus-inside', + panel, + // Deliberately NO acquireKeyboardOwner — Reference has never been modal + // (pre-#586 unchanged): the editor and results stay usable underneath it. + returnFocusTo: () => (st.initiator && (st.initiator as HTMLElement).isConnected ? (st.initiator as HTMLElement) : null), + onClose: () => { + st.token++; // any lookup already in flight for this pane is now stale + destroyViewers(st); + panes.delete(doc); + releaseInspector(app); + }, + }); + + // Only register this pane as "open" if it actually mounted — a caller with + // no shell (yet) mounted (`app.dom.inspectorHost` absent) gets an inert + // `PaneState` back rather than one `isDocPaneOpen`/`closeDocPane` believe + // is live: recording an occupant that never actually showed would leave + // that bookkeeping stuck reporting "open" for nothing anyone can see. + if (showInInspector(app, panel, () => lifecycle.close())) panes.set(doc, st); return st; } diff --git a/src/ui/drawer.ts b/src/ui/drawer.ts index 115fece5..2d6645df 100644 --- a/src/ui/drawer.ts +++ b/src/ui/drawer.ts @@ -1,11 +1,27 @@ // Shared right-side drawer chrome (#60, deferred from #101/#166's `.cd-*` -// scaffold in results.ts). This module owns exactly the NON-modal part of -// that scaffold: the panel/head/close-button DOM and the bounded horizontal -// resize handle. Modality — the backdrop, its click-outside close, Escape/ -// stacking order — is composed by each caller (results.ts's openCellDetail / -// openRowsViewer keep that themselves) so a persistent, non-modal consumer -// (a docs pane, #313) can reuse the same chrome without inheriting a -// backdrop or focus trap it doesn't want. +// scaffold in results.ts). +// +// #586 REWRITE: this module used to describe (and enforce) a deliberate +// three-independent-surface split — the cell-detail drawer, the rows viewer, +// and the Reference/docs pane each owned their OWN modality (backdrop, +// Escape, stacking order) and their OWN persisted resize width +// (`cellDrawerPx` vs `docPanePx`), composing only this file's NON-modal +// chrome (the panel/head/close-button DOM) in common. #586 replaced all three +// independent overlays with one shell-owned docked `inspectorHost` +// (app-shell.ts) — every surface's lifecycle (open/close/Escape/focus) now +// runs through the shared `surface-lifecycle.ts` primitive, and "which one +// occupies the shared dock" is `inspector-host.ts`'s job. This module keeps +// owning only what's still genuinely shared: `buildDrawerChrome` (the +// panel/head/close-button DOM, still built by every docked surface) and +// `attachDrawerResize` — which now survives ONLY for the one surface that +// still isn't docked: a cell-detail drawer opened inside a real detached +// browser tab (results.ts's Data Pane), which has no shell/`inspectorHost` of +// its own to be resized by app-shell.ts's shared handle. Every docked +// surface's OWN resize handle and its former per-surface `stateKey`/`axis` +// indirection (this module used to expose `{ stateKey: 'cellDrawerPx' | +// 'docPanePx' }`) are gone — the shared dock has exactly one width +// (`rightInspectorPx`, state.ts), owned by app-shell.ts's own resize handle, +// and this file's surviving consumer resizes against that SAME preference. import { h, withDocument } from './dom.js'; import { Icon } from './icons.js'; @@ -52,37 +68,29 @@ export function buildDrawerChrome(doc: Document, opts: DrawerChromeOptions): Dra }); } -/** The narrow app surface `attachDrawerResize` needs: the persisted drawer - * width (read on open, written mid-drag) and the preference-save seam — - * matches `ResultsApp`'s `state`/`prefs` members structurally, so results.ts - * passes its `ResultsApp` straight through. Both fields are optional so a - * caller only needs to carry whichever one its `stateKey` option (below) - * actually targets — the real `AppState` (state.ts) always has both - * (`cellDrawerPx`/`docPanePx`, #313), so no real caller ever hits the - * `undefined` branch; only a narrowly-typed test fixture (or a future - * third consumer) would omit the other key entirely. */ +/** The narrow app surface `attachDrawerResize` needs: the persisted + * right-inspector width (read on open, written mid-drag) and the + * preference-save seam — matches `ResultsApp`'s `state`/`prefs` members + * structurally, so results.ts passes its `ResultsApp` straight through. */ export interface DrawerResizeApp { - state: { cellDrawerPx?: number; docPanePx?: number }; + state: { rightInspectorPx?: number }; prefs: { save(name: PreferenceKey, value: unknown): void }; } -/** `attachDrawerResize`'s options (#313): which persisted-width field this - * drawer instance reads/writes, and which `splitters.ts` axis drives its - * geometry. Defaults to the original cell-detail/rows-viewer drawer - * (`'cellDrawerPx'` / `'drawer'`) — every existing caller (results.ts) omits - * this entirely and keeps byte-identical behavior. The docs pane (#313) - * passes `{ stateKey: 'docPanePx', axis: 'docPane' }` so its own resize drag - * never reads or persists the cell-detail drawer's width, and vice versa. */ -export interface DrawerResizeOptions { - stateKey?: 'cellDrawerPx' | 'docPanePx'; - axis?: SplitterAxis; -} - /** * Wire the left-edge drag handle that resizes a drawer panel (#101), via - * splitters.js's drag controller (the 'drawer' axis alongside 'col'/ - * 'sideRow'/'row'). Sets the initial width from the persisted `cellDrawerPx` - * pref, clamped to the current viewport, and appends the handle to `panel`. + * splitters.ts's drag controller (the `'rightInspector'` axis alongside + * 'col'/'sideRow'/'row'). Sets the initial width from the persisted + * `rightInspectorPx` pref, clamped to the current viewport, and appends the + * handle to `panel`. + * + * #586: every DOCKED surface (cell detail, rows viewer, Reference) now + * resizes via app-shell.ts's own shared handle on `inspectorHost` instead — + * this function survives only for the one surface that isn't docked: a + * cell-detail drawer opened inside a real detached browser tab (results.ts's + * Data Pane), which has no shell of its own for a shared handle to belong to. + * It resizes against the SAME `rightInspectorPx` preference the dock uses + * (there is only one right-inspector width now, not a per-surface one). * * A resize drag that ends with the mouse over a modal caller's backdrop no * longer needs a dedicated swallow-listener here: a caller using @@ -97,30 +105,23 @@ export interface DrawerResizeOptions { * gone, so a later unrelated mouseup would still persist a stale width. The * caller's close must call this before removing the panel. A no-op if no * drag is in progress. - * - * `opts.stateKey`/`opts.axis` (#313) pick which persisted-width field and - * `splitters.ts` axis this instance uses — defaulting to the original - * `'cellDrawerPx'`/`'drawer'` pair, so every pre-#313 caller is unaffected. */ -export function attachDrawerResize( - app: DrawerResizeApp, panel: HTMLElement, doc: Document, opts: DrawerResizeOptions = {}, -): () => void { - const key = opts.stateKey || 'cellDrawerPx'; - const axis: SplitterAxis = opts.axis || 'drawer'; +export function attachDrawerResize(app: DrawerResizeApp, panel: HTMLElement, doc: Document): () => void { // doc.defaultView is null for a detached document not yet attached to a real // browsing context (e.g. tests' document.implementation.createHTMLDocument()); // a real detached tab (window.open()) always has one. Fall back to the // ambient window rather than crash on the (harmless) synthetic-doc case. const win = doc.defaultView || window; - // `!`: the real AppState (state.ts) always has both cellDrawerPx and - // docPanePx — every production caller's `key` resolves to a real number. - panel.style.width = clampDrawerWidth(app.state[key]!, win.innerWidth) + 'px'; + // `!`: the real AppState (state.ts) always has rightInspectorPx — every + // production caller resolves to a real number. + panel.style.width = clampDrawerWidth(app.state.rightInspectorPx!, win.innerWidth) + 'px'; let cancelActive: (() => void) | null = null; + const axis: SplitterAxis = 'rightInspector'; const handle = h('div', { class: 'cd-resize-h', title: 'Drag to resize', onmousedown: (ev: MouseEvent) => { - const startPx = app.state[key]!; + const startPx = app.state.rightInspectorPx!; const stopDrag = startDrag( // `as Element`: this handler is only ever reached via a real // `mousedown` dispatched on `handle` itself (the listener target), @@ -136,7 +137,7 @@ export function attachDrawerResize( save: (name, value) => app.prefs.save(name as PreferenceKey, value), }, ); - cancelActive = () => { stopDrag(); app.state[key] = startPx; cancelActive = null; }; + cancelActive = () => { stopDrag(); app.state.rightInspectorPx = startPx; cancelActive = null; }; }, }); panel.appendChild(handle); diff --git a/src/ui/inspector-host.ts b/src/ui/inspector-host.ts new file mode 100644 index 00000000..9d92b798 --- /dev/null +++ b/src/ui/inspector-host.ts @@ -0,0 +1,97 @@ +// The shared docked right-inspector slot (#586): `app-shell.ts`'s `mainRow` +// mounts exactly ONE `inspectorHost` per shell, a layout sibling of +// `queryHost`/`dashboardHost` — replacing three independent `position: fixed` +// body-mounted overlays (the cell-detail drawer, the rows viewer, and the +// Reference documentation pane, results.ts/doc-pane.ts) that each used to +// manage their own visibility. +// +// Because there is exactly one physical host, only one of {cell, rows, +// reference} can occupy it at a time: `showInInspector` force-closes +// whatever currently occupies it before mounting new content. Occupancy is +// tracked per HOST ELEMENT (a `WeakMap`, not one bare module +// global) — a real app only ever mounts one shell/host, but this keeps a +// second shell instance (a second `App`/document, as several fixtures build +// side by side in tests) from cross-talking through module state, the same +// reason `doc-pane.ts`'s own pane registry is a `WeakMap` rather +// than a single slot. This is a deliberate, narrower primitive than #488's +// future tool registry: it knows nothing about tool identity, tabs, or +// preserving inactive-tool state across a switch — opening a new occupant +// DESTROYS whatever was there (via that occupant's own `SurfaceLifecycle` +// close()). #488 layers tool selection/persistence on top of this; #586 owes +// only the shared dock. + +/** The narrow app surface this module reads — the two shell-owned nodes + * `app-shell.ts` mounts as `mainRow` siblings. Optional (matching + * `AppDom`'s own convention for every render-target field — `results.ts`'s + * `resultsRegion` is the same shape): a real shell always sets both + * synchronously at mount, before any surface can call into this module, but + * the type never assumes it. */ +export interface InspectorHostApp { + dom: { + inspectorHost?: HTMLElement; + inspectorResize?: HTMLElement; + }; +} + +/** The current occupant's own close(), keyed by `inspectorHost` — the same + * "force-close the previous one before a new one opens" pattern + * `dialog-shell.ts`'s module-local `openHandle` uses for modal dialogs, + * scoped per host so independent shells never interfere. */ +const currentClose = new WeakMap void>(); + +/** True while some content currently occupies `app`'s inspector. */ +export function isInspectorOpen(app: InspectorHostApp): boolean { + return !!app.dom.inspectorHost && currentClose.has(app.dom.inspectorHost); +} + +/** Force-close whatever currently occupies `app`'s inspector. A no-op when + * the inspector is already folded (nothing to close) or the shell hasn't + * mounted a host at all. The occupant's own `SurfaceLifecycle`-backed + * `close()` runs, which in turn calls `releaseInspector` below to actually + * fold the host — this function never touches the DOM itself. */ +export function closeInspector(app: InspectorHostApp): void { + if (!app.dom.inspectorHost) return; + currentClose.get(app.dom.inspectorHost)?.(); +} + +/** + * Mount `content` into the inspector, unfolding it — force-closing any + * current occupant first. `close` is the new occupant's own lifecycle + * `close()`, recorded so a LATER occupant can force this one out via + * `closeInspector`/a fresh `showInInspector` call. Returns whether it + * actually mounted — `false` when no shell has mounted a host (never true + * once a real app is running). A caller that registers its own "is this + * surface open" bookkeeping (doc-pane.ts's `panes` map) MUST check this + * before registering: recording an occupant that never actually mounted + * would leave that bookkeeping permanently stuck reporting "open" for a + * surface nothing ever showed. + */ +export function showInInspector(app: InspectorHostApp, content: Element, close: () => void): boolean { + const { inspectorHost, inspectorResize } = app.dom; + if (!inspectorHost || !inspectorResize) return false; + closeInspector(app); + inspectorHost.replaceChildren(content); + inspectorHost.hidden = false; + inspectorResize.hidden = false; + currentClose.set(inspectorHost, close); + return true; +} + +/** + * The occupant's own teardown (its `SurfaceLifecycle`'s `onClose`) calls this + * exactly once to actually fold the host — clears its content and re-hides + * both nodes, consuming no layout width (mirrors `showHost`'s `hidden` + * pattern, app-shell.ts). Only ever reachable while the caller IS the current + * occupant: `showInInspector` always runs `closeInspector` (which runs this, + * via the outgoing occupant's own idempotent `close()`) BEFORE mounting the + * new content, so a fresh occupant's `hidden = false` always lands after — + * never clobbered by — an outgoing occupant's teardown. + */ +export function releaseInspector(app: InspectorHostApp): void { + const { inspectorHost, inspectorResize } = app.dom; + if (!inspectorHost) return; + currentClose.delete(inspectorHost); + inspectorHost.hidden = true; + if (inspectorResize) inspectorResize.hidden = true; + inspectorHost.replaceChildren(); +} diff --git a/src/ui/results.ts b/src/ui/results.ts index 5bdce953..93d14bdf 100644 --- a/src/ui/results.ts +++ b/src/ui/results.ts @@ -31,6 +31,9 @@ import type { DetachedView, DetachedViewApp, DetachedWindowLike, MountCtx } from import { buildVariableBar } from './variable-bar.js'; import type { VariableBarApp } from './variable-bar.js'; import { buildDrawerChrome, attachDrawerResize } from './drawer.js'; +import { openSurfaceLifecycle } from './surface-lifecycle.js'; +import type { SurfaceLifecycleHandle } from './surface-lifecycle.js'; +import { showInInspector, releaseInspector } from './inspector-host.js'; import { panelExecution } from '../core/panel-execution.js'; import type { AppDom, App, KeyboardOwner } from './app.types.js'; import type { PanelResolution } from '../core/panel-cfg.js'; @@ -475,35 +478,48 @@ export interface RowsViewerEntry { } /** - * Open a right-side pane with the full rows of one script SELECT, using the same - * sortable + resizable grid as the main results table (renderGridView). Sort state and - * column widths are local to this pane; clicking a cell opens its value (the same - * cell-detail drawer, stacked). Reuses the .cd-* drawer scaffold (a shared Drawer - * primitive is deferred to #60). Escape / backdrop / ✕ closes. Exported for tests. + * Open the docked right-inspector (`app.dom.inspectorHost`, #586) with the + * full rows of one script SELECT, using the same sortable + resizable grid as + * the main results table (renderGridView). Sort state and column widths are + * local to this pane; clicking a cell opens its value in the SAME shared + * dock (#586 — the docked model has room for exactly one occupant, so this + * REPLACES the rows viewer rather than stacking a second panel on top of it, + * unlike the pre-#586 stacked-backdrop behavior). Built on the shared + * `openSurfaceLifecycle` primitive (`escapePolicy: 'always'` — the docked + * model has no "topmost of several" case left to scope Escape against) and + * `inspector-host.ts`'s singleton dock. Exported for tests. */ export function openRowsViewer(app: ResultsApp, entry: RowsViewerEntry): HTMLElement { const doc = app.document; - const releaseKeyboard = app.acquireKeyboardOwner('modal'); - let backdrop: HTMLElement; - let cancelDrawerDrag: () => void; // assigned by attachDrawerResize below, before close() can possibly fire - let detachBackdrop: () => void; - const onKey = (ev: KeyboardEvent): void => { - if (ev.key === 'Escape' && isTopDrawer(doc, backdrop)) { ev.preventDefault(); close(); } - }; - function close(): void { - cancelDrawerDrag(); - detachBackdrop(); - if (backdrop) backdrop.remove(); - doc.removeEventListener('keydown', onKey, true); - releaseKeyboard(); - } + const initiator = doc.activeElement as HTMLElement | null; + let lifecycle: SurfaceLifecycleHandle; // assigned below, before close() can possibly fire const n = entry.rows.length; const { panel } = buildDrawerChrome(doc, { title: [ h('span', { class: 'cd-name' }, 'Result rows'), h('span', { class: 'cd-type' }, n + (entry.truncated ? '+' : '') + ' row' + (n === 1 ? '' : 's')), ], - onClose: close, + onClose: () => lifecycle.close(), + }); + lifecycle = openSurfaceLifecycle({ + document: doc, + escapePolicy: 'always', + panel, + // Deliberately NO acquireKeyboardOwner — the docked model is non-modal + // (#488's target contract this issue preps for: "no inspector tool + // creates a backdrop, covers the centre surface, or traps focus"), and + // `shortcuts.ts`'s global dispatcher exits immediately whenever ANY + // keyboard owner is held, disabling Run/Save/format/navigation for the + // whole app. The pre-#586 modal drawer legitimately held it (a real + // backdrop trapped focus); a docked panel must not. + // A resolver (not the captured element itself, #586's SurfaceLifecycle + // contract) — a grid re-render between open and close can detach the + // original initiator; `isConnected` catches that rather than focusing a + // dead node. Pre-#586 neither the cell drawer nor the rows viewer + // restored focus at all — this is new, correct behavior the shared + // primitive gives every docked surface uniformly. + returnFocusTo: () => (initiator && initiator.isConnected ? initiator : null), + onClose: () => releaseInspector(app), }); // Local sort + width state (persist for the lifetime of this open via the entry). entry.viewerSort = entry.viewerSort || { col: null, dir: 'asc' }; @@ -521,12 +537,8 @@ export function openRowsViewer(app: ResultsApp, entry: RowsViewerEntry): HTMLEle })); paint(); panel.appendChild(body); - cancelDrawerDrag = attachDrawerResize(app, panel, doc); - backdrop = h('div', { class: 'cd-backdrop' }, panel); - detachBackdrop = attachBackdropClose(backdrop, close); - doc.body.appendChild(backdrop); - doc.addEventListener('keydown', onKey, true); - return backdrop; + showInInspector(app, panel, () => lifecycle.close()); + return panel; } /** @@ -966,7 +978,15 @@ export function expandDataPane(app: ResultsApp, r: QueryResult): DetachedView { setSort: (next) => { sort = next; }, widths, rerender: () => paint(res), - onCell: (name, type, value) => openCellDetail(app, name, type, value, doc), + // #586: this Data Pane is itself a self-contained detached/ + // fullscreen view (a real tab, or detached-view.ts's own overlay + // fallback mounted inside app.document when window.open is + // blocked) — either way it already covers the viewport with its + // own overlay, so a nested cell click keeps the pre-#586 + // self-contained overlay explicitly (`overlay: true`) rather than + // docking invisibly behind it (`doc === app.document` alone can't + // tell the two cases apart — see OpenCellDetailOptions). + onCell: (name, type, value) => openCellDetail(app, name, type, value, doc, { overlay: true }), cap: visCap(res), panel: { mode: 'readonly', @@ -1146,11 +1166,14 @@ export function expandDataPane(app: ResultsApp, r: QueryResult): DetachedView { body.appendChild(pane); paint(); - // Esc closes an open cell-detail drawer first (its own listener, keyed - // off isTopDrawer, handles that); a second Esc — no drawer left — closes - // the pane (overlay only; a real tab closes via the browser). + // Esc closes an open cell-detail overlay first (openCellDetail's own + // SurfaceLifecycle-backed listener handles that — #586: this Data Pane + // always forces `{ overlay: true }` on its nested cell clicks, below, + // so it stays `.cell-detail-overlay`, never the docked inspector); a + // second Esc — no overlay left — closes the pane (overlay only; a real + // tab closes via the browser). const onKey = (e: KeyboardEvent): void => { - if (e.key !== 'Escape' || doc.querySelector('.cd-backdrop')) return; + if (e.key !== 'Escape' || doc.querySelector('.cell-detail-overlay')) return; e.stopPropagation(); close(); }; @@ -1171,36 +1194,53 @@ export function expandDataPane(app: ResultsApp, r: QueryResult): DetachedView { }); } -/** - * Open a right-side drawer with one cell's full value: pretty-printed (JSON is - * reindented), and for HTML a Rendered (sandboxed iframe) ↔ Source toggle. - * Escape or a backdrop/✕ click closes it. Exported for tests. - */ -// Only the topmost drawer responds to Escape, so dismissing a stacked cell drawer -// returns to the rows pane underneath instead of closing both at once. (The -// current backdrop is always in the DOM when its handler fires.) -function isTopDrawer(doc: Document, el: Element | undefined): boolean { - const all = doc.querySelectorAll('.cd-backdrop'); - return all[all.length - 1] === el; +/** `openCellDetail`'s options bag (#586). */ +export interface OpenCellDetailOptions { + /** + * Force the legacy, self-contained overlay even though `targetDoc` (or its + * default, `app.document`) IS the document the shell's `inspectorHost` + * lives in. Only `expandDataPane`'s own nested cell clicks pass this: the + * detached Data Pane is itself a self-contained detached/fullscreen view + * (a real popup tab, OR — when `window.open` is blocked — + * `detached-view.ts`'s own full-screen fallback overlay mounted INSIDE + * `app.document`) that #586 explicitly does not fold into the docked + * inspector (non-goal: "moving detached/fullscreen views into the docked + * inspector"). Either way, that Data Pane already covers the whole + * viewport with its own overlay, so docking a nested cell click would + * render it invisibly BEHIND that overlay — `doc === app.document` alone + * can't tell the two cases apart, so the caller states its context + * explicitly instead. Omitted (default) everywhere else: the ordinary + * in-place grid/Dashboard click docks. + */ + overlay?: boolean; } -export function openCellDetail(app: ResultsApp, name: string, type: string, value: unknown, targetDoc?: Document): HTMLElement { +/** + * Open one cell's full value: pretty-printed (JSON is reindented), and for + * HTML a Rendered (sandboxed iframe) ↔ Source toggle. Docks into the shared + * `app.dom.inspectorHost` (#586) — replacing whatever else currently + * occupies it (#586's docked model has room for exactly one occupant; a cell + * clicked while the rows viewer is open REPLACES it rather than stacking, per + * this issue's own non-goals: no per-tool persistence/registry here, that's + * #488) — UNLESS `targetDoc` names a genuinely separate document (a real + * detached browser tab) or `opts.overlay` says so explicitly (see + * `OpenCellDetailOptions`), in which case it keeps the pre-#586 + * self-contained overlay, rebuilt on the SAME shared `openSurfaceLifecycle` + * primitive. Escape (always — the docked model has no "topmost of several" + * left to scope against) or a ✕ click closes it. Exported for tests. + */ +export function openCellDetail( + app: ResultsApp, name: string, type: string, value: unknown, targetDoc?: Document, opts?: OpenCellDetailOptions, +): HTMLElement { const doc = targetDoc || app.document; - const releaseKeyboard = doc === app.document ? app.acquireKeyboardOwner('modal') : () => {}; + const dock = doc === app.document && !opts?.overlay; const text = value == null ? '' : String(value); - let backdrop: HTMLElement; - let cancelDrawerDrag: () => void; // assigned by attachDrawerResize below, before close() can possibly fire - let detachBackdrop: () => void; - const onKey = (e: KeyboardEvent): void => { - if (e.key === 'Escape' && isTopDrawer(doc, backdrop)) { e.preventDefault(); close(); } - }; - function close(): void { - cancelDrawerDrag(); - detachBackdrop(); - if (backdrop) backdrop.remove(); - doc.removeEventListener('keydown', onKey, true); - releaseKeyboard(); - } + const initiator = doc.activeElement as HTMLElement | null; + let lifecycle: SurfaceLifecycleHandle; // assigned below, before close() can possibly fire + // Only the non-docked (detached-doc) overlay branch uses any of these three. + let backdrop: HTMLElement | null = null; + let cancelDrawerDrag: (() => void) | null = null; + let detachBackdropClose: (() => void) | null = null; // withDocument(doc, ...) so every element (including the ones built later, // from the Rendered/Source toggle click) lands in the right realm — vital @@ -1216,9 +1256,9 @@ export function openCellDetail(app: ResultsApp, name: string, type: string, valu h('span', { class: 'cd-name' }, name), type ? h('span', { class: 'cd-type' }, type) : null, ], - onClose: close, + onClose: () => lifecycle.close(), }); - cancelDrawerDrag = attachDrawerResize(app, panel, doc); + if (!dock) cancelDrawerDrag = attachDrawerResize(app, panel, doc); // A Rendered ↔ Source toggle, defaulting to Rendered. `renderRendered` // builds the rendered node; Source is always the reindented `
`. Shared
@@ -1253,10 +1293,45 @@ export function openCellDetail(app: ResultsApp, name: string, type: string, valu
       showSource();
     }
 
-    backdrop = h('div', { class: 'cd-backdrop' }, panel);
-    detachBackdrop = attachBackdropClose(backdrop, close);
+    lifecycle = openSurfaceLifecycle({
+      document: doc,
+      escapePolicy: 'always',
+      panel,
+      // Keyboard-owner acquisition applies ONLY to the surviving non-docked
+      // overlay branch (a genuine modal backdrop, same as pre-#586) — never
+      // the docked branch: the docked model is non-modal (#488's target
+      // contract this issue preps for), and `shortcuts.ts`'s global
+      // dispatcher exits immediately whenever ANY keyboard owner is held,
+      // which would silently disable Run/Save/format/navigation for the
+      // whole app while a docked Cell panel is open. A foreign detached-tab
+      // document also has no meaningful modal-owner slot of THIS app's to
+      // acquire, so the overlay branch only acquires it when it's actually
+      // running in `app.document` (the popup-blocked fallback case).
+      acquireKeyboardOwner: !dock && doc === app.document ? app.acquireKeyboardOwner : undefined,
+      // A resolver, not the captured element itself (#586's SurfaceLifecycle
+      // contract) — a grid re-render between open and close can detach the
+      // original initiator; `isConnected` catches that rather than focusing
+      // a dead node. Pre-#586 the cell drawer never restored focus at all —
+      // this is new, correct behavior the shared primitive gives uniformly.
+      returnFocusTo: () => (initiator && initiator.isConnected ? initiator : null),
+      onClose: () => {
+        cancelDrawerDrag?.();
+        if (dock) { releaseInspector(app); return; }
+        // `!`: the non-dock branch below always assigns both before
+        // returning, and `close()` can only run after that (Escape/✕/an
+        // outside click all fire post-mount).
+        detachBackdropClose!();
+        backdrop!.remove();
+      },
+    });
+
+    if (dock) {
+      showInInspector(app, panel, () => lifecycle.close());
+      return panel;
+    }
+    backdrop = h('div', { class: 'cell-detail-overlay' }, panel);
+    detachBackdropClose = attachBackdropClose(backdrop, () => lifecycle.close());
     doc.body.appendChild(backdrop);
-    doc.addEventListener('keydown', onKey, true);
     return backdrop;
   });
 }
diff --git a/src/ui/splitters.ts b/src/ui/splitters.ts
index caf2fdcf..32ecf9a9 100644
--- a/src/ui/splitters.ts
+++ b/src/ui/splitters.ts
@@ -4,12 +4,14 @@
 
 import { clamp } from '../core/format.js';
 
-// 'docPane' (#313): the persistent documentation pane's own bounded-resize
-// axis — identical geometry to 'drawer' (right-edge anchored, same
-// clampDrawerWidth bounds) but writes `docPanePx` instead of `cellDrawerPx`,
-// so a docs-pane drag never clobbers (or reads) the cell-detail/rows-viewer
-// drawer's own persisted width.
-export type SplitterAxis = 'col' | 'sideRow' | 'row' | 'drawer' | 'docPane';
+// 'rightInspector' (#586): the docked right-inspector's own bounded-resize
+// axis — right-edge anchored, same clampDrawerWidth bounds the former
+// 'drawer'/'docPane' axes each used. Those two collapsed into this ONE axis
+// (writing the single `rightInspectorPx` preference) because #586 replaced
+// three independent per-surface overlays (cell detail, rows viewer,
+// Reference) with one shared, shell-owned dock — there is no longer a
+// separate per-surface width to keep isolated.
+export type SplitterAxis = 'col' | 'sideRow' | 'row' | 'rightInspector';
 
 /** The subset of a real (or fake, in tests) pointer/mouse event `dragValue`/
  *  `startDrag` read — never the full DOM `MouseEvent`, so a plain test
@@ -20,7 +22,7 @@ export interface DragPoint {
 }
 
 /** The subset of a bounding-rect-like `dragValue` reads, by axis: 'sideRow'/
- *  'row' need `top`/`bottom`; 'drawer'/'docPane' need `width` (the viewport
+ *  'row' need `top`/`bottom`; 'rightInspector' needs `width` (the viewport
  *  width); 'col' reads neither. */
 export interface DragRect {
   top?: number;
@@ -29,10 +31,11 @@ export interface DragRect {
 }
 
 /**
- * Clamp a drawer width (px) to [320, 92% of the viewport width] — the
- * cell-detail / rows-viewer right-hand drawer's bounds (#101). Exported so a
- * caller can apply the same clamp when first opening the drawer, not just
- * mid-drag (the viewport may have shrunk since the width was last persisted).
+ * Clamp a drawer width (px) to [320, 92% of the viewport width] — the docked
+ * right-inspector's bounds (#101, unchanged by #586's single-axis collapse).
+ * Exported so a caller can apply the same clamp when first opening a surface,
+ * not just mid-drag (the viewport may have shrunk since the width was last
+ * persisted).
  */
 export function clampDrawerWidth(px: number, viewportWidth: number): number {
   return clamp(px, 320, viewportWidth * 0.92);
@@ -40,19 +43,20 @@ export function clampDrawerWidth(px: number, viewportWidth: number): number {
 
 /**
  * Compute the new size for a drag. `axis` is 'col' (sidebar px), 'sideRow'
- * (sidebar vertical %), 'row' (editor/results %), or 'drawer' (cell-detail /
- * rows-viewer right-hand drawer px, #101). `rect` is the bounding rect of the
- * container being split (unused for 'col'; `{ width }` — the viewport width —
- * for 'drawer'). 'drawer' is anchored to the *right* edge, so its width grows
- * as the cursor moves left: `viewportWidth - clientX`.
+ * (sidebar vertical %), 'row' (editor/results %), or 'rightInspector' (the
+ * docked right-inspector's px width, #101/#586). `rect` is the bounding rect
+ * of the container being split (unused for 'col'; `{ width }` — the viewport
+ * width — for 'rightInspector'). 'rightInspector' is anchored to the *right*
+ * edge, so its width grows as the cursor moves left: `viewportWidth -
+ * clientX`.
  */
 export function dragValue(axis: SplitterAxis, ev: DragPoint, rect?: DragRect): number {
   if (axis === 'col') return clamp(ev.clientX, 180, 420);
   // `!`: every real caller (startDrag's onMove, via ctx.rectFor(axis)) supplies
-  // `width` for 'drawer'/'docPane' and `top`/`bottom` for 'sideRow'/'row' —
-  // the axis dispatch above is exactly the contract that guarantees the field
+  // `width` for 'rightInspector' and `top`/`bottom` for 'sideRow'/'row' — the
+  // axis dispatch above is exactly the contract that guarantees the field
   // this branch reads is present.
-  if (axis === 'drawer' || axis === 'docPane') return clampDrawerWidth(rect!.width! - ev.clientX, rect!.width!);
+  if (axis === 'rightInspector') return clampDrawerWidth(rect!.width! - ev.clientX, rect!.width!);
   const pct = clamp(((ev.clientY - rect!.top!) / (rect!.bottom! - rect!.top!)) * 100,
     axis === 'sideRow' ? 25 : 15, 85);
   return pct;
@@ -81,10 +85,10 @@ export interface DragState {
   sidebarPx?: number;
   sideSplitPct?: number;
   editorPct?: number;
-  cellDrawerPx?: number;
-  /** The docs pane's own persisted width (#313) — a sibling of `cellDrawerPx`,
-   *  never read/written by the 'drawer' axis. */
-  docPanePx?: number;
+  /** The docked right-inspector's width (#586) — the single field the
+   *  'rightInspector' axis reads/writes, replacing the former
+   *  `cellDrawerPx`/`docPanePx` pair. */
+  rightInspectorPx?: number;
 }
 
 /** `startDrag`'s injected context: the window seam, the caller's mutable
@@ -101,11 +105,11 @@ export interface DragCtx {
 /**
  * Begin a splitter drag. Returns a `cancel()` that stops listening without
  * persisting — for a caller whose drag surface can be torn down mid-drag
- * (e.g. the cell-detail drawer closing via Escape while the mouse button is
- * still down, #101); the plain splitters (col/sideRow/row) don't need it and
- * ignore the return value.
+ * (e.g. the docked right-inspector closing via Escape while the mouse button
+ * is still down, #101); the plain splitters (col/sideRow/row) don't need it
+ * and ignore the return value.
  * @param ev      the mousedown event (currentTarget = the handle)
- * @param axis    'col' | 'sideRow' | 'row' | 'drawer'
+ * @param axis    'col' | 'sideRow' | 'row' | 'rightInspector'
  * @param ctx     { win, state, save, rectFor(axis), apply(axis, value) }
  */
 export function startDrag(ev: DragStartEvent, axis: SplitterAxis, ctx: DragCtx): () => void {
@@ -118,8 +122,7 @@ export function startDrag(ev: DragStartEvent, axis: SplitterAxis, ctx: DragCtx):
     if (axis === 'col') ctx.state.sidebarPx = value;
     else if (axis === 'sideRow') ctx.state.sideSplitPct = value;
     else if (axis === 'row') ctx.state.editorPct = value;
-    else if (axis === 'docPane') ctx.state.docPanePx = value;
-    else ctx.state.cellDrawerPx = value;
+    else ctx.state.rightInspectorPx = value;
     ctx.apply(axis, value);
   };
   const stop = (): void => {
@@ -134,8 +137,7 @@ export function startDrag(ev: DragStartEvent, axis: SplitterAxis, ctx: DragCtx):
     if (axis === 'col') ctx.save('sidebarPx', ctx.state.sidebarPx!);
     else if (axis === 'sideRow') ctx.save('sideSplitPct', ctx.state.sideSplitPct!);
     else if (axis === 'row') ctx.save('editorPct', ctx.state.editorPct!);
-    else if (axis === 'docPane') ctx.save('docPanePx', ctx.state.docPanePx!);
-    else ctx.save('cellDrawerPx', ctx.state.cellDrawerPx!);
+    else ctx.save('rightInspectorPx', ctx.state.rightInspectorPx!);
   };
   win.addEventListener('mousemove', onMove);
   win.addEventListener('mouseup', onUp);
diff --git a/src/ui/surface-lifecycle.ts b/src/ui/surface-lifecycle.ts
new file mode 100644
index 00000000..27e3310d
--- /dev/null
+++ b/src/ui/surface-lifecycle.ts
@@ -0,0 +1,100 @@
+// The shared open/close/Escape/focus-restore primitive (#586), extracted from
+// SIX near-duplicate lifecycles that had each grown their own slightly
+// different Escape rule and focus-restore step: the cell-detail drawer and
+// rows viewer (results.ts), the Reference documentation pane (doc-pane.ts),
+// the detached-view overlay fallback (detached-view.ts), and the two BEST
+// implementations in the codebase — dialog-shell.ts and popover.ts — reused
+// by neither. This module owns exactly that shared slice: idempotent
+// teardown, an explicit `escapePolicy`, optional keyboard-owner acquisition,
+// and `returnFocusTo`'s element-or-resolver contract (borrowed verbatim from
+// dialog-shell.ts's own doc comment — a resolver is called AT close time so
+// it can hand back whatever is on screen now, rather than a possibly-detached
+// element captured at open time).
+//
+// Deliberately DOES NOT own: DOM construction (buildDrawerChrome/dialog
+// cards/panels are each caller's own job), backdrop/scrim, a Tab trap, or
+// which physical host a surface's content mounts into (`inspector-host.ts`
+// owns "one thing occupies the shared dock at a time" — a separate, smaller
+// concern layered on top of this one).
+
+/** Which Escape presses this surface reacts to. `'always'` closes
+ *  unconditionally (the cell-detail drawer / rows viewer, now that the docked
+ *  model has room for only one occupant at a time — there is no longer a
+ *  "topmost of several stacked" case to scope against). `'focus-inside'`
+ *  closes only while focus is inside `panel` (the Reference pane's existing
+ *  behavior — Escape must not ALSO fire the global cancel-running-query
+ *  shortcut when focus is elsewhere on the page). `'none'` installs no
+ *  Escape handling at all — the caller owns Escape entirely (e.g. a future
+ *  surface that must consume Escape for something other than closing). */
+export type EscapePolicy = 'always' | 'focus-inside' | 'none';
+
+export interface SurfaceLifecycleOptions {
+  /** The realm to install the capture-phase Escape listener on, and to read
+   *  `activeElement` from for `'focus-inside'`. */
+  document: Document;
+  escapePolicy: EscapePolicy;
+  /** Containment check target for `'focus-inside'` — ignored by the other two
+   *  policies (never read when `escapePolicy !== 'focus-inside'`). */
+  panel: Element;
+  /** Acquire the shared modal keyboard-owner slot on open, release it on
+   *  close. Omit for a non-modal surface (Reference) that shares the
+   *  keyboard freely with the editor/results underneath it. */
+  acquireKeyboardOwner?: (kind: 'modal') => () => void;
+  /** Where focus goes on close — an element, a resolver called AT close time
+   *  (see this module's header comment), or `null` for nothing to restore. */
+  returnFocusTo: HTMLElement | (() => HTMLElement | null) | null;
+  /** Runs on every close path, exactly once, AFTER focus has been restored. */
+  onClose?: () => void;
+}
+
+export interface SurfaceLifecycleHandle {
+  /** Idempotent — every dismissal path (Escape, a caller's own ✕ button, a
+   *  force-close from a new occupant replacing this one) funnels here, and a
+   *  second call is a harmless no-op that never re-fires `onClose`. */
+  close(): void;
+  /** Whether this surface is still open (false once `close()` has run). */
+  isOpen(): boolean;
+}
+
+/**
+ * Open one surface's shared lifecycle: install (unless `escapePolicy ===
+ * 'none'`) a capture-phase Escape listener obeying `escapePolicy`, optionally
+ * acquire the modal keyboard-owner slot, and return a `close()` that tears
+ * both down, restores focus per `returnFocusTo`, and runs `onClose` — all
+ * exactly once no matter how many times `close()` is called.
+ */
+export function openSurfaceLifecycle(opts: SurfaceLifecycleOptions): SurfaceLifecycleHandle {
+  const doc = opts.document;
+  const release = opts.acquireKeyboardOwner ? opts.acquireKeyboardOwner('modal') : null;
+  let open = true;
+
+  const onKeyDown = (e: KeyboardEvent): void => {
+    if (e.key !== 'Escape') return;
+    if (opts.escapePolicy === 'focus-inside' && !opts.panel.contains(doc.activeElement)) return;
+    // Both preventDefault (so shortcuts.ts's `if (e.defaultPrevented) return
+    // null` guard skips its own Escape handling — e.g. cancelling a running
+    // query) AND stopPropagation: a capture-phase handler that only calls
+    // preventDefault still lets the SAME event reach every bubble-phase
+    // `document` listener afterward (only real browsers enforce this —
+    // happy-dom's unit tests never caught it, only a real Chromium/WebKit
+    // e2e run did). A non-modal surface (Reference) must consume the event
+    // outright, not merely mark it handled and let it keep propagating.
+    e.preventDefault();
+    e.stopPropagation();
+    close();
+  };
+
+  function close(): void {
+    if (!open) return;
+    open = false;
+    if (opts.escapePolicy !== 'none') doc.removeEventListener('keydown', onKeyDown, true);
+    release?.();
+    const restore = typeof opts.returnFocusTo === 'function' ? opts.returnFocusTo() : opts.returnFocusTo;
+    restore?.focus();
+    opts.onClose?.();
+  }
+
+  if (opts.escapePolicy !== 'none') doc.addEventListener('keydown', onKeyDown, true);
+
+  return { close, isOpen: () => open };
+}
diff --git a/tests/e2e/editor.html b/tests/e2e/editor.html
index de8146c4..f86f3a08 100644
--- a/tests/e2e/editor.html
+++ b/tests/e2e/editor.html
@@ -121,8 +121,17 @@
         || (target.kind === 'aggregate-function' && window.__docFixtures[key('function')])
         || null;
     };
+    // #586 — the docked right-inspector's two shell-owned nodes
+    // (app-shell.ts's real construction). editor-docs.spec.js's Reference
+    // pane docks into inspectorHost now instead of mounting on
+    // document.body directly, so the harness needs real, connected elements
+    // for it to land in — starting `hidden` (folded), same as production.
+    const inspectorHost = document.body.appendChild(document.createElement('div'));
+    inspectorHost.hidden = true;
+    const inspectorResize = document.body.appendChild(document.createElement('div'));
+    inspectorResize.hidden = true;
     const app = {
-      state, dom: {}, document,
+      state, dom: { inspectorHost, inspectorResize }, document,
       // #276 Phase 5: the CM6 adapter reads the reference data through
       // `app.catalog.*` (the SchemaCatalogService member) — this harness
       // mirrors that shape with a writable plain object (editor-cm6.spec.js
diff --git a/tests/helpers/fake-app.ts b/tests/helpers/fake-app.ts
index fae310d8..f5fc85a8 100644
--- a/tests/helpers/fake-app.ts
+++ b/tests/helpers/fake-app.ts
@@ -851,6 +851,14 @@ export function makeApp>(override
       savedSearch: document.createElement('div'),
       savedList: document.createElement('div'),
       saveBtn: document.createElement('button'),
+      // #586 — the docked right-inspector slot + its resize handle
+      // (app-shell.ts). Real elements by default (matching this block's own
+      // "most consumers read these unconditionally" convention above) so
+      // `openCellDetail`/`openRowsViewer`/`openDocEntry` dock correctly
+      // without every caller overriding `dom` — starting `hidden`, matching
+      // app-shell.ts's real initial (folded) state.
+      inspectorHost: Object.assign(document.createElement('div'), { hidden: true }),
+      inspectorResize: Object.assign(document.createElement('div'), { hidden: true }),
     },
     actions: {
       run: vi.fn(),
diff --git a/tests/unit/app-preferences.test.ts b/tests/unit/app-preferences.test.ts
index b2d08f90..d1ecd0ab 100644
--- a/tests/unit/app-preferences.test.ts
+++ b/tests/unit/app-preferences.test.ts
@@ -28,8 +28,7 @@ describe('save()', () => {
       ['sidebarPx', 260, '260'],
       ['editorPct', 45, '45'],
       ['sideSplitPct', 58, '58'],
-      ['cellDrawerPx', 560, '560'],
-      ['docPanePx', 420, '420'],
+      ['rightInspectorPx', 560, '560'],
       ['sidePanel', 'history', 'history'],
       ['resultRowLimit', 1000, '1000'],
     ];
diff --git a/tests/unit/app-shell.test.ts b/tests/unit/app-shell.test.ts
index 2d77f7b6..c9e2aa67 100644
--- a/tests/unit/app-shell.test.ts
+++ b/tests/unit/app-shell.test.ts
@@ -73,3 +73,47 @@ describe('mountAppShell authentication host', () => {
     expect(host.firstElementChild).toBe(controls);
   });
 });
+
+// #586 — the docked right-inspector slot + its shared resize handle.
+describe('mountAppShell docked right-inspector (#586)', () => {
+  it('mounts inspectorHost + inspectorResize as mainRow siblings of queryHost/dashboardHost, folded by default', () => {
+    const { app, handle } = mount();
+    const mainRow = handle.queryHost.parentElement!;
+    expect(mainRow.className).toBe('main-row');
+    const kids = [...mainRow.children];
+    expect(kids.indexOf(handle.queryHost)).toBeGreaterThanOrEqual(0);
+    expect(kids.indexOf(handle.dashboardHost)).toBeGreaterThan(kids.indexOf(handle.queryHost));
+    expect(kids.at(-1)).toBe(app.dom.inspectorHost);
+    expect(kids.at(-2)).toBe(app.dom.inspectorResize);
+    expect(app.dom.inspectorHost!.hidden).toBe(true);
+    expect(app.dom.inspectorResize!.hidden).toBe(true);
+    handle.dispose();
+  });
+
+  it('sets the initial width from the persisted rightInspectorPx pref, clamped to [320, 92vw] (window.innerWidth = 1024 under happy-dom)', () => {
+    const { app, handle } = mount();
+    expect(app.dom.inspectorHost!.style.width).toBe(app.state.rightInspectorPx + 'px');
+    handle.dispose();
+
+    const wide = makeApp({ catalog: { loadSchema: vi.fn(async () => {}), loadReference: vi.fn(async () => {}) } });
+    wide.state.rightInspectorPx = 5000;
+    const wideHandle = mountAppShell({
+      app: wide, root: wide.root, document, state: wide.state, catalog: wide.catalog,
+      prefs: wide.prefs, matchMedia: null, updateBanner: vi.fn(), startDrag,
+    });
+    expect(wide.dom.inspectorHost!.style.width).toBe(1024 * 0.92 + 'px');
+    wideHandle.dispose();
+  });
+
+  it('dragging inspectorResize resizes inspectorHost live and persists rightInspectorPx on mouseup', () => {
+    const { app, handle } = mount();
+    const resize = app.dom.inspectorResize!;
+    resize.dispatchEvent(new MouseEvent('mousedown', { clientX: 700, bubbles: true }));
+    window.dispatchEvent(new MouseEvent('mousemove', { clientX: 500 })); // 1024-500
+    expect(app.dom.inspectorHost!.style.width).toBe('524px');
+    window.dispatchEvent(new MouseEvent('mouseup', {}));
+    expect(app.state.rightInspectorPx).toBe(524);
+    expect(app.prefs.save).toHaveBeenCalledWith('rightInspectorPx', 524);
+    handle.dispose();
+  });
+});
diff --git a/tests/unit/app.test.ts b/tests/unit/app.test.ts
index b7857320..65105786 100644
--- a/tests/unit/app.test.ts
+++ b/tests/unit/app.test.ts
@@ -2853,6 +2853,15 @@ describe('query run', () => {
   // not one-way for the workbench session: attachShell re-attaches).
   it('app.closeDocPane closes an open reference pane (true) and no-ops when nothing is open (false) — the global Escape wiring (#60)', async () => {
     const app = createApp(env());
+    // #586: the pane docks into the shell-owned inspectorHost (app-shell.ts),
+    // which only exists once the shell has actually rendered — this test
+    // exercises `openDocEntry`/`closeDocPane` headlessly, with no render
+    // step, so it supplies the two nodes directly (mirrors what
+    // mountAppShell would set).
+    app.dom.inspectorHost = document.body.appendChild(document.createElement('div'));
+    app.dom.inspectorHost.hidden = true;
+    app.dom.inspectorResize = document.body.appendChild(document.createElement('div'));
+    app.dom.inspectorResize.hidden = true;
     expect(app.closeDocPane()).toBe(false); // nothing open
     app.openDocEntry({ kind: 'function', name: 'sum' }); // pane opens (lookup resolves unavailable — irrelevant here)
     expect(document.querySelector('[role="complementary"]')).not.toBeNull();
diff --git a/tests/unit/codemirror-adapter.test.ts b/tests/unit/codemirror-adapter.test.ts
index ca57b093..3229a2e7 100644
--- a/tests/unit/codemirror-adapter.test.ts
+++ b/tests/unit/codemirror-adapter.test.ts
@@ -59,8 +59,8 @@ const makeApp = (over: Partial> & { catalog
 // actions bound the same way app.ts binds them — to the REAL ui/doc-pane.ts
 // `openDocEntry(app, target)`/`openDocDisambiguation(app, name)` — so "Open
 // reference" (hover button, F1) actually opens the persistent pane instead of
-// quietly no-opping. The pane's own required fields (document/prefs/
-// CodeViewer) live on the same object, mirroring the real App.
+// quietly no-opping. The pane's own required fields (document/CodeViewer)
+// live on the same object, mirroring the real App.
 // `catalog.docDisambiguate` stays undefined unless a caller's override
 // supplies it (mirrors `docSummary`/`docEntry` above) — a test that only
 // exercises `openDocEntry` never touches it.
@@ -68,7 +68,14 @@ const makeDocPaneApp = (over: Parameters[0] = {}): CodeMirrorEdi
   const app = makeApp(over);
   const paneApp = app as unknown as DocPaneApp;
   paneApp.document = document;
-  paneApp.prefs = { save: vi.fn() };
+  // #586 — the docked inspector's two shell-owned nodes, appended to the real
+  // `document` (not just held detached) so a caller asserting the pane
+  // actually mounted (`document.querySelector('[role="complementary"]')`,
+  // below) finds it, the same way app-shell.ts's real mainRow does.
+  paneApp.dom = {
+    inspectorHost: document.body.appendChild(document.createElement('div')),
+    inspectorResize: document.body.appendChild(document.createElement('div')),
+  };
   paneApp.CodeViewer = vi.fn(() => ({
     setText: vi.fn(), setLanguage: vi.fn(), setWrap: vi.fn(), focus: vi.fn(), destroy: vi.fn(),
   }));
diff --git a/tests/unit/dashboard.test.ts b/tests/unit/dashboard.test.ts
index ecd7477d..36780be7 100644
--- a/tests/unit/dashboard.test.ts
+++ b/tests/unit/dashboard.test.ts
@@ -1054,13 +1054,13 @@ describe('renderDashboard — reorder (Command/Ctrl pointer-drag) + sort (#153/#
     await render(app);
     const cards = qsa(app.root, '.dash-tile');
     stubTileRects(cards);
-    // openCellDetail appends the drawer to the tile's document.body, NOT inside
-    // app.root — assert against `document` (an app.root query is always empty).
+    // #586: openCellDetail docks into the shared app.dom.inspectorHost, not
+    // app.root — assert against that host directly.
     const cell = (): Element | null => qs(cards[0], '.res-table tbody td.cell');
     // Positive control: a plain cell click (no drag) DOES open the shared drawer.
     cell()?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
-    expect(qsa(document, '.cd-backdrop').length).toBe(1);
-    qs(document, '.cd-backdrop').remove();
+    expect(app.dom.inspectorHost.hidden).toBe(false);
+    qs(app.dom.inspectorHost, '.cd-close').dispatchEvent(new MouseEvent('click', { bubbles: true }));
     // A ⌘-drag that leaves and returns to the origin tile is a completed move
     // that releases on its OWN card — the browser synthesizes a real click on
     // that card, which the capture-phase guard must swallow.
@@ -1071,7 +1071,7 @@ describe('renderDashboard — reorder (Command/Ctrl pointer-drag) + sort (#153/#
     window.dispatchEvent(new PointerEvent('pointermove', { clientX: tileCenter(1).x, clientY: tileCenter(1).y }));
     window.dispatchEvent(new PointerEvent('pointerup', { clientX: start.x, clientY: start.y }));
     cell()?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
-    expect(qsa(document, '.cd-backdrop').length).toBe(0);
+    expect(app.dom.inspectorHost.hidden).toBe(true);
   });
 
   it('a second pointerdown while a drag is already armed is ignored (#332)', async () => {
@@ -1310,7 +1310,7 @@ describe('renderDashboard — modkey cursor cue (#332)', () => {
 });
 
 describe('renderDashboard — shared cell-detail drawer (#332)', () => {
-  it('clicking a table cell opens the shared drawer with exact name/type/value (edit mode)', async () => {
+  it('clicking a table cell opens the shared drawer with exact name/type/value (edit mode), docked in app.dom.inspectorHost', async () => {
     const { app } = dashApp({
       responder: () => ({ columns: [{ name: 'k', type: 'String' }, { name: 'v', type: 'UInt64' }], rows: [['hello', 42]] }),
       workspace: wsWith({
@@ -1320,14 +1320,12 @@ describe('renderDashboard — shared cell-detail drawer (#332)', () => {
     });
     await render(app);
     qs(app.root, '.res-table tbody td.cell')?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
-    const backdrop = qs(document, '.cd-backdrop');
-    expect(backdrop).not.toBeNull();
-    const panel = qs(backdrop, '.cd-panel');
+    expect(app.dom.inspectorHost.hidden).toBe(false);
+    const panel = qs(app.dom.inspectorHost, '.cd-panel');
     expect(panel).not.toBeNull();
     expect(qs(panel, '.cd-name')?.textContent).toBe('k');
     expect(qs(panel, '.cd-type')?.textContent).toBe('String');
     expect(panel.textContent).toContain('hello');
-    backdrop.remove();
   });
 
   it('clicking a table cell opens the shared drawer in read-only dashboard mode too', async () => {
@@ -1342,14 +1340,13 @@ describe('renderDashboard — shared cell-detail drawer (#332)', () => {
     });
     await render(app);
     qs(app.root, '.res-table tbody td.cell')?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
-    const backdrop = qs(document, '.cd-backdrop');
-    expect(backdrop).not.toBeNull();
-    expect(qs(backdrop, '.cd-name')?.textContent).toBe('k');
-    expect(qs(backdrop, '.cd-type')?.textContent).toBe('String');
-    backdrop.remove();
+    expect(app.dom.inspectorHost.hidden).toBe(false);
+    const panel = qs(app.dom.inspectorHost, '.cd-panel');
+    expect(qs(panel, '.cd-name')?.textContent).toBe('k');
+    expect(qs(panel, '.cd-type')?.textContent).toBe('String');
   });
 
-  it('Escape closes the drawer; a backdrop click closes it; close-then-open leaves exactly one .cd-backdrop', async () => {
+  it('Escape closes the drawer; the ✕ closes it too; close-then-open leaves exactly one panel in the dock', async () => {
     const { app } = dashApp({
       responder: () => ({ columns: [{ name: 'k', type: 'String' }, { name: 'v', type: 'UInt64' }], rows: [['a', 1], ['b', 2]] }),
       workspace: wsWith({
@@ -1358,24 +1355,23 @@ describe('renderDashboard — shared cell-detail drawer (#332)', () => {
       }),
     });
     await render(app);
+    const host = app.dom.inspectorHost;
     const cells = qsa(app.root, '.res-table tbody td.cell');
     cells[0].dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
-    expect(qsa(document, '.cd-backdrop').length).toBe(1);
+    expect(host.hidden).toBe(false);
+    expect(host.children).toHaveLength(1);
     document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
-    expect(qsa(document, '.cd-backdrop').length).toBe(0);
-    // Re-open, then dismiss via a backdrop click.
+    expect(host.hidden).toBe(true);
+    // Re-open, then dismiss via the ✕ button (#586: no more backdrop to click
+    // outside of — the docked panel is a normal layout sibling).
     cells[0].dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
-    let backdrop = qs(document, '.cd-backdrop');
-    expect(backdrop).not.toBeNull();
-    backdrop.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
-    backdrop.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
-    expect(qsa(document, '.cd-backdrop').length).toBe(0);
-    // Open a second time — the shared backdrop-dismiss lifecycle leaves
-    // exactly one, not a stacked pair.
+    expect(host.hidden).toBe(false);
+    qs(host, '.cd-close').dispatchEvent(new MouseEvent('click', { bubbles: true }));
+    expect(host.hidden).toBe(true);
+    // Open a second time — the shared dock lifecycle leaves exactly one panel,
+    // never a stacked pair.
     cells[0].dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
-    backdrop = qs(document, '.cd-backdrop');
-    expect(qsa(document, '.cd-backdrop').length).toBe(1);
-    backdrop.remove();
+    expect(host.children).toHaveLength(1);
   });
 });
 
@@ -1400,42 +1396,41 @@ describe('renderDashboard — logs tile cell-detail + drag interplay (#332)', ()
     const { app } = dashApp({ responder: logsResponder, workspace: logsWs() });
     await render(app);
     expect(qs(app.root, '.dash-logs')).not.toBeNull();
+    const host = app.dom.inspectorHost;
 
     const timeCell = qs(app.root, '.log-time.log-cell');
     timeCell.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
-    let backdrop = qs(document, '.cd-backdrop');
-    expect(qs(backdrop, '.cd-name')?.textContent).toBe('event_time');
-    expect(qs(backdrop, '.cd-type')?.textContent).toBe('DateTime');
-    backdrop.remove();
+    let panel = qs(host, '.cd-panel');
+    expect(qs(panel, '.cd-name')?.textContent).toBe('event_time');
+    expect(qs(panel, '.cd-type')?.textContent).toBe('DateTime');
 
     const msgCell = qs(app.root, '.log-msg.log-cell');
     msgCell.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
-    backdrop = qs(document, '.cd-backdrop');
-    expect(qs(backdrop, '.cd-name')?.textContent).toBe('message');
-    expect(backdrop.textContent).toContain('boom');
-    backdrop.remove();
+    panel = qs(host, '.cd-panel');
+    expect(qs(panel, '.cd-name')?.textContent).toBe('message');
+    expect(panel.textContent).toContain('boom');
 
     const extraCell = qs(app.root, '.log-extra.log-cell');
     extraCell.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
-    backdrop = qs(document, '.cd-backdrop');
-    expect(qs(backdrop, '.cd-name')?.textContent).toBe('extra_field');
+    panel = qs(host, '.cd-panel');
+    expect(qs(panel, '.cd-name')?.textContent).toBe('extra_field');
     // The RAW (untruncated) value is shown — the field's own display is
     // truncated to 80 chars (core/logs.ts), so raw !== display for a >80-char value.
     expect(extraCell.textContent).not.toBe(longExtra); // display was truncated
-    expect(backdrop.textContent).toContain(longExtra); // drawer shows the raw value
-    backdrop.remove();
+    expect(panel.textContent).toContain(longExtra); // drawer shows the raw value
   });
 
   it('Enter and Space on a .log-cell also open the drawer', async () => {
     const { app } = dashApp({ responder: logsResponder, workspace: logsWs() });
     await render(app);
+    const host = app.dom.inspectorHost;
     const msgCell = qs(app.root, '.log-msg.log-cell');
     msgCell.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
-    expect(qsa(document, '.cd-backdrop').length).toBe(1);
-    qs(document, '.cd-backdrop').remove();
+    expect(host.hidden).toBe(false);
+    expect(host.children).toHaveLength(1);
     msgCell.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }));
-    expect(qsa(document, '.cd-backdrop').length).toBe(1);
-    qs(document, '.cd-backdrop').remove();
+    expect(host.hidden).toBe(false);
+    expect(host.children).toHaveLength(1);
   });
 
   it('a ⌘-drag starting on a logs tile moves the tile and does not open a drawer', async () => {
@@ -1447,7 +1442,7 @@ describe('renderDashboard — logs tile cell-detail + drag interplay (#332)', ()
     expect(qsa(app.root, '.dash-tile .dash-tile-name').map((n) => n.textContent)).toEqual(['q2', 'q1']);
     await flush();
     expect(commit).toHaveBeenCalled();
-    expect(qsa(document, '.cd-backdrop').length).toBe(0);
+    expect(app.dom.inspectorHost.hidden).toBe(true);
   });
 });
 
@@ -2297,49 +2292,47 @@ describe('renderDashboard — Text (Markdown) tile preview (#332)', () => {
     expect(qsa(view, 'li').length).toBe(2);
   });
 
-  it('clicking the Text tile opens the shared cell-detail drawer with the rendered Markdown', async () => {
-    document.querySelectorAll('.cd-backdrop').forEach((b) => b.remove());
+  it('clicking the Text tile opens the shared cell-detail drawer (docked) with the rendered Markdown', async () => {
     const { app } = dashApp({ responder: () => ({}), workspace: textWs('# Hi\n\n- a\n- b') });
     await render(app);
     const mdView = qs(app.root, '.dash-tile-body .md-view');
     expect(mdView.getAttribute('role')).toBe('button');
     expect(mdView.getAttribute('tabindex')).toBe('0');
     mdView.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
-    const bd = qs(document, '.cd-backdrop');
-    expect(bd).not.toBeNull();
-    expect(qs(bd, '.docs-md h4')?.textContent).toBe('Hi');
-    bd.remove();
+    expect(app.dom.inspectorHost.hidden).toBe(false);
+    const panel = qs(app.dom.inspectorHost, '.cd-panel');
+    expect(qs(panel, '.docs-md h4')?.textContent).toBe('Hi');
   });
 
   it('Enter/Space open the drawer; other keys do not', async () => {
-    document.querySelectorAll('.cd-backdrop').forEach((b) => b.remove());
     const { app } = dashApp({ responder: () => ({}), workspace: textWs('# K') });
     await render(app);
+    const host = app.dom.inspectorHost;
     const mdView = qs(app.root, '.dash-tile-body .md-view');
     mdView.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true }));
-    expect(qs(document, '.cd-backdrop')).toBeNull();
+    expect(host.hidden).toBe(true);
     mdView.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
-    expect(qs(document, '.cd-backdrop')).not.toBeNull();
-    qs(document, '.cd-backdrop').remove();
+    expect(host.hidden).toBe(false);
+    document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
+    expect(host.hidden).toBe(true);
     mdView.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
-    expect(qs(document, '.cd-backdrop')).not.toBeNull();
-    qs(document, '.cd-backdrop').remove();
+    expect(host.hidden).toBe(false);
   });
 
   it('a click on an inner link, and a click while text is selected, do NOT open the drawer', async () => {
-    document.querySelectorAll('.cd-backdrop').forEach((b) => b.remove());
     const { app } = dashApp({ responder: () => ({}), workspace: textWs('see [docs](https://example.com/x)') });
     await render(app);
+    const host = app.dom.inspectorHost;
     const mdView = qs(app.root, '.dash-tile-body .md-view');
     // Inner link click → defers to the link, no drawer.
     qs(mdView, 'a').dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
-    expect(qs(document, '.cd-backdrop')).toBeNull();
+    expect(host.hidden).toBe(true);
     // A click that ends a text selection → no drawer (selection guard).
     const realGetSel = document.getSelection.bind(document);
     document.getSelection = () => ({ isCollapsed: false, toString: () => 'selected' }) as unknown as Selection;
     try {
       mdView.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
-      expect(qs(document, '.cd-backdrop')).toBeNull();
+      expect(host.hidden).toBe(true);
     } finally {
       document.getSelection = realGetSel;
     }
diff --git a/tests/unit/doc-pane.test.ts b/tests/unit/doc-pane.test.ts
index 61a6ae0f..8cb8c0d6 100644
--- a/tests/unit/doc-pane.test.ts
+++ b/tests/unit/doc-pane.test.ts
@@ -1,6 +1,7 @@
 import { describe, it, expect, vi } from 'vitest';
 import { openDocEntry, openDocDisambiguation, closeDocPane, isDocPaneOpen } from '../../src/ui/doc-pane.js';
 import type { DocPaneApp } from '../../src/ui/doc-pane.js';
+import { showInInspector } from '../../src/ui/inspector-host.js';
 import type { DocEntry, DocLookup, DocSummary, DocTarget } from '../../src/core/doc-types.js';
 
 function deferred(): { promise: Promise; resolve: (v: T) => void } {
@@ -13,13 +14,23 @@ function fakeViewer() {
   return { setText: vi.fn(), setLanguage: vi.fn(), setWrap: vi.fn(), focus: vi.fn(), destroy: vi.fn() };
 }
 
+// Matches app-shell.ts's real `inspectorHost`/`inspectorResize` construction:
+// appended to the live document (so this file's pervasive
+// `document.querySelector('.docs-panel')` assertions keep finding the
+// mounted pane) and starting `hidden` (folded — nothing occupies the dock
+// until a surface opens).
+function inspectorNode(): HTMLElement {
+  const el = document.body.appendChild(document.createElement('div'));
+  el.hidden = true;
+  return el;
+}
+
 function makeApp(over: Partial = {}): DocPaneApp & { catalog: { docEntry: ReturnType; docDisambiguate: ReturnType } } {
   const docEntry = vi.fn();
   const docDisambiguate = vi.fn();
   return {
     document,
-    state: { docPanePx: 420 },
-    prefs: { save: vi.fn() },
+    dom: { inspectorHost: inspectorNode(), inspectorResize: inspectorNode() },
     catalog: { docEntry, docDisambiguate, refData: { keywords: [], functions: {} } as never },
     CodeViewer: vi.fn(() => fakeViewer()),
     ...over,
@@ -94,6 +105,33 @@ describe('doc-pane lifecycle', () => {
     closeBtn.click();
     expect(document.querySelector('.docs-panel')).toBeNull();
   });
+
+  it('closing does not throw when the initiator was removed from the DOM before close (a resolver, not a captured element — #586)', () => {
+    const app = makeApp();
+    app.catalog.docEntry.mockResolvedValue({ status: 'missing' });
+    const trigger = document.createElement('button');
+    document.body.appendChild(trigger);
+    trigger.focus();
+    openDocEntry(app, T_FN);
+    trigger.remove(); // detached before close — the resolver must not restore focus to it
+    expect(() => closeDocPane(app)).not.toThrow();
+    expect(document.querySelector('.docs-panel')).toBeNull();
+  });
+
+  it('a different surface (Cell/Rows) replacing Reference in the shared dock force-closes Reference through its own registered closer (#586)', () => {
+    const app = makeApp();
+    app.catalog.docEntry.mockResolvedValue({ status: 'missing' });
+    openDocEntry(app, T_FN);
+    expect(isDocPaneOpen(app)).toBe(true);
+    // Simulate a different occupant (e.g. results.ts's cell detail) taking
+    // over the same shared inspectorHost — showInInspector force-closes
+    // whatever is currently there first, via ITS OWN registered closer
+    // (the `() => lifecycle.close()` passed to showInInspector, not the ✕
+    // button's), which must tear Reference down just as completely.
+    showInInspector(app, document.createElement('div'), vi.fn());
+    expect(isDocPaneOpen(app)).toBe(false);
+    expect(document.querySelector('.docs-panel')).toBeNull();
+  });
 });
 
 describe('states', () => {
@@ -796,6 +834,22 @@ describe('#315 openDocDisambiguation', () => {
     closeDocPane(app);
   });
 
+  it('a candidate with no title falls back to its target name', async () => {
+    const app = makeApp();
+    app.catalog.docDisambiguate.mockResolvedValue({
+      status: 'found',
+      value: [
+        summaryOf({ target: { kind: 'setting', name: 'connect' }, title: '', summary: 'A connection setting.' }),
+        summaryOf({ target: { kind: 'function', name: 'connect' }, title: 'connect', summary: 'A connection function.' }),
+      ],
+    });
+    openDocDisambiguation(app, 'connect');
+    await Promise.resolve(); await Promise.resolve();
+    const items = document.querySelectorAll('.docs-disambiguate-link');
+    expect(items[0].textContent).toContain('connect'); // falls back to target.name
+    closeDocPane(app);
+  });
+
   it('selecting a candidate loads it in the pane and pushes the list onto the back stack; Back returns to the list', async () => {
     const app = makeApp();
     app.catalog.docDisambiguate.mockResolvedValue({
@@ -1130,25 +1184,31 @@ describe('#315 markdown-subset entries', () => {
   });
 });
 
-describe('resize uses docPanePx, not cellDrawerPx', () => {
-  it('the initial width comes from state.docPanePx', () => {
-    const app = makeApp({ state: { docPanePx: 480 } });
+// #586: the Reference pane no longer has its own persisted width or resize
+// handle — it docks into the shell-owned `inspectorHost`, sized by
+// app-shell.ts's own shared resize handle (covered in app-shell.test.ts)
+// against the single `rightInspectorPx` preference (state.test.ts). The
+// `docPanePx`-specific initial-width and drag-persist behavior this block
+// used to cover no longer exists for this pane.
+describe('docked in the shared inspector (#586)', () => {
+  it('mounts into app.dom.inspectorHost, not document.body directly, and unfolds it', () => {
+    const app = makeApp();
     app.catalog.docEntry.mockResolvedValue({ status: 'missing' });
+    expect(app.dom.inspectorHost!.hidden).toBe(true);
     openDocEntry(app, T_FN);
-    const panel = document.querySelector('.docs-panel')!;
-    expect(panel.style.width).toBe('480px');
+    expect(app.dom.inspectorHost!.hidden).toBe(false);
+    expect(app.dom.inspectorResize!.hidden).toBe(false);
+    expect(app.dom.inspectorHost!.querySelector('.docs-panel')).not.toBeNull();
     closeDocPane(app);
+    expect(app.dom.inspectorHost!.hidden).toBe(true);
   });
 
-  it('dragging the handle persists docPanePx via prefs.save', () => {
-    const app = makeApp({ state: { docPanePx: 400 } });
+  it('opening with no shell mounted (no inspectorHost/inspectorResize) never mounts, and isDocPaneOpen stays false — it must not get stuck reporting "open" for a pane nothing ever showed', () => {
+    const app = makeApp({ dom: {} });
     app.catalog.docEntry.mockResolvedValue({ status: 'missing' });
     openDocEntry(app, T_FN);
-    const handle = document.querySelector('.docs-panel .cd-resize-h')!;
-    handle.dispatchEvent(new MouseEvent('mousedown', { clientX: 700, bubbles: true }));
-    window.dispatchEvent(new MouseEvent('mousemove', { clientX: 500 }));
-    window.dispatchEvent(new MouseEvent('mouseup', {}));
-    expect(app.prefs.save).toHaveBeenCalledWith('docPanePx', expect.any(Number));
-    closeDocPane(app);
+    expect(isDocPaneOpen(app)).toBe(false);
+    expect(document.querySelector('[role="complementary"]')).toBeNull();
+    expect(() => closeDocPane(app)).not.toThrow();
   });
 });
diff --git a/tests/unit/drawer.test.ts b/tests/unit/drawer.test.ts
index 1a7038cc..4a90b863 100644
--- a/tests/unit/drawer.test.ts
+++ b/tests/unit/drawer.test.ts
@@ -6,8 +6,8 @@ const qs = (root: ParentNode, selector: string)
 
 // A minimal fixture satisfying DrawerResizeApp — only the two members
 // attachDrawerResize actually reads/writes.
-function makeResizeApp(cellDrawerPx = 560): DrawerResizeApp {
-  return { state: { cellDrawerPx }, prefs: { save: vi.fn() } };
+function makeResizeApp(rightInspectorPx = 560): DrawerResizeApp {
+  return { state: { rightInspectorPx }, prefs: { save: vi.fn() } };
 }
 
 describe('buildDrawerChrome', () => {
@@ -68,7 +68,7 @@ describe('buildDrawerChrome', () => {
 });
 
 describe('attachDrawerResize', () => {
-  it('sets the initial panel width from the persisted cellDrawerPx pref and appends a resize handle', () => {
+  it('sets the initial panel width from the persisted rightInspectorPx pref and appends a resize handle', () => {
     const app = makeResizeApp(640);
     const panel = document.createElement('div');
     document.body.appendChild(panel);
@@ -98,8 +98,8 @@ describe('attachDrawerResize', () => {
     window.dispatchEvent(new MouseEvent('mousemove', { clientX: 500 })); // 1024-500
     expect(panel.style.width).toBe('524px');
     window.dispatchEvent(new MouseEvent('mouseup', {}));
-    expect(app.state.cellDrawerPx).toBe(524);
-    expect(app.prefs.save).toHaveBeenCalledWith('cellDrawerPx', 524);
+    expect(app.state.rightInspectorPx).toBe(524);
+    expect(app.prefs.save).toHaveBeenCalledWith('rightInspectorPx', 524);
     panel.remove();
   });
 
@@ -126,17 +126,17 @@ describe('attachDrawerResize', () => {
     const handle = qs(panel, '.cd-resize-h');
     handle.dispatchEvent(new MouseEvent('mousedown', { clientX: 700, bubbles: true }));
     window.dispatchEvent(new MouseEvent('mousemove', { clientX: 500 })); // mid-drag, no mouseup yet
-    expect(app.state.cellDrawerPx).toBe(524);
+    expect(app.state.rightInspectorPx).toBe(524);
 
     cancelDrag(); // e.g. a caller's close() firing while the mouse button is still down
-    expect(app.state.cellDrawerPx).toBe(560); // reverted — the abandoned drag never committed
+    expect(app.state.rightInspectorPx).toBe(560); // reverted — the abandoned drag never committed
     expect(app.prefs.save).not.toHaveBeenCalled();
 
     // Torn down, not just left to resolve later: a stray mousemove/mouseup
     // must not resurrect or persist the cancelled drag.
     window.dispatchEvent(new MouseEvent('mousemove', { clientX: 100 }));
     window.dispatchEvent(new MouseEvent('mouseup', {}));
-    expect(app.state.cellDrawerPx).toBe(560);
+    expect(app.state.rightInspectorPx).toBe(560);
     expect(app.prefs.save).not.toHaveBeenCalled();
     panel.remove();
   });
@@ -147,7 +147,7 @@ describe('attachDrawerResize', () => {
     const cancelDrag = attachDrawerResize(app, panel, document);
     expect(() => cancelDrag()).not.toThrow();
     expect(() => cancelDrag()).not.toThrow(); // idempotent
-    expect(app.state.cellDrawerPx).toBe(560);
+    expect(app.state.rightInspectorPx).toBe(560);
     expect(app.prefs.save).not.toHaveBeenCalled();
   });
 
@@ -159,54 +159,4 @@ describe('attachDrawerResize', () => {
     attachDrawerResize(app, panel, detachedDoc);
     expect(panel.style.width).toBe(1024 * 0.92 + 'px');
   });
-
-  // #313: the docs pane reuses this same resize wiring against its OWN
-  // persisted width, via `{ stateKey: 'docPanePx', axis: 'docPane' }` — it
-  // must never read or persist `cellDrawerPx`.
-  describe('stateKey/axis option (#313 docPanePx)', () => {
-    function makeDocPaneApp(docPanePx = 400, cellDrawerPx = 560): DrawerResizeApp {
-      return { state: { cellDrawerPx, docPanePx }, prefs: { save: vi.fn() } };
-    }
-
-    it('sets the initial width from docPanePx (not cellDrawerPx)', () => {
-      const app = makeDocPaneApp(480, 999);
-      const panel = document.createElement('div');
-      document.body.appendChild(panel);
-      attachDrawerResize(app, panel, document, { stateKey: 'docPanePx', axis: 'docPane' });
-      expect(panel.style.width).toBe('480px');
-      panel.remove();
-    });
-
-    it('drag resizes+persists docPanePx and never touches cellDrawerPx', () => {
-      const app = makeDocPaneApp(400, 777);
-      const panel = document.createElement('div');
-      document.body.appendChild(panel);
-      attachDrawerResize(app, panel, document, { stateKey: 'docPanePx', axis: 'docPane' });
-      const handle = qs(panel, '.cd-resize-h');
-      handle.dispatchEvent(new MouseEvent('mousedown', { clientX: 700, bubbles: true }));
-      window.dispatchEvent(new MouseEvent('mousemove', { clientX: 500 })); // 1024-500
-      expect(panel.style.width).toBe('524px');
-      expect(app.state.docPanePx).toBe(524);
-      expect(app.state.cellDrawerPx).toBe(777); // untouched
-      window.dispatchEvent(new MouseEvent('mouseup', {}));
-      expect(app.prefs.save).toHaveBeenCalledWith('docPanePx', 524);
-      expect(app.prefs.save).not.toHaveBeenCalledWith('cellDrawerPx', expect.anything());
-      panel.remove();
-    });
-
-    it("cancel() reverts docPanePx (not cellDrawerPx) and doesn't persist", () => {
-      const app = makeDocPaneApp(400, 777);
-      const panel = document.createElement('div');
-      document.body.appendChild(panel);
-      const cancelDrag = attachDrawerResize(app, panel, document, { stateKey: 'docPanePx', axis: 'docPane' });
-      const handle = qs(panel, '.cd-resize-h');
-      handle.dispatchEvent(new MouseEvent('mousedown', { clientX: 700, bubbles: true }));
-      window.dispatchEvent(new MouseEvent('mousemove', { clientX: 500 }));
-      expect(app.state.docPanePx).toBe(524);
-      cancelDrag();
-      expect(app.state.docPanePx).toBe(400);
-      expect(app.prefs.save).not.toHaveBeenCalled();
-      panel.remove();
-    });
-  });
 });
diff --git a/tests/unit/inspector-host.test.ts b/tests/unit/inspector-host.test.ts
new file mode 100644
index 00000000..10c18b3d
--- /dev/null
+++ b/tests/unit/inspector-host.test.ts
@@ -0,0 +1,150 @@
+import { describe, it, expect, vi } from 'vitest';
+import {
+  showInInspector, releaseInspector, closeInspector, isInspectorOpen,
+} from '../../src/ui/inspector-host.js';
+import type { InspectorHostApp } from '../../src/ui/inspector-host.js';
+
+// Occupancy is keyed by the HOST ELEMENT (a WeakMap, not one bare module
+// global) — each test's own fresh `inspectorHost` is therefore already
+// isolated from every other test's, with no shared module state to reset
+// between them (unlike dialog-shell.test.ts's single module-local
+// `openHandle`, which needs an `afterEach` to force-close a leftover dialog).
+// Typed with both nodes required (narrower than InspectorHostApp's own
+// optional fields) so ordinary tests below read `app.dom.inspectorHost`
+// without a null-check — the dedicated "no host mounted yet" describe block
+// further down builds its own explicitly-partial fixtures instead.
+function makeApp(): { dom: { inspectorHost: HTMLElement; inspectorResize: HTMLElement } } {
+  return {
+    dom: {
+      inspectorHost: document.createElement('div'),
+      inspectorResize: document.createElement('div'),
+    },
+  };
+}
+
+describe('showInInspector / releaseInspector / closeInspector', () => {
+  it('unfolds the host: unhides both nodes, mounts the content, and returns true', () => {
+    const app = makeApp();
+    const content = document.createElement('p');
+    content.textContent = 'cell value';
+    expect(showInInspector(app, content, vi.fn())).toBe(true);
+    expect(app.dom.inspectorHost.hidden).toBe(false);
+    expect(app.dom.inspectorResize.hidden).toBe(false);
+    expect(app.dom.inspectorHost.firstElementChild).toBe(content);
+    expect(isInspectorOpen(app)).toBe(true);
+  });
+
+  it('releaseInspector folds the host: hides both nodes and clears content', () => {
+    const app = makeApp();
+    showInInspector(app, document.createElement('p'), vi.fn());
+    releaseInspector(app);
+    expect(app.dom.inspectorHost.hidden).toBe(true);
+    expect(app.dom.inspectorResize.hidden).toBe(true);
+    expect(app.dom.inspectorHost.children).toHaveLength(0);
+    expect(isInspectorOpen(app)).toBe(false);
+  });
+
+  it('closeInspector is a no-op when the inspector is already folded', () => {
+    const app = makeApp();
+    expect(isInspectorOpen(app)).toBe(false);
+    expect(() => closeInspector(app)).not.toThrow();
+    expect(isInspectorOpen(app)).toBe(false);
+  });
+
+  it('closeInspector calls the current occupant\'s own close()', () => {
+    const app = makeApp();
+    const close = vi.fn(() => releaseInspector(app));
+    showInInspector(app, document.createElement('p'), close);
+    closeInspector(app);
+    expect(close).toHaveBeenCalledTimes(1);
+    expect(app.dom.inspectorHost.hidden).toBe(true);
+  });
+
+  it('a fresh showInInspector force-closes the current occupant BEFORE mounting the new content', () => {
+    const app = makeApp();
+    const order: string[] = [];
+    const firstClose = vi.fn(() => { order.push('first-close'); releaseInspector(app); });
+    const first = document.createElement('p');
+    first.textContent = 'rows viewer';
+    showInInspector(app, first, firstClose);
+
+    const second = document.createElement('p');
+    second.textContent = 'cell detail';
+    order.push('opening-second');
+    showInInspector(app, second, vi.fn());
+
+    // The outgoing occupant's close() ran before the new content was mounted —
+    // never the other way around (which would let the outgoing teardown
+    // clobber the incoming content).
+    expect(order).toEqual(['opening-second', 'first-close']);
+    expect(firstClose).toHaveBeenCalledTimes(1);
+    expect(app.dom.inspectorHost.firstElementChild).toBe(second);
+    expect(app.dom.inspectorHost.children).toHaveLength(1);
+    expect(app.dom.inspectorHost.hidden).toBe(false);
+    expect(isInspectorOpen(app)).toBe(true);
+  });
+
+  it('only one occupant is ever tracked — closing the CURRENT occupant after it was already replaced is inert (idempotent close on the stale occupant, per SurfaceLifecycle)', () => {
+    const app = makeApp();
+    let released = false;
+    const staleClose = vi.fn(() => { released = true; releaseInspector(app); });
+    showInInspector(app, document.createElement('p'), staleClose);
+    // Replace it — staleClose already ran once as part of this force-close.
+    showInInspector(app, document.createElement('p'), vi.fn());
+    expect(staleClose).toHaveBeenCalledTimes(1);
+    expect(released).toBe(true);
+    // The still-mounted second occupant is untouched by the stale reference —
+    // there is nothing left pointing at it for a caller to mistakenly re-invoke.
+    expect(app.dom.inspectorHost.children).toHaveLength(1);
+  });
+
+  it('two independent shells (two host elements) never interfere with each other', () => {
+    const appA = makeApp();
+    const appB = makeApp();
+    const closeA = vi.fn(() => releaseInspector(appA));
+    const closeB = vi.fn(() => releaseInspector(appB));
+    showInInspector(appA, document.createElement('p'), closeA);
+    showInInspector(appB, document.createElement('p'), closeB);
+    expect(isInspectorOpen(appA)).toBe(true);
+    expect(isInspectorOpen(appB)).toBe(true);
+    closeInspector(appA);
+    expect(closeA).toHaveBeenCalledTimes(1);
+    expect(closeB).not.toHaveBeenCalled();
+    expect(isInspectorOpen(appA)).toBe(false);
+    expect(isInspectorOpen(appB)).toBe(true);
+  });
+
+  // Every AppDom render-target field is optional (matching results.ts's own
+  // `resultsRegion` convention) — a real shell always sets both nodes
+  // synchronously at mount, before any surface can call in here, but this
+  // module never assumes it. These never fire in production; they exist so a
+  // caller whose shell hasn't mounted yet (or a narrow test fixture) degrades
+  // to a harmless no-op instead of throwing.
+  describe('no host mounted yet (AppDom fields absent)', () => {
+    it('isInspectorOpen/closeInspector are inert', () => {
+      const bare: InspectorHostApp = { dom: {} };
+      expect(isInspectorOpen(bare)).toBe(false);
+      expect(() => closeInspector(bare)).not.toThrow();
+    });
+
+    it('showInInspector is a no-op that returns false when either node is missing', () => {
+      const noHost: InspectorHostApp = { dom: { inspectorResize: document.createElement('div') } };
+      expect(showInInspector(noHost, document.createElement('p'), vi.fn())).toBe(false);
+      expect(isInspectorOpen(noHost)).toBe(false);
+
+      const noResize: InspectorHostApp = { dom: { inspectorHost: document.createElement('div') } };
+      expect(showInInspector(noResize, document.createElement('p'), vi.fn())).toBe(false);
+      expect(isInspectorOpen(noResize)).toBe(false);
+    });
+
+    it('releaseInspector is a no-op with no host, and tolerates a missing resize handle', () => {
+      const bare: InspectorHostApp = { dom: {} };
+      expect(() => releaseInspector(bare)).not.toThrow();
+
+      const hostOnly: InspectorHostApp = { dom: { inspectorHost: document.createElement('div') } };
+      showInInspector(hostOnly, document.createElement('p'), vi.fn()); // no-op (no resize node)
+      expect(() => releaseInspector(hostOnly)).not.toThrow();
+      expect(hostOnly.dom.inspectorHost!.hidden).toBe(true);
+    });
+  });
+});
diff --git a/tests/unit/results.test.ts b/tests/unit/results.test.ts
index ba59faef..b9da2027 100644
--- a/tests/unit/results.test.ts
+++ b/tests/unit/results.test.ts
@@ -190,7 +190,8 @@ describe('renderResults states', () => {
     const app = appWithResult(tableResult(), { resultView: 'table' });
     renderResults(app);
     click(qs(app.dom.resultsRegion, '.res-table tbody td.cell'));
-    expect(qs(document, '.cd-backdrop')).not.toBeNull();
+    expect(app.dom.inspectorHost.hidden).toBe(false);
+    expect(qs(app.dom.inspectorHost, '.cd-panel')).not.toBeNull();
   });
   it('clicking a Logs panel message opens detail through the panel callback', () => {
     const r = tableResult();
@@ -203,7 +204,8 @@ describe('renderResults states', () => {
     app.activeTab().specParsed!.panel = { cfg: { type: 'logs' } };
     renderResults(app);
     click(qs(app.dom.resultsRegion, '.log-msg'));
-    expect(qs(document, '.cd-backdrop')).not.toBeNull();
+    expect(app.dom.inspectorHost.hidden).toBe(false);
+    expect(qs(app.dom.inspectorHost, '.cd-panel')).not.toBeNull();
   });
   it('a cancelled result shows the "Cancelled · partial" badge with Copy/Export', () => {
     const r = tableResult();
@@ -361,8 +363,8 @@ describe('renderTable', () => {
     const cell = qs(el, 'tbody td.cell');
     expect(qs(cell, '.cell-val')).not.toBeNull();
     click(cell);
-    expect(qs(app.document, '.cd-backdrop')).not.toBeNull();
-    qs(app.document, '.cd-backdrop').remove(); // cleanup
+    expect(app.dom.inspectorHost.hidden).toBe(false);
+    expect(qs(app.dom.inspectorHost, '.cd-panel')).not.toBeNull();
   });
   it('truncates very large result sets', () => {
     const r = newResult('Table');
@@ -479,199 +481,159 @@ describe('column resize', () => {
 });
 
 describe('openCellDetail', () => {
-  it('text value → pretty 
, no toggle; closes via ✕', () => {
+  it('text value → pretty 
, no toggle; docks into app.dom.inspectorHost; closes via ✕', () => {
     const app = makeApp();
     openCellDetail(app, 'col', 'String', '{"a":1}');
-    const bd = qs(document, '.cd-backdrop');
-    expect(bd).not.toBeNull();
-    expect(qs(bd, '.cd-name').textContent).toBe('col');
-    expect(qs(bd, '.cd-type').textContent).toBe('String');
-    expect(qs(bd, '.cd-pre').textContent).toBe('{\n  "a": 1\n}');
-    expect(qs(bd, '.cd-toggle')).toBeNull();
-    click(qs(bd, '.cd-close'));
-    expect(qs(document, '.cd-backdrop')).toBeNull();
+    const host = app.dom.inspectorHost;
+    expect(host.hidden).toBe(false);
+    expect(app.dom.inspectorResize.hidden).toBe(false);
+    const panel = qs(host, '.cd-panel');
+    expect(panel).not.toBeNull();
+    expect(qs(panel, '.cd-name').textContent).toBe('col');
+    expect(qs(panel, '.cd-type').textContent).toBe('String');
+    expect(qs(panel, '.cd-pre').textContent).toBe('{\n  "a": 1\n}');
+    expect(qs(panel, '.cd-toggle')).toBeNull();
+    click(qs(panel, '.cd-close'));
+    expect(host.hidden).toBe(true);
+    expect(host.children).toHaveLength(0);
   });
   it('null value + no type → empty pre, no type chip', () => {
-    openCellDetail(makeApp(), 'c', '', null);
-    const bd = qs(document, '.cd-backdrop');
-    expect(qs(bd, '.cd-type')).toBeNull();
-    expect(qs(bd, '.cd-pre').textContent).toBe('');
-    bd.remove();
+    const app = makeApp();
+    openCellDetail(app, 'c', '', null);
+    const panel = qs(app.dom.inspectorHost, '.cd-panel');
+    expect(qs(panel, '.cd-type')).toBeNull();
+    expect(qs(panel, '.cd-pre').textContent).toBe('');
   });
   it('HTML value → Rendered (sandboxed iframe srcdoc) ↔ Source toggle', () => {
-    openCellDetail(makeApp(), 'html', 'String', 'hi');
-    const bd = qs(document, '.cd-backdrop');
-    expect([...qsa(bd, '.cd-seg')].map((s) => s.textContent)).toEqual(['Rendered', 'Source']);
-    const frame = qs(bd, 'iframe.cd-frame');
+    const app = makeApp();
+    openCellDetail(app, 'html', 'String', 'hi');
+    const panel = qs(app.dom.inspectorHost, '.cd-panel');
+    expect([...qsa(panel, '.cd-seg')].map((s) => s.textContent)).toEqual(['Rendered', 'Source']);
+    const frame = qs(panel, 'iframe.cd-frame');
     expect(frame.getAttribute('sandbox')).toBe('');
     expect(frame.getAttribute('srcdoc')).toBe('hi');
-    click(qsa(bd, '.cd-seg')[1]); // → Source
-    expect(qs(bd, 'iframe')).toBeNull();
-    expect(qs(bd, '.cd-pre').textContent).toBe('hi');
-    click(qsa(bd, '.cd-seg')[0]); // → Rendered again
-    expect(qs(bd, 'iframe.cd-frame')).not.toBeNull();
-    bd.remove();
+    click(qsa(panel, '.cd-seg')[1]); // → Source
+    expect(qs(panel, 'iframe')).toBeNull();
+    expect(qs(panel, '.cd-pre').textContent).toBe('hi');
+    click(qsa(panel, '.cd-seg')[0]); // → Rendered again
+    expect(qs(panel, 'iframe.cd-frame')).not.toBeNull();
   });
   it('Markdown value → Rendered (doc viewer) ↔ Source toggle (#332)', () => {
-    openCellDetail(makeApp(), 'notes', 'String', '# Title\n\n- one\n- two\n\n[link](https://example.com)');
-    const bd = qs(document, '.cd-backdrop');
-    expect([...qsa(bd, '.cd-seg')].map((s) => s.textContent)).toEqual(['Rendered', 'Source']);
+    const app = makeApp();
+    openCellDetail(app, 'notes', 'String', '# Title\n\n- one\n- two\n\n[link](https://example.com)');
+    const panel = qs(app.dom.inspectorHost, '.cd-panel');
+    expect([...qsa(panel, '.cd-seg')].map((s) => s.textContent)).toEqual(['Rendered', 'Source']);
     // Rendered by default, using the shared doc-markdown viewer (`.docs-md`).
-    const md = qs(bd, '.docs-md');
+    const md = qs(panel, '.docs-md');
     expect(md).not.toBeNull();
     expect(qs(md, 'h4')?.textContent).toBe('Title'); // doc viewer offsets headings (level1 → h4)
     expect(qsa(md, 'li').length).toBe(2);
     expect(qs(md, 'a')?.getAttribute('href')).toBe('https://example.com');
-    expect(qs(bd, 'iframe')).toBeNull(); // Markdown never uses the HTML iframe path
-    click(qsa(bd, '.cd-seg')[1]); // → Source
-    expect(qs(bd, '.docs-md')).toBeNull();
-    expect(qs(bd, '.cd-pre').textContent).toContain('# Title');
-    click(qsa(bd, '.cd-seg')[0]); // → Rendered again
-    expect(qs(bd, '.docs-md')).not.toBeNull();
-    bd.remove();
+    expect(qs(panel, 'iframe')).toBeNull(); // Markdown never uses the HTML iframe path
+    click(qsa(panel, '.cd-seg')[1]); // → Source
+    expect(qs(panel, '.docs-md')).toBeNull();
+    expect(qs(panel, '.cd-pre').textContent).toContain('# Title');
+    click(qsa(panel, '.cd-seg')[0]); // → Rendered again
+    expect(qs(panel, '.docs-md')).not.toBeNull();
   });
   it('plain (non-HTML, non-Markdown) text stays a source-only view — no toggle', () => {
-    openCellDetail(makeApp(), 'c', 'String', 'just a plain sentence with no markup.');
-    const bd = qs(document, '.cd-backdrop');
-    expect(qs(bd, '.cd-toggle')).toBeNull();
-    expect(qs(bd, '.docs-md')).toBeNull();
-    expect(qs(bd, '.cd-pre').textContent).toBe('just a plain sentence with no markup.');
-    bd.remove();
-  });
-  it('Escape closes; backdrop click closes; panel click does not', () => {
     const app = makeApp();
+    openCellDetail(app, 'c', 'String', 'just a plain sentence with no markup.');
+    const panel = qs(app.dom.inspectorHost, '.cd-panel');
+    expect(qs(panel, '.cd-toggle')).toBeNull();
+    expect(qs(panel, '.docs-md')).toBeNull();
+    expect(qs(panel, '.cd-pre').textContent).toBe('just a plain sentence with no markup.');
+  });
+  // #586: the docked model is non-modal and has no backdrop — Escape always
+  // closes it (escapePolicy 'always', no keyboard-owner acquisition, so the
+  // rest of the app stays reachable while it's open), and there is no
+  // outside-click-to-close behavior anymore (a docked panel is a normal
+  // layout sibling, not an overlay the user can click "outside" of).
+  it('Escape closes the docked panel; opening again after re-opens cleanly', () => {
+    const app = makeApp();
+    const host = app.dom.inspectorHost;
     openCellDetail(app, 'c', 'String', 'x');
+    expect(host.hidden).toBe(false);
     document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
-    expect(qs(document, '.cd-backdrop')).toBeNull();
+    expect(host.hidden).toBe(true);
+    expect(host.children).toHaveLength(0);
     openCellDetail(app, 'c', 'String', 'x');
-    backdropClick(qs(document, '.cd-backdrop'));
-    expect(qs(document, '.cd-backdrop')).toBeNull();
+    expect(host.hidden).toBe(false);
+    expect(qs(host, '.cd-panel')).not.toBeNull();
+  });
+  it('closing restores focus to whatever was focused when it opened (new in #586 — neither the drawer nor the rows viewer restored focus before)', () => {
+    const app = makeApp();
+    const trigger = document.createElement('button');
+    document.body.appendChild(trigger);
+    trigger.focus();
     openCellDetail(app, 'c', 'String', 'x');
-    backdropClick(qs(document, '.cd-panel')); // mousedown+click inside the panel → stays open
-    expect(qs(document, '.cd-backdrop')).not.toBeNull();
-    qs(document, '.cd-backdrop').remove();
+    document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
+    expect(document.activeElement).toBe(trigger);
+    trigger.remove();
   });
-  it('a gesture starting inside the panel and ending (mouseup/click) on the backdrop does not close it (#110)', () => {
+  it('opening Cell while it is already open replaces the panel in place (same shared dock)', () => {
     const app = makeApp();
-    openCellDetail(app, 'c', 'String', 'a selectable value');
-    const backdrop = qs(document, '.cd-backdrop');
-    const pre = qs(backdrop, '.cd-pre');
-    pre.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); // drag starts inside the panel
-    // The click that follows targets the backdrop directly — the nearest
-    // common ancestor of the mousedown (inside .cd-pre) and mouseup targets.
-    backdrop.dispatchEvent(new MouseEvent('click', { bubbles: true }));
-    expect(qs(document, '.cd-backdrop')).not.toBeNull();
-    backdropClick(backdrop); // a later, genuine backdrop click still closes it
-    expect(qs(document, '.cd-backdrop')).toBeNull();
-  });
-  it('builds in a given targetDoc instead of the main document (detached-tab safe)', () => {
+    openCellDetail(app, 'first', 'String', 'a');
+    openCellDetail(app, 'second', 'String', 'b');
+    const host = app.dom.inspectorHost;
+    expect(host.hidden).toBe(false);
+    expect(host.children).toHaveLength(1);
+    expect(qs(host, '.cd-name').textContent).toBe('second');
+  });
+  it('builds in a given targetDoc that is a genuinely separate document — the pre-#586 self-contained overlay, not the dock', () => {
     const childDoc = document.implementation.createHTMLDocument('');
     openCellDetail(makeApp(), 'c', 'String', 'x', childDoc);
-    expect(qs(document, '.cd-backdrop')).toBeNull(); // not in the main document
-    const bd = qs(childDoc, '.cd-backdrop');
+    expect(qs(document, '.cell-detail-overlay')).toBeNull(); // not in the main document
+    const bd = qs(childDoc, '.cell-detail-overlay');
     expect(bd).not.toBeNull();
     expect(qs(bd, '.cd-name').textContent).toBe('c');
     // the Rendered/Source toggle (a later callback) also lands in the same doc
     openCellDetail(makeApp(), 'html', 'String', 'hi', childDoc);
-    const bd2 = [...qsa(childDoc, '.cd-backdrop')].at(-1)!;
+    const bd2 = [...qsa(childDoc, '.cell-detail-overlay')].at(-1)!;
     click(qsa(bd2, '.cd-seg')[1]); // → Source
     expect(qs(bd2, '.cd-pre').ownerDocument).toBe(childDoc);
   });
-});
-
-describe('cell-detail drawer resize (#101)', () => {
-  it('sets the initial width from the persisted cellDrawerPx pref, and shows a handle', () => {
-    const app = makeApp();
-    app.state.cellDrawerPx = 640;
-    openCellDetail(app, 'c', 'String', 'x');
-    const panel = qs(document, '.cd-panel');
-    expect(panel.style.width).toBe('640px');
-    expect(qs(panel, '.cd-resize-h')).not.toBeNull();
-    panel.closest('.cd-backdrop')!.remove();
-  });
-  it('clamps the initial width to [320, 92vw] (window.innerWidth = 1024 under happy-dom)', () => {
-    const tooNarrow = makeApp();
-    tooNarrow.state.cellDrawerPx = 100;
-    openCellDetail(tooNarrow, 'c', 'String', 'x');
-    expect(qs(document, '.cd-panel').style.width).toBe('320px');
-    qs(document, '.cd-backdrop').remove();
-
-    const tooWide = makeApp();
-    tooWide.state.cellDrawerPx = 5000;
-    openCellDetail(tooWide, 'c', 'String', 'x');
-    expect(qs(document, '.cd-panel').style.width).toBe(1024 * 0.92 + 'px');
-    qs(document, '.cd-backdrop').remove();
-  });
-  it('dragging the handle resizes the panel and persists the width on mouseup', () => {
-    const app = makeApp();
-    openCellDetail(app, 'c', 'String', 'x');
-    const panel = qs(document, '.cd-panel');
-    const handle = qs(panel, '.cd-resize-h');
-    handle.dispatchEvent(new MouseEvent('mousedown', { clientX: 700, bubbles: true }));
-    window.dispatchEvent(new MouseEvent('mousemove', { clientX: 500 })); // 1024-500
-    expect(panel.style.width).toBe('524px');
-    window.dispatchEvent(new MouseEvent('mouseup', {}));
-    expect(app.state.cellDrawerPx).toBe(524);
-    expect(app.prefs.save).toHaveBeenCalledWith('cellDrawerPx', 524);
-    qs(document, '.cd-backdrop').remove();
-  });
-  it('clamps mid-drag width to [320, 92vw]', () => {
-    const app = makeApp();
-    openCellDetail(app, 'c', 'String', 'x');
-    const panel = qs(document, '.cd-panel');
-    const handle = qs(panel, '.cd-resize-h');
-    handle.dispatchEvent(new MouseEvent('mousedown', { clientX: 700, bubbles: true }));
-    window.dispatchEvent(new MouseEvent('mousemove', { clientX: 2000 })); // 1024-2000 < 0 → floor
-    expect(panel.style.width).toBe('320px');
-    window.dispatchEvent(new MouseEvent('mousemove', { clientX: -2000 })); // way over → 92vw cap
-    expect(panel.style.width).toBe(1024 * 0.92 + 'px');
-    window.dispatchEvent(new MouseEvent('mouseup', {}));
-    qs(document, '.cd-backdrop').remove();
-  });
-  it('finishing a resize drag with the mouse over the backdrop does not close the drawer; a later genuine click still does', () => {
+  it('a genuinely separate targetDoc still supports Escape / backdrop click / ✕ (the surviving non-docked overlay)', () => {
+    const childDoc = document.implementation.createHTMLDocument('');
     const app = makeApp();
-    openCellDetail(app, 'c', 'String', 'x');
-    const backdrop = qs(document, '.cd-backdrop');
-    const handle = qs(backdrop, '.cd-resize-h');
-    handle.dispatchEvent(new MouseEvent('mousedown', { clientX: 700, bubbles: true }));
-    window.dispatchEvent(new MouseEvent('mousemove', { clientX: 500 }));
-    window.dispatchEvent(new MouseEvent('mouseup', {}));
-    // The browser follows a drag's mouseup with a `click` targeting the nearest
-    // common ancestor of the mousedown/mouseup targets — here, since mouseup
-    // landed outside `.cd-panel`, that's the backdrop itself. attachBackdropClose
-    // (#110) gates close() on the mousedown target (the handle, inside the
-    // panel), so this click alone does not close it.
-    backdrop.dispatchEvent(new MouseEvent('click', { bubbles: true }));
-    expect(qs(document, '.cd-backdrop')).not.toBeNull(); // stays open
-    backdropClick(backdrop); // a later, genuine backdrop click still closes it
-    expect(qs(document, '.cd-backdrop')).toBeNull();
-  });
-  it('closing the drawer mid-drag (Escape, mouse still down) cancels the drag: reverts the width, and does not leak listeners that swallow a later click or persist a stale width on a later mouseup', () => {
+    openCellDetail(app, 'c', 'String', 'x', childDoc);
+    let bd = qs(childDoc, '.cell-detail-overlay');
+    childDoc.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
+    expect(qs(childDoc, '.cell-detail-overlay')).toBeNull();
+
+    openCellDetail(app, 'c', 'String', 'x', childDoc);
+    bd = qs(childDoc, '.cell-detail-overlay');
+    backdropClick(bd);
+    expect(qs(childDoc, '.cell-detail-overlay')).toBeNull();
+
+    openCellDetail(app, 'c', 'String', 'x', childDoc);
+    bd = qs(childDoc, '.cell-detail-overlay');
+    backdropClick(qs(bd, '.cd-panel')); // mousedown+click inside the panel → stays open
+    expect(qs(childDoc, '.cell-detail-overlay')).not.toBeNull();
+    click(qs(bd, '.cd-close'));
+    expect(qs(childDoc, '.cell-detail-overlay')).toBeNull();
+  });
+  it('the same-document overlay branch (opts.overlay, e.g. the detached Data Pane fallback) behaves identically to a real separate tab', () => {
     const app = makeApp();
-    app.state.cellDrawerPx = 560;
-    openCellDetail(app, 'c', 'String', 'x');
-    const handle = qs(document, '.cd-resize-h');
-    handle.dispatchEvent(new MouseEvent('mousedown', { clientX: 700, bubbles: true }));
-    window.dispatchEvent(new MouseEvent('mousemove', { clientX: 500 })); // mid-drag, no mouseup yet
-    expect(app.state.cellDrawerPx).toBe(524);
-    document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); // closes while still dragging
-    expect(qs(document, '.cd-backdrop')).toBeNull();
-    expect(app.state.cellDrawerPx).toBe(560); // reverted — the abandoned drag never committed
-
-    // The drag's own mousemove/mouseup listeners must have been torn down by
-    // the cancel, not just left to resolve later.
-    window.dispatchEvent(new MouseEvent('mousemove', { clientX: 100 }));
-    window.dispatchEvent(new MouseEvent('mouseup', {}));
-    expect(app.state.cellDrawerPx).toBe(560); // a stray mouseup doesn't resurrect + persist the drag
-    expect(app.prefs.save).not.toHaveBeenCalledWith('cellDrawerPx', expect.anything());
-
-    openCellDetail(app, 'c2', 'String', 'y'); // an unrelated, later click must work normally
-    const backdrop2 = qs(document, '.cd-backdrop');
-    backdropClick(backdrop2);
-    expect(qs(document, '.cd-backdrop')).toBeNull();
+    openCellDetail(app, 'c', 'String', 'x', app.document, { overlay: true });
+    // Forced overlay even though targetDoc === app.document: never docks.
+    expect(app.dom.inspectorHost.hidden).toBe(true);
+    const bd = qs(document, '.cell-detail-overlay');
+    expect(bd).not.toBeNull();
+    backdropClick(bd);
+    expect(qs(document, '.cell-detail-overlay')).toBeNull();
   });
 });
 
+// #586: the docked cell-detail/rows-viewer no longer have their own resize
+// handle or persisted-width read at open time — resize is shell-owned now
+// (app-shell.ts's own handle against `app.dom.inspectorHost`, exercised in
+// app-shell.test.ts), so the per-panel `.cd-resize-h`/`cellDrawerPx` behavior
+// this block used to cover no longer exists for the docked path. The ONE
+// surviving `attachDrawerResize` consumer (the detached-doc overlay) keeps
+// equivalent coverage in drawer.test.ts + the openCellDetail targetDoc tests
+// above.
+
 describe('expandDataPane', () => {
   // A window/fetch-tab stub only ever needs the few members real code reads
   // (document/close/focus/addEventListener) — never the real `Window`
@@ -709,12 +671,13 @@ describe('expandDataPane', () => {
     expect(firstRowFirstCell.textContent).toBe('1'); // ascending on 'n' → '1' before '2'
   });
 
-  it('clicking a cell in the overlay snapshot opens the cell-detail drawer in the same document', () => {
+  it('clicking a cell in the overlay snapshot opens the cell-detail overlay in the same document (never the docked inspector — the Data Pane already covers it)', () => {
     const app = makeApp();
     expandDataPane(app, tableResult());
     const overlay = qs(document, '.graph-overlay');
     click(qsa(overlay, '.res-table tbody td.cell')[0]);
-    expect(qs(document, '.cd-backdrop')).not.toBeNull();
+    expect(qs(document, '.cell-detail-overlay')).not.toBeNull();
+    expect(app.dom.inspectorHost.hidden).toBe(true);
   });
 
   it('real tab: builds the grid + toolbar in the child document, Copy targets that document', () => {
@@ -729,8 +692,8 @@ describe('expandDataPane', () => {
     expect(app.actions.copySnapshot).toHaveBeenCalledWith(r, win.document);
     // a cell click inside the tab opens the drawer in the TAB's document, not the main one
     click(qsa(win.document, '.res-table tbody td.cell')[0]);
-    expect(qs(win.document, '.cd-backdrop')).not.toBeNull();
-    expect(qs(document, '.cd-backdrop')).toBeNull();
+    expect(qs(win.document, '.cell-detail-overlay')).not.toBeNull();
+    expect(qs(document, '.cell-detail-overlay')).toBeNull();
   });
 
   it('does not repaint when the main app renders a new result: no signal/effect wiring ties the two together', () => {
@@ -755,7 +718,7 @@ describe('expandDataPane', () => {
     expect(barChildren.at(-1)!.className).toBe('graph-overlay-close');
     click(qsa(overlay, '.res-table tbody td.cell')[0]); // opens a cell drawer
     document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
-    expect(qs(document, '.cd-backdrop')).toBeNull(); // Escape closed the drawer first
+    expect(qs(document, '.cell-detail-overlay')).toBeNull(); // Escape closed the drawer first
     expect(document.body.contains(overlay)).toBe(true); // pane itself still open
     document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); // second Escape
     expect(document.body.contains(overlay)).toBe(false);
@@ -1722,7 +1685,7 @@ describe('multiquery script grid (#83)', () => {
     renderResults(app);
     const handle = qs(app.dom.resultsRegion, '.script-grid .col-resize-h');
     handle.dispatchEvent(new MouseEvent('click', { bubbles: true }));
-    expect(qs(document, '.cd-backdrop')).toBeNull(); // nothing opened
+    expect(app.dom.inspectorHost.hidden).toBe(true); // nothing opened
   });
 
   it('flags a truncated SELECT in its row meta', () => {
@@ -1733,95 +1696,88 @@ describe('multiquery script grid (#83)', () => {
     expect(qs(app.dom.resultsRegion, '.script-cell.rows').textContent).toContain('first 100');
   });
 
-  it('clicking a SELECT row opens the rows pane; Escape and backdrop close it', () => {
+  it('clicking a SELECT row opens the docked rows pane; Escape closes it', () => {
     const app = appWithResult(scriptResult());
     renderResults(app);
     click(qs(app.dom.resultsRegion, '.script-cell.rows'));
-    let backdrop = qs(document, '.cd-backdrop');
-    expect(backdrop).not.toBeNull();
-    expect(qsa(backdrop, 'tbody tr')).toHaveLength(2); // both rows
-    expect(qs(backdrop, '.cd-type').textContent).toContain('2 rows');
+    const host = app.dom.inspectorHost;
+    expect(host.hidden).toBe(false);
+    const panel = qs(host, '.cd-panel');
+    expect(qsa(panel, 'tbody tr')).toHaveLength(2); // both rows
+    expect(qs(panel, '.cd-type').textContent).toContain('2 rows');
     document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
-    expect(qs(document, '.cd-backdrop')).toBeNull();
-    // reopen + close via backdrop click
+    expect(host.hidden).toBe(true);
+    // reopen + close via ✕
     click(qs(app.dom.resultsRegion, '.script-cell.rows'));
-    backdrop = qs(document, '.cd-backdrop');
-    backdropClick(backdrop);
-    expect(qs(document, '.cd-backdrop')).toBeNull();
+    expect(host.hidden).toBe(false);
+    click(qs(host, '.cd-close'));
+    expect(host.hidden).toBe(true);
   });
 
   it('openRowsViewer renders NULL cells empty and flags a truncated count', () => {
     const app = makeApp();
     openRowsViewer(app, { columns: [{ name: 'x', type: 'String' }, { name: 'y', type: 'String' }], rows: [['a', null]], truncated: true });
-    const backdrop = qs(document, '.cd-backdrop');
-    expect(qs(backdrop, '.cd-type').textContent).toContain('1+ row');
-    const cells = [...qsa(backdrop, 'tbody td')];
+    const panel = qs(app.dom.inspectorHost, '.cd-panel');
+    expect(qs(panel, '.cd-type').textContent).toContain('1+ row');
+    const cells = [...qsa(panel, 'tbody td')];
     expect(cells[cells.length - 1].textContent).toBe(''); // null → empty
-    backdrop.remove();
-  });
-
-  it('openRowsViewer gets the same resizable drawer as openCellDetail (#101)', () => {
-    const app = makeApp();
-    app.state.cellDrawerPx = 700;
-    openRowsViewer(app, { columns: [{ name: 'x', type: 'String' }], rows: [['a']] });
-    const panel = qs(document, '.cd-panel');
-    expect(panel.style.width).toBe('700px');
-    const handle = qs(panel, '.cd-resize-h');
-    expect(handle).not.toBeNull();
-    handle.dispatchEvent(new MouseEvent('mousedown', { clientX: 700, bubbles: true }));
-    window.dispatchEvent(new MouseEvent('mousemove', { clientX: 500 })); // 1024-500
-    expect(panel.style.width).toBe('524px');
-    window.dispatchEvent(new MouseEvent('mouseup', {}));
-    expect(app.state.cellDrawerPx).toBe(524);
-    qs(document, '.cd-backdrop').remove();
   });
 
   it('the rows pane is the shared grid: sortable headers + clickable cells', () => {
     const app = makeApp();
+    const host = app.dom.inspectorHost;
     openRowsViewer(app, { columns: [{ name: 'n', type: 'UInt64' }], rows: [['2'], ['1'], ['3']] });
-    let backdrop = qs(document, '.cd-backdrop');
+    let panel = qs(host, '.cd-panel');
     // a data column header sorts the pane in place (local sort state)
-    const colHeader = [...qsa(backdrop, 'thead th')].find((th) => th.textContent!.includes('n'));
+    const colHeader = [...qsa(panel, 'thead th')].find((th) => th.textContent!.includes('n'));
     click(colHeader);
-    backdrop = qs(document, '.cd-backdrop');
-    const firstCell = qs(backdrop, 'tbody tr td.cell .cell-val');
+    panel = qs(host, '.cd-panel');
+    const firstCell = qs(panel, 'tbody tr td.cell .cell-val');
     expect(firstCell.textContent).toBe('1'); // ascending now
-    // clicking a cell opens the (stacked) cell-detail drawer
-    click(qs(backdrop, 'tbody td.cell'));
-    expect(qsa(document, '.cd-backdrop').length).toBe(2);
-    qsa(document, '.cd-backdrop').forEach((b) => b.remove());
-  });
-
-  it('Escape closes only the topmost stacked drawer (cell first, then the rows pane)', () => {
+    // #586: clicking a cell REPLACES the rows pane with cell detail in the
+    // same shared dock — the docked model has room for exactly one occupant
+    // (a real, deliberate behavior change from the pre-#586 stacked-backdrop
+    // drawer; see openRowsViewer's own doc comment).
+    click(qs(panel, 'tbody td.cell'));
+    expect(host.children).toHaveLength(1);
+    const replaced = qs(host, '.cd-panel');
+    expect(replaced).not.toBe(panel);
+    expect(qs(replaced, '.cd-name').textContent).toBe('n');
+  });
+
+  it('Escape closes whichever surface currently occupies the dock (Cell, having replaced Rows)', () => {
     const app = makeApp();
+    const host = app.dom.inspectorHost;
     openRowsViewer(app, { columns: [{ name: 'n', type: 'String' }], rows: [['x']] });
-    expect(app.keyboardOwner?.kind).toBe('modal');
-    click(qs(document, '.cd-backdrop tbody td.cell')); // opens a stacked cell drawer
-    expect(qsa(document, '.cd-backdrop')).toHaveLength(2);
-    document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
-    expect(qsa(document, '.cd-backdrop')).toHaveLength(1); // only the cell drawer closed
-    expect(app.keyboardOwner?.kind).toBe('modal'); // rows viewer still owns the keyboard
+    click(qs(host, 'tbody td.cell')); // replaces Rows with Cell
+    expect(qs(host, '.cd-name').textContent).toBe('n');
     document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
-    expect(qsa(document, '.cd-backdrop')).toHaveLength(0); // now the rows pane
-    expect(app.keyboardOwner).toBeNull();
+    expect(host.hidden).toBe(true); // folded — nothing left to show
   });
 
-  it('blocks application shortcuts and consumes Escape before a running-query cancel', () => {
+  it('does not acquire the modal keyboard owner — the docked rows pane is non-modal, so application shortcuts stay live while it is open', () => {
     const app = makeApp();
     app.state.running.value = true;
     openRowsViewer(app, { columns: [{ name: 'n', type: 'String' }], rows: [['x']] });
+    expect(app.keyboardOwner).toBeNull();
     expect(handleKeydown({
       key: 'Enter', metaKey: true, preventDefault: vi.fn(), target: document.body,
-    }, app)).toBeNull();
-    expect(app.actions.run).not.toHaveBeenCalled();
+    }, app)).not.toBeNull();
+    expect(app.actions.run).toHaveBeenCalled();
+  });
+
+  it('Escape still closes the docked rows pane and is consumed before it can also cancel a running query', () => {
+    const app = makeApp();
+    app.state.running.value = true;
+    openRowsViewer(app, { columns: [{ name: 'n', type: 'String' }], rows: [['x']] });
     const dispatchGlobal = (event: KeyboardEvent): void => {
       handleKeydown(event as unknown as ShortcutKeydownEvent, app);
     };
     document.addEventListener('keydown', dispatchGlobal);
     document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
     document.removeEventListener('keydown', dispatchGlobal);
-    expect(qsa(document, '.cd-backdrop')).toHaveLength(0);
-    expect(app.actions.cancel).not.toHaveBeenCalled();
+    expect(app.dom.inspectorHost.hidden).toBe(true); // the pane's own capture-phase Escape closed it
+    expect(app.actions.cancel).not.toHaveBeenCalled(); // …and consumed the event before the global handler's cancel
   });
 
   it('toolbar shows live elapsed + Cancel while running, with a running footer', () => {
diff --git a/tests/unit/splitters.test.ts b/tests/unit/splitters.test.ts
index 6e973a14..ee172b85 100644
--- a/tests/unit/splitters.test.ts
+++ b/tests/unit/splitters.test.ts
@@ -29,17 +29,11 @@ describe('dragValue', () => {
     expect(dragValue('row', { clientX: 0, clientY: 100 }, rect)).toBe(15);
     expect(dragValue('row', { clientX: 0, clientY: 200 }, rect)).toBe(50);
   });
-  it('drawer maps viewportWidth-clientX to px clamped [320, 92vw]', () => {
+  it('rightInspector maps viewportWidth-clientX to px clamped [320, 92vw]', () => {
     const vw = { width: 1000 };
-    expect(dragValue('drawer', { clientX: 500, clientY: 0 }, vw)).toBe(500); // 1000-500
-    expect(dragValue('drawer', { clientX: 900, clientY: 0 }, vw)).toBe(320); // 1000-900=100 → floor
-    expect(dragValue('drawer', { clientX: -100, clientY: 0 }, vw)).toBe(920); // 1000-(-100)=1100 → 92vw cap
-  });
-  it('docPane maps viewportWidth-clientX to px clamped [320, 92vw] — same geometry as drawer (#313)', () => {
-    const vw = { width: 1000 };
-    expect(dragValue('docPane', { clientX: 500, clientY: 0 }, vw)).toBe(500);
-    expect(dragValue('docPane', { clientX: 900, clientY: 0 }, vw)).toBe(320);
-    expect(dragValue('docPane', { clientX: -100, clientY: 0 }, vw)).toBe(920);
+    expect(dragValue('rightInspector', { clientX: 500, clientY: 0 }, vw)).toBe(500); // 1000-500
+    expect(dragValue('rightInspector', { clientX: 900, clientY: 0 }, vw)).toBe(320); // 1000-900=100 → floor
+    expect(dragValue('rightInspector', { clientX: -100, clientY: 0 }, vw)).toBe(920); // 1000-(-100)=1100 → 92vw cap
   });
 });
 
@@ -102,35 +96,19 @@ describe('startDrag', () => {
     win._fire('mouseup');
     expect(save).toHaveBeenCalledWith('editorPct', 50);
   });
-  it('drawer: updates cellDrawerPx + persists', () => {
-    const win = fakeWin();
-    const handle = document.createElement('div');
-    const state = { cellDrawerPx: 0 };
-    const apply = vi.fn();
-    const save = vi.fn();
-    const ctx = { win, state, apply, save, rectFor: () => ({ width: 1000 }) };
-    startDrag({ preventDefault: vi.fn(), currentTarget: handle }, 'drawer', ctx);
-    win._fire('mousemove', { clientX: 500, clientY: 0 });
-    expect(state.cellDrawerPx).toBe(500); // 1000-500
-    expect(apply).toHaveBeenCalledWith('drawer', 500);
-    win._fire('mouseup');
-    expect(save).toHaveBeenCalledWith('cellDrawerPx', 500);
-  });
-  it('docPane: updates docPanePx + persists, independent of cellDrawerPx (#313)', () => {
+  it('rightInspector: updates rightInspectorPx + persists', () => {
     const win = fakeWin();
     const handle = document.createElement('div');
-    const state = { cellDrawerPx: 777, docPanePx: 0 };
+    const state = { rightInspectorPx: 0 };
     const apply = vi.fn();
     const save = vi.fn();
     const ctx = { win, state, apply, save, rectFor: () => ({ width: 1000 }) };
-    startDrag({ preventDefault: vi.fn(), currentTarget: handle }, 'docPane', ctx);
+    startDrag({ preventDefault: vi.fn(), currentTarget: handle }, 'rightInspector', ctx);
     win._fire('mousemove', { clientX: 500, clientY: 0 });
-    expect(state.docPanePx).toBe(500); // 1000-500
-    expect(state.cellDrawerPx).toBe(777); // untouched
-    expect(apply).toHaveBeenCalledWith('docPane', 500);
+    expect(state.rightInspectorPx).toBe(500); // 1000-500
+    expect(apply).toHaveBeenCalledWith('rightInspector', 500);
     win._fire('mouseup');
-    expect(save).toHaveBeenCalledWith('docPanePx', 500);
-    expect(save).not.toHaveBeenCalledWith('cellDrawerPx', expect.anything());
+    expect(save).toHaveBeenCalledWith('rightInspectorPx', 500);
   });
   it('defaults win to global window when ctx.win is absent', () => {
     const handle = document.createElement('div');
diff --git a/tests/unit/state.test.ts b/tests/unit/state.test.ts
index cc48eee2..b77f23a5 100644
--- a/tests/unit/state.test.ts
+++ b/tests/unit/state.test.ts
@@ -144,6 +144,7 @@ describe('KEYS — persisted localStorage key names (#459)', () => {
       sidebarPx: 'asb:sidebarPx',
       editorPct: 'asb:editorPct',
       sideSplitPct: 'asb:sideSplitPct',
+      rightInspectorPx: 'asb:rightInspectorPx',
       cellDrawerPx: 'asb:cellDrawerPx',
       docPanePx: 'asb:docPanePx',
       sidePanel: 'asb:sidePanel',
@@ -189,8 +190,7 @@ describe('createState', () => {
     expect(s.sidebarPx).toBe(248);
     expect(s.editorPct).toBe(45);
     expect(s.sideSplitPct).toBe(58);
-    expect(s.cellDrawerPx).toBe(560);
-    expect(s.docPanePx).toBe(420); // #313 — a sibling default, independent of cellDrawerPx
+    expect(s.rightInspectorPx).toBe(480); // #586 — no legacy pref present, so the hardcoded default wins
     expect(s.tabs.value).toHaveLength(1);
     expect(s.savedQueries).toEqual([]);
     expect(s.savedQueryLoadDiagnostics).toEqual([]);
@@ -221,8 +221,7 @@ describe('createState', () => {
       [KEYS.sidebarPx]: '9999', // clamps to 420
       [KEYS.editorPct]: '5', // clamps to 15
       [KEYS.sideSplitPct]: '99', // clamps to 85
-      [KEYS.cellDrawerPx]: '100', // clamps up to the 320 floor
-      [KEYS.docPanePx]: '50', // clamps up to the 320 floor, independent of cellDrawerPx
+      [KEYS.rightInspectorPx]: '100', // clamps up to the 320 floor
       [KEYS.sidePanel]: 'history',
       [KEYS.saved]: [{ id: 's1', sql: 'x', name: 'n', starred: true }],
       [KEYS.history]: [{ id: 'h1', sql: 'y', ts: 1, rows: 1, ms: 2 }],
@@ -237,8 +236,7 @@ describe('createState', () => {
     expect(s.sidebarPx).toBe(420);
     expect(s.editorPct).toBe(15);
     expect(s.sideSplitPct).toBe(85);
-    expect(s.cellDrawerPx).toBe(320);
-    expect(s.docPanePx).toBe(320); // #313
+    expect(s.rightInspectorPx).toBe(320);
     expect(s.sidePanel.value).toBe('history');
     expect(s.savedQueries).toHaveLength(1);
     expect(s.history).toHaveLength(1);
@@ -247,6 +245,33 @@ describe('createState', () => {
     expect(s.varRecent).toEqual({ version: 1, nextSeq: 3, byName: { d: [{ value: 'x', seq: 2 }] } });
     expect(s.varRecentDisabled).toBe(true);
   });
+  // #586 — rightInspectorPx collapses cellDrawerPx/docPanePx into one
+  // preference; a browser that already had either legacy value must keep it
+  // across the upgrade rather than silently reset to the default.
+  describe('rightInspectorPx compat read order (#586)', () => {
+    it('a real rightInspectorPx wins outright, even alongside stale legacy keys', () => {
+      const s = createState(reader({
+        [KEYS.rightInspectorPx]: '500',
+        [KEYS.docPanePx]: '420',
+        [KEYS.cellDrawerPx]: '560',
+      }));
+      expect(s.rightInspectorPx).toBe(500);
+    });
+    it('falls back to docPanePx when rightInspectorPx is absent, ignoring cellDrawerPx', () => {
+      const s = createState(reader({
+        [KEYS.docPanePx]: '420',
+        [KEYS.cellDrawerPx]: '560',
+      }));
+      expect(s.rightInspectorPx).toBe(420);
+    });
+    it('falls back to cellDrawerPx when neither rightInspectorPx nor docPanePx is present', () => {
+      const s = createState(reader({ [KEYS.cellDrawerPx]: '560' }));
+      expect(s.rightInspectorPx).toBe(560);
+    });
+    it('falls back to the 480 default when nothing is persisted at all', () => {
+      expect(createState(reader()).rightInspectorPx).toBe(480);
+    });
+  });
   it('defaults the reader to storage helpers', () => {
     vi.stubGlobal('localStorage', memStore({ [KEYS.theme]: 'light' }));
     const s = createState();
diff --git a/tests/unit/surface-lifecycle.test.ts b/tests/unit/surface-lifecycle.test.ts
new file mode 100644
index 00000000..b58c7380
--- /dev/null
+++ b/tests/unit/surface-lifecycle.test.ts
@@ -0,0 +1,163 @@
+import { describe, it, expect, vi } from 'vitest';
+import { openSurfaceLifecycle } from '../../src/ui/surface-lifecycle.js';
+
+const key = (target: EventTarget, k: string): boolean =>
+  target.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }));
+
+describe('openSurfaceLifecycle', () => {
+  it('close() is idempotent — a second call never re-fires onClose or throws', () => {
+    const onClose = vi.fn();
+    const panel = document.createElement('div');
+    const { close } = openSurfaceLifecycle({
+      document, escapePolicy: 'none', panel, returnFocusTo: null, onClose,
+    });
+    close();
+    expect(onClose).toHaveBeenCalledTimes(1);
+    expect(() => close()).not.toThrow();
+    close();
+    expect(onClose).toHaveBeenCalledTimes(1);
+  });
+
+  it('isOpen() reflects state before and after close()', () => {
+    const panel = document.createElement('div');
+    const { close, isOpen } = openSurfaceLifecycle({
+      document, escapePolicy: 'none', panel, returnFocusTo: null,
+    });
+    expect(isOpen()).toBe(true);
+    close();
+    expect(isOpen()).toBe(false);
+  });
+
+  describe('escapePolicy', () => {
+    it("'always' closes on Escape regardless of focus location", () => {
+      const onClose = vi.fn();
+      const panel = document.createElement('div');
+      document.body.appendChild(panel);
+      const outside = document.createElement('button');
+      document.body.appendChild(outside);
+      outside.focus();
+      const { isOpen } = openSurfaceLifecycle({
+        document, escapePolicy: 'always', panel, returnFocusTo: null, onClose,
+      });
+      key(document, 'Escape');
+      expect(isOpen()).toBe(false);
+      expect(onClose).toHaveBeenCalledTimes(1);
+      panel.remove();
+      outside.remove();
+    });
+
+    it("'focus-inside' closes only while focus is inside panel — Escape elsewhere is a no-op", () => {
+      const onClose = vi.fn();
+      const panel = document.createElement('div');
+      const inner = document.createElement('button');
+      panel.appendChild(inner);
+      document.body.appendChild(panel);
+      const outside = document.createElement('button');
+      document.body.appendChild(outside);
+
+      outside.focus();
+      const { isOpen } = openSurfaceLifecycle({
+        document, escapePolicy: 'focus-inside', panel, returnFocusTo: null, onClose,
+      });
+      key(document, 'Escape');
+      expect(isOpen()).toBe(true); // focus was outside — untouched
+      expect(onClose).not.toHaveBeenCalled();
+
+      inner.focus();
+      key(document, 'Escape');
+      expect(isOpen()).toBe(false);
+      expect(onClose).toHaveBeenCalledTimes(1);
+      panel.remove();
+      outside.remove();
+    });
+
+    it("'none' installs no Escape handling at all — the caller owns Escape entirely", () => {
+      const onClose = vi.fn();
+      const panel = document.createElement('div');
+      const { isOpen } = openSurfaceLifecycle({
+        document, escapePolicy: 'none', panel, returnFocusTo: null, onClose,
+      });
+      key(document, 'Escape');
+      expect(isOpen()).toBe(true);
+      expect(onClose).not.toHaveBeenCalled();
+    });
+
+    it("a 'none' surface's close() still tears down cleanly (no listener was ever installed to remove)", () => {
+      const panel = document.createElement('div');
+      const { close } = openSurfaceLifecycle({
+        document, escapePolicy: 'none', panel, returnFocusTo: null,
+      });
+      expect(() => close()).not.toThrow();
+    });
+  });
+
+  describe('returnFocusTo', () => {
+    it('an element is focused on close', () => {
+      const panel = document.createElement('div');
+      const target = document.createElement('button');
+      document.body.appendChild(target);
+      const { close } = openSurfaceLifecycle({
+        document, escapePolicy: 'none', panel, returnFocusTo: target,
+      });
+      close();
+      expect(document.activeElement).toBe(target);
+      target.remove();
+    });
+
+    it('a resolver is called AT close time, not at open time', () => {
+      const panel = document.createElement('div');
+      let target: HTMLButtonElement | null = null;
+      const { close } = openSurfaceLifecycle({
+        document, escapePolicy: 'none', panel, returnFocusTo: () => target,
+      });
+      target = document.createElement('button'); // created only after open()
+      document.body.appendChild(target);
+      close();
+      expect(document.activeElement).toBe(target);
+      target.remove();
+    });
+
+    it('null means nothing is focused — close() does not throw when nothing is on screen to restore to', () => {
+      const panel = document.createElement('div');
+      const { close } = openSurfaceLifecycle({
+        document, escapePolicy: 'none', panel, returnFocusTo: null,
+      });
+      expect(() => close()).not.toThrow();
+    });
+
+    it('a resolver returning null is a harmless no-op restore', () => {
+      const panel = document.createElement('div');
+      const { close } = openSurfaceLifecycle({
+        document, escapePolicy: 'none', panel, returnFocusTo: () => null,
+      });
+      expect(() => close()).not.toThrow();
+    });
+  });
+
+  describe('keyboard-owner acquisition', () => {
+    it('acquires on open and releases on close when acquireKeyboardOwner is supplied', () => {
+      const release = vi.fn();
+      const acquireKeyboardOwner = vi.fn().mockReturnValue(release);
+      const panel = document.createElement('div');
+      const { close } = openSurfaceLifecycle({
+        document, escapePolicy: 'none', panel, returnFocusTo: null, acquireKeyboardOwner,
+      });
+      expect(acquireKeyboardOwner).toHaveBeenCalledWith('modal');
+      expect(release).not.toHaveBeenCalled();
+      close();
+      expect(release).toHaveBeenCalledTimes(1);
+      close(); // idempotent — no double release
+      expect(release).toHaveBeenCalledTimes(1);
+    });
+
+    it('omitting acquireKeyboardOwner never acquires or releases anything (a non-modal surface)', () => {
+      const panel = document.createElement('div');
+      expect(() => {
+        const { close } = openSurfaceLifecycle({
+          document, escapePolicy: 'none', panel, returnFocusTo: null,
+        });
+        close();
+      }).not.toThrow();
+    });
+  });
+});

From ad88a88d25dbd96f0562c00b5140d6c2270e6ce0 Mon Sep 17 00:00:00 2001
From: Boris Tyshkevich 
Date: Mon, 3 Aug 2026 21:46:21 +0200
Subject: [PATCH 2/4] fix(#586): make the non-modal claims falsifiable and stop
 a lifecycle leak
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Three review findings on the SurfaceLifecycle phase:

1. Two assertions could no longer fail. AC3 deletes `.cd-backdrop` repo-wide,
   so `tests/e2e/editor-docs.spec.js`'s `.cd-backdrop` count probe and
   `tests/unit/doc-pane.test.ts`'s `querySelector('.cd-backdrop')` both passed
   unconditionally — a scrim reintroduced under any other class name would
   have kept reporting green. Re-pointed at the real rendered claims: the
   editor is the topmost element at its own centre (`elementFromPoint`), and
   the inspector host's child IS the panel rather than a wrapper around it.

2. AC2's "not a `position:fixed` overlay" had no automated coverage at all —
   happy-dom cannot evaluate CSS layout, so the unit suite could only prove
   DOM sibling order, `hidden`, and the inline width write. Adds
   `tests/e2e/inspector-dock-layout.spec.js`, a real-browser geometric gate
   (chromium + webkit): folded contributes zero layout width, opening narrows
   `.query-host` and the inspector's box never intersects it, and dragging the
   handle resizes live.

3. `openSurfaceLifecycle` installs its capture-phase Escape listener before
   `showInInspector`'s return value reveals whether a shell is mounted, so a
   failed mount leaked a permanent unclosable document listener. `doc-pane.ts`
   skipped only its `panes` bookkeeping; `results.ts` did not check the return
   value at all. Both now tear the lifecycle down on failed mount, covered by
   tests asserting the same listener reference is added and removed.

Co-Authored-By: Claude Fable 5 
Claude-Session: https://claude.ai/code/session_01Da66KLYSmCey6Gi7RMFGcf
---
 src/ui/doc-pane.ts                      |   8 ++
 src/ui/results.ts                       |  15 +++-
 tests/e2e/dashboard-tree.html           |  20 +++++
 tests/e2e/editor-docs.spec.js           |  20 ++++-
 tests/e2e/inspector-dock-layout.spec.js | 103 ++++++++++++++++++++++++
 tests/unit/doc-pane.test.ts             |  35 +++++++-
 tests/unit/results.test.ts              |  42 ++++++++++
 7 files changed, 238 insertions(+), 5 deletions(-)
 create mode 100644 tests/e2e/inspector-dock-layout.spec.js

diff --git a/src/ui/doc-pane.ts b/src/ui/doc-pane.ts
index c34098ec..70bb27f7 100644
--- a/src/ui/doc-pane.ts
+++ b/src/ui/doc-pane.ts
@@ -219,7 +219,15 @@ function ensurePane(app: DocPaneApp, doc: Document): PaneState {
   // `PaneState` back rather than one `isDocPaneOpen`/`closeDocPane` believe
   // is live: recording an occupant that never actually showed would leave
   // that bookkeeping stuck reporting "open" for nothing anyone can see.
+  // #586 finding 3: a failed mount must ALSO tear the just-opened
+  // `SurfaceLifecycle` back down — `openSurfaceLifecycle` installs its
+  // capture-phase Escape listener unconditionally, before this return value
+  // is known, so skipping `panes.set` alone (the pre-fix behavior) left that
+  // listener (and the `st` closure it captures) permanently attached to
+  // `doc` with no way for `isDocPaneOpen`/`closeDocPane` — both keyed off
+  // `panes`, which never got an entry — to ever reach it again.
   if (showInInspector(app, panel, () => lifecycle.close())) panes.set(doc, st);
+  else lifecycle.close();
   return st;
 }
 
diff --git a/src/ui/results.ts b/src/ui/results.ts
index 93d14bdf..5fb652c0 100644
--- a/src/ui/results.ts
+++ b/src/ui/results.ts
@@ -537,7 +537,14 @@ export function openRowsViewer(app: ResultsApp, entry: RowsViewerEntry): HTMLEle
   }));
   paint();
   panel.appendChild(body);
-  showInInspector(app, panel, () => lifecycle.close());
+  // #586 finding 3: `openSurfaceLifecycle` above already installed its
+  // capture-phase Escape listener unconditionally (before this call tells us
+  // whether a shell is even mounted) — a failed mount (no `app.dom
+  // .inspectorHost`/`inspectorResize` yet) must tear that lifecycle back
+  // down too, or the listener (and this closure) leaks forever with nothing
+  // left referencing it to close it later. Symmetric with doc-pane.ts's
+  // `ensurePane`.
+  if (!showInInspector(app, panel, () => lifecycle.close())) lifecycle.close();
   return panel;
 }
 
@@ -1326,7 +1333,11 @@ export function openCellDetail(
     });
 
     if (dock) {
-      showInInspector(app, panel, () => lifecycle.close());
+      // #586 finding 3: symmetric with `openRowsViewer`/doc-pane.ts's
+      // `ensurePane` — a failed mount must tear the just-opened
+      // `SurfaceLifecycle` down too, or its capture-phase Escape listener
+      // leaks with nothing left able to close it.
+      if (!showInInspector(app, panel, () => lifecycle.close())) lifecycle.close();
       return panel;
     }
     backdrop = h('div', { class: 'cell-detail-overlay' }, panel);
diff --git a/tests/e2e/dashboard-tree.html b/tests/e2e/dashboard-tree.html
index 625d4615..fd875aef 100644
--- a/tests/e2e/dashboard-tree.html
+++ b/tests/e2e/dashboard-tree.html
@@ -42,6 +42,7 @@
     import { mountAppShell } from '/src/ui/app-shell.js';
     import { startDrag } from '/src/ui/splitters.js';
     import { createAuthenticatedExecutionScope } from '/src/application/authenticated-execution-scope.js';
+    import { showInInspector, releaseInspector } from '/src/ui/inspector-host.js';
 
     const query = (id, name, sql = 'SELECT 1', over = {}) => ({
       id, sql, specVersion: 1,
@@ -136,6 +137,25 @@
     });
     app.applyCommittedWorkspace(workspace);
 
+    // #586 AC2 real-browser layout gate (sidebar-tabs-narrow.spec.js's own
+    // header comment explains why this fixture, not a happy-dom unit test,
+    // is the only place that can prove CSS layout claims): this fixture
+    // mounts the real shell — `.main-row`/`.query-host`/`.inspector-host`/
+    // `.inspector-resize` are ALL genuine, styled DOM here — but never wires
+    // a real docked surface (no workbench/editor/results mounted; see this
+    // file's own header comment on why). Driving `inspector-host.ts`'s own
+    // dock primitive directly is the same shared mechanism EVERY docked
+    // surface (Cell, Rows, Reference) is built on (#586) — the geometry
+    // this gate checks is exactly and only that primitive's, not any one
+    // surface's content.
+    window.__openInspector = () => {
+      const content = document.createElement('div');
+      content.className = 'test-inspector-content';
+      content.textContent = 'inspector content';
+      return showInInspector(app, content, () => releaseInspector(app));
+    };
+    window.__closeInspector = () => releaseInspector(app);
+
     /** Drive the selected Dashboard/member the way the real controller would, so
      *  current-resource styling can be verified against real CSS. */
     // #457: a variable row opens a main-editor TAB now. This fixture deliberately
diff --git a/tests/e2e/editor-docs.spec.js b/tests/e2e/editor-docs.spec.js
index 9f21d1df..b0699eed 100644
--- a/tests/e2e/editor-docs.spec.js
+++ b/tests/e2e/editor-docs.spec.js
@@ -42,11 +42,27 @@ test.describe('docs reference (#313)', () => {
     await expect(page.locator('[role="complementary"]')).toHaveCount(0);
   });
 
-  test('the pane is non-modal: no backdrop, and the editor keeps accepting input while it is open', async ({ page }) => {
+  test('the pane is non-modal: nothing overlays the editor, and it keeps accepting input while the pane is open', async ({ page }) => {
     await page.keyboard.type('sum');
     await page.keyboard.press('F1');
     await expect(page.locator('[role="complementary"]')).toBeVisible();
-    expect(await page.locator('.cd-backdrop').count()).toBe(0);
+    // #586 finding 1: `.cd-backdrop` is deleted repo-wide (AC3), so
+    // `page.locator('.cd-backdrop').count()` can never find anything again —
+    // reintroduce a scrim over this pane under ANY other class name and the
+    // old assertion still reported green. Assert the real rendered claim
+    // instead: at the editor's own on-screen point, the topmost element is
+    // the editor itself, not some overlay sitting above it. A reintroduced
+    // full-viewport scrim (this pane's pre-#586 modal drawer ancestor) would
+    // sit above `.cm-content` at that point, so `elementFromPoint` would
+    // resolve to the scrim instead — this genuinely fails for a real
+    // overlay, unlike the deleted class-name probe.
+    const editorIsTopmostAtItsOwnCenter = await page.evaluate(() => {
+      const editor = document.querySelector('.cm-content');
+      const rect = editor.getBoundingClientRect();
+      const atCenter = document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2);
+      return editor === atCenter || editor.contains(atCenter);
+    });
+    expect(editorIsTopmostAtItsOwnCenter).toBe(true);
     // Focus stays workable in the editor: keep typing.
     await page.click('.cm-content');
     await page.keyboard.type('(x)');
diff --git a/tests/e2e/inspector-dock-layout.spec.js b/tests/e2e/inspector-dock-layout.spec.js
new file mode 100644
index 00000000..2d9fcd2e
--- /dev/null
+++ b/tests/e2e/inspector-dock-layout.spec.js
@@ -0,0 +1,103 @@
+import { test, expect } from '@playwright/test';
+
+// #586 AC2 real-browser layout gate: `.main-row` must have a real
+// `inspectorHost` slot "as a layout sibling of `queryHost`/`dashboardHost`,
+// not a `position:fixed` overlay". happy-dom cannot evaluate CSS layout at
+// all (see `sidebar-tabs-narrow.spec.js`'s own header comment for the same
+// reasoning) — `tests/unit/app-shell.test.ts` proves DOM sibling order,
+// `hidden`, and the inline `style.width` write, but nothing in the repo
+// asserted the GEOMETRIC claim that is this phase's entire point until now.
+//
+// Reuses `dashboard-tree.html` (#426's fixture, already extended by
+// sidebar-tabs-narrow.spec.js for the same reason): it mounts the REAL
+// `mountAppShell`, so `.main-row`/`.query-host`/`.inspector-host`/
+// `.inspector-resize` are genuine, styled DOM here, not a hand-built stand-in
+// (see that spec's header comment on why this fixture, not a fresh one, is
+// the right harness). It never wires a real docked surface (no workbench),
+// so this spec drives `inspector-host.ts`'s own `showInInspector`/
+// `releaseInspector` primitive directly via the fixture's own
+// `window.__openInspector`/`__closeInspector` helpers (added alongside this
+// spec) — the SAME shared mechanism every docked surface (Cell, Rows,
+// Reference) is built on, so the geometry this gate checks is exactly and
+// only that primitive's, never any one surface's content.
+
+const open = async (page) => {
+  await page.setViewportSize({ width: 1280, height: 800 });
+  await page.goto('/tests/e2e/dashboard-tree.html');
+  await page.waitForFunction(() => window.__ready === true);
+};
+
+test.describe('docked right-inspector layout geometry (#586 AC2)', () => {
+  test('folded: .inspector-host and .inspector-resize contribute zero layout width', async ({ page }) => {
+    await open(page);
+    const host = page.locator('.inspector-host');
+    const resize = page.locator('.inspector-resize');
+    await expect(host).toBeHidden();
+    await expect(resize).toBeHidden();
+    // Playwright's boundingBox() returns null for a `display: none` element
+    // (the `[hidden]` override, styles.css) — there is no box to measure at
+    // all, the strongest form of "contributes zero layout width".
+    expect(await host.boundingBox()).toBeNull();
+    expect(await resize.boundingBox()).toBeNull();
+  });
+
+  test('open: .query-host narrows, and the inspector never intersects it — docked, not an overlay', async ({ page }) => {
+    await open(page);
+    const queryHost = page.locator('.query-host');
+    const foldedBox = await queryHost.boundingBox();
+
+    const mounted = await page.evaluate(() => window.__openInspector());
+    expect(mounted).toBe(true);
+    const inspectorHost = page.locator('.inspector-host');
+    await expect(inspectorHost).toBeVisible();
+
+    const openBox = await queryHost.boundingBox();
+    const inspectorBox = await inspectorHost.boundingBox();
+
+    // The real "docked, not overlay" assertion (AC2): centre width shrinks by
+    // (about) the same amount the inspector claims — a `position: fixed`
+    // overlay would consume zero centre width instead, leaving these equal.
+    expect(openBox.width).toBeLessThan(foldedBox.width);
+    // Geometric non-overlap: the two boxes' horizontal spans do not
+    // intersect at all. A `position: fixed` overlay drawn ON TOP of the
+    // centre surface would intersect it; a genuine layout sibling never can.
+    const intersectsHorizontally = inspectorBox.x < openBox.x + openBox.width
+      && openBox.x < inspectorBox.x + inspectorBox.width;
+    expect(intersectsHorizontally).toBe(false);
+
+    // Record the actual measured numbers for the report — not asserted
+    // beyond the above (viewport chrome/sidebar width vary by engine).
+    test.info().annotations.push(
+      { type: 'query-host folded width', description: String(foldedBox.width) },
+      { type: 'query-host open width', description: String(openBox.width) },
+      { type: 'inspector-host open width', description: String(inspectorBox.width) },
+    );
+  });
+
+  test('resize: dragging .inspector-resize changes the host width live', async ({ page }) => {
+    await open(page);
+    await page.evaluate(() => window.__openInspector());
+    const inspectorHost = page.locator('.inspector-host');
+    await expect(inspectorHost).toBeVisible();
+    const before = await inspectorHost.boundingBox();
+
+    const handle = page.locator('.inspector-resize');
+    const box = await handle.boundingBox();
+    await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+    await page.mouse.down();
+    // 'rightInspector' is anchored to the right edge (splitters.ts's
+    // `dragValue`): width = viewportWidth - clientX, so moving the cursor
+    // LEFT grows the host — pick a delta comfortably inside the [320,
+    // 92vw] clamp for a 1280px viewport.
+    await page.mouse.move(box.x - 120, box.y + box.height / 2, { steps: 5 });
+    await page.mouse.up();
+
+    const after = await inspectorHost.boundingBox();
+    expect(after.width).toBeGreaterThan(before.width + 100);
+
+    test.info().annotations.push(
+      { type: 'inspector-host width before drag', description: String(before.width) },
+      { type: 'inspector-host width after drag', description: String(after.width) },
+    );
+  });
+});
diff --git a/tests/unit/doc-pane.test.ts b/tests/unit/doc-pane.test.ts
index 8cb8c0d6..6de35187 100644
--- a/tests/unit/doc-pane.test.ts
+++ b/tests/unit/doc-pane.test.ts
@@ -70,7 +70,15 @@ describe('doc-pane lifecycle', () => {
     expect(panel).not.toBeNull();
     expect(panel.getAttribute('role')).toBe('complementary');
     expect(panel.getAttribute('aria-label')).toBeTruthy();
-    expect(document.querySelector('.cd-backdrop')).toBeNull();
+    // #586 finding 1's same rot, found one file over: `.cd-backdrop` is
+    // deleted repo-wide (#586 AC3), so `querySelector('.cd-backdrop')` can
+    // never find anything — a reintroduced backdrop-style wrapper around
+    // this pane would NOT be `.cd-backdrop` (that class is gone), so the old
+    // assertion passed unconditionally. `showInInspector` mounts `panel`
+    // itself directly into `inspectorHost` (inspector-host.ts), so the real,
+    // falsifiable claim is that the host's child IS the panel — not some
+    // wrapper containing it.
+    expect(app.dom.inspectorHost!.firstElementChild).toBe(panel);
     await Promise.resolve(); await Promise.resolve();
     closeDocPane(app);
   });
@@ -1211,4 +1219,29 @@ describe('docked in the shared inspector (#586)', () => {
     expect(document.querySelector('[role="complementary"]')).toBeNull();
     expect(() => closeDocPane(app)).not.toThrow();
   });
+
+  // #586 finding 3: `ensurePane` calls `openSurfaceLifecycle` (which installs
+  // its capture-phase `keydown` listener on `doc` UNCONDITIONALLY) before it
+  // learns whether `showInInspector` actually mounted anything. The test
+  // above only proves the `panes` bookkeeping stays honest; it says nothing
+  // about the listener itself — before the fix, a failed mount left it (and
+  // the `st` closure it captures) permanently attached with nothing left
+  // able to remove it. Proving that directly (rather than merely
+  // `isDocPaneOpen` staying false) needs to see the SAME listener reference
+  // added and then removed — a spy on add/removeEventListener, not a
+  // behavioral proxy that could pass by coincidence.
+  it('opening with no shell mounted still tears the SurfaceLifecycle back down — the capture-phase Escape listener does not leak', () => {
+    const app = makeApp({ dom: {} });
+    app.catalog.docEntry.mockResolvedValue({ status: 'missing' });
+    const addSpy = vi.spyOn(document, 'addEventListener');
+    const removeSpy = vi.spyOn(document, 'removeEventListener');
+    openDocEntry(app, T_FN);
+    const added = addSpy.mock.calls.filter((c) => c[0] === 'keydown');
+    expect(added).toHaveLength(1);
+    const handler = added[0][1];
+    const removed = removeSpy.mock.calls.filter((c) => c[0] === 'keydown' && c[1] === handler);
+    expect(removed).toHaveLength(1); // same listener reference removed — the lifecycle actually closed
+    addSpy.mockRestore();
+    removeSpy.mockRestore();
+  });
 });
diff --git a/tests/unit/results.test.ts b/tests/unit/results.test.ts
index b9da2027..fd1831ee 100644
--- a/tests/unit/results.test.ts
+++ b/tests/unit/results.test.ts
@@ -623,6 +623,27 @@ describe('openCellDetail', () => {
     backdropClick(bd);
     expect(qs(document, '.cell-detail-overlay')).toBeNull();
   });
+
+  // #586 finding 3: the docked branch's `openSurfaceLifecycle` call installs
+  // its capture-phase Escape listener on `doc` BEFORE `showInInspector`'s
+  // return value says whether anything actually mounted (no shell yet →
+  // `app.dom.inspectorHost`/`inspectorResize` absent). Symmetric with
+  // doc-pane.ts's own leak (and its unit-test coverage): prove the SAME
+  // listener reference that was added is also removed — not merely that
+  // nothing visibly opened, which would pass even with a dangling listener.
+  it('opening with no shell mounted (dock branch) tears the SurfaceLifecycle back down — the Escape listener does not leak', () => {
+    const app = makeApp({ dom: { inspectorHost: undefined, inspectorResize: undefined } });
+    const addSpy = vi.spyOn(document, 'addEventListener');
+    const removeSpy = vi.spyOn(document, 'removeEventListener');
+    openCellDetail(app, 'c', 'String', 'x');
+    const added = addSpy.mock.calls.filter((c) => c[0] === 'keydown');
+    expect(added).toHaveLength(1);
+    const handler = added[0][1];
+    const removed = removeSpy.mock.calls.filter((c) => c[0] === 'keydown' && c[1] === handler);
+    expect(removed).toHaveLength(1); // same listener reference removed — the lifecycle actually closed
+    addSpy.mockRestore();
+    removeSpy.mockRestore();
+  });
 });
 
 // #586: the docked cell-detail/rows-viewer no longer have their own resize
@@ -1780,6 +1801,27 @@ describe('multiquery script grid (#83)', () => {
     expect(app.actions.cancel).not.toHaveBeenCalled(); // …and consumed the event before the global handler's cancel
   });
 
+  // #586 finding 3: `openRowsViewer` never checked `showInInspector`'s return
+  // value at all (unlike `openCellDetail`'s dock branch and doc-pane.ts's
+  // `ensurePane`, both of which at least had SOME guard) — a failed mount
+  // left `openSurfaceLifecycle`'s capture-phase Escape listener permanently
+  // attached with no reference anywhere able to close it. Prove the SAME
+  // listener reference is added and then removed, not merely that nothing
+  // visibly opened (which would pass even with a dangling listener).
+  it('opening with no shell mounted tears the SurfaceLifecycle back down — the Escape listener does not leak', () => {
+    const app = makeApp({ dom: { inspectorHost: undefined, inspectorResize: undefined } });
+    const addSpy = vi.spyOn(document, 'addEventListener');
+    const removeSpy = vi.spyOn(document, 'removeEventListener');
+    openRowsViewer(app, { columns: [{ name: 'n', type: 'String' }], rows: [['x']] });
+    const added = addSpy.mock.calls.filter((c) => c[0] === 'keydown');
+    expect(added).toHaveLength(1);
+    const handler = added[0][1];
+    const removed = removeSpy.mock.calls.filter((c) => c[0] === 'keydown' && c[1] === handler);
+    expect(removed).toHaveLength(1); // same listener reference removed — the lifecycle actually closed
+    addSpy.mockRestore();
+    removeSpy.mockRestore();
+  });
+
   it('toolbar shows live elapsed + Cancel while running, with a running footer', () => {
     const app = appWithResult(scriptResult(), { running: true });
     renderResults(app);

From 8539d817448b60773417bbfc4f0ce112af68a9ab Mon Sep 17 00:00:00 2001
From: Boris Tyshkevich 
Date: Mon, 3 Aug 2026 22:52:23 +0200
Subject: [PATCH 3/4] fix(#586): cancel abandoned inspector drags, dock-aware
 width clamp, fix compat-read NaN
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Three third-party review findings against #596/#586 (SurfaceLifecycle +
docked right-inspector, umbrella #593 phase 1):

1. Closing the docked right-inspector mid-drag used to leak the resize
   drag's window mousemove/mouseup listeners: app-shell.ts discarded
   startDrag's returned cancel handle, so a drag surviving Escape/sign-out/
   surface-switch/occupant-replacement kept mutating a hidden host and
   persisted an abandoned width on the eventual mouseup. The shell now owns
   the cancel handle and calls it from inspector-host.ts's releaseInspector
   (the single choke point every close path funnels through) and from the
   shell's own dispose(), mirroring drawer.ts's existing cancelActive
   pattern including reverting the pre-drag width.

2. The docked inspector's width used clampDrawerWidth's flat [320, 92vw]
   bound, unsafe now that the inspector is a real `.main-row` flex sibling
   next to a non-shrinking sidebar and two resize handles — it could starve
   `.query-host`/`.dashboard-host` to nothing. Added a dock-aware
   clampDockedInspectorWidth (splitters.ts) that reserves a CENTRE_MIN_PX
   floor (320, matching the inspector's own floor) for the centre surface,
   and reclamps on every unfold and viewport resize via a shell-owned
   reclampInspectorWidth hook — not only once at construction — without
   ever mutating the persisted rightInspectorPx preference itself.

3. state.ts's rightInspectorPx compat read chained candidates with `||`,
   so a malformed canonical value both blocked a valid docPanePx/
   cellDrawerPx fallback and survived as NaN through clamp, applying a
   literal "NaNpx" width. Each candidate is now parsed and validated
   independently, keeping the documented precedence for real values.

Co-Authored-By: Claude Fable 5 
Claude-Session: https://claude.ai/code/session_01Da66KLYSmCey6Gi7RMFGcf
---
 src/state.ts                            |  39 ++++--
 src/ui/app-shell.ts                     | 104 +++++++++++++--
 src/ui/app.types.ts                     |  11 ++
 src/ui/inspector-host.ts                |  31 +++++
 src/ui/splitters.ts                     |  77 ++++++++++--
 tests/e2e/dashboard-tree.html           |   8 ++
 tests/e2e/inspector-dock-layout.spec.js |  98 +++++++++++++++
 tests/unit/app-shell.test.ts            | 160 ++++++++++++++++++++++--
 tests/unit/inspector-host.test.ts       |  65 ++++++++++
 tests/unit/splitters.test.ts            |  43 ++++++-
 tests/unit/state.test.ts                |  51 ++++++++
 11 files changed, 650 insertions(+), 37 deletions(-)

diff --git a/src/state.ts b/src/state.ts
index 56e5e893..10ef65a9 100644
--- a/src/state.ts
+++ b/src/state.ts
@@ -633,6 +633,22 @@ export function setTabSpecDraft(
 export function createState(read: StateReader = { loadJSON, loadStr }): AppState {
   const num = (key: string, dflt: number, lo: number, hi: number) =>
     clamp(parseFloat(read.loadStr(key, String(dflt))), lo, hi);
+  // #586 finding 4: the compat-read precedence below needs each candidate
+  // parsed and validated INDEPENDENTLY, not chained with `||` — `||`
+  // short-circuits on any non-empty string, so a malformed canonical value
+  // (e.g. a corrupted `"bad"`) both blocks a perfectly valid legacy fallback
+  // AND survives as `NaN` through `clamp` (`Math.min(Math.max(NaN,320),
+  // Infinity)` is `NaN`), which the shell then applies as a literal
+  // `"NaNpx"` width. Returns the first candidate that parses to a finite
+  // number (whatever its magnitude — the caller's own `clamp` still bounds
+  // it), or `480` if none does.
+  const firstValidPx = (...raws: string[]): number => {
+    for (const raw of raws) {
+      const n = parseInt(raw, 10);
+      if (Number.isFinite(n)) return n;
+    }
+    return 480;
+  };
   const storedQueries = decodeStoredSavedQueries(read.loadJSON(KEYS.saved, []));
   const initialWorkspaceName = read.loadStr(KEYS.libraryName, DEFAULT_LIBRARY_NAME);
   return {
@@ -650,17 +666,18 @@ export function createState(read: StateReader = { loadJSON, loadStr }): AppState
     // The docked right-inspector's width (#586). Compat read order: a real
     // rightInspectorPx wins; else a real docPanePx (a pre-#586 Reference-pane
     // width); else a real cellDrawerPx (a pre-#586 cell/rows drawer width);
-    // else the default (matches #488's RIGHT_INSPECTOR_DEFAULT_PX). The 92vw
-    // upper bound depends on the live viewport, not this load-time default,
-    // so only the floor is enforced here — clampDrawerWidth (splitters.ts)
-    // applies the full [320, 92vw] clamp whenever the inspector is opened or
-    // resized.
-    rightInspectorPx: clamp(parseInt(
-      read.loadStr(KEYS.rightInspectorPx, '') ||
-        read.loadStr(KEYS.docPanePx, '') ||
-        read.loadStr(KEYS.cellDrawerPx, '') ||
-        '480',
-      10,
+    // else the default (matches #488's RIGHT_INSPECTOR_DEFAULT_PX) — see
+    // `firstValidPx` above for why each candidate is validated independently
+    // rather than chained with `||`. The dock-aware upper bound depends on
+    // the live viewport AND the sidebar/handles beside the inspector, not
+    // this load-time default, so only the floor is enforced here —
+    // `clampDockedInspectorWidth` (splitters.ts) applies the real clamp,
+    // via app-shell.ts's `reclampInspectorWidth`, whenever the inspector is
+    // opened, resized, or the viewport changes (#586 findings 2a/2b).
+    rightInspectorPx: clamp(firstValidPx(
+      read.loadStr(KEYS.rightInspectorPx, ''),
+      read.loadStr(KEYS.docPanePx, ''),
+      read.loadStr(KEYS.cellDrawerPx, ''),
     ), 320, Infinity),
     // Reactive (signals): mutating these drives repaints via effects in
     // createApp — no manual refresh() list to keep in sync. Read/write through
diff --git a/src/ui/app-shell.ts b/src/ui/app-shell.ts
index f2b052db..cd1a8cfa 100644
--- a/src/ui/app-shell.ts
+++ b/src/ui/app-shell.ts
@@ -40,7 +40,7 @@ import { renderSavedHistory } from './saved-history.js';
 import { renderLibraryTitle } from './file-menu.js';
 import { applyConnectionStatus } from './app-header.js';
 import type { DragCtx, DragRect, DragStartEvent, SplitterAxis } from './splitters.js';
-import { startDrag, clampDrawerWidth } from './splitters.js';
+import { startDrag, clampDockedInspectorWidth } from './splitters.js';
 import type { App } from './app.types.js';
 import type { SchemaCatalogService } from '../application/schema-catalog-service.js';
 import type { AppPreferences, PreferenceKey } from '../application/app-preferences.js';
@@ -98,6 +98,16 @@ export interface AppShellHandle {
   dispose(): void;
 }
 
+/** The two fixed-width `.main-row` resize handles (`.col-resize` and
+ *  `.inspector-resize`, styles.css) — reserved alongside the sidebar's own
+ *  tracked width when dock-aware-clamping the right-inspector (#586 finding
+ *  2a). A literal, not a measured rect: `getBoundingClientRect` returns all
+ *  zeros under happy-dom (no real layout engine), so this mirrors the
+ *  existing convention of tracking `sidebarPx` as a plain JS number rather
+ *  than reading it back off the DOM — exact under both happy-dom and a real
+ *  browser, unlike a rect measurement would be. */
+const HANDLE_PX = 7;
+
 /** Build the persistent frame (header slot, sidebar, mobile nav) and mount
  *  it. Ported byte-identically from `mountWorkbenchShell`'s former body
  *  (#276 Phase 5 → this split) — every ordering comment below is original. */
@@ -106,6 +116,7 @@ export function mountAppShell(deps: AppShellDeps): AppShellHandle {
     app, root, document: doc, state, catalog, prefs, matchMedia, updateBanner,
     startDrag: doStartDrag,
   } = deps;
+  const win = doc.defaultView || window;
   doc.documentElement.setAttribute('data-theme', state.theme);
   doc.documentElement.setAttribute('data-density', state.density);
 
@@ -150,7 +161,19 @@ export function mountAppShell(deps: AppShellDeps): AppShellHandle {
   // for the one shared dock instead of one handle per surface.
   const rectFor = (axis: SplitterAxis): DragRect => {
     if (axis === 'sideRow') return sidebar.getBoundingClientRect();
-    if (axis === 'rightInspector') return { width: (doc.defaultView || window).innerWidth };
+    if (axis === 'rightInspector') {
+      return {
+        width: win.innerWidth,
+        // #586 finding 2a: the dock-aware ceiling needs everything ELSE
+        // `.main-row` gives space to before the inspector/centre split what
+        // is left — `state.sidebarPx` (not a `getBoundingClientRect`
+        // measurement: the sidebar's own width is already tracked exactly as
+        // this same number, and a rect read returns all zeros under
+        // happy-dom, see `HANDLE_PX`'s own comment) plus both fixed-width
+        // resize handles.
+        reservedPx: state.sidebarPx + HANDLE_PX * 2,
+      };
+    }
     return {};
   };
   const dragCtx: DragCtx = {
@@ -161,7 +184,17 @@ export function mountAppShell(deps: AppShellDeps): AppShellHandle {
       else if (axis === 'rightInspector') inspectorHost.style.width = value + 'px';
       else schemaPane.style.height = value + '%';
     },
-    save: (name, value) => prefs.save(name as PreferenceKey, value),
+    save: (name, value) => {
+      // #586 finding 1: a drag that ends NORMALLY (mouseup → splitters.ts's
+      // own `onUp` → here) must retire the shell's cancel handle too — not
+      // just an explicit mid-drag cancel — or a later `releaseInspector`
+      // call on the next ordinary close (there is no drag in progress at
+      // that point) would wrongly revert the width this same mouseup just
+      // persisted. `cancelInspectorDrag` is declared further down (read here
+      // only once this callback actually runs, well after that point).
+      if (name === 'rightInspectorPx') cancelInspectorDrag = null;
+      prefs.save(name as PreferenceKey, value);
+    },
   };
   app.dom.sideSplit = h('div', { class: 'row-resize side-split', onmousedown: (e: DragStartEvent) => doStartDrag(e, 'sideRow', dragCtx) });
   // Mobile Tables view (#126): a segmented control at the top of the sidebar. CSS
@@ -202,19 +235,54 @@ export function mountAppShell(deps: AppShellDeps): AppShellHandle {
   // like `sideHandle` does for the sidebar, driving the `'rightInspector'`
   // splitter axis against the SAME `rightInspectorPx` preference every
   // docked surface shares now (state.ts).
-  // clampDrawerWidth (not the raw persisted value): a monitor-to-monitor move
-  // can leave `rightInspectorPx` wider than 92vw of THIS window — the old
-  // per-surface drawers always re-clamped against the live viewport at open
-  // time (attachDrawerResize), and the docked host must too, or a narrow
-  // window opens with the panel wider than the screen the very first time.
+  // clampDockedInspectorWidth (not the raw persisted value, and not just
+  // `clampDrawerWidth`'s flat 92vw): a monitor-to-monitor move, a resized
+  // sidebar, or the DEFAULT preference itself can all leave `rightInspectorPx`
+  // wider than this window can safely dock without starving `.query-host`/
+  // `.dashboard-host` (#586 finding 2a) — the old per-surface drawers only
+  // ever re-clamped against the live viewport at open time (attachDrawerResize),
+  // never against dock siblings, because they had none. `inspectorDisplayWidth`
+  // below is recomputed here at construction, again on every unfold
+  // (`reclampInspectorWidth`, exposed via `app.dom` for `inspector-host.ts`'s
+  // `showInInspector` to call — #586 finding 2b), and on a live window resize
+  // (below) — it only ever writes the DOM style, never `state.rightInspectorPx`
+  // itself, so the user's PERSISTED preference survives a trip through a
+  // narrow viewport and back unchanged.
+  const inspectorDisplayWidth = (): number => clampDockedInspectorWidth(
+    state.rightInspectorPx, win.innerWidth, state.sidebarPx + HANDLE_PX * 2,
+  );
   const inspectorHost = app.dom.inspectorHost = h('div', {
     class: 'inspector-host', hidden: true,
-    style: { width: clampDrawerWidth(state.rightInspectorPx, (doc.defaultView || window).innerWidth) + 'px' },
+    style: { width: inspectorDisplayWidth() + 'px' },
   });
+  // #586 finding 1: keep the drag's own cancel handle — `startDrag`
+  // (splitters.ts) returns one for exactly this — so a surface that closes
+  // mid-drag (Escape, sign-out, a surface switch, or a fresh occupant
+  // replacing this one; every path funnels through `inspector-host.ts`'s
+  // `releaseInspector`) can stop the live `window` mousemove/mouseup
+  // listeners before they keep mutating a now-hidden host and a `mouseup`
+  // persists an abandoned width. Mirrors `drawer.ts`'s `attachDrawerResize`/
+  // `cancelActive` for the one surface that isn't docked, including
+  // reverting the pre-drag width on cancel.
+  let cancelInspectorDrag: (() => void) | null = null;
   const inspectorResize = app.dom.inspectorResize = h('div', {
     class: 'inspector-resize', hidden: true,
-    onmousedown: (e: DragStartEvent) => doStartDrag(e, 'rightInspector', dragCtx),
+    onmousedown: (e: DragStartEvent) => {
+      const startPx = state.rightInspectorPx;
+      const stopDrag = doStartDrag(e, 'rightInspector', dragCtx);
+      cancelInspectorDrag = () => {
+        stopDrag();
+        state.rightInspectorPx = startPx;
+        cancelInspectorDrag = null;
+      };
+    },
   });
+  // Stable wrapper (the `let` above is reassigned to `null` once a drag ends
+  // normally) — this is the reference `app.dom.cancelInspectorDrag` keeps.
+  const cancelActiveInspectorDrag = (): void => { cancelInspectorDrag?.(); };
+  app.dom.cancelInspectorDrag = cancelActiveInspectorDrag;
+  const reclampInspectorWidth = (): void => { inspectorHost.style.width = inspectorDisplayWidth() + 'px'; };
+  app.dom.reclampInspectorWidth = reclampInspectorWidth;
   const mainRow = h('div', { class: 'main-row' }, sidebar, sideHandle, queryHost, dashboardHost, inspectorResize, inspectorHost);
 
   // Mobile bottom-tab nav (#126): one full-screen panel at a time. CSS hides it
@@ -248,6 +316,15 @@ export function mountAppShell(deps: AppShellDeps): AppShellHandle {
 
   root!.replaceChildren(headerSlot, authHost, app.dom.banner, mainRow, app.dom.mobileNav);
 
+  // #586 finding 2b: a live viewport resize re-clamps the docked host's
+  // DISPLAYED width too, not only a fold→unfold trip — otherwise an open
+  // inspector on a shrinking window keeps whatever width it had, which can
+  // starve the centre surface exactly like the unclamped case this whole
+  // fix addresses. Harmless (and cheap: it's a single style write) to run
+  // while folded as well, since the next unfold would recompute it anyway.
+  const onWindowResize = (): void => { reclampInspectorWidth(); };
+  win.addEventListener('resize', onWindowResize);
+
   const disposers: (() => void)[] = [];
   // Reactive repaint of the schema tree — replaces the scattered renderSchema()
   // calls: re-runs on schema load, load error, filter text, or expand/collapse.
@@ -360,6 +437,13 @@ export function mountAppShell(deps: AppShellDeps): AppShellHandle {
       // torn down (sign-out, a surface teardown) — the arbiter's timer outlives
       // this DOM otherwise.
       cancelDashboardTreeClicks(app);
+      // #586 finding 1: a shell teardown mid-drag must stop the live
+      // 'rightInspector' listeners too, same as `releaseInspector` does —
+      // this handle can outlive `releaseInspector` ever running (e.g. the
+      // whole shell tearing down around an open, still-being-dragged
+      // inspector).
+      cancelActiveInspectorDrag();
+      win.removeEventListener('resize', onWindowResize);
       for (const dispose of disposers) dispose();
       mq?.removeEventListener('change', onMobileChange);
     },
diff --git a/src/ui/app.types.ts b/src/ui/app.types.ts
index b07a495e..e53b5bb6 100644
--- a/src/ui/app.types.ts
+++ b/src/ui/app.types.ts
@@ -121,6 +121,17 @@ export interface AppDom {
    *  `releaseInspector` — never `document.body` directly. */
   inspectorHost?: HTMLElement;
   inspectorResize?: HTMLElement;
+  /** #586 findings 1/2b — shell-owned hooks `inspector-host.ts` calls at the
+   *  two points it folds/unfolds the host: `cancelInspectorDrag` stops a
+   *  still-live 'rightInspector' drag before folding (so it can't keep
+   *  mutating a now-hidden host or persist an abandoned width);
+   *  `reclampInspectorWidth` recomputes the DISPLAYED width against the
+   *  current viewport/sidebar before unfolding (the persisted preference may
+   *  be stale). See `inspector-host.ts`'s `InspectorHostApp` for the full
+   *  rationale — this is the same `dom` bag that module already reads
+   *  `inspectorHost`/`inspectorResize` off of. */
+  cancelInspectorDrag?: () => void;
+  reclampInspectorWidth?: () => void;
   runElapsedEl?: HTMLElement;
   savedList?: HTMLElement;
   savedSearch?: HTMLElement;
diff --git a/src/ui/inspector-host.ts b/src/ui/inspector-host.ts
index 9d92b798..55ab286a 100644
--- a/src/ui/inspector-host.ts
+++ b/src/ui/inspector-host.ts
@@ -30,6 +30,26 @@ export interface InspectorHostApp {
   dom: {
     inspectorHost?: HTMLElement;
     inspectorResize?: HTMLElement;
+    /** Shell-owned hook (app-shell.ts, #586 finding 1): cancels any
+     *  in-progress 'rightInspector' resize drag and reverts the pre-drag
+     *  width. Called from `releaseInspector` below — the single teardown
+     *  every fold path (Escape, sign-out, a surface switch, a fresh occupant
+     *  replacing this one) funnels through via the outgoing occupant's own
+     *  `SurfaceLifecycle` `onClose` — so a drag that outlives the surface it
+     *  was resizing doesn't keep mutating a now-hidden host or persist an
+     *  abandoned width via a later `mouseup`. Absent when no shell has wired
+     *  a drag handle (this module's own unit tests; #586's e2e fixture,
+     *  which drives `showInInspector`/`releaseInspector` directly). */
+    cancelInspectorDrag?: () => void;
+    /** Shell-owned hook (app-shell.ts, #586 finding 2b): recomputes the
+     *  docked host's DISPLAYED width against the CURRENT viewport/sidebar
+     *  before `showInInspector` reveals it — the persisted `rightInspectorPx`
+     *  preference may have been saved on a wider viewport (or with a
+     *  different sidebar width) than the one unfolding now, and the only
+     *  other place a clamp is ever applied is once, at shell construction.
+     *  Never mutates the preference itself, only the DOM style. Absent when
+     *  no shell has wired a resize handle. */
+    reclampInspectorWidth?: () => void;
   };
 }
 
@@ -70,6 +90,12 @@ export function showInInspector(app: InspectorHostApp, content: Element, close:
   const { inspectorHost, inspectorResize } = app.dom;
   if (!inspectorHost || !inspectorResize) return false;
   closeInspector(app);
+  // #586 finding 2b: re-clamp the DISPLAYED width against the current
+  // viewport/sidebar before revealing — the persisted preference may be
+  // stale (set on a wider viewport, or before the sidebar's own width
+  // changed) since the only other place a clamp applies is once, at shell
+  // construction.
+  app.dom.reclampInspectorWidth?.();
   inspectorHost.replaceChildren(content);
   inspectorHost.hidden = false;
   inspectorResize.hidden = false;
@@ -90,6 +116,11 @@ export function showInInspector(app: InspectorHostApp, content: Element, close:
 export function releaseInspector(app: InspectorHostApp): void {
   const { inspectorHost, inspectorResize } = app.dom;
   if (!inspectorHost) return;
+  // #586 finding 1: stop a still-live 'rightInspector' drag BEFORE folding —
+  // otherwise its `window` mousemove/mouseup listeners outlive the host they
+  // were resizing, keep mutating a now-hidden element, and the eventual
+  // mouseup persists an abandoned width.
+  app.dom.cancelInspectorDrag?.();
   currentClose.delete(inspectorHost);
   inspectorHost.hidden = true;
   if (inspectorResize) inspectorResize.hidden = true;
diff --git a/src/ui/splitters.ts b/src/ui/splitters.ts
index 32ecf9a9..6d4cb89b 100644
--- a/src/ui/splitters.ts
+++ b/src/ui/splitters.ts
@@ -23,24 +23,78 @@ export interface DragPoint {
 
 /** The subset of a bounding-rect-like `dragValue` reads, by axis: 'sideRow'/
  *  'row' need `top`/`bottom`; 'rightInspector' needs `width` (the viewport
- *  width); 'col' reads neither. */
+ *  width) and, for a genuinely DOCKED caller (#586 finding 2a), `reservedPx`;
+ *  'col' reads neither. */
 export interface DragRect {
   top?: number;
   bottom?: number;
   width?: number;
+  /** 'rightInspector' only, and only for a docked caller (app-shell.ts) — the
+   *  total px every OTHER `.main-row` child (the sidebar + both resize
+   *  handles) currently claims, subtracted from `width` before reserving
+   *  `CENTRE_MIN_PX` for the centre work surface. Omitted by a non-docked
+   *  caller (drawer.ts's `attachDrawerResize`, resizing a cell-detail drawer
+   *  opened in a real detached browser tab — there is no centre surface
+   *  beside it to protect), which keeps `dragValue` on the plain
+   *  `clampDrawerWidth` bound instead. */
+  reservedPx?: number;
 }
 
 /**
- * Clamp a drawer width (px) to [320, 92% of the viewport width] — the docked
- * right-inspector's bounds (#101, unchanged by #586's single-axis collapse).
- * Exported so a caller can apply the same clamp when first opening a surface,
- * not just mid-drag (the viewport may have shrunk since the width was last
- * persisted).
+ * Clamp a drawer width (px) to [320, 92% of the viewport width] — the
+ * ORIGINAL, viewport-only bound (#101) predating the docked right-inspector.
+ * #586 kept it for the two callers with no `.main-row` dock siblings to
+ * protect: the shell's own construction-time default (app-shell.ts, corrected
+ * immediately after mount — and on every unfold/resize thereafter — by its
+ * `reclampInspectorWidth`) and `drawer.ts`'s `attachDrawerResize` (the one
+ * surface, a cell-detail drawer opened in a real detached browser tab, that
+ * IS the whole tab rather than a sibling of a centre work surface). A
+ * genuinely docked caller wants `clampDockedInspectorWidth` instead (#586
+ * finding 2a) — this plain viewport bound alone can claim nearly the whole
+ * viewport and starve `.query-host`/`.dashboard-host` to nothing. Exported so
+ * a caller can apply the same clamp when first opening a surface, not just
+ * mid-drag (the viewport may have shrunk since the width was last
+ * persisted) — app-shell.ts does exactly that on every unfold and viewport
+ * resize (#586 finding 2b), not only at construction.
  */
 export function clampDrawerWidth(px: number, viewportWidth: number): number {
   return clamp(px, 320, viewportWidth * 0.92);
 }
 
+/**
+ * The smallest usable width (px) the centre work surface (`.query-host`/
+ * `.dashboard-host`) is guaranteed to keep once the docked right-inspector is
+ * open (#586 finding 2a) — the SAME 320px floor `clampDrawerWidth` already
+ * gives the inspector itself, applied symmetrically to the other side of the
+ * split: neither panel the docked layout creates may shrink below the
+ * narrowest width this codebase already treats as "usable" for one.
+ */
+export const CENTRE_MIN_PX = 320;
+
+/**
+ * Clamp a drawer width (px) for the DOCKED right-inspector's real layout
+ * position: a `flex: 0 0 auto` sibling inside `.main-row`, beside a
+ * non-shrinking sidebar and two resize handles (#586) — NOT the
+ * `position: fixed` overlay `clampDrawerWidth` was originally sized for.
+ * `totalWidth` is the space `.main-row` has to divide between the sidebar,
+ * both handles, the inspector, and the centre surface (in practice the
+ * viewport width — nothing at the app-shell root narrows `.main-row` below
+ * it); `reservedPx` is everything `.main-row` gives every OTHER child before
+ * the inspector and the centre surface split what is left. The dock-aware
+ * ceiling — `totalWidth - reservedPx - CENTRE_MIN_PX` — replaces
+ * `clampDrawerWidth`'s flat `92vw` bound, which alone can claim nearly the
+ * whole viewport and starve the centre surface to nothing (#586 finding 2a);
+ * `Math.min` against that original 92vw bound keeps the inspector from
+ * claiming more than that even on an otherwise roomy row. `clamp`'s own floor
+ * (320) wins even when the computed ceiling falls below it (an extremely
+ * narrow window) — that width is `styles.css`'s full-screen mobile override's
+ * job (`.inspector-host` under `MOBILE_BREAKPOINT_PX`), not this function's.
+ */
+export function clampDockedInspectorWidth(px: number, totalWidth: number, reservedPx: number): number {
+  const ceiling = Math.min(totalWidth * 0.92, totalWidth - reservedPx - CENTRE_MIN_PX);
+  return clamp(px, 320, ceiling);
+}
+
 /**
  * Compute the new size for a drag. `axis` is 'col' (sidebar px), 'sideRow'
  * (sidebar vertical %), 'row' (editor/results %), or 'rightInspector' (the
@@ -56,7 +110,16 @@ export function dragValue(axis: SplitterAxis, ev: DragPoint, rect?: DragRect): n
   // `width` for 'rightInspector' and `top`/`bottom` for 'sideRow'/'row' — the
   // axis dispatch above is exactly the contract that guarantees the field
   // this branch reads is present.
-  if (axis === 'rightInspector') return clampDrawerWidth(rect!.width! - ev.clientX, rect!.width!);
+  if (axis === 'rightInspector') {
+    const raw = rect!.width! - ev.clientX;
+    // A docked caller (app-shell.ts) always supplies `reservedPx` (even a
+    // computed 0); a non-docked caller (drawer.ts) never does — that
+    // presence/absence, not the axis itself, is what picks the dock-aware
+    // ceiling over the plain viewport one (#586 finding 2a).
+    return rect!.reservedPx !== undefined
+      ? clampDockedInspectorWidth(raw, rect!.width!, rect!.reservedPx)
+      : clampDrawerWidth(raw, rect!.width!);
+  }
   const pct = clamp(((ev.clientY - rect!.top!) / (rect!.bottom! - rect!.top!)) * 100,
     axis === 'sideRow' ? 25 : 15, 85);
   return pct;
diff --git a/tests/e2e/dashboard-tree.html b/tests/e2e/dashboard-tree.html
index fd875aef..d60ef50b 100644
--- a/tests/e2e/dashboard-tree.html
+++ b/tests/e2e/dashboard-tree.html
@@ -155,6 +155,14 @@
       return showInInspector(app, content, () => releaseInspector(app));
     };
     window.__closeInspector = () => releaseInspector(app);
+    // #586 findings 2a/2b real-browser gate: let a spec set the persisted
+    // preference (and, for the sidebar case, the tracked width app-shell.ts's
+    // dock-aware clamp reads) directly, rather than driving a real drag.
+    window.__setRightInspectorPx = (px) => { app.state.rightInspectorPx = px; };
+    window.__setSidebarPx = (px) => {
+      app.state.sidebarPx = px;
+      document.querySelector('.sidebar').style.width = px + 'px';
+    };
 
     /** Drive the selected Dashboard/member the way the real controller would, so
      *  current-resource styling can be verified against real CSS. */
diff --git a/tests/e2e/inspector-dock-layout.spec.js b/tests/e2e/inspector-dock-layout.spec.js
index 2d9fcd2e..6bd3702a 100644
--- a/tests/e2e/inspector-dock-layout.spec.js
+++ b/tests/e2e/inspector-dock-layout.spec.js
@@ -101,3 +101,101 @@ test.describe('docked right-inspector layout geometry (#586 AC2)', () => {
     );
   });
 });
+
+// #586 findings 2a/2b: happy-dom cannot evaluate real CSS layout at all (this
+// file's own header comment), so the dock-aware maximum, the legacy-width
+// regression, and the reclamp-on-viewport-resize behavior can only be proven
+// here, against genuine `.main-row` geometry.
+test.describe('docked right-inspector dock-aware width (#586 findings 2a/2b)', () => {
+  test('maximum: an oversized preferred width is clamped to protect the centre surface, not just 92vw', async ({ page }) => {
+    await open(page); // 1280x800
+    await page.evaluate(() => window.__setRightInspectorPx(5000));
+    const mounted = await page.evaluate(() => window.__openInspector());
+    expect(mounted).toBe(true);
+
+    const queryHost = page.locator('.query-host');
+    const inspectorHost = page.locator('.inspector-host');
+    const queryBox = await queryHost.boundingBox();
+    const inspectorBox = await inspectorHost.boundingBox();
+
+    // The OLD clampDrawerWidth alone would have let this claim ~92% of 1280
+    // (≈1178px), leaving the centre surface a sliver. The dock-aware ceiling
+    // instead reserves real room for `.query-host` beside the sidebar/handles.
+    expect(inspectorBox.width).toBeLessThan(1280 * 0.92);
+    expect(queryBox.width).toBeGreaterThan(200);
+
+    test.info().annotations.push(
+      { type: 'query-host width (oversized preference)', description: String(queryBox.width) },
+      { type: 'inspector-host width (oversized preference)', description: String(inspectorBox.width) },
+    );
+  });
+
+  test('legacy width: a modest persisted preference applies unclamped (no spurious shrink)', async ({ page }) => {
+    await open(page);
+    await page.evaluate(() => window.__setRightInspectorPx(420));
+    await page.evaluate(() => window.__openInspector());
+    const box = await page.locator('.inspector-host').boundingBox();
+    expect(Math.round(box.width)).toBe(420);
+  });
+
+  // 900px is deliberately ABOVE `MOBILE_BREAKPOINT_PX` (768, state.ts) — below
+  // it `.inspector-host` switches to the full-screen `position: fixed; inset:
+  // 0` mobile presentation (styles.css, explicitly out of scope for this fix,
+  // see this file's own note on #586's fourth, deferred finding), which would
+  // make any width assertion here about that CSS rule instead of the
+  // dock-aware JS clamp this test targets.
+  test('viewport resize while OPEN live re-clamps the displayed width, all the way down to the shared 320 floor', async ({ page }) => {
+    await open(page);
+    await page.evaluate(() => window.__setRightInspectorPx(500));
+    await page.evaluate(() => window.__openInspector());
+    const inspectorHost = page.locator('.inspector-host');
+    const before = await inspectorHost.boundingBox();
+    expect(Math.round(before.width)).toBe(500);
+
+    await page.setViewportSize({ width: 900, height: 800 });
+    const after = await inspectorHost.boundingBox();
+    // Default sidebarPx (248) + 2 handles (14) reserved, minus CENTRE_MIN_PX
+    // (320): ceiling = 900-262-320 = 318, below the shared 320 floor — clamp
+    // floors it at 320 exactly.
+    expect(Math.round(after.width)).toBe(320);
+    expect(after.width).toBeLessThan(before.width);
+
+    test.info().annotations.push(
+      { type: 'inspector-host width before resize', description: String(before.width) },
+      { type: 'inspector-host width after resize (900px viewport)', description: String(after.width) },
+    );
+  });
+
+  test('viewport resize while FOLDED re-clamps before the next unfold, not the stale wide-viewport width', async ({ page }) => {
+    await open(page); // 1280x800
+    await page.evaluate(() => window.__setRightInspectorPx(1000));
+    await page.setViewportSize({ width: 900, height: 800 });
+    const mounted = await page.evaluate(() => window.__openInspector());
+    expect(mounted).toBe(true);
+    const box = await page.locator('.inspector-host').boundingBox();
+    // 1000px would have exceeded even 92% of a 900px viewport (828px) under
+    // the OLD single-clamp-at-construction behavior; must never render that
+    // wide, whether the reclamp ran while folded or only at the unfold that
+    // follows it. Same derivation as the previous test: ceiling floors at 320.
+    expect(Math.round(box.width)).toBe(320);
+  });
+
+  test('a wider sidebar leaves proportionally less room for the inspector', async ({ page }) => {
+    await open(page); // 1280x800
+    await page.evaluate(() => window.__setRightInspectorPx(600));
+    await page.evaluate(() => window.__openInspector());
+    const narrowSidebarBox = await page.locator('.inspector-host').boundingBox();
+    await page.evaluate(() => window.__closeInspector());
+
+    await page.evaluate(() => window.__setSidebarPx(420)); // the sidebar's own max
+    await page.evaluate(() => window.__openInspector());
+    const wideSidebarBox = await page.locator('.inspector-host').boundingBox();
+
+    expect(wideSidebarBox.width).toBeLessThanOrEqual(narrowSidebarBox.width);
+
+    test.info().annotations.push(
+      { type: 'inspector-host width (sidebarPx 248)', description: String(narrowSidebarBox.width) },
+      { type: 'inspector-host width (sidebarPx 420)', description: String(wideSidebarBox.width) },
+    );
+  });
+});
diff --git a/tests/unit/app-shell.test.ts b/tests/unit/app-shell.test.ts
index c9e2aa67..1e52bbee 100644
--- a/tests/unit/app-shell.test.ts
+++ b/tests/unit/app-shell.test.ts
@@ -1,6 +1,7 @@
 import { describe, expect, it, vi } from 'vitest';
 import { mountAppShell } from '../../src/ui/app-shell.js';
 import { startDrag } from '../../src/ui/splitters.js';
+import { showInInspector, releaseInspector } from '../../src/ui/inspector-host.js';
 import { makeApp } from '../helpers/fake-app.js';
 
 function mount() {
@@ -90,9 +91,17 @@ describe('mountAppShell docked right-inspector (#586)', () => {
     handle.dispose();
   });
 
-  it('sets the initial width from the persisted rightInspectorPx pref, clamped to [320, 92vw] (window.innerWidth = 1024 under happy-dom)', () => {
+  // #586 finding 2a: the initial width is now DOCK-AWARE, not just clamped to
+  // [320, 92vw] — under happy-dom `window.innerWidth` is 1024 and `makeApp`'s
+  // default `sidebarPx` is 248, so `reservedPx` = 248 + 2*7 (HANDLE_PX) = 262
+  // and the ceiling is `min(1024*0.92=942.08, 1024-262-320=442)` = 442. Both
+  // the default preference (480) and a deliberately oversized one (5000)
+  // exceed that ceiling, so BOTH clamp to the same 442 — proof the dock-aware
+  // bound (not 92vw) is the one actually applied.
+  it('sets the initial width from the persisted rightInspectorPx pref, dock-aware clamped (window.innerWidth = 1024, sidebarPx = 248 under happy-dom)', () => {
     const { app, handle } = mount();
-    expect(app.dom.inspectorHost!.style.width).toBe(app.state.rightInspectorPx + 'px');
+    expect(app.state.rightInspectorPx).toBe(480); // the raw preference is untouched...
+    expect(app.dom.inspectorHost!.style.width).toBe('442px'); // ...only the DISPLAYED width is clamped
     handle.dispose();
 
     const wide = makeApp({ catalog: { loadSchema: vi.fn(async () => {}), loadReference: vi.fn(async () => {}) } });
@@ -101,7 +110,8 @@ describe('mountAppShell docked right-inspector (#586)', () => {
       app: wide, root: wide.root, document, state: wide.state, catalog: wide.catalog,
       prefs: wide.prefs, matchMedia: null, updateBanner: vi.fn(), startDrag,
     });
-    expect(wide.dom.inspectorHost!.style.width).toBe(1024 * 0.92 + 'px');
+    expect(wide.dom.inspectorHost!.style.width).toBe('442px');
+    expect(wide.state.rightInspectorPx).toBe(5000); // still not mutated by the display clamp
     wideHandle.dispose();
   });
 
@@ -109,11 +119,147 @@ describe('mountAppShell docked right-inspector (#586)', () => {
     const { app, handle } = mount();
     const resize = app.dom.inspectorResize!;
     resize.dispatchEvent(new MouseEvent('mousedown', { clientX: 700, bubbles: true }));
-    window.dispatchEvent(new MouseEvent('mousemove', { clientX: 500 })); // 1024-500
-    expect(app.dom.inspectorHost!.style.width).toBe('524px');
+    // 1024-650=374 — comfortably inside the dock-aware ceiling (442, see
+    // above), so this exercises a plain unclamped drag.
+    window.dispatchEvent(new MouseEvent('mousemove', { clientX: 650 }));
+    expect(app.dom.inspectorHost!.style.width).toBe('374px');
     window.dispatchEvent(new MouseEvent('mouseup', {}));
-    expect(app.state.rightInspectorPx).toBe(524);
-    expect(app.prefs.save).toHaveBeenCalledWith('rightInspectorPx', 524);
+    expect(app.state.rightInspectorPx).toBe(374);
+    expect(app.prefs.save).toHaveBeenCalledWith('rightInspectorPx', 374);
     handle.dispose();
   });
+
+  // #586 finding 2a: dragging the handle far enough left to claim (nearly)
+  // the whole row must not starve `.query-host`/`.dashboard-host` — the
+  // dock-aware ceiling (442, see above) binds well short of the old flat
+  // 92vw cap (942.08px).
+  it('dragging inspectorResize past the dock-aware ceiling clamps live, protecting the centre surface', () => {
+    const { app, handle } = mount();
+    const resize = app.dom.inspectorResize!;
+    resize.dispatchEvent(new MouseEvent('mousedown', { clientX: 700, bubbles: true }));
+    window.dispatchEvent(new MouseEvent('mousemove', { clientX: -500 })); // 1024-(-500)=1524, way over
+    expect(app.dom.inspectorHost!.style.width).toBe('442px');
+    window.dispatchEvent(new MouseEvent('mouseup', {}));
+    expect(app.state.rightInspectorPx).toBe(442);
+    handle.dispose();
+  });
+
+  // #586 finding 1: `startDrag`'s returned cancel handle used to be discarded
+  // (`doStartDrag(e, 'rightInspector', dragCtx)` with no assignment) — a
+  // surface closing mid-drag left the `window` mousemove/mouseup listeners
+  // live, so further movement kept mutating a now-hidden host and the
+  // eventual mouseup persisted an abandoned width. `releaseInspector` is the
+  // single choke point every real close path (Escape, sign-out, a surface
+  // switch, a fresh occupant replacing this one) funnels through.
+  describe('mid-drag cancellation on inspector fold (#586 finding 1)', () => {
+    it('releaseInspector cancels a still-active drag: no further style/state mutation, no persisted width', () => {
+      const { app, handle } = mount();
+      showInInspector(app, document.createElement('div'), vi.fn());
+      const startPx = app.state.rightInspectorPx; // 480 — the raw preference, pre-drag
+      const resize = app.dom.inspectorResize!;
+      resize.dispatchEvent(new MouseEvent('mousedown', { clientX: 700, bubbles: true }));
+      window.dispatchEvent(new MouseEvent('mousemove', { clientX: 650 })); // mid-drag, no mouseup yet
+      expect(app.state.rightInspectorPx).toBe(374); // actively dragging (see above)
+      expect(resize.classList.contains('dragging')).toBe(true);
+
+      // The surface closes while the mouse button is still down.
+      releaseInspector(app);
+      expect(app.state.rightInspectorPx).toBe(startPx); // reverted, not the abandoned drag value
+      expect(resize.classList.contains('dragging')).toBe(false);
+      const widthAfterCancel = app.dom.inspectorHost!.style.width;
+
+      // A stray mousemove/mouseup after the cancel must not resurrect the
+      // drag or persist anything.
+      window.dispatchEvent(new MouseEvent('mousemove', { clientX: 100 }));
+      window.dispatchEvent(new MouseEvent('mouseup', {}));
+      expect(app.dom.inspectorHost!.style.width).toBe(widthAfterCancel);
+      expect(app.state.rightInspectorPx).toBe(startPx);
+      expect(app.prefs.save).not.toHaveBeenCalledWith('rightInspectorPx', expect.anything());
+      handle.dispose();
+    });
+
+    it('a drag that ends normally (mouseup, no close in between) is unaffected by the cancellation wiring', () => {
+      const { app, handle } = mount();
+      const resize = app.dom.inspectorResize!;
+      resize.dispatchEvent(new MouseEvent('mousedown', { clientX: 700, bubbles: true }));
+      window.dispatchEvent(new MouseEvent('mousemove', { clientX: 650 }));
+      window.dispatchEvent(new MouseEvent('mouseup', {}));
+      expect(app.state.rightInspectorPx).toBe(374);
+      expect(app.prefs.save).toHaveBeenCalledWith('rightInspectorPx', 374);
+      // releaseInspector after a normal end-of-drag must not revert anything
+      // — there is no active drag left to cancel.
+      releaseInspector(app);
+      expect(app.state.rightInspectorPx).toBe(374);
+      handle.dispose();
+    });
+
+    it('handle.dispose() cancels a still-active drag too (a shell teardown, not just a fold)', () => {
+      const { app, handle } = mount();
+      const startPx = app.state.rightInspectorPx;
+      const resize = app.dom.inspectorResize!;
+      resize.dispatchEvent(new MouseEvent('mousedown', { clientX: 700, bubbles: true }));
+      window.dispatchEvent(new MouseEvent('mousemove', { clientX: 650 }));
+      expect(app.state.rightInspectorPx).toBe(374);
+
+      handle.dispose();
+      expect(app.state.rightInspectorPx).toBe(startPx);
+
+      window.dispatchEvent(new MouseEvent('mousemove', { clientX: 100 }));
+      window.dispatchEvent(new MouseEvent('mouseup', {}));
+      expect(app.state.rightInspectorPx).toBe(startPx);
+      expect(app.prefs.save).not.toHaveBeenCalled();
+    });
+  });
+
+  // #586 finding 2b: the persisted width used to be clamped ONLY once, at
+  // shell construction — folding and re-opening (or a viewport change while
+  // open) never re-applied it, so a stale width could outlive the layout it
+  // was computed for.
+  describe('re-clamp on unfold and viewport resize (#586 finding 2b)', () => {
+    it('showInInspector reclamps against the CURRENT sidebarPx before revealing, not the stale mount-time value', () => {
+      const { app, handle } = mount();
+      expect(app.dom.inspectorHost!.style.width).toBe('442px'); // mount-time value (see above)
+      releaseInspector(app); // fold it (starts folded anyway; harmless no-op-ish here)
+      // Widen the sidebar AFTER mount, as if the user dragged it wider while
+      // the inspector stayed folded.
+      app.state.sidebarPx = 350;
+      expect(showInInspector(app, document.createElement('div'), vi.fn())).toBe(true);
+      // reserved = 350 + 14 = 364; ceiling = min(942.08, 1024-364-320=340) = 340
+      expect(app.dom.inspectorHost!.style.width).toBe('340px');
+      handle.dispose();
+    });
+
+    it('a live window resize re-clamps the OPEN inspector width without mutating the persisted preference', () => {
+      const { app, handle } = mount();
+      showInInspector(app, document.createElement('div'), vi.fn());
+      expect(app.dom.inspectorHost!.style.width).toBe('442px');
+      const preferenceBefore = app.state.rightInspectorPx;
+
+      const vw = vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(700);
+      try {
+        window.dispatchEvent(new Event('resize'));
+        // reserved unchanged (248+14=262); ceiling = min(700*0.92=644, 700-262-320=118)
+        // — below the shared 320 floor, so clamp's floor wins.
+        expect(app.dom.inspectorHost!.style.width).toBe('320px');
+        expect(app.state.rightInspectorPx).toBe(preferenceBefore); // untouched
+      } finally {
+        vw.mockRestore();
+      }
+      handle.dispose();
+    });
+
+    it('a window resize while FOLDED does not throw, and the next unfold reflects the new viewport', () => {
+      const { app, handle } = mount();
+      expect(app.dom.inspectorHost!.hidden).toBe(true);
+      const vw = vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(700);
+      try {
+        expect(() => window.dispatchEvent(new Event('resize'))).not.toThrow();
+        showInInspector(app, document.createElement('div'), vi.fn());
+        expect(app.dom.inspectorHost!.style.width).toBe('320px'); // per the ceiling computed above
+      } finally {
+        vw.mockRestore();
+      }
+      handle.dispose();
+    });
+  });
 });
diff --git a/tests/unit/inspector-host.test.ts b/tests/unit/inspector-host.test.ts
index 10c18b3d..8af70457 100644
--- a/tests/unit/inspector-host.test.ts
+++ b/tests/unit/inspector-host.test.ts
@@ -120,6 +120,71 @@ describe('showInInspector / releaseInspector / closeInspector', () => {
   // module never assumes it. These never fire in production; they exist so a
   // caller whose shell hasn't mounted yet (or a narrow test fixture) degrades
   // to a harmless no-op instead of throwing.
+  // #586 findings 1/2b: the two shell-owned hooks this module calls at the
+  // exact points it folds/unfolds the host, without knowing anything about
+  // drag mechanics or layout math itself — just invoking an optional
+  // callback on the same `dom` bag it already reads `inspectorHost`/
+  // `inspectorResize` off of.
+  describe('shell-owned fold/unfold hooks (#586 findings 1/2b)', () => {
+    it('showInInspector calls reclampInspectorWidth, when present, before revealing the host', () => {
+      const order: string[] = [];
+      const app = {
+        dom: {
+          inspectorHost: document.createElement('div'),
+          inspectorResize: document.createElement('div'),
+          reclampInspectorWidth: vi.fn(() => { order.push('reclamp'); }),
+        },
+      };
+      app.dom.inspectorHost.hidden = true;
+      order.push(app.dom.inspectorHost.hidden ? 'was-hidden' : 'was-visible');
+      showInInspector(app, document.createElement('p'), vi.fn());
+      expect(app.dom.reclampInspectorWidth).toHaveBeenCalledTimes(1);
+      expect(order).toEqual(['was-hidden', 'reclamp']); // reclamp runs before hidden flips to false
+      expect(app.dom.inspectorHost.hidden).toBe(false);
+    });
+
+    it('showInInspector tolerates a missing reclampInspectorWidth hook', () => {
+      const app = makeApp();
+      expect(() => showInInspector(app, document.createElement('p'), vi.fn())).not.toThrow();
+      expect(app.dom.inspectorHost.hidden).toBe(false);
+    });
+
+    it('showInInspector does not call reclampInspectorWidth when the mount itself fails (no host/resize node)', () => {
+      const reclamp = vi.fn();
+      const noResize = { dom: { inspectorHost: document.createElement('div'), reclampInspectorWidth: reclamp } };
+      expect(showInInspector(noResize, document.createElement('p'), vi.fn())).toBe(false);
+      expect(reclamp).not.toHaveBeenCalled();
+    });
+
+    it('releaseInspector calls cancelInspectorDrag, when present', () => {
+      const cancel = vi.fn();
+      const app = {
+        dom: {
+          inspectorHost: document.createElement('div'),
+          inspectorResize: document.createElement('div'),
+          cancelInspectorDrag: cancel,
+        },
+      };
+      showInInspector(app, document.createElement('p'), vi.fn());
+      releaseInspector(app);
+      expect(cancel).toHaveBeenCalledTimes(1);
+      expect(app.dom.inspectorHost.hidden).toBe(true);
+    });
+
+    it('releaseInspector tolerates a missing cancelInspectorDrag hook', () => {
+      const app = makeApp();
+      showInInspector(app, document.createElement('p'), vi.fn());
+      expect(() => releaseInspector(app)).not.toThrow();
+    });
+
+    it('releaseInspector with no host mounted at all never calls cancelInspectorDrag', () => {
+      const cancel = vi.fn();
+      const bare: InspectorHostApp = { dom: { cancelInspectorDrag: cancel } };
+      releaseInspector(bare);
+      expect(cancel).not.toHaveBeenCalled();
+    });
+  });
+
   describe('no host mounted yet (AppDom fields absent)', () => {
     it('isInspectorOpen/closeInspector are inert', () => {
       const bare: InspectorHostApp = { dom: {} };
diff --git a/tests/unit/splitters.test.ts b/tests/unit/splitters.test.ts
index ee172b85..59833b44 100644
--- a/tests/unit/splitters.test.ts
+++ b/tests/unit/splitters.test.ts
@@ -1,5 +1,5 @@
 import { describe, it, expect, vi } from 'vitest';
-import { dragValue, startDrag, clampDrawerWidth } from '../../src/ui/splitters.js';
+import { dragValue, startDrag, clampDrawerWidth, clampDockedInspectorWidth, CENTRE_MIN_PX } from '../../src/ui/splitters.js';
 import type { DragPoint } from '../../src/ui/splitters.js';
 
 describe('clampDrawerWidth', () => {
@@ -10,6 +10,36 @@ describe('clampDrawerWidth', () => {
   });
 });
 
+// #586 finding 2a: the dock-aware ceiling protects the centre work surface
+// (`.query-host`/`.dashboard-host`) — `clampDrawerWidth`'s flat 92vw bound
+// alone can starve it to nothing once the inspector is a real `.main-row`
+// sibling instead of a `position: fixed` overlay.
+describe('clampDockedInspectorWidth', () => {
+  it('CENTRE_MIN_PX matches clampDrawerWidth\'s own 320 floor — both sides of the split share one "usable panel" minimum', () => {
+    expect(CENTRE_MIN_PX).toBe(320);
+  });
+  it('the dock-aware ceiling (totalWidth - reservedPx - CENTRE_MIN_PX) binds when it is tighter than 92vw', () => {
+    // 1000 total, 200 reserved (sidebar + handles): ceiling = 1000-200-320 = 480,
+    // well under 92vw (920) — the dock-aware bound is the one that bites.
+    expect(clampDockedInspectorWidth(999, 1000, 200)).toBe(480);
+    expect(clampDockedInspectorWidth(480, 1000, 200)).toBe(480); // exactly at the ceiling
+    expect(clampDockedInspectorWidth(300, 1000, 200)).toBe(320); // below the shared floor
+  });
+  it('falls back to the 92vw ceiling when reservedPx is small enough not to bind', () => {
+    // 1000 total, 0 reserved: dock-aware ceiling = 1000-0-320 = 680, tighter
+    // than 92vw (920) — still the dock-aware bound wins here too, proving
+    // Math.min picks whichever is tighter, not "dock-aware always wins".
+    expect(clampDockedInspectorWidth(999, 1000, 0)).toBe(680);
+  });
+  it('a wide row with heavy reservation still floors at 320 even when the computed ceiling is below it', () => {
+    // 500 total, 300 reserved: ceiling = 500-300-320 = -120 — clamp's own
+    // floor (320) wins regardless (Math.max(lo, Math.min(hi, v)) with hi {
   const rect = { top: 100, bottom: 300 }; // height 200
   it('col clamps clientX to [180,420]', () => {
@@ -29,12 +59,21 @@ describe('dragValue', () => {
     expect(dragValue('row', { clientX: 0, clientY: 100 }, rect)).toBe(15);
     expect(dragValue('row', { clientX: 0, clientY: 200 }, rect)).toBe(50);
   });
-  it('rightInspector maps viewportWidth-clientX to px clamped [320, 92vw]', () => {
+  it('rightInspector maps viewportWidth-clientX to px clamped [320, 92vw] when reservedPx is absent (a non-docked caller, e.g. drawer.ts)', () => {
     const vw = { width: 1000 };
     expect(dragValue('rightInspector', { clientX: 500, clientY: 0 }, vw)).toBe(500); // 1000-500
     expect(dragValue('rightInspector', { clientX: 900, clientY: 0 }, vw)).toBe(320); // 1000-900=100 → floor
     expect(dragValue('rightInspector', { clientX: -100, clientY: 0 }, vw)).toBe(920); // 1000-(-100)=1100 → 92vw cap
   });
+  // #586 finding 2a: a docked caller (app-shell.ts) always supplies
+  // `reservedPx` — its presence (even 0), not the axis itself, switches
+  // `dragValue` onto the dock-aware ceiling instead of the plain 92vw one.
+  it('rightInspector uses the dock-aware ceiling instead of 92vw when reservedPx is present', () => {
+    const vw = { width: 1000, reservedPx: 200 }; // ceiling = 1000-200-320 = 480
+    expect(dragValue('rightInspector', { clientX: 500, clientY: 0 }, vw)).toBe(480); // 1000-500=500 → clamped to 480
+    expect(dragValue('rightInspector', { clientX: 900, clientY: 0 }, vw)).toBe(320); // 1000-900=100 → floor
+    expect(dragValue('rightInspector', { clientX: 600, clientY: 0 }, vw)).toBe(400); // 1000-600=400, within [320,480] — unclamped
+  });
 });
 
 function fakeWin() {
diff --git a/tests/unit/state.test.ts b/tests/unit/state.test.ts
index b77f23a5..f2546235 100644
--- a/tests/unit/state.test.ts
+++ b/tests/unit/state.test.ts
@@ -271,6 +271,57 @@ describe('createState', () => {
     it('falls back to the 480 default when nothing is persisted at all', () => {
       expect(createState(reader()).rightInspectorPx).toBe(480);
     });
+
+    // #586 finding 4: the old `||`-chained read short-circuited on ANY
+    // non-empty string, so a malformed canonical value both blocked a
+    // perfectly valid legacy fallback AND survived as `NaN` through `clamp`
+    // (rendering a literal "NaNpx" width). Each candidate must now be
+    // validated independently.
+    it('a malformed canonical rightInspectorPx is skipped in favor of a valid docPanePx fallback', () => {
+      const s = createState(reader({
+        [KEYS.rightInspectorPx]: 'bad',
+        [KEYS.docPanePx]: '420',
+        [KEYS.cellDrawerPx]: '560',
+      }));
+      expect(s.rightInspectorPx).toBe(420);
+      expect(Number.isNaN(s.rightInspectorPx)).toBe(false);
+    });
+
+    it('a malformed canonical AND docPanePx both skipped in favor of a valid cellDrawerPx', () => {
+      const s = createState(reader({
+        [KEYS.rightInspectorPx]: 'bad',
+        [KEYS.docPanePx]: 'also-bad',
+        [KEYS.cellDrawerPx]: '560',
+      }));
+      expect(s.rightInspectorPx).toBe(560);
+    });
+
+    it('every candidate malformed falls back to the 480 default, never NaN', () => {
+      const s = createState(reader({
+        [KEYS.rightInspectorPx]: 'bad',
+        [KEYS.docPanePx]: 'also-bad',
+        [KEYS.cellDrawerPx]: 'still-bad',
+      }));
+      expect(s.rightInspectorPx).toBe(480);
+    });
+
+    it('a whitespace-only canonical value is treated as absent, not as a real (NaN) value', () => {
+      const s = createState(reader({
+        [KEYS.rightInspectorPx]: '   ',
+        [KEYS.docPanePx]: '420',
+      }));
+      expect(s.rightInspectorPx).toBe(420);
+    });
+
+    it('an out-of-range but numeric value is still "valid" — parsed, then clamped by the outer bound, not rejected', () => {
+      const s = createState(reader({ [KEYS.rightInspectorPx]: '-50' }));
+      expect(s.rightInspectorPx).toBe(320); // clamp(-50, 320, Infinity)
+    });
+
+    it('a leading-whitespace numeric value still parses (parseInt tolerates it, matching every other numeric pref read in this file)', () => {
+      const s = createState(reader({ [KEYS.rightInspectorPx]: '  650' }));
+      expect(s.rightInspectorPx).toBe(650);
+    });
   });
   it('defaults the reader to storage helpers', () => {
     vi.stubGlobal('localStorage', memStore({ [KEYS.theme]: 'light' }));

From dfd91413e92a2ebe297ec54f97e191b9b282a744 Mon Sep 17 00:00:00 2001
From: Boris Tyshkevich 
Date: Mon, 3 Aug 2026 22:55:52 +0200
Subject: [PATCH 4/4] docs(#586): record the dock-aware width clamp and
 fail-closed width parsing

The review fixes in 8539d81 changed user-visible behaviour that the
CHANGELOG entry did not yet describe: the inspector's maximum width now
reserves a centre minimum instead of allowing a flat 92vw, is recomputed on
unfold and window resize, and never narrows the persisted preference; and a
corrupt canonical width now falls through to a real legacy value rather than
producing NaN.

Co-Authored-By: Claude Fable 5 
Claude-Session: https://claude.ai/code/session_01Da66KLYSmCey6Gi7RMFGcf
---
 CHANGELOG.md | 11 ++++++++++-
 1 file changed, 10 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index f53ef934..e7d8985d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,7 +35,16 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
   probes/CSS, and the docs pane's own bespoke resize/keydown wiring — all
   deleted). `cellDrawerPx`/`docPanePx` collapse into one `rightInspectorPx`
   preference (compat read order: `rightInspectorPx` → `docPanePx` →
-  `cellDrawerPx` → 480px default; single canonical write). Docked surfaces
+  `cellDrawerPx` → 480px default; single canonical write, and each candidate
+  is validated independently so a corrupt canonical value falls through to a
+  real legacy one instead of yielding `NaN`). Because the inspector is now a
+  layout sibling rather than an overlay, its width is clamped **dock-aware** —
+  the old flat 92vw ceiling could starve the centre surface once the panel
+  took real layout space, so the ceiling now also reserves a 320px minimum for
+  the centre (plus the sidebar and handles) and is recomputed whenever the
+  panel unfolds or the window resizes, not once at construction. The clamp
+  only ever changes the *displayed* width; the user's persisted preference is
+  never narrowed by it. Docked surfaces
   are now non-modal (no keyboard-owner acquisition — the pre-#586 modal cell
   drawer blocked every app shortcut while open; this issue's docked model
   fixes that), so `app.ts`'s Query↔Dashboard surface transition and