diff --git a/docs/architecture/10-interaction-and-feedback.md b/docs/architecture/10-interaction-and-feedback.md index 504c63a..1400a7e 100644 --- a/docs/architecture/10-interaction-and-feedback.md +++ b/docs/architecture/10-interaction-and-feedback.md @@ -162,13 +162,41 @@ lives in [04 · Routing & Global Events](04-routing-and-events.md). is unit-tested, not trapped in the component); the live value needs the container width, observed via `ResizeObserver` so it tracks window resizes, not just drags. - `aria-controls` points at the pane it sizes (`pane-library` / `pane-preview`). -- **Keyboard**: ←/→ nudge; **Home** → smallest pane size, **End** → largest. **Enter-to-collapse - is deferred to M6** with the pane-visibility / toggle strip — collapsing needs a - hidden-pane state that doesn't exist yet, so we don't fake it. +- **Keyboard**: ←/→ nudge; **Home** → smallest pane size, **End** → largest. **Enter-to-collapse** + now has a home — the hidden-pane state landed in M6 with the toggle strip (below). The strip + owns show/hide; wiring the splitter's Enter to it is an optional convenience, not required. _(Consulted via `/council` → WAI-ARIA APG `windowsplitter`. This bullet is the contract; cite it, not the APG file.)_ +**Resolved — pane toggle strip.** The persistent show/hide strip (spec §01A) is a **WAI-ARIA +APG `toolbar`** (`role="toolbar"`, `aria-orientation="vertical"`, an `aria-label` such as +"Workspace panes") — **not** a row of independently-tabbable buttons. Grouping into a toolbar +gives the cluster a **single tab stop** with a **roving tabindex**, which APG names as the way +to reduce tab stops for a control group. Vertical keyboard model: **Up/Down** move among +controls, **Home/End** jump to first/last, **Tab/Shift+Tab** move into/out and restore the +last-focused control on re-entry. + +- The three pane controls are **toggle buttons** — `aria-pressed` with a **stable** accessible + name that does **not** change with state (`aria-pressed="true"` ⇔ pane visible; the name stays + "Library pane" / "Editor pane" / "Preview pane"; only the icon may swap). This matches the + `ThemeToggle` precedent and APG's toggle-button rule — _"it is critical the label on a toggle + does not change when its state changes."_ These are **independent booleans**, so toggle + buttons — never a radio/segmented group; reserve `role="switch"` for genuine single-setting + on/off. +- The **Datasets** control is a plain **command button** (no `aria-pressed`) in the _same_ + toolbar — APG permits mixed control types — set off from the toggles by a visual divider (and + optionally a nested `role="group"`), but kept in the roving sequence as its last element. +- **Focus**: show/hide is only ever initiated **from the strip**, so the activating toggle + already holds focus when its pane disappears and **retains it** (the button stays, flips to + not-pressed) — no orphaned focus, no restoration logic. The strip is **never itself hidden**, + so even with **all panes hidden** it stays the always-reachable "emergency exit" (NN/g #3 user + control). The pane appearing/disappearing plus the `aria-pressed` flip is the status feedback + (NN/g #1 visibility of system status). + +_(Consulted via `/council` → WAI-ARIA APG `toolbar` + `button` (toggle); NN/g #1/#3. This +bullet is the contract; cite it, not the APG files.)_ + **Resolved — segmented (single-select) controls.** A "pick one of N" control (fit modes, the Draft/Published view) is a **radio group**, never a row of `aria-pressed` toggles (those model N independent booleans). Use the shared `SegmentedControl`: `role="radiogroup"` diff --git a/src/app/App.tsx b/src/app/App.tsx index 86ae313..0ba5cf8 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'; import { ConfirmDialog } from './components/ConfirmDialog'; import { LivePreview } from './components/LivePreview'; import { ModalShell } from './components/ModalShell'; +import { PaneToggleStrip } from './components/PaneToggleStrip'; import { ResizeHandle } from './components/ResizeHandle'; import { SnippetLibrary } from './components/SnippetLibrary'; import { SpecEditor } from './components/SpecEditor'; @@ -19,13 +20,23 @@ import styles from './App.module.css'; * (library · editor · preview) under a fixed header. * * 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). + * are resized via the drag handles between them (spec §01A). Each pane can be + * shown/hidden from the always-present toggle strip (the leftmost rail); a hidden + * pane frees its space and the rest redistribute — when the editor is hidden the + * two side panes flex proportionally to their remembered widths. */ export function App() { const libraryWidth = usePanesStore((s) => s.libraryWidth); const previewWidth = usePanesStore((s) => s.previewWidth); + const libraryVisible = usePanesStore((s) => s.libraryVisible); + const editorVisible = usePanesStore((s) => s.editorVisible); + const previewVisible = usePanesStore((s) => s.previewVisible); + + // Side-pane sizing: fixed remembered width while the editor (the flex filler) is + // present; when it's hidden, the side panes grow proportionally to those widths + // so they fill the freed space (spec §01A → "redistributing proportionally"). + const sideStyle = (width: number): React.CSSProperties => + editorVisible ? { width } : { flex: `${width} 1 0` }; // Hidden file input driving Import — the header button proxies its click so the // browser file picker is the only chrome (spec §08 → no intermediate dialog). const fileInputRef = useRef(null); @@ -112,27 +123,40 @@ export function App() { {/* tabIndex -1 makes the landmark a focus target for the skip link. */}
-
- -
- -
- -
- -
- -
+ {/* Always-present rail: shows/hides panes and shortcuts to Datasets (§01A). */} + + {libraryVisible && ( +
+ +
+ )} + {/* A resize handle only sits between two visible panes that flank the editor. */} + {libraryVisible && editorVisible && ( + + )} + {editorVisible && ( +
+ +
+ )} + {editorVisible && previewVisible && ( + + )} + {previewVisible && ( +
+ +
+ )}
{/* The one feature modal (Datasets / Extract / …), rendered from the diff --git a/src/app/components/Icon.tsx b/src/app/components/Icon.tsx index 6632c83..b438be1 100644 --- a/src/app/components/Icon.tsx +++ b/src/app/components/Icon.tsx @@ -28,6 +28,11 @@ export type IconName = | 'delete' // delete — Carbon TrashCan | 'add' // add / create-new — Carbon Add | 'settings' // per-pane settings disclosure (gear) — Carbon Settings + // Pane-toggle sub-family (spec §01A): a panel frame with one region filled, so the + // glyph shows *which* pane it controls by position (left / centre / right). + | 'pane-library' // toggle the library pane (left) + | 'pane-editor' // toggle the editor pane (centre) + | 'pane-preview' // toggle the preview pane (right) // Status sub-family (arch 09 §5.2) — Carbon's FILLED notification glyphs, coloured // by status (not text). A redundant non-colour severity channel (WCAG 1.4.1): the // triangle shape-codes warning apart from the round error/success/info. @@ -70,6 +75,35 @@ const GLYPHS: Record = { ), + // Panel frame (x4–28 / y6–26, 2px border) with one third filled. The filled bar's + // position maps to the pane: left = library, centre = editor, right = preview. + 'pane-library': ( + <> + + + + + + + ), + 'pane-editor': ( + <> + + + + + + + ), + 'pane-preview': ( + <> + + + + + + + ), moon: ( ), diff --git a/src/app/components/PaneToggleStrip.module.css b/src/app/components/PaneToggleStrip.module.css new file mode 100644 index 0000000..c285777 --- /dev/null +++ b/src/app/components/PaneToggleStrip.module.css @@ -0,0 +1,57 @@ +/* + * The pane toggle strip — a slim vertical rail at the left edge of the work area + * (spec §01A). Always present (never hidden), so it's the way back when panes are + * hidden. Visual look only; semantics live in PaneToggleStrip.tsx. + */ +.strip { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-2); + flex: 0 0 auto; + padding: var(--space-3) var(--space-2); + border-right: var(--border-width) solid var(--border); + background: var(--layer-01); +} + +.button { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: var(--border-width) solid transparent; + border-radius: var(--radius); + background: transparent; + /* Not-pressed (hidden pane) reads as muted; pressed (shown) lifts to full text. */ + color: var(--text-secondary); + cursor: pointer; + transition: + background var(--dur-fast) var(--ease), + color var(--dur-fast) var(--ease); +} + +.button:hover { + background: var(--layer-02); + color: var(--text); +} + +.button:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + +/* Shown pane: the toggle is "on" — full-strength glyph on a filled chip. */ +.pressed { + color: var(--text); + background: var(--layer-02); + border-color: var(--border-strong); +} + +.divider { + width: 20px; + height: var(--border-width); + margin: var(--space-1) 0; + background: var(--border); +} diff --git a/src/app/components/PaneToggleStrip.test.tsx b/src/app/components/PaneToggleStrip.test.tsx new file mode 100644 index 0000000..d628e9d --- /dev/null +++ b/src/app/components/PaneToggleStrip.test.tsx @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { usePanesStore } from '../stores/PanesStore'; +import { PaneToggleStrip } from './PaneToggleStrip'; + +vi.mock('../modals/ModalCoordinator', () => ({ openModal: vi.fn() })); +import { openModal } from '../modals/ModalCoordinator'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLDivElement; +let root: Root; + +const toggleButtons = () => + Array.from(container.querySelectorAll('button[aria-pressed]')); +const datasetsButton = () => + container.querySelector('button[aria-label="Datasets"]')!; + +beforeEach(() => { + usePanesStore.getState().hydrate({}, {}); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + act(() => root.render()); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); +}); + +describe('PaneToggleStrip', () => { + test('renders an APG toolbar: three pane toggles + a Datasets command button', () => { + expect(container.querySelector('[role="toolbar"][aria-orientation="vertical"]')).not.toBeNull(); + expect(toggleButtons()).toHaveLength(3); + // The Datasets control is a command button, not a toggle (no aria-pressed). + expect(datasetsButton().hasAttribute('aria-pressed')).toBe(false); + }); + + test('aria-pressed mirrors pane visibility and flips on click', () => { + const [library] = toggleButtons(); + expect(library.getAttribute('aria-pressed')).toBe('true'); + + act(() => library.click()); + + expect(usePanesStore.getState().libraryVisible).toBe(false); + expect(library.getAttribute('aria-pressed')).toBe('false'); + }); + + test('the accessible name is stable across the pressed/not-pressed flip (APG)', () => { + const [library] = toggleButtons(); + expect(library.getAttribute('aria-label')).toBe('Library pane'); + act(() => library.click()); + expect(library.getAttribute('aria-label')).toBe('Library pane'); + }); + + test('roving tabindex: exactly one control is in the tab order at a time', () => { + const all = [...toggleButtons(), datasetsButton()]; + expect(all.filter((b) => b.tabIndex === 0)).toHaveLength(1); + expect(all[0].tabIndex).toBe(0); // first control by default + }); + + test('ArrowDown moves the roving tab stop to the next control', () => { + const all = [...toggleButtons(), datasetsButton()]; + act(() => { + all[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + }); + expect(all[0].tabIndex).toBe(-1); + expect(all[1].tabIndex).toBe(0); + }); + + test('End jumps to the last control (Datasets); Home returns to the first', () => { + const all = [...toggleButtons(), datasetsButton()]; + act(() => { + all[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true })); + }); + expect(datasetsButton().tabIndex).toBe(0); + + act(() => { + datasetsButton().dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true })); + }); + expect(all[0].tabIndex).toBe(0); + }); + + test('ArrowUp from the first control wraps to the last', () => { + const all = [...toggleButtons(), datasetsButton()]; + act(() => { + all[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + }); + expect(datasetsButton().tabIndex).toBe(0); + }); + + test('the Datasets button opens the Datasets manager', () => { + act(() => datasetsButton().click()); + expect(openModal).toHaveBeenCalledWith('datasets'); + }); +}); diff --git a/src/app/components/PaneToggleStrip.tsx b/src/app/components/PaneToggleStrip.tsx new file mode 100644 index 0000000..343fd98 --- /dev/null +++ b/src/app/components/PaneToggleStrip.tsx @@ -0,0 +1,149 @@ +/** + * PaneToggleStrip — the persistent show/hide strip (spec §01A). + * + * Per the interaction contract (docs/architecture/10 → "Resolved — pane toggle + * strip", consulted via /council → WAI-ARIA APG `toolbar` + `button`): + * + * - It is an APG **toolbar** (`role="toolbar"`, vertical) — a **single tab stop** + * with a **roving tabindex**, so the whole strip is one stop in the tab order + * and Up/Down (Left/Right duplicate) move among its controls; Home/End jump to + * the ends. Re-entry restores the last-focused control (the `focusIndex` state). + * - The three pane controls are **toggle buttons** (`aria-pressed`) with a + * **stable** accessible name ("Library pane" …) that never changes with state; + * `aria-pressed=true` ⇔ pane visible. The positional glyph shows which pane. + * - The **Datasets** control is a plain command button (no `aria-pressed`) — APG + * permits mixed control types in one toolbar — set off by a divider, kept last + * in the roving order. + * - Focus is never orphaned: hiding is only ever initiated *from here*, so the + * activating toggle keeps focus (it stays mounted, flips to not-pressed). The + * strip is never itself hidden, so it stays reachable even with all panes hidden. + */ + +import { useRef, useState } from 'react'; +import { openModal } from '../modals/ModalCoordinator'; +import { usePanesStore, type PaneName } from '../stores/PanesStore'; +import { Icon, type IconName } from './Icon'; +import styles from './PaneToggleStrip.module.css'; + +interface PaneItem { + pane: PaneName; + icon: IconName; + /** Stable accessible name (APG: never changes with the pressed state). */ + label: string; + /** The id of the pane section this toggle controls. */ + controls: string; +} + +const PANES: readonly PaneItem[] = [ + { pane: 'library', icon: 'pane-library', label: 'Library pane', controls: 'pane-library' }, + { pane: 'editor', icon: 'pane-editor', label: 'Editor pane', controls: 'pane-editor' }, + { pane: 'preview', icon: 'pane-preview', label: 'Preview pane', controls: 'pane-preview' }, +]; + +export function PaneToggleStrip() { + const libraryVisible = usePanesStore((s) => s.libraryVisible); + const editorVisible = usePanesStore((s) => s.editorVisible); + const previewVisible = usePanesStore((s) => s.previewVisible); + const togglePane = usePanesStore((s) => s.togglePane); + const visible: Record = { + library: libraryVisible, + editor: editorVisible, + preview: previewVisible, + }; + + // Roving tabindex over the toggles + the trailing Datasets action (the divider + // is not a control). One tab stop; the focused index is the only `tabIndex=0`. + const count = PANES.length + 1; + const datasetsIndex = PANES.length; + const refs = useRef>([]); + const [focusIndex, setFocusIndex] = useState(0); + + const focusAt = (i: number) => { + const idx = ((i % count) + count) % count; + setFocusIndex(idx); + refs.current[idx]?.focus(); + }; + + const onKeyDown = (e: React.KeyboardEvent, index: number) => { + switch (e.key) { + case 'ArrowDown': + case 'ArrowRight': + focusAt(index + 1); + break; + case 'ArrowUp': + case 'ArrowLeft': + focusAt(index - 1); + break; + case 'Home': + focusAt(0); + break; + case 'End': + focusAt(count - 1); + break; + default: + return; // not ours — let it bubble + } + e.preventDefault(); + }; + + return ( +
+ {PANES.map((item, i) => ( + + ))} + + + ); +} diff --git a/src/app/infrastructure/ux-prefs.test.ts b/src/app/infrastructure/ux-prefs.test.ts index 9cb0613..e6a5106 100644 --- a/src/app/infrastructure/ux-prefs.test.ts +++ b/src/app/infrastructure/ux-prefs.test.ts @@ -1,5 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { loadPanelLayout, savePanelLayout } from './ux-prefs'; +import { + loadPanelLayout, + loadPaneVisibility, + savePanelLayout, + savePaneVisibility, +} from './ux-prefs'; const KEY = 'astrolabe:ux-prefs'; @@ -61,3 +66,35 @@ describe('ux-prefs · panelLayout', () => { expect(stored.panelLayout.libraryWidth).toBe(260); }); }); + +describe('ux-prefs · paneVisibility', () => { + beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub())); + + it('round-trips persisted visibility; absent flags stay undefined', () => { + savePaneVisibility({ library: false, editor: true }); + expect(loadPaneVisibility()).toEqual({ library: false, editor: true, preview: undefined }); + }); + + it('surfaces only booleans — junk in storage falls back to undefined', () => { + localStorage.setItem( + KEY, + JSON.stringify({ paneVisibility: { library: 'nope', preview: false } }), + ); + expect(loadPaneVisibility()).toEqual({ + library: undefined, + editor: undefined, + preview: false, + }); + }); + + it('preserves panelLayout when writing visibility (separate keys)', () => { + savePanelLayout({ libraryWidth: 300 }); + savePaneVisibility({ editor: false }); + const stored = JSON.parse(localStorage.getItem(KEY)!) as { + panelLayout: { libraryWidth: number }; + paneVisibility: { editor: boolean }; + }; + expect(stored.panelLayout.libraryWidth).toBe(300); + expect(stored.paneVisibility.editor).toBe(false); + }); +}); diff --git a/src/app/infrastructure/ux-prefs.ts b/src/app/infrastructure/ux-prefs.ts index afa5be9..a1ba5e5 100644 --- a/src/app/infrastructure/ux-prefs.ts +++ b/src/app/infrastructure/ux-prefs.ts @@ -6,9 +6,10 @@ * 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. + * This slice persists the panel **widths** and per-pane **visibility** (spec §01A). + * Read-with-fallback + write-through merge, the same contract as the settings + * adapter, so adding fields later upgrades cleanly. Widths and visibility are kept + * as separate keys: a hidden pane retains its remembered width. * * Per the architecture rule this is one of the only modules that may touch * `localStorage`; everything else goes through these typed functions. @@ -22,8 +23,16 @@ export interface PanelLayout { previewWidth?: number; } +/** Per-pane visibility. Optional — a missing field falls back to "shown". */ +export interface PaneVisibilityPref { + library?: boolean; + editor?: boolean; + preview?: boolean; +} + interface StoredPrefs { panelLayout?: PanelLayout; + paneVisibility?: PaneVisibilityPref; [k: string]: unknown; } @@ -76,3 +85,24 @@ export function savePanelLayout(layout: PanelLayout): void { const current = readRaw(); writeRaw({ ...current, panelLayout: { ...current.panelLayout, ...layout } }); } + +/** A boolean, or undefined — guards against junk in storage. */ +function bool(v: unknown): boolean | undefined { + return typeof v === 'boolean' ? v : undefined; +} + +/** The persisted per-pane visibility, with only valid booleans surfaced. */ +export function loadPaneVisibility(): PaneVisibilityPref { + const stored = readRaw().paneVisibility ?? {}; + return { + library: bool(stored.library), + editor: bool(stored.editor), + preview: bool(stored.preview), + }; +} + +/** Persist per-pane visibility, preserving every other key already in the record. */ +export function savePaneVisibility(visibility: PaneVisibilityPref): void { + const current = readRaw(); + writeRaw({ ...current, paneVisibility: { ...current.paneVisibility, ...visibility } }); +} diff --git a/src/app/orchestration/panes.ts b/src/app/orchestration/panes.ts index 08771ab..a7ed58b 100644 --- a/src/app/orchestration/panes.ts +++ b/src/app/orchestration/panes.ts @@ -8,28 +8,47 @@ * the write to the final resting widths (the same approach as snippet auto-save). */ -import { loadPanelLayout, savePanelLayout } from '../infrastructure/ux-prefs'; +import { + loadPanelLayout, + loadPaneVisibility, + savePanelLayout, + savePaneVisibility, +} 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. */ +/** Hydrate persisted pane widths + visibility into the store. Call before render. */ export function initPanes(): void { - usePanesStore.getState().hydrate(loadPanelLayout()); + usePanesStore.getState().hydrate(loadPanelLayout(), loadPaneVisibility()); } -/** Persist width changes (debounced). Returns a teardown that detaches the subscriber. */ +/** + * Persist layout changes. Returns a teardown that detaches the subscriber. + * Widths are **debounced** (a drag emits an update per pointer move); visibility + * is a discrete click, so it's written **immediately**. + */ export function wirePanes(): () => void { let timer: ReturnType | undefined; return usePanesStore.subscribe((state, prev) => { - if (state.libraryWidth === prev.libraryWidth && state.previewWidth === prev.previewWidth) { - return; + if (state.libraryWidth !== prev.libraryWidth || state.previewWidth !== prev.previewWidth) { + clearTimeout(timer); + timer = setTimeout(() => { + const { libraryWidth, previewWidth } = usePanesStore.getState(); + savePanelLayout({ libraryWidth, previewWidth }); + }, PANES_PERSIST_DEBOUNCE_MS); + } + if ( + state.libraryVisible !== prev.libraryVisible || + state.editorVisible !== prev.editorVisible || + state.previewVisible !== prev.previewVisible + ) { + savePaneVisibility({ + library: state.libraryVisible, + editor: state.editorVisible, + preview: state.previewVisible, + }); } - 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 index 6cf405a..f9f7a51 100644 --- a/src/app/stores/PanesStore.test.ts +++ b/src/app/stores/PanesStore.test.ts @@ -93,3 +93,48 @@ describe('usePanesStore', () => { expect(store().previewWidth).toBe(PANE_DEFAULT.preview); }); }); + +describe('pane visibility (§01A)', () => { + test('all panes are visible by default', () => { + store().hydrate({}, undefined); + expect(store().libraryVisible).toBe(true); + expect(store().editorVisible).toBe(true); + expect(store().previewVisible).toBe(true); + }); + + test('togglePane flips only the targeted pane', () => { + store().hydrate({}, {}); + store().togglePane('editor'); + expect(store().editorVisible).toBe(false); + expect(store().libraryVisible).toBe(true); + expect(store().previewVisible).toBe(true); + + store().togglePane('editor'); + expect(store().editorVisible).toBe(true); + }); + + test('hiding all panes is permitted', () => { + store().hydrate({}, {}); + store().togglePane('library'); + store().togglePane('editor'); + store().togglePane('preview'); + expect(store().libraryVisible).toBe(false); + expect(store().editorVisible).toBe(false); + expect(store().previewVisible).toBe(false); + }); + + test('hydrate restores explicit visibility; missing flags default to shown', () => { + store().hydrate({}, { library: false }); + expect(store().libraryVisible).toBe(false); + expect(store().editorVisible).toBe(true); + expect(store().previewVisible).toBe(true); + }); + + test('a hidden pane keeps its remembered width (re-shows at it)', () => { + store().hydrate({ libraryWidth: 250 }, {}); + store().togglePane('library'); // hide + expect(store().libraryWidth).toBe(250); + store().togglePane('library'); // re-show + expect(store().libraryWidth).toBe(250); + }); +}); diff --git a/src/app/stores/PanesStore.ts b/src/app/stores/PanesStore.ts index a3463ff..4c764fb 100644 --- a/src/app/stores/PanesStore.ts +++ b/src/app/stores/PanesStore.ts @@ -22,6 +22,9 @@ 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; @@ -79,25 +82,56 @@ export function sideWidthValue( 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; - /** Restore persisted widths on startup; missing values keep their defaults. */ - hydrate: (layout: { libraryWidth?: number; previewWidth?: 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 }), - hydrate: (layout) => + 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, }), }));