From 247ecbdccc4f95e64e0b3451c34da2505449790e Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Wed, 29 Jul 2026 22:25:07 +0200 Subject: [PATCH 01/78] feat(#487): left-navigation layout core and preferences (phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of 4 for the drag-foldable desktop left navigation. Adds the pure module that owns every layout decision the feature needs, plus the browser preferences behind it. No user-visible change: the rail, the docked focused drawer and the resize separator arrive in phase 3. `src/core/left-nav-layout.ts` holds the named constants and thresholds, the explicit 'wide' | 'rail' mode, the hysteresis reducer, the keyboard separator resolver, rail activation, the separator's ARIA range, and the mobile projection. Hysteresis is the threshold PAIR — folding needs a proposal below 140px, restoring one above 260px — so the mode is sticky between them and no single pointer pixel can oscillate it. The keyboard path routes through the same reducer as the pointer path, so "keyboard operations match pointer transitions" holds by construction rather than by two implementations agreeing. Every function takes the navigation's proposed TOTAL width and derives each mode's own panel width itself. An earlier revision handed the reducer a mode-relative width, and that made a monotone rightward drag snap the navigation 108px BACKWARDS on the frame a drawer converted to the wide sidebar, because the two measurements disagree by exactly the rail's width at the crossing. The regression tests now sweep a whole pointer path and assert the width response is monotone; per-frame assertions could not see it, since each frame's output was individually defensible. Deviations from the issue's *suggested* shape, all deliberate: - the wide width reuses the existing `asb:sidebarPx` rather than adding a parallel `wideWidthPx`. That key already persists exactly this width over exactly this range; two owners of one width is a bug waiting to happen. The new keys are only `asb:leftNavMode` and `asb:leftNavDrawerPx`. - the focused drawer's band is [fold, wide] = [140, 260], not the wide sidebar's [180, 420]. A drawer drag must fold below the fold threshold and convert above the wide one, so the wide range is mostly unreachable for a drawer; MIN/MAX govern the sidebar. - a drag follows the pointer and does NOT restore remembered widths. The issue asks a rail -> wide drag for both "restore the last useful wide width" and "deterministic resize feedback", and those cannot both hold: the next pointermove overwrites whatever the crossing frame installed, so a restored width survives one frame and reads as a flicker. `End` restores the remembered width — it is the discrete counterpart, with no pointer to honour. - the centre-width clamp moves to phase 4, where its caller is. A single total-in/total-out signature is the wrong shape: there is no inverse from a total back to (mode, panel width), so it could return a width no mode can render. Deferring also respects CLAUDE.md hard rule 5. Also drops the second owner of [180, 420]: `splitters.ts`'s 'col' axis, which WRITES `sidebarPx`, kept its own copy of the bounds as literals while the load path moved onto the constants. Behaviour is unchanged (a real drag always carries a finite clientX); the sidebar e2e drag specs pass untouched. `clampWideWidthPx` closes a NaN hole on the way past: `clamp` is not NaN-safe (`Math.max(180, NaN)` is NaN), so a corrupt `asb:sidebarPx` would have decoded to `width: NaNpx`, which the browser drops. Hardening, not a reproducible bug — no code path writes a non-numeric value. The same hole in the four sibling geometry keys is filed as #570 (`inbox`). `'library'` is this module's section name but NOT the value `AppState.sidePanel` stores for it: that is still `'saved'`, because #427 renamed the label and left the persisted value alone. Phase 2's registry owns the one mapping; the type's doc records it so phase 2 does not rediscover it the hard way. npm test 6734 passing, `left-nav-layout.ts` and `splitters.ts` both at 100/100/100/100. Sabotage-checked: reverting the crossing to the remembered width, basing the bare-rail arrow step on `wideWidthPx`, loosening the drawer's fold comparison to `<=`, and adding a field to the workspace projection each fail a specific test. Part of #487 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FPnt3pq98P5Y1ww3sWawp1 --- CHANGELOG.md | 36 ++ src/application/app-preferences.ts | 6 +- src/core/left-nav-layout.ts | 406 +++++++++++++++++++++ src/state.ts | 55 ++- src/styles.css | 3 +- src/ui/splitters.ts | 12 +- tests/unit/app-preferences.test.ts | 4 + tests/unit/left-nav-layout.test.ts | 561 +++++++++++++++++++++++++++++ tests/unit/state.test.ts | 98 +++++ 9 files changed, 1177 insertions(+), 4 deletions(-) create mode 100644 src/core/left-nav-layout.ts create mode 100644 tests/unit/left-nav-layout.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ae7999ca..b60c5788 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,31 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] +### Added +- **The desktop left navigation's layout core and preferences** (#487, phase 1 of + 4). A new pure `src/core/left-nav-layout.ts` owns every layout decision the + foldable left navigation needs: the named constants and thresholds, the + explicit `'wide' | 'rail'` mode, the hysteresis reducer that maps a proposed + width onto the next mode, the keyboard separator resolver, rail activation, the + separator's ARIA range, and the mobile projection that makes a phone ignore + rail mode without discarding the preference. Two new browser preferences back + it (`asb:leftNavMode`, `asb:leftNavDrawerPx`); the wide sidebar's width + deliberately stays on the existing `asb:sidebarPx` rather than gaining a second + owner, and the focused section is session state that does not reopen after a + reload. Hysteresis comes from the threshold *pair* — folding needs a proposal + below 140px and restoring one above 260px — so no single pointer pixel can + oscillate the mode, and the keyboard path routes through the same reducer as + the pointer path rather than reimplementing it. Every function takes the + navigation's proposed *total* width, which is what keeps a drag continuous + across a mode change: handing the reducer a mode-relative width instead made a + monotone rightward drag snap the navigation 108px backwards on the frame a + drawer converted to the sidebar. **No user-visible change yet**: the rail, the + docked focused drawer and the resize separator arrive in phase 3. +- The sidebar's `'col'` drag axis now clamps through the same + `LEFT_PANEL_MIN_PX`/`LEFT_PANEL_MAX_PX` constants as the load path, instead of + repeating `180`/`420` as literals (#487). Behaviour is unchanged; it removes + the second owner of a range whose whole point is having one. + ### Changed - **`VariableBarApp`'s shared activation port is now caller-neutral** (#478). `state.filterActive`/`params.saveFilterActive` — named after Workbench @@ -25,6 +50,17 @@ auto-generated per-PR notes; this file is the curated, human-readable history. adapter refactor — no user-visible behavior changes. ### Fixed +- **A corrupt `asb:sidebarPx` decodes to the default width instead of `NaN`** + (#487). The width decoded through a bare `clamp(parseInt(stored), 180, 420)`, + and `clamp` is not NaN-safe (`Math.max(180, NaN)` is `NaN`), so a non-numeric + stored value would reach the DOM as `width: NaNpx` — which the browser drops, + collapsing the sidebar with nothing to explain why, and the bad value would + persist across reloads. It now falls back to the documented 248px default. + Hardening rather than a reproducible user-visible bug: no code path in the app + writes a non-numeric value (a real drag always carries a finite `clientX`), so + reaching it takes a hand-edited or foreign-origin `localStorage` entry. The + same hole in `editorPct`/`sideSplitPct`/`cellDrawerPx`/`docPanePx` is tracked + separately in #570. - **The Dashboard tree no longer reveals two rows' pencil/trash actions at once, and its `· N` count now sits inline after the label** (#568). The hover/focus reveal rule (`.dash-tree-row:focus-within .dash-tree-act`) diff --git a/src/application/app-preferences.ts b/src/application/app-preferences.ts index f79e5b4d..18cb6886 100644 --- a/src/application/app-preferences.ts +++ b/src/application/app-preferences.ts @@ -31,7 +31,11 @@ export type PreferenceKey = | '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'; + | 'docPanePx' + // #487 — the desktop left navigation's semantic mode and its focused drawer's + // width. The WIDE sidebar width stays `sidebarPx` above: `core/left-nav-layout.ts` + // reuses that preference rather than introducing a second owner of one width. + | 'leftNavMode' | 'leftNavDrawerPx'; /** The one state field this service reads/writes (`toggleTheme` only) — a * plain settable property, not a signal (matches `AppState.theme`). */ diff --git a/src/core/left-nav-layout.ts b/src/core/left-nav-layout.ts new file mode 100644 index 00000000..e4cea2b0 --- /dev/null +++ b/src/core/left-nav-layout.ts @@ -0,0 +1,406 @@ +// The desktop left navigation's layout decisions (#487 phase 1) — pure, no DOM, +// no globals. This module owns the *semantic* mode ('wide' two-pane sidebar vs +// compact 'rail'), the widths each presentation may take, and every transition +// between them. It owns none of the rendering: phase 2 extracts the section +// registry, phase 3 builds the rail, the docked focused drawer and the resize +// separator on top of exactly these functions. +// +// Why a mode reducer rather than reading CSS widths back: #487 is explicit that +// "the mode is explicit application-shell state" and must not be re-derived from +// every pointer pixel after every repaint. So the separator's job in phase 3 is +// only to report a proposed width; what that width *means* is decided here, once, +// in one place both the pointer and the keyboard paths go through — which is also +// why the issue's "keyboard separator operations match pointer transitions" test +// is a property of this module rather than something the UI has to keep in sync. +// +// Every function here takes and returns the navigation's proposed *total* width. +// That is deliberate and it is what keeps a drag continuous: a wide sidebar's own +// width IS the total, while an open drawer's width is the total minus the rail +// beside it. Handing the reducer one mode-relative number instead made a +// monotone rightward drag snap the navigation 108px BACKWARDS on the frame it +// converted a drawer to the wide sidebar, because the two measurements disagree +// by exactly the rail's width at the crossing. Totals are the only common +// currency, so the reducer does the per-mode subtraction itself. + +import { clamp } from './format.js'; + +/** The compact rail's fixed visual width. Nothing resizes the rail *to* another + * value — it is the mode, not a width — though it does appear as a lower bound + * elsewhere here (the separator's `aria-valuemin`, and the offset an open + * drawer's own width sits behind). */ +export const LEFT_RAIL_PX = 48; + +/** + * The two mode thresholds, and the pair *is* the hysteresis: folding wide → rail + * requires a proposal below `LEFT_FOLD_THRESHOLD_PX`, restoring rail → wide + * requires one above `LEFT_WIDE_THRESHOLD_PX`. Between the two the mode is + * sticky, so no single pointer pixel can oscillate it — which is what #487 means + * by "must not flicker near a single threshold". + */ +export const LEFT_FOLD_THRESHOLD_PX = 140; +export const LEFT_WIDE_THRESHOLD_PX = 260; + +/** The WIDE two-pane sidebar's resize bounds — the range `asb:sidebarPx` has + * always used, preserved verbatim per #487 ("preserve the existing + * sidebar-width preference range"). These do NOT bound the focused drawer; see + * `clampDrawerWidthPx`. */ +export const LEFT_PANEL_MIN_PX = 180; +export const LEFT_PANEL_MAX_PX = 420; + +/** The wide sidebar's documented default — the value `asb:sidebarPx` has + * defaulted to since it was introduced, and the fallback a corrupt stored value + * falls back to. */ +export const LEFT_WIDE_DEFAULT_PX = 248; + +/** The focused drawer's documented default, inside the drawer's own + * [fold, wide] band below. */ +export const LEFT_DRAWER_DEFAULT_PX = 240; + +/** Keyboard resize steps for the separator: a small step for a bare arrow, a + * large one for Shift+arrow (#487's "Left/Right Arrow resize by a small step; + * Shift+Left/Right resize by a larger step"). */ +export const LEFT_NAV_STEP_PX = 16; +export const LEFT_NAV_LARGE_STEP_PX = 64; + +export type LeftNavigationMode = 'wide' | 'rail'; + +/** + * The four rail sections, named as #487's own section table names them. + * + * **`'library'` is NOT the value `AppState.sidePanel` holds for the same + * section.** That signal still stores `'saved'` (persisted at `asb:sidePanel`, + * compared against in `ui/saved-history.ts`); #427 renamed the visible *label* to + * "Library" and deliberately left the stored value alone, since migrating it + * would discard every user's persisted lower-pane choice for no behavioural gain. + * + * So the vocabularies genuinely differ, and phase 2's navigation section registry + * owns the mapping in exactly one place — `'library' ↔ 'saved'`, with the other + * three sections identical. That mapping is deliberately NOT written here yet: it + * has no caller until the registry exists, and a second copy of it is precisely + * the duplication phase 2 is meant to prevent. + */ +export type LeftNavigationSection = 'databases' | 'dashboards' | 'library' | 'history'; + +/** Rail order, top to bottom — the order #487's section table lists, and the + * same order the existing wide switchers present (Databases | Dashboards above, + * Library | History below). */ +export const LEFT_NAV_SECTIONS: readonly LeftNavigationSection[] = + ['databases', 'dashboards', 'library', 'history']; + +/** + * The complete left-navigation layout. `wideWidthPx` is deliberately the SAME + * value `AppState.sidebarPx` persists at `asb:sidebarPx` — #487 suggests a new + * `wideWidthPx` field, but that key already holds exactly this width over + * exactly this range, and two sources of truth for one width is a bug waiting + * to happen. `state.ts` maps the two names at the boundary. + * + * `focusedSection` is session UI state (never persisted, per #487) and is only + * meaningful in 'rail' mode: 'wide' shows both panes, so there is nothing to + * focus. **Every reducer here maintains that invariant** — no sequence of drags, + * keys or rail activations can produce 'wide' with a non-null `focusedSection`, + * and `leftNavigationLayoutIsCoherent` states it as a checkable predicate. + * + * The invariant lives in the reducers, though, NOT in the two signals + * `state.ts` stores it across: those are independently settable, so phase 3 must + * route every write through these functions. In particular a wide-mode + * `openFocusedSection(section)` must drive the existing pane switchers, never + * `state.leftNavSection` — see `resolveRailActivation`. + * + * `readonly` throughout, matching `core/dashboard-tree-ui-state.ts` (the closest + * sibling: also a pure copy-on-write UI-state reducer). It matters here because + * the reducers below return the SAME object when nothing changes, and phase 3 is + * invited to use that identity to skip a repaint — which only holds if a caller + * cannot mutate a layout in place. + */ +export interface LeftNavigationLayout { + readonly mode: LeftNavigationMode; + readonly wideWidthPx: number; + readonly drawerWidthPx: number; + readonly focusedSection: LeftNavigationSection | null; +} + +/** + * True for exactly the four known section names. + * + * No caller yet, and deliberately so: phase 3's `openFocusedSection(section)` is + * a public seam #428's drag-hover path calls from outside this module, and that + * is the boundary an unchecked string could cross. Nothing validates through it + * today — `focusedSection` is session-only state seeded from a literal, so there + * is no persistence path an obsolete section could arrive by. It lands with the + * type it guards rather than being invented later against a half-remembered union. + */ +export function isLeftNavigationSection(value: unknown): value is LeftNavigationSection { + return LEFT_NAV_SECTIONS.some((section) => section === value); +} + +/** The `mode`/`focusedSection` invariant, as a predicate — a focused drawer + * exists only in rail mode. Exported so the reducers' shared postcondition can + * be asserted directly instead of re-derived in each test. */ +export function leftNavigationLayoutIsCoherent(layout: LeftNavigationLayout): boolean { + return layout.mode === 'rail' || layout.focusedSection === null; +} + +/** Decode a persisted mode. Anything that is not exactly `'rail'` — a missing + * key, an obsolete value, a truncated write — decodes to `'wide'`, the + * documented default for a fresh desktop session. */ +export function decodeLeftNavigationMode(value: unknown): LeftNavigationMode { + return value === 'rail' ? 'rail' : 'wide'; +} + +/** + * Clamp a wide-sidebar width into `[LEFT_PANEL_MIN_PX, LEFT_PANEL_MAX_PX]`. + * + * NaN-safe on purpose: `clamp` alone is not. `Math.min(420, NaN)` is NaN and so + * is `Math.max(180, NaN)`, so the bare `clamp(parseInt(stored), 180, 420)` this + * replaces decoded a corrupt `asb:sidebarPx` straight through to NaN — and a NaN + * width reaches the DOM as `width: NaNpx`, which the browser drops, silently + * collapsing the sidebar. #487 requires invalid widths to "clamp safely". + * + * Only NaN takes the default: `±Infinity` has an unambiguous clamp target and + * still returns the bound it is pressed against, exactly as the bare `clamp` did. + * Guarding both would have introduced a discontinuity — `-1` → 180 but + * `-Infinity` → 248 — and quietly changed this key's existing behaviour. + */ +export function clampWideWidthPx(px: number): number { + if (Number.isNaN(px)) return LEFT_WIDE_DEFAULT_PX; + return clamp(px, LEFT_PANEL_MIN_PX, LEFT_PANEL_MAX_PX); +} + +/** + * Clamp a focused-drawer width into `[LEFT_FOLD_THRESHOLD_PX, + * LEFT_WIDE_THRESHOLD_PX]` — the drawer's own band, NOT the wide sidebar's + * `[MIN, MAX]`. + * + * That is a reading of #487's drawer-resize rules rather than of its constant + * list: a drawer drag must fold closed below the fold threshold and convert to + * the wide sidebar above the wide threshold, so everything in between is the + * only width a drawer can hold. Giving the drawer the wide sidebar's 180 floor + * and 420 ceiling would make most of that range unreachable — the drag would + * have converted to wide long before 420. `MIN`/`MAX` govern the wide sidebar. + */ +export function clampDrawerWidthPx(px: number): number { + if (Number.isNaN(px)) return LEFT_DRAWER_DEFAULT_PX; + return clamp(px, LEFT_FOLD_THRESHOLD_PX, LEFT_WIDE_THRESHOLD_PX); +} + +/** + * The width the navigation actually occupies in the shell row — what the centre + * surface is pushed by, and what the separator reports as `aria-valuenow`. + * 'rail' with a drawer open occupies both: #487 requires every rail icon to stay + * visible while a drawer is open, so the drawer sits beside the rail rather than + * replacing it. + */ +export function leftNavigationWidthPx(layout: LeftNavigationLayout): number { + if (layout.mode === 'wide') return layout.wideWidthPx; + return layout.focusedSection === null ? LEFT_RAIL_PX : LEFT_RAIL_PX + layout.drawerWidthPx; +} + +/** + * The mode machine. Given the current layout and the proposed TOTAL navigation + * width, return the next layout. + * + * `totalPx` is the navigation's full width measured from its own left edge — for + * a pointer drag, `clientX` minus whatever the shell offsets the navigation by + * (zero today; phase 3 must subtract a real offset if a left gutter ever appears, + * or every threshold here silently shifts by it). The reducer derives each mode's + * own panel width from that total itself, which is what keeps a drag continuous + * across a mode change. + * + * Returns the SAME object when nothing changes — a bare rail drag below the wide + * threshold has no effect at all, since the rail's width is the mode. + * + * **A drag follows the pointer; it does not restore remembered widths.** #487 + * asks a rail → wide drag to "restore the last useful wide width" *and* to "show + * deterministic resize feedback", and those two cannot both hold: whatever width + * the crossing frame installs, the very next pointermove overwrites with the + * pointer's own position, so a restored width would survive exactly one frame and + * read as a flicker. Direct manipulation wins during a gesture — the panel edge + * stays under the finger — and the remembered width is restored by `End`, which + * has no pointer to follow. See `resolveLeftNavigationKey`. + * + * Width memory: `wideWidthPx` is only committed for proposals inside + * `[MIN, MAX]`, and a fold leaves it untouched, so it survives a trip through + * rail mode for `End` and for the persisted preference. The documented + * consequence is that dragging through the 140–180 dead zone rests the width at + * the 180 floor, so a later `End` restores 180 rather than the pre-drag width — + * deterministic, and the alternative (freezing the width while the pointer keeps + * moving) would show a sidebar that refuses to shrink to its own floor. + */ +export function resolveLeftNavigationDrag( + layout: LeftNavigationLayout, totalPx: number, +): LeftNavigationLayout { + if (layout.mode === 'wide') { + // A wide sidebar IS the whole navigation, so its panel width is the total. + // Past the fold threshold: commit rail. The wide width is frozen at whatever + // the drag last rested at inside the wide range, and the rail opens with no + // focused section — #487 gives it none automatically. + if (totalPx < LEFT_FOLD_THRESHOLD_PX) { + return { ...layout, mode: 'rail', focusedSection: null }; + } + // Ordinary wide resize. Between the fold threshold and the 180 floor the + // sidebar sits AT the floor rather than clipping, so the user has to pull + // decisively past 140 to fold — "do not leave a partially clipped wide + // sidebar". + return { ...layout, wideWidthPx: clampWideWidthPx(totalPx) }; + } + // An open drawer sits BESIDE the rail, so its own width is the total minus the + // rail; a bare rail has no panel, and a rightward drag is a bid for a sidebar + // whose width would be the whole total. + const hasDrawer = layout.focusedSection !== null; + const panelPx = hasDrawer ? totalPx - LEFT_RAIL_PX : totalPx; + // Past the wide threshold: restore the two-pane sidebar AT THE POINTER, not at + // the remembered width (see this function's doc — a restored width would live + // one frame). `totalPx`, not `panelPx`: the sidebar replaces the rail as well + // as the drawer, so the total is what it must fill to stay under the finger. + if (panelPx > LEFT_WIDE_THRESHOLD_PX) { + return { ...layout, mode: 'wide', wideWidthPx: clampWideWidthPx(totalPx), focusedSection: null }; + } + // A bare rail below the wide threshold has nothing to resize. + if (!hasDrawer) return layout; + // Fold the focused drawer closed, keeping its width for the next open — the + // rail itself stays visible, per #487. + if (panelPx < LEFT_FOLD_THRESHOLD_PX) return { ...layout, focusedSection: null }; + return { ...layout, drawerWidthPx: clampDrawerWidthPx(panelPx) }; +} + +/** The subset of a keyboard event the separator's key handling reads — so a + * plain `{ key }` fixture satisfies it without a DOM event. The modifiers are + * read to REJECT chords: `Ctrl+Home` must not fold the navigation, and + * `Alt+ArrowLeft` is the browser's Back on some platforms. Shift is the one + * modifier with a meaning here (the large step). */ +export interface LeftNavigationKey { + key: string; + shiftKey?: boolean; + ctrlKey?: boolean; + metaKey?: boolean; + altKey?: boolean; +} + +/** + * The TOTAL width an arrow step starts from — the same currency the reducer + * takes, so for a wide sidebar and for an open drawer this is simply the width + * the navigation already occupies. + * + * A bare rail is the exception: its base is `LEFT_WIDE_THRESHOLD_PX`, not its own + * 48px. That is not a fudge — it is the only base that keeps the keyboard honest. + * A pointer can leave a bare rail because `clientX` is absolute, so dragging to + * x=300 proposes 300; an arrow key only has a *relative* step, so a base of 48 + * would propose 64, land in the sticky band, and change nothing — forever — while + * the separator still advertised `aria-valuemax: 420`, a control that lies about + * being resizable. Basing it at the threshold makes both directions come out + * right through the ordinary reducer, with no special-casing there: a rightward + * step crosses into wide (nothing legal exists between the rail and the 180 + * floor), and a leftward step lands in the sticky band and correctly does + * nothing, because the rail is already as folded as it goes. + * + * Deliberately a CONSTANT and not `wideWidthPx`: any remembered width at or below + * 244 would otherwise leave a bare rail's small ArrowRight stuck in the sticky + * band again, reintroducing exactly the dead end this exists to prevent. + */ +function keyboardBaseTotalPx(layout: LeftNavigationLayout): number { + if (layout.mode === 'rail' && layout.focusedSection === null) return LEFT_WIDE_THRESHOLD_PX; + return leftNavigationWidthPx(layout); +} + +/** + * Resolve a keyboard separator operation, or `null` when the key is not one of + * ours — phase 3 must not swallow keys it does not handle, and must not treat a + * Ctrl/Meta/Alt chord as a resize. + * + * Every arrow step routes through `resolveLeftNavigationDrag`, which is what + * makes #487's "keyboard separator operations match pointer transitions" true by + * construction rather than by two implementations agreeing. + * + * `End` is the one place a remembered width is restored — it is the discrete + * counterpart to a drag, with no pointer position to honour instead. + */ +export function resolveLeftNavigationKey( + layout: LeftNavigationLayout, event: LeftNavigationKey, +): LeftNavigationLayout | null { + if (event.ctrlKey || event.metaKey || event.altKey) return null; + // Home folds to rail and End restores wide, both regardless of the current + // mode — pressed twice they are idempotent, not a toggle. + if (event.key === 'Home') { + return layout.mode === 'rail' && layout.focusedSection === null + ? layout + : { ...layout, mode: 'rail', focusedSection: null }; + } + if (event.key === 'End') { + return layout.mode === 'wide' + ? layout + : { ...layout, mode: 'wide', wideWidthPx: clampWideWidthPx(layout.wideWidthPx), focusedSection: null }; + } + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return null; + const step = event.shiftKey ? LEFT_NAV_LARGE_STEP_PX : LEFT_NAV_STEP_PX; + const delta = event.key === 'ArrowRight' ? step : -step; + return resolveLeftNavigationDrag(layout, keyboardBaseTotalPx(layout) + delta); +} + +/** + * Resolve a rail launcher activation — a click, or phase 3's + * `openFocusedSection(section)` seam (#428's deterministic entry point). + * Activating the ACTIVE section closes the drawer; activating a different one + * switches content in place without closing first, as #487 requires. + * + * **Returns the layout unchanged in wide mode, and that is a hard limit, not a + * silent fallback.** There is no drawer in wide mode and both panes are already + * showing, so "open this section" there means selecting a pane, which is the + * registry's job and not a width decision. Phase 3 must branch on the mode and + * drive the existing upper/lower switchers — writing `state.leftNavSection` + * directly to force a wide-mode drawer would break the `mode`/`focusedSection` + * invariant, and `leftNavigationWidthPx` would then push the centre surface by a + * width that omits the drawer entirely. + */ +export function resolveRailActivation( + layout: LeftNavigationLayout, section: LeftNavigationSection, +): LeftNavigationLayout { + if (layout.mode !== 'rail') return layout; + return { ...layout, focusedSection: layout.focusedSection === section ? null : section }; +} + +/** + * The layout that actually applies at this viewport. Below the mobile breakpoint + * #487 requires the desktop rail and focused drawer not to render at all, and the + * established mobile segmented/bottom navigation to stand in — so the effective + * mode is always the two-pane presentation mobile already styles, with no focused + * section. + * + * The argument is returned UNTOUCHED for desktop, and the mobile branch is a + * projection, never a write: the persisted `mode` and both widths keep whatever + * the user last chose, which is what "ignore desktop folding preferences for + * effective mobile layout; preserve those preferences for the next desktop + * session" asks for. Phase 3 renders through this rather than reading `mode` + * directly. + */ +export function effectiveLeftNavigationLayout( + layout: LeftNavigationLayout, isMobile: boolean, +): LeftNavigationLayout { + if (!isMobile) return layout; + return layout.mode === 'wide' && layout.focusedSection === null + ? layout + : { ...layout, mode: 'wide', focusedSection: null }; +} + +/** The separator's ARIA range: the rail's width is the floor (the navigation can + * never be narrower than the mode it folds into) and the wide sidebar's ceiling + * is the max, with the live occupied width as `aria-valuenow`. + * + * Both extremes are genuinely reachable — a rail+drawer drag that keeps going + * right converts to wide and on to 420 — but the interior is not continuous: + * 49–179 is no resting width in any mode, because a wide sidebar folds before it + * gets there. That gap is inherent to one control spanning two modes rather than + * a bug in the range, and phase 3's assistive-technology pass is where the + * announcement wording gets judged against it. */ +export interface LeftNavigationSeparatorAria { + readonly valueMin: number; + readonly valueMax: number; + readonly valueNow: number; +} + +export function leftNavigationSeparatorAria(layout: LeftNavigationLayout): LeftNavigationSeparatorAria { + return { + valueMin: LEFT_RAIL_PX, + valueMax: LEFT_PANEL_MAX_PX, + valueNow: leftNavigationWidthPx(layout), + }; +} diff --git a/src/state.ts b/src/state.ts index 150de7f6..dd74f759 100644 --- a/src/state.ts +++ b/src/state.ts @@ -35,6 +35,11 @@ import type { LinkedTabSnapshot } from './workspace/workspace-sync.js'; import { materializeQueryTimeRange } from './core/query-time-range.js'; import type { QueryTimeRangeInferenceDiagnostic } from './core/query-time-range.js'; import { deriveWorkspaceKey } from './core/workspace-key.js'; +import { + LEFT_DRAWER_DEFAULT_PX, LEFT_WIDE_DEFAULT_PX, + clampDrawerWidthPx, clampWideWidthPx, decodeLeftNavigationMode, +} from './core/left-nav-layout.js'; +import type { LeftNavigationMode, LeftNavigationSection } from './core/left-nav-layout.js'; // ── Persisted-data types (schema-generated) ───────────────────────────────── @@ -431,6 +436,31 @@ export interface AppState { workspaceKey: string; libraryFilter: string; shortcutsOpen: Signal; + /** + * #487 — the desktop left navigation's explicit semantic mode: the established + * two-pane sidebar, or the compact icon rail. A signal because phase 3 repaints + * the shell from it; persisted at `asb:leftNavMode` because #487 makes it a + * browser preference. + * + * The WIDE width this mode pairs with is `sidebarPx` above — #487 suggests a + * separate `wideWidthPx`, but `asb:sidebarPx` already persists exactly that + * width over exactly the range the issue specifies, and one width with two + * owners is a bug waiting to happen. `core/left-nav-layout.ts` names it + * `wideWidthPx`; the mapping happens where the two meet. + */ + leftNavMode: Signal; + /** #487 — the focused drawer's persisted width, inside the drawer's own + * [fold, wide] band (never the wide sidebar's [180, 420] — see + * `clampDrawerWidthPx`). A plain number like the other splitter widths: the + * drag writes it, then persists it on mouseup. */ + leftNavDrawerPx: number; + /** + * #487 — which section the focused drawer is showing, or `null` for a bare + * rail. Deliberately NOT persisted: the issue specifies "`focusedSection` is + * session UI state and need not reopen automatically after reload". Session + * only; never part of `StoredWorkspaceV5`. + */ + leftNavSection: Signal; isMobile: Signal; mobileView: Signal<'tables' | 'editor' | 'results'>; mobileTab: Signal<'schema' | 'library'>; @@ -480,6 +510,12 @@ export const KEYS = { sideSplitPct: 'asb:sideSplitPct', cellDrawerPx: 'asb:cellDrawerPx', docPanePx: 'asb:docPanePx', + /** #487 — the desktop left navigation's semantic mode ('wide' | 'rail') and + * the focused drawer's width. The WIDE sidebar's width is `sidebarPx` above, + * not a third key: see `AppState.leftNavMode`'s comment for why that key is + * reused rather than duplicated. */ + leftNavMode: 'asb:leftNavMode', + leftNavDrawerPx: 'asb:leftNavDrawerPx', sidePanel: 'asb:sidePanel', saved: 'asb:saved', history: 'asb:history', @@ -629,7 +665,13 @@ export function createState(read: StateReader = { loadJSON, loadStr }): AppState // One persisted preference, default 500; a non-option stored value snaps // back to the default so the selector always reflects a real choice. resultRowLimit: normalizeRowLimit(parseInt(read.loadStr(KEYS.resultRowLimit, '500'), 10)), - sidebarPx: clamp(parseInt(read.loadStr(KEYS.sidebarPx, '248'), 10), 180, 420), + // #487 — the WIDE left-navigation width, and the one width the fold/restore + // machine remembers. `clampWideWidthPx` enforces the same [180, 420] range + // and 248 default this key has always had, but is NaN-safe where the bare + // `clamp(parseInt(...))` was not: `Math.max(180, NaN)` is NaN, so a corrupt + // stored value used to decode straight through to `width: NaNpx`, which the + // browser drops — collapsing the sidebar with no way to tell why. + sidebarPx: clampWideWidthPx(parseInt(read.loadStr(KEYS.sidebarPx, String(LEFT_WIDE_DEFAULT_PX)), 10)), 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 @@ -762,6 +804,17 @@ export function createState(read: StateReader = { loadJSON, loadStr }): AppState // a signal for consistency with the rest of the state (no reactive reader // today — shortcuts.js drives its own mount/unmount). shortcutsOpen: signal(false), + // #487 — the desktop left navigation. `leftNavMode` and `leftNavDrawerPx` + // are browser preferences; both decoders fall back to the documented default + // for a missing, invalid or obsolete stored value rather than propagating it + // (an unknown mode string is not a third mode, and a NaN width is not a + // width). `leftNavSection` is session-only by design — #487 specifies the + // focused drawer "need not reopen automatically after reload", so a fresh + // desktop session shows a bare rail even when rail mode was persisted. + leftNavMode: signal(decodeLeftNavigationMode(read.loadStr(KEYS.leftNavMode, 'wide'))), + leftNavDrawerPx: clampDrawerWidthPx( + parseInt(read.loadStr(KEYS.leftNavDrawerPx, String(LEFT_DRAWER_DEFAULT_PX)), 10)), + leftNavSection: signal(null), // Best-effort mobile mode (#126). `isMobile` mirrors the viewport width // against MOBILE_BREAKPOINT_PX — set once and on `change` by app.js's // injected matchMedia listener. Read by the schema tree (to drop diff --git a/src/styles.css b/src/styles.css index 4685b913..df1ba02a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1007,7 +1007,8 @@ h1, h2, h3, h4, h5, h6 { .sv-act:hover { color: var(--fg); background: var(--bg-hover); } .side-count { color: var(--fg-faint); font-weight: var(--fw-regular); } /* #552: at a narrow sidebar (bottom quarter of the 180–420px drag range — - `src/ui/splitters.ts`'s `clamp(ev.clientX, 180, 420)`), both tab rows + `LEFT_PANEL_MIN_PX`/`LEFT_PANEL_MAX_PX` in `src/core/left-nav-layout.ts`, + applied to the 'col' drag axis by `splitters.ts`'s `clampWideWidthPx`), both tab rows (`.upper-role-tabs`'s Databases/Dashboard and this row's Library/History) drop to text-only labels: the icon, and `.side-count`'s "· N" (the count AND its separator dot share one text node, so hiding the element hides both). diff --git a/src/ui/splitters.ts b/src/ui/splitters.ts index caf2fdcf..01a8b81d 100644 --- a/src/ui/splitters.ts +++ b/src/ui/splitters.ts @@ -3,6 +3,7 @@ // (window + persistence) for testing. import { clamp } from '../core/format.js'; +import { clampWideWidthPx } from '../core/left-nav-layout.js'; // 'docPane' (#313): the persistent documentation pane's own bounded-resize // axis — identical geometry to 'drawer' (right-edge anchored, same @@ -45,9 +46,18 @@ export function clampDrawerWidth(px: number, viewportWidth: number): number { * 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`. + * + * 'col' defers to `clampWideWidthPx` (#487) rather than repeating the sidebar's + * [180, 420] bounds. This axis WRITES `sidebarPx`, the same preference + * `createState` reads back through that function, so a local copy of the bounds + * here would be a second owner of one width — exactly what + * `core/left-nav-layout.ts` exists to prevent. Behaviour is unchanged for a real + * drag (`clientX` is always finite); phase 3 replaces this axis outright with the + * left-navigation separator, which routes the same proposal through the mode + * reducer instead of a bare clamp. */ export function dragValue(axis: SplitterAxis, ev: DragPoint, rect?: DragRect): number { - if (axis === 'col') return clamp(ev.clientX, 180, 420); + if (axis === 'col') return clampWideWidthPx(ev.clientX); // `!`: 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 diff --git a/tests/unit/app-preferences.test.ts b/tests/unit/app-preferences.test.ts index b2d08f90..19d14fa9 100644 --- a/tests/unit/app-preferences.test.ts +++ b/tests/unit/app-preferences.test.ts @@ -30,6 +30,10 @@ describe('save()', () => { ['sideSplitPct', 58, '58'], ['cellDrawerPx', 560, '560'], ['docPanePx', 420, '420'], + // #487 — the left navigation's two browser preferences. The WIDE width is + // `sidebarPx` above, deliberately not a third key. + ['leftNavMode', 'rail', 'rail'], + ['leftNavDrawerPx', 200, '200'], ['sidePanel', 'history', 'history'], ['resultRowLimit', 1000, '1000'], ]; diff --git a/tests/unit/left-nav-layout.test.ts b/tests/unit/left-nav-layout.test.ts new file mode 100644 index 00000000..72510a3e --- /dev/null +++ b/tests/unit/left-nav-layout.test.ts @@ -0,0 +1,561 @@ +// #487 phase 1 — the desktop left navigation's layout decisions. Pure module, so +// every case here is a plain input/output assertion: no DOM, no fixture app. +// +// Two structural choices, both learned from a review that caught a real bug this +// file had already declared green: +// +// 1. The drag tests sweep a POINTER PATH and assert a property of the whole +// sweep (mode-monotone, width-monotone), not just the transition frames. The +// bug that slipped through was a 108px backwards snap on the single frame a +// drawer converted to a wide sidebar — every per-frame assertion passed, +// because each frame's output was individually defensible. Only the sequence +// was wrong. +// 2. The "keyboard matches pointer" property drives the pointer side from the +// module's OWN public `leftNavigationWidthPx`, never from a copy of the +// private base formula. A property test that recomputes the implementation is +// a tautology with respect to that implementation, and it hid a second latent +// bug in the bare-rail base. + +import { describe, it, expect } from 'vitest'; +import { + LEFT_DRAWER_DEFAULT_PX, LEFT_FOLD_THRESHOLD_PX, LEFT_NAV_LARGE_STEP_PX, LEFT_NAV_SECTIONS, + LEFT_NAV_STEP_PX, LEFT_PANEL_MAX_PX, LEFT_PANEL_MIN_PX, LEFT_RAIL_PX, LEFT_WIDE_DEFAULT_PX, + LEFT_WIDE_THRESHOLD_PX, + clampDrawerWidthPx, clampWideWidthPx, decodeLeftNavigationMode, effectiveLeftNavigationLayout, + isLeftNavigationSection, leftNavigationLayoutIsCoherent, leftNavigationSeparatorAria, + leftNavigationWidthPx, resolveLeftNavigationDrag, resolveLeftNavigationKey, resolveRailActivation, +} from '../../src/core/left-nav-layout.js'; +import type { LeftNavigationLayout } from '../../src/core/left-nav-layout.js'; + +/** A wide layout at the documented default width. */ +const wide = (over: Partial = {}): LeftNavigationLayout => ({ + mode: 'wide', + wideWidthPx: LEFT_WIDE_DEFAULT_PX, + drawerWidthPx: LEFT_DRAWER_DEFAULT_PX, + focusedSection: null, + ...over, +}); + +/** A rail layout; pass `focusedSection` to open its drawer. */ +const rail = (over: Partial = {}): LeftNavigationLayout => + wide({ mode: 'rail', ...over }); + +/** Drive a pointer path through the reducer, returning the occupied width after + * each step — the sequence, which is what per-frame assertions cannot see. */ +function sweep(from: LeftNavigationLayout, xs: readonly number[]): { + widths: number[]; modes: LeftNavigationMode[]; final: LeftNavigationLayout; +} { + let layout = from; + const widths: number[] = []; + const modes: LeftNavigationMode[] = []; + for (const x of xs) { + layout = resolveLeftNavigationDrag(layout, x); + widths.push(leftNavigationWidthPx(layout)); + modes.push(layout.mode); + } + return { widths, modes, final: layout }; +} + +type LeftNavigationMode = LeftNavigationLayout['mode']; + +const range = (lo: number, hi: number): number[] => + Array.from({ length: hi - lo + 1 }, (_, i) => lo + i); + +describe('constants (#487)', () => { + it('uses the values the issue specifies, with the fold/wide pair ordered as hysteresis', () => { + expect(LEFT_RAIL_PX).toBe(48); + expect(LEFT_FOLD_THRESHOLD_PX).toBe(140); + expect(LEFT_WIDE_THRESHOLD_PX).toBe(260); + expect(LEFT_PANEL_MIN_PX).toBe(180); + expect(LEFT_PANEL_MAX_PX).toBe(420); + // The gap between the two thresholds IS the hysteresis. If these ever met, + // one pointer pixel could oscillate the mode — the exact failure #487's + // "must not flicker near a single threshold" names. + expect(LEFT_FOLD_THRESHOLD_PX).toBeLessThan(LEFT_WIDE_THRESHOLD_PX); + // The rail must be narrower than the width that folds into it, and the wide + // range must sit above the fold threshold, or the dead zone inverts. + expect(LEFT_RAIL_PX).toBeLessThan(LEFT_FOLD_THRESHOLD_PX); + expect(LEFT_FOLD_THRESHOLD_PX).toBeLessThan(LEFT_PANEL_MIN_PX); + }); + it('keeps both documented defaults inside their own band', () => { + expect(LEFT_WIDE_DEFAULT_PX).toBeGreaterThanOrEqual(LEFT_PANEL_MIN_PX); + expect(LEFT_WIDE_DEFAULT_PX).toBeLessThanOrEqual(LEFT_PANEL_MAX_PX); + expect(LEFT_DRAWER_DEFAULT_PX).toBeGreaterThanOrEqual(LEFT_FOLD_THRESHOLD_PX); + expect(LEFT_DRAWER_DEFAULT_PX).toBeLessThanOrEqual(LEFT_WIDE_THRESHOLD_PX); + expect(LEFT_NAV_STEP_PX).toBeLessThan(LEFT_NAV_LARGE_STEP_PX); + }); + it('lists the four sections in rail order', () => { + expect(LEFT_NAV_SECTIONS).toEqual(['databases', 'dashboards', 'library', 'history']); + }); +}); + +describe('isLeftNavigationSection', () => { + it('accepts exactly the four known sections', () => { + for (const section of LEFT_NAV_SECTIONS) expect(isLeftNavigationSection(section)).toBe(true); + }); + it("rejects 'saved' — the value AppState.sidePanel actually stores for Library", () => { + // NOT a pre-#427 name: `asb:sidePanel` still persists 'saved', and + // `ui/saved-history.ts` still compares against it. #427 renamed the LABEL to + // "Library" and left the stored value alone. So this guard rejecting 'saved' + // is correct AND is exactly why phase 2's registry has to own a + // 'library' <-> 'saved' mapping. + expect(isLeftNavigationSection('saved')).toBe(false); + expect(isLeftNavigationSection('queries')).toBe(false); + }); + it('rejects a near miss and every non-string', () => { + expect(isLeftNavigationSection('Databases')).toBe(false); + expect(isLeftNavigationSection('')).toBe(false); + expect(isLeftNavigationSection(null)).toBe(false); + expect(isLeftNavigationSection(undefined)).toBe(false); + expect(isLeftNavigationSection(0)).toBe(false); + expect(isLeftNavigationSection(['databases'])).toBe(false); + }); +}); + +describe('decodeLeftNavigationMode', () => { + it('decodes a stored rail preference', () => { + expect(decodeLeftNavigationMode('rail')).toBe('rail'); + }); + it('falls back to wide for a missing, obsolete or malformed value', () => { + expect(decodeLeftNavigationMode('wide')).toBe('wide'); + expect(decodeLeftNavigationMode(undefined)).toBe('wide'); + expect(decodeLeftNavigationMode(null)).toBe('wide'); + expect(decodeLeftNavigationMode('')).toBe('wide'); + expect(decodeLeftNavigationMode('collapsed')).toBe('wide'); // an obsolete third mode + expect(decodeLeftNavigationMode('RAIL')).toBe('wide'); + expect(decodeLeftNavigationMode(1)).toBe('wide'); + }); +}); + +describe('clampWideWidthPx', () => { + it('passes an in-range width through and clamps both bounds', () => { + expect(clampWideWidthPx(300)).toBe(300); + expect(clampWideWidthPx(LEFT_PANEL_MIN_PX)).toBe(LEFT_PANEL_MIN_PX); + expect(clampWideWidthPx(LEFT_PANEL_MAX_PX)).toBe(LEFT_PANEL_MAX_PX); + expect(clampWideWidthPx(10)).toBe(LEFT_PANEL_MIN_PX); + expect(clampWideWidthPx(9999)).toBe(LEFT_PANEL_MAX_PX); + expect(clampWideWidthPx(-1)).toBe(LEFT_PANEL_MIN_PX); + }); + it('sends only NaN to the default, leaving the infinities on their bounds', () => { + // The regression the guard exists for: `clamp(NaN, 180, 420)` is NaN, so the + // bare clamp this replaced decoded a corrupt `asb:sidebarPx` to `width: NaNpx`. + expect(clampWideWidthPx(NaN)).toBe(LEFT_WIDE_DEFAULT_PX); + // ±Infinity has an unambiguous target, so it keeps the bare clamp's answer — + // guarding it too would make -1 → 180 but -Infinity → 248, a discontinuity + // for no reason, and would change this key's long-standing behaviour. + expect(clampWideWidthPx(Infinity)).toBe(LEFT_PANEL_MAX_PX); + expect(clampWideWidthPx(-Infinity)).toBe(LEFT_PANEL_MIN_PX); + }); +}); + +describe('clampDrawerWidthPx', () => { + it('clamps to the drawer band, not the wide sidebar range', () => { + expect(clampDrawerWidthPx(200)).toBe(200); + expect(clampDrawerWidthPx(LEFT_FOLD_THRESHOLD_PX)).toBe(LEFT_FOLD_THRESHOLD_PX); + expect(clampDrawerWidthPx(LEFT_WIDE_THRESHOLD_PX)).toBe(LEFT_WIDE_THRESHOLD_PX); + expect(clampDrawerWidthPx(0)).toBe(LEFT_FOLD_THRESHOLD_PX); + // Explicitly NOT the wide sidebar's bounds: a 400px drawer is impossible, + // because a drag that far right converts to the wide sidebar instead. + expect(clampDrawerWidthPx(400)).toBe(LEFT_WIDE_THRESHOLD_PX); + expect(clampDrawerWidthPx(LEFT_PANEL_MIN_PX)).toBe(LEFT_PANEL_MIN_PX); + }); + it('sends only NaN to the default', () => { + expect(clampDrawerWidthPx(NaN)).toBe(LEFT_DRAWER_DEFAULT_PX); + expect(clampDrawerWidthPx(Infinity)).toBe(LEFT_WIDE_THRESHOLD_PX); + expect(clampDrawerWidthPx(-Infinity)).toBe(LEFT_FOLD_THRESHOLD_PX); + }); +}); + +describe('leftNavigationWidthPx', () => { + it('is the sidebar width when wide', () => { + expect(leftNavigationWidthPx(wide({ wideWidthPx: 300 }))).toBe(300); + }); + it('is the bare rail width when rail with no drawer', () => { + expect(leftNavigationWidthPx(rail())).toBe(LEFT_RAIL_PX); + }); + it('is rail PLUS drawer when a drawer is open — the rail stays visible beside it', () => { + expect(leftNavigationWidthPx(rail({ focusedSection: 'databases', drawerWidthPx: 200 }))) + .toBe(LEFT_RAIL_PX + 200); + }); +}); + +// The regression suite for the bug this file previously certified as green: a +// monotone pointer path must produce a monotone width response and at most one +// mode change. Each assertion is over the whole sweep. +describe('resolveLeftNavigationDrag — a monotone drag never reverses (#487 regression)', () => { + it('never snaps backwards converting an open drawer to the wide sidebar', () => { + // The original defect, exactly: at clientX 308 the drawer was at its 260 + // maximum (total 308); crossing at 309 installed the REMEMBERED 200 for one + // frame before 310 jumped to 310. A 108px backwards snap mid-gesture. + const { widths } = sweep( + rail({ focusedSection: 'databases', drawerWidthPx: LEFT_WIDE_THRESHOLD_PX, wideWidthPx: 200 }), + range(300, 320)); + for (let i = 1; i < widths.length; i++) expect(widths[i]).toBeGreaterThanOrEqual(widths[i - 1]); + expect(widths.at(-1)).toBe(320); + }); + + it('is width-monotone and mode-monotone dragging right across every threshold', () => { + const { widths, modes } = sweep(rail({ focusedSection: 'library', wideWidthPx: 200 }), range(40, 460)); + for (let i = 1; i < widths.length; i++) expect(widths[i]).toBeGreaterThanOrEqual(widths[i - 1]); + // Exactly one rail → wide transition, and never back. + expect(modes.indexOf('wide')).toBeGreaterThan(0); + expect(modes.slice(modes.indexOf('wide')).every((m) => m === 'wide')).toBe(true); + }); + + it('is width-monotone and mode-monotone dragging left across every threshold', () => { + const { widths, modes } = sweep(wide({ wideWidthPx: LEFT_PANEL_MAX_PX }), range(40, 460).reverse()); + for (let i = 1; i < widths.length; i++) expect(widths[i]).toBeLessThanOrEqual(widths[i - 1]); + expect(modes.indexOf('rail')).toBeGreaterThan(0); + expect(modes.slice(modes.indexOf('rail')).every((m) => m === 'rail')).toBe(true); + }); + + it('folds a wide sidebar to the rail without an intermediate clipped width', () => { + // Swept from the 180 floor down, so every step is inside the dead zone or past + // the fold — above 180 a narrowing drag is an ordinary resize, not this claim. + const { widths } = sweep(wide({ wideWidthPx: 300 }), range(120, LEFT_PANEL_MIN_PX).reverse()); + // Only two widths ever appear: the 180 floor, and the rail. Nothing between — + // that is "do not leave a partially clipped wide sidebar". + expect(new Set(widths)).toEqual(new Set([LEFT_PANEL_MIN_PX, LEFT_RAIL_PX])); + }); + + it('keeps the drawer under the pointer through its whole band', () => { + let layout: LeftNavigationLayout = rail({ focusedSection: 'history' }); + for (const x of range(LEFT_RAIL_PX + LEFT_FOLD_THRESHOLD_PX, LEFT_RAIL_PX + LEFT_WIDE_THRESHOLD_PX)) { + layout = resolveLeftNavigationDrag(layout, x); + expect(leftNavigationWidthPx(layout)).toBe(x); + } + }); +}); + +describe('resolveLeftNavigationDrag — wide', () => { + it('resizes within the wide range', () => { + expect(resolveLeftNavigationDrag(wide(), 320)).toEqual(wide({ wideWidthPx: 320 })); + }); + it('clamps to the ceiling instead of growing past it', () => { + expect(resolveLeftNavigationDrag(wide(), 9999).wideWidthPx).toBe(LEFT_PANEL_MAX_PX); + }); + it('sits at the 180 floor through the whole dead zone rather than clipping', () => { + // #487: "do not leave a partially clipped wide sidebar". Between the fold + // threshold and the floor the sidebar holds at 180 and the mode does not + // change, so the user has to pull decisively past 140 to fold. + for (const x of [LEFT_FOLD_THRESHOLD_PX, 150, 179]) { + const next = resolveLeftNavigationDrag(wide({ wideWidthPx: 300 }), x); + expect(next.mode).toBe('wide'); + expect(next.wideWidthPx).toBe(LEFT_PANEL_MIN_PX); + } + }); + it('commits rail once past the fold threshold, exactly once', () => { + const next = resolveLeftNavigationDrag(wide({ wideWidthPx: 300 }), LEFT_FOLD_THRESHOLD_PX - 1); + expect(next.mode).toBe('rail'); + expect(next.focusedSection).toBeNull(); + // Continuing to drag left is idempotent — it does not fold "twice", and it + // does not keep rewriting the remembered width. + expect(resolveLeftNavigationDrag(next, 0)).toEqual(next); + }); + it('carries the remembered wide width through rail mode for End and for persistence', () => { + const folded = resolveLeftNavigationDrag(wide({ wideWidthPx: 300 }), 10); + expect(folded.wideWidthPx).toBe(300); + // A DRAG back out follows the pointer rather than restoring 300 (see the + // reducer's doc: a restored width would survive one frame); `End` is the + // path that restores it, asserted in the keyboard block below. + expect(resolveLeftNavigationDrag(folded, 400).wideWidthPx).toBe(400); + }); + it('keeps the drawer width untouched while folding, ready for the first rail click', () => { + expect(resolveLeftNavigationDrag(wide({ drawerWidthPx: 210 }), 10).drawerWidthPx).toBe(210); + }); +}); + +describe('resolveLeftNavigationDrag — rail', () => { + it('does nothing for a bare rail below the wide threshold — the rail width IS the mode', () => { + const layout = rail(); + // Same object back, so phase 3 can skip the repaint on identity. + expect(resolveLeftNavigationDrag(layout, LEFT_WIDE_THRESHOLD_PX)).toBe(layout); + expect(resolveLeftNavigationDrag(layout, 200)).toBe(layout); + expect(resolveLeftNavigationDrag(layout, 0)).toBe(layout); + }); + it('restores wide AT THE POINTER once past the wide threshold', () => { + const next = resolveLeftNavigationDrag(rail({ wideWidthPx: 330 }), LEFT_WIDE_THRESHOLD_PX + 1); + expect(next.mode).toBe('wide'); + // 261, not the remembered 330 — the panel edge stays under the finger. + expect(next.wideWidthPx).toBe(LEFT_WIDE_THRESHOLD_PX + 1); + expect(next.focusedSection).toBeNull(); + }); + it('does NOT restore wide at the threshold itself — hysteresis needs a decisive pull', () => { + const layout = rail(); + expect(resolveLeftNavigationDrag(layout, LEFT_WIDE_THRESHOLD_PX)).toBe(layout); + }); + it('resizes an open drawer inside its own band, measured beside the rail', () => { + const next = resolveLeftNavigationDrag(rail({ focusedSection: 'dashboards' }), LEFT_RAIL_PX + 200); + expect(next.mode).toBe('rail'); + expect(next.focusedSection).toBe('dashboards'); + expect(next.drawerWidthPx).toBe(200); + }); + it('holds an open drawer at exactly the fold threshold, and folds one pixel below it', () => { + // The closed lower edge of the drawer band: `clampDrawerWidthPx` claims 140 is + // reachable, so the reducer's comparison must agree. A `<=` here would make + // 140 unreachable while the clamp still advertised it. + const open = resolveLeftNavigationDrag( + rail({ focusedSection: 'history' }), LEFT_RAIL_PX + LEFT_FOLD_THRESHOLD_PX); + expect(open.focusedSection).toBe('history'); + expect(open.drawerWidthPx).toBe(LEFT_FOLD_THRESHOLD_PX); + const closed = resolveLeftNavigationDrag( + rail({ focusedSection: 'history' }), LEFT_RAIL_PX + LEFT_FOLD_THRESHOLD_PX - 1); + expect(closed.focusedSection).toBeNull(); + }); + it('holds an open drawer at exactly the wide threshold, and converts one pixel above it', () => { + const open = resolveLeftNavigationDrag( + rail({ focusedSection: 'history' }), LEFT_RAIL_PX + LEFT_WIDE_THRESHOLD_PX); + expect(open.mode).toBe('rail'); + expect(open.drawerWidthPx).toBe(LEFT_WIDE_THRESHOLD_PX); + const converted = resolveLeftNavigationDrag( + rail({ focusedSection: 'history' }), LEFT_RAIL_PX + LEFT_WIDE_THRESHOLD_PX + 1); + expect(converted.mode).toBe('wide'); + }); + it('folds an open drawer closed below the fold threshold, leaving the rail', () => { + const next = resolveLeftNavigationDrag( + rail({ focusedSection: 'history', drawerWidthPx: 220 }), LEFT_RAIL_PX + 100); + expect(next.mode).toBe('rail'); + expect(next.focusedSection).toBeNull(); + // Width kept for the next open — closing is not a reset. + expect(next.drawerWidthPx).toBe(220); + }); + it('cannot oscillate across the two thresholds', () => { + // A pointer parked in the sticky band, arriving from either side, keeps + // whatever mode it already had. + for (const x of [LEFT_FOLD_THRESHOLD_PX, 200, LEFT_WIDE_THRESHOLD_PX]) { + expect(resolveLeftNavigationDrag(rail(), x).mode).toBe('rail'); + expect(resolveLeftNavigationDrag(wide(), x).mode).toBe('wide'); + } + }); + it('heals a NaN proposal into a legal width rather than propagating it', () => { + expect(resolveLeftNavigationDrag(wide(), NaN).wideWidthPx).toBe(LEFT_WIDE_DEFAULT_PX); + expect(resolveLeftNavigationDrag(rail({ focusedSection: 'library' }), NaN).drawerWidthPx) + .toBe(LEFT_DRAWER_DEFAULT_PX); + }); +}); + +describe('resolveLeftNavigationKey', () => { + it('returns null for a key the separator does not own', () => { + // Phase 3 must not swallow Tab, Escape or anything else global. + for (const key of ['Tab', 'Escape', 'Enter', ' ', 'ArrowUp', 'ArrowDown', 'PageUp']) { + expect(resolveLeftNavigationKey(wide(), { key })).toBeNull(); + } + }); + it('returns null for a Ctrl/Meta/Alt chord on a key it otherwise owns', () => { + // Ctrl+Home must not fold the navigation, and Alt+ArrowLeft is the browser's + // Back on some platforms. Shift is the one modifier with a meaning here. + for (const key of ['Home', 'End', 'ArrowLeft', 'ArrowRight']) { + expect(resolveLeftNavigationKey(wide(), { key, ctrlKey: true })).toBeNull(); + expect(resolveLeftNavigationKey(wide(), { key, metaKey: true })).toBeNull(); + expect(resolveLeftNavigationKey(wide(), { key, altKey: true })).toBeNull(); + } + }); + it('Home folds to rail from wide and is idempotent', () => { + expect(resolveLeftNavigationKey(wide({ wideWidthPx: 300 }), { key: 'Home' })) + .toEqual(rail({ wideWidthPx: 300 })); + const bare = rail(); + expect(resolveLeftNavigationKey(bare, { key: 'Home' })).toBe(bare); + }); + it('Home also closes an open focused drawer', () => { + expect(resolveLeftNavigationKey(rail({ focusedSection: 'databases' }), { key: 'Home' })) + .toEqual(rail()); + }); + it('End is the one path that restores the REMEMBERED wide width', () => { + // The counterpart to the drag rule: a discrete restore has no pointer to + // honour, so the memory is what it uses. + expect(resolveLeftNavigationKey(rail({ wideWidthPx: 330, focusedSection: 'library' }), { key: 'End' })) + .toEqual(wide({ wideWidthPx: 330 })); + const already = wide(); + expect(resolveLeftNavigationKey(already, { key: 'End' })).toBe(already); + }); + it('End re-clamps an invalid remembered width', () => { + expect(resolveLeftNavigationKey(rail({ wideWidthPx: NaN }), { key: 'End' })!.wideWidthPx) + .toBe(LEFT_WIDE_DEFAULT_PX); + }); + it('arrows step the wide sidebar by the small and large steps', () => { + const at = (over: Partial, key: string, shiftKey = false) => + resolveLeftNavigationKey(wide(over), { key, shiftKey })!.wideWidthPx; + expect(at({ wideWidthPx: 300 }, 'ArrowRight')).toBe(300 + LEFT_NAV_STEP_PX); + expect(at({ wideWidthPx: 300 }, 'ArrowLeft')).toBe(300 - LEFT_NAV_STEP_PX); + expect(at({ wideWidthPx: 300 }, 'ArrowRight', true)).toBe(300 + LEFT_NAV_LARGE_STEP_PX); + expect(at({ wideWidthPx: 300 }, 'ArrowLeft', true)).toBe(300 - LEFT_NAV_LARGE_STEP_PX); + }); + it('steps the DRAWER width when a drawer is open, not the sidebar width', () => { + const next = resolveLeftNavigationKey( + rail({ focusedSection: 'history', drawerWidthPx: 200 }), { key: 'ArrowRight' })!; + expect(next.drawerWidthPx).toBe(200 + LEFT_NAV_STEP_PX); + expect(next.wideWidthPx).toBe(LEFT_WIDE_DEFAULT_PX); + }); + it('can fold an open drawer closed with an arrow at its floor', () => { + const next = resolveLeftNavigationKey( + rail({ focusedSection: 'history', drawerWidthPx: LEFT_FOLD_THRESHOLD_PX }), { key: 'ArrowLeft' })!; + expect(next.focusedSection).toBeNull(); + }); + it('leaves a bare rail on ONE rightward step, whatever the remembered width', () => { + // The bare-rail base must be the wide THRESHOLD, not `wideWidthPx`. With the + // remembered width as the base, any value at or below 244 would leave a small + // ArrowRight stuck in the sticky band forever — a separator advertising + // `aria-valuemax: 420` while refusing to move. 180 is trivially reachable by + // dragging the sidebar narrow before folding, so this is not a corner case. + for (const wideWidthPx of [LEFT_PANEL_MIN_PX, 200, 244, LEFT_WIDE_DEFAULT_PX, LEFT_PANEL_MAX_PX]) { + for (const shiftKey of [false, true]) { + expect(resolveLeftNavigationKey(rail({ wideWidthPx }), { key: 'ArrowRight', shiftKey })!.mode) + .toBe('wide'); + } + } + }); + it('holds a bare rail on a leftward step — it is already as folded as it goes', () => { + for (const shiftKey of [false, true]) { + const stay = rail(); + expect(resolveLeftNavigationKey(stay, { key: 'ArrowLeft', shiftKey })).toBe(stay); + } + }); + it('matches pointer transitions for the same proposed total width', () => { + // The property the design exists for. The pointer side is driven from the + // module's PUBLIC occupied width, never from a copy of the private base + // formula — recomputing the implementation here is what let a bare-rail base + // bug survive a green suite, so the bare rail (whose base is deliberately the + // threshold, not its own width) is asserted separately above. + const cases: LeftNavigationLayout[] = [ + wide({ wideWidthPx: 300 }), + wide({ wideWidthPx: LEFT_PANEL_MIN_PX }), + wide({ wideWidthPx: LEFT_PANEL_MAX_PX }), + rail({ focusedSection: 'databases', drawerWidthPx: LEFT_FOLD_THRESHOLD_PX }), + rail({ focusedSection: 'databases', drawerWidthPx: 200 }), + rail({ focusedSection: 'databases', drawerWidthPx: LEFT_WIDE_THRESHOLD_PX }), + ]; + for (const layout of cases) { + for (const shiftKey of [false, true]) { + const step = shiftKey ? LEFT_NAV_LARGE_STEP_PX : LEFT_NAV_STEP_PX; + const base = leftNavigationWidthPx(layout); + expect(resolveLeftNavigationKey(layout, { key: 'ArrowRight', shiftKey })) + .toEqual(resolveLeftNavigationDrag(layout, base + step)); + expect(resolveLeftNavigationKey(layout, { key: 'ArrowLeft', shiftKey })) + .toEqual(resolveLeftNavigationDrag(layout, base - step)); + } + } + }); + it('is a no-op at the wide floor and ceiling for a small step, and folds on a large one', () => { + // Unlike the bare rail, this is NOT a dead end: the pointer does the same + // thing at these extremes (clientX 164 also leaves a 180 sidebar at 180), and + // Shift+ArrowLeft escapes. Keyboard and pointer agree, which is the contract. + expect(resolveLeftNavigationKey(wide({ wideWidthPx: LEFT_PANEL_MIN_PX }), { key: 'ArrowLeft' })) + .toEqual(wide({ wideWidthPx: LEFT_PANEL_MIN_PX })); + expect(resolveLeftNavigationKey(wide({ wideWidthPx: LEFT_PANEL_MAX_PX }), { key: 'ArrowRight' })) + .toEqual(wide({ wideWidthPx: LEFT_PANEL_MAX_PX })); + expect(resolveLeftNavigationKey( + wide({ wideWidthPx: LEFT_PANEL_MIN_PX }), { key: 'ArrowLeft', shiftKey: true })!.mode).toBe('rail'); + }); +}); + +describe('resolveRailActivation', () => { + it('opens a section from a bare rail', () => { + expect(resolveRailActivation(rail(), 'dashboards')).toEqual(rail({ focusedSection: 'dashboards' })); + }); + it('closes the drawer when the ACTIVE section is activated again', () => { + expect(resolveRailActivation(rail({ focusedSection: 'dashboards' }), 'dashboards')).toEqual(rail()); + }); + it('switches content in place without closing first', () => { + const next = resolveRailActivation(rail({ focusedSection: 'dashboards' }), 'history'); + expect(next.focusedSection).toBe('history'); + expect(next.mode).toBe('rail'); + }); + it('preserves the remembered widths across every activation', () => { + const layout = rail({ wideWidthPx: 330, drawerWidthPx: 210 }); + for (const section of LEFT_NAV_SECTIONS) { + const next = resolveRailActivation(layout, section); + expect(next.wideWidthPx).toBe(330); + expect(next.drawerWidthPx).toBe(210); + } + }); + it('cannot open a drawer in wide mode — phase 3 must route to the pane switchers', () => { + const layout = wide(); + for (const section of LEFT_NAV_SECTIONS) { + expect(resolveRailActivation(layout, section)).toBe(layout); + } + }); +}); + +// The invariant every reducer shares, asserted over their combined reachable +// space rather than re-derived per test: a focused drawer exists only in rail +// mode. `leftNavigationWidthPx` reads `drawerWidthPx` only in rail mode, so a +// 'wide' layout carrying a section would push the centre surface by a width that +// omits the open drawer. +describe('mode/focusedSection coherence', () => { + it('holds across every reachable drag, key and activation from every seed', () => { + const seeds: LeftNavigationLayout[] = [ + wide(), wide({ wideWidthPx: LEFT_PANEL_MAX_PX }), rail(), + ...LEFT_NAV_SECTIONS.map((s) => rail({ focusedSection: s })), + ]; + const keys = ['Home', 'End', 'ArrowLeft', 'ArrowRight']; + for (const seed of seeds) { + expect(leftNavigationLayoutIsCoherent(seed)).toBe(true); + for (const x of [0, 100, 139, 140, 180, 200, 260, 261, 308, 309, 420, 999, NaN]) { + expect(leftNavigationLayoutIsCoherent(resolveLeftNavigationDrag(seed, x))).toBe(true); + } + for (const key of keys) { + for (const shiftKey of [false, true]) { + const next = resolveLeftNavigationKey(seed, { key, shiftKey }); + if (next) expect(leftNavigationLayoutIsCoherent(next)).toBe(true); + } + } + for (const section of LEFT_NAV_SECTIONS) { + expect(leftNavigationLayoutIsCoherent(resolveRailActivation(seed, section))).toBe(true); + } + } + }); + it('rejects the incoherent shape it exists to forbid', () => { + // Guards the guard: if this predicate were vacuously true, the sweep above + // would prove nothing. + expect(leftNavigationLayoutIsCoherent(wide({ focusedSection: 'databases' }))).toBe(false); + }); +}); + +describe('effectiveLeftNavigationLayout', () => { + it('returns the desktop layout untouched', () => { + for (const layout of [wide(), rail(), rail({ focusedSection: 'library' })]) { + expect(effectiveLeftNavigationLayout(layout, false)).toBe(layout); + } + }); + it('ignores rail mode and any open drawer below the mobile breakpoint', () => { + // #487: "do not render the desktop rail or desktop focused drawer" on mobile. + const effective = effectiveLeftNavigationLayout( + rail({ focusedSection: 'dashboards', wideWidthPx: 330, drawerWidthPx: 210 }), true); + expect(effective.mode).toBe('wide'); + expect(effective.focusedSection).toBeNull(); + }); + it('preserves the desktop preferences it is ignoring, for the next desktop session', () => { + const stored = rail({ focusedSection: 'dashboards', wideWidthPx: 330, drawerWidthPx: 210 }); + const effective = effectiveLeftNavigationLayout(stored, true); + // The projection carries both widths through … + expect(effective.wideWidthPx).toBe(330); + expect(effective.drawerWidthPx).toBe(210); + // … and never writes back: the stored layout still says rail. + expect(stored.mode).toBe('rail'); + expect(stored.focusedSection).toBe('dashboards'); + }); + it('returns an already-wide layout by identity even on mobile', () => { + const layout = wide(); + expect(effectiveLeftNavigationLayout(layout, true)).toBe(layout); + }); +}); + +describe('leftNavigationSeparatorAria', () => { + it('reports the rail floor, the wide ceiling and the live occupied width', () => { + expect(leftNavigationSeparatorAria(wide({ wideWidthPx: 300 }))) + .toEqual({ valueMin: LEFT_RAIL_PX, valueMax: LEFT_PANEL_MAX_PX, valueNow: 300 }); + expect(leftNavigationSeparatorAria(rail()).valueNow).toBe(LEFT_RAIL_PX); + expect(leftNavigationSeparatorAria(rail({ focusedSection: 'library', drawerWidthPx: 200 })).valueNow) + .toBe(LEFT_RAIL_PX + 200); + }); + it('keeps valueNow inside the advertised range in every mode', () => { + for (const layout of [ + wide({ wideWidthPx: LEFT_PANEL_MIN_PX }), wide({ wideWidthPx: LEFT_PANEL_MAX_PX }), rail(), + rail({ focusedSection: 'library', drawerWidthPx: LEFT_WIDE_THRESHOLD_PX }), + ]) { + const { valueMin, valueMax, valueNow } = leftNavigationSeparatorAria(layout); + expect(valueNow).toBeGreaterThanOrEqual(valueMin); + expect(valueNow).toBeLessThanOrEqual(valueMax); + } + }); +}); diff --git a/tests/unit/state.test.ts b/tests/unit/state.test.ts index cc48eee2..ad28cbd6 100644 --- a/tests/unit/state.test.ts +++ b/tests/unit/state.test.ts @@ -146,6 +146,8 @@ describe('KEYS — persisted localStorage key names (#459)', () => { sideSplitPct: 'asb:sideSplitPct', cellDrawerPx: 'asb:cellDrawerPx', docPanePx: 'asb:docPanePx', + leftNavMode: 'asb:leftNavMode', + leftNavDrawerPx: 'asb:leftNavDrawerPx', sidePanel: 'asb:sidePanel', saved: 'asb:saved', history: 'asb:history', @@ -191,6 +193,12 @@ describe('createState', () => { expect(s.sideSplitPct).toBe(58); expect(s.cellDrawerPx).toBe(560); expect(s.docPanePx).toBe(420); // #313 — a sibling default, independent of cellDrawerPx + // #487 — a fresh desktop session starts from the documented default: the + // established two-pane sidebar, a drawer width ready for its first open, and + // NO focused section (the drawer is session state and does not reopen). + expect(s.leftNavMode.value).toBe('wide'); + expect(s.leftNavDrawerPx).toBe(240); + expect(s.leftNavSection.value).toBeNull(); expect(s.tabs.value).toHaveLength(1); expect(s.savedQueries).toEqual([]); expect(s.savedQueryLoadDiagnostics).toEqual([]); @@ -223,6 +231,8 @@ describe('createState', () => { [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.leftNavMode]: 'rail', // #487 — a valid persisted mode restores + [KEYS.leftNavDrawerPx]: '200', [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 }], @@ -239,6 +249,11 @@ describe('createState', () => { expect(s.sideSplitPct).toBe(85); expect(s.cellDrawerPx).toBe(320); expect(s.docPanePx).toBe(320); // #313 + expect(s.leftNavMode.value).toBe('rail'); // #487 + expect(s.leftNavDrawerPx).toBe(200); + // …but the focused drawer still does not reopen: #487 makes `focusedSection` + // session UI state, so restoring rail mode restores a BARE rail. + expect(s.leftNavSection.value).toBeNull(); expect(s.sidePanel.value).toBe('history'); expect(s.savedQueries).toHaveLength(1); expect(s.history).toHaveLength(1); @@ -255,6 +270,89 @@ describe('createState', () => { }); }); +// #487 phase 1 — the desktop left navigation's persisted preferences. The +// transitions themselves live in `core/left-nav-layout.ts` (and its own spec); +// what is asserted here is the STORAGE contract: which keys exist, what a fresh +// session gets, that a hostile stored value can never reach the DOM, and that +// none of it leaks into the workspace document. +describe('createState — left navigation preferences (#487)', () => { + it('clamps every invalid or obsolete stored value back to its documented default', () => { + const s = createState(reader({ + // An obsolete third mode from a future/older build is not a third mode. + [KEYS.leftNavMode]: 'collapsed', + [KEYS.leftNavDrawerPx]: 'not-a-number', + [KEYS.sidebarPx]: 'not-a-number', + })); + expect(s.leftNavMode.value).toBe('wide'); + expect(s.leftNavDrawerPx).toBe(240); + // The regression this case exists for: `clamp(parseInt('not-a-number'), 180, + // 420)` is NaN (`Math.max(180, NaN)` is NaN), and a NaN width reaches the DOM + // as `width: NaNpx`, which the browser drops — silently collapsing the + // sidebar with nothing in the UI to explain it. + expect(s.sidebarPx).toBe(248); + }); + + it('clamps an out-of-range drawer width into the drawer own band', () => { + // Not the wide sidebar's [180, 420]: a drawer wider than the wide threshold + // is unreachable, because a drag that far right converts to the sidebar. + expect(createState(reader({ [KEYS.leftNavDrawerPx]: '9999' })).leftNavDrawerPx).toBe(260); + expect(createState(reader({ [KEYS.leftNavDrawerPx]: '0' })).leftNavDrawerPx).toBe(140); + }); + + it('keeps the wide width on the ONE preference key, with no second owner', () => { + // #487 suggests a separate `wideWidthPx`; `asb:sidebarPx` already persists + // exactly that width over exactly that range, and two owners of one width is + // a bug waiting to happen. This pins the decision: the left-nav keys are the + // mode and the drawer, and nothing else. Order-independent — reordering two + // adjacent declarations in `KEYS` is not a semantic change. + const leftNavKeys = Object.keys(KEYS).filter((k) => k.startsWith('leftNav')); + expect(new Set(leftNavKeys)).toEqual(new Set(['leftNavMode', 'leftNavDrawerPx'])); + expect(leftNavKeys).toHaveLength(2); + const s = createState(reader({ [KEYS.sidebarPx]: '330' })); + expect(s.sidebarPx).toBe(330); + }); + + it('never writes left-navigation state into the workspace document', async () => { + // #487: "none of this state belongs in StoredWorkspaceV3, Dashboard documents + // or query specs" (V5 today). + // + // This drives the REAL commit path — `createSavedQuery` builds its candidate + // through `state.ts`'s own `baselineWorkspace`/`candidateFrom` projection — and + // inspects the candidate that projection actually produced. Asserting over a + // hand-built workspace literal instead would be unfalsifiable: it would only + // prove the test didn't add the fields itself. + // + // Sabotage-checked, and the exercise located where the guarantee really lives: + // adding a field to `baselineWorkspace`'s fallback alone does NOT reach a + // commit, because `candidateFrom` re-enumerates the six aggregate fields + // explicitly and drops anything else. That enumeration is the structural + // guarantee, and adding `leftNavMode` to it fails this test. + const s = savedTestState({ [KEYS.leftNavMode]: 'rail', [KEYS.leftNavDrawerPx]: '200' }); + s.leftNavSection.value = 'dashboards'; + s.tabs.value[0].sqlDraft = 'SELECT 1'; + const mutate = fakeMutateWorkspace(s); + expect(okEntry(await createSavedQuery(s, s.tabs.value[0], 'Q', '', mutate))).toBeTruthy(); + + const candidate = mutate.commit.mock.calls[0]![0] as StoredWorkspaceV5; + // The aggregate's own key set, exactly — no left-navigation field smuggled in. + expect(Object.keys(candidate).sort()) + .toEqual(['dashboards', 'id', 'key', 'name', 'queries', 'storageVersion']); + for (const marker of ['leftNav', 'focusedSection', 'drawerWidth', 'sidebarPx']) { + expect(JSON.stringify(candidate)).not.toContain(marker); + } + // Second layer: the repository validated this candidate against the closed + // stored-workspace schema (`additionalProperties: false`), so an extra field + // would have been REJECTED rather than persisted. The commit succeeded, which + // is that check passing on the real record. + expect(mutate.commit).toHaveBeenCalledTimes(1); + // And the preferences are localStorage keys, never workspace fields. + for (const key of [KEYS.leftNavMode, KEYS.leftNavDrawerPx]) { + expect(key.startsWith('asb:')).toBe(true); + expect(Object.keys(candidate)).not.toContain(key); + } + }); +}); + describe('effectiveFilterActive (#165)', () => { it('an explicit filterActive entry wins over the stored value', () => { expect(effectiveFilterActive({ d: 'stale' }, { d: false })).toEqual({ d: false }); From acc7317ba269b7194b059eed28c3f352391ad8c8 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Wed, 29 Jul 2026 22:47:09 +0200 Subject: [PATCH 02/78] fix(#487): address ChatGPT review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A third-party review pass raised six findings against the phase-1 module. All six reproduced against the real code; all six are fixed here. The most valuable one is the exact mirror of the bug the earlier review caught, which this suite had been blessing with a test. 1. **Plain ArrowLeft could never fold a wide sidebar.** At the 180px floor a -16 step proposes 164, which clamps straight back to 180 — so eleven presses from a 300px sidebar sat at 180 forever while the equivalent pointer path folded: keyboard 284,268,252,236,220,204,188,180,180,180,180 -> wide pointer 284,268,252,236,220,204,188,180,180,180,48 -> rail This is the same class of dead end as the bare-rail one already fixed, at the opposite end, and the previous commit's test explicitly blessed it on the grounds that Home and Shift+Arrow escape. That is not a defence: the W3C splitter pattern makes plain Left/Right the separator's move keys, and `aria-valuemin: 48` was advertised while the control refused to move. Arrows now resize within a band and perform the band edge's semantic transition, symmetric at both ends. `keyboardBaseTotalPx`'s virtual 260 base is gone with it — it was a false relative step (ArrowRight from a 48px rail moved +228, not +16) and it discarded a remembered 420, handing back 276. A bare rail's ArrowRight now restores the remembered width, like End. 2. **`resolveRailActivation` is a toggle, so it cannot be the `openFocusedSection` seam** #487 mandates for #428. Bounded drag-hover re-asserts intent while a query is held over the Dashboards icon, so a toggle flaps the drawer open and shut on alternate notifications. Added `resolveRailOpen`, idempotent and identity-returning when already open; the toggle stays for clicks. 3. **The coherence invariant was a precondition, not a postcondition.** `state.ts` stores `mode` and `focusedSection` as two independently writable signals, and the reducers preserved an illegal pair rather than healing it — `drag({mode: 'wide', focusedSection: 'databases'}, 300)` returned it intact, and `End` handed it straight back. Every reducer now normalizes its input through `normalizeLeftNavigationLayout`, which also lifts non-finite and out-of-band widths, so `leftNavigationLayoutIsCoherent` is "normalizing changes nothing" and covers widths rather than only the mode/section pairing. A NaN width can no longer reach `aria-valuenow`. 4. **`parseInt` accepted a numeric prefix**, so `'12junk'` decoded to 12 and `'200px'` to 200 while the documented contract promised the default. Added `decodeStoredPx`, which requires the whole string to be a finite number — and therefore also rejects a stored `'Infinity'`, which is corruption rather than a width pressed against a bound. 5. **The restore memory is sampling-dependent** and this commit does NOT fix it: from a 300px sidebar, a single coarse sample past the fold remembers 300, but an intermediate sample inside the 140-179 dead zone rests the width at the floor first and remembers 180. One field is serving as both the live drag width and the restore memory; separating them needs a drag-session snapshot, which a pure reducer cannot take. Pinned with a test so phase 3 has to change it deliberately, and recorded as a phase-3 obligation in the ship log. 6. **The centre clamp's phase boundary was unsafe.** Phase 3 turns the feature on while the clamp sat in phase 4, leaving a shippable interval where both docked panels could starve the centre surface. Moved to phase 3, before activation. Two findings were verified and NOT changed, with reasons recorded in the ship log: the drawer's [140, 260] band (a 150px drawer may be unusably narrow, but #487 says these constants are settled only by real-browser verification), and the separator's discontinuous ARIA interior (inherent to one control spanning two modes; phase 3 adds mode-aware `aria-valuetext`). npm test 6755 passing, `left-nav-layout.ts` at 100/100/100/100 (17 functions, 64 lines, 86 branches). Sabotage-checked: dropping the wide-floor transition, making the open seam a toggle, removing reducer normalization, and reverting to parseInt each fail specific tests. Sidebar drag e2e green. Part of #487 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FPnt3pq98P5Y1ww3sWawp1 --- CHANGELOG.md | 24 ++- src/core/left-nav-layout.ts | 207 ++++++++++++++++++-------- src/state.ts | 17 ++- tests/unit/left-nav-layout.test.ts | 228 +++++++++++++++++++++++++---- tests/unit/state.test.ts | 17 +++ 5 files changed, 395 insertions(+), 98 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b60c5788..4e0e25e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,8 +27,17 @@ auto-generated per-PR notes; this file is the curated, human-readable history. navigation's proposed *total* width, which is what keeps a drag continuous across a mode change: handing the reducer a mode-relative width instead made a monotone rightward drag snap the navigation 108px backwards on the frame a - drawer converted to the sidebar. **No user-visible change yet**: the rail, the - docked focused drawer and the resize separator arrive in phase 3. + drawer converted to the sidebar. Arrow keys resize within a band and perform + the band edge's semantic transition, because an arrow's *relative* step cannot + cross a dead zone wider than itself — leaving that relative stranded a bare + rail's `ArrowRight` and a floored sidebar's `ArrowLeft` permanently, while + `aria-valuemin: 48` was advertised throughout. Opening a section is a separate + operation from the click toggle, since #428's bounded drag-hover re-asserts + intent repeatedly and a toggle would flap the drawer shut. Every reducer + normalizes its input, so the "a focused drawer exists only in rail mode" + invariant is an unconditional postcondition rather than a precondition the + caller has to honour. **No user-visible change yet**: the rail, the docked + focused drawer and the resize separator arrive in phase 3. - The sidebar's `'col'` drag axis now clamps through the same `LEFT_PANEL_MIN_PX`/`LEFT_PANEL_MAX_PX` constants as the load path, instead of repeating `180`/`420` as literals (#487). Behaviour is unchanged; it removes @@ -55,12 +64,15 @@ auto-generated per-PR notes; this file is the curated, human-readable history. and `clamp` is not NaN-safe (`Math.max(180, NaN)` is `NaN`), so a non-numeric stored value would reach the DOM as `width: NaNpx` — which the browser drops, collapsing the sidebar with nothing to explain why, and the bad value would - persist across reloads. It now falls back to the documented 248px default. + persist across reloads. It now falls back to the documented 248px default, and + decoding requires the *whole* stored string to be a finite number — `parseInt` + accepted a numeric prefix, so a truncated write or a hand-edited `"200px"` + decoded to a plausible-looking width while the contract promised the default. Hardening rather than a reproducible user-visible bug: no code path in the app - writes a non-numeric value (a real drag always carries a finite `clientX`), so + writes a malformed value (a real drag always carries a finite `clientX`), so reaching it takes a hand-edited or foreign-origin `localStorage` entry. The - same hole in `editorPct`/`sideSplitPct`/`cellDrawerPx`/`docPanePx` is tracked - separately in #570. + same NaN hole in `editorPct`/`sideSplitPct`/`cellDrawerPx`/`docPanePx` is + tracked separately in #570. - **The Dashboard tree no longer reveals two rows' pencil/trash actions at once, and its `· N` count now sits inline after the label** (#568). The hover/focus reveal rule (`.dash-tree-row:focus-within .dash-tree-act`) diff --git a/src/core/left-nav-layout.ts b/src/core/left-nav-layout.ts index e4cea2b0..edf84c1b 100644 --- a/src/core/left-nav-layout.ts +++ b/src/core/left-nav-layout.ts @@ -133,11 +133,42 @@ export function isLeftNavigationSection(value: unknown): value is LeftNavigation return LEFT_NAV_SECTIONS.some((section) => section === value); } -/** The `mode`/`focusedSection` invariant, as a predicate — a focused drawer - * exists only in rail mode. Exported so the reducers' shared postcondition can - * be asserted directly instead of re-derived in each test. */ +/** + * Heal a layout into a renderable one: a focused drawer only in rail mode, both + * widths finite and inside their own band, and a section only if it is one of the + * four. Returns the argument itself when it is already legal, so the reducers' + * identity-skip contract survives. + * + * Every reducer normalizes its input through this, which is what makes their + * postcondition unconditional. Without it the invariant was only ever a + * *precondition* — `resolveLeftNavigationDrag({ mode: 'wide', focusedSection: + * 'databases' }, 300)` preserved the illegal pair rather than fixing it, and + * `End` handed it straight back. That matters because `state.ts` stores `mode` and + * `focusedSection` in two independently writable signals: any caller that writes + * one without the other produces exactly that pair, and `leftNavigationWidthPx` + * would then push the centre surface by a width that omits the open drawer. + * + * Healing here does not make the atomic-write discipline optional — phase 3 should + * still write both signals together — but it means a slip is corrected on the next + * interaction instead of persisting as an unrenderable shell. + */ +export function normalizeLeftNavigationLayout(layout: LeftNavigationLayout): LeftNavigationLayout { + const mode = layout.mode === 'rail' ? 'rail' : 'wide'; + const wideWidthPx = clampWideWidthPx(layout.wideWidthPx); + const drawerWidthPx = clampDrawerWidthPx(layout.drawerWidthPx); + const focusedSection = mode === 'rail' && isLeftNavigationSection(layout.focusedSection) + ? layout.focusedSection + : null; + const unchanged = mode === layout.mode && wideWidthPx === layout.wideWidthPx + && drawerWidthPx === layout.drawerWidthPx && focusedSection === layout.focusedSection; + return unchanged ? layout : { mode, wideWidthPx, drawerWidthPx, focusedSection }; +} + +/** A layout is coherent exactly when normalizing it changes nothing — so this + * covers the `mode`/`focusedSection` pairing AND finite, in-band widths, rather + * than only the pairing (a `NaN` width used to pass). */ export function leftNavigationLayoutIsCoherent(layout: LeftNavigationLayout): boolean { - return layout.mode === 'rail' || layout.focusedSection === null; + return normalizeLeftNavigationLayout(layout) === layout; } /** Decode a persisted mode. Anything that is not exactly `'rail'` — a missing @@ -183,6 +214,28 @@ export function clampDrawerWidthPx(px: number): number { return clamp(px, LEFT_FOLD_THRESHOLD_PX, LEFT_WIDE_THRESHOLD_PX); } +/** + * Decode a persisted pixel width, falling back to `fallbackPx` for anything that + * is not a complete number. + * + * `parseInt` is deliberately not used: it accepts a numeric *prefix*, so + * `'12junk'` decodes to 12 and `'200px'` to 200 — which made the "an invalid + * stored value returns to its documented default" contract false for exactly the + * corruption most likely to occur (a truncated write, or a value someone hand- + * edited with a CSS unit). `Number` requires the whole string, and the + * `Number.isFinite` guard also rejects the literal `'Infinity'` that `Number` + * would otherwise accept — a stored infinity is a corrupt value, not a width + * pressed against a bound. + * + * The clamp still runs afterwards, so a well-formed but out-of-range value is + * pulled into its band rather than discarded. + */ +export function decodeStoredPx(raw: unknown, fallbackPx: number): number { + if (typeof raw !== 'string' || raw.trim() === '') return fallbackPx; + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : fallbackPx; +} + /** * The width the navigation actually occupies in the shell row — what the centre * surface is pushed by, and what the separator reports as `aria-valuenow`. @@ -227,8 +280,9 @@ export function leftNavigationWidthPx(layout: LeftNavigationLayout): number { * moving) would show a sidebar that refuses to shrink to its own floor. */ export function resolveLeftNavigationDrag( - layout: LeftNavigationLayout, totalPx: number, + input: LeftNavigationLayout, totalPx: number, ): LeftNavigationLayout { + const layout = normalizeLeftNavigationLayout(input); if (layout.mode === 'wide') { // A wide sidebar IS the whole navigation, so its panel width is the total. // Past the fold threshold: commit rail. The wide width is frozen at whatever @@ -276,30 +330,20 @@ export interface LeftNavigationKey { altKey?: boolean; } -/** - * The TOTAL width an arrow step starts from — the same currency the reducer - * takes, so for a wide sidebar and for an open drawer this is simply the width - * the navigation already occupies. - * - * A bare rail is the exception: its base is `LEFT_WIDE_THRESHOLD_PX`, not its own - * 48px. That is not a fudge — it is the only base that keeps the keyboard honest. - * A pointer can leave a bare rail because `clientX` is absolute, so dragging to - * x=300 proposes 300; an arrow key only has a *relative* step, so a base of 48 - * would propose 64, land in the sticky band, and change nothing — forever — while - * the separator still advertised `aria-valuemax: 420`, a control that lies about - * being resizable. Basing it at the threshold makes both directions come out - * right through the ordinary reducer, with no special-casing there: a rightward - * step crosses into wide (nothing legal exists between the rail and the 180 - * floor), and a leftward step lands in the sticky band and correctly does - * nothing, because the rail is already as folded as it goes. - * - * Deliberately a CONSTANT and not `wideWidthPx`: any remembered width at or below - * 244 would otherwise leave a bare rail's small ArrowRight stuck in the sticky - * band again, reintroducing exactly the dead end this exists to prevent. - */ -function keyboardBaseTotalPx(layout: LeftNavigationLayout): number { - if (layout.mode === 'rail' && layout.focusedSection === null) return LEFT_WIDE_THRESHOLD_PX; - return leftNavigationWidthPx(layout); +/** Fold to a bare rail — `Home`, and the leftward arrow's boundary transition. */ +function foldToRail(layout: LeftNavigationLayout): LeftNavigationLayout { + return layout.mode === 'rail' && layout.focusedSection === null + ? layout + : { ...layout, mode: 'rail', focusedSection: null }; +} + +/** Restore the wide sidebar at its REMEMBERED width — `End`, and the rightward + * arrow's boundary transition out of a bare rail. This is the one place a + * remembered width is restored; a pointer drag follows the pointer instead. */ +function restoreWide(layout: LeftNavigationLayout): LeftNavigationLayout { + return layout.mode === 'wide' + ? layout + : { ...layout, mode: 'wide', wideWidthPx: clampWideWidthPx(layout.wideWidthPx), focusedSection: null }; } /** @@ -307,40 +351,67 @@ function keyboardBaseTotalPx(layout: LeftNavigationLayout): number { * ours — phase 3 must not swallow keys it does not handle, and must not treat a * Ctrl/Meta/Alt chord as a resize. * - * Every arrow step routes through `resolveLeftNavigationDrag`, which is what - * makes #487's "keyboard separator operations match pointer transitions" true by - * construction rather than by two implementations agreeing. + * **Arrows resize within a band and perform the semantic transition at its edge.** + * That edge case is not decoration: an arrow key carries a *relative* step, and + * both bands are bounded by a dead zone wider than one step, so a purely relative + * arrow gets stranded at a boundary forever. + * + * Both ends had that failure, and they are exact mirrors: * - * `End` is the one place a remembered width is restored — it is the discrete - * counterpart to a drag, with no pointer position to honour instead. + * - a bare rail is 48px wide and the nearest legal wide width is 180, so a +16 + * step proposes 64, lands in the sticky band and does nothing; + * - a wide sidebar at its 180 floor folds only below 140, so a −16 step proposes + * 164, clamps straight back to 180 and does nothing. + * + * A *pointer* escapes both because `clientX` is absolute — it keeps travelling + * until it crosses the threshold — so leaving them relative made the keyboard and + * pointer disagree over a *sequence* even while agreeing on every single step. + * Eleven ArrowLeft presses from a 300px sidebar used to sit at 180 forever while + * the equivalent pointer path folded, with `aria-valuemin: 48` advertised + * throughout. `Home`/`Shift+Arrow` escaping is not a defence: the W3C splitter + * pattern makes plain Left/Right the separator's move keys. + * + * So the boundary step performs the transition the band edge implies, and every + * step inside a band still routes through `resolveLeftNavigationDrag` — the + * resize arithmetic has exactly one implementation. */ export function resolveLeftNavigationKey( - layout: LeftNavigationLayout, event: LeftNavigationKey, + input: LeftNavigationLayout, event: LeftNavigationKey, ): LeftNavigationLayout | null { if (event.ctrlKey || event.metaKey || event.altKey) return null; - // Home folds to rail and End restores wide, both regardless of the current - // mode — pressed twice they are idempotent, not a toggle. - if (event.key === 'Home') { - return layout.mode === 'rail' && layout.focusedSection === null - ? layout - : { ...layout, mode: 'rail', focusedSection: null }; + const layout = normalizeLeftNavigationLayout(input); + // Home folds and End restores, regardless of mode — pressed twice they are + // idempotent, not a toggle. + if (event.key === 'Home') return foldToRail(layout); + if (event.key === 'End') return restoreWide(layout); + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return null; + const towardWide = event.key === 'ArrowRight'; + // A bare rail has no panel to resize: rightward is the restore transition (at + // the remembered width, like End — a fixed threshold-plus-step would silently + // discard a remembered 420 and hand back 276), and leftward is a no-op because + // the rail is already as folded as it goes. + if (layout.mode === 'rail' && layout.focusedSection === null) { + return towardWide ? restoreWide(layout) : layout; } - if (event.key === 'End') { - return layout.mode === 'wide' - ? layout - : { ...layout, mode: 'wide', wideWidthPx: clampWideWidthPx(layout.wideWidthPx), focusedSection: null }; + // A wide sidebar already at its floor: leftward is the fold transition. An open + // drawer needs no equivalent — its own floor IS the fold threshold, so an + // ordinary step below it already closes it. + if (layout.mode === 'wide' && !towardWide && layout.wideWidthPx <= LEFT_PANEL_MIN_PX) { + return foldToRail(layout); } - if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return null; const step = event.shiftKey ? LEFT_NAV_LARGE_STEP_PX : LEFT_NAV_STEP_PX; - const delta = event.key === 'ArrowRight' ? step : -step; - return resolveLeftNavigationDrag(layout, keyboardBaseTotalPx(layout) + delta); + return resolveLeftNavigationDrag(layout, leftNavigationWidthPx(layout) + (towardWide ? step : -step)); } /** - * Resolve a rail launcher activation — a click, or phase 3's - * `openFocusedSection(section)` seam (#428's deterministic entry point). - * Activating the ACTIVE section closes the drawer; activating a different one - * switches content in place without closing first, as #487 requires. + * A rail launcher CLICK. Clicking the active section closes the drawer; clicking a + * different one switches content in place without closing first, as #487 requires. + * + * **This is a toggle, so it is not the `openFocusedSection` seam** — see + * `resolveRailOpen` below. Conflating the two is a real bug rather than a naming + * quibble: #428's bounded drag-hover fires repeatedly while a Library query is + * held over the Dashboards icon, and a toggle would flap the drawer open and shut + * on alternate notifications. * * **Returns the layout unchanged in wide mode, and that is a hard limit, not a * silent fallback.** There is no drawer in wide mode and both panes are already @@ -352,12 +423,31 @@ export function resolveLeftNavigationKey( * width that omits the drawer entirely. */ export function resolveRailActivation( - layout: LeftNavigationLayout, section: LeftNavigationSection, + input: LeftNavigationLayout, section: LeftNavigationSection, ): LeftNavigationLayout { + const layout = normalizeLeftNavigationLayout(input); if (layout.mode !== 'rail') return layout; return { ...layout, focusedSection: layout.focusedSection === section ? null : section }; } +/** + * Open a section IDEMPOTENTLY — the deterministic `openFocusedSection(section)` + * seam #487 requires the left-navigation API to provide for #428. + * + * "Deterministic" is the operative word: repeated calls must leave the section + * open, because the caller is a bounded drag-hover that re-asserts intent rather + * than a click that expresses a change. Already showing this section returns the + * layout by identity; wide mode returns unchanged, for the same reason as + * `resolveRailActivation`. + */ +export function resolveRailOpen( + input: LeftNavigationLayout, section: LeftNavigationSection, +): LeftNavigationLayout { + const layout = normalizeLeftNavigationLayout(input); + if (layout.mode !== 'rail' || layout.focusedSection === section) return layout; + return { ...layout, focusedSection: section }; +} + /** * The layout that actually applies at this viewport. Below the mobile breakpoint * #487 requires the desktop rail and focused drawer not to render at all, and the @@ -373,12 +463,11 @@ export function resolveRailActivation( * directly. */ export function effectiveLeftNavigationLayout( - layout: LeftNavigationLayout, isMobile: boolean, + input: LeftNavigationLayout, isMobile: boolean, ): LeftNavigationLayout { + const layout = normalizeLeftNavigationLayout(input); if (!isMobile) return layout; - return layout.mode === 'wide' && layout.focusedSection === null - ? layout - : { ...layout, mode: 'wide', focusedSection: null }; + return layout.mode === 'wide' ? layout : { ...layout, mode: 'wide', focusedSection: null }; } /** The separator's ARIA range: the rail's width is the floor (the navigation can @@ -401,6 +490,8 @@ export function leftNavigationSeparatorAria(layout: LeftNavigationLayout): LeftN return { valueMin: LEFT_RAIL_PX, valueMax: LEFT_PANEL_MAX_PX, - valueNow: leftNavigationWidthPx(layout), + // Normalized, so a caller holding a layout with a non-finite width cannot + // publish `aria-valuenow="NaN"` to assistive technology. + valueNow: leftNavigationWidthPx(normalizeLeftNavigationLayout(layout)), }; } diff --git a/src/state.ts b/src/state.ts index dd74f759..94d4811b 100644 --- a/src/state.ts +++ b/src/state.ts @@ -37,7 +37,7 @@ import type { QueryTimeRangeInferenceDiagnostic } from './core/query-time-range. import { deriveWorkspaceKey } from './core/workspace-key.js'; import { LEFT_DRAWER_DEFAULT_PX, LEFT_WIDE_DEFAULT_PX, - clampDrawerWidthPx, clampWideWidthPx, decodeLeftNavigationMode, + clampDrawerWidthPx, clampWideWidthPx, decodeLeftNavigationMode, decodeStoredPx, } from './core/left-nav-layout.js'; import type { LeftNavigationMode, LeftNavigationSection } from './core/left-nav-layout.js'; @@ -666,12 +666,13 @@ export function createState(read: StateReader = { loadJSON, loadStr }): AppState // back to the default so the selector always reflects a real choice. resultRowLimit: normalizeRowLimit(parseInt(read.loadStr(KEYS.resultRowLimit, '500'), 10)), // #487 — the WIDE left-navigation width, and the one width the fold/restore - // machine remembers. `clampWideWidthPx` enforces the same [180, 420] range - // and 248 default this key has always had, but is NaN-safe where the bare - // `clamp(parseInt(...))` was not: `Math.max(180, NaN)` is NaN, so a corrupt - // stored value used to decode straight through to `width: NaNpx`, which the - // browser drops — collapsing the sidebar with no way to tell why. - sidebarPx: clampWideWidthPx(parseInt(read.loadStr(KEYS.sidebarPx, String(LEFT_WIDE_DEFAULT_PX)), 10)), + // machine remembers. Same [180, 420] range and 248 default this key has always + // had, but decoded safely: the bare `clamp(parseInt(...))` it replaces was not + // NaN-safe (`Math.max(180, NaN)` is NaN, so a corrupt value reached the DOM as + // `width: NaNpx`, which the browser drops), and `parseInt` also accepted a + // numeric PREFIX, so `'12junk'` silently decoded to 12. `decodeStoredPx` + // requires the whole string to be a finite number before the clamp runs. + sidebarPx: clampWideWidthPx(decodeStoredPx(read.loadStr(KEYS.sidebarPx, ''), LEFT_WIDE_DEFAULT_PX)), 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 @@ -813,7 +814,7 @@ export function createState(read: StateReader = { loadJSON, loadStr }): AppState // desktop session shows a bare rail even when rail mode was persisted. leftNavMode: signal(decodeLeftNavigationMode(read.loadStr(KEYS.leftNavMode, 'wide'))), leftNavDrawerPx: clampDrawerWidthPx( - parseInt(read.loadStr(KEYS.leftNavDrawerPx, String(LEFT_DRAWER_DEFAULT_PX)), 10)), + decodeStoredPx(read.loadStr(KEYS.leftNavDrawerPx, ''), LEFT_DRAWER_DEFAULT_PX)), leftNavSection: signal(null), // Best-effort mobile mode (#126). `isMobile` mirrors the viewport width // against MOBILE_BREAKPOINT_PX — set once and on `change` by app.js's diff --git a/tests/unit/left-nav-layout.test.ts b/tests/unit/left-nav-layout.test.ts index 72510a3e..028a468f 100644 --- a/tests/unit/left-nav-layout.test.ts +++ b/tests/unit/left-nav-layout.test.ts @@ -21,9 +21,10 @@ import { LEFT_DRAWER_DEFAULT_PX, LEFT_FOLD_THRESHOLD_PX, LEFT_NAV_LARGE_STEP_PX, LEFT_NAV_SECTIONS, LEFT_NAV_STEP_PX, LEFT_PANEL_MAX_PX, LEFT_PANEL_MIN_PX, LEFT_RAIL_PX, LEFT_WIDE_DEFAULT_PX, LEFT_WIDE_THRESHOLD_PX, - clampDrawerWidthPx, clampWideWidthPx, decodeLeftNavigationMode, effectiveLeftNavigationLayout, - isLeftNavigationSection, leftNavigationLayoutIsCoherent, leftNavigationSeparatorAria, - leftNavigationWidthPx, resolveLeftNavigationDrag, resolveLeftNavigationKey, resolveRailActivation, + clampDrawerWidthPx, clampWideWidthPx, decodeLeftNavigationMode, decodeStoredPx, + effectiveLeftNavigationLayout, isLeftNavigationSection, leftNavigationLayoutIsCoherent, + leftNavigationSeparatorAria, leftNavigationWidthPx, normalizeLeftNavigationLayout, + resolveLeftNavigationDrag, resolveLeftNavigationKey, resolveRailActivation, resolveRailOpen, } from '../../src/core/left-nav-layout.js'; import type { LeftNavigationLayout } from '../../src/core/left-nav-layout.js'; @@ -391,16 +392,16 @@ describe('resolveLeftNavigationKey', () => { rail({ focusedSection: 'history', drawerWidthPx: LEFT_FOLD_THRESHOLD_PX }), { key: 'ArrowLeft' })!; expect(next.focusedSection).toBeNull(); }); - it('leaves a bare rail on ONE rightward step, whatever the remembered width', () => { - // The bare-rail base must be the wide THRESHOLD, not `wideWidthPx`. With the - // remembered width as the base, any value at or below 244 would leave a small - // ArrowRight stuck in the sticky band forever — a separator advertising - // `aria-valuemax: 420` while refusing to move. 180 is trivially reachable by - // dragging the sidebar narrow before folding, so this is not a corner case. + it('leaves a bare rail on ONE rightward step, at the REMEMBERED width', () => { + // A relative +16 from the rail's own 48px would propose 64, land in the sticky + // band and do nothing forever, so the boundary step performs the restore + // transition instead — and at the remembered width, like End. A fixed + // threshold-plus-step base would hand back 276 and silently discard this 420. for (const wideWidthPx of [LEFT_PANEL_MIN_PX, 200, 244, LEFT_WIDE_DEFAULT_PX, LEFT_PANEL_MAX_PX]) { for (const shiftKey of [false, true]) { - expect(resolveLeftNavigationKey(rail({ wideWidthPx }), { key: 'ArrowRight', shiftKey })!.mode) - .toBe('wide'); + const out = resolveLeftNavigationKey(rail({ wideWidthPx }), { key: 'ArrowRight', shiftKey })!; + expect(out.mode).toBe('wide'); + expect(out.wideWidthPx).toBe(wideWidthPx); } } }); @@ -410,15 +411,20 @@ describe('resolveLeftNavigationKey', () => { expect(resolveLeftNavigationKey(stay, { key: 'ArrowLeft', shiftKey })).toBe(stay); } }); - it('matches pointer transitions for the same proposed total width', () => { - // The property the design exists for. The pointer side is driven from the - // module's PUBLIC occupied width, never from a copy of the private base - // formula — recomputing the implementation here is what let a bare-rail base - // bug survive a green suite, so the bare rail (whose base is deliberately the - // threshold, not its own width) is asserted separately above. + it('matches pointer transitions INSIDE a band, for the same proposed total width', () => { + // The property the design exists for: inside a band the keyboard IS the drag + // reducer, so the resize arithmetic has one implementation. The pointer side is + // driven from the module's PUBLIC occupied width, never from a copy of a private + // base formula — recomputing the implementation here is what let a bare-rail + // base bug survive a green suite. + // + // Band EDGES are deliberately excluded and asserted separately: there the + // keyboard performs a semantic transition the pointer reaches by simply + // travelling further, which no single shared proposal can express. A bare rail + // and a 180px sidebar are the two such states. const cases: LeftNavigationLayout[] = [ wide({ wideWidthPx: 300 }), - wide({ wideWidthPx: LEFT_PANEL_MIN_PX }), + wide({ wideWidthPx: LEFT_PANEL_MIN_PX + LEFT_NAV_LARGE_STEP_PX }), wide({ wideWidthPx: LEFT_PANEL_MAX_PX }), rail({ focusedSection: 'databases', drawerWidthPx: LEFT_FOLD_THRESHOLD_PX }), rail({ focusedSection: 'databases', drawerWidthPx: 200 }), @@ -435,16 +441,186 @@ describe('resolveLeftNavigationKey', () => { } } }); - it('is a no-op at the wide floor and ceiling for a small step, and folds on a large one', () => { - // Unlike the bare rail, this is NOT a dead end: the pointer does the same - // thing at these extremes (clientX 164 also leaves a 180 sidebar at 180), and - // Shift+ArrowLeft escapes. Keyboard and pointer agree, which is the contract. - expect(resolveLeftNavigationKey(wide({ wideWidthPx: LEFT_PANEL_MIN_PX }), { key: 'ArrowLeft' })) - .toEqual(wide({ wideWidthPx: LEFT_PANEL_MIN_PX })); + it('folds from the wide floor on a leftward step — the mirror of the bare-rail case', () => { + // The dead end this replaces: at the 180 floor a −16 step proposes 164, which + // clamps back to 180, so plain ArrowLeft did nothing FOREVER while + // `aria-valuemin: 48` was advertised. Home and Shift+Arrow escaping is not a + // defence — the W3C splitter pattern makes plain Left/Right the move keys. + for (const shiftKey of [false, true]) { + expect(resolveLeftNavigationKey( + wide({ wideWidthPx: LEFT_PANEL_MIN_PX }), { key: 'ArrowLeft', shiftKey })!.mode).toBe('rail'); + } + }); + it('holds at the wide ceiling on a rightward step — a real bound, not a dead zone', () => { + // Nothing legal exists to the right of 420, so refusing to move is the correct + // answer rather than a stranded control. expect(resolveLeftNavigationKey(wide({ wideWidthPx: LEFT_PANEL_MAX_PX }), { key: 'ArrowRight' })) .toEqual(wide({ wideWidthPx: LEFT_PANEL_MAX_PX })); - expect(resolveLeftNavigationKey( - wide({ wideWidthPx: LEFT_PANEL_MIN_PX }), { key: 'ArrowLeft', shiftKey: true })!.mode).toBe('rail'); + }); + it('reaches the rail from any wide width by repeated plain ArrowLeft', () => { + // The property the single-step tests could not express: a keyboard SEQUENCE + // must be able to go where the equivalent pointer path goes. Eleven presses + // from 300 used to sit at 180 forever while the pointer folded. + for (const start of [LEFT_PANEL_MAX_PX, 300, 190, LEFT_PANEL_MIN_PX]) { + let layout: LeftNavigationLayout = wide({ wideWidthPx: start }); + for (let i = 0; i < 40 && layout.mode === 'wide'; i++) { + layout = resolveLeftNavigationKey(layout, { key: 'ArrowLeft' })!; + } + expect(layout.mode).toBe('rail'); + } + }); + it('round-trips between rail and wide with plain arrows, in both directions', () => { + // Reversibility of the MODE, which a stranded boundary silently broke. The width + // does not round-trip, and should not: the intervening ArrowLefts really did + // resize the sidebar down to its floor before folding, so 180 is the honest + // remembered width on the way back. + const start = rail({ wideWidthPx: 300 }); + expect(resolveLeftNavigationKey(start, { key: 'ArrowRight' })).toEqual(wide({ wideWidthPx: 300 })); + let back: LeftNavigationLayout = wide({ wideWidthPx: 300 }); + for (let i = 0; i < 40 && back.mode === 'wide'; i++) { + back = resolveLeftNavigationKey(back, { key: 'ArrowLeft' })!; + } + expect(back).toEqual(rail({ wideWidthPx: LEFT_PANEL_MIN_PX })); + // And straight back out again, so neither end is a trap. + expect(resolveLeftNavigationKey(back, { key: 'ArrowRight' })!.mode).toBe('wide'); + }); + it('normalizes an incoherent layout before acting on it', () => { + const healed = resolveLeftNavigationKey( + wide({ focusedSection: 'databases' }) as LeftNavigationLayout, { key: 'ArrowRight' })!; + expect(healed.focusedSection).toBeNull(); + expect(resolveLeftNavigationKey(wide({ wideWidthPx: NaN }), { key: 'End' })!.wideWidthPx) + .toBe(LEFT_WIDE_DEFAULT_PX); + }); +}); + +// #487 requires "a deterministic `openFocusedSection('dashboards')` seam" for +// #428's bounded drag-hover. A toggle cannot serve that: hover re-asserts intent +// repeatedly, so a toggle would flap the drawer open and shut on alternate +// notifications. Open and toggle are therefore separate operations. +describe('resolveRailOpen — the idempotent seam', () => { + it('opens a section from a bare rail', () => { + expect(resolveRailOpen(rail(), 'dashboards')).toEqual(rail({ focusedSection: 'dashboards' })); + }); + it('is IDEMPOTENT — repeated opens leave the section open', () => { + // The exact #428 failure mode this exists to prevent. + let layout: LeftNavigationLayout = rail(); + for (let i = 0; i < 5; i++) layout = resolveRailOpen(layout, 'dashboards'); + expect(layout.focusedSection).toBe('dashboards'); + // …and returns by identity once already open, so a hover notification storm + // cannot cause a repaint per event. + expect(resolveRailOpen(layout, 'dashboards')).toBe(layout); + }); + it('switches from another open section without closing first', () => { + expect(resolveRailOpen(rail({ focusedSection: 'history' }), 'dashboards').focusedSection) + .toBe('dashboards'); + }); + it('never closes a drawer, unlike the click toggle', () => { + const open = rail({ focusedSection: 'dashboards' }); + expect(resolveRailOpen(open, 'dashboards').focusedSection).toBe('dashboards'); + expect(resolveRailActivation(open, 'dashboards').focusedSection).toBeNull(); + }); + it('is a no-op in wide mode, like the toggle', () => { + const layout = wide(); + expect(resolveRailOpen(layout, 'dashboards')).toBe(layout); + }); +}); + +describe('normalizeLeftNavigationLayout', () => { + it('returns a legal layout by identity', () => { + for (const layout of [wide(), rail(), rail({ focusedSection: 'library' })]) { + expect(normalizeLeftNavigationLayout(layout)).toBe(layout); + } + }); + it('drops a focused section that wide mode cannot render', () => { + expect(normalizeLeftNavigationLayout(wide({ focusedSection: 'databases' }))) + .toEqual(wide()); + }); + it('heals a non-finite or out-of-band width', () => { + expect(normalizeLeftNavigationLayout(wide({ wideWidthPx: NaN })).wideWidthPx) + .toBe(LEFT_WIDE_DEFAULT_PX); + expect(normalizeLeftNavigationLayout(wide({ wideWidthPx: 9999 })).wideWidthPx) + .toBe(LEFT_PANEL_MAX_PX); + expect(normalizeLeftNavigationLayout(wide({ drawerWidthPx: 9999 })).drawerWidthPx) + .toBe(LEFT_WIDE_THRESHOLD_PX); + }); + it('rejects an unknown mode and an unknown section', () => { + expect(normalizeLeftNavigationLayout({ ...wide(), mode: 'collapsed' } as unknown as LeftNavigationLayout).mode) + .toBe('wide'); + expect(normalizeLeftNavigationLayout( + { ...rail(), focusedSection: 'saved' } as unknown as LeftNavigationLayout).focusedSection).toBeNull(); + }); + it('makes every reducer heal an incoherent seed rather than preserve it', () => { + // Previously the invariant was only a PRECONDITION: a drag over an incoherent + // layout carried the illegal mode/section pair straight through, and End handed + // it back untouched. `state.ts` stores the two as independently writable + // signals, so that pair is one stray assignment away. + const bad = wide({ focusedSection: 'databases' }); + expect(leftNavigationLayoutIsCoherent(bad)).toBe(false); + expect(resolveLeftNavigationDrag(bad, 300).focusedSection).toBeNull(); + expect(resolveLeftNavigationKey(bad, { key: 'End' })!.focusedSection).toBeNull(); + expect(resolveRailActivation(bad, 'databases').focusedSection).toBeNull(); + expect(resolveRailOpen(bad, 'databases').focusedSection).toBeNull(); + expect(effectiveLeftNavigationLayout(bad, false).focusedSection).toBeNull(); + }); + it('keeps a NaN width out of the ARIA value published to assistive technology', () => { + expect(leftNavigationSeparatorAria(wide({ wideWidthPx: NaN })).valueNow) + .toBe(LEFT_WIDE_DEFAULT_PX); + }); + it('lifts a type-valid but illegal seed into its band before measuring', () => { + // A 150px "wide" sidebar is type-valid and state-invalid. Normalizing on entry + // means the sweep is measured from a legal 180 rather than reporting an occupied + // width no mode can render. + expect(normalizeLeftNavigationLayout(wide({ wideWidthPx: 150 })).wideWidthPx) + .toBe(LEFT_PANEL_MIN_PX); + // 149 is still above the fold threshold, so it resizes to the floor … + expect(resolveLeftNavigationDrag(wide({ wideWidthPx: 150 }), 149)) + .toEqual(wide({ wideWidthPx: LEFT_PANEL_MIN_PX })); + // … and only a proposal past the threshold folds. + expect(resolveLeftNavigationDrag(wide({ wideWidthPx: 150 }), LEFT_FOLD_THRESHOLD_PX - 1).mode) + .toBe('rail'); + }); +}); + +describe('decodeStoredPx', () => { + it('accepts a complete number, with surrounding whitespace', () => { + expect(decodeStoredPx('300', 1)).toBe(300); + expect(decodeStoredPx(' 300 ', 1)).toBe(300); + expect(decodeStoredPx('300.5', 1)).toBe(300.5); + expect(decodeStoredPx('-5', 1)).toBe(-5); + }); + it('rejects a numeric PREFIX, which parseInt would have accepted', () => { + // The contract this fixes: `parseInt('12junk')` is 12 and `parseInt('200px')` + // is 200, so a truncated write or a hand-edited CSS unit decoded to a + // plausible-looking width while the docs promised the default. + expect(decodeStoredPx('12junk', 248)).toBe(248); + expect(decodeStoredPx('200px', 240)).toBe(240); + expect(decodeStoredPx('1e', 248)).toBe(248); + }); + it('rejects a stored infinity, empty string, whitespace and every non-string', () => { + expect(decodeStoredPx('Infinity', 248)).toBe(248); + expect(decodeStoredPx('-Infinity', 248)).toBe(248); + expect(decodeStoredPx('NaN', 248)).toBe(248); + expect(decodeStoredPx('', 248)).toBe(248); + expect(decodeStoredPx(' ', 248)).toBe(248); + expect(decodeStoredPx(null, 248)).toBe(248); + expect(decodeStoredPx(undefined, 248)).toBe(248); + expect(decodeStoredPx(300, 248)).toBe(248); + }); +}); + +// Documented, deliberately pinned, and phase 3's to change: the remembered wide +// width depends on which pointer samples the browser happened to deliver, because +// one field is doing duty as both the live drag width and the restore memory. +// Separating them needs a drag-session snapshot, which a pure reducer cannot take. +describe('restore memory is sampling-dependent (phase 3 obligation)', () => { + it('remembers a different width for the same gesture depending on event cadence', () => { + const seed = wide({ wideWidthPx: 300 }); + // One coarse sample straight past the fold keeps the pre-drag width … + expect(resolveLeftNavigationDrag(seed, 139).wideWidthPx).toBe(300); + // … while an intermediate sample inside the 140–179 dead zone rests the width + // at the floor first, so the fold remembers 180 instead. + expect(resolveLeftNavigationDrag(resolveLeftNavigationDrag(seed, 179), 139).wideWidthPx) + .toBe(LEFT_PANEL_MIN_PX); }); }); diff --git a/tests/unit/state.test.ts b/tests/unit/state.test.ts index ad28cbd6..5d82e091 100644 --- a/tests/unit/state.test.ts +++ b/tests/unit/state.test.ts @@ -292,6 +292,23 @@ describe('createState — left navigation preferences (#487)', () => { expect(s.sidebarPx).toBe(248); }); + it('rejects a partially numeric stored width instead of trusting its prefix', () => { + // `parseInt` accepted a numeric PREFIX, so a truncated write or a hand-edited + // CSS unit decoded to a plausible-looking width while the docs promised the + // default: `parseInt('12junk')` is 12 (then clamped to 180) and + // `parseInt('200px')` is 200 (accepted outright). + const s = createState(reader({ + [KEYS.sidebarPx]: '12junk', + [KEYS.leftNavDrawerPx]: '200px', + })); + expect(s.sidebarPx).toBe(248); + expect(s.leftNavDrawerPx).toBe(240); + // A stored infinity is corruption too, not a width pressed against a bound. + expect(createState(reader({ [KEYS.sidebarPx]: 'Infinity' })).sidebarPx).toBe(248); + // …while a well-formed value still decodes, whitespace and decimals included. + expect(createState(reader({ [KEYS.sidebarPx]: ' 330 ' })).sidebarPx).toBe(330); + }); + it('clamps an out-of-range drawer width into the drawer own band', () => { // Not the wide sidebar's [180, 420]: a drawer wider than the wide threshold // is unreachable, because a drag that far right converts to the sidebar. From 5f656559613bed675ccbcbe4f5f1c6c4d226ab62 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Wed, 29 Jul 2026 23:35:39 +0200 Subject: [PATCH 03/78] feat(#487): navigation section registry (phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the four navigation sections hostable by any container, over one shared store per section. The wide sidebar renders exactly as it does today. `src/ui/nav-sections.ts` maps each `LeftNavigationSection` to its label, icon factory, accessible label, wide pane and — the point — its single PERSISTENT host element. `app-shell.ts` composes both sidebar panes out of `registry.entries` filtered by pane, so the shell no longer names which sections belong where. The hosts are built once and never rebuilt, extending #426's `buildSidebarUpper` contract to the lower pane: phase 3's rail and docked drawer MOVE these elements, and a moved element keeps its input values, expansion and scroll, which is how "wide and focused presentations share and preserve all navigation state" becomes structural rather than save/restore logic. The lower pane's Library and History sections gained separate persistent search/list pairs (`historySearch`/`historyList` join `savedSearch`/`savedList`, which are now the Library's). Before this both rendered through one pair a section switch repainted — workable for two tabs in one pane, but neither section's live DOM could be handed to another container without carrying the other's content along. Behaviour is unchanged: only the active section renders, and a switch still clears the shared search filter. Both wide switchers now take their label and icon from `NAV_SECTION_META`. Leaving the upper one hard-coded would have left the registry a second source of truth for exactly the sections phase 3's rail presents again, so a relabel would drift silently. The `'library' ↔ 'saved'` bridge lives in `core/left-nav-layout.ts` beside the other decoders, because `state.ts` applies it at the load boundary and must not import `src/ui/`; the registry re-exports it as its UI-side owner. Fixes a real bug this exposed: `asb:sidePanel` was read undecoded and every reader compared `=== 'saved'`, so an unrecognized or obsolete stored value fell through to History — neither the documented default nor the value's own meaning. Harmless with one shared element pair; with two hosts, two readers resolving one value differently expose one section's host while painting into the other's, i.e. a blank pane. It is decoded once at load now, like `leftNavMode` two lines below it, and `AppState.sidePanel` narrowed from `Signal` to `Signal`. All four hosts share `.nav-section-host` / `data-section` (was `.upper-role-host` / `data-role`, upper pane only) so phase 3's drawer needs no per-section layout rule. Tests: `## Tests` → Wide state 1-4, one host per section, and the exposure rules. `tests/e2e/dashboard-tree.spec.js` gains a lower-pane geometry assertion — the split added a flex wrapper between `.saved-pane` and its scroller, and happy-dom computes no layout, so that pane's box model had no gate in any suite. Part of #487. Follow-up filed: #572 (`inbox`). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NZfeGpkpJw2zsfPtysGHUN --- CHANGELOG.md | 35 ++++- src/core/left-nav-layout.ts | 48 ++++++- src/state.ts | 27 +++- src/styles.css | 20 ++- src/ui/app-shell.ts | 46 +++++-- src/ui/app.types.ts | 9 ++ src/ui/nav-sections.ts | 194 ++++++++++++++++++++++++++++ src/ui/saved-history.ts | 80 ++++++++---- src/ui/sidebar-upper.ts | 25 +++- tests/e2e/dashboard-membership.html | 10 +- tests/e2e/dashboard-tree.spec.js | 69 ++++++++-- tests/helpers/fake-app.ts | 5 + tests/unit/app-shell.test.ts | 124 ++++++++++++++++++ tests/unit/left-nav-layout.test.ts | 36 ++++++ tests/unit/nav-sections.test.ts | 188 +++++++++++++++++++++++++++ tests/unit/saved-history.test.ts | 61 ++++++--- tests/unit/sidebar-upper.test.ts | 39 +++++- tests/unit/state.test.ts | 6 + 18 files changed, 936 insertions(+), 86 deletions(-) create mode 100644 src/ui/nav-sections.ts create mode 100644 tests/unit/nav-sections.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e0e25e6..91943d6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,28 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] ### Added +- **A navigation section registry behind the left sidebar** (#487, phase 2 of 4). + Each of the four navigation sections — Databases, Dashboards, Library, History — + is now addressable through one registry (`src/ui/nav-sections.ts`) that owns its + label, its icon, its accessible label and its single *persistent* host element, + and both sidebar panes are composed out of those hosts. The lower pane's Library + and History sections gained separate persistent search/list element pairs; before + this they shared one pair that a tab switch repainted, which meant neither + section's live DOM could be handed to another container without taking the + other's content along. #426 established the pattern for the upper pane's two + sections; this generalizes it to all four, so the hosts all carry one + `.nav-section-host` class (it was `.upper-role-host`) and one `data-section` + attribute. The `'library' ↔ 'saved'` vocabulary bridge — `AppState.sidePanel` + still persists `'saved'` for the section #427 relabelled "Library" — now lives in + exactly one place instead of being re-derived per caller, and both wide switchers + take their label and icon from the registry so the two presentations of a section + cannot drift. **No user-visible change**: the same two panes, the same switchers, + the same splitters, and a section switch still clears the search exactly as + before. What it buys is + structural: phase 3's compact rail and docked focused drawer can *move* a + section's live DOM instead of rebuilding it, which is what makes "wide and + focused presentations share and preserve all navigation state" true by + construction rather than by save/restore logic. - **The desktop left navigation's layout core and preferences** (#487, phase 1 of 4). A new pure `src/core/left-nav-layout.ts` owns every layout decision the foldable left navigation needs: the named constants and thresholds, the @@ -38,12 +60,12 @@ auto-generated per-PR notes; this file is the curated, human-readable history. invariant is an unconditional postcondition rather than a precondition the caller has to honour. **No user-visible change yet**: the rail, the docked focused drawer and the resize separator arrive in phase 3. + +### Changed - The sidebar's `'col'` drag axis now clamps through the same `LEFT_PANEL_MIN_PX`/`LEFT_PANEL_MAX_PX` constants as the load path, instead of repeating `180`/`420` as literals (#487). Behaviour is unchanged; it removes the second owner of a range whose whole point is having one. - -### Changed - **`VariableBarApp`'s shared activation port is now caller-neutral** (#478). `state.filterActive`/`params.saveFilterActive` — named after Workbench persistence even though Dashboard's own caller uses them for an unpersisted @@ -59,6 +81,15 @@ auto-generated per-PR notes; this file is the curated, human-readable history. adapter refactor — no user-visible behavior changes. ### Fixed +- **A corrupt `asb:sidePanel` decodes to the Library instead of propagating** + (#487). The lower pane's active section was read from localStorage undecoded, + and every reader compared `=== 'saved'`, so an unrecognized or obsolete value + fell through to the History branch — neither the documented default nor the + value's own meaning. It is now decoded once at load, like the `leftNavMode` and + width preferences beside it. This became load-bearing rather than cosmetic in + phase 2: with the pane's two sections on separate hosts, two readers resolving + one value differently expose one section's host while painting into the other's, + which renders as a blank pane. - **A corrupt `asb:sidebarPx` decodes to the default width instead of `NaN`** (#487). The width decoded through a bare `clamp(parseInt(stored), 180, 420)`, and `clamp` is not NaN-safe (`Math.max(180, NaN)` is `NaN`), so a non-numeric diff --git a/src/core/left-nav-layout.ts b/src/core/left-nav-layout.ts index edf84c1b..436fd993 100644 --- a/src/core/left-nav-layout.ts +++ b/src/core/left-nav-layout.ts @@ -73,11 +73,12 @@ export type LeftNavigationMode = 'wide' | 'rail'; * "Library" and deliberately left the stored value alone, since migrating it * would discard every user's persisted lower-pane choice for no behavioural gain. * - * So the vocabularies genuinely differ, and phase 2's navigation section registry - * owns the mapping in exactly one place — `'library' ↔ 'saved'`, with the other - * three sections identical. That mapping is deliberately NOT written here yet: it - * has no caller until the registry exists, and a second copy of it is precisely - * the duplication phase 2 is meant to prevent. + * So the vocabularies genuinely differ, and the mapping lives in exactly one + * place — `sectionForSidePanelKey` / `sidePanelKeyFor` below, added in phase 2. + * `ui/nav-sections.ts` (the navigation section registry) is its UI-side owner and + * only consumer of the section half; the *decode* half belongs here beside + * `decodeLeftNavigationMode`, because `state.ts` applies it at the load boundary + * and must not import from `src/ui/`. */ export type LeftNavigationSection = 'databases' | 'dashboards' | 'library' | 'history'; @@ -87,6 +88,43 @@ export type LeftNavigationSection = 'databases' | 'dashboards' | 'library' | 'hi export const LEFT_NAV_SECTIONS: readonly LeftNavigationSection[] = ['databases', 'dashboards', 'library', 'history']; +/** The lower sidebar pane's two sections, in the registry's vocabulary. Derived + * from `LeftNavigationSection` so the section names have exactly one source. */ +export type LowerNavigationSection = Extract; + +/** + * What `AppState.sidePanel` actually stores. `'saved'` is the Library section: + * #427 renamed the visible label but deliberately left the persisted value at + * `asb:sidePanel` alone, since migrating it would discard every user's lower-pane + * choice for no behavioural gain. + */ +export type SidePanelKey = 'saved' | 'history'; + +/** Section → stored value. */ +export function sidePanelKeyFor(section: LowerNavigationSection): SidePanelKey { + return section === 'library' ? 'saved' : 'history'; +} + +/** + * Stored value → section, and the DECODER for `asb:sidePanel`: a missing, invalid + * or obsolete stored value resolves to the Library section, which is that + * preference's documented default (`state.ts` reads it with `'saved'` as the + * fallback), rather than propagating an unrecognized string. + * + * That fallback direction matters, and it is a deliberate fix rather than + * preserved behaviour. Before phase 2 the lower pane's two sections shared one + * search/list pair and every reader compared `=== 'saved'`, so an unrecognized + * value fell through to the History branch — i.e. to neither the default nor the + * value's own meaning. With two hosts, two readers disagreeing about the fallback + * exposes one section's host while painting into the other's, which renders as a + * blank pane. `state.ts` now decodes once at load, so the signal only ever holds + * a `SidePanelKey` and the disagreement is unreachable rather than merely + * avoided by discipline. + */ +export function sectionForSidePanelKey(key: unknown): LowerNavigationSection { + return key === 'history' ? 'history' : 'library'; +} + /** * The complete left-navigation layout. `wideWidthPx` is deliberately the SAME * value `AppState.sidebarPx` persists at `asb:sidebarPx` — #487 suggests a new diff --git a/src/state.ts b/src/state.ts index 94d4811b..9702cf91 100644 --- a/src/state.ts +++ b/src/state.ts @@ -38,8 +38,11 @@ import { deriveWorkspaceKey } from './core/workspace-key.js'; import { LEFT_DRAWER_DEFAULT_PX, LEFT_WIDE_DEFAULT_PX, clampDrawerWidthPx, clampWideWidthPx, decodeLeftNavigationMode, decodeStoredPx, + sectionForSidePanelKey, sidePanelKeyFor, +} from './core/left-nav-layout.js'; +import type { + LeftNavigationMode, LeftNavigationSection, SidePanelKey, } from './core/left-nav-layout.js'; -import type { LeftNavigationMode, LeftNavigationSection } from './core/left-nav-layout.js'; // ── Persisted-data types (schema-generated) ───────────────────────────────── @@ -383,9 +386,17 @@ export interface AppState { filterActive: Record; varRecent: RecentMap; varRecentDisabled: boolean; - /** 'saved' | 'history' at every write site; typed string because the - * initial value is an undecoded localStorage read (`asb:sidePanel`). */ - sidePanel: Signal; + /** + * The lower sidebar pane's active section, as `asb:sidePanel` stores it — + * `'saved'` is the Library (#427 relabelled it without migrating the value). + * + * #487 phase 2 narrowed this from `Signal`: the stored value is now + * DECODED at load (below), so an obsolete or corrupt string can never reach a + * reader. It had to be, once the pane's two sections gained separate hosts — + * two readers disagreeing about what an unrecognized value means exposes one + * section while painting into the other, i.e. a blank pane. + */ + sidePanel: Signal; /** * #426 — the UPPER sidebar pane's role. Deliberately NOT persisted (unlike * `sidePanel`): the issue specifies "default to Databases for a fresh session", @@ -763,7 +774,13 @@ export function createState(read: StateReader = { loadJSON, loadStr }): AppState // cleared (Clear all recent values / per-field Clear recent). // The `as` trusts the localStorage shape verbatim — no decoder exists today. varRecentDisabled: read.loadJSON(KEYS.varRecentDisabled, false) as boolean, - sidePanel: signal(read.loadStr(KEYS.sidePanel, 'saved')), + // Decoded, not passed through (#487 phase 2) — the same discipline + // `leftNavMode` below has, and for the same reason: an unknown stored string + // is not a third section. Round-tripping through the section vocabulary is + // what makes the fallback the documented default rather than whichever branch + // an `=== 'saved'` comparison happens to take. + sidePanel: signal(sidePanelKeyFor( + sectionForSidePanelKey(read.loadStr(KEYS.sidePanel, 'saved')))), upperRole: signal<'databases' | 'dashboards'>('databases'), dashboardTreeRevision: signal(0), dashboardTreeUi: new Map(), diff --git a/src/styles.css b/src/styles.css index df1ba02a..c22535f6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1704,18 +1704,24 @@ body.detached-tab .graph-overlay-panel { .schema-empty { padding: 24px 14px; color: var(--fg-faint); font-size: var(--text-body); text-align: center; } -/* ------------ upper sidebar roles (#426) ------------ - Databases | Dashboards over two persistent hosts, exactly one exposed. The tab - row reuses .side-tabs/.side-tab/.side-count from the lower switcher verbatim — - DESIGN.md asks for one tab vocabulary across the app. */ -.upper-role-host { +/* ------------ navigation section hosts (#426, generalized in #487) ------------ + One host per navigation section, exactly one exposed per pane: Databases | + Dashboards above, Library | History below. Each pane's tab row reuses + .side-tabs/.side-tab/.side-count verbatim — DESIGN.md asks for one tab + vocabulary across the app. + + #487 phase 2 gave all four sections this one class (it was .upper-role-host, + for the upper pane's two) so that phase 3's focused drawer can host any of them + with no per-section layout rule. */ +.nav-section-host { flex: 1; min-height: 0; display: flex; flex-direction: column; } /* A hidden host contributes no layout, so the exposed one owns the whole pane — but it keeps its DOM, which is what preserves search text, expansion, lazily - loaded columns and scroll across a role switch. */ -.upper-role-host[hidden] { display: none; } + loaded columns and scroll across a section switch, and (phase 3) across a move + between the wide sidebar and the focused drawer. */ +.nav-section-host[hidden] { display: none; } /* ------------ Dashboard hierarchy tree (#426) ------------ */ .dash-tree-row { position: relative; } diff --git a/src/ui/app-shell.ts b/src/ui/app-shell.ts index 506f7074..2fa9d326 100644 --- a/src/ui/app-shell.ts +++ b/src/ui/app-shell.ts @@ -35,6 +35,8 @@ import type { AppState as State } from '../state.js'; import { effect } from '@preact/signals-core'; import { renderSchema } from './schema.js'; import { buildSidebarUpper, renderUpperRoleTabs } from './sidebar-upper.js'; +import { buildNavSectionRegistry, sectionForSidePanelKey } from './nav-sections.js'; +import type { NavSectionPane } from './nav-sections.js'; import { renderDashboardTree, cancelDashboardTreeClicks } from './dashboard-tree.js'; import { renderSavedHistory } from './saved-history.js'; import { renderLibraryTitle } from './file-menu.js'; @@ -126,21 +128,32 @@ export function mountAppShell(deps: AppShellDeps): AppShellHandle { oninput: (e: Event) => { state.schemaFilter.value = (e.target as HTMLInputElement).value; }, }); app.dom.schemaList = h('div', { class: 'schema-list' }); - // #426: the upper pane now hosts TWO roles. The Databases content is built here - // exactly as before and handed to the role host, which only ever toggles + // #426: the upper pane hosts TWO sections. The Databases content is built here + // exactly as before and handed to the section host, which only ever toggles // `hidden` — so schema search text/focus, expansion, lazily-loaded columns and - // scroll all survive a trip through the Dashboards role by construction. - const upper = buildSidebarUpper(app, [ + // scroll all survive a trip through the Dashboards section by construction. + // + // #487 phase 2: both panes are now composed out of the SAME registry, which owns + // all four sections' persistent hosts, their labels and their icons. This shell + // no longer builds the lower pane's search/list elements, and it does not name + // which sections belong to which pane — it asks the registry, which is the only + // way the claim stays true when phase 3 adds a third container. Phase 3's rail + // and focused drawer address exactly the same four hosts, which is what makes a + // mode change a MOVE of live DOM rather than a rebuild. + const registry = buildNavSectionRegistry(app, buildSidebarUpper(app, [ h('div', { class: 'schema-search' }, h('div', { class: 'search-wrap' }, Icon.search(), app.dom.schemaSearchInput)), app.dom.schemaList, - ]); + ])); + // `entries` is in rail order, so each pane's hosts come out in the order its + // switcher presents them (Databases | Dashboards above, Library | History below). + const hostsIn = (pane: NavSectionPane): HTMLElement[] => + registry.entries.filter((entry) => entry.pane === pane).map((entry) => entry.host); const schemaPane = h('div', { class: 'side-pane schema-pane', style: { height: state.sideSplitPct + '%', flexShrink: '0', minHeight: '0' } }, - app.dom.upperRoleTabs!, upper.databasesHost, upper.dashboardsHost); + app.dom.upperRoleTabs!, ...hostsIn('upper')); app.dom.savedTabsRow = h('div', { class: 'side-tabs' }); - app.dom.savedSearch = h('div', { class: 'saved-search' }); - app.dom.savedList = h('div', { class: 'saved-list' }); - const savedPane = h('div', { class: 'side-pane saved-pane', style: { flex: '1', minHeight: '0' } }, app.dom.savedTabsRow, app.dom.savedSearch, app.dom.savedList); + const savedPane = h('div', { class: 'side-pane saved-pane', style: { flex: '1', minHeight: '0' } }, + app.dom.savedTabsRow, ...hostsIn('lower')); const sidebar = h('div', { class: 'sidebar', style: { width: state.sidebarPx + 'px' } }); // Only 'col' (sidebar width) and 'sideRow' (schema/saved split) run through @@ -244,10 +257,19 @@ export function mountAppShell(deps: AppShellDeps): AppShellHandle { state.dashboardTreeRevision.value; renderUpperRoleTabs(app); })); - // #426: expose exactly one role host, and repaint the Dashboard tree. Kept - // separate from the tab effect so a schema load does not rebuild the tree. + // #426: expose exactly one upper section host, and repaint the Dashboard tree. + // Kept separate from the tab effect so a schema load does not rebuild the tree. + disposers.push(effect(() => { + registry.showSection(state.upperRole.value); + })); + // #487 phase 2: the same rule for the lower pane, which until now had no + // exposure step at all — its two sections shared one search/list pair that the + // repaint below simply overwrote. Subscribed to `sidePanel` ALONE (unlike the + // repaint effect, which also tracks the projection revision): a Dashboard + // mutation changes which rows the Library shows, never which section is + // exposed. disposers.push(effect(() => { - upper.showRole(state.upperRole.value); + registry.showSection(sectionForSidePanelKey(state.sidePanel.value)); })); disposers.push(effect(() => { // The ONE reactive input the tree has: every trigger #426 lists (workspace diff --git a/src/ui/app.types.ts b/src/ui/app.types.ts index 32942d94..096d8fb4 100644 --- a/src/ui/app.types.ts +++ b/src/ui/app.types.ts @@ -116,8 +116,17 @@ export interface AppDom { qtabsInner?: HTMLElement; resultsRegion?: HTMLElement; runElapsedEl?: HTMLElement; + /** The LIBRARY section's list and search box. Since #487 phase 2 the lower + * pane's two sections own separate, persistent element pairs (see + * `historyList`/`historySearch`) — before that both rendered through this one + * pair, which a section switch repainted. */ savedList?: HTMLElement; savedSearch?: HTMLElement; + /** #487 phase 2 — the HISTORY section's own list and search box, so each lower + * section has a persistent host a container can move without dragging the + * other section's content along. */ + historyList?: HTMLElement; + historySearch?: HTMLElement; savedTabsRow?: HTMLElement; schemaList?: HTMLElement; specEditorView?: EditorView; diff --git a/src/ui/nav-sections.ts b/src/ui/nav-sections.ts new file mode 100644 index 00000000..c549547c --- /dev/null +++ b/src/ui/nav-sections.ts @@ -0,0 +1,194 @@ +// The navigation section registry (#487 phase 2) — the one place that maps each +// `LeftNavigationSection` to what a container needs in order to *host* it: a +// label, an icon, an accessible label, and its single PERSISTENT host element. +// +// Why a registry at all: the four sections live in two hard-wired pane switchers +// (`Databases | Dashboards` above, `Library | History` below), each of which knew +// both its sections' labels and its sections' DOM. Phase 3 adds a third container +// — the rail's focused drawer — that must be able to show ANY one of the four with +// no switcher inside it. Three containers over one section vocabulary is exactly +// the duplication #487's "Navigation section registry" boundary exists to prevent +// ("Maps rail sections to existing views and labels without duplicating their +// domain state"). +// +// The hosts are built ONCE and never rebuilt, extending #426's `buildSidebarUpper` +// contract from the upper pane to the lower one. That is what makes #487's "Wide +// and focused presentations share and preserve all navigation state" true by +// construction rather than by restoration logic: a phase-3 mode change MOVES a +// host element between containers, and a moved element keeps its input values, its +// expansion, its lazily-loaded rows and its scroll offset. Nothing here restores +// anything, because nothing here destroys anything. +// +// What this module deliberately does NOT own: any section's rendering, search or +// domain behaviour. `buildSidebarUpper` still builds the Databases/Dashboards +// hosts and owns their exposure (this registry is handed that handle, so the +// dependency runs one way and there is no import cycle); `saved-history.ts` still +// renders the Library and History lists into the elements built below. This module +// owns only the *hosting* contract — which is why it can be the single seam all +// three containers address. + +import { h } from './dom.js'; +import { Icon } from './icons.js'; +import { LEFT_NAV_SECTIONS } from '../core/left-nav-layout.js'; +import type { LeftNavigationSection } from '../core/left-nav-layout.js'; +// Re-exported so a UI caller reads the whole section vocabulary from the registry +// (its owner) without also importing `core/`. The pure decode itself lives beside +// `decodeLeftNavigationMode`, because `state.ts` applies it at the load boundary +// and cannot import `src/ui/`. +export { sectionForSidePanelKey, sidePanelKeyFor } from '../core/left-nav-layout.js'; +export type { LowerNavigationSection, SidePanelKey } from '../core/left-nav-layout.js'; +import type { SidebarUpperHandle } from './sidebar-upper.js'; +import type { AppDom } from './app.types.js'; + +/** The slice of `app` the registry needs — the four lower-pane elements it + * attaches to `app.dom`, which `saved-history.ts` then renders into. A real + * `App` satisfies it directly. */ +export interface NavSectionsApp { + dom: Pick; +} + +/** + * Which wide-sidebar pane presents a section. The rail presents all four + * identically, so this is about the WIDE presentation only — it is how + * `showSection` knows which hosts are a section's siblings (i.e. which ones it + * must hide in order to expose this one). + */ +export type NavSectionPane = 'upper' | 'lower'; + +/** A section's presentation, independent of any DOM — so a switcher tab, a rail + * launcher and a drawer header all name a section identically. */ +export interface NavSectionMeta { + /** The visible label, exactly as the wide switchers already show it — #427 + * renamed the Queries tab to "Library" and that is the user-facing name. */ + readonly label: string; + /** A FACTORY, not an element: one SVG node cannot be in the wide switcher and + * the rail launcher at the same time, so each caller mints its own. */ + readonly icon: () => SVGElement; + /** + * For a control whose visible label is absent or insufficient — phase 3's rail + * launchers are icon-only, so this is what they announce. The strings are + * #487's own "Rail state" table verbatim, which is why they are not simply + * `label`: a launcher has to say what activating it *does*, and a tab that + * already sits in a labelled switcher does not. + */ + readonly accessibleLabel: string; + readonly pane: NavSectionPane; +} + +/** + * The four sections' presentation, in one place. Both wide switchers and (in + * phase 3) the rail read it, so a label or icon can never disagree between the two + * presentations of the same section. + */ +export const NAV_SECTION_META: Readonly> = { + databases: { + label: 'Databases', icon: Icon.database, pane: 'upper', + accessibleLabel: 'Open Databases navigation', + }, + dashboards: { + label: 'Dashboards', icon: Icon.dashboard, pane: 'upper', + accessibleLabel: 'Open Dashboards navigation', + }, + library: { + // "Library", not "Queries" — #427 landed, and #487's table says the label and + // the rail tooltip follow it. + label: 'Library', icon: Icon.layers, pane: 'lower', + accessibleLabel: 'Open Library navigation', + }, + history: { + label: 'History', icon: Icon.history, pane: 'lower', + accessibleLabel: 'Open query History', + }, +}; + +export interface NavSectionEntry extends NavSectionMeta { + readonly section: LeftNavigationSection; + /** The single persistent host. Built once, moved between containers, never + * rebuilt. */ + readonly host: HTMLElement; +} + +export interface NavSectionRegistry { + /** All four, in `LEFT_NAV_SECTIONS` order (rail order, top to bottom). */ + readonly entries: readonly NavSectionEntry[]; + entry(section: LeftNavigationSection): NavSectionEntry; + /** + * Expose exactly one section within its own pane, hiding its pane siblings. + * A hidden host contributes no layout but keeps its DOM — the whole point. + * + * Scoped to the pane because the wide sidebar shows one upper section AND one + * lower section simultaneously; a global "exactly one of four" would blank half + * the sidebar. Phase 3's drawer shows one of four, and gets there by moving the + * host rather than by widening this rule. + */ + showSection(section: LeftNavigationSection): void; +} + +/** A section host: the wrapper a container mounts, and the element `showSection` + * toggles. The same class for all four, so phase 3's drawer needs no per-section + * layout rule. */ +const sectionHost = (section: LeftNavigationSection, hidden: boolean, ...content: Node[]): HTMLElement => + h('div', { class: 'nav-section-host', 'data-section': section, hidden }, ...content); + +/** + * Build the registry. Called once per shell mount, right after the `app.dom` reset + * — every host it owns is a singleton for the life of that shell. + * + * `upper` is #426's already-built upper pane: the registry adopts its two hosts + * and delegates their exposure back to it, rather than reaching into another + * module's DOM. The lower pane has no such owner, so the registry builds its two + * hosts here. + */ +export function buildNavSectionRegistry( + app: NavSectionsApp, upper: SidebarUpperHandle, +): NavSectionRegistry { + // Each lower section gets its OWN search box and list. Before #487 both rendered + // through one shared pair that a section switch repainted — workable for two tabs + // in one pane, but it cannot satisfy "search/expansion/scroll state survives + // section and mode changes" for phase 3's drawer, and it cannot be moved into the + // drawer without taking the other section's content along. Two persistent pairs + // is the same shape the upper pane has had since #426. + app.dom.savedSearch = h('div', { class: 'saved-search' }); + app.dom.savedList = h('div', { class: 'saved-list' }); + app.dom.historySearch = h('div', { class: 'saved-search' }); + app.dom.historyList = h('div', { class: 'saved-list' }); + + // The initially-exposed section per pane matches what each pane's own default + // has always been (Databases above, Library below); the shell's exposure effects + // correct both on their first, registration-time run anyway. + const hosts: Readonly> = { + databases: upper.databasesHost, + dashboards: upper.dashboardsHost, + library: sectionHost('library', false, app.dom.savedSearch, app.dom.savedList), + history: sectionHost('history', true, app.dom.historySearch, app.dom.historyList), + }; + + const entries: readonly NavSectionEntry[] = LEFT_NAV_SECTIONS.map((section) => ({ + section, host: hosts[section], ...NAV_SECTION_META[section], + })); + const bySection = new Map(entries.map((entry) => [entry.section, entry])); + + // Each pane exposes its own sections. The upper pane DELEGATES to #426's + // `showRole`, which has owned that pair's exposure since it was written — this + // registry unifies how the containers *address* a section, it does not take over + // another module's hosts. The ternary keeps `showUpper` total without a cast: + // `pane` is a runtime value TypeScript cannot narrow the section union by, and + // only the upper pane's own two sections ever reach it. + const showUpper = (section: LeftNavigationSection): void => + upper.showRole(section === 'dashboards' ? 'dashboards' : 'databases'); + const showLower = (section: LeftNavigationSection): void => { + hosts.library.hidden = section !== 'library'; + hosts.history.hidden = section !== 'history'; + }; + const showers: Readonly void>> = { + upper: showUpper, lower: showLower, + }; + + return { + entries, + // `!`: `entries` is built from LEFT_NAV_SECTIONS, so the map has every member + // of the `LeftNavigationSection` union as a key. + entry: (section) => bySection.get(section)!, + showSection: (section) => { showers[NAV_SECTION_META[section].pane](section); }, + }; +} diff --git a/src/ui/saved-history.ts b/src/ui/saved-history.ts index 592fd8cf..6c7642a5 100644 --- a/src/ui/saved-history.ts +++ b/src/ui/saved-history.ts @@ -1,7 +1,16 @@ -// The bottom sidebar pane: a Saved / History switcher, a search box, and the -// two lists. Saved items support favorite (star), inline rename (pencil) and -// delete (trash). The search filters the active list (name/description/sql for +// The bottom sidebar pane: a Library / History switcher and, per section, its own +// search box and list. Saved items support favorite (star), inline rename (pencil) +// and delete (trash). The search filters the active list (name/description/sql for // Library, sql for History); it re-renders only the list so typing keeps focus. +// +// #487 phase 2 split the two sections' DOM: each renders into its own persistent +// search/list pair (`ui/nav-sections.ts` builds them and hosts them), where before +// both shared one pair that a section switch repainted. Everything below still +// renders ONLY the active section, exactly as it always did — the switcher's +// clear-the-search semantics are unchanged, and the inactive host simply keeps the +// DOM it last painted until it is shown again. What the split buys is that a +// container can move one section's live elements (phase 3's focused drawer) +// without taking the other section's content along. import { h } from './dom.js'; import { Icon } from './icons.js'; @@ -20,6 +29,8 @@ import { isQuerylessPanel } from '../core/panel-cfg.js'; import { queryDescription, queryFavorite, queryName, queryPanel, queryView } from '../core/saved-query.js'; import { libraryQueries } from '../dashboard/model/query-ownership.js'; import { openLibraryAssignMenu } from './library-assign-menu.js'; +import { NAV_SECTION_META, sectionForSidePanelKey, sidePanelKeyFor } from './nav-sections.js'; +import type { LowerNavigationSection } from './nav-sections.js'; import type { App } from './app.types.js'; import type { SavedQueryV2 } from '../generated/json-schema.types.js'; @@ -101,9 +112,27 @@ function libraryEntries(app: App): SavedQueryV2[] { return app.state.savedQueries.filter((query) => libraryIds.has(query.id)); } +/** + * The active section, in the registry's vocabulary. EVERY branch in this module + * goes through this one function rather than comparing `sidePanel` to `'saved'` + * directly (#487 phase 2). With two hosts, a reader that resolves an unrecognized + * value differently from the shell's exposure effect would expose one section's + * host and paint into the other's — a blank pane. `state.ts` also decodes the + * stored value at load, so the two guards are belt and braces on purpose. + */ +const activeSection = (app: App): LowerNavigationSection => + sectionForSidePanelKey(app.state.sidePanel.value); + +/** The ACTIVE section's own search box and list (#487 phase 2) — the two lower + * sections no longer share one pair. */ +const activeEls = (app: App): { search: HTMLElement | undefined; list: HTMLElement | undefined } => + activeSection(app) === 'library' + ? { search: app.dom.savedSearch, list: app.dom.savedList } + : { search: app.dom.historySearch, list: app.dom.historyList }; + export function renderSavedHistory(app: App): void { const tabsRow = app.dom.savedTabsRow; - const list = app.dom.savedList; + const list = activeEls(app).list; if (!tabsRow || !list) return; const state = app.state; // #427: the count is the LIBRARY count, not every stored query — the owned @@ -114,22 +143,28 @@ export function renderSavedHistory(app: App): void { // (plain) filter first, then set the sidePanel signal — its render effect runs // synchronously on assignment and must see the cleared filter. No manual // re-render call: the effect in createApp() repaints. - const switchTo = (panel: string): void => { + // + // #487 phase 2: the tab row speaks the registry's SECTION vocabulary and derives + // the persisted value once, through the one mapping — rather than repeating the + // `'library' means 'saved'` knowledge here. + const switchTo = (section: LowerNavigationSection): void => { + const panel = sidePanelKeyFor(section); state.libraryFilter = ''; app.prefs.save('sidePanel', panel); state.sidePanel.value = panel; }; + const active = activeSection(app); + const tab = (section: LowerNavigationSection, extra: Node | null): HTMLButtonElement => { + const meta = NAV_SECTION_META[section]; + return h('button', { + class: 'side-tab' + (active === section ? ' active' : ''), + onclick: () => switchTo(section), + }, meta.icon(), h('span', null, meta.label), extra); + }; tabsRow.replaceChildren( - h('button', { - class: 'side-tab' + (state.sidePanel.value === 'saved' ? ' active' : ''), - onclick: () => switchTo('saved'), - }, Icon.layers(), h('span', null, 'Library'), - count ? h('span', { class: 'side-count' }, '· ' + count) : null), - h('button', { - class: 'side-tab' + (state.sidePanel.value === 'history' ? ' active' : ''), - onclick: () => switchTo('history'), - }, Icon.history(), h('span', null, 'History')), + tab('library', count ? h('span', { class: 'side-count' }, '· ' + count) : null), + tab('history', null), ); renderSearch(app); @@ -140,25 +175,26 @@ export function renderSavedHistory(app: App): void { * the search input, so the caret/focus survive filtering). */ function renderList(app: App): void { // `!`: every caller (renderSavedHistory, renderSearch below) only reaches - // this after confirming `app.dom.savedList` is mounted. - const list = app.dom.savedList!; + // this after confirming the active section's list is mounted. + const list = activeEls(app).list!; list.replaceChildren(); - if (app.state.sidePanel.value === 'saved') renderSaved(app, list); + if (activeSection(app) === 'library') renderSaved(app, list); else renderHistory(app, list); } /** - * Render the search box into `app.dom.savedSearch` (built once per full render; - * a tab with no items shows nothing). Its `input` handler mutates + * Render the search box into the ACTIVE section's own search host (built once per + * full render; a section with no items shows nothing). Its `input` handler mutates * `state.libraryFilter` and re-renders only the list, so it stays focused. */ function renderSearch(app: App): void { - const box = app.dom.savedSearch; + const box = activeEls(app).search; if (!box) return; const state = app.state; // Gated on the LIBRARY count (#427): a workspace whose every query is owned // has an empty list, so a search box over it would filter nothing. - const hasItems = state.sidePanel.value === 'saved' + const isLibrary = activeSection(app) === 'library'; + const hasItems = isLibrary ? libraryEntries(app).length > 0 : state.history.length > 0; box.replaceChildren(); @@ -166,7 +202,7 @@ function renderSearch(app: App): void { const input = h('input', { class: 'sv-search-input', type: 'text', - placeholder: state.sidePanel.value === 'saved' ? 'Search library queries…' : 'Search history…', + placeholder: isLibrary ? 'Search library queries…' : 'Search history…', value: state.libraryFilter, }); const clear = h('button', { class: 'sv-search-clear', title: 'Clear' }, Icon.close()); diff --git a/src/ui/sidebar-upper.ts b/src/ui/sidebar-upper.ts index 9d7a7f2e..bacc08d5 100644 --- a/src/ui/sidebar-upper.ts +++ b/src/ui/sidebar-upper.ts @@ -12,11 +12,19 @@ // The tab row reuses the lower switcher's `.side-tabs`/`.side-tab`/`.side-count` // vocabulary verbatim, as #426 asks and DESIGN.md requires (one tab/segmented // control language across the app). +// +// #487 phase 2 generalized this pattern to all four navigation sections: both +// hosts below now carry the shared `.nav-section-host` class and a `data-section` +// attribute, so the lower pane's Library/History hosts and phase 3's focused +// drawer need no per-pane layout rule. `ui/nav-sections.ts` composes this builder +// as the registry's upper half and delegates upper-pane exposure to `showRole` +// below — this module still owns its own two hosts. import { h } from './dom.js'; import { Icon } from './icons.js'; import { renderDashboardTree, cancelDashboardTreeClicks, type DashboardTreeApp } from './dashboard-tree.js'; import { readTreeUi, setTreeSearch } from '../core/dashboard-tree-ui-state.js'; +import { NAV_SECTION_META } from './nav-sections.js'; import type { AppState } from '../state.js'; import type { AppDom } from './app.types.js'; @@ -51,7 +59,7 @@ export function buildSidebarUpper( app.dom.upperRoleTabs = h('div', { class: 'side-tabs upper-role-tabs' }); - const databasesHost = h('div', { class: 'upper-role-host', 'data-role': 'databases' }, ...databasesContent); + const databasesHost = h('div', { class: 'nav-section-host', 'data-section': 'databases' }, ...databasesContent); // Built ONCE and never inside the repainted row list, so typing keeps the caret // (the same reason `saved-history.ts` builds its search box outside `renderList`). @@ -73,7 +81,7 @@ export function buildSidebarUpper( role: 'tree', 'aria-label': 'Dashboards', }); - const dashboardsHost = h('div', { class: 'upper-role-host', 'data-role': 'dashboards', hidden: true }, + const dashboardsHost = h('div', { class: 'nav-section-host', 'data-section': 'dashboards', hidden: true }, h('div', { class: 'schema-search' }, h('div', { class: 'search-wrap' }, Icon.search(), app.dom.dashboardSearchInput)), app.dom.dashboardTreeList); @@ -102,7 +110,12 @@ export function renderUpperRoleTabs(app: SidebarUpperApp): void { const databaseCount = state.schemaError.value || schema === null ? null : schema.length; const dashboardCount = app.currentWorkspace?.dashboards?.length ?? 0; - const tab = (role: UpperRole, label: string, icon: SVGElement, count: number | null): HTMLButtonElement => + // #487 phase 2: the label and the icon come from the registry, not from here. + // Both wide switchers and phase 3's rail present the same four sections, so a + // second copy of either would let the presentations drift — which is the whole + // reason `NAV_SECTION_META` exists. (No import cycle: `nav-sections.ts` imports + // only this module's *type*, which esbuild erases.) + const tab = (role: UpperRole, count: number | null): HTMLButtonElement => h('button', { class: 'side-tab' + (active === role ? ' active' : ''), type: 'button', @@ -113,11 +126,11 @@ export function renderUpperRoleTabs(app: SidebarUpperApp): void { cancelDashboardTreeClicks(app); state.upperRole.value = role; }, - }, icon, h('span', null, label), + }, NAV_SECTION_META[role].icon(), h('span', null, NAV_SECTION_META[role].label), count === null ? null : h('span', { class: 'side-count' }, '· ' + count)); row.replaceChildren( - tab('databases', 'Databases', Icon.database(), databaseCount), - tab('dashboards', 'Dashboards', Icon.dashboard(), dashboardCount), + tab('databases', databaseCount), + tab('dashboards', dashboardCount), ); } diff --git a/tests/e2e/dashboard-membership.html b/tests/e2e/dashboard-membership.html index 0291f2d2..44b6a2e0 100644 --- a/tests/e2e/dashboard-membership.html +++ b/tests/e2e/dashboard-membership.html @@ -93,13 +93,21 @@ const tabs = document.createElement('div'); const search = document.createElement('div'); const list = document.createElement('div'); + // #487 phase 2: History has its own persistent search/list pair now. This + // harness only ever shows the Library section, so nothing here reads these + // two — they are mounted so the fixture matches the real shell's shape and a + // future spec that switches sections does not silently render into nothing. + const historySearch = document.createElement('div'); + const historyList = document.createElement('div'); const open = document.createElement('button'); open.textContent = 'Open Dashboard'; open.onclick = () => { void renderDashboard(app, dashboardTarget()); }; - root.replaceChildren(tabs, search, list, open, headerSlot, dashboardHost); + root.replaceChildren(tabs, search, list, historySearch, historyList, open, headerSlot, dashboardHost); app.dom.savedTabsRow = tabs; app.dom.savedSearch = search; app.dom.savedList = list; + app.dom.historySearch = historySearch; + app.dom.historyList = historyList; renderSavedHistory(app); } diff --git a/tests/e2e/dashboard-tree.spec.js b/tests/e2e/dashboard-tree.spec.js index a1f3d489..0c76dea1 100644 --- a/tests/e2e/dashboard-tree.spec.js +++ b/tests/e2e/dashboard-tree.spec.js @@ -24,22 +24,22 @@ test.describe('upper sidebar role switcher', () => { // The schema stub loads two databases; the seed has three Dashboards. await expect(roleTab(page, 'Databases')).toContainText('· 2'); await expect(roleTab(page, 'Dashboards')).toContainText('· 3'); - await expect(page.locator('.upper-role-host[data-role="databases"]')).toBeVisible(); - await expect(page.locator('.upper-role-host[data-role="dashboards"]')).toBeHidden(); + await expect(page.locator('.nav-section-host[data-section="databases"]')).toBeVisible(); + await expect(page.locator('.nav-section-host[data-section="dashboards"]')).toBeHidden(); }); test('a hidden role host contributes NO layout, so the visible one owns the pane', async ({ page }) => { await open(page); const geometry = await page.evaluate(() => { const pane = document.querySelector('.schema-pane'); - const databases = document.querySelector('.upper-role-host[data-role="databases"]'); + const databases = document.querySelector('.nav-section-host[data-section="databases"]'); const paneBox = pane.getBoundingClientRect(); const dbBox = databases.getBoundingClientRect(); return { paneHeight: paneBox.height, dbHeight: dbBox.height, tabsHeight: document.querySelector('.upper-role-tabs').getBoundingClientRect().height, - hiddenDisplay: getComputedStyle(document.querySelector('.upper-role-host[data-role="dashboards"]')).display, + hiddenDisplay: getComputedStyle(document.querySelector('.nav-section-host[data-section="dashboards"]')).display, }; }); expect(geometry.hiddenDisplay).toBe('none'); @@ -49,21 +49,70 @@ test.describe('upper sidebar role switcher', () => { expect(Math.abs(geometry.paneHeight - geometry.tabsHeight - geometry.dbHeight)).toBeLessThan(2); }); + // #487 phase 2 wrapped the LOWER pane's two sections in section hosts as well, so + // `.saved-search`/`.saved-list` now sit one level deeper than the pane. happy-dom + // computes no layout, so the unit suite cannot see whether the scroller still + // fills its host — and this pane is the one the phase actually changed. + test('the lower pane\'s exposed section host fills it, and its list still scrolls', async ({ page }) => { + await open(page); + const geometry = await page.evaluate(() => { + const box = (selector) => { + const rect = document.querySelector(selector).getBoundingClientRect(); + return { top: rect.top, height: rect.height, width: rect.width }; + }; + const library = document.querySelector('.nav-section-host[data-section="library"]'); + const list = library.querySelector('.saved-list'); + // Force the scroller past its host so overflow is actually exercised. + for (let i = 0; i < 40; i += 1) { + const row = document.createElement('div'); + row.className = 'saved-row'; + row.textContent = 'filler row ' + i; + list.appendChild(row); + } + const listBox = list.getBoundingClientRect(); + return { + pane: box('.saved-pane'), + tabsHeight: box('.saved-pane .side-tabs').height, + host: { top: listBox.top, height: library.getBoundingClientRect().height }, + hostTop: library.getBoundingClientRect().top, + listHeight: listBox.height, + searchHeight: library.querySelector('.saved-search').getBoundingClientRect().height, + overflowY: getComputedStyle(list).overflowY, + scrolls: list.scrollHeight > list.clientHeight, + horizontalOverflow: list.scrollWidth > list.clientWidth, + hiddenDisplay: getComputedStyle(document.querySelector('.nav-section-host[data-section="history"]')).display, + }; + }); + + expect(geometry.hiddenDisplay).toBe('none'); + // The exposed host starts below the tab row and fills the rest of the pane. + expect(Math.abs(geometry.hostTop - (geometry.pane.top + geometry.tabsHeight))).toBeLessThan(2); + expect(Math.abs(geometry.pane.height - geometry.tabsHeight - geometry.host.height)).toBeLessThan(2); + // Inside the host, the search box keeps its intrinsic height and the list + // takes the remainder — the flex chain the extra wrapper could have broken. + expect(geometry.searchHeight).toBeGreaterThan(0); + expect(Math.abs(geometry.host.height - geometry.searchHeight - geometry.listHeight)).toBeLessThan(2); + // Still a scroller, and still no sideways overflow at the sidebar's width. + expect(geometry.overflowY).toBe('auto'); + expect(geometry.scrolls).toBe(true); + expect(geometry.horizontalOverflow).toBe(false); + }); + test('switching roles preserves the schema search text, scroll and expansion', async ({ page }) => { await open(page); - const schemaSearch = page.locator('.upper-role-host[data-role="databases"] input'); + const schemaSearch = page.locator('.nav-section-host[data-section="databases"] input'); await schemaSearch.fill('events'); // Expand a database so there is lazily-built row state to lose. - await page.locator('.upper-role-host[data-role="databases"] .tree-row').first().click(); - const rowsBefore = await page.locator('.upper-role-host[data-role="databases"] .tree-row').count(); + await page.locator('.nav-section-host[data-section="databases"] .tree-row').first().click(); + const rowsBefore = await page.locator('.nav-section-host[data-section="databases"] .tree-row').count(); await roleTab(page, 'Dashboards').click(); - await expect(page.locator('.upper-role-host[data-role="dashboards"]')).toBeVisible(); + await expect(page.locator('.nav-section-host[data-section="dashboards"]')).toBeVisible(); await roleTab(page, 'Databases').click(); // Preserved BY CONSTRUCTION: the host is never rebuilt, only un-hidden. await expect(schemaSearch).toHaveValue('events'); - expect(await page.locator('.upper-role-host[data-role="databases"] .tree-row').count()).toBe(rowsBefore); + expect(await page.locator('.nav-section-host[data-section="databases"] .tree-row').count()).toBe(rowsBefore); }); test('the sidebar width and the upper/lower splitter survive a role switch', async ({ page }) => { @@ -449,7 +498,7 @@ test.describe('Dashboard hierarchy tree', () => { test('search narrows the tree and clearing it restores the prior state', async ({ page }) => { await open(page); await roleTab(page, 'Dashboards').click(); - const search = page.locator('.upper-role-host[data-role="dashboards"] input'); + const search = page.locator('.nav-section-host[data-section="dashboards"] input'); await search.fill('zone'); // The variable's own NAME matches, so its ancestors are exposed. await expect(page.locator('.dash-tree-row .label')).toHaveText(['Sales revenue', 'Variables', 'zone', 'Panels']); diff --git a/tests/helpers/fake-app.ts b/tests/helpers/fake-app.ts index fae310d8..20edb92c 100644 --- a/tests/helpers/fake-app.ts +++ b/tests/helpers/fake-app.ts @@ -848,8 +848,13 @@ export function makeApp>(override schemaList: document.createElement('div'), resultsRegion: document.createElement('div'), savedTabsRow: document.createElement('div'), + // #487 phase 2: the lower pane's two sections own separate persistent + // search/list pairs, so a fixture needs both — `renderSavedHistory` targets + // whichever pair the active `sidePanel` names. savedSearch: document.createElement('div'), savedList: document.createElement('div'), + historySearch: document.createElement('div'), + historyList: document.createElement('div'), saveBtn: document.createElement('button'), }, actions: { diff --git a/tests/unit/app-shell.test.ts b/tests/unit/app-shell.test.ts index 2d77f7b6..01c2e817 100644 --- a/tests/unit/app-shell.test.ts +++ b/tests/unit/app-shell.test.ts @@ -25,6 +25,130 @@ function mount() { return { app, handle, loadSchema, loadReference }; } +/** Every mounted section host, keyed by its `data-section`. */ +const hosts = (root: ParentNode): Record => Object.fromEntries( + [...root.querySelectorAll('.nav-section-host')].map((h) => [h.dataset.section!, h]), +); + +// #487 phase 2 — `## Tests` → "Wide state" bullets 1-4. The wide sidebar is now +// composed out of the navigation section registry rather than out of hard-wired +// per-pane DOM, and this is the gate on "existing navigation behaviour is +// unchanged": the same two panes, the same switchers, the same splitters, and the +// rail that phase 3 introduces is not here yet. +describe('mountAppShell wide navigation (#487 phase 2)', () => { + it('renders no rail — the sidebar is the only container hosting a section', () => { + const { app, handle } = mount(); + const sidebar = app.root.querySelector('.sidebar')!; + + expect(app.root.querySelectorAll('.sidebar')).toHaveLength(1); + // Stated positively, so it is falsifiable TODAY rather than an assertion about + // class names no code emits yet: every section host lives inside the one + // sidebar, and the `.main-row` holds only the sidebar, its width handle and the + // two work-surface hosts. Phase 3 moving a host into a rail-side drawer — or + // adding a second navigation column — has to fail this. + const hosts = [...app.root.querySelectorAll('.nav-section-host')]; + expect(hosts).toHaveLength(4); + expect(hosts.every((host) => sidebar.contains(host))).toBe(true); + expect([...app.root.querySelector('.main-row')!.children].map((el) => el.className)) + .toEqual(['sidebar', 'col-resize', 'query-host', 'dashboard-host']); + handle.dispose(); + }); + + it('renders the upper and lower panes together, one exposed host each', () => { + const { app, handle } = mount(); + const sidebar = app.root.querySelector('.sidebar')!; + const panes = [...sidebar.querySelectorAll('.side-pane')].map((p) => p.className); + + expect(panes).toEqual(['side-pane schema-pane', 'side-pane saved-pane']); + const host = hosts(sidebar); + // Both panes are exposed at once — this is what makes a "one of four" exposure + // rule wrong for the wide presentation and a per-pane rule right. + expect(host.databases.hidden).toBe(false); + expect(host.dashboards.hidden).toBe(true); + expect(host.library.hidden).toBe(false); + expect(host.history.hidden).toBe(true); + handle.dispose(); + }); + + it('keeps both switchers and both splitters', () => { + const { app, handle } = mount(); + const sidebar = app.root.querySelector('.sidebar')!; + + // Upper role tabs (#426), lower Library/History tabs (#427 labels). + expect(sidebar.querySelectorAll('.upper-role-tabs')).toHaveLength(1); + expect([...sidebar.querySelectorAll('.side-tabs')]).toHaveLength(2); + expect([...app.dom.savedTabsRow!.querySelectorAll('.side-tab')].map((t) => t.textContent)) + .toEqual(['Library', 'History']); + // The horizontal upper/lower splitter and the vertical sidebar-width handle. + expect(sidebar.querySelectorAll('.row-resize.side-split')).toHaveLength(1); + expect(app.root.querySelectorAll('.col-resize')).toHaveLength(1); + handle.dispose(); + }); + + it('mounts EXACTLY ONE host per section, each holding that section\'s own elements', () => { + const { app, handle } = mount(); + const host = hosts(app.root); + + expect(Object.keys(host).sort()).toEqual(['dashboards', 'databases', 'history', 'library']); + expect(app.root.querySelectorAll('.nav-section-host')).toHaveLength(4); + // The section's content is the live DOM other modules render into, reached + // through `app.dom` exactly as before — the registry hosts it, it does not + // copy or re-create it. + expect(host.databases.contains(app.dom.schemaList!)).toBe(true); + expect(host.dashboards.contains(app.dom.dashboardTreeList!)).toBe(true); + expect(host.library.contains(app.dom.savedList!)).toBe(true); + expect(host.history.contains(app.dom.historyList!)).toBe(true); + // Each list belongs to exactly one host: no section renders into another's. + expect(host.history.contains(app.dom.savedList!)).toBe(false); + expect(host.library.contains(app.dom.historyList!)).toBe(false); + handle.dispose(); + }); + + it('switches the exposed lower host on sidePanel without rebuilding either', () => { + const { app, handle } = mount(); + const host = hosts(app.root); + const libraryList = app.dom.savedList!; + const historyList = app.dom.historyList!; + const marker = libraryList.appendChild(document.createElement('span')); + + app.state.sidePanel.value = 'history'; + expect(host.library.hidden).toBe(true); + expect(host.history.hidden).toBe(false); + // A hidden host keeps its DOM: History's repaint went into History's OWN list + // and left the Library's content standing. Before the split both sections + // rendered through one pair, so this content could not have survived. + expect(libraryList.contains(marker)).toBe(true); + expect(historyList.textContent).toContain('No history yet.'); + + app.state.sidePanel.value = 'saved'; + expect(host.library.hidden).toBe(false); + expect(host.history.hidden).toBe(true); + // The same element objects throughout — never rebuilt, only exposed or hidden. + // That identity is what makes phase 3's mode change a MOVE of live DOM. + expect(app.dom.savedList).toBe(libraryList); + expect(app.dom.historyList).toBe(historyList); + // Becoming active DOES repaint the section, exactly as it always has: the + // switcher clears the shared search filter, so the list is rebuilt from + // scratch. #487 phase 3 owns whether a drawer should preserve it instead. + expect(libraryList.contains(marker)).toBe(false); + handle.dispose(); + }); + + it('switches the exposed upper host on upperRole', () => { + const { app, handle } = mount(); + const host = hosts(app.root); + + app.state.upperRole.value = 'dashboards'; + expect(host.databases.hidden).toBe(true); + expect(host.dashboards.hidden).toBe(false); + + app.state.upperRole.value = 'databases'; + expect(host.databases.hidden).toBe(false); + expect(host.dashboards.hidden).toBe(true); + handle.dispose(); + }); +}); + describe('mountAppShell authentication host', () => { it('exposes one stable, hidden, labelled host immediately below the header', () => { const { app, handle, loadSchema, loadReference } = mount(); diff --git a/tests/unit/left-nav-layout.test.ts b/tests/unit/left-nav-layout.test.ts index 028a468f..d393fb24 100644 --- a/tests/unit/left-nav-layout.test.ts +++ b/tests/unit/left-nav-layout.test.ts @@ -25,6 +25,7 @@ import { effectiveLeftNavigationLayout, isLeftNavigationSection, leftNavigationLayoutIsCoherent, leftNavigationSeparatorAria, leftNavigationWidthPx, normalizeLeftNavigationLayout, resolveLeftNavigationDrag, resolveLeftNavigationKey, resolveRailActivation, resolveRailOpen, + sectionForSidePanelKey, sidePanelKeyFor, } from '../../src/core/left-nav-layout.js'; import type { LeftNavigationLayout } from '../../src/core/left-nav-layout.js'; @@ -608,6 +609,41 @@ describe('decodeStoredPx', () => { }); }); +// The `'library' ↔ 'saved'` bridge (#487 phase 2). It lives here, beside the other +// decoders, because `state.ts` applies it at the load boundary and must not import +// from `src/ui/`; `ui/nav-sections.ts` is its UI-side owner and re-exports it. +describe('sidePanelKeyFor / sectionForSidePanelKey', () => { + it('maps the Library section to the value `asb:sidePanel` has always stored', () => { + // #427 renamed the visible label and deliberately left the stored value alone. + expect(sidePanelKeyFor('library')).toBe('saved'); + expect(sidePanelKeyFor('history')).toBe('history'); + }); + + it('round-trips both sections', () => { + expect(sectionForSidePanelKey(sidePanelKeyFor('library'))).toBe('library'); + expect(sectionForSidePanelKey(sidePanelKeyFor('history'))).toBe('history'); + }); + + it('resolves a missing, invalid or obsolete stored value to Library, the default', () => { + expect(sectionForSidePanelKey('saved')).toBe('library'); + expect(sectionForSidePanelKey('history')).toBe('history'); + // The fallback direction is load-bearing, and it is a deliberate FIX rather + // than preserved behaviour: before the lower pane's two sections had separate + // hosts, every reader compared `=== 'saved'`, so an unrecognized value fell + // through to the History branch — neither the documented default nor the + // value's own meaning. With two hosts, two readers disagreeing here exposes + // one section's host while painting into the other's, i.e. a blank pane. + expect(sectionForSidePanelKey('queries')).toBe('library'); + expect(sectionForSidePanelKey('')).toBe('library'); + expect(sectionForSidePanelKey(undefined)).toBe('library'); + expect(sectionForSidePanelKey(null)).toBe('library'); + expect(sectionForSidePanelKey(0)).toBe('library'); + // Not merely "anything truthy is History": only the exact key is. + expect(sectionForSidePanelKey('History')).toBe('library'); + expect(sectionForSidePanelKey(' history ')).toBe('library'); + }); +}); + // Documented, deliberately pinned, and phase 3's to change: the remembered wide // width depends on which pointer samples the browser happened to deliver, because // one field is doing duty as both the live drag width and the restore memory. diff --git a/tests/unit/nav-sections.test.ts b/tests/unit/nav-sections.test.ts new file mode 100644 index 00000000..38dda2b4 --- /dev/null +++ b/tests/unit/nav-sections.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + buildNavSectionRegistry, NAV_SECTION_META, sectionForSidePanelKey, sidePanelKeyFor, +} from '../../src/ui/nav-sections.js'; +import { + sectionForSidePanelKey as coreSectionFor, sidePanelKeyFor as coreKeyFor, +} from '../../src/core/left-nav-layout.js'; +import type { NavSectionsApp } from '../../src/ui/nav-sections.js'; +import { LEFT_NAV_SECTIONS } from '../../src/core/left-nav-layout.js'; +import type { SidebarUpperHandle } from '../../src/ui/sidebar-upper.js'; +import { h } from '../../src/ui/dom.js'; + +/** + * A stand-in for #426's upper pane. The registry ADOPTS those two hosts and + * delegates their exposure back to `showRole`, so a fake handle is exactly the + * right seam here — `sidebar-upper.test.ts` covers the real one, and this spec + * proves the delegation rather than re-testing it. + */ +const upperHandle = (): SidebarUpperHandle & { showRole: ReturnType } => { + const databasesHost = h('div', { class: 'nav-section-host', 'data-section': 'databases' }); + const dashboardsHost = h('div', { class: 'nav-section-host', 'data-section': 'dashboards', hidden: true }); + const showRole = vi.fn((role: 'databases' | 'dashboards') => { + databasesHost.hidden = role !== 'databases'; + dashboardsHost.hidden = role !== 'dashboards'; + }); + return { databasesHost, dashboardsHost, showRole }; +}; + +const build = () => { + const app: NavSectionsApp = { dom: {} }; + const upper = upperHandle(); + const registry = buildNavSectionRegistry(app, upper); + return { app, upper, registry }; +}; + +describe('the library ↔ saved vocabulary bridge', () => { + // The bridge's own behaviour is specified in `left-nav-layout.test.ts` — the + // pure decode lives in `core/` so `state.ts` can apply it at the load boundary. + // What matters HERE is that the registry re-exports that one implementation + // instead of carrying a second copy: a UI caller must be unable to reach a + // different answer than the state layer did. + it('re-exports the core implementation, not a second copy', () => { + expect(sidePanelKeyFor).toBe(coreKeyFor); + expect(sectionForSidePanelKey).toBe(coreSectionFor); + }); +}); + +describe('NAV_SECTION_META', () => { + it('describes all four sections with a distinct label and an icon FACTORY', () => { + expect(Object.keys(NAV_SECTION_META).sort()).toEqual([...LEFT_NAV_SECTIONS].sort()); + const labels = LEFT_NAV_SECTIONS.map((section) => NAV_SECTION_META[section].label); + expect(labels).toEqual(['Databases', 'Dashboards', 'Library', 'History']); + }); + + it('carries #487\'s own accessible labels for the icon-only rail launchers', () => { + // Verbatim from the issue's "Rail state" table. Pinned as exact strings + // because a launcher has to announce what activating it DOES — a `toBeTruthy` + // assertion would pass for a copy-paste of the wrong section's name, and + // phase 3 would then either announce the wrong thing or hard-code the right + // thing beside the registry. + expect(LEFT_NAV_SECTIONS.map((s) => NAV_SECTION_META[s].accessibleLabel)).toEqual([ + 'Open Databases navigation', + 'Open Dashboards navigation', + 'Open Library navigation', + 'Open query History', + ]); + // Distinct from the tab label in every case, which is why it is its own field. + for (const section of LEFT_NAV_SECTIONS) { + expect(NAV_SECTION_META[section].accessibleLabel).not.toBe(NAV_SECTION_META[section].label); + } + }); + + it('mints a FRESH icon per call, so two presentations can show one section at once', () => { + // One SVG node cannot be in the wide switcher and the rail launcher + // simultaneously — appending it to the second would remove it from the first. + const first = NAV_SECTION_META.library.icon(); + const second = NAV_SECTION_META.library.icon(); + expect(first).not.toBe(second); + expect(first.tagName).toBe(second.tagName); + }); + + it('places two sections in each wide pane', () => { + const panes = LEFT_NAV_SECTIONS.map((section) => NAV_SECTION_META[section].pane); + expect(panes).toEqual(['upper', 'upper', 'lower', 'lower']); + }); +}); + +describe('buildNavSectionRegistry', () => { + it('exposes one entry per section, in rail order', () => { + const { registry } = build(); + expect(registry.entries.map((entry) => entry.section)).toEqual([...LEFT_NAV_SECTIONS]); + for (const section of LEFT_NAV_SECTIONS) { + const entry = registry.entry(section); + expect(entry.section).toBe(section); + expect(entry.label).toBe(NAV_SECTION_META[section].label); + expect(entry.pane).toBe(NAV_SECTION_META[section].pane); + } + }); + + it('gives each section EXACTLY ONE host, and adopts the upper pane\'s two', () => { + const { upper, registry } = build(); + const hosts = registry.entries.map((entry) => entry.host); + + expect(new Set(hosts).size).toBe(4); + // The upper pane's hosts are #426's own elements, not copies — a copy would + // silently strand every schema/Dashboard behaviour bound to the originals. + expect(registry.entry('databases').host).toBe(upper.databasesHost); + expect(registry.entry('dashboards').host).toBe(upper.dashboardsHost); + for (const host of hosts) { + expect(host.classList.contains('nav-section-host')).toBe(true); + } + expect(registry.entry('library').host.dataset.section).toBe('library'); + expect(registry.entry('history').host.dataset.section).toBe('history'); + }); + + it('builds the lower pane\'s two search/list pairs and hands them to app.dom', () => { + const { app, registry } = build(); + const library = registry.entry('library').host; + const history = registry.entry('history').host; + + expect([...library.children]).toEqual([app.dom.savedSearch, app.dom.savedList]); + expect([...history.children]).toEqual([app.dom.historySearch, app.dom.historyList]); + // Separate elements, not one shared pair — that is the split. + expect(app.dom.savedList).not.toBe(app.dom.historyList); + expect(app.dom.savedSearch).not.toBe(app.dom.historySearch); + expect(app.dom.savedSearch!.className).toBe('saved-search'); + expect(app.dom.historySearch!.className).toBe('saved-search'); + expect(app.dom.savedList!.className).toBe('saved-list'); + expect(app.dom.historyList!.className).toBe('saved-list'); + }); + + it('starts each pane on its historical default section', () => { + const { registry } = build(); + expect(registry.entry('databases').host.hidden).toBe(false); + expect(registry.entry('dashboards').host.hidden).toBe(true); + expect(registry.entry('library').host.hidden).toBe(false); + expect(registry.entry('history').host.hidden).toBe(true); + }); + + it('exposes exactly one section per pane, leaving the OTHER pane alone', () => { + const { registry } = build(); + // `!!`: `hidden` is typed `boolean | 'until-found'` in lib.dom; the registry + // only ever assigns booleans, and the shape of the assertion is what matters. + const hidden = (): boolean[] => registry.entries.map((entry) => !!entry.host.hidden); + + registry.showSection('history'); + // The upper pane is untouched: the wide sidebar shows one upper AND one lower + // section at once, so a global "one of four" would blank half the sidebar. + expect(hidden()).toEqual([false, true, true, false]); + + registry.showSection('dashboards'); + expect(hidden()).toEqual([true, false, true, false]); + + registry.showSection('library'); + expect(hidden()).toEqual([true, false, false, true]); + + registry.showSection('databases'); + expect(hidden()).toEqual([false, true, false, true]); + }); + + it('delegates upper-pane exposure to #426\'s showRole rather than setting hidden itself', () => { + const { upper, registry } = build(); + + registry.showSection('dashboards'); + registry.showSection('databases'); + expect(upper.showRole.mock.calls).toEqual([['dashboards'], ['databases']]); + + // A lower section must not reach the upper pane's owner at all. + registry.showSection('history'); + expect(upper.showRole).toHaveBeenCalledTimes(2); + }); + + it('never rebuilds a host when exposure changes', () => { + const { registry } = build(); + const before = registry.entries.map((entry) => entry.host); + const marker = h('div', { class: 'sv-search-input' }); + registry.entry('history').host.appendChild(marker); + + registry.showSection('history'); + registry.showSection('library'); + registry.showSection('history'); + + expect(registry.entries.map((entry) => entry.host)).toEqual(before); + // The hidden host kept its DOM — which is what makes "wide and focused + // presentations share and preserve all navigation state" structural. + expect(registry.entry('history').host.contains(marker)).toBe(true); + }); +}); diff --git a/tests/unit/saved-history.test.ts b/tests/unit/saved-history.test.ts index c3a3e422..65ddbc0f 100644 --- a/tests/unit/saved-history.test.ts +++ b/tests/unit/saved-history.test.ts @@ -36,6 +36,12 @@ const byTitle = (root: ParentNode, t: string): HTMLElement => const savedList = (app: App): HTMLElement => app.dom.savedList!; const savedTabsRow = (app: App): HTMLElement => app.dom.savedTabsRow!; const savedSearch = (app: App): HTMLElement => app.dom.savedSearch!; +// #487 phase 2: the History section renders into its OWN persistent pair, so every +// History assertion below names those elements explicitly. Reading through an +// "active section" helper instead would pass even if the renderer painted History +// rows into the Library's list — which is the one thing this split has to prevent. +const historyList = (app: App): HTMLElement => app.dom.historyList!; +const historySearch = (app: App): HTMLElement => app.dom.historySearch!; describe('renderSavedHistory', () => { it('no-ops without mounts', () => { @@ -558,7 +564,7 @@ describe('renderSavedHistory', () => { const app = makeApp(); app.state.sidePanel.value = 'history'; renderSavedHistory(app); - expect(savedList(app).textContent).toContain('No history yet.'); + expect(historyList(app).textContent).toContain('No history yet.'); }); it('history: lists rows (with + without row count) and loads on click', () => { @@ -569,7 +575,7 @@ describe('renderSavedHistory', () => { { id: 'h2', sql: 'INSERT …', ts: Date.now(), rows: null, ms: 1 }, ]; renderSavedHistory(app); - const rows = qsa(savedList(app), '.history-row'); + const rows = qsa(historyList(app), '.history-row'); expect(rows).toHaveLength(2); expect(rows[0].textContent).toContain('3 rows'); expect(rows[1].textContent).not.toContain('rows'); @@ -583,7 +589,7 @@ describe('renderSavedHistory', () => { app.state.sidePanel.value = 'history'; app.state.history = [{ id: 'h1', sql: 'DROP TABLE t', ts: Date.now(), rows: null, ms: 1 }]; renderSavedHistory(app); - click(qs(savedList(app), '.history-row')); + click(qs(historyList(app), '.history-row')); expect(app.actions.loadIntoNewTab).toHaveBeenCalledWith('From history', 'DROP TABLE t'); expect(app.actions.run).not.toHaveBeenCalled(); }); @@ -596,10 +602,27 @@ describe('renderSavedHistory', () => { { id: 'h2', sql: 'SELECT 2', ts: Date.now(), rows: 1, ms: 2 }, ]; renderSavedHistory(app); - click(qs(savedList(app), '.history-row .del')); + click(qs(historyList(app), '.history-row .del')); expect(app.state.history.map((e: HistoryEntry) => e.id)).toEqual(['h2']); expect(app.actions.loadIntoNewTab).not.toHaveBeenCalled(); - expect(qsa(savedList(app), '.history-row')).toHaveLength(1); + expect(qsa(historyList(app), '.history-row')).toHaveLength(1); + }); + + it('resolves an out-of-union sidePanel the SAME way the shell exposes it', () => { + // `state.ts` decodes `asb:sidePanel` at load, so this value cannot come from + // storage — but the signal is settable by any module, and the two readers must + // not be able to disagree. `app-shell.ts`'s exposure effect resolves anything + // that is not 'history' to the Library host; this renderer has to paint into + // the LIBRARY pair for the same input, or the pane shows an exposed empty host + // while the content sits inside the hidden one. + const app = makeApp(); + (app.state.sidePanel as { value: string }).value = 'queries'; + setSaved(app, [{ id: 's1', name: 'Q1', sql: 'SELECT 1' }]); + renderSavedHistory(app); + + expect(qsa(savedList(app), '.saved-row')).toHaveLength(1); + expect(historyList(app).children.length).toBe(0); + expect(qsa(savedTabsRow(app), '.side-tab')[0].classList.contains('active')).toBe(true); }); it('switching panels persists the choice', () => { @@ -639,9 +662,16 @@ describe('renderSavedHistory — search/filter', () => { expect(() => renderSavedHistory(app)).not.toThrow(); }); - it('collapses the search box when the active list is empty', () => { - const app = makeApp(); - app.state.sidePanel.value = 'saved'; + it('collapses the search box when the active list becomes empty', () => { + // Populate the box FIRST, then empty the list and re-render. Asserting + // `children.length === 0` on a freshly built fixture element proves nothing — + // `makeApp()` creates `savedSearch` empty, so that assertion held even if + // `renderSearch` never touched this element at all (which, since the two lower + // sections own separate boxes, is now a reachable bug rather than a hypothetical). + const app = savedApp(); + expect(savedSearch(app).querySelector('.sv-search-input')).not.toBeNull(); + + app.state.savedQueries = []; renderSavedHistory(app); expect(savedSearch(app).children.length).toBe(0); // :empty → hidden via CSS expect(savedSearch(app).querySelector('.sv-search-input')).toBeNull(); @@ -653,7 +683,8 @@ describe('renderSavedHistory — search/filter', () => { app.state.sidePanel.value = 'history'; app.state.history = [{ id: 'h1', sql: 'SELECT 1', ts: Date.now(), rows: 1, ms: 1 }]; renderSavedHistory(app); - expect(input(app).placeholder).toBe('Search history…'); + expect(qs(historySearch(app), '.sv-search-input').placeholder) + .toBe('Search history…'); }); it('filters saved by name / description / sql, case-insensitively, reusing the input node', () => { @@ -691,12 +722,12 @@ describe('renderSavedHistory — search/filter', () => { { id: 'h2', sql: 'INSERT INTO t', ts: Date.now(), rows: null, ms: 1 }, ]; renderSavedHistory(app); - const i = qs(savedSearch(app), '.sv-search-input'); + const i = qs(historySearch(app), '.sv-search-input'); i.value = 'insert'; i.dispatchEvent(new Event('input', { bubbles: true })); - expect(qsa(savedList(app), '.history-row')).toHaveLength(1); - expect(savedList(app).textContent).toContain('INSERT INTO t'); + expect(qsa(historyList(app), '.history-row')).toHaveLength(1); + expect(historyList(app).textContent).toContain('INSERT INTO t'); i.value = 'nope'; i.dispatchEvent(new Event('input', { bubbles: true })); - expect(savedList(app).textContent).toContain('No history matches'); + expect(historyList(app).textContent).toContain('No history matches'); }); it('clears the filter when switching tabs', () => { @@ -724,7 +755,7 @@ describe('drag a row into the editor', () => { app.state.sidePanel.value = 'history'; app.state.history = [{ id: 'h1', sql: 'SELECT 2', ts: Date.now(), rows: 1, ms: 1 }]; renderSavedHistory(app); - const row = qs(savedList(app), '.history-row'); + const row = qs(historyList(app), '.history-row'); expect(row.getAttribute('draggable')).toBe('true'); const setData = dragStart(row); expect(setData).toHaveBeenCalledWith(SUBQUERY_MIME, 'SELECT 2'); @@ -777,7 +808,7 @@ describe('drag a Library row onto a Dashboard (#428)', () => { app.state.sidePanel.value = 'history'; app.state.history = [{ id: 'h1', sql: 'SELECT 2', ts: Date.now(), rows: 1, ms: 1 }]; renderSavedHistory(app); - const setData = dragStart(qs(savedList(app), '.history-row')); + const setData = dragStart(qs(historyList(app), '.history-row')); expect(setData).toHaveBeenCalledTimes(1); expect(setData).toHaveBeenCalledWith(SUBQUERY_MIME, 'SELECT 2'); diff --git a/tests/unit/sidebar-upper.test.ts b/tests/unit/sidebar-upper.test.ts index 68565599..717583fa 100644 --- a/tests/unit/sidebar-upper.test.ts +++ b/tests/unit/sidebar-upper.test.ts @@ -3,7 +3,8 @@ import { buildSidebarUpper, renderUpperRoleTabs } from '../../src/ui/sidebar-upp import type { SidebarUpperApp } from '../../src/ui/sidebar-upper.js'; import { renderDashboardTree } from '../../src/ui/dashboard-tree.js'; import { makeApp } from '../helpers/fake-app.js'; -import { h } from '../../src/ui/dom.js'; +import { h, s } from '../../src/ui/dom.js'; +import { NAV_SECTION_META } from '../../src/ui/nav-sections.js'; import { readTreeUi, setTreeSearch } from '../../src/core/dashboard-tree-ui-state.js'; import type { TreeWorkspace } from '../../src/application/dashboard-tree-model.js'; @@ -49,6 +50,29 @@ describe('buildSidebarUpper — role tabs', () => { expect(tabText(app)).toEqual(['Databases· 3', 'Dashboards· 2']); }); + it('takes both labels and both icons FROM the registry, not from a local copy', () => { + // Asserting the rendered text equals 'Databases' cannot distinguish reading + // `NAV_SECTION_META` from hard-coding the same string — and hard-coding it is + // exactly the drift #487 phase 2 exists to prevent, since phase 3's rail + // presents these same two sections. So override the registry and require the + // tab row to follow it. + const { app } = mount(); + const meta = NAV_SECTION_META.databases as { label: string; icon: () => SVGElement }; + const label = meta.label; + const icon = meta.icon; + try { + meta.label = 'Explore'; + meta.icon = () => s('svg', { 'data-registry-icon': 'yes' }); + renderUpperRoleTabs(app); + // No count: `mount()` leaves the schema unloaded, which omits it. + expect(tabText(app)[0]).toBe('Explore'); + expect(tabs(app)[0].querySelector('[data-registry-icon="yes"]')).not.toBeNull(); + } finally { + meta.label = label; + meta.icon = icon; + } + }); + it('omits the Databases count while the schema is loading or failed', () => { const { app } = mount(); // `null` schema is the loading state — a confident "· 0" would be a lie. @@ -109,6 +133,19 @@ describe('buildSidebarUpper — role tabs', () => { }); describe('buildSidebarUpper — persistent hosts', () => { + it('marks both hosts with the shared section-host contract (#487 phase 2)', () => { + const { upper } = mount(); + // One class and one attribute vocabulary for all four navigation sections, so + // phase 3's focused drawer can host any of them with no per-section layout + // rule — and so `ui/nav-sections.ts` can address these two the same way it + // addresses the lower pane's. + for (const host of [upper.databasesHost, upper.dashboardsHost]) { + expect(host.classList.contains('nav-section-host')).toBe(true); + } + expect(upper.databasesHost.dataset.section).toBe('databases'); + expect(upper.dashboardsHost.dataset.section).toBe('dashboards'); + }); + it('exposes exactly one host at a time', () => { const { app, upper } = mount(); expect(upper.databasesHost.hidden).toBe(false); diff --git a/tests/unit/state.test.ts b/tests/unit/state.test.ts index 5d82e091..861443c4 100644 --- a/tests/unit/state.test.ts +++ b/tests/unit/state.test.ts @@ -282,9 +282,15 @@ describe('createState — left navigation preferences (#487)', () => { [KEYS.leftNavMode]: 'collapsed', [KEYS.leftNavDrawerPx]: 'not-a-number', [KEYS.sidebarPx]: 'not-a-number', + // #487 phase 2: `sidePanel` is decoded now too, not passed through. It has + // to be: the lower pane's two sections own separate hosts, so a value that + // one reader resolves to Library and another to History exposes one host + // while painting into the other — a visibly blank pane. + [KEYS.sidePanel]: 'queries', })); expect(s.leftNavMode.value).toBe('wide'); expect(s.leftNavDrawerPx).toBe(240); + expect(s.sidePanel.value).toBe('saved'); // The regression this case exists for: `clamp(parseInt('not-a-number'), 180, // 420)` is NaN (`Math.max(180, NaN)` is NaN), and a NaN width reaches the DOM // as `width: NaNpx`, which the browser drops — silently collapsing the From 6177a51b3985e084652006d441906c875f4c1119 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Thu, 30 Jul 2026 00:00:43 +0200 Subject: [PATCH 04/78] fix(#487): address ChatGPT review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings, all reproduced against the real code first. **The shell/renderer agreement test never ran the shell.** The out-of-union `sidePanel` case lived in `saved-history.test.ts` and called `renderSavedHistory` directly, so it pinned only the renderer's half of the blank-pane invariant — the shell could have gone back to resolving the value inline and it would still have passed. The case now also exists in `app-shell.test.ts` against the real mounted shell, asserting exposure AND painted content together, which is the only place the two halves can be caught disagreeing. **`WorkbenchStateSlice.sidePanel` widened the narrowed signal back to `Signal`**, so that session stayed type-authorized to write an arbitrary string into a signal this branch had just narrowed to `SidePanelKey` — the claim "only ever holds a SidePanelKey" was not mechanically true across the boundary. It derives from `AppState['sidePanel']` now, like the other slices do. **A retained inactive search input could repaint the OTHER section.** The hidden host keeps its listeners, `state.libraryFilter` is still one shared string, and `renderList` paints the active section — so an event from a stale input rewrote the filter and repainted the visible list with the wrong section's text. Unreachable through the UI (a `display: none` subtree gets no events), but phase 3 moves hosts into containers where a host can be visible while another section is active, so the handlers now enforce ownership rather than relying on CSS. A guard, not a redesign: per-section filter state is what fixes the shared string, and phase 3 owns it. **No test proved the LOWER switcher reads the registry** — the mirror of the gap already fixed for the upper one, and hard-coding `Library`/`History` back in passed everything. Added the same override-the-registry test. The icon-factory test now covers all four sections instead of Library alone. Not done here, recorded as a phase-3 prerequisite in the ship log: the lower renderer still requires `savedTabsRow` to exist and resolves its target from the global `sidePanel` at call time, so a switcher-less drawer needs `renderLowerTabs`/`renderLowerSection` split apart. That is phase 3's own refactor, not something to smuggle into a phase whose gate is "behaviour unchanged". Part of #487. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NZfeGpkpJw2zsfPtysGHUN --- src/ui/saved-history.ts | 26 ++++++++++-- src/ui/workbench/workbench-session.ts | 12 +++++- tests/unit/app-shell.test.ts | 22 ++++++++++ tests/unit/nav-sections.test.ts | 15 ++++--- tests/unit/saved-history.test.ts | 58 +++++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 10 deletions(-) diff --git a/src/ui/saved-history.ts b/src/ui/saved-history.ts index 6c7642a5..496ce974 100644 --- a/src/ui/saved-history.ts +++ b/src/ui/saved-history.ts @@ -193,7 +193,8 @@ function renderSearch(app: App): void { const state = app.state; // Gated on the LIBRARY count (#427): a workspace whose every query is owned // has an empty list, so a search box over it would filter nothing. - const isLibrary = activeSection(app) === 'library'; + const section = activeSection(app); + const isLibrary = section === 'library'; const hasItems = isLibrary ? libraryEntries(app).length > 0 : state.history.length > 0; @@ -207,9 +208,28 @@ function renderSearch(app: App): void { }); const clear = h('button', { class: 'sv-search-clear', title: 'Clear' }, Icon.close()); const syncClear = (): void => { clear.style.display = input.value ? '' : 'none'; }; - const setFilter = (v: string): void => { input.value = v; state.libraryFilter = v; syncClear(); renderList(app); }; + // These controls belong to the section that was active when they were built, and + // that host now OUTLIVES the switch away from it (#487 phase 2) — the inactive + // host keeps its DOM, listeners included. `state.libraryFilter` is still one + // shared string and `renderList` still paints the ACTIVE section, so an event + // from a stale input would rewrite the filter and repaint the OTHER section's + // list with this section's search text. Unreachable through the UI today (a + // `display: none` subtree receives no pointer or keyboard events) — but phase 3 + // moves hosts between containers, where a host can be visible while a different + // section is active, so ownership is enforced here rather than left to CSS. + // + // A guard, not a redesign: per-section filter state is what actually fixes the + // shared-string design, and #487 phase 3 owns that (see the ship log). + const ownsTheList = (): boolean => activeSection(app) === section; + const setFilter = (v: string): void => { + if (!ownsTheList()) return; + input.value = v; state.libraryFilter = v; syncClear(); renderList(app); + }; - input.addEventListener('input', () => { state.libraryFilter = input.value; syncClear(); renderList(app); }); + input.addEventListener('input', () => { + if (!ownsTheList()) return; + state.libraryFilter = input.value; syncClear(); renderList(app); + }); input.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); setFilter(''); } }); clear.addEventListener('click', () => { setFilter(''); input.focus(); }); syncClear(); diff --git a/src/ui/workbench/workbench-session.ts b/src/ui/workbench/workbench-session.ts index 7c81b99c..b08a178b 100644 --- a/src/ui/workbench/workbench-session.ts +++ b/src/ui/workbench/workbench-session.ts @@ -66,8 +66,16 @@ export interface WorkbenchStateSlice { forceExplain: boolean; resultRowLimit: number; serverVersion: string | null; - /** Read by runScript's clean-run history branch ('history' ⇒ repaint). */ - sidePanel: Signal; + /** + * Read by runScript's clean-run history branch ('history' ⇒ repaint). + * + * Derived from `AppState` rather than restated as `Signal` (#487 + * phase 2): the real signal holds a decoded `'saved' | 'history'`, and a + * structural `Signal` here would leave this session type-authorized to + * write an arbitrary string into it — re-opening exactly the divergence the + * load-boundary decode closes. This slice only ever reads it. + */ + sidePanel: AppState['sidePanel']; isMobile: Signal; mobileView: Signal<'tables' | 'editor' | 'results'>; /** Read by the Run-button effect (Run ↔ "Run selection" label). */ diff --git a/tests/unit/app-shell.test.ts b/tests/unit/app-shell.test.ts index 01c2e817..0da650bb 100644 --- a/tests/unit/app-shell.test.ts +++ b/tests/unit/app-shell.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { mountAppShell } from '../../src/ui/app-shell.js'; import { startDrag } from '../../src/ui/splitters.js'; import { makeApp } from '../helpers/fake-app.js'; +import { savedQuery } from '../helpers/saved-query.js'; function mount() { const loadSchema = vi.fn(async () => {}); @@ -134,6 +135,27 @@ describe('mountAppShell wide navigation (#487 phase 2)', () => { handle.dispose(); }); + it('exposes and PAINTS the same lower section for an out-of-union sidePanel', () => { + // The blank-pane invariant, tested against the real shell rather than the + // renderer alone. `saved-history.test.ts` has a sibling case, but it calls + // `renderSavedHistory` directly — so it pins only the renderer's half and would + // still pass if this shell went back to resolving the value inline. Exposure and + // content have to be asserted in the SAME mounted shell, because the bug is + // precisely that the two halves can disagree: one host exposed, the other + // painted, nothing visible. + const { app, handle } = mount(); + const host = hosts(app.root); + app.state.savedQueries = [savedQuery({ id: 's1', name: 'Q1', sql: 'SELECT 1' })]; + + (app.state.sidePanel as { value: string }).value = 'queries'; + + expect(host.library.hidden).toBe(false); + expect(host.history.hidden).toBe(true); + expect(app.dom.savedList!.querySelectorAll('.saved-row')).toHaveLength(1); + expect(app.dom.historyList!.children.length).toBe(0); + handle.dispose(); + }); + it('switches the exposed upper host on upperRole', () => { const { app, handle } = mount(); const host = hosts(app.root); diff --git a/tests/unit/nav-sections.test.ts b/tests/unit/nav-sections.test.ts index 38dda2b4..fd9bc99c 100644 --- a/tests/unit/nav-sections.test.ts +++ b/tests/unit/nav-sections.test.ts @@ -70,13 +70,18 @@ describe('NAV_SECTION_META', () => { } }); - it('mints a FRESH icon per call, so two presentations can show one section at once', () => { + it('mints a FRESH icon per call, for EVERY section', () => { // One SVG node cannot be in the wide switcher and the rail launcher // simultaneously — appending it to the second would remove it from the first. - const first = NAV_SECTION_META.library.icon(); - const second = NAV_SECTION_META.library.icon(); - expect(first).not.toBe(second); - expect(first.tagName).toBe(second.tagName); + // Checked for all four, not just one: a single reused node among them is + // exactly the kind of asymmetry a one-section spot check misses. + for (const section of LEFT_NAV_SECTIONS) { + const first = NAV_SECTION_META[section].icon(); + const second = NAV_SECTION_META[section].icon(); + expect(first).not.toBe(second); + expect(first.tagName).toBe(second.tagName); + expect(first.isConnected).toBe(false); + } }); it('places two sections in each wide pane', () => { diff --git a/tests/unit/saved-history.test.ts b/tests/unit/saved-history.test.ts index 65ddbc0f..9be988dd 100644 --- a/tests/unit/saved-history.test.ts +++ b/tests/unit/saved-history.test.ts @@ -3,6 +3,8 @@ import { renderSavedHistory } from '../../src/ui/saved-history.js'; import { LIBRARY_QUERY_MIME, SUBQUERY_MIME } from '../../src/ui/dnd-mime.js'; import { queryDescription, queryFavorite, queryName } from '../../src/core/saved-query.js'; import { makeApp } from '../helpers/fake-app.js'; +import { NAV_SECTION_META } from '../../src/ui/nav-sections.js'; +import { s as svgEl } from '../../src/ui/dom.js'; import { savedQuery } from '../helpers/saved-query.js'; import type { SavedQueryFixture } from '../helpers/saved-query.js'; import { setTabSpecDraft, toggleFavorite, deleteSaved } from '../../src/state.js'; @@ -625,6 +627,62 @@ describe('renderSavedHistory', () => { expect(qsa(savedTabsRow(app), '.side-tab')[0].classList.contains('active')).toBe(true); }); + it('takes the lower tabs\' labels and icons FROM the registry', () => { + // The mirror of `sidebar-upper.test.ts`'s equivalent. Asserting the rendered + // text is 'Library' cannot distinguish reading NAV_SECTION_META from + // hard-coding the same string next to it — so override the registry and + // require the tab row to follow. Phase 3's rail is the third consumer of + // these same labels; a second copy here is how the presentations drift. + const app = makeApp(); + app.state.sidePanel.value = 'saved'; + const meta = NAV_SECTION_META.history as { label: string; icon: () => SVGElement }; + const label = meta.label; + const icon = meta.icon; + try { + meta.label = 'Recent runs'; + meta.icon = () => svgEl('svg', { 'data-registry-icon': 'yes' }); + renderSavedHistory(app); + const tabs = qsa(savedTabsRow(app), '.side-tab'); + expect(tabs[1].textContent).toBe('Recent runs'); + expect(tabs[1].querySelector('[data-registry-icon="yes"]')).not.toBeNull(); + } finally { + meta.label = label; + meta.icon = icon; + } + }); + + it('ignores input from a retained search box whose section is no longer active', () => { + // #487 phase 2: the inactive section's host keeps its DOM, so its search input + // and listeners OUTLIVE the switch away from it. `state.libraryFilter` is still + // one shared string and `renderList` paints the ACTIVE section, so a stale + // event would rewrite the filter and repaint the OTHER section's list with this + // section's text. CSS makes it unreachable today; phase 3 moves hosts into + // containers where a host can be visible while another section is active. + const app = makeApp(); + app.state.sidePanel.value = 'saved'; + setSaved(app, [{ id: 's1', name: 'Carrier delays', sql: 'SELECT 1' }]); + renderSavedHistory(app); + const staleInput = qs(savedSearch(app), '.sv-search-input'); + + app.state.sidePanel.value = 'history'; + app.state.history = [{ id: 'h1', sql: 'SELECT 1', ts: Date.now(), rows: 1, ms: 1 }]; + renderSavedHistory(app); + expect(qsa(historyList(app), '.history-row')).toHaveLength(1); + + staleInput.value = 'zzzz'; + staleInput.dispatchEvent(new Event('input', { bubbles: true })); + + // The shared filter is untouched and History still shows its row — no + // cross-section rewrite, no "No history matches “zzzz”". + expect(app.state.libraryFilter).toBe(''); + expect(qsa(historyList(app), '.history-row')).toHaveLength(1); + expect(historyList(app).textContent).not.toContain('zzzz'); + + // Escape on the stale input is inert for the same reason. + staleInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(qsa(historyList(app), '.history-row')).toHaveLength(1); + }); + it('switching panels persists the choice', () => { const app = makeApp(); app.state.sidePanel.value = 'saved'; From c82295b528224057b2c1d51971841e8760884086 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Thu, 30 Jul 2026 07:20:07 +0200 Subject: [PATCH 05/78] fix(#487): address ChatGPT second-review minors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second pass on the updated head approved phase 2 and left two minors, both real. **The registry's icon MAPPING was unpinned.** The tests proved every entry is a factory that mints a fresh node, and two mutation tests proved both switchers read the table — but a wrong icon in the table propagates consistently to every presentation, so swapping Library's and History's icons passed the whole suite. Pinned by identity (`NAV_SECTION_META.library.icon === Icon.layers`). This is the same lesson as the two earlier unfalsifiable tests, one level up: proving the consumers read the source says nothing about the source being right. **The host CSS comment overstated what a hidden host preserves.** It claimed search text and scroll survive "a section switch", which is true for the upper pane's two roles and deliberately FALSE for the lower pane — the switcher clears the shared `libraryFilter` and activating the destination repaints its search box and list. The comment now separates the three cases (upper role switch, lower section switch, and a move between containers), because a phase-3 maintainer reading the old wording would assume lower search preservation was already solved when it is phase 3's job. Also asserts both lower lists stay mounted across a section switch, not merely that their object identity holds — identity alone would survive a host being detached and replaced by a look-alike. Part of #487. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NZfeGpkpJw2zsfPtysGHUN --- src/styles.css | 15 ++++++++++++--- tests/unit/app-shell.test.ts | 6 +++++- tests/unit/nav-sections.test.ts | 12 ++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/styles.css b/src/styles.css index c22535f6..e65df5ff 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1718,9 +1718,18 @@ body.detached-tab .graph-overlay-panel { display: flex; flex-direction: column; } /* A hidden host contributes no layout, so the exposed one owns the whole pane — - but it keeps its DOM, which is what preserves search text, expansion, lazily - loaded columns and scroll across a section switch, and (phase 3) across a move - between the wide sidebar and the focused drawer. */ + but it keeps its DOM. What that preserves differs by pane, and the distinction + matters to #487 phase 3: + + - UPPER (Databases | Dashboards): everything, across a role switch. Each role + owns its own search and expansion state, so switching only flips `hidden` and + the schema search text, lazily loaded columns and scroll all survive (#426). + - LOWER (Library | History): NOT across a section switch. The switcher clears + the shared `libraryFilter`, and activating the destination repaints its search + box and list. Per-section lower search state is phase 3's, not solved here. + - BOTH, across a MOVE between containers (phase 3's wide sidebar ↔ focused + drawer): everything, because moving an element repaints nothing. That is the + guarantee the persistent hosts exist for. */ .nav-section-host[hidden] { display: none; } /* ------------ Dashboard hierarchy tree (#426) ------------ */ diff --git a/tests/unit/app-shell.test.ts b/tests/unit/app-shell.test.ts index 0da650bb..7789404c 100644 --- a/tests/unit/app-shell.test.ts +++ b/tests/unit/app-shell.test.ts @@ -125,9 +125,13 @@ describe('mountAppShell wide navigation (#487 phase 2)', () => { expect(host.library.hidden).toBe(false); expect(host.history.hidden).toBe(true); // The same element objects throughout — never rebuilt, only exposed or hidden. - // That identity is what makes phase 3's mode change a MOVE of live DOM. + // That identity is what makes phase 3's mode change a MOVE of live DOM. Both + // stay MOUNTED too: identity alone would also hold for a host that had been + // detached from the shell and replaced by a look-alike. expect(app.dom.savedList).toBe(libraryList); expect(app.dom.historyList).toBe(historyList); + expect(app.root.contains(libraryList)).toBe(true); + expect(app.root.contains(historyList)).toBe(true); // Becoming active DOES repaint the section, exactly as it always has: the // switcher clears the shared search filter, so the list is rebuilt from // scratch. #487 phase 3 owns whether a drawer should preserve it instead. diff --git a/tests/unit/nav-sections.test.ts b/tests/unit/nav-sections.test.ts index fd9bc99c..12b9d2e9 100644 --- a/tests/unit/nav-sections.test.ts +++ b/tests/unit/nav-sections.test.ts @@ -9,6 +9,7 @@ import type { NavSectionsApp } from '../../src/ui/nav-sections.js'; import { LEFT_NAV_SECTIONS } from '../../src/core/left-nav-layout.js'; import type { SidebarUpperHandle } from '../../src/ui/sidebar-upper.js'; import { h } from '../../src/ui/dom.js'; +import { Icon } from '../../src/ui/icons.js'; /** * A stand-in for #426's upper pane. The registry ADOPTS those two hosts and @@ -70,6 +71,17 @@ describe('NAV_SECTION_META', () => { } }); + it('maps each section to the RIGHT icon primitive', () => { + // Identity, not shape. The freshness test below proves each entry is a factory + // and the two consumer-mutation tests prove both switchers read this table — + // but a WRONG icon in the table propagates consistently to every presentation, + // so all of those stay green if Library and History are swapped here. + expect(NAV_SECTION_META.databases.icon).toBe(Icon.database); + expect(NAV_SECTION_META.dashboards.icon).toBe(Icon.dashboard); + expect(NAV_SECTION_META.library.icon).toBe(Icon.layers); + expect(NAV_SECTION_META.history.icon).toBe(Icon.history); + }); + it('mints a FRESH icon per call, for EVERY section', () => { // One SVG node cannot be in the wide switcher and the rail launcher // simultaneously — appending it to the second would remove it from the first. From a1bea7df1ff14d92004bf92be6daeb3151b75540 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Thu, 30 Jul 2026 09:46:49 +0200 Subject: [PATCH 06/78] feat(#487): left-nav resize session and centre-width clamp (phase 3, step 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the pure core pieces phase 3 needs before any UI wiring: a resize session (begin/advance/commit) that fixes the sampling-dependent restore memory from phase 1 for both bands without reintroducing it (two rounds of adversarial review caught that "continuously updated" memory has the same bug relocated), and clampLeftNavigationToMaximumTotal + the viewport- aware separator ARIA ceiling for the centre-width safety constraint. No production consumer yet — wiring lands in later steps of this phase. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NZfeGpkpJw2zsfPtysGHUN --- src/core/left-nav-layout.ts | 221 ++++++++++++++++++++++++++- tests/unit/left-nav-layout.test.ts | 236 ++++++++++++++++++++++++++++- 2 files changed, 451 insertions(+), 6 deletions(-) diff --git a/src/core/left-nav-layout.ts b/src/core/left-nav-layout.ts index 436fd993..c6b2c2f1 100644 --- a/src/core/left-nav-layout.ts +++ b/src/core/left-nav-layout.ts @@ -524,12 +524,229 @@ export interface LeftNavigationSeparatorAria { readonly valueNow: number; } -export function leftNavigationSeparatorAria(layout: LeftNavigationLayout): LeftNavigationSeparatorAria { +/** + * `maxNavigationTotalPx` is optional and, when given, tightens `valueMax` to + * whatever the viewport currently allows — see `clampLeftNavigationToMaximumTotal` + * below, which computes the layout this ceiling must agree with. Omitted (or a + * non-finite/non-positive value, which cannot be a real viewport budget), the + * ceiling is `LEFT_PANEL_MAX_PX` exactly as before phase 3 — this parameter is + * additive and every existing caller (there is still none in production, but the + * unit tests below stand in for one) keeps its prior behaviour unconditionally. + */ +export function leftNavigationSeparatorAria( + layout: LeftNavigationLayout, maxNavigationTotalPx?: number, +): LeftNavigationSeparatorAria { + const valueMax = Number.isFinite(maxNavigationTotalPx) && (maxNavigationTotalPx as number) > 0 + ? Math.min(LEFT_PANEL_MAX_PX, maxNavigationTotalPx as number) + : LEFT_PANEL_MAX_PX; return { valueMin: LEFT_RAIL_PX, - valueMax: LEFT_PANEL_MAX_PX, + valueMax, // Normalized, so a caller holding a layout with a non-finite width cannot // publish `aria-valuenow="NaN"` to assistive technology. valueNow: leftNavigationWidthPx(normalizeLeftNavigationLayout(layout)), }; } + +/** + * The centre SQL/results surface's documented minimum width. Phase 4's + * viewport-resize handling subtracts this (plus the resize separator's own + * width and any docked panel) from the viewport to get the budget it hands to + * `clampLeftNavigationToMaximumTotal` below — this module does not read the + * viewport itself, so the constant lives here purely so the UI layer has one + * source for it rather than a second copy of the number. + */ +export const LEFT_CENTRE_MIN_PX = 480; + +/** + * Shrink `layout`'s CURRENT mode's own panel width so the navigation's total + * occupied width (`leftNavigationWidthPx`) fits inside `maxNavigationTotalPx`, + * without ever changing `mode`. + * + * This is a narrower job than the mode reducer above. #487 phase 4 is where a + * viewport too small for the centre surface's own minimum gets to fold a wide + * sidebar to rail or close an open drawer — a MODE decision, driven by the + * viewport crossing a breakpoint, not by a pointer or a key. Doing any of that + * here would duplicate `resolveLeftNavigationDrag`'s job with a second, + * viewport-shaped entry point, and the two would disagree about hysteresis the + * first time someone edited only one of them. So this function answers a + * strictly smaller question — "shrink the width THIS mode already has, inside + * the band this mode can already legally occupy" — and leaves whether to fold + * or close entirely to phase 4. + * + * That narrower scope is also why a budget below the mode's own floor is + * "best effort" rather than an error: `wide`'s floor is `LEFT_PANEL_MIN_PX` + * (180) and a bare `rail`'s occupied width is the fixed `LEFT_RAIL_PX` (48, + * nothing to shrink), so the only floor this function can be asked to violate + * is the wide sidebar's 180 or an open drawer's 140. Returning the floor + * anyway — rather than throwing, or returning something outside every mode's + * legal range — keeps the result always renderable; a caller that actually + * needs the navigation narrower than what a mode CAN render must change the + * mode, which is phase 4's job, not this function's. + * + * In practice that floor path is unreachable above the existing mobile + * breakpoint, so it is defensive rather than a state phase 4 has to design + * for today. `MOBILE_BREAKPOINT_PX` (`state.ts`) is 768, the resize separator + * that will subtract from the viewport is 7px wide (`.col-resize` in + * `styles.css`), and `LEFT_CENTRE_MIN_PX` above is 480 — so at the smallest + * viewport this function is ever consulted at (768px wide, the boundary where + * mobile's own two-pane layout stops standing in), the budget phase 4 would + * pass is `768 − 480 − 7 = 281`. That clears the wide sidebar's 180 floor with + * 101px to spare, and clears an open drawer's `281 − LEFT_RAIL_PX(48) = 233` + * against its 140 floor with 93px to spare. Below 768 the mobile layout + * replaces the rail and drawer entirely (`effectiveLeftNavigationLayout` + * above), so this function is never consulted there either. + * + * `maxNavigationTotalPx` itself is a plain number budget, not a viewport: a + * non-finite or NEGATIVE value (a corrupt measurement, or a caller that has + * not measured yet) is treated as "no additional constraint" rather than + * propagated into a NaN or Infinity output. Zero is not in that list — it is + * an extreme but legitimate budget, and clamping into either band's own range + * floors it exactly like any other too-small value. + */ +export function clampLeftNavigationToMaximumTotal( + layout: LeftNavigationLayout, maxNavigationTotalPx: number, +): LeftNavigationLayout { + const normalized = normalizeLeftNavigationLayout(layout); + // Zero is a legitimate (if extreme) budget — clamping into either band's own + // range floors it correctly. Only a NEGATIVE or non-finite value cannot mean + // a real width, so those fall back to "no additional constraint" rather than + // being clamped into a nonsensical floor. + const budget = Number.isFinite(maxNavigationTotalPx) && maxNavigationTotalPx >= 0 + ? maxNavigationTotalPx + : Infinity; + if (normalized.mode === 'wide') { + // The wide sidebar IS the total, so the budget applies to it directly. + const maxWideWidthPx = clamp(budget, LEFT_PANEL_MIN_PX, LEFT_PANEL_MAX_PX); + return normalized.wideWidthPx <= maxWideWidthPx + ? normalized + : { ...normalized, wideWidthPx: maxWideWidthPx }; + } + if (normalized.focusedSection === null) return normalized; // bare rail: fixed width, nothing to shrink. + // An open drawer sits BESIDE the rail, so its own budget is the total minus + // the rail — mirroring `resolveLeftNavigationDrag`'s `panelPx` derivation. + const maxDrawerWidthPx = clamp(budget - LEFT_RAIL_PX, LEFT_FOLD_THRESHOLD_PX, LEFT_WIDE_THRESHOLD_PX); + return normalized.drawerWidthPx <= maxDrawerWidthPx + ? normalized + : { ...normalized, drawerWidthPx: maxDrawerWidthPx }; +} + +/** + * A resize SESSION — the drag/keyboard-resize memory phase 3 needs and a plain + * continuously-advancing width field cannot provide. + * + * **Why not just keep overwriting a remembered width on every frame?** That was + * tried and is exactly the bug this module's own + * "restore memory is sampling-dependent" test (above) already pins for the OLD + * design: which width ends up remembered depends on which intermediate pointer + * samples the browser happened to deliver, because one field was doing duty as + * both "the width currently on screen" and "the width to restore later". A + * session separates those two questions by keeping THREE layouts, not one: + * + * - `preferredAtStart` — the persisted preference as of when the gesture began. + * This is the memory source `commitLeftNavigationResize` reconstructs from, + * and it is captured ONCE, so no intermediate frame can overwrite it. + * - `effectiveAtStart` — what was actually rendered when the gesture began, + * i.e. `preferredAtStart` after `clampLeftNavigationToMaximumTotal` ran + * against whatever the viewport allowed at that moment. This can differ from + * `preferredAtStart` — a maximized preference squeezed by a narrow window — + * and the gap between the two is precisely what lets the commit step tell + * "the user actually resized this band" apart from "the band was just + * rendered smaller than preferred by an unrelated viewport constraint". + * - `effective` — the live, post-clamp layout, replaced wholesale on every + * `advanceLeftNavigationResize` call. This is what gets rendered and reported + * as the gesture continues; it is not memory. + * + * **`commitLeftNavigationResize`'s table, restated as one rule:** only commit a + * band's width if that band is the one `effective` currently renders AND its + * rendered width actually differs from `effectiveAtStart`'s. Every other case — + * a dormant band, a fold-through to bare rail, a click-and-release with no + * movement — preserves `preferredAtStart` for that band UNCONDITIONALLY. Two + * consequences fall out of that one rule rather than needing their own case: + * + * 1. **The dormant-band fix.** A gesture that resizes the drawer and THEN + * crosses into wide mode must not commit the drawer's mid-gesture width, + * because the drawer is no longer the band `effective` renders once the + * session ends — `drawerChanged` requires `effective.mode === 'rail'`, which + * is false at wide, so the drawer memory falls through to + * `preferredAtStart.drawerWidthPx` untouched. Without that mode guard, a + * drag that opened the drawer to 300 before continuing on to a 350px wide + * sidebar would silently overwrite the drawer's remembered width with a + * value the user never asked to keep. + * 2. **Preferred wins over effective on a fold-through.** Ending at bare rail + * preserves BOTH widths from `preferredAtStart`, never from `effectiveAtStart` + * or `effective` — so a maximized 420px preference that a narrow viewport + * rendered at a clamped 313px, then folded to rail by the same gesture, + * still remembers 420 for the next `End`/restore. Committing `313` instead + * would silently downgrade a preference the user never touched, purely + * because the viewport happened to be narrow during an unrelated fold. + * + * `Home`/`End`/a bare-rail `ArrowRight` restore need no special case either: + * they are restore commands, so the `effective` layout they produce typically + * already equals `preferredAtStart`'s remembered width for the band they + * restore, which is exactly the "nothing changed" shape the general rule + * preserves correctly. + * + * A session is deliberately NOT a reducer step in `resolveLeftNavigationDrag`'s + * family — `advanceLeftNavigationResize` does not call the mode reducer or the + * maximum-total clamp itself. The caller runs those first to produce the next + * `effective` layout (a pointer/keyboard event resolves through the existing + * reducers, then `clampLeftNavigationToMaximumTotal` fits it to the viewport), + * and only then advances the session with the result. Session bookkeeping and + * layout arithmetic stay two separate concerns, so the arithmetic keeps its + * one implementation. + */ +export interface LeftNavigationResizeSession { + /** The persisted preference as of session start — the memory source every + * commit is reconstructed from, band by band. */ + readonly preferredAtStart: LeftNavigationLayout; + /** What was actually rendered when the session began, i.e. + * `preferredAtStart` after the viewport's maximum-total clamp. */ + readonly effectiveAtStart: LeftNavigationLayout; + /** The live, post-clamp layout — what is rendered and reported right now. */ + readonly effective: LeftNavigationLayout; +} + +/** Begin a resize session: `effective` starts out equal to `effectiveAtStart`, + * since nothing has moved yet. */ +export function beginLeftNavigationResize( + preferred: LeftNavigationLayout, effective: LeftNavigationLayout, +): LeftNavigationResizeSession { + return { preferredAtStart: preferred, effectiveAtStart: effective, effective }; +} + +/** + * Advance a session to a new live layout. Pure snapshot replacement — the + * caller has already run the layout through `resolveLeftNavigationDrag` / + * `resolveLeftNavigationKey` and `clampLeftNavigationToMaximumTotal` to produce + * `nextEffectiveLayout`; this function does not call either. Returns the SAME + * session when the layout is unchanged by reference, so a caller can use + * identity to skip a repaint exactly as the mode reducers do. + */ +export function advanceLeftNavigationResize( + session: LeftNavigationResizeSession, nextEffectiveLayout: LeftNavigationLayout, +): LeftNavigationResizeSession { + return nextEffectiveLayout === session.effective + ? session + : { ...session, effective: nextEffectiveLayout }; +} + +/** + * Reconstruct the `LeftNavigationLayout` to persist from a resize session — see + * this section's block comment above for the rule and why it is shaped this + * way. `mode` and `focusedSection` always follow wherever the session ended + * (a legitimate mode transition, not a width memory question); only the two + * WIDTHS get the preserve-vs-commit treatment, band by band. + */ +export function commitLeftNavigationResize(session: LeftNavigationResizeSession): LeftNavigationLayout { + const { preferredAtStart, effectiveAtStart, effective } = session; + const wideChanged = effective.mode === 'wide' && effective.wideWidthPx !== effectiveAtStart.wideWidthPx; + const drawerChanged = effective.mode === 'rail' && effective.focusedSection !== null + && effective.drawerWidthPx !== effectiveAtStart.drawerWidthPx; + return normalizeLeftNavigationLayout({ + mode: effective.mode, + focusedSection: effective.focusedSection, + wideWidthPx: wideChanged ? effective.wideWidthPx : preferredAtStart.wideWidthPx, + drawerWidthPx: drawerChanged ? effective.drawerWidthPx : preferredAtStart.drawerWidthPx, + }); +} diff --git a/tests/unit/left-nav-layout.test.ts b/tests/unit/left-nav-layout.test.ts index d393fb24..28737cca 100644 --- a/tests/unit/left-nav-layout.test.ts +++ b/tests/unit/left-nav-layout.test.ts @@ -18,10 +18,12 @@ import { describe, it, expect } from 'vitest'; import { - LEFT_DRAWER_DEFAULT_PX, LEFT_FOLD_THRESHOLD_PX, LEFT_NAV_LARGE_STEP_PX, LEFT_NAV_SECTIONS, - LEFT_NAV_STEP_PX, LEFT_PANEL_MAX_PX, LEFT_PANEL_MIN_PX, LEFT_RAIL_PX, LEFT_WIDE_DEFAULT_PX, - LEFT_WIDE_THRESHOLD_PX, - clampDrawerWidthPx, clampWideWidthPx, decodeLeftNavigationMode, decodeStoredPx, + LEFT_CENTRE_MIN_PX, LEFT_DRAWER_DEFAULT_PX, LEFT_FOLD_THRESHOLD_PX, LEFT_NAV_LARGE_STEP_PX, + LEFT_NAV_SECTIONS, LEFT_NAV_STEP_PX, LEFT_PANEL_MAX_PX, LEFT_PANEL_MIN_PX, LEFT_RAIL_PX, + LEFT_WIDE_DEFAULT_PX, LEFT_WIDE_THRESHOLD_PX, + advanceLeftNavigationResize, beginLeftNavigationResize, clampDrawerWidthPx, + clampLeftNavigationToMaximumTotal, clampWideWidthPx, commitLeftNavigationResize, + decodeLeftNavigationMode, decodeStoredPx, effectiveLeftNavigationLayout, isLeftNavigationSection, leftNavigationLayoutIsCoherent, leftNavigationSeparatorAria, leftNavigationWidthPx, normalizeLeftNavigationLayout, resolveLeftNavigationDrag, resolveLeftNavigationKey, resolveRailActivation, resolveRailOpen, @@ -770,4 +772,230 @@ describe('leftNavigationSeparatorAria', () => { expect(valueNow).toBeLessThanOrEqual(valueMax); } }); + + // #487 phase 3: an optional ceiling, added without disturbing any existing + // caller — this is the regression the "omitted" case guards. + describe('the optional maxNavigationTotalPx ceiling (#487 phase 3)', () => { + it('is unchanged from before phase 3 when the parameter is omitted', () => { + // Would fail if a phase-3 edit accidentally tightened the default ceiling. + expect(leftNavigationSeparatorAria(wide({ wideWidthPx: 300 }))) + .toEqual({ valueMin: LEFT_RAIL_PX, valueMax: LEFT_PANEL_MAX_PX, valueNow: 300 }); + }); + it('ignores a non-finite or non-positive ceiling, exactly like omitting it', () => { + for (const bogus of [NaN, Infinity, -Infinity, 0, -100]) { + expect(leftNavigationSeparatorAria(wide({ wideWidthPx: 300 }), bogus).valueMax) + .toBe(LEFT_PANEL_MAX_PX); + } + }); + it('shrinks valueMax to a smaller ceiling', () => { + expect(leftNavigationSeparatorAria(wide({ wideWidthPx: 200 }), 300).valueMax).toBe(300); + }); + it('has no effect when the ceiling exceeds LEFT_PANEL_MAX_PX', () => { + expect(leftNavigationSeparatorAria(wide({ wideWidthPx: 200 }), 9999).valueMax) + .toBe(LEFT_PANEL_MAX_PX); + }); + }); +}); + +describe('clampLeftNavigationToMaximumTotal (#487 phase 3)', () => { + it('shrinks a wide layout that exceeds the budget', () => { + const next = clampLeftNavigationToMaximumTotal(wide({ wideWidthPx: 350 }), 300); + expect(next.mode).toBe('wide'); + expect(next.wideWidthPx).toBe(300); + }); + it('shrinks a drawer layout that exceeds the budget, measured beside the rail', () => { + const next = clampLeftNavigationToMaximumTotal( + rail({ focusedSection: 'library', drawerWidthPx: 220 }), LEFT_RAIL_PX + 180); + expect(next.mode).toBe('rail'); + expect(next.focusedSection).toBe('library'); + expect(next.drawerWidthPx).toBe(180); + }); + it('never changes mode, including for a bare rail (nothing to shrink)', () => { + expect(clampLeftNavigationToMaximumTotal(rail(), 0).mode).toBe('rail'); + expect(clampLeftNavigationToMaximumTotal(rail(), 0)).toEqual(rail()); + expect(clampLeftNavigationToMaximumTotal(wide({ wideWidthPx: 350 }), 0).mode).toBe('wide'); + expect(clampLeftNavigationToMaximumTotal( + rail({ focusedSection: 'library', drawerWidthPx: 220 }), 0).mode).toBe('rail'); + }); + it("returns wide's own floor, not NaN or a thrown error, for a budget below it", () => { + expect(clampLeftNavigationToMaximumTotal(wide({ wideWidthPx: 350 }), 0).wideWidthPx) + .toBe(LEFT_PANEL_MIN_PX); + expect(clampLeftNavigationToMaximumTotal(wide({ wideWidthPx: 350 }), LEFT_PANEL_MIN_PX - 1).wideWidthPx) + .toBe(LEFT_PANEL_MIN_PX); + }); + it("returns the drawer's own floor for a budget below it", () => { + const next = clampLeftNavigationToMaximumTotal( + rail({ focusedSection: 'library', drawerWidthPx: 220 }), LEFT_RAIL_PX + LEFT_FOLD_THRESHOLD_PX - 1); + expect(next.drawerWidthPx).toBe(LEFT_FOLD_THRESHOLD_PX); + }); + it('is exact at the floor boundary — the floor itself still fits', () => { + expect(clampLeftNavigationToMaximumTotal(wide({ wideWidthPx: 350 }), LEFT_PANEL_MIN_PX).wideWidthPx) + .toBe(LEFT_PANEL_MIN_PX); + const next = clampLeftNavigationToMaximumTotal( + rail({ focusedSection: 'library', drawerWidthPx: 220 }), LEFT_RAIL_PX + LEFT_FOLD_THRESHOLD_PX); + expect(next.drawerWidthPx).toBe(LEFT_FOLD_THRESHOLD_PX); + }); + it('does not propagate NaN or Infinity for a non-finite or negative budget', () => { + for (const bogus of [NaN, Infinity, -Infinity, -1, -9999]) { + const wideResult = clampLeftNavigationToMaximumTotal(wide({ wideWidthPx: 300 }), bogus); + expect(Number.isFinite(wideResult.wideWidthPx)).toBe(true); + const drawerResult = clampLeftNavigationToMaximumTotal( + rail({ focusedSection: 'library', drawerWidthPx: 200 }), bogus); + expect(Number.isFinite(drawerResult.drawerWidthPx)).toBe(true); + } + }); + it('treats a non-finite or negative budget as no additional constraint', () => { + // Falls back to the mode's own existing band, not an artificially shrunk one. + for (const bogus of [NaN, Infinity, -Infinity, -1]) { + expect(clampLeftNavigationToMaximumTotal(wide({ wideWidthPx: 300 }), bogus).wideWidthPx).toBe(300); + } + }); + it('is a no-op — by identity — when the layout already fits the budget', () => { + const w = wide({ wideWidthPx: 300 }); + expect(clampLeftNavigationToMaximumTotal(w, 9999)).toBe(w); + const d = rail({ focusedSection: 'library', drawerWidthPx: 200 }); + expect(clampLeftNavigationToMaximumTotal(d, 9999)).toBe(d); + const b = rail(); + expect(clampLeftNavigationToMaximumTotal(b, 0)).toBe(b); + }); + it("LEFT_CENTRE_MIN_PX is exported for the UI layer's budget arithmetic", () => { + expect(LEFT_CENTRE_MIN_PX).toBe(480); + }); +}); + +// The resize-session design's own comment block (above `LeftNavigationResizeSession` +// in left-nav-layout.ts) explains WHY it is shaped the way it is; these tests are +// the counter-examples that shape was reviewed against. +describe('resize session (#487 phase 3)', () => { + it('begin captures both inputs, with effective starting equal to effectiveAtStart', () => { + const preferred = wide({ wideWidthPx: 420 }); + const effective = wide({ wideWidthPx: 313 }); // squeezed by a narrow viewport + const session = beginLeftNavigationResize(preferred, effective); + expect(session.preferredAtStart).toBe(preferred); + expect(session.effectiveAtStart).toBe(effective); + expect(session.effective).toBe(effective); + }); + + it('advance replaces effective and returns the same session by reference when unchanged', () => { + const start = wide({ wideWidthPx: 300 }); + const session = beginLeftNavigationResize(start, start); + const same = advanceLeftNavigationResize(session, start); + expect(same).toBe(session); + const moved = wide({ wideWidthPx: 320 }); + const advanced = advanceLeftNavigationResize(session, moved); + expect(advanced).not.toBe(session); + expect(advanced.effective).toBe(moved); + expect(advanced.preferredAtStart).toBe(session.preferredAtStart); + expect(advanced.effectiveAtStart).toBe(session.effectiveAtStart); + }); + + // Each row of commitLeftNavigationResize's table, driven through a single + // begin -> advance -> commit step. + describe('commit table', () => { + it('a wide resize that changed width commits it, leaving the drawer memory untouched', () => { + const start = wide({ wideWidthPx: 250, drawerWidthPx: 210 }); + let session = beginLeftNavigationResize(start, start); + session = advanceLeftNavigationResize(session, wide({ wideWidthPx: 320, drawerWidthPx: 210 })); + expect(commitLeftNavigationResize(session)).toEqual(wide({ wideWidthPx: 320, drawerWidthPx: 210 })); + }); + + it('a drawer resize that changed width commits it, leaving the wide memory untouched', () => { + const start = rail({ focusedSection: 'library', wideWidthPx: 260, drawerWidthPx: 200 }); + let session = beginLeftNavigationResize(start, start); + session = advanceLeftNavigationResize( + session, rail({ focusedSection: 'library', wideWidthPx: 260, drawerWidthPx: 230 })); + expect(commitLeftNavigationResize(session)).toEqual( + rail({ focusedSection: 'library', wideWidthPx: 260, drawerWidthPx: 230 })); + }); + + it('a fold-through to bare rail preserves both remembered widths', () => { + const start = wide({ wideWidthPx: 300, drawerWidthPx: 210 }); + let session = beginLeftNavigationResize(start, start); + session = advanceLeftNavigationResize(session, resolveLeftNavigationDrag(start, 10)); + const committed = commitLeftNavigationResize(session); + expect(committed.mode).toBe('rail'); + expect(committed.focusedSection).toBeNull(); + expect(committed.wideWidthPx).toBe(300); + expect(committed.drawerWidthPx).toBe(210); + }); + + it('a no-op (advance to the same effective layout) commits both preserved', () => { + const start = wide({ wideWidthPx: 300, drawerWidthPx: 210 }); + let session = beginLeftNavigationResize(start, start); + session = advanceLeftNavigationResize(session, wide({ wideWidthPx: 300, drawerWidthPx: 210 })); + expect(commitLeftNavigationResize(session)).toEqual(start); + }); + }); + + it('the dormant-band case: a drawer resize followed by a crossing into wide does not commit the transient drawer width', () => { + const start = rail({ focusedSection: 'library', wideWidthPx: 260, drawerWidthPx: 200 }); + let session = beginLeftNavigationResize(start, start); + // Resize the drawer open further … + session = advanceLeftNavigationResize( + session, rail({ focusedSection: 'library', wideWidthPx: 260, drawerWidthPx: 259 })); + // … then keep dragging past the wide threshold, converting to the wide sidebar. + const wideLayout = resolveLeftNavigationDrag(session.effective, LEFT_RAIL_PX + LEFT_WIDE_THRESHOLD_PX + 40); + session = advanceLeftNavigationResize(session, wideLayout); + const committed = commitLeftNavigationResize(session); + expect(committed.mode).toBe('wide'); + // The wide memory reflects where the session actually ended. + expect(committed.wideWidthPx).toBe(wideLayout.wideWidthPx); + // The drawer memory is the ORIGINAL preferred value, not the 259px it + // transiently passed through mid-session. + expect(committed.drawerWidthPx).toBe(200); + }); + + it('the preferred/effective divergence case: a fold-through commits the PREFERRED wide width, not the viewport-clamped effective one', () => { + const preferred = wide({ wideWidthPx: 420, drawerWidthPx: 210 }); + // The viewport at session start could only render 313px, well inside the + // legal wide band, so this is a legitimate effectiveAtStart on its own. + const effectiveAtStart = wide({ wideWidthPx: 313, drawerWidthPx: 210 }); + let session = beginLeftNavigationResize(preferred, effectiveAtStart); + // The gesture keeps going left and folds all the way to bare rail. + session = advanceLeftNavigationResize(session, resolveLeftNavigationDrag(effectiveAtStart, 10)); + const committed = commitLeftNavigationResize(session); + expect(committed.mode).toBe('rail'); + // 420, the PREFERRED value — not 313, the clamped value the session actually + // rendered at the start. + expect(committed.wideWidthPx).toBe(420); + expect(committed.drawerWidthPx).toBe(210); + }); + + it('a dense sweep and a coarse sweep over the same drag commit the same result — even though the underlying reducer state they pass through provably disagrees', () => { + // This is the exact counter-example the "restore memory is sampling-dependent + // (phase 3 obligation)" test above pins at the raw-reducer level: dragging + // from a 300px wide sidebar to a fold, sampling a dead-zone point (179) along + // the way freezes the underlying layout's OWN wideWidthPx at the 180 floor, + // while jumping straight from 300 to the fold in one step leaves it at 300 — + // two different answers for what is, physically, the same gesture ending at + // the same pointer position. A session must not inherit that disagreement. + const start = wide({ wideWidthPx: 300, drawerWidthPx: 210 }); + + // Dense: samples the dead zone (179) before the fold. + let denseSession = beginLeftNavigationResize(start, start); + let denseLayout: LeftNavigationLayout = start; + for (const x of [200, 179, 139]) { + denseLayout = resolveLeftNavigationDrag(denseLayout, x); + denseSession = advanceLeftNavigationResize(denseSession, denseLayout); + } + // Sanity check on the known artifact this dense path produces. + expect(denseLayout.mode).toBe('rail'); + expect(denseLayout.wideWidthPx).toBe(LEFT_PANEL_MIN_PX); + + // Coarse: one jump straight past the fold threshold, skipping the dead zone. + let coarseSession = beginLeftNavigationResize(start, start); + const coarseLayout = resolveLeftNavigationDrag(start, 139); + coarseSession = advanceLeftNavigationResize(coarseSession, coarseLayout); + // Sanity check on the known — and DIFFERENT — artifact the coarse path + // produces for the very same underlying field. + expect(coarseLayout.mode).toBe('rail'); + expect(coarseLayout.wideWidthPx).toBe(300); + + // Despite that disagreement in the raw layouts, both sessions reconstruct + // from `preferredAtStart` on a fold-through, so both commit identically. + const denseCommitted = commitLeftNavigationResize(denseSession); + const coarseCommitted = commitLeftNavigationResize(coarseSession); + expect(denseCommitted).toEqual(coarseCommitted); + expect(denseCommitted).toEqual(rail({ wideWidthPx: 300, drawerWidthPx: 210 })); + }); }); From c7e3e74c0b84762838183ed4e25adfafd136d145 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Thu, 30 Jul 2026 10:05:25 +0200 Subject: [PATCH 07/78] feat(#487): left navigation controller seam (phase 3, step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New src/application/left-nav.ts: openFocusedSection/toggleFocusedSection compose the pane-selection write and the layout write into ONE batch(), fixing a real atomicity gap the phase-2 handoff flagged (two independently batched functions can let an effect observe a mismatched intermediate state). Also fixes a confirmed persistence gap: opening a lower section via this seam now persists `sidePanel` exactly like the wide sidebar's own tab switch already does (saved-history.ts's switchTo), so a rail/drawer selection survives a reload. Wires app.openFocusedSection as the deterministic seam #428 needs. No UI calls it yet — the rail/drawer land in a later step of this phase. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NZfeGpkpJw2zsfPtysGHUN --- src/application/left-nav.ts | 147 +++++++++++++++++++++++++ src/ui/app.ts | 5 + src/ui/app.types.ts | 7 ++ tests/helpers/fake-app.ts | 1 + tests/unit/app.test.ts | 15 +++ tests/unit/left-nav.test.ts | 207 ++++++++++++++++++++++++++++++++++++ 6 files changed, 382 insertions(+) create mode 100644 src/application/left-nav.ts create mode 100644 tests/unit/left-nav.test.ts diff --git a/src/application/left-nav.ts b/src/application/left-nav.ts new file mode 100644 index 00000000..15b51b5c --- /dev/null +++ b/src/application/left-nav.ts @@ -0,0 +1,147 @@ +// #487 phase 3 — the desktop left navigation's controller seam. The pure mode +// machine lives in `core/left-nav-layout.ts`; this module is the thin async/ +// stateful glue that reads `AppState`'s scattered fields into one +// `LeftNavigationLayout`, drives the reducers, and writes the result back — +// plus the one persistence gap phase 3 exists to close (see +// `selectSectionInExistingPane` below). +// +// Typed against a narrow structural interface, not `App`/`AppState` from +// `src/ui/`: `src/application/**` must never import `src/ui/**` or +// `src/editor/**` (build/check-boundaries.mjs), and a real `App` satisfies the +// shape below directly — the same convention `library-assignment-service.ts` +// and `app-preferences.ts` use. + +import { batch } from '@preact/signals-core'; +import type { Signal } from '@preact/signals-core'; +import { + resolveRailActivation, resolveRailOpen, sidePanelKeyFor, +} from '../core/left-nav-layout.js'; +import type { + LeftNavigationLayout, LeftNavigationMode, LeftNavigationSection, SidePanelKey, +} from '../core/left-nav-layout.js'; + +/** The `AppState` fields the left navigation reads and writes, named exactly as + * `state.ts` names them. Some are signals (repainted/observed reactively), + * some are plain numbers (written like any other splitter width, persisted + * only on a later resize-session commit) — that split matches `state.ts` + * exactly and matters for every write below. */ +export interface LeftNavStateSlice { + sidebarPx: number; + leftNavDrawerPx: number; + readonly leftNavMode: Signal; + readonly leftNavSection: Signal; + readonly upperRole: Signal<'databases' | 'dashboards'>; + readonly sidePanel: Signal; +} + +/** The one persistence call this module makes — `app.prefs.save`'s real + * signature (`AppPreferences.save`) takes any `PreferenceKey`; this module + * only ever names `'sidePanel'`, so the seam is narrowed to that one key + * rather than importing the full `PreferenceKey` union from + * `application/app-preferences.ts`. */ +export interface LeftNavApp { + readonly state: LeftNavStateSlice; + readonly prefs: { save(name: 'sidePanel', value: SidePanelKey): void }; +} + +/** + * Project the scattered `AppState` fields this module reads into one + * `LeftNavigationLayout` — the shape every reducer in `core/left-nav-layout.ts` + * takes and returns. Exported: later phase-3 steps (the resize separator, the + * app-shell repaint effect) need the same projection. + */ +export function readLeftNavigationLayout(state: LeftNavStateSlice): LeftNavigationLayout { + return { + mode: state.leftNavMode.value, + wideWidthPx: state.sidebarPx, + drawerWidthPx: state.leftNavDrawerPx, + focusedSection: state.leftNavSection.value, + }; +} + +/** + * Drive the pane that ALREADY shows this section when there is no drawer to + * open for it — the wide sidebar's upper role switch, or its lower + * library/history tab. Not exported: it is only ever the one-signal half of + * `openFocusedSection`/`toggleFocusedSection`'s single batched write, never a + * standalone operation (see those functions' own comments for why they must + * not each get their own `batch()`). + * + * The lower-section branch is the fix phase 3 exists to make: today only the + * wide sidebar's own tab click (`ui/saved-history.ts`'s `switchTo`) persists + * `sidePanel`, so a rail/drawer selection of the same section left the + * signal's NEW value unpersisted — a reload would silently revert to + * whichever pane was last chosen through the wide tabs. Persisting BEFORE + * writing the signal mirrors `switchTo` exactly. `state.libraryFilter` is + * deliberately untouched — a later phase-3 step owns per-section filters. + */ +function selectSectionInExistingPane(app: LeftNavApp, section: LeftNavigationSection): void { + if (section === 'databases' || section === 'dashboards') { + // Session-only, like `switchTo`'s counterpart for the upper pane: `upperRole` + // is never persisted (state.ts), so there is nothing to save here. + app.state.upperRole.value = section; + return; + } + const panel = sidePanelKeyFor(section); + app.prefs.save('sidePanel', panel); + app.state.sidePanel.value = panel; +} + +/** + * Write a resolved `LeftNavigationLayout` back onto the scattered signals/ + * fields it was read from. Not exported, for the same reason as + * `selectSectionInExistingPane`: it is only ever the other half of one batched + * write. + * + * Every caller only ever hands this the result of `resolveRailOpen` / + * `resolveRailActivation`, and neither reducer changes `mode` or either width + * in practice (rail-only reducers) — so writing all four fields + * unconditionally is harmless, and simpler than special-casing which changed. + * It does NOT call `prefs.save` for `leftNavMode`/`leftNavDrawerPx`: that + * persistence belongs to a later step's resize-session commit, not to opening + * a section. + */ +function writeLeftNavigationLayout(state: LeftNavStateSlice, layout: LeftNavigationLayout): void { + state.leftNavMode.value = layout.mode; + state.sidebarPx = layout.wideWidthPx; + state.leftNavDrawerPx = layout.drawerWidthPx; + state.leftNavSection.value = layout.focusedSection; +} + +/** + * Open a section IDEMPOTENTLY (`core/left-nav-layout.ts`'s `resolveRailOpen`) — + * the deterministic seam #487 asks the left navigation to expose for #428's + * bounded drag-hover, and the one a plain rail-icon click also uses. + * + * In rail mode this opens (or keeps open) the focused drawer; in wide mode + * `resolveRailOpen` returns the layout unchanged (both panes already show, so + * `selectSectionInExistingPane` is the only effect), and for a lower section it + * ALSO drives the existing wide-mode pane switch, so calling this while wide + * still selects the right lower tab. Both halves are wrapped in exactly ONE + * `batch()` call: composing two independently-`batch()`-wrapped functions is + * NOT one atomic transition, since Preact signals' `batch()` flushes on each + * top-level call's own exit — two separate calls would let an effect observe + * an intermediate, mismatched combination (e.g. `sidePanel` updated but + * `leftNavSection` still stale) on the frame between them. + */ +export function openFocusedSection(app: LeftNavApp, section: LeftNavigationSection): void { + batch(() => { + selectSectionInExistingPane(app, section); + writeLeftNavigationLayout(app.state, resolveRailOpen(readLeftNavigationLayout(app.state), section)); + }); +} + +/** + * Toggle a section (`core/left-nav-layout.ts`'s `resolveRailActivation`) — + * the same shape as `openFocusedSection`, but a second activation of the SAME + * already-open section closes the drawer instead of re-asserting it open. + * Same single-`batch()` requirement and rationale as `openFocusedSection`. + */ +export function toggleFocusedSection(app: LeftNavApp, section: LeftNavigationSection): void { + batch(() => { + selectSectionInExistingPane(app, section); + writeLeftNavigationLayout( + app.state, resolveRailActivation(readLeftNavigationLayout(app.state), section), + ); + }); +} diff --git a/src/ui/app.ts b/src/ui/app.ts index b0df5c9f..abe7fde7 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -43,6 +43,7 @@ import { renderTabs, selectTab, newTab, closeTab, loadIntoNewTab, openVariableTa import type { QueryOrName } from './tabs.js'; import { commitVariableConfig } from '../application/dashboard-variable-config.js'; import { dashboardVariables } from '../application/dashboard-tree-model.js'; +import { openFocusedSection } from '../application/left-nav.js'; import { normalizeVariableSql } from '../core/dashboard-variables.js'; import { batch } from '@preact/signals-core'; import { renderResults } from './results.js'; @@ -2936,6 +2937,10 @@ export function createApp(env: CreateAppEnv = {}): App { if (query) openQueryDocument(query); }; + // #487 phase 3 — the left navigation's controller seam. `app` (state + + // prefs) satisfies `left-nav.ts`'s narrow structural `LeftNavApp` directly. + app.openFocusedSection = (section) => { openFocusedSection(app, section); }; + // #535 — the tile's expand action. Order matters: the tree is revealed FIRST, // exactly as the Library-drop settlement does it (ui/dashboard-tree.ts), so the // row is expanded and armed as the tree's position and then `loadIntoNewTab` diff --git a/src/ui/app.types.ts b/src/ui/app.types.ts index 096d8fb4..2f5d4da0 100644 --- a/src/ui/app.types.ts +++ b/src/ui/app.types.ts @@ -17,6 +17,7 @@ import type { WorkspaceExternallyChangedInfo, WorkspaceMutationInput, WorkspaceMutationOutcome, } from '../state.js'; import type { DocTarget } from '../core/doc-types.js'; +import type { LeftNavigationSection } from '../core/left-nav-layout.js'; import type { QueryExecutionService } from '../application/query-execution-service.js'; import type { ConnectionSession, SessionChCtx } from '../application/connection-session.js'; import type { AuthenticatedExecutionScope } from '../application/authenticated-execution-scope.js'; @@ -542,6 +543,12 @@ export interface App { * #443 — the id is resolved BEFORE anything moves: one that names no saved * query reports a diagnostic and changes no surface, no route and no tab. */ openSavedQuery(queryId: string): void; + /** #487 phase 3 — open (or re-assert open) the left navigation's focused + * drawer on `section`, idempotently: repeated calls with the drawer already + * showing this section are a no-op (`core/left-nav-layout.ts`'s + * `resolveRailOpen`). In wide mode there is no drawer, so this only drives + * the existing upper/lower pane switch for `section`. */ + openFocusedSection(section: LeftNavigationSection): void; /** * #535 — a Dashboard TILE's own expand action: everything `openSavedQuery` * does, plus the two things that make it an act of "go work on this panel" diff --git a/tests/helpers/fake-app.ts b/tests/helpers/fake-app.ts index 20edb92c..f22cdbf7 100644 --- a/tests/helpers/fake-app.ts +++ b/tests/helpers/fake-app.ts @@ -597,6 +597,7 @@ const appDefaults: App = { showQuerySurface: () => {}, showDashboardSurface: () => {}, openSavedQuery: () => {}, + openFocusedSection: () => {}, openPanelQuery: () => {}, openVariableTab: () => {}, actions: {} as ActionsRegistry, diff --git a/tests/unit/app.test.ts b/tests/unit/app.test.ts index b7857320..67071177 100644 --- a/tests/unit/app.test.ts +++ b/tests/unit/app.test.ts @@ -7093,6 +7093,21 @@ describe('unified /sql routing', () => { expect(toastEl.textContent).toBe('That query is no longer part of this workspace.'); }); + // #487 phase 3 — `app.openFocusedSection` is a thin controller-seam + // delegate to `application/left-nav.ts`'s own `openFocusedSection` + // (exhaustively covered in `left-nav.test.ts`); this just proves the + // wiring actually calls through into real `AppState`. + it('openFocusedSection delegates through to application/left-nav.ts', () => { + const { app } = readyApp(['a']); + app.state.leftNavMode.value = 'rail'; + app.state.sidePanel.value = 'history'; + + app.openFocusedSection('library'); + + expect(app.state.sidePanel.value).toBe('saved'); + expect(app.state.leftNavSection.value).toBe('library'); + }); + // #535 — the tile's expand action. `openSavedQuery` opens a document; // `openPanelQuery` opens a PANEL: same tab, plus a run and a tree reveal, so // leaving the Dashboard neither loses the user's place in it nor drops them on diff --git a/tests/unit/left-nav.test.ts b/tests/unit/left-nav.test.ts new file mode 100644 index 00000000..12d04a11 --- /dev/null +++ b/tests/unit/left-nav.test.ts @@ -0,0 +1,207 @@ +// #487 phase 3 — the left navigation's controller seam (`application/left-nav.ts`). +// The pure mode machine itself (`resolveRailOpen`/`resolveRailActivation`/…) is +// covered exhaustively in `left-nav-layout.test.ts`; what is tested here is the +// glue: the state-slice projection, the persistence gap the module exists to +// close (a rail/drawer selection of a lower section now persists `sidePanel`, +// matching the wide sidebar's own tab switch), and — the specific regression +// this phase's design review caught — that `openFocusedSection`/ +// `toggleFocusedSection` commit as ONE atomic signals transition, never two. + +import { describe, it, expect, vi } from 'vitest'; +import { effect, signal } from '@preact/signals-core'; +import { + openFocusedSection, readLeftNavigationLayout, toggleFocusedSection, +} from '../../src/application/left-nav.js'; +import type { LeftNavApp, LeftNavStateSlice } from '../../src/application/left-nav.js'; +import { + LEFT_DRAWER_DEFAULT_PX, LEFT_WIDE_DEFAULT_PX, +} from '../../src/core/left-nav-layout.js'; +import type { LeftNavigationSection, SidePanelKey } from '../../src/core/left-nav-layout.js'; + +/** A fake `LeftNavStateSlice`, rail mode by default (the mode every reducer + * here actually acts on) with no section focused and Library as the lower + * pane — override via `over`. */ +function makeState(over: Partial<{ + mode: 'wide' | 'rail'; + sidebarPx: number; + leftNavDrawerPx: number; + section: LeftNavigationSection | null; + upperRole: 'databases' | 'dashboards'; + sidePanel: SidePanelKey; +}> = {}): LeftNavStateSlice { + return { + sidebarPx: over.sidebarPx ?? LEFT_WIDE_DEFAULT_PX, + leftNavDrawerPx: over.leftNavDrawerPx ?? LEFT_DRAWER_DEFAULT_PX, + leftNavMode: signal(over.mode ?? 'rail'), + leftNavSection: signal(over.section ?? null), + upperRole: signal(over.upperRole ?? 'databases'), + sidePanel: signal(over.sidePanel ?? 'saved'), + }; +} + +function makeApp(state: LeftNavStateSlice): LeftNavApp & { save: ReturnType } { + const save = vi.fn(); + return { state, prefs: { save }, save }; +} + +describe('readLeftNavigationLayout', () => { + it('maps every field from the state slice', () => { + const state = makeState({ + mode: 'rail', sidebarPx: 300, leftNavDrawerPx: 200, section: 'library', + }); + expect(readLeftNavigationLayout(state)).toEqual({ + mode: 'rail', wideWidthPx: 300, drawerWidthPx: 200, focusedSection: 'library', + }); + }); + + it('maps a wide/no-focus state too', () => { + const state = makeState({ mode: 'wide', section: null }); + expect(readLeftNavigationLayout(state)).toEqual({ + mode: 'wide', + wideWidthPx: LEFT_WIDE_DEFAULT_PX, + drawerWidthPx: LEFT_DRAWER_DEFAULT_PX, + focusedSection: null, + }); + }); +}); + +describe('openFocusedSection — lower sections persist sidePanel', () => { + it('opening Library from a mismatched History state fixes both signals together', () => { + const state = makeState({ mode: 'rail', sidePanel: 'history', section: 'history' }); + const app = makeApp(state); + + openFocusedSection(app, 'library'); + + expect(state.sidePanel.value).toBe('saved'); + expect(state.leftNavSection.value).toBe('library'); + expect(app.save).toHaveBeenCalledWith('sidePanel', 'saved'); + }); + + it('opening History from a mismatched Library state fixes both signals together', () => { + const state = makeState({ mode: 'rail', sidePanel: 'saved', section: 'library' }); + const app = makeApp(state); + + openFocusedSection(app, 'history'); + + expect(state.sidePanel.value).toBe('history'); + expect(state.leftNavSection.value).toBe('history'); + expect(app.save).toHaveBeenCalledWith('sidePanel', 'history'); + }); + + it('does not touch libraryFilter or any field beyond the four it owns', () => { + const state = makeState({ mode: 'rail' }) as LeftNavStateSlice & { libraryFilter: string }; + state.libraryFilter = 'unchanged-marker'; + const app = makeApp(state); + + openFocusedSection(app, 'library'); + + expect(state.libraryFilter).toBe('unchanged-marker'); + }); +}); + +describe('openFocusedSection — upper sections never persist', () => { + it('opening Dashboards from a mismatched Databases state fixes both signals, no persistence', () => { + const state = makeState({ mode: 'rail', upperRole: 'databases', section: 'databases' }); + const app = makeApp(state); + + openFocusedSection(app, 'dashboards'); + + expect(state.upperRole.value).toBe('dashboards'); + expect(state.leftNavSection.value).toBe('dashboards'); + expect(app.save).not.toHaveBeenCalled(); + }); + + it('opening Databases from a mismatched Dashboards state fixes both signals, no persistence', () => { + const state = makeState({ mode: 'rail', upperRole: 'dashboards', section: 'dashboards' }); + const app = makeApp(state); + + openFocusedSection(app, 'databases'); + + expect(state.upperRole.value).toBe('databases'); + expect(state.leftNavSection.value).toBe('databases'); + expect(app.save).not.toHaveBeenCalled(); + }); +}); + +describe('atomicity — one batched transition, not two', () => { + it('an effect reading both the pane signal and leftNavSection runs exactly once, and only ever sees the final combination', () => { + const state = makeState({ mode: 'rail', sidePanel: 'history', section: 'history' }); + const app = makeApp(state); + + const seen: Array<{ panel: SidePanelKey; section: LeftNavigationSection | null }> = []; + const dispose = effect(() => { + seen.push({ panel: state.sidePanel.value, section: state.leftNavSection.value }); + }); + // The effect above runs once on install; clear that baseline observation so + // the assertions below are only about the call under test. + seen.length = 0; + + openFocusedSection(app, 'library'); + + expect(seen).toHaveLength(1); + expect(seen[0]).toEqual({ panel: 'saved', section: 'library' }); + + dispose(); + }); + + it('same atomicity guarantee for toggleFocusedSection', () => { + const state = makeState({ mode: 'rail', upperRole: 'databases', section: 'databases' }); + const app = makeApp(state); + + const seen: Array<{ role: 'databases' | 'dashboards'; section: LeftNavigationSection | null }> = []; + const dispose = effect(() => { + seen.push({ role: state.upperRole.value, section: state.leftNavSection.value }); + }); + seen.length = 0; + + toggleFocusedSection(app, 'dashboards'); + + expect(seen).toHaveLength(1); + expect(seen[0]).toEqual({ role: 'dashboards', section: 'dashboards' }); + + dispose(); + }); +}); + +describe('toggleFocusedSection — closes an already-open section', () => { + it('toggling the section that is already focused closes the drawer (resolveRailActivation semantics)', () => { + const state = makeState({ mode: 'rail', section: 'library', sidePanel: 'saved' }); + const app = makeApp(state); + + toggleFocusedSection(app, 'library'); + + expect(state.leftNavSection.value).toBeNull(); + }); + + it('toggling a DIFFERENT section than the one open focuses the new one instead (no close)', () => { + const state = makeState({ mode: 'rail', section: 'library', sidePanel: 'saved' }); + const app = makeApp(state); + + toggleFocusedSection(app, 'history'); + + expect(state.leftNavSection.value).toBe('history'); + }); + + it('openFocusedSection is idempotent instead of toggling — repeated calls keep the section open', () => { + const state = makeState({ mode: 'rail', section: 'library', sidePanel: 'saved' }); + const app = makeApp(state); + + openFocusedSection(app, 'library'); + + expect(state.leftNavSection.value).toBe('library'); + }); +}); + +describe('wide mode — no drawer, only the pane switch applies', () => { + it('openFocusedSection in wide mode leaves mode/section alone but still switches the pane', () => { + const state = makeState({ mode: 'wide', section: null, sidePanel: 'history' }); + const app = makeApp(state); + + openFocusedSection(app, 'library'); + + expect(state.leftNavMode.value).toBe('wide'); + expect(state.leftNavSection.value).toBeNull(); + expect(state.sidePanel.value).toBe('saved'); + expect(app.save).toHaveBeenCalledWith('sidePanel', 'saved'); + }); +}); From ad36b0cb46e0af8381f3fbfd248612f2adc0e35f Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Thu, 30 Jul 2026 10:30:37 +0200 Subject: [PATCH 08/78] feat(#487): per-section lower-nav filters and independent rendering (phase 3, step 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the shared state.libraryFilter with lowerNavigationFilters, a per-section record — switching between Library and History no longer clears the search box, required by this phase's own acceptance bullet that wide and focused presentations share and preserve all navigation state (a deliberate, user-visible change from phase 2's "unchanged" gate, documented in the CHANGELOG). Splits renderSavedHistory so both lower sections render their own content unconditionally, mirroring the upper pane's renderSchema/ renderDashboardTree pattern, closing two confirmed bugs: a section not active at mount never painted until the first switch, and a section's content going stale when its own data changed while the other section was exposed (History-while-Library-active, and the reverse). The two call sites that guarded a History repaint behind `sidePanel === 'history'` (app.recordHistory, the script-run history path) are now unconditional. Also closes #572: the lower switcher's tabs gain type="button" and aria-pressed, matching the upper switcher. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NZfeGpkpJw2zsfPtysGHUN --- CHANGELOG.md | 14 ++ src/core/dashboard-tree-ui-state.ts | 2 +- src/state.ts | 17 ++- src/ui/app-shell.ts | 43 ++++-- src/ui/app.ts | 19 ++- src/ui/saved-history.ts | 184 ++++++++++++++++---------- src/ui/workbench/workbench-session.ts | 27 ++-- tests/unit/app-shell.test.ts | 86 ++++++++++-- tests/unit/app.test.ts | 18 +++ tests/unit/saved-history.test.ts | 159 +++++++++++++++++++--- tests/unit/state.test.ts | 4 + tests/unit/workbench-session.test.ts | 17 ++- 12 files changed, 455 insertions(+), 135 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91943d6a..2d17159e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,20 @@ auto-generated per-PR notes; this file is the curated, human-readable history. focused drawer and the resize separator arrive in phase 3. ### Changed +- **Switching between Library and History no longer clears the search box** + (#487, phase 3 of 4). Each lower-navigation section now keeps its own filter + text (`state.lowerNavigationFilters`, replacing the single shared + `state.libraryFilter`), preserved across every switch between them — a + deliberate, user-visible behavior change from phase 2's "a section switch + still clears the search exactly as before," required by phase 3's own + acceptance bullet that wide and focused presentations share and preserve all + navigation state. Both sections' own search box and list now also render + unconditionally, independently of which section is currently exposed + (mirroring the upper pane's `renderSchema`/`renderDashboardTree`), which + fixes two latent bugs: the section that wasn't active at mount never painted + until the first switch to it, and the section that wasn't active when its own + data changed (e.g. a query recorded to History while Library was shown) went + stale. - The sidebar's `'col'` drag axis now clamps through the same `LEFT_PANEL_MIN_PX`/`LEFT_PANEL_MAX_PX` constants as the load path, instead of repeating `180`/`420` as literals (#487). Behaviour is unchanged; it removes diff --git a/src/core/dashboard-tree-ui-state.ts b/src/core/dashboard-tree-ui-state.ts index 9c5066f2..73d6b2fb 100644 --- a/src/core/dashboard-tree-ui-state.ts +++ b/src/core/dashboard-tree-ui-state.ts @@ -14,7 +14,7 @@ // Keyed by `StoredWorkspaceV5.id` — the immutable opaque application identity // (#406) — never by the mutable `name` or the rewritable URL `key`. // -// Deliberately NOT a signal, matching `state.libraryFilter`'s precedent +// Deliberately NOT a signal, matching `state.lowerNavigationFilters`'s precedent // (`src/state.ts`): if a repaint effect observed this state, every keystroke in // the search box and every scroll frame would repaint the tree — losing the caret // on the first and doing pointless work on the second. The tree's ONE reactive diff --git a/src/state.ts b/src/state.ts index 9702cf91..a1072eab 100644 --- a/src/state.ts +++ b/src/state.ts @@ -41,7 +41,7 @@ import { sectionForSidePanelKey, sidePanelKeyFor, } from './core/left-nav-layout.js'; import type { - LeftNavigationMode, LeftNavigationSection, SidePanelKey, + LeftNavigationMode, LeftNavigationSection, LowerNavigationSection, SidePanelKey, } from './core/left-nav-layout.js'; // ── Persisted-data types (schema-generated) ───────────────────────────────── @@ -445,7 +445,13 @@ export interface AppState { workspaceId: string; /** Immutable canonical URL key for the active workspace. */ workspaceKey: string; - libraryFilter: string; + /** #487 phase 3: each lower-navigation section (Library, History) keeps its + * OWN search text, independently preserved across every switch between them + * — switching no longer clears anything. Deliberately NOT a signal (see + * `core/dashboard-tree-ui-state.ts`'s comment): a signal here would rebuild + * the search input on every keystroke, and `renderList`-only re-renders is + * how focus/caret survive typing today. */ + lowerNavigationFilters: Record; shortcutsOpen: Signal; /** * #487 — the desktop left navigation's explicit semantic mode: the established @@ -815,9 +821,10 @@ export function createState(read: StateReader = { loadJSON, loadStr }): AppState dashboard: null, workspaceId: mintWorkspaceId(), workspaceKey: deriveWorkspaceKey(initialWorkspaceName), - // Transient search text for the Library/History side panel (session-only, - // cleared on a tab switch); never persisted. - libraryFilter: '', + // Transient search text for the Library/History side panel (session-only; + // never persisted). #487 phase 3: each section keeps its own slot, preserved + // across every switch between them. + lowerNavigationFilters: { library: '', history: '' }, // Whether the keyboard-shortcuts modal is open (shortcuts.js). Session-only; // a signal for consistency with the rest of the state (no reactive reader // today — shortcuts.js drives its own mount/unmount). diff --git a/src/ui/app-shell.ts b/src/ui/app-shell.ts index 2fa9d326..da3f340d 100644 --- a/src/ui/app-shell.ts +++ b/src/ui/app-shell.ts @@ -24,7 +24,7 @@ // // `deps.app` is kept for the same reasons `mountWorkbenchShell` keeps it // (see that module's own header comment): the render-module pass-through -// (renderSchema/renderSavedHistory/renderLibraryTitle all +// (renderSchema/renderLowerTabs/renderLibrarySection/renderLibraryTitle all // still take the full `App`), and the `app.dom` reset + population other // modules read `app.dom.*` off of directly. @@ -38,7 +38,7 @@ import { buildSidebarUpper, renderUpperRoleTabs } from './sidebar-upper.js'; import { buildNavSectionRegistry, sectionForSidePanelKey } from './nav-sections.js'; import type { NavSectionPane } from './nav-sections.js'; import { renderDashboardTree, cancelDashboardTreeClicks } from './dashboard-tree.js'; -import { renderSavedHistory } from './saved-history.js'; +import { renderLowerTabs, renderLibrarySection, renderHistorySection } 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'; @@ -52,7 +52,7 @@ import type { AppPreferences, PreferenceKey } from '../application/app-preferenc * shell's own logic, never through `app.*`. */ export interface AppShellDeps { /** Kept ONLY for: the render-module pass-through (renderSchema/ - * renderSavedHistory/renderLibraryTitle), and the + * renderLowerTabs/renderLibrarySection/renderLibraryTitle), and the * `app.dom` reset + population (other modules read `app.dom.*` * directly — see the header comment). */ app: App; @@ -285,20 +285,47 @@ export function mountAppShell(deps: AppShellDeps): AppShellHandle { state.schemaError.value; updateBanner(); })); - // Reactive repaint of the side panel: re-runs when the active panel changes - // (Library ↔ History). Data-driven repaints (savedQueries/history mutations) - // still call renderSavedHistory directly until those slices are signals too. + // Reactive repaint of the lower tab row: re-runs when the active panel changes + // (Library ↔ History) or the Library count might have (projection revision). + // #487 phase 3 split this from the content repaint below, mirroring the upper + // pane's own split (`renderUpperRoleTabs` vs `renderSchema`) — switching which + // section is exposed repaints only the tab row's active class/count, never + // either section's content. + disposers.push(effect(() => { + state.sidePanel.value; + state.dashboardTreeRevision.value; + renderLowerTabs(app); + })); + // Reactive repaint of Library's own content: re-runs on the projection + // revision alone, regardless of which lower section is exposed (#487 phase 3) + // — mirroring `renderSchema`, which does not subscribe to `upperRole` either. + // Data-driven repaints of ROW-level state (favorite/rename/delete) still call + // the full `renderSavedHistory` facade directly. // // #427 added the projection revision. Library membership is now a function of // `dashboards[]` — a query is in the Library exactly while no Dashboard member // references it — so a committed Dashboard change can move a query in or out of // this list without `savedQueries` changing at all. It is the same one signal // the Dashboard tree subscribes to, bumped from the single projection funnel. + // + // Deliberately no matching reactive effect for History's own content: + // `state.history` is a plain array, not a signal, so History has never been + // signal-driven — it stays current via direct calls at its mutation sites + // (`app.recordHistory`, the script-run history path, and the facade above). disposers.push(effect(() => { - state.sidePanel.value; state.dashboardTreeRevision.value; - renderSavedHistory(app); + renderLibrarySection(app); })); + // History's OWN initial paint: unlike Library, History has no signal to key a + // reactive effect off (see the comment above), so it cannot pick up its first + // paint by registering one. Without this direct, one-time call the History + // host would stay the empty div `nav-sections.ts` built it as until the first + // history-recording event — reintroducing, for History specifically, the exact + // "section that wasn't active at mount never got painted" bug this phase + // fixes for Library via the effect above. Not an effect itself (no signal + // read, so it never re-runs) — every subsequent History repaint still comes + // from its own mutation sites or the full `renderSavedHistory` facade. + renderHistorySection(app); // Reactive repaint of the header library title (name + unsaved-changes dot): // re-runs when the name or dirty flag changes. The edit-mode toggle is driven // separately (editingLibrary is not a signal — file-menu.js renders it directly). diff --git a/src/ui/app.ts b/src/ui/app.ts index abe7fde7..d43d98ac 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -56,7 +56,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 { renderSavedHistory } from './saved-history.js'; +import { renderSavedHistory, renderHistorySection } from './saved-history.js'; import { applyFieldState, applyFieldWidth } from './var-field.js'; import { buildRelativeTimeField } from './relative-time-field.js'; import type { RelativeTimeField } from './relative-time-field.js'; @@ -986,7 +986,7 @@ export function createApp(env: CreateAppEnv = {}): App { activeTab: () => app.activeTab(), hooks: { renderResults: () => renderResults(app), - renderSavedHistory: () => renderSavedHistory(app), + renderHistorySection: () => renderHistorySection(app), cancelSchemaGraph, loadSchema: () => { void catalog.loadSchema(); }, recordHistory: (tab, sql) => app.recordHistory(tab, sql), @@ -1558,12 +1558,19 @@ export function createApp(env: CreateAppEnv = {}): App { // --- saved / history bridges ------------------------------------------ // The history-recording POLICY itself now lives in `saved.recordHistory` - // (#276 Phase 4C) — this wrapper's own conditional History-panel repaint is - // a rendering concern the service must never own (see its header comment), - // so it stays here, unchanged. + // (#276 Phase 4C) — this wrapper's own History-panel repaint is a rendering + // concern the service must never own (see its header comment), so it stays + // here. + // + // #487 phase 3: unconditional — History's own content must stay current even + // while Library is the exposed section, since History repaints only at its + // own mutation sites (`state.history` is a plain array, not a signal, so it + // is not part of any reactive repaint effect). Only History's own data + // changed here, so this calls `renderHistorySection` rather than the full + // `renderSavedHistory` facade. app.recordHistory = (tab, sqlText) => { saved.recordHistory(tab, sqlText); - if (app.state.sidePanel.value === 'history') renderSavedHistory(app); + renderHistorySection(app); }; // --- share + star ------------------------------------------------------ diff --git a/src/ui/saved-history.ts b/src/ui/saved-history.ts index 496ce974..eebf8277 100644 --- a/src/ui/saved-history.ts +++ b/src/ui/saved-history.ts @@ -1,16 +1,24 @@ // The bottom sidebar pane: a Library / History switcher and, per section, its own // search box and list. Saved items support favorite (star), inline rename (pencil) -// and delete (trash). The search filters the active list (name/description/sql for -// Library, sql for History); it re-renders only the list so typing keeps focus. +// and delete (trash). The search filters a section's own list (name/description/sql +// for Library, sql for History); it re-renders only the list so typing keeps focus. // // #487 phase 2 split the two sections' DOM: each renders into its own persistent // search/list pair (`ui/nav-sections.ts` builds them and hosts them), where before -// both shared one pair that a section switch repainted. Everything below still -// renders ONLY the active section, exactly as it always did — the switcher's -// clear-the-search semantics are unchanged, and the inactive host simply keeps the -// DOM it last painted until it is shown again. What the split buys is that a -// container can move one section's live elements (phase 3's focused drawer) -// without taking the other section's content along. +// both shared one pair that a section switch repainted. +// +// #487 phase 3 finishes the split: BOTH sections now render their own content +// unconditionally, independently of which one is exposed — mirroring the upper +// pane's `renderSchema`/`renderDashboardTree`, which have always painted on their +// own data triggers regardless of `state.upperRole`. Exposure (which host is +// visible) is a wholly separate concern, owned by `app-shell.ts`'s registry +// effect. This fixes two real bugs the old "paint only the active section" design +// had: the section that wasn't active at mount never got its first paint (blank +// until the first switch), and the section that wasn't active when its own data +// changed (e.g. a query runs and gets recorded to History while Library is shown) +// went stale until the next unrelated repaint. Each section also now keeps its +// OWN filter text (`state.lowerNavigationFilters`) rather than sharing one string, +// so switching between them no longer clears anything. import { h } from './dom.js'; import { Icon } from './icons.js'; @@ -113,43 +121,55 @@ function libraryEntries(app: App): SavedQueryV2[] { } /** - * The active section, in the registry's vocabulary. EVERY branch in this module - * goes through this one function rather than comparing `sidePanel` to `'saved'` - * directly (#487 phase 2). With two hosts, a reader that resolves an unrecognized - * value differently from the shell's exposure effect would expose one section's - * host and paint into the other's — a blank pane. `state.ts` also decodes the - * stored value at load, so the two guards are belt and braces on purpose. + * The active section, in the registry's vocabulary — used ONLY to decide the tab + * row's "active" class/`aria-pressed` state. It no longer decides which section's + * content renders (#487 phase 3: both sections always render their own, regardless + * of which is exposed). `state.ts` also decodes the stored value at load. */ const activeSection = (app: App): LowerNavigationSection => sectionForSidePanelKey(app.state.sidePanel.value); -/** The ACTIVE section's own search box and list (#487 phase 2) — the two lower - * sections no longer share one pair. */ -const activeEls = (app: App): { search: HTMLElement | undefined; list: HTMLElement | undefined } => - activeSection(app) === 'library' +/** A given section's OWN search box and list hosts. Deliberately keyed by an + * explicit `section` parameter rather than "whichever is active" (#487 phase 3) + * — each section's render functions always target their own hosts. */ +const elsFor = (app: App, section: LowerNavigationSection): { search: HTMLElement | undefined; list: HTMLElement | undefined } => + section === 'library' ? { search: app.dom.savedSearch, list: app.dom.savedList } : { search: app.dom.historySearch, list: app.dom.historyList }; -export function renderSavedHistory(app: App): void { +/** Read a section's own search filter text (#487 phase 3 — each lower-navigation + * section keeps its own, so switching between them preserves both instead of + * clearing one shared string). Exported for later phase-3 steps. */ +export function filterFor(state: AppState, section: LowerNavigationSection): string { + return state.lowerNavigationFilters[section]; +} + +/** Write a section's own search filter text. See `filterFor`. */ +export function setFilterFor(state: AppState, section: LowerNavigationSection, value: string): void { + state.lowerNavigationFilters[section] = value; +} + +/** + * The tab row only: the Library/History switcher plus the Library count badge. + * #487 phase 2: the tab row speaks the registry's SECTION vocabulary and derives + * the persisted value once, through the one mapping — rather than repeating the + * `'library' means 'saved'` knowledge here. + * + * #487 phase 3: switching sections no longer clears anything — each section keeps + * its own filter text (`state.lowerNavigationFilters`), preserved across every + * switch. `switchTo` now only persists the choice and sets which section is + * exposed. + */ +export function renderLowerTabs(app: App): void { const tabsRow = app.dom.savedTabsRow; - const list = activeEls(app).list; - if (!tabsRow || !list) return; + if (!tabsRow) return; const state = app.state; // #427: the count is the LIBRARY count, not every stored query — the owned // copies are reachable through the Dashboard tree, not through this list. const count = libraryEntries(app).length; - // Switching panes clears the search so each tab starts unfiltered. Clear the - // (plain) filter first, then set the sidePanel signal — its render effect runs - // synchronously on assignment and must see the cleared filter. No manual - // re-render call: the effect in createApp() repaints. - // - // #487 phase 2: the tab row speaks the registry's SECTION vocabulary and derives - // the persisted value once, through the one mapping — rather than repeating the - // `'library' means 'saved'` knowledge here. const switchTo = (section: LowerNavigationSection): void => { const panel = sidePanelKeyFor(section); - state.libraryFilter = ''; app.prefs.save('sidePanel', panel); state.sidePanel.value = panel; }; @@ -158,6 +178,8 @@ export function renderSavedHistory(app: App): void { const meta = NAV_SECTION_META[section]; return h('button', { class: 'side-tab' + (active === section ? ' active' : ''), + type: 'button', + 'aria-pressed': active === section ? 'true' : 'false', onclick: () => switchTo(section), }, meta.icon(), h('span', null, meta.label), extra); }; @@ -166,34 +188,34 @@ export function renderSavedHistory(app: App): void { tab('library', count ? h('span', { class: 'side-count' }, '· ' + count) : null), tab('history', null), ); - - renderSearch(app); - renderList(app); } -/** Re-render just the active list (called on every keystroke without rebuilding - * the search input, so the caret/focus survive filtering). */ -function renderList(app: App): void { - // `!`: every caller (renderSavedHistory, renderSearch below) only reaches - // this after confirming the active section's list is mounted. - const list = activeEls(app).list!; +/** Render a given section's own list into a given host — favorites/rename/delete + * for Library, delete for History (`renderSaved`/`renderHistory` below), reading + * that section's OWN filter. Independent of which section is exposed. */ +function renderSectionList(app: App, section: LowerNavigationSection, list: HTMLElement): void { list.replaceChildren(); - if (activeSection(app) === 'library') renderSaved(app, list); + if (section === 'library') renderSaved(app, list); else renderHistory(app, list); } /** - * Render the search box into the ACTIVE section's own search host (built once per - * full render; a section with no items shows nothing). Its `input` handler mutates - * `state.libraryFilter` and re-renders only the list, so it stays focused. + * Render the search box into a given section's OWN search host (built once per + * full render; a section with no items shows nothing). Its `input` handler + * mutates that section's own filter slot and re-renders only that section's own + * list, so it stays focused. + * + * #487 phase 3 removed the old "does this input still own the active list" + * guard: with one shared `libraryFilter` string, a stale/hidden input's events + * could corrupt the OTHER section's filter and repaint the wrong list. With + * per-section storage that is structurally impossible — a hidden Library input + * can only ever write Library's own filter and repaint Library's own list, + * which is correct regardless of whether Library is currently exposed. */ -function renderSearch(app: App): void { - const box = activeEls(app).search; - if (!box) return; +function renderSectionSearch(app: App, section: LowerNavigationSection, box: HTMLElement): void { const state = app.state; // Gated on the LIBRARY count (#427): a workspace whose every query is owned // has an empty list, so a search box over it would filter nothing. - const section = activeSection(app); const isLibrary = section === 'library'; const hasItems = isLibrary ? libraryEntries(app).length > 0 @@ -204,31 +226,22 @@ function renderSearch(app: App): void { const input = h('input', { class: 'sv-search-input', type: 'text', placeholder: isLibrary ? 'Search library queries…' : 'Search history…', - value: state.libraryFilter, + value: filterFor(state, section), }); const clear = h('button', { class: 'sv-search-clear', title: 'Clear' }, Icon.close()); const syncClear = (): void => { clear.style.display = input.value ? '' : 'none'; }; - // These controls belong to the section that was active when they were built, and - // that host now OUTLIVES the switch away from it (#487 phase 2) — the inactive - // host keeps its DOM, listeners included. `state.libraryFilter` is still one - // shared string and `renderList` still paints the ACTIVE section, so an event - // from a stale input would rewrite the filter and repaint the OTHER section's - // list with this section's search text. Unreachable through the UI today (a - // `display: none` subtree receives no pointer or keyboard events) — but phase 3 - // moves hosts between containers, where a host can be visible while a different - // section is active, so ownership is enforced here rather than left to CSS. - // - // A guard, not a redesign: per-section filter state is what actually fixes the - // shared-string design, and #487 phase 3 owns that (see the ship log). - const ownsTheList = (): boolean => activeSection(app) === section; + const list = elsFor(app, section).list; const setFilter = (v: string): void => { - if (!ownsTheList()) return; - input.value = v; state.libraryFilter = v; syncClear(); renderList(app); + input.value = v; + setFilterFor(state, section, v); + syncClear(); + if (list) renderSectionList(app, section, list); }; input.addEventListener('input', () => { - if (!ownsTheList()) return; - state.libraryFilter = input.value; syncClear(); renderList(app); + setFilterFor(state, section, input.value); + syncClear(); + if (list) renderSectionList(app, section, list); }); input.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); setFilter(''); } }); clear.addEventListener('click', () => { setFilter(''); input.focus(); }); @@ -237,6 +250,37 @@ function renderSearch(app: App): void { box.append(h('span', { class: 'sv-search-icon' }, Icon.search()), input, clear); } +/** Library's own search + list, into Library's own hosts — unconditionally, + * regardless of whether Library is currently exposed (#487 phase 3). */ +export function renderLibrarySection(app: App): void { + const { search, list } = elsFor(app, 'library'); + if (search) renderSectionSearch(app, 'library', search); + if (list) renderSectionList(app, 'library', list); +} + +/** History's own search + list, into History's own hosts — unconditionally, + * regardless of whether History is currently exposed (#487 phase 3). */ +export function renderHistorySection(app: App): void { + const { search, list } = elsFor(app, 'history'); + if (search) renderSectionSearch(app, 'history', search); + if (list) renderSectionList(app, 'history', list); +} + +/** + * The facade: repaint the tab row and BOTH sections' own content, always (#487 + * phase 3 — mirrors the upper pane, where `renderSchema`/`renderDashboardTree` + * each paint on their own triggers regardless of `state.upperRole`). Existing + * internal call sites (favorite toggle, inline edit commit, row delete, + * history-row delete) keep calling this facade unchanged — repainting all three + * on those broader events is simple, safe, and cheap enough for these small + * lists. + */ +export function renderSavedHistory(app: App): void { + renderLowerTabs(app); + renderLibrarySection(app); + renderHistorySection(app); +} + function renderSaved(app: App, list: HTMLElement): void { const state = app.state; const surfaceGeneration = app.captureSurfaceGeneration(); @@ -250,9 +294,10 @@ function renderSaved(app: App, list: HTMLElement): void { } // Favourites first, then `workspace.queries[]` order — the projection filters, // it never reorders (#427). - const items = filterSaved(sortedSaved({ ...state, savedQueries: library }), state.libraryFilter); + const libraryFilter = filterFor(state, 'library'); + const items = filterSaved(sortedSaved({ ...state, savedQueries: library }), libraryFilter); if (items.length === 0) { - list.appendChild(h('div', { class: 'saved-empty' }, 'No library queries match “' + state.libraryFilter.trim() + '”.')); + list.appendChild(h('div', { class: 'saved-empty' }, 'No library queries match “' + libraryFilter.trim() + '”.')); return; } for (const q of items) { @@ -429,9 +474,10 @@ function renderHistory(app: App, list: HTMLElement): void { list.appendChild(h('div', { class: 'saved-empty' }, 'No history yet.')); return; } - const items = filterHistory(state.history, state.libraryFilter); + const historyFilter = filterFor(state, 'history'); + const items = filterHistory(state.history, historyFilter); if (items.length === 0) { - list.appendChild(h('div', { class: 'saved-empty' }, 'No history matches “' + state.libraryFilter.trim() + '”.')); + list.appendChild(h('div', { class: 'saved-empty' }, 'No history matches “' + historyFilter.trim() + '”.')); return; } for (const ent of items as HistoryEntry[]) { diff --git a/src/ui/workbench/workbench-session.ts b/src/ui/workbench/workbench-session.ts index b08a178b..741c2a27 100644 --- a/src/ui/workbench/workbench-session.ts +++ b/src/ui/workbench/workbench-session.ts @@ -67,13 +67,18 @@ export interface WorkbenchStateSlice { resultRowLimit: number; serverVersion: string | null; /** - * Read by runScript's clean-run history branch ('history' ⇒ repaint). + * #487 phase 3: no longer read by this session's own logic — runScript's + * clean-run history repaint (`hooks.renderHistorySection()`) is now + * unconditional, since History's content must stay current regardless of + * which lower-navigation section is exposed. Kept on the slice because + * `AppState` carries it regardless (structural pass-through) and a later + * caller may still need it; if it stays unread, a future cleanup can drop it. * * Derived from `AppState` rather than restated as `Signal` (#487 * phase 2): the real signal holds a decoded `'saved' | 'history'`, and a * structural `Signal` here would leave this session type-authorized to * write an arbitrary string into it — re-opening exactly the divergence the - * load-boundary decode closes. This slice only ever reads it. + * load-boundary decode closes. */ sidePanel: AppState['sidePanel']; isMobile: Signal; @@ -97,15 +102,18 @@ export interface WorkbenchStateSlice { export interface WorkbenchHooks { /** Per-chunk (run) + per-statement (runScript) results-pane repaint. */ renderResults(): void; - /** runScript's clean-run history repaint when `sidePanel === 'history'`. */ - renderSavedHistory(): void; + /** runScript's clean-run History repaint — unconditional (#487 phase 3): + * History's own content must stay current even while Library is the + * exposed section, since History is not part of any reactive repaint + * effect (`state.history` is a plain array, not a signal). */ + renderHistorySection(): void; cancelSchemaGraph(): void; /** Fire-and-forget schema reload after schema-mutating SQL succeeds. */ loadSchema(): void; /** Records a successful single-statement run in history (and, per the real - * app.ts wrapper this replaces, repaints History when it's the open side - * panel — that repaint is this hook's own responsibility, unlike - * `renderSavedHistory` above which the session calls itself for the + * app.ts wrapper this replaces, unconditionally repaints History's own + * content — that repaint is this hook's own responsibility, unlike + * `renderHistorySection` above which the session calls itself for the * script-history path). */ recordHistory(tab: QueryTab, sql?: string): void; recordBoundParams(bp: readonly BoundParamSnapshot[]): void; @@ -755,7 +763,10 @@ export function createWorkbenchSession(deps: WorkbenchSessionDeps): WorkbenchSes // run(): no history for an aborted or failed script). if (!aborted && !entries.some((e) => e.status === 'error')) { recordScriptHistory(state, originalInput, scriptResult.elapsedMs!, hooks.saveJSON); - if (state.sidePanel.value === 'history') hooks.renderSavedHistory(); + // #487 phase 3: unconditional — History's own content must stay current + // even while Library is the exposed section (History is not part of any + // reactive repaint effect; see `renderHistorySection`'s own doc comment). + hooks.renderHistorySection(); } retireWave(operation); } diff --git a/tests/unit/app-shell.test.ts b/tests/unit/app-shell.test.ts index 7789404c..ab610027 100644 --- a/tests/unit/app-shell.test.ts +++ b/tests/unit/app-shell.test.ts @@ -106,20 +106,24 @@ describe('mountAppShell wide navigation (#487 phase 2)', () => { }); it('switches the exposed lower host on sidePanel without rebuilding either', () => { + // #487 phase 3: content repaint is decoupled from `sidePanel` entirely (the + // Library-content effect keys only on `dashboardTreeRevision`, and History has + // no reactive trigger of its own) — a plain sidePanel flip now repaints ONLY + // the tab row (active class/count), never either section's own content, in + // EITHER direction. const { app, handle } = mount(); const host = hosts(app.root); const libraryList = app.dom.savedList!; const historyList = app.dom.historyList!; - const marker = libraryList.appendChild(document.createElement('span')); + const libraryMarker = libraryList.appendChild(document.createElement('span')); + const historyMarker = historyList.appendChild(document.createElement('span')); app.state.sidePanel.value = 'history'; expect(host.library.hidden).toBe(true); expect(host.history.hidden).toBe(false); - // A hidden host keeps its DOM: History's repaint went into History's OWN list - // and left the Library's content standing. Before the split both sections - // rendered through one pair, so this content could not have survived. - expect(libraryList.contains(marker)).toBe(true); - expect(historyList.textContent).toContain('No history yet.'); + // A hidden host keeps its DOM, and switching TO it does not rebuild it either. + expect(libraryList.contains(libraryMarker)).toBe(true); + expect(historyList.contains(historyMarker)).toBe(true); app.state.sidePanel.value = 'saved'; expect(host.library.hidden).toBe(false); @@ -132,10 +136,49 @@ describe('mountAppShell wide navigation (#487 phase 2)', () => { expect(app.dom.historyList).toBe(historyList); expect(app.root.contains(libraryList)).toBe(true); expect(app.root.contains(historyList)).toBe(true); - // Becoming active DOES repaint the section, exactly as it always has: the - // switcher clears the shared search filter, so the list is rebuilt from - // scratch. #487 phase 3 owns whether a drawer should preserve it instead. - expect(libraryList.contains(marker)).toBe(false); + // Becoming active again STILL does not repaint either section — the + // deliberate #487 phase 3 behavior change from "activating a pane clears and + // rebuilds its search/list." + expect(libraryList.contains(libraryMarker)).toBe(true); + expect(historyList.contains(historyMarker)).toBe(true); + handle.dispose(); + }); + + // #487 phase 3: the search input node inside a section's host survives a plain + // sidePanel flip — proof that switching never triggers a destructive rebuild of + // either section's content (only the tab row/exposure react to it). + it('preserves node identity of a section\'s search input across a sidePanel flip', () => { + const { app, handle } = mount(); + app.state.savedQueries = [savedQuery({ id: 's1', name: 'Q1', sql: 'SELECT 1' })]; + app.state.dashboardTreeRevision.value++; // force one real Library content paint + const libraryInput = app.dom.savedSearch!.querySelector('.sv-search-input'); + expect(libraryInput).not.toBeNull(); + + app.state.sidePanel.value = 'history'; + app.state.sidePanel.value = 'saved'; + + expect(app.dom.savedSearch!.querySelector('.sv-search-input')).toBe(libraryInput); + handle.dispose(); + }); + + it('a bare dashboardTreeRevision bump (no sidePanel change) still repaints Library', () => { + const { app, handle } = mount(); + app.state.savedQueries = [savedQuery({ id: 's1', name: 'Q1', sql: 'SELECT 1' })]; + + app.state.dashboardTreeRevision.value++; + + expect(app.dom.savedList!.querySelectorAll('.saved-row')).toHaveLength(1); + handle.dispose(); + }); + + it('a sidePanel change alone still repaints the tab row\'s active class', () => { + const { app, handle } = mount(); + + app.state.sidePanel.value = 'history'; + + const tabs = [...app.dom.savedTabsRow!.querySelectorAll('.side-tab')]; + expect(tabs[0].classList.contains('active')).toBe(false); + expect(tabs[1].classList.contains('active')).toBe(true); handle.dispose(); }); @@ -150,13 +193,34 @@ describe('mountAppShell wide navigation (#487 phase 2)', () => { const { app, handle } = mount(); const host = hosts(app.root); app.state.savedQueries = [savedQuery({ id: 's1', name: 'Q1', sql: 'SELECT 1' })]; + app.state.dashboardTreeRevision.value++; // force Library's own content effect to (re)paint (app.state.sidePanel as { value: string }).value = 'queries'; expect(host.library.hidden).toBe(false); expect(host.history.hidden).toBe(true); expect(app.dom.savedList!.querySelectorAll('.saved-row')).toHaveLength(1); - expect(app.dom.historyList!.children.length).toBe(0); + // #487 phase 3: History now ALSO always renders its own content — its host is + // never truly blank, even when Library is exposed. + expect(app.dom.historyList!.textContent).toContain('No history yet.'); + handle.dispose(); + }); + + // #487 phase 3 regression test: the section that was NOT active at mount used + // to never get its first paint at all (blank until the first switch to it). + it('paints BOTH lower sections at mount, before either is ever switched to', () => { + const loadSchema = vi.fn(async () => {}); + const loadReference = vi.fn(async () => {}); + const app = makeApp({ catalog: { loadSchema, loadReference }, prefs: { save: vi.fn() } }); + app.state.savedQueries = [savedQuery({ id: 's1', name: 'Q1', sql: 'SELECT 1' })]; + app.state.history = [{ id: 'h1', sql: 'SELECT 1', ts: Date.now(), rows: 1, ms: 1 }]; + // sidePanel defaults to Library ('saved') — History is never exposed here. + const handle = mountAppShell({ + app, root: app.root, document, state: app.state, catalog: app.catalog, + prefs: app.prefs, matchMedia: null, updateBanner: vi.fn(), startDrag, + }); + + expect(app.dom.historyList!.querySelectorAll('.history-row')).toHaveLength(1); handle.dispose(); }); diff --git a/tests/unit/app.test.ts b/tests/unit/app.test.ts index 67071177..97979cb7 100644 --- a/tests/unit/app.test.ts +++ b/tests/unit/app.test.ts @@ -2123,6 +2123,24 @@ describe('query run', () => { // a plain SELECT needs no session, so none is opened (avoids the session race) expect(asMock(app.conn.chCtx.fetch).mock.calls.map((c) => c[0]).some((u) => /session_id=/.test(u))).toBe(false); }); + // #487 phase 3 regression test: a run's history recording used to skip its + // repaint of History entirely whenever Library ('saved', the default side + // panel) was exposed — `app.recordHistory` guarded the call on + // `sidePanel.value === 'history'`. That left History's own DOM stale until + // some unrelated event happened to repaint it, which never happens for + // History on its own (`state.history` is a plain array, not a signal). The + // fix makes `app.recordHistory`'s repaint unconditional. + it('a recorded run repaints History even while Library is the exposed side panel', async () => { + const { app } = appForRun([ + [(u, sql) => /SELECT 1/.test(sql), resp({ body: streamBody(['{"meta":[{"name":"a","type":"UInt8"}]}\n', '{"row":{"a":"1"}}\n']) })], + ]); + expect(app.state.sidePanel.value).toBe('saved'); // Library is the exposed section + app.activeTab().sqlDraft = 'SELECT 1'; + await app.actions.run(); + expect(app.state.history.length).toBe(1); + expect(app.dom.historyList!.querySelectorAll('.history-row')).toHaveLength(1); + expect(app.dom.historyList!.textContent).toContain('SELECT 1'); + }); it('captures result.source for a normal row-returning result (#185)', async () => { const { app } = appForRun([ [(u, sql) => /SELECT 1/.test(sql), resp({ body: streamBody(['{"meta":[{"name":"a","type":"UInt8"}]}\n', '{"row":{"a":"1"}}\n']) })], diff --git a/tests/unit/saved-history.test.ts b/tests/unit/saved-history.test.ts index 9be988dd..6b4d9ff1 100644 --- a/tests/unit/saved-history.test.ts +++ b/tests/unit/saved-history.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { renderSavedHistory } from '../../src/ui/saved-history.js'; +import { renderSavedHistory, renderLibrarySection, renderHistorySection } from '../../src/ui/saved-history.js'; import { LIBRARY_QUERY_MIME, SUBQUERY_MIME } from '../../src/ui/dnd-mime.js'; import { queryDescription, queryFavorite, queryName } from '../../src/core/saved-query.js'; import { makeApp } from '../helpers/fake-app.js'; @@ -614,17 +614,23 @@ describe('renderSavedHistory', () => { // `state.ts` decodes `asb:sidePanel` at load, so this value cannot come from // storage — but the signal is settable by any module, and the two readers must // not be able to disagree. `app-shell.ts`'s exposure effect resolves anything - // that is not 'history' to the Library host; this renderer has to paint into - // the LIBRARY pair for the same input, or the pane shows an exposed empty host - // while the content sits inside the hidden one. + // that is not 'history' to the Library host. + // + // #487 phase 3: content rendering no longer depends on this decoding at all + // — both sections always render their own content regardless of which is + // exposed. What this decoding still controls is only the tab row's "active" + // class / `aria-pressed`, asserted here. const app = makeApp(); (app.state.sidePanel as { value: string }).value = 'queries'; setSaved(app, [{ id: 's1', name: 'Q1', sql: 'SELECT 1' }]); renderSavedHistory(app); expect(qsa(savedList(app), '.saved-row')).toHaveLength(1); - expect(historyList(app).children.length).toBe(0); - expect(qsa(savedTabsRow(app), '.side-tab')[0].classList.contains('active')).toBe(true); + const tabs = qsa(savedTabsRow(app), '.side-tab'); + expect(tabs[0].classList.contains('active')).toBe(true); + expect(tabs[0].getAttribute('aria-pressed')).toBe('true'); + expect(tabs[1].classList.contains('active')).toBe(false); + expect(tabs[1].getAttribute('aria-pressed')).toBe('false'); }); it('takes the lower tabs\' labels and icons FROM the registry', () => { @@ -651,13 +657,12 @@ describe('renderSavedHistory', () => { } }); - it('ignores input from a retained search box whose section is no longer active', () => { + it('a retained search box whose section is no longer exposed still writes only its OWN section (#487 phase 3)', () => { // #487 phase 2: the inactive section's host keeps its DOM, so its search input - // and listeners OUTLIVE the switch away from it. `state.libraryFilter` is still - // one shared string and `renderList` paints the ACTIVE section, so a stale - // event would rewrite the filter and repaint the OTHER section's list with this - // section's text. CSS makes it unreachable today; phase 3 moves hosts into - // containers where a host can be visible while another section is active. + // and listeners OUTLIVE the switch away from it. #487 phase 3 gives each + // section its own filter slot (`state.lowerNavigationFilters`), so a "stale" + // Library input firing while History is exposed can only ever write Library's + // own filter and repaint Library's own (hidden) list — never History's. const app = makeApp(); app.state.sidePanel.value = 'saved'; setSaved(app, [{ id: 's1', name: 'Carrier delays', sql: 'SELECT 1' }]); @@ -672,14 +677,20 @@ describe('renderSavedHistory', () => { staleInput.value = 'zzzz'; staleInput.dispatchEvent(new Event('input', { bubbles: true })); - // The shared filter is untouched and History still shows its row — no - // cross-section rewrite, no "No history matches “zzzz”". - expect(app.state.libraryFilter).toBe(''); + // Library's OWN filter took the input; History's own filter and rendered + // content are untouched — no cross-section rewrite, no "No history matches + // “zzzz”". + expect(app.state.lowerNavigationFilters.library).toBe('zzzz'); + expect(app.state.lowerNavigationFilters.history).toBe(''); expect(qsa(historyList(app), '.history-row')).toHaveLength(1); expect(historyList(app).textContent).not.toContain('zzzz'); + expect(savedList(app).textContent).toContain('No library queries match'); + expect(savedList(app).textContent).toContain('zzzz'); - // Escape on the stale input is inert for the same reason. + // Escape on the stale input clears LIBRARY's own filter, still without + // touching History. staleInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(app.state.lowerNavigationFilters.library).toBe(''); expect(qsa(historyList(app), '.history-row')).toHaveLength(1); }); @@ -695,6 +706,77 @@ describe('renderSavedHistory', () => { expect(app.state.sidePanel.value).toBe('saved'); expect(app.prefs.save).toHaveBeenCalledWith('sidePanel', 'saved'); }); + + // #572: the lower switcher's tabs had neither attribute, unlike the upper + // switcher's (`sidebar-upper.ts`), which already has both. + it('renders the lower tabs as real buttons with correct aria-pressed (#572)', () => { + const app = makeApp(); + app.state.sidePanel.value = 'saved'; + renderSavedHistory(app); + const [libraryTab, historyTab] = qsa(savedTabsRow(app), '.side-tab'); + expect(libraryTab.type).toBe('button'); + expect(libraryTab.getAttribute('aria-pressed')).toBe('true'); + expect(historyTab.type).toBe('button'); + expect(historyTab.getAttribute('aria-pressed')).toBe('false'); + + click(historyTab); + renderSavedHistory(app); + const [libraryTab2, historyTab2] = qsa(savedTabsRow(app), '.side-tab'); + expect(libraryTab2.getAttribute('aria-pressed')).toBe('false'); + expect(historyTab2.getAttribute('aria-pressed')).toBe('true'); + }); +}); + +describe('renderLibrarySection / renderHistorySection (#487 phase 3: independent of exposure)', () => { + // Direct regression test for the "first switch to a section shows empty" bug: + // before this phase, a section's content only ever painted while it was the + // ACTIVE section, so the section that was not active when the pane first + // mounted had never been painted. + it('renderHistorySection paints History\'s own host even while Library is the exposed section', () => { + const app = makeApp(); + app.state.sidePanel.value = 'saved'; // Library is exposed, not History + app.state.history = [{ id: 'h1', sql: 'SELECT 1', ts: Date.now(), rows: 1, ms: 1 }]; + renderHistorySection(app); + expect(qsa(historyList(app), '.history-row')).toHaveLength(1); + }); + + it('renderLibrarySection paints Library\'s own host even while History is the exposed section', () => { + const app = makeApp(); + app.state.sidePanel.value = 'history'; // History is exposed, not Library + setSaved(app, [{ id: 's1', name: 'Q1', sql: 'SELECT 1' }]); + renderLibrarySection(app); + expect(qsa(savedList(app), '.saved-row')).toHaveLength(1); + }); + + // Direct regression test for the "the section that wasn't active when its own + // data changed goes stale" bug, and its mirror. + it('a History-only mutation does not touch Library\'s DOM', () => { + const app = makeApp(); + app.state.sidePanel.value = 'saved'; + setSaved(app, [{ id: 's1', name: 'Q1', sql: 'SELECT 1' }]); + renderSavedHistory(app); + const before = savedList(app).innerHTML; + + app.state.history = [{ id: 'h1', sql: 'SELECT 1', ts: Date.now(), rows: 1, ms: 1 }]; + renderHistorySection(app); + + expect(savedList(app).innerHTML).toBe(before); + expect(qsa(historyList(app), '.history-row')).toHaveLength(1); + }); + + it('a Library-only mutation does not touch History\'s DOM', () => { + const app = makeApp(); + app.state.sidePanel.value = 'history'; + app.state.history = [{ id: 'h1', sql: 'SELECT 1', ts: Date.now(), rows: 1, ms: 1 }]; + renderSavedHistory(app); + const before = historyList(app).innerHTML; + + setSaved(app, [{ id: 's1', name: 'Q1', sql: 'SELECT 1' }]); + renderLibrarySection(app); + + expect(historyList(app).innerHTML).toBe(before); + expect(qsa(savedList(app), '.saved-row')).toHaveLength(1); + }); }); describe('renderSavedHistory — search/filter', () => { @@ -763,12 +845,12 @@ describe('renderSavedHistory — search/filter', () => { expect(savedList(app).textContent).toContain('No library queries match'); expect(savedList(app).textContent).toContain('zzzz'); click(qs(savedSearch(app), '.sv-search-clear')); - expect(app.state.libraryFilter).toBe(''); + expect(app.state.lowerNavigationFilters.library).toBe(''); expect(names(app)).toHaveLength(3); type(app, 'busiest'); expect(names(app)).toEqual(['Busiest airports']); input(app).dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); - expect(app.state.libraryFilter).toBe(''); + expect(app.state.lowerNavigationFilters.library).toBe(''); expect(names(app)).toHaveLength(3); }); @@ -788,12 +870,47 @@ describe('renderSavedHistory — search/filter', () => { expect(historyList(app).textContent).toContain('No history matches'); }); - it('clears the filter when switching tabs', () => { + // #487 phase 3: the OPPOSITE of the old behavior — switching used to clear + // the (single, shared) filter; now each section keeps its own, preserved + // across every switch. Sabotage-checked: reintroducing a clear on `switchTo` + // (or gating either section's render behind `activeSection`) makes this fail. + it('preserves each section\'s own filter text across every switch', () => { const app = savedApp(); type(app, 'delay'); - expect(app.state.libraryFilter).toBe('delay'); + expect(app.state.lowerNavigationFilters.library).toBe('delay'); + click(qsa(savedTabsRow(app), '.side-tab')[1]); // → History - expect(app.state.libraryFilter).toBe(''); + expect(app.state.sidePanel.value).toBe('history'); + expect(app.state.lowerNavigationFilters.library).toBe('delay'); // NOT cleared + expect(app.state.lowerNavigationFilters.history).toBe(''); + + // Give History its OWN, independent filter text. + app.state.history = [ + { id: 'h1', sql: 'SELECT 1', ts: Date.now(), rows: 1, ms: 1 }, + { id: 'h2', sql: 'INSERT INTO t', ts: Date.now(), rows: null, ms: 1 }, + ]; + renderSavedHistory(app); + const historyInput = qs(historySearch(app), '.sv-search-input'); + historyInput.value = 'insert'; + historyInput.dispatchEvent(new Event('input', { bubbles: true })); + expect(app.state.lowerNavigationFilters.history).toBe('insert'); + + click(qsa(savedTabsRow(app), '.side-tab')[0]); // → Library + expect(app.state.sidePanel.value).toBe('saved'); + expect(app.state.lowerNavigationFilters.library).toBe('delay'); // still preserved + expect(app.state.lowerNavigationFilters.history).toBe('insert'); // still preserved + + // Re-render (as the real app-shell repaint effects would) and confirm the + // preserved text is actually what's shown, not just stored. + renderSavedHistory(app); + expect(input(app).value).toBe('delay'); + expect(names(app)).toEqual(['Carrier delays']); + + click(qsa(savedTabsRow(app), '.side-tab')[1]); // → History again + renderSavedHistory(app); + expect(qs(historySearch(app), '.sv-search-input').value).toBe('insert'); + expect(qsa(historyList(app), '.history-row')).toHaveLength(1); + expect(historyList(app).textContent).toContain('INSERT INTO t'); }); }); diff --git a/tests/unit/state.test.ts b/tests/unit/state.test.ts index 861443c4..5ccb0084 100644 --- a/tests/unit/state.test.ts +++ b/tests/unit/state.test.ts @@ -209,6 +209,10 @@ describe('createState', () => { expect(s.expanded.value.size).toBe(0); expect(s.libraryName.value).toBe(DEFAULT_LIBRARY_NAME); expect(s.libraryDirty.value).toBe(false); + // #487 phase 3: each lower-navigation section keeps its own filter slot — + // both start empty, and (unlike the old single `libraryFilter` string) + // neither is ever cleared by the other switching. + expect(s.lowerNavigationFilters).toEqual({ library: '', history: '' }); // #287 W4: no aggregate loaded yet — `dashboard` starts null; // `loadWorkspaceOnBoot` (app.ts's async boot step) projects the real // aggregate onto both after this synchronous constructor. `workspaceId` diff --git a/tests/unit/workbench-session.test.ts b/tests/unit/workbench-session.test.ts index e21c9664..89721aac 100644 --- a/tests/unit/workbench-session.test.ts +++ b/tests/unit/workbench-session.test.ts @@ -103,7 +103,7 @@ function makeState(over: Partial = {}): WorkbenchStateSlice function makeHooks(over: Partial = {}): WorkbenchHooks { return { renderResults: vi.fn(), - renderSavedHistory: vi.fn(), + renderHistorySection: vi.fn(), cancelSchemaGraph: vi.fn(), loadSchema: vi.fn(), recordHistory: vi.fn(), @@ -843,10 +843,10 @@ describe('createWorkbenchSession: runScript()', () => { await session.runScript(['SELECT 1'], 'SELECT 1'); expect(h.hooks.recordBoundParams).not.toHaveBeenCalled(); expect(h.state.history).toEqual([]); - expect(h.hooks.renderSavedHistory).not.toHaveBeenCalled(); + expect(h.hooks.renderHistorySection).not.toHaveBeenCalled(); }); - it('a clean run records one script history entry, and repaints History when open', async () => { + it('a clean run records one script history entry, and repaints History when it is the open panel', async () => { const h = makeHarness({ state: { sidePanel: signal('history') } }); h.execFakes.executeScript.mockImplementation(async (req: ScriptExecutionRequest) => { const entry = { sql: 'SELECT 1', status: 'rows' as const, columns: [], rows: [], truncated: false, preview: '', ms: 5 }; @@ -857,11 +857,16 @@ describe('createWorkbenchSession: runScript()', () => { await session.runScript(['SELECT 1'], 'SELECT 1; SELECT 1;'); expect(h.state.history).toHaveLength(1); expect(h.state.history[0].sql).toBe('SELECT 1; SELECT 1;'); - expect(h.hooks.renderSavedHistory).toHaveBeenCalled(); + expect(h.hooks.renderHistorySection).toHaveBeenCalled(); expect(h.hooks.saveJSON).toHaveBeenCalled(); }); - it('a clean run does not repaint History when a different side panel is open', async () => { + // #487 phase 3 regression test: History used to skip its repaint entirely + // whenever Library ('saved') was the exposed side panel, leaving History's + // content stale until some unrelated repaint happened to fire (which, for + // History, never does — `state.history` is a plain array, not part of any + // reactive effect). The fix makes this call unconditional. + it('a clean run repaints History even while a different side panel is open', async () => { const h = makeHarness({ state: { sidePanel: signal('saved') } }); h.execFakes.executeScript.mockImplementation(async (req: ScriptExecutionRequest) => { const entry = { sql: 'SELECT 1', status: 'ok' as const, ms: 5 }; @@ -871,7 +876,7 @@ describe('createWorkbenchSession: runScript()', () => { const session = createWorkbenchSession(h.deps); await session.runScript(['SELECT 1'], 'SELECT 1;'); expect(h.state.history).toHaveLength(1); - expect(h.hooks.renderSavedHistory).not.toHaveBeenCalled(); + expect(h.hooks.renderHistorySection).toHaveBeenCalled(); }); it('sets `cancelled` on the script result when aborted', async () => { From 2a115f04fa70bae5d4be6fe1e6428f75dec8cfae Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Thu, 30 Jul 2026 11:17:50 +0200 Subject: [PATCH 09/78] fix(#487): resize-session must compare raw proposals, not clamped ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An independent ChatGPT review of the phase-3 steps 1-3 diff found a real bug, confirmed by hand: commitLeftNavigationResize compared two POST-CLAMP layouts (effective vs effectiveAtStart) to decide whether a band's width changed. That's wrong for a restore command (Home/End/a bare-rail ArrowRight) under an active viewport clamp — a bare rail's dormant preferred width passes through effectiveAtStart unclamped (there is nothing to clamp yet), so a restore's honest, unclamped proposal gets compared against it, differs, and the TRANSIENT clamped render value gets committed instead of the user's real preference. E.g. a 420px preference restored on a ~800px window would silently downgrade to ~313px. Fix: the session now tracks the RAW reducer proposal separately from the clamped effective layout, and the commit decision compares the raw proposal against the preference, never the clamped value. This also folds the viewport clamp into advanceLeftNavigationResize itself, so a future caller can no longer forget to apply it. Also: openFocusedSection/toggleFocusedSection no longer re-persist sidePanel when the value is already current (relevant since #428's bounded drag-hover reasserts the same section repeatedly), a stale test that checked a field name already renamed in step 3, a dead field on WorkbenchStateSlice, and stale doc references in ADR-0001 and saved-query-service.ts. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NZfeGpkpJw2zsfPtysGHUN --- docs/ADR-0001-reactivity.md | 6 +- src/application/left-nav.ts | 19 ++- src/application/saved-query-service.ts | 6 +- src/core/left-nav-layout.ts | 194 +++++++++++++++---------- src/ui/workbench/workbench-session.ts | 15 -- tests/unit/left-nav-layout.test.ts | 183 ++++++++++++++++++----- tests/unit/left-nav.test.ts | 49 ++++++- tests/unit/workbench-session.test.ts | 15 +- 8 files changed, 343 insertions(+), 144 deletions(-) diff --git a/docs/ADR-0001-reactivity.md b/docs/ADR-0001-reactivity.md index 7bb8c766..7eaa78f1 100644 --- a/docs/ADR-0001-reactivity.md +++ b/docs/ADR-0001-reactivity.md @@ -175,8 +175,10 @@ forgettable as the old manual `renderSchema` calls, revisit via a fresh ADR. `state.shortcutsOpen`, `state.editingSavedId`, and `state.bannerDismissedFor` (previously bare fields — the latter two lived on `app` directly, not `app.state`) were converted to `signal(...)` and consolidated into `state.js` -alongside the other session-only, non-persisted fields (`libraryFilter`, -`resultSort`). None had a reactive reader before or after — each site that sets +alongside the other session-only, non-persisted fields (`lowerNavigationFilters` +— renamed from `libraryFilter` by #487 phase 3, which split the one field into +per-section search text — and `resultSort`). None had a reactive reader before +or after — each site that sets one already calls its own repaint (`renderSavedHistory`, `updateBanner`, `openShortcuts`'s own mount/unmount) — so this is a pure `.value` mechanical edit, not a new `effect()`. Housing them in `state.js` rather than on `app` diff --git a/src/application/left-nav.ts b/src/application/left-nav.ts index 15b51b5c..336147bc 100644 --- a/src/application/left-nav.ts +++ b/src/application/left-nav.ts @@ -72,19 +72,28 @@ export function readLeftNavigationLayout(state: LeftNavStateSlice): LeftNavigati * `sidePanel`, so a rail/drawer selection of the same section left the * signal's NEW value unpersisted — a reload would silently revert to * whichever pane was last chosen through the wide tabs. Persisting BEFORE - * writing the signal mirrors `switchTo` exactly. `state.libraryFilter` is - * deliberately untouched — a later phase-3 step owns per-section filters. + * writing the signal mirrors `switchTo` exactly. Per-section filters + * (`state.lowerNavigationFilters`, phase 3 step 3) are already implemented + * elsewhere and this module still correctly never touches them. + * + * Both branches are guarded to a no-op when the target value is already + * current: `openFocusedSection`'s documented caller is #428's bounded + * drag-hover, which can re-assert the SAME section repeatedly on every hover + * notification, and `sidePanel`'s write has a real synchronous side effect + * (`app.prefs.save`) that must not fire on every one of those. */ function selectSectionInExistingPane(app: LeftNavApp, section: LeftNavigationSection): void { if (section === 'databases' || section === 'dashboards') { // Session-only, like `switchTo`'s counterpart for the upper pane: `upperRole` // is never persisted (state.ts), so there is nothing to save here. - app.state.upperRole.value = section; + if (app.state.upperRole.value !== section) app.state.upperRole.value = section; return; } const panel = sidePanelKeyFor(section); - app.prefs.save('sidePanel', panel); - app.state.sidePanel.value = panel; + if (app.state.sidePanel.value !== panel) { + app.prefs.save('sidePanel', panel); + app.state.sidePanel.value = panel; + } } /** diff --git a/src/application/saved-query-service.ts b/src/application/saved-query-service.ts index c3832618..04392d01 100644 --- a/src/application/saved-query-service.ts +++ b/src/application/saved-query-service.ts @@ -146,8 +146,10 @@ export interface SavedQueryService { commit(tab: QueryTab, evaluated: { parsed: unknown; diagnostics: SpecValidationDiagnostic[] }): Promise; /** Record a successful run in history (state.ts's own `recordHistory`) — * never touches rendering; app.ts's own `app.recordHistory` delegate - * conditionally repaints the History side panel itself after calling - * this. */ + * unconditionally repaints History's own content after calling this + * (#487 phase 3 removed the `sidePanel === 'history'` guard, since + * History's content must stay current regardless of which lower-navigation + * section is currently exposed). */ recordHistory(tab: QueryTab, sqlText?: string): void; /** Build the shareable URL for an already-evaluated Spec, or a typed * rejection reason — never writes `location`/clipboard itself. */ diff --git a/src/core/left-nav-layout.ts b/src/core/left-nav-layout.ts index c6b2c2f1..6bdb6923 100644 --- a/src/core/left-nav-layout.ts +++ b/src/core/left-nav-layout.ts @@ -532,20 +532,33 @@ export interface LeftNavigationSeparatorAria { * ceiling is `LEFT_PANEL_MAX_PX` exactly as before phase 3 — this parameter is * additive and every existing caller (there is still none in production, but the * unit tests below stand in for one) keeps its prior behaviour unconditionally. + * + * `valueMax` is floored at `valueMin` (`LEFT_RAIL_PX`) and `valueNow` is + * clamped into `[valueMin, valueMax]`, so the ARIA invariant `valueMin <= + * valueNow <= valueMax` holds for ANY budget, including one pathologically + * below the rail's own width or below the current mode's own floor. #487's + * planned production UI never reaches that range in practice — see + * `clampLeftNavigationToMaximumTotal`'s own comment for the arithmetic showing + * the realistic budget floor sits around 280px, well above every mode's own + * minimum — so this is defensive robustness on a PUBLIC pure helper rather + * than a behaviour change for any realistic budget: neither clamp does + * anything when `maxNavigationTotalPx` is omitted or at least `LEFT_RAIL_PX`. */ export function leftNavigationSeparatorAria( layout: LeftNavigationLayout, maxNavigationTotalPx?: number, ): LeftNavigationSeparatorAria { - const valueMax = Number.isFinite(maxNavigationTotalPx) && (maxNavigationTotalPx as number) > 0 + const valueMin = LEFT_RAIL_PX; + const uncappedMax = Number.isFinite(maxNavigationTotalPx) && (maxNavigationTotalPx as number) > 0 ? Math.min(LEFT_PANEL_MAX_PX, maxNavigationTotalPx as number) : LEFT_PANEL_MAX_PX; - return { - valueMin: LEFT_RAIL_PX, - valueMax, - // Normalized, so a caller holding a layout with a non-finite width cannot - // publish `aria-valuenow="NaN"` to assistive technology. - valueNow: leftNavigationWidthPx(normalizeLeftNavigationLayout(layout)), - }; + const valueMax = Math.max(valueMin, uncappedMax); + // Normalized, so a caller holding a layout with a non-finite width cannot + // publish `aria-valuenow="NaN"` to assistive technology; clamped into the + // final [valueMin, valueMax] range so an occupied width that exceeds a + // pathologically small budget cannot publish an out-of-range valueNow either. + const rawValueNow = leftNavigationWidthPx(normalizeLeftNavigationLayout(layout)); + const valueNow = clamp(rawValueNow, valueMin, valueMax); + return { valueMin, valueMax, valueNow }; } /** @@ -646,107 +659,142 @@ export function clampLeftNavigationToMaximumTotal( * - `preferredAtStart` — the persisted preference as of when the gesture began. * This is the memory source `commitLeftNavigationResize` reconstructs from, * and it is captured ONCE, so no intermediate frame can overwrite it. - * - `effectiveAtStart` — what was actually rendered when the gesture began, - * i.e. `preferredAtStart` after `clampLeftNavigationToMaximumTotal` ran - * against whatever the viewport allowed at that moment. This can differ from - * `preferredAtStart` — a maximized preference squeezed by a narrow window — - * and the gap between the two is precisely what lets the commit step tell - * "the user actually resized this band" apart from "the band was just - * rendered smaller than preferred by an unrelated viewport constraint". - * - `effective` — the live, post-clamp layout, replaced wholesale on every - * `advanceLeftNavigationResize` call. This is what gets rendered and reported - * as the gesture continues; it is not memory. + * - `proposed` — the latest RAW reducer proposal, BEFORE the viewport's + * maximum-total clamp runs. This is the OTHER memory source + * `commitLeftNavigationResize` reads: comparing the raw proposal against + * `preferredAtStart` is what lets a restore command commit the user's actual + * remembered width even while a narrow viewport cannot render it in full — + * see the bug history below. + * - `effective` — `proposed` after `clampLeftNavigationToMaximumTotal` has run + * against the current viewport budget. This is what gets rendered and + * reported as the gesture continues; it is not memory, and + * `commitLeftNavigationResize` never reads it for its width decision. + * + * **A real bug this shape fixes, not a hypothetical one.** An earlier version + * of this session compared `effective` against an `effectiveAtStart` snapshot — + * i.e. two POST-CLAMP layouts — to decide whether a band's width had "changed". + * That is wrong whenever a restore command (`Home`, `End`, or a bare-rail + * `ArrowRight`) runs while the clamp is active: starting at a bare rail, + * `effectiveAtStart.wideWidthPx` passes the dormant preferred width straight + * through unclamped (there is nothing to clamp — 'wide' is not the rendered + * mode yet), so it read as the full preference, e.g. 420. `End` then proposed + * exactly 420 back — correct — but if the viewport only allows 313, `effective` + * rendered 313, `effective.wideWidthPx (313) !== effectiveAtStart.wideWidthPx + * (420)` read as true, and the old logic committed 313: a transient, + * viewport-driven value the user never asked to keep, silently overwriting + * their real preference. Comparing the RAW `proposed` (420) against + * `preferredAtStart` (420) instead finds no change, because + * `clampLeftNavigationToMaximumTotal` never touches `mode` — so `mode`/ + * `focusedSection` read identically off `proposed` or `effective`, and only the + * WIDTH needs the pre/post-clamp distinction the session now keeps. * * **`commitLeftNavigationResize`'s table, restated as one rule:** only commit a - * band's width if that band is the one `effective` currently renders AND its - * rendered width actually differs from `effectiveAtStart`'s. Every other case — - * a dormant band, a fold-through to bare rail, a click-and-release with no - * movement — preserves `preferredAtStart` for that band UNCONDITIONALLY. Two - * consequences fall out of that one rule rather than needing their own case: + * band's width if that band is the one the session's FINAL `proposed` layout is + * in, AND its raw proposed width actually differs from `preferredAtStart`'s. + * Every other case — a dormant band, a fold-through to bare rail, a + * click-and-release with no movement — preserves `preferredAtStart` for that + * band UNCONDITIONALLY. Two consequences fall out of that one rule rather than + * needing their own case: * * 1. **The dormant-band fix.** A gesture that resizes the drawer and THEN * crosses into wide mode must not commit the drawer's mid-gesture width, - * because the drawer is no longer the band `effective` renders once the - * session ends — `drawerChanged` requires `effective.mode === 'rail'`, which - * is false at wide, so the drawer memory falls through to - * `preferredAtStart.drawerWidthPx` untouched. Without that mode guard, a - * drag that opened the drawer to 300 before continuing on to a 350px wide - * sidebar would silently overwrite the drawer's remembered width with a - * value the user never asked to keep. - * 2. **Preferred wins over effective on a fold-through.** Ending at bare rail - * preserves BOTH widths from `preferredAtStart`, never from `effectiveAtStart` - * or `effective` — so a maximized 420px preference that a narrow viewport - * rendered at a clamped 313px, then folded to rail by the same gesture, - * still remembers 420 for the next `End`/restore. Committing `313` instead - * would silently downgrade a preference the user never touched, purely - * because the viewport happened to be narrow during an unrelated fold. + * because the drawer is no longer the band the FINAL `proposed` is in — + * `drawerChanged` requires `proposed.mode === 'rail'`, which is false once + * the session ends at wide, so the drawer memory falls through to + * `preferredAtStart.drawerWidthPx` untouched regardless of what the + * drawer's transient width was mid-drag. + * 2. **Preferred wins over a viewport clamp on ANY commit, not only a + * fold-through.** Because the comparison is always RAW-proposed vs + * preferred, never rendered-effective vs anything, a maximized 420px + * preference that a narrow viewport can only render at a clamped 313px + * still commits the user's honest 420 whenever the session ends without an + * actual new proposal for that band — restoring it in full the next time + * there is room, exactly as #487's "a viewport clamp must never downgrade a + * stored preference" requires. * * `Home`/`End`/a bare-rail `ArrowRight` restore need no special case either: - * they are restore commands, so the `effective` layout they produce typically - * already equals `preferredAtStart`'s remembered width for the band they - * restore, which is exactly the "nothing changed" shape the general rule + * they are restore commands, so the RAW `proposed` layout they produce + * typically already equals `preferredAtStart`'s remembered width for the band + * they restore, which is exactly the "nothing changed" shape the general rule * preserves correctly. * - * A session is deliberately NOT a reducer step in `resolveLeftNavigationDrag`'s - * family — `advanceLeftNavigationResize` does not call the mode reducer or the - * maximum-total clamp itself. The caller runs those first to produce the next - * `effective` layout (a pointer/keyboard event resolves through the existing - * reducers, then `clampLeftNavigationToMaximumTotal` fits it to the viewport), - * and only then advances the session with the result. Session bookkeeping and - * layout arithmetic stay two separate concerns, so the arithmetic keeps its - * one implementation. + * `advanceLeftNavigationResize` performs the viewport clamp ITSELF now (taking + * the raw proposal and the current budget as arguments), rather than asking the + * caller to clamp first and hand in an already-clamped layout — a future + * caller (the pointer/keyboard handler a later phase-3 step builds, which does + * not exist yet) cannot forget the clamp, because it is part of this + * function's contract rather than caller discipline. The MODE arithmetic + * itself stays out of this module either way — `advanceLeftNavigationResize` + * still does not call `resolveLeftNavigationDrag`/`resolveLeftNavigationKey`; + * the caller runs the proposal through those first, then hands the raw result + * here alongside the viewport budget. Session bookkeeping and layout + * arithmetic stay two separate concerns, so the arithmetic keeps its one + * implementation. */ export interface LeftNavigationResizeSession { /** The persisted preference as of session start — the memory source every * commit is reconstructed from, band by band. */ readonly preferredAtStart: LeftNavigationLayout; - /** What was actually rendered when the session began, i.e. - * `preferredAtStart` after the viewport's maximum-total clamp. */ - readonly effectiveAtStart: LeftNavigationLayout; - /** The live, post-clamp layout — what is rendered and reported right now. */ + /** The latest RAW reducer proposal, BEFORE the viewport clamp — the other + * memory source `commitLeftNavigationResize` reads from. */ + readonly proposed: LeftNavigationLayout; + /** The latest proposal AFTER the viewport clamp — what is rendered and + * reported right now. Never read by the commit decision. */ readonly effective: LeftNavigationLayout; } -/** Begin a resize session: `effective` starts out equal to `effectiveAtStart`, - * since nothing has moved yet. */ +/** Begin a resize session: `proposed` starts out equal to `preferred` (nothing + * has moved yet), and `effective` is `preferred` clamped to the viewport + * budget at hand — mirroring what a caller would render before any gesture + * begins. */ export function beginLeftNavigationResize( - preferred: LeftNavigationLayout, effective: LeftNavigationLayout, + preferred: LeftNavigationLayout, maxNavigationTotalPx: number, ): LeftNavigationResizeSession { - return { preferredAtStart: preferred, effectiveAtStart: effective, effective }; + return { + preferredAtStart: preferred, + proposed: preferred, + effective: clampLeftNavigationToMaximumTotal(preferred, maxNavigationTotalPx), + }; } /** - * Advance a session to a new live layout. Pure snapshot replacement — the - * caller has already run the layout through `resolveLeftNavigationDrag` / - * `resolveLeftNavigationKey` and `clampLeftNavigationToMaximumTotal` to produce - * `nextEffectiveLayout`; this function does not call either. Returns the SAME - * session when the layout is unchanged by reference, so a caller can use - * identity to skip a repaint exactly as the mode reducers do. + * Advance a session to a new RAW proposal. The caller has already run the + * layout through `resolveLeftNavigationDrag`/`resolveLeftNavigationKey` to + * produce `proposedLayout`; this function applies the viewport clamp itself + * (see this section's block comment for why the clamp lives here rather than + * in the caller) and records both the raw proposal and its clamped `effective` + * counterpart. Returns the SAME session when neither changes by reference, so + * a caller can use identity to skip a repaint exactly as the mode reducers do. */ export function advanceLeftNavigationResize( - session: LeftNavigationResizeSession, nextEffectiveLayout: LeftNavigationLayout, + session: LeftNavigationResizeSession, proposedLayout: LeftNavigationLayout, maxNavigationTotalPx: number, ): LeftNavigationResizeSession { - return nextEffectiveLayout === session.effective + const effective = clampLeftNavigationToMaximumTotal(proposedLayout, maxNavigationTotalPx); + return proposedLayout === session.proposed && effective === session.effective ? session - : { ...session, effective: nextEffectiveLayout }; + : { ...session, proposed: proposedLayout, effective }; } /** * Reconstruct the `LeftNavigationLayout` to persist from a resize session — see * this section's block comment above for the rule and why it is shaped this * way. `mode` and `focusedSection` always follow wherever the session ended - * (a legitimate mode transition, not a width memory question); only the two - * WIDTHS get the preserve-vs-commit treatment, band by band. + * (a legitimate mode transition, not a width memory question) — read off + * `effective`, since `clampLeftNavigationToMaximumTotal` never changes `mode`, + * so `effective.mode`/`effective.focusedSection` agree with `proposed`'s + * exactly. Only the two WIDTHS get the preserve-vs-commit treatment, band by + * band, and that decision is made against the RAW `proposed` width, never + * `effective`'s — the fix this function exists for. */ export function commitLeftNavigationResize(session: LeftNavigationResizeSession): LeftNavigationLayout { - const { preferredAtStart, effectiveAtStart, effective } = session; - const wideChanged = effective.mode === 'wide' && effective.wideWidthPx !== effectiveAtStart.wideWidthPx; - const drawerChanged = effective.mode === 'rail' && effective.focusedSection !== null - && effective.drawerWidthPx !== effectiveAtStart.drawerWidthPx; + const { preferredAtStart, proposed, effective } = session; + const wideChanged = proposed.mode === 'wide' && proposed.wideWidthPx !== preferredAtStart.wideWidthPx; + const drawerChanged = proposed.mode === 'rail' && proposed.focusedSection !== null + && proposed.drawerWidthPx !== preferredAtStart.drawerWidthPx; return normalizeLeftNavigationLayout({ mode: effective.mode, focusedSection: effective.focusedSection, - wideWidthPx: wideChanged ? effective.wideWidthPx : preferredAtStart.wideWidthPx, - drawerWidthPx: drawerChanged ? effective.drawerWidthPx : preferredAtStart.drawerWidthPx, + wideWidthPx: wideChanged ? proposed.wideWidthPx : preferredAtStart.wideWidthPx, + drawerWidthPx: drawerChanged ? proposed.drawerWidthPx : preferredAtStart.drawerWidthPx, }); } diff --git a/src/ui/workbench/workbench-session.ts b/src/ui/workbench/workbench-session.ts index 741c2a27..b30aa20d 100644 --- a/src/ui/workbench/workbench-session.ts +++ b/src/ui/workbench/workbench-session.ts @@ -66,21 +66,6 @@ export interface WorkbenchStateSlice { forceExplain: boolean; resultRowLimit: number; serverVersion: string | null; - /** - * #487 phase 3: no longer read by this session's own logic — runScript's - * clean-run history repaint (`hooks.renderHistorySection()`) is now - * unconditional, since History's content must stay current regardless of - * which lower-navigation section is exposed. Kept on the slice because - * `AppState` carries it regardless (structural pass-through) and a later - * caller may still need it; if it stays unread, a future cleanup can drop it. - * - * Derived from `AppState` rather than restated as `Signal` (#487 - * phase 2): the real signal holds a decoded `'saved' | 'history'`, and a - * structural `Signal` here would leave this session type-authorized to - * write an arbitrary string into it — re-opening exactly the divergence the - * load-boundary decode closes. - */ - sidePanel: AppState['sidePanel']; isMobile: Signal; mobileView: Signal<'tables' | 'editor' | 'results'>; /** Read by the Run-button effect (Run ↔ "Run selection" label). */ diff --git a/tests/unit/left-nav-layout.test.ts b/tests/unit/left-nav-layout.test.ts index 28737cca..6dbe796c 100644 --- a/tests/unit/left-nav-layout.test.ts +++ b/tests/unit/left-nav-layout.test.ts @@ -795,6 +795,36 @@ describe('leftNavigationSeparatorAria', () => { .toBe(LEFT_PANEL_MAX_PX); }); }); + + // A pathologically small budget (below LEFT_RAIL_PX, or below an open + // drawer's own floor) is unreachable in the planned production UI — + // `clampLeftNavigationToMaximumTotal`'s own comment works out the realistic + // budget floor at ~281px — but this is a PUBLIC pure helper, so the + // valueMin <= valueNow <= valueMax invariant must hold defensively for any + // budget rather than relying on a caller never passing one this small. + describe('the valueMin <= valueNow <= valueMax invariant for pathologically small budgets', () => { + it.each([1, 47, 100])('holds for a bare rail at budget %ipx', (budget) => { + const { valueMin, valueMax, valueNow } = leftNavigationSeparatorAria(rail(), budget); + expect(valueMin).toBeLessThanOrEqual(valueNow); + expect(valueNow).toBeLessThanOrEqual(valueMax); + }); + it('holds for an open drawer at a budget below the drawer\'s own floor', () => { + // The drawer's floor is LEFT_RAIL_PX + LEFT_FOLD_THRESHOLD_PX (188); pick a + // budget well below that so valueNow (the drawer's occupied width) would + // exceed an unclamped valueMax. + const layout = rail({ focusedSection: 'library', drawerWidthPx: LEFT_WIDE_THRESHOLD_PX }); + const { valueMin, valueMax, valueNow } = leftNavigationSeparatorAria(layout, 50); + expect(valueMin).toBeLessThanOrEqual(valueNow); + expect(valueNow).toBeLessThanOrEqual(valueMax); + // valueMax never drops below valueMin, even under a budget below the rail. + expect(valueMax).toBeGreaterThanOrEqual(valueMin); + }); + it('does not change behaviour for any realistic budget (existing tests above stay exact)', () => { + // Re-assert one exact case from the "shrinks valueMax" test above: the + // floor-at-valueMin change must be a no-op once the budget clears LEFT_RAIL_PX. + expect(leftNavigationSeparatorAria(wide({ wideWidthPx: 200 }), 300).valueMax).toBe(300); + }); + }); }); describe('clampLeftNavigationToMaximumTotal (#487 phase 3)', () => { @@ -865,28 +895,45 @@ describe('clampLeftNavigationToMaximumTotal (#487 phase 3)', () => { // The resize-session design's own comment block (above `LeftNavigationResizeSession` // in left-nav-layout.ts) explains WHY it is shaped the way it is; these tests are -// the counter-examples that shape was reviewed against. +// the counter-examples that shape was reviewed against. `Infinity` is used +// throughout as "no viewport constraint" — `clampLeftNavigationToMaximumTotal` +// treats any non-finite budget as unconstrained, so it is a clean way to +// exercise the session's bookkeeping without an unrelated clamp in the way. describe('resize session (#487 phase 3)', () => { - it('begin captures both inputs, with effective starting equal to effectiveAtStart', () => { + it('begin captures preferred as the initial proposal, and clamps effective to the budget', () => { const preferred = wide({ wideWidthPx: 420 }); - const effective = wide({ wideWidthPx: 313 }); // squeezed by a narrow viewport - const session = beginLeftNavigationResize(preferred, effective); + const session = beginLeftNavigationResize(preferred, 313); // a narrow viewport expect(session.preferredAtStart).toBe(preferred); - expect(session.effectiveAtStart).toBe(effective); - expect(session.effective).toBe(effective); + expect(session.proposed).toBe(preferred); + expect(session.effective).toEqual(wide({ wideWidthPx: 313 })); + }); + + it('begin leaves effective equal to preferred when the budget does not constrain it', () => { + const preferred = wide({ wideWidthPx: 300 }); + const session = beginLeftNavigationResize(preferred, Infinity); + expect(session.effective).toBe(preferred); }); - it('advance replaces effective and returns the same session by reference when unchanged', () => { + it('advance replaces proposed/effective and returns the same session by reference when unchanged', () => { const start = wide({ wideWidthPx: 300 }); - const session = beginLeftNavigationResize(start, start); - const same = advanceLeftNavigationResize(session, start); + const session = beginLeftNavigationResize(start, Infinity); + const same = advanceLeftNavigationResize(session, start, Infinity); expect(same).toBe(session); const moved = wide({ wideWidthPx: 320 }); - const advanced = advanceLeftNavigationResize(session, moved); + const advanced = advanceLeftNavigationResize(session, moved, Infinity); expect(advanced).not.toBe(session); - expect(advanced.effective).toBe(moved); + expect(advanced.proposed).toBe(moved); + expect(advanced.effective).toEqual(moved); expect(advanced.preferredAtStart).toBe(session.preferredAtStart); - expect(advanced.effectiveAtStart).toBe(session.effectiveAtStart); + }); + + it('advance clamps a new proposal against the given budget', () => { + const start = wide({ wideWidthPx: 300 }); + const session = beginLeftNavigationResize(start, Infinity); + const proposedLayout = wide({ wideWidthPx: 350 }); + const advanced = advanceLeftNavigationResize(session, proposedLayout, 313); + expect(advanced.proposed).toBe(proposedLayout); // raw, unclamped + expect(advanced.effective.wideWidthPx).toBe(313); // clamped for rendering }); // Each row of commitLeftNavigationResize's table, driven through a single @@ -894,24 +941,24 @@ describe('resize session (#487 phase 3)', () => { describe('commit table', () => { it('a wide resize that changed width commits it, leaving the drawer memory untouched', () => { const start = wide({ wideWidthPx: 250, drawerWidthPx: 210 }); - let session = beginLeftNavigationResize(start, start); - session = advanceLeftNavigationResize(session, wide({ wideWidthPx: 320, drawerWidthPx: 210 })); + let session = beginLeftNavigationResize(start, Infinity); + session = advanceLeftNavigationResize(session, wide({ wideWidthPx: 320, drawerWidthPx: 210 }), Infinity); expect(commitLeftNavigationResize(session)).toEqual(wide({ wideWidthPx: 320, drawerWidthPx: 210 })); }); it('a drawer resize that changed width commits it, leaving the wide memory untouched', () => { const start = rail({ focusedSection: 'library', wideWidthPx: 260, drawerWidthPx: 200 }); - let session = beginLeftNavigationResize(start, start); + let session = beginLeftNavigationResize(start, Infinity); session = advanceLeftNavigationResize( - session, rail({ focusedSection: 'library', wideWidthPx: 260, drawerWidthPx: 230 })); + session, rail({ focusedSection: 'library', wideWidthPx: 260, drawerWidthPx: 230 }), Infinity); expect(commitLeftNavigationResize(session)).toEqual( rail({ focusedSection: 'library', wideWidthPx: 260, drawerWidthPx: 230 })); }); it('a fold-through to bare rail preserves both remembered widths', () => { const start = wide({ wideWidthPx: 300, drawerWidthPx: 210 }); - let session = beginLeftNavigationResize(start, start); - session = advanceLeftNavigationResize(session, resolveLeftNavigationDrag(start, 10)); + let session = beginLeftNavigationResize(start, Infinity); + session = advanceLeftNavigationResize(session, resolveLeftNavigationDrag(start, 10), Infinity); const committed = commitLeftNavigationResize(session); expect(committed.mode).toBe('rail'); expect(committed.focusedSection).toBeNull(); @@ -919,23 +966,26 @@ describe('resize session (#487 phase 3)', () => { expect(committed.drawerWidthPx).toBe(210); }); - it('a no-op (advance to the same effective layout) commits both preserved', () => { + it('a no-op (advance to the same proposed layout) commits both preserved', () => { const start = wide({ wideWidthPx: 300, drawerWidthPx: 210 }); - let session = beginLeftNavigationResize(start, start); - session = advanceLeftNavigationResize(session, wide({ wideWidthPx: 300, drawerWidthPx: 210 })); + let session = beginLeftNavigationResize(start, Infinity); + session = advanceLeftNavigationResize(session, wide({ wideWidthPx: 300, drawerWidthPx: 210 }), Infinity); expect(commitLeftNavigationResize(session)).toEqual(start); }); }); it('the dormant-band case: a drawer resize followed by a crossing into wide does not commit the transient drawer width', () => { const start = rail({ focusedSection: 'library', wideWidthPx: 260, drawerWidthPx: 200 }); - let session = beginLeftNavigationResize(start, start); + let session = beginLeftNavigationResize(start, Infinity); // Resize the drawer open further … session = advanceLeftNavigationResize( - session, rail({ focusedSection: 'library', wideWidthPx: 260, drawerWidthPx: 259 })); + session, rail({ focusedSection: 'library', wideWidthPx: 260, drawerWidthPx: 259 }), Infinity); // … then keep dragging past the wide threshold, converting to the wide sidebar. - const wideLayout = resolveLeftNavigationDrag(session.effective, LEFT_RAIL_PX + LEFT_WIDE_THRESHOLD_PX + 40); - session = advanceLeftNavigationResize(session, wideLayout); + // Dragging continues from the RAW proposal, not the rendered `effective` — + // in this test they agree (no clamp is active), but `proposed` is the + // physically correct thing for a caller to keep dragging from. + const wideLayout = resolveLeftNavigationDrag(session.proposed, LEFT_RAIL_PX + LEFT_WIDE_THRESHOLD_PX + 40); + session = advanceLeftNavigationResize(session, wideLayout, Infinity); const committed = commitLeftNavigationResize(session); expect(committed.mode).toBe('wide'); // The wide memory reflects where the session actually ended. @@ -945,22 +995,81 @@ describe('resize session (#487 phase 3)', () => { expect(committed.drawerWidthPx).toBe(200); }); - it('the preferred/effective divergence case: a fold-through commits the PREFERRED wide width, not the viewport-clamped effective one', () => { + it('a fold-through from a viewport-clamped wide layout still commits the PREFERRED wide width, not the clamped one', () => { const preferred = wide({ wideWidthPx: 420, drawerWidthPx: 210 }); - // The viewport at session start could only render 313px, well inside the - // legal wide band, so this is a legitimate effectiveAtStart on its own. - const effectiveAtStart = wide({ wideWidthPx: 313, drawerWidthPx: 210 }); - let session = beginLeftNavigationResize(preferred, effectiveAtStart); - // The gesture keeps going left and folds all the way to bare rail. - session = advanceLeftNavigationResize(session, resolveLeftNavigationDrag(effectiveAtStart, 10)); + const budget = 313; // narrow viewport: renders 313 even though the mode is wide + let session = beginLeftNavigationResize(preferred, budget); + expect(session.effective.wideWidthPx).toBe(313); + // The gesture continues from the rendered position and folds all the way + // to bare rail. + session = advanceLeftNavigationResize(session, resolveLeftNavigationDrag(session.effective, 10), budget); const committed = commitLeftNavigationResize(session); expect(committed.mode).toBe('rail'); - // 420, the PREFERRED value — not 313, the clamped value the session actually + // 420, the PREFERRED value — never 313, the value the session actually // rendered at the start. expect(committed.wideWidthPx).toBe(420); expect(committed.drawerWidthPx).toBe(210); }); + // THE regression test for the confirmed bug: comparing two POST-CLAMP + // layouts (the old `effective` vs `effectiveAtStart` design) let a viewport + // clamp silently downgrade a stored preference on a plain restore. Comparing + // the RAW proposal against `preferredAtStart` instead — this function's fix — + // must commit the user's honest, full preference regardless of what the + // viewport could render. + it('BUG regression: restoring a bare rail to a viewport-clamped wide width commits the honest 420, not the rendered 313', () => { + const start = rail({ wideWidthPx: 420, drawerWidthPx: 210 }); // dormant wide preference: 420 + const budget = 313; + let session = beginLeftNavigationResize(start, budget); + // Sanity: a bare rail's occupied width is the fixed LEFT_RAIL_PX, so there + // is nothing for the clamp to shrink yet — the dormant 420 preference + // passes through `effective` untouched even though the budget could not + // render it as a wide sidebar. + expect(session.effective.wideWidthPx).toBe(420); + + // `End` (a restore command) proposes restoring the REMEMBERED width — the + // RAW, pre-clamp proposal — exactly. + const proposedLayout = resolveLeftNavigationKey(start, { key: 'End' }); + expect(proposedLayout?.mode).toBe('wide'); + expect(proposedLayout?.wideWidthPx).toBe(420); + + session = advanceLeftNavigationResize(session, proposedLayout as LeftNavigationLayout, budget); + // The viewport clamp still shrinks what gets RENDERED … + expect(session.effective.wideWidthPx).toBe(313); + // … but the commit must reconstruct from the raw proposal (420), which + // equals preferredAtStart.wideWidthPx (420) — no real change — so the + // honest preference survives, rather than the transient 313 the viewport + // happened to allow. + const committed = commitLeftNavigationResize(session); + expect(committed.mode).toBe('wide'); + expect(committed.wideWidthPx).toBe(420); + }); + + it('a genuinely new user-driven proposal commits even when effective was clamped for an unrelated reason earlier in the session', () => { + const start = wide({ wideWidthPx: 300, drawerWidthPx: 210 }); + let session = beginLeftNavigationResize(start, 250); // clamped at session start + expect(session.effective.wideWidthPx).toBe(250); + // The user then drags to a genuinely new width, well inside any constraint. + const proposedLayout = wide({ wideWidthPx: 200, drawerWidthPx: 210 }); + session = advanceLeftNavigationResize(session, proposedLayout, 250); + expect(commitLeftNavigationResize(session).wideWidthPx).toBe(200); + }); + + it('a drag proposal the viewport clamps commits the HONEST proposal, not the rendered value (deliberate design choice)', () => { + // The user's real intent should be restorable later when there is room — + // committing the rendered/clamped value instead would silently downgrade a + // preference the user never asked to change, purely because of a + // transient viewport constraint mid-gesture. This mirrors the fold-through + // case above, but for an ordinary in-band resize rather than a restore. + const start = wide({ wideWidthPx: 300, drawerWidthPx: 210 }); + let session = beginLeftNavigationResize(start, Infinity); + const proposedLayout = wide({ wideWidthPx: 350, drawerWidthPx: 210 }); + session = advanceLeftNavigationResize(session, proposedLayout, 313); + expect(session.effective.wideWidthPx).toBe(313); // rendered, clamped + const committed = commitLeftNavigationResize(session); + expect(committed.wideWidthPx).toBe(350); // committed: the honest proposal + }); + it('a dense sweep and a coarse sweep over the same drag commit the same result — even though the underlying reducer state they pass through provably disagrees', () => { // This is the exact counter-example the "restore memory is sampling-dependent // (phase 3 obligation)" test above pins at the raw-reducer level: dragging @@ -972,20 +1081,20 @@ describe('resize session (#487 phase 3)', () => { const start = wide({ wideWidthPx: 300, drawerWidthPx: 210 }); // Dense: samples the dead zone (179) before the fold. - let denseSession = beginLeftNavigationResize(start, start); + let denseSession = beginLeftNavigationResize(start, Infinity); let denseLayout: LeftNavigationLayout = start; for (const x of [200, 179, 139]) { denseLayout = resolveLeftNavigationDrag(denseLayout, x); - denseSession = advanceLeftNavigationResize(denseSession, denseLayout); + denseSession = advanceLeftNavigationResize(denseSession, denseLayout, Infinity); } // Sanity check on the known artifact this dense path produces. expect(denseLayout.mode).toBe('rail'); expect(denseLayout.wideWidthPx).toBe(LEFT_PANEL_MIN_PX); // Coarse: one jump straight past the fold threshold, skipping the dead zone. - let coarseSession = beginLeftNavigationResize(start, start); + let coarseSession = beginLeftNavigationResize(start, Infinity); const coarseLayout = resolveLeftNavigationDrag(start, 139); - coarseSession = advanceLeftNavigationResize(coarseSession, coarseLayout); + coarseSession = advanceLeftNavigationResize(coarseSession, coarseLayout, Infinity); // Sanity check on the known — and DIFFERENT — artifact the coarse path // produces for the very same underlying field. expect(coarseLayout.mode).toBe('rail'); diff --git a/tests/unit/left-nav.test.ts b/tests/unit/left-nav.test.ts index 12d04a11..05cdeb97 100644 --- a/tests/unit/left-nav.test.ts +++ b/tests/unit/left-nav.test.ts @@ -88,14 +88,55 @@ describe('openFocusedSection — lower sections persist sidePanel', () => { expect(app.save).toHaveBeenCalledWith('sidePanel', 'history'); }); - it('does not touch libraryFilter or any field beyond the four it owns', () => { - const state = makeState({ mode: 'rail' }) as LeftNavStateSlice & { libraryFilter: string }; - state.libraryFilter = 'unchanged-marker'; + it('does not touch lowerNavigationFilters or any field beyond the four it owns', () => { + const state = makeState({ mode: 'rail' }) as LeftNavStateSlice & { + lowerNavigationFilters: Record<'library' | 'history', string>; + }; + state.lowerNavigationFilters = { library: 'unchanged-marker', history: 'also-unchanged' }; const app = makeApp(state); openFocusedSection(app, 'library'); - expect(state.libraryFilter).toBe('unchanged-marker'); + expect(state.lowerNavigationFilters).toEqual({ library: 'unchanged-marker', history: 'also-unchanged' }); + }); +}); + +describe('idempotent side effects — no repeated writes for an already-selected section', () => { + it('opening the same lower section twice only persists sidePanel once (#428 bounded drag-hover re-asserts repeatedly)', () => { + const state = makeState({ mode: 'rail', sidePanel: 'history', section: 'history' }); + const app = makeApp(state); + + openFocusedSection(app, 'library'); + expect(app.save).toHaveBeenCalledTimes(1); + expect(state.sidePanel.value).toBe('saved'); + + openFocusedSection(app, 'library'); + expect(app.save).toHaveBeenCalledTimes(1); + expect(state.sidePanel.value).toBe('saved'); + }); + + it('opening the same upper section twice does not rewrite upperRole redundantly', () => { + const state = makeState({ mode: 'rail', upperRole: 'databases', section: 'databases' }); + const app = makeApp(state); + + openFocusedSection(app, 'databases'); + openFocusedSection(app, 'databases'); + + expect(state.upperRole.value).toBe('databases'); + expect(app.save).not.toHaveBeenCalled(); + }); + + it('toggleFocusedSection also skips the redundant sidePanel persistence when re-activating the open section', () => { + const state = makeState({ mode: 'rail', sidePanel: 'saved', section: 'library' }); + const app = makeApp(state); + + // toggleFocusedSection closes the drawer on the SAME section, but the pane + // switch itself (selectSectionInExistingPane) still runs first with the + // section still 'library' — sidePanel is already 'saved', so no save. + toggleFocusedSection(app, 'library'); + + expect(app.save).not.toHaveBeenCalled(); + expect(state.leftNavSection.value).toBeNull(); }); }); diff --git a/tests/unit/workbench-session.test.ts b/tests/unit/workbench-session.test.ts index 89721aac..2223b2bf 100644 --- a/tests/unit/workbench-session.test.ts +++ b/tests/unit/workbench-session.test.ts @@ -89,7 +89,6 @@ function makeState(over: Partial = {}): WorkbenchStateSlice forceExplain: false, resultRowLimit: 500, serverVersion: null, - sidePanel: signal('saved'), isMobile: signal(false), mobileView: signal('editor'), hasSelection: signal(false), @@ -846,8 +845,8 @@ describe('createWorkbenchSession: runScript()', () => { expect(h.hooks.renderHistorySection).not.toHaveBeenCalled(); }); - it('a clean run records one script history entry, and repaints History when it is the open panel', async () => { - const h = makeHarness({ state: { sidePanel: signal('history') } }); + it('a clean run records one script history entry, and repaints History unconditionally', async () => { + const h = makeHarness(); h.execFakes.executeScript.mockImplementation(async (req: ScriptExecutionRequest) => { const entry = { sql: 'SELECT 1', status: 'rows' as const, columns: [], rows: [], truncated: false, preview: '', ms: 5 }; req.onStatementResult(0, entry); @@ -865,9 +864,13 @@ describe('createWorkbenchSession: runScript()', () => { // whenever Library ('saved') was the exposed side panel, leaving History's // content stale until some unrelated repaint happened to fire (which, for // History, never does — `state.history` is a plain array, not part of any - // reactive effect). The fix makes this call unconditional. - it('a clean run repaints History even while a different side panel is open', async () => { - const h = makeHarness({ state: { sidePanel: signal('saved') } }); + // reactive effect). The fix makes this call unconditional — and #487 phase 3 + // step 4 later confirmed `WorkbenchStateSlice.sidePanel` unread by this + // session's own logic and removed the field entirely, so there is no longer + // any side-panel state to vary here; this test just re-confirms the repaint + // fires on every clean run regardless. + it('a clean run repaints History unconditionally, independent of any side-panel state', async () => { + const h = makeHarness(); h.execFakes.executeScript.mockImplementation(async (req: ScriptExecutionRequest) => { const entry = { sql: 'SELECT 1', status: 'ok' as const, ms: 5 }; req.onStatementResult(0, entry); From 7ff6428eecd1eeb56ae1335172cbb34d4d818406 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Thu, 30 Jul 2026 11:44:34 +0200 Subject: [PATCH 10/78] feat(#487): left-rail and left-nav-separator modules (phase 3, step 4a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new standalone modules, not yet wired into the app shell: - src/ui/left-rail.ts: the compact icon rail, four launchers built from the section registry (nav-sections.ts) so a rail tooltip/aria-label can never disagree with the wide switchers' own label for the same section. A click routes through toggleFocusedSection. - src/ui/left-nav-separator.ts: the resize/mode-changing separator that will replace splitters.ts's 'col' axis in the next step. Mirrors splitters.ts's existing mouse-event drag model (not Pointer Events). Every pixel decision routes through the LeftNavigationResizeSession from left-nav-layout.ts; this module's own job is pointer/keyboard mechanics, session bookkeeping, and ARIA, never the resize arithmetic or painting the sidebar directly (that's the injected applyEffectiveLayout seam a later step implements). Terminates safely on blur and visibilitychange; mouseup processes its own final coordinate rather than the last mousemove's. Caught during review before wiring anything up: commitSession never called applyEffectiveLayout, so a keyboard-driven resize updated state and ARIA but never actually repainted the sidebar (the pointer path only worked because advanceTo already paints on every mousemove/final mouseup). Fixed by having commitSession paint with the session's own final effective layout — the one place a keyboard resize ever reaches the DOM. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NZfeGpkpJw2zsfPtysGHUN --- src/ui/left-nav-separator.ts | 291 ++++++++++++++++ src/ui/left-rail.ts | 100 ++++++ tests/unit/left-nav-separator.test.ts | 469 ++++++++++++++++++++++++++ tests/unit/left-rail.test.ts | 192 +++++++++++ 4 files changed, 1052 insertions(+) create mode 100644 src/ui/left-nav-separator.ts create mode 100644 src/ui/left-rail.ts create mode 100644 tests/unit/left-nav-separator.test.ts create mode 100644 tests/unit/left-rail.test.ts diff --git a/src/ui/left-nav-separator.ts b/src/ui/left-nav-separator.ts new file mode 100644 index 00000000..ec065f4f --- /dev/null +++ b/src/ui/left-nav-separator.ts @@ -0,0 +1,291 @@ +// #487 phase 3 — the left navigation's resize/mode-changing separator. A +// STANDALONE module today: it will replace `splitters.ts`'s `'col'` axis in a +// later step, but nothing here is wired into the app shell yet, and nothing in +// `splitters.ts` is touched by this change. +// +// Deliberately mirrors `splitters.ts`'s existing MOUSE-event drag model +// (mousedown/mousemove/mouseup on an injected `window`-shaped seam) rather than +// switching to Pointer Events — one drag primitive for the whole app, not two. +// +// Every pixel decision routes through `core/left-nav-layout.ts`'s pure +// reducers via a `LeftNavigationResizeSession` (`beginLeftNavigationResize`/ +// `advanceLeftNavigationResize`/`commitLeftNavigationResize`): this module's own +// job is strictly pointer/keyboard mechanics, session bookkeeping, and ARIA — +// never the resize arithmetic itself, and never painting the sidebar (that is +// `deps.applyEffectiveLayout`, a callback a later app-shell step implements). + +import { batch, effect } from '@preact/signals-core'; +import { + advanceLeftNavigationResize, beginLeftNavigationResize, commitLeftNavigationResize, + leftNavigationSeparatorAria, normalizeLeftNavigationLayout, resolveLeftNavigationDrag, + resolveLeftNavigationKey, +} from '../core/left-nav-layout.js'; +import type { + LeftNavigationLayout, LeftNavigationResizeSession, LeftNavigationSeparatorAria, +} from '../core/left-nav-layout.js'; +import { readLeftNavigationLayout } from '../application/left-nav.js'; +import type { LeftNavStateSlice } from '../application/left-nav.js'; +import { NAV_SECTION_META } from './nav-sections.js'; + +/** The one field `mousedown`/`mousemove`/`mouseup` read — a plain `{clientX}` + * fixture satisfies it, exactly like `splitters.ts`'s own `DragPoint`. */ +export interface LeftNavSeparatorPointerEvent { + clientX: number; +} + +/** The `window`-shaped mouse/blur seam — a real `Window` satisfies this + * directly (mirroring `splitters.ts`'s `DragWindow`, widened to also carry + * `blur`, which takes no event payload worth reading). */ +export interface LeftNavSeparatorWindow { + addEventListener(type: string, listener: (ev: LeftNavSeparatorPointerEvent) => void): void; + removeEventListener(type: string, listener: (ev: LeftNavSeparatorPointerEvent) => void): void; +} + +/** The `document`-shaped `visibilitychange` seam — deliberately NOT read for + * `document.visibilityState` (happy-dom cannot fake it): the event firing at + * all, regardless of direction, is treated as "stop and commit now". */ +export interface LeftNavSeparatorTarget { + addEventListener(type: string, listener: () => void): void; + removeEventListener(type: string, listener: () => void): void; +} + +/** The narrowed persistence seam — this module only ever names these three + * keys, the same narrowing precedent `application/left-nav.ts`'s own + * `LeftNavApp.prefs` sets for `'sidePanel'`. */ +export interface LeftNavSeparatorPrefs { + save(name: 'leftNavMode' | 'sidebarPx' | 'leftNavDrawerPx', value: unknown): void; +} + +export interface LeftNavSeparatorDeps { + /** The separator DOM element — `role`/`aria-*`/`tabindex` are applied to + * THIS element, and mousedown/keydown listen on it directly. */ + el: HTMLElement; + /** Mouse-move/up/blur seam. Defaults to the real `window`. */ + win?: LeftNavSeparatorWindow; + /** `visibilitychange` seam. Defaults to the real `document` — deliberately + * a SEPARATE seam from `win`, since visibility fires on the document. */ + target?: LeftNavSeparatorTarget; + /** The same `LeftNavStateSlice` `application/left-nav.ts` reads/writes — + * reused rather than re-declared, so this module calls the real + * `readLeftNavigationLayout(state)` rather than re-implementing the same + * projection. */ + state: LeftNavStateSlice; + prefs: LeftNavSeparatorPrefs; + /** The live navigation width budget (shell width minus the centre surface's + * minimum minus the separator's own width) — a caller's job, not this + * module's; it only ever asks for a number when it needs one. */ + getMaxNavigationTotalPx(): number; + /** The ONE DOM-painting seam: apply a proposed layout's pixels to the + * sidebar. This module never touches sidebar DOM itself. */ + applyEffectiveLayout(layout: LeftNavigationLayout): void; + /** Optional status-announcement seam — a no-op when omitted. Called only on + * a semantic mode (or drawer open/closed) change, never on a plain width + * change within the same mode. */ + announce?(message: string): void; +} + +export interface LeftNavSeparatorHandle { + /** Remove every listener this module registered (mousedown/keydown on `el`; + * blur on `win`; visibilitychange on `target`; and, if a drag happens to be + * in progress, the drag-only mousemove/mouseup on `win` too) and stop the + * ARIA-refresh effect. Safe to call once; idempotent-safe to call again + * (every underlying `removeEventListener` is a no-op for an + * already-removed listener). */ + dispose(): void; +} + +/** Describe the OCCUPIED-width quantity `aria-valuenow` reports, in words — + * mode-aware, but always naming the SAME total `aria-valuenow` carries (never + * a per-mode panel width; see this module's own header comment for the + * earlier design round that got this wrong). */ +function describeOccupiedWidth(layout: LeftNavigationLayout): string { + if (layout.mode === 'wide') return 'Wide sidebar'; + if (layout.focusedSection === null) return 'Rail only'; + return `Rail with ${NAV_SECTION_META[layout.focusedSection].label} drawer`; +} + +function ariaValueText(layout: LeftNavigationLayout, aria: LeftNavigationSeparatorAria): string { + if (layout.mode === 'rail' && layout.focusedSection !== null) { + return `${describeOccupiedWidth(layout)}, ${aria.valueNow} pixels total`; + } + return `${describeOccupiedWidth(layout)}, ${aria.valueNow} pixels`; +} + +/** + * Mount the separator: apply its static ARIA/DOM attributes once, wire mouse, + * keyboard, blur and visibilitychange handling, and return a `dispose()`. + */ +export function mountLeftNavSeparator(deps: LeftNavSeparatorDeps): LeftNavSeparatorHandle { + const { el, state } = deps; + const win: LeftNavSeparatorWindow = deps.win || window; + const target: LeftNavSeparatorTarget = deps.target || document; + + el.setAttribute('role', 'separator'); + el.setAttribute('aria-orientation', 'vertical'); + el.setAttribute('tabindex', '0'); + + // The in-progress drag/keyboard-resize session, or null between gestures. + let session: LeftNavigationResizeSession | null = null; + + function applyAria(layout: LeftNavigationLayout): void { + const normalized = normalizeLeftNavigationLayout(layout); + const aria = leftNavigationSeparatorAria(normalized, deps.getMaxNavigationTotalPx()); + el.setAttribute('aria-valuemin', String(aria.valueMin)); + el.setAttribute('aria-valuemax', String(aria.valueMax)); + el.setAttribute('aria-valuenow', String(aria.valueNow)); + el.setAttribute('aria-valuetext', ariaValueText(normalized, aria)); + } + + // `writeLeftNavigationLayout` (`application/left-nav.ts`) is module-private + // there (only ever the other half of THAT module's own single batched + // write), so this mirrors its four-field write rather than importing it. + // All four fields, including `leftNavSection`: a committed session's `mode` + // and `focusedSection` always travel together (the layout's own coherence + // invariant — see `core/left-nav-layout.ts`), so writing three of the four + // could leave `state` holding an incoherent pair (e.g. `mode: 'wide'` with a + // stale non-null `leftNavSection` from before the gesture converted rail to + // wide). Only the PERSISTENCE call below is narrowed to three keys — + // `leftNavSection` has no preference key at all (`focusedSection` is + // session-only, per #487), so nothing here ever calls `prefs.save` for it. + // Batched so the reactive ARIA effect below observes one coherent write, not + // an intermediate mode/width mismatch mid-assignment. + function writeLayout(layout: LeftNavigationLayout): void { + batch(() => { + state.leftNavMode.value = layout.mode; + state.sidebarPx = layout.wideWidthPx; + state.leftNavDrawerPx = layout.drawerWidthPx; + state.leftNavSection.value = layout.focusedSection; + }); + } + + function announceIfChanged(before: LeftNavigationLayout, after: LeftNavigationLayout): void { + const modeChanged = before.mode !== after.mode; + const openChanged = (before.focusedSection !== null) !== (after.focusedSection !== null); + if (!modeChanged && !openChanged) return; // a plain width change — no chatter. + deps.announce?.(`Left navigation: ${describeOccupiedWidth(after)}`); + } + + function commitSession(finished: LeftNavigationResizeSession): void { + const committed = commitLeftNavigationResize(finished); + // Paint with the session's own final, viewport-clamped `effective` layout — + // never `committed`, which can legitimately hold a larger, un-clamped + // "honest preference" (the restore-while-clamped case below) that would + // overflow the viewport if painted directly. For a pointer drag this is a + // harmless repaint of the identical layout `advanceTo` already applied + // (`onMouseUp` calls `advanceTo` immediately before `endDrag`/ + // `commitSession`); for the keyboard path (`onKeyDown`) this is the ONLY + // place a keyboard-driven resize ever reaches the DOM at all. + deps.applyEffectiveLayout(finished.effective); + writeLayout(committed); + deps.prefs.save('leftNavMode', committed.mode); + deps.prefs.save('sidebarPx', committed.wideWidthPx); + deps.prefs.save('leftNavDrawerPx', committed.drawerWidthPx); + applyAria(committed); + announceIfChanged(finished.preferredAtStart, committed); + } + + // One reactive effect keeps ARIA current whenever `mode`/`focusedSection` + // change for ANY reason — including a rail click elsewhere in the shell + // (`left-rail.ts`'s `toggleFocusedSection`) that this module never + // initiated. Runs once immediately (the mount-time paint), then on every + // dependency change. `sidebarPx`/`leftNavDrawerPx` are plain fields, not + // signals, so a WIDTH-only change from THIS module's own gestures relies on + // `commitSession`'s own `applyAria(committed)` call above, not this effect. + const disposeAriaEffect = effect(() => { + state.leftNavMode.value; + state.leftNavSection.value; + applyAria(readLeftNavigationLayout(state)); + }); + + /** + * Advance the in-progress session to `clientX`. The shell-left offset is 0 + * today (phase 1's note in `core/left-nav-layout.ts`) — a future left + * gutter would subtract it from `clientX` here, once, in this one place. + */ + function advanceTo(clientX: number): void { + // `!`: `onMouseMove`/`onMouseUp` are only ever listening on `win` while a + // session is active — attached in `onMouseDown`, detached in `endDrag` — + // so `advanceTo` is never reached with `session` null. + const proposedLayout = resolveLeftNavigationDrag(session!.proposed, clientX); + session = advanceLeftNavigationResize(session!, proposedLayout, deps.getMaxNavigationTotalPx()); + deps.applyEffectiveLayout(session.effective); + applyAria(session.effective); + } + + function onMouseMove(ev: LeftNavSeparatorPointerEvent): void { + advanceTo(ev.clientX); + } + + /** Stop listening for the mouse half of a drag and commit whatever the + * session currently holds — used by mouseup (after one final `advanceTo`) + * AND by blur/visibilitychange (with no final coordinate — commit the + * session's CURRENT state as-is, never a rollback to session start). */ + function endDrag(): void { + if (!session) return; + el.classList.remove('dragging'); + win.removeEventListener('mousemove', onMouseMove); + win.removeEventListener('mouseup', onMouseUp); + const finished = session; + session = null; + commitSession(finished); + } + + function onMouseUp(ev: LeftNavSeparatorPointerEvent): void { + // The LAST coordinate before committing — never whatever the last + // mousemove happened to leave (that can differ from the release point). + advanceTo(ev.clientX); + endDrag(); + } + + // `el` is always a real DOM element (never an injected fake, unlike + // `win`/`target`), so its own listeners are typed against the real DOM + // event types directly — no cast needed, and a plain fixture is never + // dispatched through it in tests either. + function onMouseDown(ev: MouseEvent): void { + ev.preventDefault(); + el.classList.add('dragging'); + session = beginLeftNavigationResize(readLeftNavigationLayout(state), deps.getMaxNavigationTotalPx()); + win.addEventListener('mousemove', onMouseMove); + win.addEventListener('mouseup', onMouseUp); + } + + // No coordinate to read on either event — happy-dom cannot fake + // `document.visibilityState` regardless, so both simply stop-and-commit + // whatever the session already holds. A no-op when no drag is active. + function onBlur(): void { endDrag(); } + function onVisibilityChange(): void { endDrag(); } + + function onKeyDown(ev: KeyboardEvent): void { + const layout = readLeftNavigationLayout(state); + const resolved = resolveLeftNavigationKey(layout, ev); + if (resolved === null) return; // not one of ours — no preventDefault, no session. + ev.preventDefault(); + // A single keydown is its own complete session: begin → one advance → + // commit, immediately, through the exact same machinery a drag uses. + const keySession = advanceLeftNavigationResize( + beginLeftNavigationResize(layout, deps.getMaxNavigationTotalPx()), + resolved, + deps.getMaxNavigationTotalPx(), + ); + commitSession(keySession); + } + + el.addEventListener('mousedown', onMouseDown); + el.addEventListener('keydown', onKeyDown); + win.addEventListener('blur', onBlur); + target.addEventListener('visibilitychange', onVisibilityChange); + + function dispose(): void { + disposeAriaEffect(); + el.removeEventListener('mousedown', onMouseDown); + el.removeEventListener('keydown', onKeyDown); + win.removeEventListener('blur', onBlur); + target.removeEventListener('visibilitychange', onVisibilityChange); + // Defensive: harmless no-op if a drag isn't in progress, but guarantees no + // lingering mousemove/mouseup handler if dispose() runs mid-drag. + win.removeEventListener('mousemove', onMouseMove); + win.removeEventListener('mouseup', onMouseUp); + } + + return { dispose }; +} diff --git a/src/ui/left-rail.ts b/src/ui/left-rail.ts new file mode 100644 index 00000000..03087530 --- /dev/null +++ b/src/ui/left-rail.ts @@ -0,0 +1,100 @@ +// #487 phase 3 — the compact icon rail. Four launcher buttons, one per +// `LEFT_NAV_SECTIONS` entry (rail order), built from the section registry +// (`nav-sections.ts`) so a rail tooltip/aria-label can never disagree with the +// wide switchers' own label for the same section. +// +// This module owns DOM + behaviour only: the rail's WIDTH (`LEFT_RAIL_PX`) is +// informational here and is CSS's job in a later step, and the drawer this +// rail's buttons control does not exist as a separate element yet — `showSection` +// keeps living on the section registry, this module just points every button's +// `aria-controls` at whatever single id a later step gives the drawer container. +// +// Reactivity: each button's `aria-expanded` is driven by its own `effect()` +// reading `state.leftNavSection` — a section's drawer is "open" exactly when +// `state.leftNavSection.value === section` (`application/left-nav.ts`'s own +// notion of the focused section). One effect per button, not one for the whole +// rail, so a section switch touches only the two buttons whose expanded state +// actually changed. +// +// A rail click is a TOGGLE (`toggleFocusedSection`), not an idempotent open +// (`openFocusedSection`): clicking the already-open section's icon closes the +// drawer, which is #487's own rail-click semantics and is NOT what the +// drag-hover seam (#428) wants — that seam calls `openFocusedSection` directly, +// bypassing this module entirely. + +import { effect } from '@preact/signals-core'; +import type { Signal } from '@preact/signals-core'; +import { h } from './dom.js'; +import { LEFT_NAV_SECTIONS } from '../core/left-nav-layout.js'; +import type { LeftNavigationSection } from '../core/left-nav-layout.js'; +import { toggleFocusedSection } from '../application/left-nav.js'; +import type { LeftNavApp } from '../application/left-nav.js'; +import type { NavSectionRegistry } from './nav-sections.js'; + +/** The state slice the rail reads — just enough to know which section (if any) + * the focused drawer currently shows. */ +export interface LeftRailStateSlice { + readonly leftNavSection: Signal; +} + +export interface LeftRailDeps { + /** Reused verbatim from `application/left-nav.ts` — a click routes through + * its own `toggleFocusedSection`, never a locally re-implemented write. */ + app: LeftNavApp; + /** Only metadata lookup is needed — the rail neither renders a section's own + * content nor decides which pane exposes it. */ + registry: Pick; + state: LeftRailStateSlice; + /** The stable DOM id of the (single, content-swapping) focused-drawer + * container a later step gives the sidebar element — every launcher's + * `aria-controls` points at this same id, since all four buttons control + * the one drawer, just with different content. */ + drawerElementId: string; +} + +export interface LeftRailHandle { + readonly el: HTMLElement; + /** Stop every per-button reactive effect. Idempotent-safe to call once. */ + dispose(): void; +} + +/** + * Build the rail `