Add resizable panes with drag handles, min widths, and persistence

This commit is contained in:
2026-06-05 10:46:07 +03:00
parent 411bfbc6c2
commit c50f141d57
11 changed files with 482 additions and 25 deletions
+8 -12
View File
@@ -40,28 +40,24 @@
min-height: 0;
}
/*
* Side panes (library, preview) carry an explicit width (set inline from the
* PanesStore) and don't grow or shrink — the drag handles change that width.
* The editor between them flexes to fill the remainder, so a drag leaves the
* opposite side pane untouched (spec §01A). Panes are separated by the
* ResizeHandle, so no inter-pane borders here.
*/
.pane {
flex: 1 1 0;
flex: 0 0 auto;
min-width: 0;
overflow: auto;
border-right: var(--border-width) solid var(--border);
background: var(--bg);
}
/* Library is a fixed-ish sidebar; editor + preview share the rest. */
.panes > .pane:first-child {
flex: 0 0 280px;
}
/* Editor pane: Monaco manages its own scroll/layout, so no padding. */
.paneEditor {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
border-right: var(--border-width) solid var(--border);
background: var(--bg);
}
.pane:last-child {
border-right: none;
}
+16 -4
View File
@@ -1,19 +1,25 @@
import { ConfirmDialog } from './components/ConfirmDialog';
import { LivePreview } from './components/LivePreview';
import { ResizeHandle } from './components/ResizeHandle';
import { SnippetLibrary } from './components/SnippetLibrary';
import { SpecEditor } from './components/SpecEditor';
import { ThemeToggle } from './components/ThemeToggle';
import { usePanesStore } from './stores/PanesStore';
import styles from './App.module.css';
/**
* Application shell — the three-pane workspace from spec §01A
* (library · editor · preview) under a fixed header.
*
* M1 fills the panes with the MVP authoring loop. Pane resizing/toggling,
* modals, routing, and shortcuts arrive in later milestones (see
* The center editor flexes; the library and preview carry remembered widths and
* are resized via the drag handles between them (spec §01A). Pane show/hide
* toggling, modals, routing, and shortcuts arrive in later milestones (see
* docs/IMPLEMENTATION-PLAN.md).
*/
export function App() {
const libraryWidth = usePanesStore((s) => s.libraryWidth);
const previewWidth = usePanesStore((s) => s.previewWidth);
return (
<div className={styles.app}>
<header className={styles.header}>
@@ -24,13 +30,19 @@ export function App() {
</header>
<main className={styles.panes}>
<section className={styles.pane} aria-label="Snippet library">
<section
className={styles.pane}
style={{ width: libraryWidth }}
aria-label="Snippet library"
>
<SnippetLibrary />
</section>
<ResizeHandle side="library" label="Resize snippet library" />
<section className={styles.paneEditor} aria-label="Spec editor">
<SpecEditor />
</section>
<section className={styles.pane} aria-label="Live preview">
<ResizeHandle side="preview" label="Resize live preview" />
<section className={styles.pane} style={{ width: previewWidth }} aria-label="Live preview">
<LivePreview />
</section>
</main>
@@ -0,0 +1,35 @@
/*
* 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.
*/
.handle {
flex: 0 0 6px;
position: relative;
cursor: col-resize;
background: var(--border);
touch-action: none; /* let pointer drags own the gesture, not scroll */
transition: background var(--dur-fast) var(--ease);
}
.handle:hover,
.handle:focus-visible {
background: var(--accent);
outline: none;
}
/* A short centered grip line, so the handle reads as a draggable divider. */
.grip {
position: absolute;
top: 50%;
left: 50%;
width: 2px;
height: 24px;
transform: translate(-50%, -50%);
background: var(--border-strong);
border-radius: var(--radius);
}
.handle:hover .grip,
.handle:focus-visible .grip {
background: var(--accent-contrast);
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Vertical drag handle between two panes (spec §01A).
*
* Sits between a side pane and the editor; dragging resizes the side pane while
* the editor absorbs the change, so the opposite side pane is unaffected. Width
* is clamped (pure `clampSideWidth`) so neither the dragged pane nor the editor
* falls below its minimum. Keyboard accessible: focus and use ←/→ to nudge.
*
* The handle reads the panes-row width from its own parent at interaction time,
* so it needs no layout props — it just controls the `side` it is told to.
*/
import { useRef } from 'react';
import { clampSideWidth, usePanesStore, type PaneSide } from '../stores/PanesStore';
import styles from './ResizeHandle.module.css';
/** Keyboard nudge step (px) per arrow press. */
const KEY_STEP = 16;
interface ResizeHandleProps {
/** Which side pane this handle resizes. */
side: PaneSide;
/** Accessible label, e.g. "Resize snippet library". */
label: string;
}
export function ResizeHandle({ side, label }: ResizeHandleProps) {
const ref = useRef<HTMLDivElement>(null);
/** Full panes-row width — the handle's parent (`.panes`). */
const containerWidth = (): number => ref.current?.parentElement?.clientWidth ?? 0;
/** Apply a desired width for this side, clamped against the current layout. */
const applyWidth = (desired: number) => {
const { libraryWidth, previewWidth, setWidth } = usePanesStore.getState();
const other = side === 'library' ? previewWidth : libraryWidth;
setWidth(side, clampSideWidth(side, desired, containerWidth(), other));
};
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return; // primary button only
e.preventDefault();
const startX = e.clientX;
const startWidth =
side === 'library'
? usePanesStore.getState().libraryWidth
: usePanesStore.getState().previewWidth;
const onMove = (ev: PointerEvent) => {
const delta = ev.clientX - startX;
// The left handle grows its pane as it moves right; the right handle (left
// of the preview) shrinks the preview as it moves right.
const desired = side === 'library' ? startWidth + delta : startWidth - delta;
applyWidth(desired);
};
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);
// While dragging, force the resize cursor and suppress text selection.
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
};
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
const dir = e.key === 'ArrowRight' ? 1 : -1;
const current =
side === 'library'
? usePanesStore.getState().libraryWidth
: usePanesStore.getState().previewWidth;
const delta = side === 'library' ? dir * KEY_STEP : -dir * KEY_STEP;
applyWidth(current + delta);
};
return (
// TODO: a focusable window-splitter should also expose aria-valuenow/min/max
// (the side pane's current/min/max width) so assistive tech can announce the
// size as it changes. Wire it when the pane toggle strip lands in M6.
<div
ref={ref}
className={styles.handle}
role="separator"
aria-orientation="vertical"
aria-label={label}
tabIndex={0}
onPointerDown={onPointerDown}
onKeyDown={onKeyDown}
>
<span className={styles.grip} aria-hidden="true" />
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { loadPanelLayout, savePanelLayout } from './ux-prefs';
const KEY = 'astrolabe:ux-prefs';
/** In-memory localStorage stub (Node's global one is non-functional). */
function makeStorageStub() {
const map = new Map<string, string>();
return {
getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
setItem: (k: string, v: string) => void map.set(k, String(v)),
removeItem: (k: string) => void map.delete(k),
clear: () => map.clear(),
key: (i: number) => [...map.keys()][i] ?? null,
get length() {
return map.size;
},
};
}
describe('ux-prefs · panelLayout', () => {
beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub()));
afterEach(() => vi.unstubAllGlobals());
it('returns empty widths when nothing is stored', () => {
expect(loadPanelLayout()).toEqual({ libraryWidth: undefined, previewWidth: undefined });
});
it('round-trips stored widths', () => {
savePanelLayout({ libraryWidth: 300, previewWidth: 420 });
expect(loadPanelLayout()).toEqual({ libraryWidth: 300, previewWidth: 420 });
});
it('merges partial writes without dropping the other width', () => {
savePanelLayout({ libraryWidth: 300, previewWidth: 420 });
savePanelLayout({ libraryWidth: 250 });
expect(loadPanelLayout()).toEqual({ libraryWidth: 250, previewWidth: 420 });
});
it('rejects non-positive / non-finite junk', () => {
localStorage.setItem(
KEY,
JSON.stringify({ panelLayout: { libraryWidth: -5, previewWidth: 'wide' } }),
);
expect(loadPanelLayout()).toEqual({ libraryWidth: undefined, previewWidth: undefined });
});
it('tolerates malformed JSON', () => {
localStorage.setItem(KEY, '{ not json');
expect(loadPanelLayout()).toEqual({ libraryWidth: undefined, previewWidth: undefined });
});
it('preserves unrelated keys in the record (forward-compatible merge)', () => {
localStorage.setItem(KEY, JSON.stringify({ sort: { sortBy: 'name', sortOrder: 'asc' } }));
savePanelLayout({ libraryWidth: 260 });
const stored = JSON.parse(localStorage.getItem(KEY)!) as {
sort: { sortBy: string };
panelLayout: { libraryWidth: number };
};
expect(stored.sort.sortBy).toBe('name');
expect(stored.panelLayout.libraryWidth).toBe(260);
});
});
+78
View File
@@ -0,0 +1,78 @@
/**
* UX preferences persistence (localStorage) — docs/architecture/02 §5, spec §09D.
*
* These preferences persist **separately** from UserSettings so they can change
* frequently (a drag emits many width updates) without rewriting the settings
* record. Its own key, `astrolabe:ux-prefs`, holds the snippet sort preference
* (lands with M5/M6) and the panel layout (per-pane widths + visibility).
*
* This slice persists the panel **widths** only; visibility joins it when the
* toggle strip lands. Read-with-fallback + write-through merge, the same
* contract as the settings adapter, so adding fields later upgrades cleanly.
*
* Per the architecture rule this is one of the only modules that may touch
* `localStorage`; everything else goes through these typed functions.
*/
const KEY = 'astrolabe:ux-prefs';
/** Per-pane widths (px). Optional — a missing field falls back to its default. */
export interface PanelLayout {
libraryWidth?: number;
previewWidth?: number;
}
interface StoredPrefs {
panelLayout?: PanelLayout;
[k: string]: unknown;
}
function available(): boolean {
try {
return typeof localStorage !== 'undefined' && typeof localStorage.getItem === 'function';
} catch {
return false;
}
}
function readRaw(): StoredPrefs {
if (!available()) return {};
try {
const raw = localStorage.getItem(KEY);
if (!raw) return {};
const parsed: unknown = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? (parsed as StoredPrefs) : {};
} catch (err) {
console.warn('[ux-prefs] failed to read, using defaults', err);
return {};
}
}
function writeRaw(next: StoredPrefs): void {
if (!available()) return;
try {
localStorage.setItem(KEY, JSON.stringify(next));
} catch (err) {
console.warn('[ux-prefs] failed to write', err);
}
}
/** A finite positive number, or undefined — guards against junk in storage. */
function posNumber(v: unknown): number | undefined {
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
}
/** The persisted panel layout, with only valid numeric widths surfaced. */
export function loadPanelLayout(): PanelLayout {
const stored = readRaw().panelLayout ?? {};
return {
libraryWidth: posNumber(stored.libraryWidth),
previewWidth: posNumber(stored.previewWidth),
};
}
/** Persist the panel layout, preserving every other key already in the record. */
export function savePanelLayout(layout: PanelLayout): void {
const current = readRaw();
writeRaw({ ...current, panelLayout: { ...current.panelLayout, ...layout } });
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Pane-layout orchestration — bridges the (browser-free) PanesStore to the
* ux-prefs adapter. Same store↔adapter pattern as theme/preferences.
*
* `initPanes` hydrates persisted widths into the store before render. `wirePanes`
* persists changes, **debounced**, because a drag emits a width update on every
* pointer move — writing localStorage on each would thrash. The debounce settles
* the write to the final resting widths (the same approach as snippet auto-save).
*/
import { loadPanelLayout, savePanelLayout } from '../infrastructure/ux-prefs';
import { usePanesStore } from '../stores/PanesStore';
/** Delay before a settled resize is persisted. */
export const PANES_PERSIST_DEBOUNCE_MS = 300;
/** Hydrate persisted pane widths into the store. Call before render. */
export function initPanes(): void {
usePanesStore.getState().hydrate(loadPanelLayout());
}
/** Persist width changes (debounced). Returns a teardown that detaches the subscriber. */
export function wirePanes(): () => void {
let timer: ReturnType<typeof setTimeout> | undefined;
return usePanesStore.subscribe((state, prev) => {
if (state.libraryWidth === prev.libraryWidth && state.previewWidth === prev.previewWidth) {
return;
}
clearTimeout(timer);
timer = setTimeout(() => {
const { libraryWidth, previewWidth } = usePanesStore.getState();
savePanelLayout({ libraryWidth, previewWidth });
}, PANES_PERSIST_DEBOUNCE_MS);
});
}
+55
View File
@@ -0,0 +1,55 @@
import { beforeEach, describe, expect, test } from 'vitest';
import { clampSideWidth, HANDLES_TOTAL, PANE_DEFAULT, PANE_MIN, usePanesStore } from './PanesStore';
const store = () => usePanesStore.getState();
describe('clampSideWidth', () => {
// A roomy container where nothing is constrained.
const W = 1400;
test('passes a comfortable width through unchanged', () => {
expect(clampSideWidth('library', 300, W, PANE_DEFAULT.preview)).toBe(300);
expect(clampSideWidth('preview', 400, W, PANE_DEFAULT.library)).toBe(400);
});
test('never goes below the pane minimum', () => {
expect(clampSideWidth('library', 50, W, PANE_DEFAULT.preview)).toBe(PANE_MIN.library);
expect(clampSideWidth('preview', 10, W, PANE_DEFAULT.library)).toBe(PANE_MIN.preview);
});
test('caps the width so the editor keeps at least its minimum', () => {
const other = PANE_DEFAULT.preview;
const max = W - other - HANDLES_TOTAL - PANE_MIN.editor;
// Asking for far more than the editor can spare clamps to that maximum.
expect(clampSideWidth('library', W, W, other)).toBe(max);
// The editor sits exactly at its minimum at that point.
expect(W - max - other - HANDLES_TOTAL).toBe(PANE_MIN.editor);
});
test('a container too narrow for all minimums still never drops below the min', () => {
// 500px total can't fit library(180)+preview(240)+editor(320)+handles.
expect(clampSideWidth('library', 300, 500, PANE_MIN.preview)).toBe(PANE_MIN.library);
});
});
describe('usePanesStore', () => {
beforeEach(() =>
store().hydrate({ libraryWidth: PANE_DEFAULT.library, previewWidth: PANE_DEFAULT.preview }),
);
test('setWidth updates the targeted side only', () => {
store().setWidth('library', 320);
expect(store().libraryWidth).toBe(320);
expect(store().previewWidth).toBe(PANE_DEFAULT.preview);
store().setWidth('preview', 420);
expect(store().previewWidth).toBe(420);
expect(store().libraryWidth).toBe(320);
});
test('hydrate fills missing values from defaults', () => {
store().hydrate({ libraryWidth: 250 });
expect(store().libraryWidth).toBe(250);
expect(store().previewWidth).toBe(PANE_DEFAULT.preview);
});
});
+74
View File
@@ -0,0 +1,74 @@
/**
* Pane layout state — the resizable three-pane shell (spec §01A, persisted per
* §09D "Panel layout").
*
* Model: the two **side** panes (library, preview) carry explicit remembered
* widths; the **center** editor flexes to fill the remainder. This is what makes
* a drag "leave the rest of the layout unaffected" (§01A): dragging the left
* handle trades width between library and editor, the right handle between
* preview and editor — the opposite side pane never moves.
*
* Per-pane visibility / the toggle strip (the other half of §01A) is not here
* yet; this slice is resize + persistence, pulled forward from M6 because it is
* coupled to the Live Preview's container sizing.
*
* Pure clamp helpers live alongside the store so the resize math is unit-tested
* without a DOM. Persistence is a startup subscriber (orchestration/panes), not
* done here — the store stays browser-free.
*/
import { create } from 'zustand';
/** Which side pane a drag handle controls. */
export type PaneSide = 'library' | 'preview';
/** Minimum widths (px) enforced while resizing, so no pane collapses (§01A). */
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;
/**
* Clamp a desired side-pane width so it stays at least its own minimum and
* leaves the editor at least its minimum. Pure — the single place the resize
* constraint lives. `containerWidth` is the full panes-row width; `otherWidth`
* is the opposite side pane's current width.
*/
export function clampSideWidth(
side: PaneSide,
desired: number,
containerWidth: number,
otherWidth: number,
): number {
const min = PANE_MIN[side];
// The widest this pane can be while the editor still meets its minimum.
const max = containerWidth - otherWidth - HANDLES_TOTAL - PANE_MIN.editor;
// If the container is too narrow for everyone, the min wins (never below it).
return Math.max(min, Math.min(desired, Math.max(min, max)));
}
export interface PanesState {
libraryWidth: number;
previewWidth: number;
/** Set a side pane's width (already clamped by the caller). */
setWidth: (side: PaneSide, width: number) => void;
/** Restore persisted widths on startup; missing values keep their defaults. */
hydrate: (layout: { libraryWidth?: number; previewWidth?: number }) => void;
}
export const usePanesStore = create<PanesState>((set) => ({
libraryWidth: PANE_DEFAULT.library,
previewWidth: PANE_DEFAULT.preview,
setWidth: (side, width) =>
set(side === 'library' ? { libraryWidth: width } : { previewWidth: width }),
hydrate: (layout) =>
set({
libraryWidth: layout.libraryWidth ?? PANE_DEFAULT.library,
previewWidth: layout.previewWidth ?? PANE_DEFAULT.preview,
}),
}));
+5 -2
View File
@@ -1,5 +1,6 @@
import { createRoot } from 'react-dom/client';
import { App } from './app/App';
import { initPanes, wirePanes } from './app/orchestration/panes';
import { initPreviewFitMode, wirePreviewFitMode } from './app/orchestration/preferences';
import { initApp } from './app/orchestration/startup';
import { initTheme, wireTheme } from './app/orchestration/theme';
@@ -11,10 +12,12 @@ import '../styles/base.css';
initTheme();
wireTheme();
// Hydrate + persist the preview fit mode the same way (pulled ahead of the M5
// Settings modal); hydrating before render keeps the store authoritative.
// Hydrate + persist the small UI preferences (preview fit mode, pane widths) the
// same way. Pane widths hydrate before render so the layout opens as left.
initPreviewFitMode();
wirePreviewFitMode();
initPanes();
wirePanes();
// Load the library from IndexedDB (seeding a sample on first run) and wire
// persistence. Fire-and-forget: the UI renders immediately and fills in when