mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add resizable panes with drag handles, min widths, and persistence
This commit is contained in:
@@ -18,7 +18,7 @@ src/
|
|||||||
│ ├── snippet-store.ts # IndexedDB: snippets (metadata + drafts)
|
│ ├── snippet-store.ts # IndexedDB: snippets (metadata + drafts)
|
||||||
│ ├── dataset-store.ts # IndexedDB: datasets (heavy payloads)
|
│ ├── dataset-store.ts # IndexedDB: datasets (heavy payloads)
|
||||||
│ ├── settings-store.ts # localStorage: UserSettings
|
│ ├── settings-store.ts # localStorage: UserSettings
|
||||||
│ └── prefs-store.ts # localStorage: app/UI prefs (sort, layout)
|
│ └── ux-prefs.ts # localStorage: app/UI prefs (sort, panel layout)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Why this boundary exists
|
### Why this boundary exists
|
||||||
@@ -317,17 +317,24 @@ export function saveSettings(s: UserSettings): void {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
App/UI prefs follow the identical pattern under their own keys, e.g.:
|
App/UI prefs follow the identical guard+fallback pattern, but live in **one
|
||||||
|
record under their own key**, `astrolabe:ux-prefs` (the snippet sort and the
|
||||||
|
panel layout together — the plan's "ux-prefs for sort + panel layout", §09D):
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// src/app/infrastructure/prefs-store.ts
|
// src/app/infrastructure/ux-prefs.ts
|
||||||
const SORT_KEY = 'astrolabe:snippet-sort';
|
const KEY = 'astrolabe:ux-prefs'; // { panelLayout: { libraryWidth, previewWidth }, sort: { … } }
|
||||||
const LAYOUT_KEY = 'astrolabe:panel-layout';
|
|
||||||
|
|
||||||
const SORT_DEFAULTS = { sortBy: 'modified' as const, sortOrder: 'desc' as const };
|
// loadPanelLayout()/savePanelLayout() (and sort later) mirror §5's guard+fallback shape,
|
||||||
// loadSort()/saveSort() and loadLayout()/saveLayout() mirror §5's guard+fallback shape.
|
// merging the changed section so a frequent write (a drag) never clobbers the others.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> One nuance vs. the settings record: the panel-layout widths are **validated**
|
||||||
|
> (positive finite numbers) and surfaced as `undefined` when absent/invalid; the
|
||||||
|
> **defaults live in the `PanesStore`** (`PANE_DEFAULT`), applied at `hydrate`,
|
||||||
|
> rather than merged in the adapter. Persistence of the live drag is **debounced**
|
||||||
|
> in `orchestration/panes.ts` (a drag emits an update per pointer move).
|
||||||
|
|
||||||
> **Do:** keep a single complete `DEFAULTS` object as the source of truth and merge over it.
|
> **Do:** keep a single complete `DEFAULTS` object as the source of truth and merge over it.
|
||||||
> **Don't:** read individual keys with bespoke `?? fallback` at each call site; one stale default and the shapes drift.
|
> **Don't:** read individual keys with bespoke `?? fallback` at each call site; one stale default and the shapes drift.
|
||||||
|
|
||||||
|
|||||||
+8
-12
@@ -40,28 +40,24 @@
|
|||||||
min-height: 0;
|
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 {
|
.pane {
|
||||||
flex: 1 1 0;
|
flex: 0 0 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border-right: var(--border-width) solid var(--border);
|
|
||||||
background: var(--bg);
|
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. */
|
/* Editor pane: Monaco manages its own scroll/layout, so no padding. */
|
||||||
.paneEditor {
|
.paneEditor {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-right: var(--border-width) solid var(--border);
|
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pane:last-child {
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
|
|||||||
+16
-4
@@ -1,19 +1,25 @@
|
|||||||
import { ConfirmDialog } from './components/ConfirmDialog';
|
import { ConfirmDialog } from './components/ConfirmDialog';
|
||||||
import { LivePreview } from './components/LivePreview';
|
import { LivePreview } from './components/LivePreview';
|
||||||
|
import { ResizeHandle } from './components/ResizeHandle';
|
||||||
import { SnippetLibrary } from './components/SnippetLibrary';
|
import { SnippetLibrary } from './components/SnippetLibrary';
|
||||||
import { SpecEditor } from './components/SpecEditor';
|
import { SpecEditor } from './components/SpecEditor';
|
||||||
import { ThemeToggle } from './components/ThemeToggle';
|
import { ThemeToggle } from './components/ThemeToggle';
|
||||||
|
import { usePanesStore } from './stores/PanesStore';
|
||||||
import styles from './App.module.css';
|
import styles from './App.module.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Application shell — the three-pane workspace from spec §01A
|
* Application shell — the three-pane workspace from spec §01A
|
||||||
* (library · editor · preview) under a fixed header.
|
* (library · editor · preview) under a fixed header.
|
||||||
*
|
*
|
||||||
* M1 fills the panes with the MVP authoring loop. Pane resizing/toggling,
|
* The center editor flexes; the library and preview carry remembered widths and
|
||||||
* modals, routing, and shortcuts arrive in later milestones (see
|
* 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).
|
* docs/IMPLEMENTATION-PLAN.md).
|
||||||
*/
|
*/
|
||||||
export function App() {
|
export function App() {
|
||||||
|
const libraryWidth = usePanesStore((s) => s.libraryWidth);
|
||||||
|
const previewWidth = usePanesStore((s) => s.previewWidth);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.app}>
|
<div className={styles.app}>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
@@ -24,13 +30,19 @@ export function App() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className={styles.panes}>
|
<main className={styles.panes}>
|
||||||
<section className={styles.pane} aria-label="Snippet library">
|
<section
|
||||||
|
className={styles.pane}
|
||||||
|
style={{ width: libraryWidth }}
|
||||||
|
aria-label="Snippet library"
|
||||||
|
>
|
||||||
<SnippetLibrary />
|
<SnippetLibrary />
|
||||||
</section>
|
</section>
|
||||||
|
<ResizeHandle side="library" label="Resize snippet library" />
|
||||||
<section className={styles.paneEditor} aria-label="Spec editor">
|
<section className={styles.paneEditor} aria-label="Spec editor">
|
||||||
<SpecEditor />
|
<SpecEditor />
|
||||||
</section>
|
</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 />
|
<LivePreview />
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 } });
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -1,5 +1,6 @@
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { App } from './app/App';
|
import { App } from './app/App';
|
||||||
|
import { initPanes, wirePanes } from './app/orchestration/panes';
|
||||||
import { initPreviewFitMode, wirePreviewFitMode } from './app/orchestration/preferences';
|
import { initPreviewFitMode, wirePreviewFitMode } from './app/orchestration/preferences';
|
||||||
import { initApp } from './app/orchestration/startup';
|
import { initApp } from './app/orchestration/startup';
|
||||||
import { initTheme, wireTheme } from './app/orchestration/theme';
|
import { initTheme, wireTheme } from './app/orchestration/theme';
|
||||||
@@ -11,10 +12,12 @@ import '../styles/base.css';
|
|||||||
initTheme();
|
initTheme();
|
||||||
wireTheme();
|
wireTheme();
|
||||||
|
|
||||||
// Hydrate + persist the preview fit mode the same way (pulled ahead of the M5
|
// Hydrate + persist the small UI preferences (preview fit mode, pane widths) the
|
||||||
// Settings modal); hydrating before render keeps the store authoritative.
|
// same way. Pane widths hydrate before render so the layout opens as left.
|
||||||
initPreviewFitMode();
|
initPreviewFitMode();
|
||||||
wirePreviewFitMode();
|
wirePreviewFitMode();
|
||||||
|
initPanes();
|
||||||
|
wirePanes();
|
||||||
|
|
||||||
// Load the library from IndexedDB (seeding a sample on first run) and wire
|
// 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
|
// persistence. Fire-and-forget: the UI renders immediately and fills in when
|
||||||
|
|||||||
Reference in New Issue
Block a user