/** * 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'; /** Every pane that can be shown/hidden via the toggle strip (spec §01A). */ export type PaneName = 'library' | 'editor' | '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; /** * The widest a side pane can be while the editor still meets its minimum. * `containerWidth` is the full panes-row width; `otherWidth` is the opposite * side pane's current width. Never reports below the pane's own minimum. */ export function maxSideWidth(side: PaneSide, containerWidth: number, otherWidth: number): number { return Math.max(PANE_MIN[side], containerWidth - otherWidth - HANDLES_TOTAL - PANE_MIN.editor); } /** * 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. */ export function clampSideWidth( side: PaneSide, desired: number, containerWidth: number, otherWidth: number, ): number { return Math.max( PANE_MIN[side], Math.min(desired, maxSideWidth(side, containerWidth, otherWidth)), ); } /** * Normalize a side pane's width to the 0–100 position a window splitter reports * via `aria-valuenow` (WAI-ARIA APG → Window Splitter): 0 = pane at its minimum * size, 100 = pane at its maximum. Returns null when the container width isn't * known yet (pre-layout) or the range has collapsed, so the caller omits the * attribute rather than emitting NaN. The 0–100 scale is APG's "typical" choice * and announces as a percentage — more meaningful than a moving pixel count. */ export function sideWidthValue( side: PaneSide, width: number, containerWidth: number, otherWidth: number, ): number | null { if (containerWidth <= 0) return null; const min = PANE_MIN[side]; const max = maxSideWidth(side, containerWidth, otherWidth); if (max <= min) return null; const pct = ((width - min) / (max - min)) * 100; return Math.round(Math.min(100, Math.max(0, pct))); } /** 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 { library?: boolean; editor?: boolean; preview?: boolean; } export interface PanesState { libraryWidth: number; previewWidth: 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; /** Show/hide one pane. Hiding all panes is permitted (§01A) — the strip stays. */ togglePane: (pane: PaneName) => void; /** Restore persisted widths + visibility on startup; missing values keep defaults. */ hydrate: ( layout: { libraryWidth?: number; previewWidth?: number }, visibility?: PaneVisibility, ) => void; } const VISIBLE_KEY = { library: 'libraryVisible', editor: 'editorVisible', preview: 'previewVisible', } as const; export const usePanesStore = create((set) => ({ libraryWidth: PANE_DEFAULT.library, previewWidth: PANE_DEFAULT.preview, libraryVisible: true, editorVisible: true, previewVisible: true, setWidth: (side, width) => set(side === 'library' ? { libraryWidth: width } : { previewWidth: width }), togglePane: (pane) => set((s) => ({ [VISIBLE_KEY[pane]]: !s[VISIBLE_KEY[pane]] })), hydrate: (layout, visibility) => set({ libraryWidth: layout.libraryWidth ?? PANE_DEFAULT.library, previewWidth: layout.previewWidth ?? PANE_DEFAULT.preview, libraryVisible: visibility?.library ?? true, editorVisible: visibility?.editor ?? true, previewVisible: visibility?.preview ?? true, }), }));