Re-split library and preview when the editor is hidden; remember the editor's width

This commit is contained in:
2026-06-09 23:54:20 +03:00
parent 418bf23cd8
commit 3c053310ac
10 changed files with 452 additions and 14 deletions
@@ -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 0100 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.)_
+2 -2
View File
@@ -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.
+8 -1
View File
@@ -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() {
<SnippetLibrary />
</section>
)}
{/* 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 && (
<ResizeHandle side="library" label="Resize snippet library" />
)}
{libraryVisible && previewVisible && !editorVisible && (
<PaneSplitHandle label="Resize library and preview" />
)}
{editorVisible && (
<section id="pane-editor" className={styles.paneEditor} aria-label="Spec editor">
<SpecEditor />
+122
View File
@@ -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 0100 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<HTMLDivElement>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<div
ref={ref}
className={styles.handle}
role="separator"
aria-orientation="vertical"
aria-label={label}
// The split governs both panes it sits between.
aria-controls="pane-library pane-preview"
aria-valuenow={valueNow ?? undefined}
aria-valuemin={valueNow === null ? undefined : 0}
aria-valuemax={valueNow === null ? undefined : 100}
aria-valuetext={valueNow === null ? undefined : `${valueNow}%`}
tabIndex={0}
onPointerDown={onPointerDown}
onKeyDown={onKeyDown}
>
<span className={styles.grip} aria-hidden="true" />
</div>
);
}
+12 -1
View File
@@ -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<HTMLDivElement>(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 (
<div
ref={stripRef}
role="toolbar"
aria-orientation="vertical"
aria-label="Workspace panes"
@@ -117,7 +128,7 @@ export function PaneToggleStrip() {
.filter(Boolean)
.join(' ')}
onClick={() => {
togglePane(item.pane);
togglePane(item.pane, panesInner());
setFocusIndex(i);
}}
onKeyDown={(e) => onKeyDown(e, i)}
+1 -1
View File
@@ -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;
+3
View File
@@ -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),
};
}
+7 -3
View File
@@ -32,11 +32,15 @@ export function initPanes(): void {
export function wirePanes(): () => void {
let timer: ReturnType<typeof setTimeout> | 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 (
+135
View File
@@ -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;
+145 -6
View File
@@ -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 librarypreview 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 0100 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 librarypreview 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<PanesState>((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<PanesState>((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<PanesState>((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,