From 3c053310ac8a02b6c8e0e4ebd5b25bf7e768db61 Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Tue, 9 Jun 2026 23:54:20 +0300 Subject: [PATCH] Re-split library and preview when the editor is hidden; remember the editor's width --- .../10-interaction-and-feedback.md | 17 ++ docs/spec/01-application-shell.md | 4 +- src/app/App.tsx | 9 +- src/app/components/PaneSplitHandle.tsx | 122 ++++++++++++++ src/app/components/PaneToggleStrip.tsx | 13 +- src/app/components/ResizeHandle.module.css | 2 +- src/app/infrastructure/ux-prefs.ts | 3 + src/app/orchestration/panes.ts | 10 +- src/app/stores/PanesStore.test.ts | 135 ++++++++++++++++ src/app/stores/PanesStore.ts | 151 +++++++++++++++++- 10 files changed, 452 insertions(+), 14 deletions(-) create mode 100644 src/app/components/PaneSplitHandle.tsx diff --git a/docs/architecture/10-interaction-and-feedback.md b/docs/architecture/10-interaction-and-feedback.md index 64ced98..361f467 100644 --- a/docs/architecture/10-interaction-and-feedback.md +++ b/docs/architecture/10-interaction-and-feedback.md @@ -181,6 +181,23 @@ lives in [04 · Routing & Global Events](04-routing-and-events.md). now has a home — the hidden-pane state landed in M6 with the toggle strip (below). The strip owns show/hide; wiring the splitter's Enter to it is an optional convenience, not required. +**Editor-hidden layout.** Pane model: the two **side** panes carry stored widths; the **editor +is the flex filler** (no stored width while shown). Show/hide is layout-aware only for the +editor — hiding **captures** the width it had (`capturedEditorWidth`), showing **pins it back** +and re-splits the rest at the side panes' ratio (`shownSideWidths`); side panes just flip +visibility and flex-fill the freed space (spec §01A). So `togglePane` needs `panesInner` +(panes-row width minus the toggle strip, measured by `PaneToggleStrip`) for the editor only. +With the editor hidden, library and preview become adjacent and share a **second splitter**, +`PaneSplitHandle` — separate from `ResizeHandle` because the geometry differs: `ResizeHandle` +resizes one side pane while the editor absorbs the change (the opposite side is untouched); +`PaneSplitHandle` re-proportions **two** panes with no filler between them (editor-hidden, the +side panes render `flex: width 1 0` and share the span), the pure `splitLibraryWidth()` holding +each above its min. **valuenow trade-off:** it reports the library's 0–100 position +(`splitValue()`) from the **stored ratio**, deliberately container-independent — so unlike +`ResizeHandle` it does **not** observe the container, and `aria-valuenow` can drift from the +rendered position after a window enlargement (worst near the extremes). Drag/keyboard read live +`clientWidth`, so resizing itself stays accurate; only the announced value drifts. + _(Consulted via `/council` → WAI-ARIA APG `windowsplitter`. This bullet is the contract; cite it, not the APG file.)_ diff --git a/docs/spec/01-application-shell.md b/docs/spec/01-application-shell.md index ca5952c..cd4ca26 100644 --- a/docs/spec/01-application-shell.md +++ b/docs/spec/01-application-shell.md @@ -13,10 +13,10 @@ Astrolabe is a single-screen workspace. Below a fixed top header sits a three-pa Behavior: - All three panes are visible by default, laid out side by side in the order library, editor, preview. -- Adjacent panes are separated by a vertical drag handle. The user can drag a handle left/right to resize the two panes it sits between; the rest of the layout is unaffected. +- Adjacent panes are separated by a vertical drag handle. The user can drag a handle left/right to resize the two panes it sits between; the rest of the layout is unaffected. When the editor is hidden, the library and preview become adjacent and a single handle between them re-splits the freed space. - Each pane enforces a minimum width while resizing, so a pane cannot be dragged to nothing. - Each pane can be individually shown or hidden via a persistent **toggle strip** (a narrow vertical strip of toggle buttons, one per pane, each indicating whether its pane is currently shown). The toggle strip also contains a shortcut button that opens the Datasets manager. -- When a pane is hidden, the remaining visible panes expand to fill the freed space, redistributing proportionally to their remembered widths. When a previously hidden pane is shown again, it returns at its remembered width. +- When a pane is hidden, the remaining visible panes expand to fill the freed space, redistributing proportionally to their remembered widths. When a previously hidden pane is shown again, it returns at its remembered width. The center editor is normally the flex filler with no fixed width, so it remembers the width it had at the moment it was hidden and reclaims that width when shown again; the library and preview then keep the split ratio they were left at while it was hidden. - Hiding all panes is permitted; the toggle strip remains available to bring panes back. - Pane widths and per-pane visibility persist locally across sessions and are restored on next load. The app remembers a pane's preferred width even while it is hidden, so re-showing it restores that width rather than an arbitrary one. diff --git a/src/app/App.tsx b/src/app/App.tsx index a75b8f3..3258d03 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -3,6 +3,7 @@ import { ConfirmDialog } from './components/ConfirmDialog'; import { LivePreview } from './components/LivePreview'; import { ModalShell } from './components/ModalShell'; import { Onboarding } from './components/Onboarding'; +import { PaneSplitHandle } from './components/PaneSplitHandle'; import { PaneToggleStrip } from './components/PaneToggleStrip'; import { ResizeHandle } from './components/ResizeHandle'; import { SnippetLibrary } from './components/SnippetLibrary'; @@ -143,10 +144,16 @@ export function App() { )} - {/* A resize handle only sits between two visible panes that flank the editor. */} + {/* A resize handle sits between any two adjacent visible panes. With the + editor present it flanks the editor (it absorbs the drag); with the + editor hidden, the library and preview become adjacent and share a + single split handle between them (spec §01A). */} {libraryVisible && editorVisible && ( )} + {libraryVisible && previewVisible && !editorVisible && ( + + )} {editorVisible && (
diff --git a/src/app/components/PaneSplitHandle.tsx b/src/app/components/PaneSplitHandle.tsx new file mode 100644 index 0000000..9b3fa32 --- /dev/null +++ b/src/app/components/PaneSplitHandle.tsx @@ -0,0 +1,122 @@ +/** + * Vertical drag handle between the library and preview when the editor is hidden + * (spec §01A). With no editor between them to absorb the change, the two side + * panes share the span: dragging re-splits it — one grows, the other shrinks + * (zero-sum) — each kept above its minimum by the pure `splitLibraryWidth`. + * + * The split is read from the live DOM: the two flanking panes (this handle's + * previous/next siblings) render proportionally (`flex: width 1 0`), so writing + * their measured widths back makes the boundary track the pointer 1:1, at any + * window size. Re-showing the editor later keeps whatever ratio was left here + * (PanesStore → `shownSideWidths`). + * + * Accessibility mirrors the editor-flanking ResizeHandle (WAI-ARIA APG → Window + * Splitter): a focusable `separator` reporting the library's 0–100 position via + * `aria-valuenow`, driven by ←/→ to nudge and Home/End to jump to min/max. It + * reuses ResizeHandle's stylesheet so the two handles look identical. + */ + +import { useRef } from 'react'; +import { splitLibraryWidth, splitValue, usePanesStore } from '../stores/PanesStore'; +import styles from './ResizeHandle.module.css'; + +/** Keyboard nudge step (px) per arrow press — matches ResizeHandle. */ +const KEY_STEP = 16; + +interface PaneSplitHandleProps { + /** Accessible label, e.g. "Resize library and preview". */ + label: string; +} + +export function PaneSplitHandle({ label }: PaneSplitHandleProps) { + const ref = useRef(null); + + // The reported position recomputes from the stored split as it changes. It reads + // the *stored* ratio (container-independent, per the file header), so unlike + // ResizeHandle aria-valuenow can drift from the rendered position after a window + // enlargement (worst near the extremes) — an accepted simplicity trade-off, the + // contract in arch/10 (splitter). Drag/keyboard read live clientWidth below, so + // resizing itself stays accurate; only the announced value drifts. + const libraryWidth = usePanesStore((s) => s.libraryWidth); + const previewWidth = usePanesStore((s) => s.previewWidth); + const valueNow = splitValue(libraryWidth, previewWidth); + + /** The panes this handle sits between: library before it, preview after it. */ + const flanks = () => ({ + lib: ref.current?.previousElementSibling as HTMLElement | null, + prev: ref.current?.nextElementSibling as HTMLElement | null, + }); + + /** Apply a desired (rendered) library width, clamped against the live span. */ + const applySplit = (desiredLibrary: number) => { + const { lib, prev } = flanks(); + if (!lib || !prev) return; + const avail = lib.clientWidth + prev.clientWidth; + const library = splitLibraryWidth(desiredLibrary, avail); + usePanesStore.getState().setSplit(library, avail - library); + }; + + const onPointerDown = (e: React.PointerEvent) => { + if (e.button !== 0) return; // primary button only + e.preventDefault(); + const startX = e.clientX; + const startLib = flanks().lib?.clientWidth ?? 0; + + const onMove = (ev: PointerEvent) => applySplit(startLib + (ev.clientX - startX)); + const onUp = () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }; + + // Keyboard per WAI-ARIA APG → Window Splitter: arrows nudge the library side; + // Home/End jump to the library's smallest/largest allowed share of the span. + const onKeyDown = (e: React.KeyboardEvent) => { + const lib = flanks().lib?.clientWidth ?? 0; + switch (e.key) { + case 'ArrowLeft': + applySplit(lib - KEY_STEP); + break; + case 'ArrowRight': + applySplit(lib + KEY_STEP); + break; + case 'Home': // library at its minimum + applySplit(0); + break; + case 'End': // library at its maximum (preview at its minimum) + applySplit(Number.MAX_SAFE_INTEGER); + break; + default: + return; // not ours — let it bubble + } + e.preventDefault(); + }; + + return ( +
+
+ ); +} diff --git a/src/app/components/PaneToggleStrip.tsx b/src/app/components/PaneToggleStrip.tsx index 148eb8f..50df0f1 100644 --- a/src/app/components/PaneToggleStrip.tsx +++ b/src/app/components/PaneToggleStrip.tsx @@ -51,6 +51,16 @@ export function PaneToggleStrip() { preview: previewVisible, }; + // The strip's own element — its parent is the panes row, so we can measure the + // width available to the panes (row minus this strip) when toggling the editor, + // which needs it to remember/restore the editor's width (PanesStore.togglePane). + const stripRef = useRef(null); + const panesInner = (): number => { + const strip = stripRef.current; + const row = strip?.parentElement; + return row ? row.clientWidth - strip.offsetWidth : 0; + }; + // Roving tabindex over the toggles + the trailing Datasets action (the divider // is not a control). One tab stop; the focused index is the only `tabIndex=0`. const count = PANES.length + 1; @@ -88,6 +98,7 @@ export function PaneToggleStrip() { return (
{ - togglePane(item.pane); + togglePane(item.pane, panesInner()); setFocusIndex(i); }} onKeyDown={(e) => onKeyDown(e, i)} diff --git a/src/app/components/ResizeHandle.module.css b/src/app/components/ResizeHandle.module.css index 3d82cff..b5d24cd 100644 --- a/src/app/components/ResizeHandle.module.css +++ b/src/app/components/ResizeHandle.module.css @@ -1,6 +1,6 @@ /* * 6px-wide hit target with a thin visible grip. Width is part of the layout - * budget (HANDLES_TOTAL in PanesStore); keep them in sync if this changes. + * budget (HANDLE_WIDTH in PanesStore); keep them in sync if this changes. */ .handle { flex: 0 0 6px; diff --git a/src/app/infrastructure/ux-prefs.ts b/src/app/infrastructure/ux-prefs.ts index bf18117..87162cb 100644 --- a/src/app/infrastructure/ux-prefs.ts +++ b/src/app/infrastructure/ux-prefs.ts @@ -28,6 +28,8 @@ const KEY = 'astrolabe:ux-prefs'; export interface PanelLayout { libraryWidth?: number; previewWidth?: number; + /** The editor's remembered width, so it re-shows at it when revealed (§01A). */ + editorWidth?: number; } /** Per-pane visibility. Optional — a missing field falls back to "shown". */ @@ -91,6 +93,7 @@ export function loadPanelLayout(): PanelLayout { return { libraryWidth: posNumber(stored.libraryWidth), previewWidth: posNumber(stored.previewWidth), + editorWidth: posNumber(stored.editorWidth), }; } diff --git a/src/app/orchestration/panes.ts b/src/app/orchestration/panes.ts index a7ed58b..dfdaefc 100644 --- a/src/app/orchestration/panes.ts +++ b/src/app/orchestration/panes.ts @@ -32,11 +32,15 @@ export function initPanes(): void { export function wirePanes(): () => void { let timer: ReturnType | undefined; return usePanesStore.subscribe((state, prev) => { - if (state.libraryWidth !== prev.libraryWidth || state.previewWidth !== prev.previewWidth) { + if ( + state.libraryWidth !== prev.libraryWidth || + state.previewWidth !== prev.previewWidth || + state.editorWidth !== prev.editorWidth + ) { clearTimeout(timer); timer = setTimeout(() => { - const { libraryWidth, previewWidth } = usePanesStore.getState(); - savePanelLayout({ libraryWidth, previewWidth }); + const { libraryWidth, previewWidth, editorWidth } = usePanesStore.getState(); + savePanelLayout({ libraryWidth, previewWidth, editorWidth }); }, PANES_PERSIST_DEBOUNCE_MS); } if ( diff --git a/src/app/stores/PanesStore.test.ts b/src/app/stores/PanesStore.test.ts index 7cd213c..938e68b 100644 --- a/src/app/stores/PanesStore.test.ts +++ b/src/app/stores/PanesStore.test.ts @@ -1,11 +1,16 @@ import { beforeEach, describe, expect, test } from 'vitest'; import { + capturedEditorWidth, clampSideWidth, + HANDLE_WIDTH, HANDLES_TOTAL, maxSideWidth, PANE_DEFAULT, PANE_MIN, + shownSideWidths, sideWidthValue, + splitLibraryWidth, + splitValue, usePanesStore, } from './PanesStore'; @@ -139,6 +144,136 @@ describe('pane visibility (§01A)', () => { }); }); +describe('splitLibraryWidth (editor-hidden library↔preview split, §01A)', () => { + // The two side panes share this span when the editor is hidden. + const AVAIL = 1000; + + test('passes a comfortable width through unchanged', () => { + expect(splitLibraryWidth(400, AVAIL)).toBe(400); + }); + + test('never goes below the library minimum', () => { + expect(splitLibraryWidth(50, AVAIL)).toBe(PANE_MIN.library); + }); + + test('caps so the preview keeps at least its minimum', () => { + expect(splitLibraryWidth(AVAIL, AVAIL)).toBe(AVAIL - PANE_MIN.preview); + }); + + test('a span too small for both minimums still keeps the library minimum', () => { + // 300px can't fit library(180)+preview(240); the library holds its own min. + expect(splitLibraryWidth(250, 300)).toBe(PANE_MIN.library); + }); +}); + +describe('splitValue (aria-valuenow for the editor-hidden split)', () => { + test('reports 0 at the library minimum and 100 at its maximum', () => { + const span = 1000; + expect(splitValue(PANE_MIN.library, span - PANE_MIN.library)).toBe(0); + expect(splitValue(span - PANE_MIN.preview, PANE_MIN.preview)).toBe(100); + }); + + test('reports the midpoint as ~50', () => { + // Library halfway between its min and its max (preview at min) over a 1000 span. + const max = 1000 - PANE_MIN.preview; + const mid = (PANE_MIN.library + max) / 2; + expect(splitValue(mid, 1000 - mid)).toBe(50); + }); + + test('returns null when the span has no range (too narrow for both minimums)', () => { + expect(splitValue(190, 190)).toBeNull(); + }); +}); + +describe('capturedEditorWidth (remembered on hide, §01A)', () => { + const PANES_INNER = 1200; + + test('is the span left after both side panes and their two handles', () => { + expect(capturedEditorWidth(PANES_INNER, 280, 360, true, true)).toBe( + PANES_INNER - 280 - 360 - HANDLES_TOTAL, + ); + }); + + test('accounts for only the visible side panes (one handle when one is hidden)', () => { + expect(capturedEditorWidth(PANES_INNER, 280, 360, true, false)).toBe( + PANES_INNER - 280 - HANDLE_WIDTH, + ); + }); + + test('floors at the editor minimum on a cramped row', () => { + expect(capturedEditorWidth(700, 300, 300, true, true)).toBe(PANE_MIN.editor); + }); +}); + +describe('shownSideWidths (editor re-shown, §01A)', () => { + const PANES_INNER = 1200; + // The width captured when hiding the editor from a 280/360 layout. + const E = capturedEditorWidth(PANES_INNER, 280, 360, true, true); // 548 + + test('an untouched split returns to pixel-perfect on re-show', () => { + expect(shownSideWidths(PANES_INNER, E, 280, 360, true, true)).toEqual({ + libraryWidth: 280, + previewWidth: 360, + }); + }); + + test('pins the editor width and keeps the ratio set while hidden', () => { + // The user re-split to 400:240 while the editor was hidden. + const { libraryWidth, previewWidth } = shownSideWidths(PANES_INNER, E, 400, 240, true, true); + // Editor reclaims its slot, so the side panes' combined width is unchanged… + expect(libraryWidth + previewWidth).toBe(PANES_INNER - E - HANDLES_TOTAL); + // …and the 400:240 ratio is preserved (5:3). + expect(libraryWidth / previewWidth).toBeCloseTo(400 / 240, 5); + }); + + test('no remembered editor width leaves the side widths untouched (legacy/never hidden)', () => { + expect(shownSideWidths(PANES_INNER, 0, 280, 360, true, true)).toEqual({ + libraryWidth: 280, + previewWidth: 360, + }); + }); + + test('a lone visible side pane takes the whole leftover span', () => { + const { libraryWidth, previewWidth } = shownSideWidths(PANES_INNER, E, 280, 360, true, false); + expect(libraryWidth).toBe(PANES_INNER - E - HANDLE_WIDTH); + expect(previewWidth).toBe(360); // the hidden preview keeps its remembered width + }); +}); + +describe('editor toggle remembers and restores its width (§01A)', () => { + const PANES_INNER = 1200; + + test('hiding captures the editor width; showing restores the whole layout', () => { + store().hydrate({ libraryWidth: 280, previewWidth: 360 }); + store().togglePane('editor', PANES_INNER); + expect(store().editorVisible).toBe(false); + expect(store().editorWidth).toBe(PANES_INNER - 280 - 360 - HANDLES_TOTAL); + // The side widths are untouched while hidden (they fill via proportional flex). + expect(store().libraryWidth).toBe(280); + expect(store().previewWidth).toBe(360); + + store().togglePane('editor', PANES_INNER); + expect(store().editorVisible).toBe(true); + expect(store().libraryWidth).toBe(280); + expect(store().previewWidth).toBe(360); + }); + + test('a split made while hidden carries its ratio back, editor width pinned', () => { + store().hydrate({ libraryWidth: 280, previewWidth: 360 }); + store().togglePane('editor', PANES_INNER); + const captured = store().editorWidth; + // Drag the library↔preview handle while hidden (setSplit writes both widths). + store().setSplit(400, 240); + + store().togglePane('editor', PANES_INNER); + expect(store().editorWidth).toBe(captured); // editor reclaims its slot + expect(store().libraryWidth + store().previewWidth).toBe( + PANES_INNER - captured - HANDLES_TOTAL, + ); + expect(store().libraryWidth / store().previewWidth).toBeCloseTo(400 / 240, 5); + }); +}); + describe('applyOnboardingSplit (spec §02 → First-Run & Empty Workspace)', () => { test('lays out 25·25·50 on a roomy container', () => { const W = 1600; diff --git a/src/app/stores/PanesStore.ts b/src/app/stores/PanesStore.ts index 2afdc30..1345472 100644 --- a/src/app/stores/PanesStore.ts +++ b/src/app/stores/PanesStore.ts @@ -31,8 +31,11 @@ export const PANE_MIN = { library: 180, preview: 240, editor: 320 } as const; /** Initial side-pane widths (px) on first run, before any persisted layout. */ export const PANE_DEFAULT = { library: 280, preview: 360 } as const; -/** Combined width (px) the resize handles occupy between the panes. */ -export const HANDLES_TOTAL = 12; +/** Width (px) of one resize handle. */ +export const HANDLE_WIDTH = 6; + +/** Combined width (px) the two handles flanking the editor occupy. */ +export const HANDLES_TOTAL = HANDLE_WIDTH * 2; /** * The widest a side pane can be while the editor still meets its minimum. @@ -82,6 +85,88 @@ export function sideWidthValue( return Math.round(Math.min(100, Math.max(0, pct))); } +/** + * Clamp the library's width when it and the preview share a span with no editor + * between them — the editor-hidden split (spec §01A). `availForBoth` is the + * combined width the two panes fill; each keeps at least its own minimum. Pure — + * the single place the library↔preview split constraint lives, used by both the + * split drag handle and the editor-reveal re-layout. + */ +export function splitLibraryWidth(desiredLibrary: number, availForBoth: number): number { + const min = PANE_MIN.library; + // Never let the library crowd the preview below its minimum; if the span is too + // small for both, the library still keeps its own minimum. + const max = Math.max(min, availForBoth - PANE_MIN.preview); + return Math.max(min, Math.min(desiredLibrary, max)); +} + +/** + * The library's 0–100 position in the editor-hidden split, for the separator's + * `aria-valuenow` (WAI-ARIA APG → Window Splitter): 0 = library at its minimum, + * 100 = library at its maximum (preview at its minimum). Reads the ratio straight + * from the two stored widths, so it's independent of the container. Null when the + * span has no range (too narrow for both minimums), so the caller omits the attr. + */ +export function splitValue(libraryWidth: number, previewWidth: number): number | null { + const span = libraryWidth + previewWidth; + const min = PANE_MIN.library; + const max = span - PANE_MIN.preview; + if (max <= min) return null; + const pct = ((libraryWidth - min) / (max - min)) * 100; + return Math.round(Math.min(100, Math.max(0, pct))); +} + +/** + * The width to remember for the editor at the moment it is hidden, so it re-shows + * at the same width (spec §01A; the editor gains a remembered width of its own). + * It's the span the editor currently occupies: the panes row minus the toggle + * strip (`panesInner`), minus the visible side panes and the handle beside each. + * Floored at the editor minimum. + */ +export function capturedEditorWidth( + panesInner: number, + libraryWidth: number, + previewWidth: number, + libraryVisible: boolean, + previewVisible: boolean, +): number { + const sides = (libraryVisible ? libraryWidth : 0) + (previewVisible ? previewWidth : 0); + const handles = (Number(libraryVisible) + Number(previewVisible)) * HANDLE_WIDTH; + return Math.max(PANE_MIN.editor, panesInner - sides - handles); +} + +/** + * The side-pane widths to apply when the editor is shown again: pin the editor to + * its remembered width and keep the library:preview *ratio*, rescaled to whatever + * space is left (spec §01A — the editor reclaims its slot; the side panes keep the + * proportion set while it was hidden). When the window hasn't changed since hiding, + * the remembered editor width makes `avail` equal the side panes' former combined + * width, so an untouched split returns pixel-perfect. A zero/absent remembered + * width (never hidden, or legacy state) means leave the widths be and let the + * editor fill the remainder as the flex filler. + */ +export function shownSideWidths( + panesInner: number, + editorWidth: number, + libraryWidth: number, + previewWidth: number, + libraryVisible: boolean, + previewVisible: boolean, +): { libraryWidth: number; previewWidth: number } { + const handles = (Number(libraryVisible) + Number(previewVisible)) * HANDLE_WIDTH; + const avail = panesInner - editorWidth - handles; + if (editorWidth <= 0 || avail <= 0) return { libraryWidth, previewWidth }; + if (libraryVisible && previewVisible) { + const ratio = libraryWidth / (libraryWidth + previewWidth || 1); + const library = splitLibraryWidth(Math.round(ratio * avail), avail); + return { libraryWidth: library, previewWidth: avail - library }; + } + // Only one side pane is visible: it takes the whole leftover span. + if (libraryVisible) return { libraryWidth: Math.max(PANE_MIN.library, avail), previewWidth }; + if (previewVisible) return { libraryWidth, previewWidth: Math.max(PANE_MIN.preview, avail) }; + return { libraryWidth, previewWidth }; +} + /** Per-pane visibility (spec §01A). Widths are kept *independently* of visibility, * so a hidden pane keeps its remembered width and re-shows at it (not a default). */ export interface PaneVisibility { @@ -93,12 +178,24 @@ export interface PaneVisibility { export interface PanesState { libraryWidth: number; previewWidth: number; + /** + * The editor's remembered width, captured when it is hidden so it re-shows at + * the same size (spec §01A). Zero until the editor has been hidden at least once + * — while the editor is shown it is the flex filler and this is unused. + */ + editorWidth: number; /** Whether each pane is currently shown. All visible by default (§01A). */ libraryVisible: boolean; editorVisible: boolean; previewVisible: boolean; /** Set a side pane's width (already clamped by the caller). */ setWidth: (side: PaneSide, width: number) => void; + /** + * Set both side-pane widths at once — the editor-hidden split, where dragging + * the library↔preview handle re-proportions the two together (already clamped by + * the caller via `splitLibraryWidth`). + */ + setSplit: (libraryWidth: number, previewWidth: number) => void; /** * Lay the workspace out at the onboarding default split — library 25% · editor * 25% · preview 50% of `containerWidth` — and show all three panes. Applied when @@ -108,11 +205,17 @@ export interface PanesState { * to keep every pane at least its minimum. */ applyOnboardingSplit: (containerWidth: number) => void; - /** Show/hide one pane. Hiding all panes is permitted (§01A) — the strip stays. */ - togglePane: (pane: PaneName) => void; + /** + * Show/hide one pane. Hiding all panes is permitted (§01A) — the strip stays. + * Toggling the **editor** is layout-aware: hiding remembers its current width and + * showing pins it back, keeping the library:preview ratio — so `panesInner` (the + * panes-row width minus the toggle strip) must be passed for the editor. It is + * ignored for the side panes, which only flip visibility. + */ + togglePane: (pane: PaneName, panesInner?: number) => void; /** Restore persisted widths + visibility on startup; missing values keep defaults. */ hydrate: ( - layout: { libraryWidth?: number; previewWidth?: number }, + layout: { libraryWidth?: number; previewWidth?: number; editorWidth?: number }, visibility?: PaneVisibility, ) => void; } @@ -126,6 +229,7 @@ const VISIBLE_KEY = { export const usePanesStore = create((set) => ({ libraryWidth: PANE_DEFAULT.library, previewWidth: PANE_DEFAULT.preview, + editorWidth: 0, libraryVisible: true, editorVisible: true, previewVisible: true, @@ -133,6 +237,8 @@ export const usePanesStore = create((set) => ({ setWidth: (side, width) => set(side === 'library' ? { libraryWidth: width } : { previewWidth: width }), + setSplit: (libraryWidth, previewWidth) => set({ libraryWidth, previewWidth }), + applyOnboardingSplit: (containerWidth) => set(() => { // Clamp preview (the larger share) first, then library against it, so the @@ -158,12 +264,45 @@ export const usePanesStore = create((set) => ({ }; }), - togglePane: (pane) => set((s) => ({ [VISIBLE_KEY[pane]]: !s[VISIBLE_KEY[pane]] })), + togglePane: (pane, panesInner = 0) => + set((s) => { + // Side panes only flip visibility; the editor (the flex filler) also carries + // its width across the hide/show so it reclaims its slot (spec §01A). + if (pane !== 'editor') return { [VISIBLE_KEY[pane]]: !s[VISIBLE_KEY[pane]] }; + if (s.editorVisible) { + // Hiding: remember the editor's current width; the side panes keep theirs + // and expand proportionally to fill the freed space (their flex render). + return { + editorVisible: false, + editorWidth: capturedEditorWidth( + panesInner, + s.libraryWidth, + s.previewWidth, + s.libraryVisible, + s.previewVisible, + ), + }; + } + // Showing: pin the editor to its remembered width; re-split the rest between + // the side panes at the ratio they were left at while it was hidden. + return { + editorVisible: true, + ...shownSideWidths( + panesInner, + s.editorWidth, + s.libraryWidth, + s.previewWidth, + s.libraryVisible, + s.previewVisible, + ), + }; + }), hydrate: (layout, visibility) => set({ libraryWidth: layout.libraryWidth ?? PANE_DEFAULT.library, previewWidth: layout.previewWidth ?? PANE_DEFAULT.preview, + editorWidth: layout.editorWidth ?? 0, libraryVisible: visibility?.library ?? true, editorVisible: visibility?.editor ?? true, previewVisible: visibility?.preview ?? true,