{
- 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,