From c50f141d5779466c5ceffd4fbf9be1b52d767ccb Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Fri, 5 Jun 2026 10:46:07 +0300 Subject: [PATCH] Add resizable panes with drag handles, min widths, and persistence --- docs/architecture/02-persistence.md | 21 +++-- src/app/App.module.css | 20 ++--- src/app/App.tsx | 20 ++++- src/app/components/ResizeHandle.module.css | 35 ++++++++ src/app/components/ResizeHandle.tsx | 99 ++++++++++++++++++++++ src/app/infrastructure/ux-prefs.test.ts | 63 ++++++++++++++ src/app/infrastructure/ux-prefs.ts | 78 +++++++++++++++++ src/app/orchestration/panes.ts | 35 ++++++++ src/app/stores/PanesStore.test.ts | 55 ++++++++++++ src/app/stores/PanesStore.ts | 74 ++++++++++++++++ src/main.tsx | 7 +- 11 files changed, 482 insertions(+), 25 deletions(-) create mode 100644 src/app/components/ResizeHandle.module.css create mode 100644 src/app/components/ResizeHandle.tsx create mode 100644 src/app/infrastructure/ux-prefs.test.ts create mode 100644 src/app/infrastructure/ux-prefs.ts create mode 100644 src/app/orchestration/panes.ts create mode 100644 src/app/stores/PanesStore.test.ts create mode 100644 src/app/stores/PanesStore.ts diff --git a/docs/architecture/02-persistence.md b/docs/architecture/02-persistence.md index 11943a4..c20b26f 100644 --- a/docs/architecture/02-persistence.md +++ b/docs/architecture/02-persistence.md @@ -18,7 +18,7 @@ src/ │ ├── snippet-store.ts # IndexedDB: snippets (metadata + drafts) │ ├── dataset-store.ts # IndexedDB: datasets (heavy payloads) │ ├── 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 @@ -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 -// src/app/infrastructure/prefs-store.ts -const SORT_KEY = 'astrolabe:snippet-sort'; -const LAYOUT_KEY = 'astrolabe:panel-layout'; +// src/app/infrastructure/ux-prefs.ts +const KEY = 'astrolabe:ux-prefs'; // { panelLayout: { libraryWidth, previewWidth }, sort: { … } } -const SORT_DEFAULTS = { sortBy: 'modified' as const, sortOrder: 'desc' as const }; -// loadSort()/saveSort() and loadLayout()/saveLayout() mirror §5's guard+fallback shape. +// loadPanelLayout()/savePanelLayout() (and sort later) 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. > **Don't:** read individual keys with bespoke `?? fallback` at each call site; one stale default and the shapes drift. diff --git a/src/app/App.module.css b/src/app/App.module.css index ccd2207..7c0987e 100644 --- a/src/app/App.module.css +++ b/src/app/App.module.css @@ -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; -} diff --git a/src/app/App.tsx b/src/app/App.tsx index f0b0a42..59a8b4b 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -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 (
@@ -24,13 +30,19 @@ export function App() {
-
+
+
-
+ +
diff --git a/src/app/components/ResizeHandle.module.css b/src/app/components/ResizeHandle.module.css new file mode 100644 index 0000000..3d82cff --- /dev/null +++ b/src/app/components/ResizeHandle.module.css @@ -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); +} diff --git a/src/app/components/ResizeHandle.tsx b/src/app/components/ResizeHandle.tsx new file mode 100644 index 0000000..6f401fd --- /dev/null +++ b/src/app/components/ResizeHandle.tsx @@ -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(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) => { + 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) => { + 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. +
+
+ ); +} diff --git a/src/app/infrastructure/ux-prefs.test.ts b/src/app/infrastructure/ux-prefs.test.ts new file mode 100644 index 0000000..9cb0613 --- /dev/null +++ b/src/app/infrastructure/ux-prefs.test.ts @@ -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(); + 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); + }); +}); diff --git a/src/app/infrastructure/ux-prefs.ts b/src/app/infrastructure/ux-prefs.ts new file mode 100644 index 0000000..afa5be9 --- /dev/null +++ b/src/app/infrastructure/ux-prefs.ts @@ -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 } }); +} diff --git a/src/app/orchestration/panes.ts b/src/app/orchestration/panes.ts new file mode 100644 index 0000000..08771ab --- /dev/null +++ b/src/app/orchestration/panes.ts @@ -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 | 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); + }); +} diff --git a/src/app/stores/PanesStore.test.ts b/src/app/stores/PanesStore.test.ts new file mode 100644 index 0000000..701080d --- /dev/null +++ b/src/app/stores/PanesStore.test.ts @@ -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); + }); +}); diff --git a/src/app/stores/PanesStore.ts b/src/app/stores/PanesStore.ts new file mode 100644 index 0000000..be4e745 --- /dev/null +++ b/src/app/stores/PanesStore.ts @@ -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((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, + }), +})); diff --git a/src/main.tsx b/src/main.tsx index 948ed44..5136733 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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