From 800a313be2b35103e56e6a5d32c091d96c7ebb4c Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Sun, 7 Jun 2026 20:00:40 +0300 Subject: [PATCH] =?UTF-8?q?Add=20snippet-library=20search,=20sort,=20empty?= =?UTF-8?q?=20states,=20storage=20monitor=20(M6,=20=C2=A702/=C2=A709D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../10-interaction-and-feedback.md | 39 ++++ docs/spec/02-snippet-library.md | 4 +- src/app/components/Icon.tsx | 5 + src/app/components/SnippetLibrary.module.css | 112 +++++++++- src/app/components/SnippetLibrary.test.tsx | 94 ++++++++- src/app/components/SnippetLibrary.tsx | 100 ++++++++- src/app/components/SortControl.module.css | 96 +++++++++ src/app/components/SortControl.tsx | 166 +++++++++++++++ src/app/components/StorageMonitor.module.css | 86 ++++++++ src/app/components/StorageMonitor.test.tsx | 199 ++++++++++++++++++ src/app/components/StorageMonitor.tsx | 93 ++++++++ src/app/infrastructure/ux-prefs.test.ts | 32 +++ src/app/infrastructure/ux-prefs.ts | 42 +++- src/app/orchestration/snippet-sort.ts | 32 +++ src/app/stores/SnippetStore.test.ts | 52 +++++ src/app/stores/SnippetStore.ts | 48 +++++ src/core/snippet-sort.test.ts | 158 ++++++++++++++ src/core/snippet-sort.ts | 113 ++++++++++ src/main.tsx | 6 + 19 files changed, 1461 insertions(+), 16 deletions(-) create mode 100644 src/app/components/SortControl.module.css create mode 100644 src/app/components/SortControl.tsx create mode 100644 src/app/components/StorageMonitor.module.css create mode 100644 src/app/components/StorageMonitor.test.tsx create mode 100644 src/app/components/StorageMonitor.tsx create mode 100644 src/app/orchestration/snippet-sort.ts create mode 100644 src/core/snippet-sort.test.ts create mode 100644 src/core/snippet-sort.ts diff --git a/docs/architecture/10-interaction-and-feedback.md b/docs/architecture/10-interaction-and-feedback.md index 897a9fc..1ac67ae 100644 --- a/docs/architecture/10-interaction-and-feedback.md +++ b/docs/architecture/10-interaction-and-feedback.md @@ -236,6 +236,45 @@ relative date and size), in a fixed-width leading slot so it never shifts adjace _(Consulted via /council → GOV.UK Tag, Carbon status-indicator-pattern, APG. This bullet is the contract; cite it, not the external source.)_ +**Resolved — library search (Carbon active-search).** The snippet-library search (spec +§02) is an **unlabelled active-search input** pinned above the list: `type="search"` with a +leading magnifier and `aria-label="Search snippets"` (no visible label — the icon + +placeholder name it), filtering the list on **each keystroke** (no Search button, no results +page). A **clear (✕)** appears only when the box is non-empty; it empties the box **and +returns focus to the input** (NN/g #3 user control). Matching is case-insensitive substring +across **name + comment + draft spec text** (the pure `snippetMatchesQuery` in +`core/snippet-sort.ts`). Search affects **visibility only** — it never changes +`activeSnippetId` or any data. A **polite `aria-live` region announces the result count, +including no results** (Carbon: _"always include the number of results, including no +results"_); it stays silent for the default unfiltered list. _(Consulted via /council → +Carbon search/active-search, NN/g #3. This bullet is the contract; cite it, not the source.)_ + +**Resolved — library sort (APG menu-button + NN/g recognition).** The Sort control (spec +§02) reuses the **disclosure popover** primitive (the settings-popover model above), **not** +an ARIA menu — but its trigger shows the **current state for recognition** ("Sort: Modified +↓"), per NN/g #6 (recognition over recall), instead of a bare gear. Fields are **Modified / +Created / Name / Size**; the active field shows a **direction arrow** (↓ desc / ↑ asc) in both +the trigger and the field row, and the arrow's meaning is mirrored into the field's +`aria-label` ("Modified, descending") so it isn't carried by the glyph alone. **Selection +model** (spec §02): re-selecting the **active** field flips direction; selecting a +**different** field switches to it and **resets to descending** — encapsulated in +`SnippetStore.setSort` and tested in core. APG disclosure keyboard/focus is inherited from +the shared popover (Enter/Space open; **Esc closes and returns focus to the trigger**; outside +click closes; one open at a time). Field+direction **persist across sessions** (ux-prefs; +default Modified/desc); search is **not** persisted (it's a transient view narrowing). +_(Consulted via /council → WAI-ARIA APG disclosure + menu-button, NN/g #6. This bullet is the +contract; cite it, not the source.)_ + +**Resolved — the two library empty states (Carbon's two types).** The snippet list owes +**two distinct empty states** (Carbon empty-state types), chosen by cause and never conflated: +(a) a **genuinely empty library** (first run) is **positive and concise and guides to the +Create action** — title "No snippets yet" + a one-line nudge to create one; (b) **no search +matches** says "No snippets match your search" + a hint to try a different term. Both are +plain-language and minimal (NN/g aesthetic-and-minimalist; §3 empty ≠ error). The library +derives **which** to show from whether a query is active, not from the raw count alone. +_(Consulted via /council → Carbon empty-state, NN/g. This bullet is the contract; cite it, +not the source.)_ + **Resolved — one live region per shared message.** When the same error feeds two surfaces (the §1 "one producer, two subscribers" case — render errors via `PreviewStore`), exactly **one** subscriber is the live region (`role="alert"` on the editor, where focus is); the diff --git a/docs/spec/02-snippet-library.md b/docs/spec/02-snippet-library.md index 23da7fe..3fc4097 100644 --- a/docs/spec/02-snippet-library.md +++ b/docs/spec/02-snippet-library.md @@ -10,7 +10,7 @@ The list shows every saved snippet and is always visible. A persistent "Create N - A "Create New Snippet" item is pinned at the top of the list; activating it creates and selects a new snippet (see _Snippet Operations_). - Selecting a snippet makes it the **active snippet**: it loads into the editor and preview, becomes highlighted in the list, and the URL updates to reflect the selected snippet so the state is shareable and survives a page reload (see _Application Shell & Navigation_). - Exactly one snippet is active at a time. -- When no snippets match the current search, the list shows an empty-state message ("No snippets match your search"); when there are genuinely no snippets, it shows "No snippets found". +- When no snippets match the current search, the list shows an empty-state message ("No snippets match your search", with a hint to try a different term); when there are genuinely no snippets, it shows "No snippets yet" with a nudge to create one. - On first run, when no snippets exist, the app seeds one sample bar-chart snippet so the user starts with a working example. ## List Item @@ -78,5 +78,5 @@ A small indicator at the bottom of the library shows how much of the snippet sto - Displays current usage against the total budget (used vs. total), where the practical snippet budget is about 5 MB. - A fill indicator reflects the percentage used. -- The indicator enters escalating warning states as usage climbs (a cautionary state past roughly 70% and a critical state past roughly 90%). +- The indicator enters escalating warning states as usage climbs (a cautionary state past roughly 80% and a critical state past roughly 95%). - When storage is full, a save may fail; the system warns the user that the snippet could not be saved rather than silently losing data, so the user can delete snippets to free space. diff --git a/src/app/components/Icon.tsx b/src/app/components/Icon.tsx index b438be1..0834581 100644 --- a/src/app/components/Icon.tsx +++ b/src/app/components/Icon.tsx @@ -27,6 +27,7 @@ export type IconName = | 'dataset' // "references a dataset" — Carbon DataTable | 'delete' // delete — Carbon TrashCan | 'add' // add / create-new — Carbon Add + | 'search' // live library search — Carbon Search | '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). @@ -50,6 +51,10 @@ const GLYPHS: Record = { ), add: , + // Carbon Search — magnifying glass, the active-search affordance (council SEARCH). + search: ( + + ), settings: ( <> diff --git a/src/app/components/SnippetLibrary.module.css b/src/app/components/SnippetLibrary.module.css index 9c8cc1e..3c43cb2 100644 --- a/src/app/components/SnippetLibrary.module.css +++ b/src/app/components/SnippetLibrary.module.css @@ -4,7 +4,7 @@ height: 100%; } -/* Library toolbar — heading + settings gear (sort/search will join, spec §02). */ +/* Library toolbar — heading + date-format settings gear (spec §02 / §07). */ .toolbar { flex: 0 0 auto; display: flex; @@ -47,6 +47,101 @@ background: var(--accent-hover); } +/* Search + Sort row, pinned above the list (spec §02). */ +.controls { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: var(--space-2); + padding: 0 var(--space-4) var(--space-4); +} + +/* Carbon active-search: leading magnifier, optional trailing clear (council). */ +.search { + flex: 1 1 auto; + min-width: 0; + position: relative; + display: flex; + align-items: center; +} + +.searchIcon { + position: absolute; + left: var(--space-3); + color: var(--text-secondary); + pointer-events: none; +} + +.searchInput { + width: 100%; + height: 32px; + /* Room for the leading magnifier and the trailing clear button. */ + padding: 0 32px 0 32px; + border: var(--border-width) solid var(--border-strong); + border-radius: var(--radius); + background: var(--bg); + color: var(--text); + font: inherit; + font-size: 13px; +} + +.searchInput::placeholder { + color: var(--text-placeholder); +} + +.searchInput:focus-visible { + outline: 2px solid var(--focus); + outline-offset: -1px; + border-color: var(--accent); +} + +/* Hide the browser's native search clear so we control the affordance. */ +.searchInput::-webkit-search-cancel-button { + appearance: none; +} + +.searchClear { + position: absolute; + right: var(--space-2); + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: var(--radius); + background: none; + color: var(--text-secondary); + cursor: pointer; + transition: + background var(--dur-fast) var(--ease), + color var(--dur-fast) var(--ease); +} + +.searchClear:hover { + background: var(--layer-01); + color: var(--text); +} + +.searchClear:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 1px; +} + +/* Visually-hidden live region for the search result count (council SEARCH). */ +.srOnly { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .list { list-style: none; margin: 0; @@ -57,12 +152,27 @@ border-top: var(--border-width) solid var(--border); } +/* Empty states (spec §02; council EMPTY STATES) — a title + a one-line hint, + positive and concise. Two variants share this layout: an empty library and a + search with no matches. */ .empty { + display: flex; + flex-direction: column; + gap: var(--space-1); color: var(--text-secondary); font-size: 13px; padding: var(--space-5) var(--space-4); } +.emptyTitle { + font-weight: 600; + color: var(--text); +} + +.emptyHint { + color: var(--text-secondary); +} + .item { display: flex; align-items: stretch; diff --git a/src/app/components/SnippetLibrary.test.tsx b/src/app/components/SnippetLibrary.test.tsx index 3c01962..3e1f9de 100644 --- a/src/app/components/SnippetLibrary.test.tsx +++ b/src/app/components/SnippetLibrary.test.tsx @@ -5,9 +5,20 @@ import { createSnippet } from '@core/snippet'; import { useSnippetStore } from '../stores/SnippetStore'; import { SnippetLibrary } from './SnippetLibrary'; +// StorageMonitor (rendered at the bottom of the pane) fetches an async storage +// estimate on mount; stub it so its setState doesn't fire outside act() and add +// test noise. This suite is about the library, not the monitor. +vi.mock('../infrastructure/storage-estimate', () => ({ + readStorageEstimate: () => Promise.resolve({ available: false }), +})); + // React 19 wants this flag set for act() to drive effects without warnings. (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +/** The metadata-panel name field — scoped so the new search input doesn't shadow it. */ +const nameInput = () => + document.querySelector('section[aria-label="Snippet details"] input') as HTMLInputElement; + let container: HTMLDivElement; let root: Root; @@ -51,7 +62,7 @@ describe('SnippetLibrary metadata panel (spec §02)', () => { await Promise.resolve(); }); - const name = container.querySelector('input') as HTMLInputElement; + const name = nameInput(); const comment = container.querySelector('textarea') as HTMLTextAreaElement; expect(name.value).toBe('Bar chart'); expect(comment.value).toBe('a note'); @@ -68,7 +79,7 @@ describe('SnippetLibrary metadata panel (spec §02)', () => { root.render(); }); - const name = container.querySelector('input') as HTMLInputElement; + const name = nameInput(); act(() => typeInto(name, 'Renamed')); // Before the debounce fires, the store is unchanged. expect(useSnippetStore.getState().snippets[0].name).toBe('Old'); @@ -79,6 +90,85 @@ describe('SnippetLibrary metadata panel (spec §02)', () => { expect(useSnippetStore.getState().snippets[0].name).toBe('Renamed'); }); + test('does not loop on a search/sort state change (render-loop guard)', async () => { + // A selector that returned a fresh filtered array would re-render forever + // (MEMORY → "Zustand stable selectors"); the component derives via useMemo. + const a = createSnippet({ id: 'a', name: 'Alpha', now: new Date('2026-01-01T00:00:00Z') }); + const b = createSnippet({ id: 'b', name: 'Beta', now: new Date('2026-02-01T00:00:00Z') }); + useSnippetStore.getState().hydrate([a, b], 'a'); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + // Each store change below would throw "Maximum update depth" inside act() if + // a render loop existed. Bounded, finite settling = no loop. + await act(async () => { + useSnippetStore.getState().setSearch('beta'); + await Promise.resolve(); + }); + await act(async () => { + useSnippetStore.getState().setSort('name'); + await Promise.resolve(); + }); + + // The list reflects the final derived view (only Beta matches "beta"). + const names = [...container.querySelectorAll('li')].map((li) => li.textContent ?? ''); + expect(names.some((t) => t.includes('Beta'))).toBe(true); + expect(names.some((t) => t.includes('Alpha'))).toBe(false); + }); + + test('search filters the list and the clear control restores it + refocuses input', () => { + const a = createSnippet({ id: 'a', name: 'Alpha', now: new Date('2026-01-01T00:00:00Z') }); + const b = createSnippet({ id: 'b', name: 'Beta', now: new Date('2026-02-01T00:00:00Z') }); + useSnippetStore.getState().hydrate([a, b], 'a'); + + act(() => { + root.render(); + }); + + const search = container.querySelector('input[type="search"]') as HTMLInputElement; + act(() => typeInto(search, 'alpha')); + expect(useSnippetStore.getState().searchQuery).toBe('alpha'); + + let rows = [...container.querySelectorAll('li')].map((li) => li.textContent ?? ''); + expect(rows.some((t) => t.includes('Alpha'))).toBe(true); + expect(rows.some((t) => t.includes('Beta'))).toBe(false); + + const clear = container.querySelector('button[aria-label="Clear search"]') as HTMLButtonElement; + act(() => clear.click()); + expect(useSnippetStore.getState().searchQuery).toBe(''); + // Clear returns focus to the input (council SEARCH). + expect(document.activeElement).toBe(container.querySelector('input[type="search"]')); + + rows = [...container.querySelectorAll('li')].map((li) => li.textContent ?? ''); + expect(rows.some((t) => t.includes('Beta'))).toBe(true); + }); + + test('shows the no-matches empty state when a search filters everything out', () => { + const a = createSnippet({ id: 'a', name: 'Alpha', now: new Date('2026-01-01T00:00:00Z') }); + useSnippetStore.getState().hydrate([a], 'a'); + + act(() => { + root.render(); + }); + + const search = container.querySelector('input[type="search"]') as HTMLInputElement; + act(() => typeInto(search, 'zzz-no-match')); + expect(container.textContent).toContain('No snippets match your search'); + }); + + test('shows the empty-library state when there are no snippets', () => { + useSnippetStore.getState().hydrate([], null); + + act(() => { + root.render(); + }); + + expect(container.textContent).toContain('No snippets yet'); + }); + test('Duplicate adds an independent copy and makes it active', async () => { const s = createSnippet({ id: 'a', name: 'Chart', now: new Date('2026-01-01T00:00:00Z') }); useSnippetStore.getState().hydrate([s], 'a'); diff --git a/src/app/components/SnippetLibrary.tsx b/src/app/components/SnippetLibrary.tsx index 74b966d..5b6696b 100644 --- a/src/app/components/SnippetLibrary.tsx +++ b/src/app/components/SnippetLibrary.tsx @@ -6,11 +6,11 @@ * draft status indicator, relative date, and size (spec §02). Below the list, the * Selected-Snippet Metadata Panel exposes the active snippet's editable Name and * Comment (auto-saved), its timestamps and linked datasets, and the Duplicate / - * Delete operations. Search, sort controls, and the storage monitor arrive in - * later milestones. + * Delete operations. A live Search box and a Sort disclosure sit above the list + * (spec §02), and a Storage Monitor pins to the bottom of the pane. */ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { formatDate } from '@core/date-format'; import { @@ -19,6 +19,7 @@ import { snippetSizeBytes, type Snippet, } from '@core/snippet'; +import { filterAndSortSnippets } from '@core/snippet-sort'; import { confirm } from '../stores/ConfirmStore'; import { notify } from '../stores/NotificationStore'; import { selectActiveSnippet, useSnippetStore } from '../stores/SnippetStore'; @@ -26,6 +27,8 @@ import { useUserSettingsStore } from '../stores/UserSettingsStore'; import { Icon } from './Icon'; import { SegmentedControl, type SegmentedOption } from './SegmentedControl'; import { SettingRow, SettingsPopover, TextControl } from './SettingsPopover'; +import { SortControl } from './SortControl'; +import StorageMonitor from './StorageMonitor'; import styles from './SnippetLibrary.module.css'; /** Date display modes (spec §07 → Formatting). */ @@ -181,10 +184,30 @@ export function SnippetLibrary() { const selectSnippet = useSnippetStore((s) => s.selectSnippet); const removeSnippet = useSnippetStore((s) => s.removeSnippet); const duplicateActiveSnippet = useSnippetStore((s) => s.duplicateActiveSnippet); + const searchQuery = useSnippetStore((s) => s.searchQuery); + const sortBy = useSnippetStore((s) => s.sortBy); + const sortOrder = useSnippetStore((s) => s.sortOrder); + const setSearch = useSnippetStore((s) => s.setSearch); const formatting = useUserSettingsStore((s) => s.saved.formatting); + const searchRef = useRef(null); - // Default ordering: newest-modified first (spec §02 → Sort). - const ordered = [...snippets].sort((a, b) => b.modified.localeCompare(a.modified)); + // Derive the visible list with useMemo over PRIMITIVE store values — never + // inside a selector that builds a fresh array, which would loop the app + // (MEMORY → "Zustand stable selectors"; the pure pipeline lives in core). + const ordered = useMemo( + () => filterAndSortSnippets(snippets, searchQuery, { sortBy, sortOrder }), + [snippets, searchQuery, sortBy, sortOrder], + ); + + // Empty-state copy splits two cases (council EMPTY STATES; Carbon two types): + // a genuinely empty library vs. a search that matched nothing. + const searching = searchQuery.trim() !== ''; + const noMatches = ordered.length === 0; + + const clearSearch = () => { + setSearch(''); + searchRef.current?.focus(); // clear returns focus to the input (council SEARCH) + }; const handleDelete = async (id: string, name: string) => { // In-app confirmation (docs/architecture/03 → confirmation dialogs). @@ -220,8 +243,7 @@ export function SnippetLibrary() { return (
- {/* Library toolbar: heading + the date-format settings gear (sort/search - controls will join it here per spec §02). */} + {/* Library toolbar: heading + the date-format settings gear. */}
Snippets @@ -234,16 +256,70 @@ export function SnippetLibrary() { Create New Snippet + {/* Search + Sort controls, pinned above the list (spec §02; council + SEARCH + SORT). Search is an unlabelled Carbon active-search: a search + input with a leading magnifier and a clear (✕) that appears when + non-empty and returns focus to the input. Sort is a disclosure whose + trigger shows the current order for recognition. */} +
+
+ + setSearch(e.target.value)} + spellCheck={false} + /> + {searching && ( + + )} +
+ +
+ + {/* Polite result-count announcement (council SEARCH; Carbon "always + include the number of results, including no results"). Only announces + while searching, so it stays quiet for the default full list. */} +
+ {searching ? `${ordered.length} ${ordered.length === 1 ? 'result' : 'results'}` : ''} +
+
    - {ordered.length === 0 && ( + {noMatches && searching && ( + // Empty state (b): a search that matched nothing (council EMPTY STATES).
  • - No snippets yet — create your first one with the button above. + No snippets match your search + Try a different term. +
  • + )} + {noMatches && !searching && ( + // Empty state (a): a genuinely empty library — guide to Create. +
  • + No snippets yet + Create your first one with the button above.
  • )} {ordered.map((s) => { // Size is omitted under ~1 KB per spec §02; null collapses the suffix. const size = formatSnippetSize(snippetSizeBytes(s)); - const date = formatDate(s.modified, formatting.dateFormat, formatting.customDateFormat); + // When sorting by Created, the row shows the created date instead of + // modified, so the visible date matches the ordering key (spec §02 → + // List Item). + const stamp = sortBy === 'created' ? s.created : s.modified; + const date = formatDate(stamp, formatting.dateFormat, formatting.customDateFormat); return (
  • {/* The row's selectable area is a real
); } diff --git a/src/app/components/SortControl.module.css b/src/app/components/SortControl.module.css new file mode 100644 index 0000000..07a3a87 --- /dev/null +++ b/src/app/components/SortControl.module.css @@ -0,0 +1,96 @@ +/* SortControl — the library sort disclosure (spec §02 → Sort; arch 10). */ + +.wrap { + position: relative; + display: inline-flex; +} + +/* Trigger — shows the current field + direction for recognition (council SORT). */ +.trigger { + display: inline-flex; + align-items: center; + gap: var(--space-1); + height: 28px; + padding: 0 var(--space-3); + border: var(--border-width) solid var(--border-strong); + border-radius: var(--radius); + background: transparent; + color: var(--text-secondary); + font: inherit; + font-size: 12px; + white-space: nowrap; + cursor: pointer; + transition: + background var(--dur-fast) var(--ease), + color var(--dur-fast) var(--ease); +} + +.trigger:hover { + background: var(--layer-01); + color: var(--text); +} + +.trigger[aria-expanded='true'] { + background: var(--layer-02); + color: var(--text); +} + +.trigger:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 1px; +} + +/* The disclosed panel — portaled to , positioned fixed (mirrors the + settings popover) so it escapes the panes' overflow clipping. */ +.pop { + position: fixed; + z-index: 1000; + min-width: 160px; + padding: var(--space-2); + background: var(--layer-01); + border: var(--border-width) solid var(--border-strong); + border-radius: var(--radius); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} + +.title { + margin: 0 0 var(--space-2); + padding: 0 var(--space-2); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-secondary); +} + +.field { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + width: 100%; + padding: var(--space-2) var(--space-3); + border: none; + border-radius: var(--radius); + background: transparent; + color: var(--text); + font: inherit; + font-size: 13px; + text-align: left; + cursor: pointer; + transition: background var(--dur-fast) var(--ease); +} + +.field:hover { + background: var(--layer-02); +} + +.field:focus-visible { + outline: 2px solid var(--focus); + outline-offset: -1px; +} + +/* The active field is the current sort; its trailing arrow shows the direction. */ +.active { + font-weight: 600; +} diff --git a/src/app/components/SortControl.tsx b/src/app/components/SortControl.tsx new file mode 100644 index 0000000..2840f72 --- /dev/null +++ b/src/app/components/SortControl.tsx @@ -0,0 +1,166 @@ +/** + * SortControl — the library's sort disclosure (spec §02 → Sort; council SORT). + * + * A disclosure + non-modal popover, the same APG primitive as SettingsPopover + * (docs/architecture/10 → "settings are … a disclosure popover") — deliberately + * NOT an ARIA menu. It differs from SettingsPopover only in its **trigger**: per + * NN/g recognition-over-recall the trigger shows the current state ("Sort: + * Modified ↓") instead of a bare gear, so the user reads the order without + * opening the popover. The body is a labelled `group` of field buttons (radio + * group), each showing a direction arrow on the active field. + * + * Selection model (spec §02): re-selecting the ACTIVE field flips direction; + * selecting a DIFFERENT field switches to it and resets to descending — all + * encapsulated in `SnippetStore.setSort`. Keyboard/focus (APG disclosure): the + * shared `useSettingsPopoverStore` makes at most one popover open at a time; + * Enter/Space toggle the trigger; Esc closes and returns focus to the trigger; + * an outside click closes. The panel is portaled to and positioned fixed + * because the panes clip their content. + */ + +import { useCallback, useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import type { SortBy } from '@core/snippet-sort'; +import { useSettingsPopoverStore } from '../stores/SettingsPopoverStore'; +import { useSnippetStore } from '../stores/SnippetStore'; +import styles from './SortControl.module.css'; + +/** Gap (px) between the trigger and the disclosed panel (matches SettingsPopover). */ +const GAP = 6; + +const ID = 'library-sort'; + +/** Field labels in display order (the order the popover lists them). */ +const FIELDS: ReadonlyArray<{ value: SortBy; label: string }> = [ + { value: 'modified', label: 'Modified' }, + { value: 'created', label: 'Created' }, + { value: 'name', label: 'Name' }, + { value: 'size', label: 'Size' }, +]; + +function labelFor(by: SortBy): string { + return FIELDS.find((f) => f.value === by)?.label ?? by; +} + +export function SortControl() { + const sortBy = useSnippetStore((s) => s.sortBy); + const sortOrder = useSnippetStore((s) => s.sortOrder); + const setSort = useSnippetStore((s) => s.setSort); + const open = useSettingsPopoverStore((s) => s.openId === ID); + const toggle = useSettingsPopoverStore((s) => s.toggle); + const close = useSettingsPopoverStore((s) => s.close); + const triggerRef = useRef(null); + const popRef = useRef(null); + + const arrow = sortOrder === 'desc' ? '↓' : '↑'; + + // Position the (fixed) panel from the trigger's rect — no React state, so no + // re-render on scroll. Aligns to the trigger's left edge (the library pane is + // narrow; opening rightward keeps the panel on-screen). + const place = useCallback(() => { + const trigger = triggerRef.current; + const pop = popRef.current; + if (!trigger || !pop) return; + const r = trigger.getBoundingClientRect(); + pop.style.top = `${r.bottom + GAP}px`; + pop.style.left = `${r.left}px`; + pop.style.right = 'auto'; + }, []); + + useEffect(() => { + if (!open) return; + window.addEventListener('resize', place); + window.addEventListener('scroll', place, true); + return () => { + window.removeEventListener('resize', place); + window.removeEventListener('scroll', place, true); + }; + }, [open, place]); + + // Esc closes + restores focus to the trigger; an outside pointer click closes + // (APG disclosure; non-modal). Capture Esc so it settles here, not a parent. + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + close(); + triggerRef.current?.focus(); + } + }; + const onPointer = (e: PointerEvent) => { + const t = e.target as Node; + if (!popRef.current?.contains(t) && !triggerRef.current?.contains(t)) close(); + }; + document.addEventListener('keydown', onKey, true); + document.addEventListener('pointerdown', onPointer, true); + return () => { + document.removeEventListener('keydown', onKey, true); + document.removeEventListener('pointerdown', onPointer, true); + }; + }, [open, close]); + + // On mount, position before paint and move focus to the active field button so + // keyboard users land inside the popover. + const setPopNode = useCallback( + (node: HTMLDivElement | null) => { + popRef.current = node; + if (node) { + place(); + node.querySelector('[aria-checked="true"], button')?.focus(); + } + }, + [place], + ); + + return ( +
+ + {open && + createPortal( +
+

Sort by

+ {FIELDS.map((f) => { + const active = f.value === sortBy; + return ( + + ); + })} +
, + document.body, + )} +
+ ); +} diff --git a/src/app/components/StorageMonitor.module.css b/src/app/components/StorageMonitor.module.css new file mode 100644 index 0000000..2f9347c --- /dev/null +++ b/src/app/components/StorageMonitor.module.css @@ -0,0 +1,86 @@ +/* + * Storage Monitor — token-based styles. + * All colours are role tokens; no raw hexes, no hardcoded hues (arch 09 §3.3). + * Three visual levels — ok / warning / critical — driven by state classes on + * the root element; the fill bar picks up the level colour from a CSS var + * resolved locally so the cascade stays flat. + */ + +.monitor { + flex: 0 0 auto; + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + border-top: var(--border-width) solid var(--border); + /* Default fill colour token; overridden by level classes below. */ + --fill-color: var(--accent); +} + +/* Warning level — cautionary amber (uses the contrast-safe fg token so it + clears 4.5:1 on light surfaces; the raw yellow fails — arch 09 §3.3). */ +.warning { + --fill-color: var(--support-warning-fg); +} + +/* Critical level — error red. */ +.critical { + --fill-color: var(--support-error); +} + +/* Usage text row: "1.2 MB of 5.0 MB" */ +.label { + display: flex; + align-items: baseline; + gap: 0; + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; +} + +.used { + font-weight: 600; + color: var(--text); + font-variant-numeric: tabular-nums; +} + +.separator { + color: var(--text-secondary); +} + +.quota { + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} + +/* The track that contains the fill bar. */ +.track { + width: 100%; + height: 4px; + background: var(--layer-02, var(--border)); + border-radius: var(--radius); + overflow: hidden; +} + +/* The coloured fill — width is set inline from fraction; colour via --fill-color. */ +.fill { + height: 100%; + background: var(--fill-color); + transition: width var(--dur-moderate) var(--ease); +} + +/* The polite live-region announcement (warning / critical copy). Visually + muted; assistive tech reads it because of role="status" + aria-live="polite". */ +.announcement { + margin: 0; + font-size: 11px; + color: var(--fill-color); + line-height: 1.4; +} + +/* Suppress the fill bar transition for users who prefer reduced motion (arch 09 §3.5). */ +@media (prefers-reduced-motion: reduce) { + .fill { + transition: none; + } +} diff --git a/src/app/components/StorageMonitor.test.tsx b/src/app/components/StorageMonitor.test.tsx new file mode 100644 index 0000000..aba9874 --- /dev/null +++ b/src/app/components/StorageMonitor.test.tsx @@ -0,0 +1,199 @@ +/** + * StorageMonitor render tests. + * + * Strategy: mock readStorageEstimate (the async browser adapter) so tests run + * in happy-dom without a real Storage Manager, then assert on the rendered DOM + * — text content, ARIA attributes, and CSS-module class presence for each level. + * + * CSS Modules are identity-mapped in Vitest's happy-dom environment (class names + * come through as-is), so we match on the raw class name tokens from the .module.css. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import type { StorageSummary } from '@core/storage-estimate'; +import StorageMonitor from './StorageMonitor'; + +// Mock the browser adapter — tests must not touch navigator.storage. +vi.mock('../infrastructure/storage-estimate', () => ({ + readStorageEstimate: vi.fn(), +})); +import { readStorageEstimate } from '../infrastructure/storage-estimate'; +const mockEstimate = vi.mocked(readStorageEstimate); + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +// ---- helpers ---------------------------------------------------------------- + +/** Build a complete StorageSummary for test fixtures. */ +function makeSummary( + overrides: Partial & + Pick, +): StorageSummary { + return { + available: true, + ...overrides, + }; +} + +// ---- test setup ------------------------------------------------------------- + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); +}); + +/** Render the component and wait for the async estimate to resolve. */ +async function renderMonitor() { + await act(async () => { + root.render(); + await Promise.resolve(); // flush the async readStorageEstimate() resolution + }); +} + +// ---- tests ------------------------------------------------------------------ + +describe('StorageMonitor', () => { + describe('when the estimate is available and at ok level', () => { + const okSummary = makeSummary({ + usedBytes: 1.2 * 1024 * 1024, // ~1.2 MB + quotaBytes: 5 * 1024 * 1024, // 5 MB + fraction: 0.24, + level: 'ok', + }); + + test('renders usage text: used of quota', async () => { + mockEstimate.mockResolvedValue(okSummary); + await renderMonitor(); + const label = container.querySelector('[aria-label="Storage usage"]'); + expect(label).not.toBeNull(); + // The humanizeBytes output for 1.2 MB appears somewhere in the label area. + expect(label!.textContent).toContain('MB'); + expect(label!.textContent).toContain(' of '); + }); + + test('renders a meter element with ARIA attributes', async () => { + mockEstimate.mockResolvedValue(okSummary); + await renderMonitor(); + const meter = container.querySelector('[role="meter"]'); + expect(meter).not.toBeNull(); + expect(meter!.getAttribute('aria-valuemin')).toBe('0'); + expect(meter!.getAttribute('aria-valuemax')).toBe('100'); + // aria-valuenow should be 24 (24% from fraction 0.24) + expect(meter!.getAttribute('aria-valuenow')).toBe('24'); + expect(meter!.getAttribute('aria-valuetext')).toContain('%'); + }); + + test('applies the ok class (no warning/critical) to the root element', async () => { + mockEstimate.mockResolvedValue(okSummary); + await renderMonitor(); + const root_ = container.firstElementChild as HTMLElement | null; + expect(root_).not.toBeNull(); + expect(root_!.className).toContain('ok'); + expect(root_!.className).not.toContain('warning'); + expect(root_!.className).not.toContain('critical'); + }); + + test('does not render a live-region announcement at ok level', async () => { + mockEstimate.mockResolvedValue(okSummary); + await renderMonitor(); + expect(container.querySelector('[role="status"]')).toBeNull(); + }); + }); + + describe('when the estimate is at warning level', () => { + const warnSummary = makeSummary({ + usedBytes: 4.2 * 1024 * 1024, + quotaBytes: 5 * 1024 * 1024, + fraction: 0.84, + level: 'warning', + }); + + test('applies the warning class to the root element', async () => { + mockEstimate.mockResolvedValue(warnSummary); + await renderMonitor(); + const root_ = container.firstElementChild as HTMLElement | null; + expect(root_!.className).toContain('warning'); + expect(root_!.className).not.toContain('critical'); + }); + + test('renders a polite live-region with warning copy', async () => { + mockEstimate.mockResolvedValue(warnSummary); + await renderMonitor(); + const status = container.querySelector('[role="status"]'); + expect(status).not.toBeNull(); + expect(status!.getAttribute('aria-live')).toBe('polite'); + expect(status!.textContent).toBeTruthy(); + }); + + test('meter aria-valuenow reflects the fraction', async () => { + mockEstimate.mockResolvedValue(warnSummary); + await renderMonitor(); + const meter = container.querySelector('[role="meter"]'); + expect(meter!.getAttribute('aria-valuenow')).toBe('84'); + }); + }); + + describe('when the estimate is at critical level', () => { + const critSummary = makeSummary({ + usedBytes: 4.9 * 1024 * 1024, + quotaBytes: 5 * 1024 * 1024, + fraction: 0.98, + level: 'critical', + }); + + test('applies the critical class to the root element', async () => { + mockEstimate.mockResolvedValue(critSummary); + await renderMonitor(); + const root_ = container.firstElementChild as HTMLElement | null; + expect(root_!.className).toContain('critical'); + expect(root_!.className).not.toContain('warning'); + }); + + test('renders a polite live-region with critical copy mentioning deletion', async () => { + mockEstimate.mockResolvedValue(critSummary); + await renderMonitor(); + const status = container.querySelector('[role="status"]'); + expect(status).not.toBeNull(); + expect(status!.getAttribute('aria-live')).toBe('polite'); + // Critical copy must direct user to delete snippets. + expect(status!.textContent?.toLowerCase()).toContain('delete'); + }); + + test('meter aria-valuenow is 98 and aria-valuetext contains the used bytes', async () => { + mockEstimate.mockResolvedValue(critSummary); + await renderMonitor(); + const meter = container.querySelector('[role="meter"]'); + expect(meter!.getAttribute('aria-valuenow')).toBe('98'); + expect(meter!.getAttribute('aria-valuetext')).toContain('98%'); + }); + }); + + describe('graceful unavailable case', () => { + const unavailableSummary: StorageSummary = { + available: false, + usedBytes: 0, + quotaBytes: 0, + fraction: 0, + level: 'ok', + }; + + test('renders nothing when the Storage Manager API is unavailable', async () => { + mockEstimate.mockResolvedValue(unavailableSummary); + await renderMonitor(); + // The component should return null — no DOM output. + expect(container.firstElementChild).toBeNull(); + }); + }); +}); diff --git a/src/app/components/StorageMonitor.tsx b/src/app/components/StorageMonitor.tsx new file mode 100644 index 0000000..7763efa --- /dev/null +++ b/src/app/components/StorageMonitor.tsx @@ -0,0 +1,93 @@ +/** + * Storage Monitor — library pane footer (spec §02 → Storage Monitor; + * spec §10 → "Warn before storage failure"; docs/architecture/10 §1 status + * indicator channel). + * + * Fetches the browser storage estimate on mount (readStorageEstimate), runs it + * through summarizeStorage, and renders: + * - human-readable "used of quota" text + * - a fill bar (meter) reflecting the percentage used + * - escalating visual treatment at warning / critical levels via design tokens + * + * When the Storage Manager API is unavailable (feature-detected by the adapter) + * the component renders nothing — a missing ambient indicator is harmless, and + * surfacing a "unavailable" line adds noise with no actionable value. + * + * Critical state is announced politely via `aria-live="polite"` so assistive + * technology is informed without interrupting the user mid-task (arch 10 §5 — + * status indicators are ambient, not assertive). + */ + +import { useEffect, useState } from 'react'; +import { humanizeBytes, type StorageSummary } from '@core/storage-estimate'; +import { readStorageEstimate } from '../infrastructure/storage-estimate'; +import styles from './StorageMonitor.module.css'; + +/** Level-to-label for the accessible announcement copy. */ +const LEVEL_LABEL: Record = { + ok: '', + warning: 'Storage is getting full.', + critical: 'Storage is almost full. Delete snippets to free space.', +}; + +export default function StorageMonitor() { + const [summary, setSummary] = useState(null); + + useEffect(() => { + let cancelled = false; + void readStorageEstimate().then((s) => { + if (!cancelled) setSummary(s); + }); + return () => { + cancelled = true; + }; + }, []); + + // While loading, or when the API is unavailable, render nothing. + if (!summary || !summary.available) return null; + + const usedText = humanizeBytes(summary.usedBytes); + const quotaText = humanizeBytes(summary.quotaBytes); + const pct = Math.round(summary.fraction * 100); + const announcement = LEVEL_LABEL[summary.level]; + + return ( +
+ {/* Usage text */} +
+ {usedText} + of + {quotaText} +
+ + {/* + * Fill bar — ARIA meter (WAI-ARIA 1.1). + * role="meter" conveys a scalar value within a known range; aria-valuetext + * gives a human-readable reading that matches the visible label. + */} +
+
+
+ + {/* + * Polite live region — announces the warning / critical state to assistive + * technology without interrupting ongoing work. Empty for the 'ok' level so + * there is no announcement when storage is healthy (arch 10 §1 — status + * indicators are ambient, not assertive; only escalation warrants notice). + */} + {announcement && ( +

+ {announcement} +

+ )} +
+ ); +} diff --git a/src/app/infrastructure/ux-prefs.test.ts b/src/app/infrastructure/ux-prefs.test.ts index e6a5106..c4e6d09 100644 --- a/src/app/infrastructure/ux-prefs.test.ts +++ b/src/app/infrastructure/ux-prefs.test.ts @@ -2,8 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { loadPanelLayout, loadPaneVisibility, + loadSnippetSort, savePanelLayout, savePaneVisibility, + saveSnippetSort, } from './ux-prefs'; const KEY = 'astrolabe:ux-prefs'; @@ -98,3 +100,33 @@ describe('ux-prefs · paneVisibility', () => { expect(stored.paneVisibility.editor).toBe(false); }); }); + +describe('ux-prefs · snippetSort', () => { + beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub())); + afterEach(() => vi.unstubAllGlobals()); + + it('defaults to Modified, descending when nothing is stored', () => { + expect(loadSnippetSort()).toEqual({ by: 'modified', order: 'desc' }); + }); + + it('round-trips a stored sort', () => { + saveSnippetSort({ by: 'name', order: 'asc' }); + expect(loadSnippetSort()).toEqual({ by: 'name', order: 'asc' }); + }); + + it('falls back to defaults for invalid stored values', () => { + localStorage.setItem(KEY, JSON.stringify({ snippetSort: { by: 'bogus', order: 'sideways' } })); + expect(loadSnippetSort()).toEqual({ by: 'modified', order: 'desc' }); + }); + + it('preserves panelLayout when writing the sort (separate keys)', () => { + savePanelLayout({ libraryWidth: 280 }); + saveSnippetSort({ by: 'size', order: 'desc' }); + const stored = JSON.parse(localStorage.getItem(KEY)!) as { + panelLayout: { libraryWidth: number }; + snippetSort: { by: string; order: string }; + }; + expect(stored.panelLayout.libraryWidth).toBe(280); + expect(stored.snippetSort).toEqual({ by: 'size', order: 'desc' }); + }); +}); diff --git a/src/app/infrastructure/ux-prefs.ts b/src/app/infrastructure/ux-prefs.ts index a1ba5e5..bf18117 100644 --- a/src/app/infrastructure/ux-prefs.ts +++ b/src/app/infrastructure/ux-prefs.ts @@ -4,7 +4,7 @@ * 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). + * (spec §02 → Sort) and the panel layout (per-pane widths + visibility). * * This slice persists the panel **widths** and per-pane **visibility** (spec §01A). * Read-with-fallback + write-through merge, the same contract as the settings @@ -15,6 +15,13 @@ * `localStorage`; everything else goes through these typed functions. */ +import { + DEFAULT_SORT_BY, + DEFAULT_SORT_ORDER, + type SortBy, + type SortOrder, +} from '@core/snippet-sort'; + const KEY = 'astrolabe:ux-prefs'; /** Per-pane widths (px). Optional — a missing field falls back to its default. */ @@ -30,9 +37,16 @@ export interface PaneVisibilityPref { preview?: boolean; } +/** The persisted library sort (spec §02 → Sort; persists across sessions). */ +export interface SnippetSortPref { + by: SortBy; + order: SortOrder; +} + interface StoredPrefs { panelLayout?: PanelLayout; paneVisibility?: PaneVisibilityPref; + snippetSort?: Partial; [k: string]: unknown; } @@ -106,3 +120,29 @@ export function savePaneVisibility(visibility: PaneVisibilityPref): void { const current = readRaw(); writeRaw({ ...current, paneVisibility: { ...current.paneVisibility, ...visibility } }); } + +/** Guards against junk in storage — only the known sort fields survive. */ +function sortBy(v: unknown): SortBy | undefined { + return v === 'modified' || v === 'created' || v === 'name' || v === 'size' ? v : undefined; +} +function sortOrder(v: unknown): SortOrder | undefined { + return v === 'asc' || v === 'desc' ? v : undefined; +} + +/** + * The persisted library sort, falling back to the spec default (Modified, desc) + * for anything missing or invalid in storage. + */ +export function loadSnippetSort(): SnippetSortPref { + const stored = readRaw().snippetSort ?? {}; + return { + by: sortBy(stored.by) ?? DEFAULT_SORT_BY, + order: sortOrder(stored.order) ?? DEFAULT_SORT_ORDER, + }; +} + +/** Persist the library sort, preserving every other key already in the record. */ +export function saveSnippetSort(sort: SnippetSortPref): void { + const current = readRaw(); + writeRaw({ ...current, snippetSort: { ...current.snippetSort, ...sort } }); +} diff --git a/src/app/orchestration/snippet-sort.ts b/src/app/orchestration/snippet-sort.ts new file mode 100644 index 0000000..6d31d87 --- /dev/null +++ b/src/app/orchestration/snippet-sort.ts @@ -0,0 +1,32 @@ +/** + * Snippet-sort orchestration — bridges the (browser-free) SnippetStore's library + * sort to the ux-prefs adapter (spec §02 → Sort; persists across sessions). Same + * store↔adapter pattern as theme / pane-layout / preferences. + * + * `initSnippetSort` hydrates the persisted field+direction into the store before + * render, so the library opens the way the user left it. `wireSnippetSort` + * persists the choice on change — written **immediately** (a sort change is a + * single discrete click, not a stream like a resize drag). + * + * Search is intentionally NOT persisted: it's a transient narrowing of the view, + * not a remembered preference (spec §02 — search affects visibility only). + */ + +import { loadSnippetSort, saveSnippetSort } from '../infrastructure/ux-prefs'; +import { useSnippetStore } from '../stores/SnippetStore'; + +/** Hydrate the persisted library sort into the store. Call before render. */ +export function initSnippetSort(): void { + const { by, order } = loadSnippetSort(); + // Pass the explicit order so setSort hydrates the exact stored state rather + // than running its flip/reset selection logic. + useSnippetStore.getState().setSort(by, order); +} + +/** Persist the sort on change. Returns a teardown that detaches the subscriber. */ +export function wireSnippetSort(): () => void { + return useSnippetStore.subscribe((state, prev) => { + if (state.sortBy === prev.sortBy && state.sortOrder === prev.sortOrder) return; + saveSnippetSort({ by: state.sortBy, order: state.sortOrder }); + }); +} diff --git a/src/app/stores/SnippetStore.test.ts b/src/app/stores/SnippetStore.test.ts index 1546bee..6e83efb 100644 --- a/src/app/stores/SnippetStore.test.ts +++ b/src/app/stores/SnippetStore.test.ts @@ -410,3 +410,55 @@ describe('renameDatasetRefs', () => { expect(store().renameDatasetRefs('Unknown', 'Other')).toBe(0); }); }); + +describe('library view state (spec §02 → Search / Sort)', () => { + test('setSearch updates the query without touching the active snippet', () => { + const a = createSnippet({ id: 'a', now: new Date('2026-01-01T00:00:00Z') }); + const b = createSnippet({ id: 'b', now: new Date('2026-02-01T00:00:00Z') }); + store().hydrate([a, b], 'a'); + + store().setSearch('bar'); + expect(store().searchQuery).toBe('bar'); + expect(store().activeSnippetId).toBe('a'); // search is visibility-only + }); + + test('defaults to Modified, descending', () => { + expect(store().sortBy).toBe('modified'); + expect(store().sortOrder).toBe('desc'); + }); + + test('re-selecting the active field flips direction', () => { + expect(store().sortOrder).toBe('desc'); + store().setSort('modified'); + expect(store().sortBy).toBe('modified'); + expect(store().sortOrder).toBe('asc'); + store().setSort('modified'); + expect(store().sortOrder).toBe('desc'); + }); + + test('selecting a different field switches to it and resets to descending', () => { + store().setSort('name'); // from modified/desc → name/desc + expect(store().sortBy).toBe('name'); + expect(store().sortOrder).toBe('desc'); + + store().setSort('name'); // flip to asc + expect(store().sortOrder).toBe('asc'); + + store().setSort('size'); // different field resets to desc, not carrying asc + expect(store().sortBy).toBe('size'); + expect(store().sortOrder).toBe('desc'); + }); + + test('an explicit order hydrates the exact stored state (no flip logic)', () => { + store().setSort('name', 'asc'); + expect(store().sortBy).toBe('name'); + expect(store().sortOrder).toBe('asc'); + }); + + test('sort changes never touch the active snippet', () => { + const a = createSnippet({ id: 'a', now: new Date('2026-01-01T00:00:00Z') }); + store().hydrate([a], 'a'); + store().setSort('name'); + expect(store().activeSnippetId).toBe('a'); + }); +}); diff --git a/src/app/stores/SnippetStore.ts b/src/app/stores/SnippetStore.ts index 26d9588..c1a8e09 100644 --- a/src/app/stores/SnippetStore.ts +++ b/src/app/stores/SnippetStore.ts @@ -25,6 +25,12 @@ import { type Snippet, } from '@core/snippet'; import { recomputeDatasetRefs, renameDatasetInSpec } from '@core/spec-refs'; +import { + DEFAULT_SORT_BY, + DEFAULT_SORT_ORDER, + type SortBy, + type SortOrder, +} from '@core/snippet-sort'; /** Which version of the active snippet the editor is showing (spec §03D). */ export type EditorView = 'draft' | 'published'; @@ -44,6 +50,17 @@ export interface SnippetState { */ bufferEpoch: number; + /** + * Library view state (spec §02 → Search / Sort). These affect only which + * snippets are shown and in what order — never `activeSnippetId` or any data. + * The component derives its visible list from these primitives via `useMemo` + * (core `filterAndSortSnippets`), NOT through a selector that builds a fresh + * array (which would loop the app — MEMORY → "Zustand stable selectors"). + */ + searchQuery: string; + sortBy: SortBy; + sortOrder: SortOrder; + /** Replace the library from storage and choose an active snippet. */ hydrate: (snippets: Snippet[], activeId?: string | null) => void; /** @@ -72,6 +89,16 @@ export interface SnippetState { * an unchanged comment. `now` injectable. */ setComment: (id: string, comment: string, now?: Date) => void; + /** Set the live search query (spec §02 → Search). Visibility only — never + * touches `activeSnippetId` or any snippet data. */ + setSearch: (query: string) => void; + /** + * Set the library sort (spec §02 → Sort): re-selecting the **active** field + * flips direction; choosing a **different** field switches to it and resets to + * descending. Persisted by an orchestration subscriber; never touches the + * active snippet. `order` may be passed to hydrate an exact stored state. + */ + setSort: (by: SortBy, order?: SortOrder) => void; /** * Duplicate the active snippet (spec §02 → Duplicate): flushes the live buffer * into the source draft first so the copy reflects in-progress edits, then @@ -143,6 +170,9 @@ export const useSnippetStore = create((set, get) => ({ draftText: '', editorView: 'draft', bufferEpoch: 0, + searchQuery: '', + sortBy: DEFAULT_SORT_BY, + sortOrder: DEFAULT_SORT_ORDER, hydrate: (snippets, activeId) => { const id = activeId !== undefined ? activeId : newestId(snippets); @@ -243,6 +273,21 @@ export const useSnippetStore = create((set, get) => ({ }); }, + setSearch: (searchQuery) => set({ searchQuery }), + + setSort: (by, order) => { + set((s) => { + // An explicit order hydrates a stored state directly (orchestration init). + if (order !== undefined) return { sortBy: by, sortOrder: order }; + // Re-selecting the active field flips direction; a different field resets + // to descending (spec §02 → Sort). + if (by === s.sortBy) { + return { sortOrder: s.sortOrder === 'desc' ? 'asc' : 'desc' }; + } + return { sortBy: by, sortOrder: 'desc' }; + }); + }, + duplicateActiveSnippet: (now, id) => { // Flush the live buffer into the source draft first, so the copy faithfully // mirrors what the user currently sees, not the last auto-saved draft. @@ -396,6 +441,9 @@ export const useSnippetStore = create((set, get) => ({ draftText: '', editorView: 'draft', bufferEpoch: 0, + searchQuery: '', + sortBy: DEFAULT_SORT_BY, + sortOrder: DEFAULT_SORT_ORDER, }), })); diff --git a/src/core/snippet-sort.test.ts b/src/core/snippet-sort.test.ts new file mode 100644 index 0000000..597260a --- /dev/null +++ b/src/core/snippet-sort.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from 'vitest'; +import { createSnippet, type Snippet } from './snippet'; +import { + filterAndSortSnippets, + snippetMatchesQuery, + snippetSortSize, + sortSnippets, + type SortState, +} from './snippet-sort'; + +/** Build a snippet with the fields the sort/search cares about. */ +function snip(over: Partial & { id: string }): Snippet { + const base = createSnippet({ id: over.id, now: new Date('2026-01-01T00:00:00Z') }); + return { ...base, ...over }; +} + +describe('sortSnippets · fields', () => { + const a = snip({ + id: 'a', + name: 'banana', + created: '2026-01-03T00:00:00Z', + modified: '2026-01-05T00:00:00Z', + spec: '{"x":1}', + }); + const b = snip({ + id: 'b', + name: 'Apple', + created: '2026-01-01T00:00:00Z', + modified: '2026-01-09T00:00:00Z', + spec: '{"longer":true,"value":2}', + }); + const c = snip({ + id: 'c', + name: 'cherry', + created: '2026-01-08T00:00:00Z', + modified: '2026-01-02T00:00:00Z', + spec: '{}', + }); + const all = [a, b, c]; + + const order = (s: SortState) => sortSnippets(all, s).map((x) => x.id); + + test('modified desc / asc', () => { + expect(order({ sortBy: 'modified', sortOrder: 'desc' })).toEqual(['b', 'a', 'c']); + expect(order({ sortBy: 'modified', sortOrder: 'asc' })).toEqual(['c', 'a', 'b']); + }); + + test('created desc / asc', () => { + expect(order({ sortBy: 'created', sortOrder: 'desc' })).toEqual(['c', 'a', 'b']); + expect(order({ sortBy: 'created', sortOrder: 'asc' })).toEqual(['b', 'a', 'c']); + }); + + test('name asc / desc — case-insensitive (Apple sorts before banana)', () => { + expect(order({ sortBy: 'name', sortOrder: 'asc' })).toEqual(['b', 'a', 'c']); + expect(order({ sortBy: 'name', sortOrder: 'desc' })).toEqual(['c', 'a', 'b']); + }); + + test('size sorts by published-spec character length', () => { + expect(snippetSortSize(c)).toBeLessThan(snippetSortSize(a)); + expect(snippetSortSize(a)).toBeLessThan(snippetSortSize(b)); + expect(order({ sortBy: 'size', sortOrder: 'asc' })).toEqual(['c', 'a', 'b']); + expect(order({ sortBy: 'size', sortOrder: 'desc' })).toEqual(['b', 'a', 'c']); + }); + + test('does not mutate the input array', () => { + const input = [a, b, c]; + sortSnippets(input, { sortBy: 'name', sortOrder: 'asc' }); + expect(input.map((x) => x.id)).toEqual(['a', 'b', 'c']); + }); +}); + +describe('sortSnippets · stable tiebreak', () => { + test('equal keys break by id deterministically', () => { + const ts = '2026-01-01T00:00:00Z'; + const x = snip({ id: 'x', modified: ts }); + const y = snip({ id: 'y', modified: ts }); + const z = snip({ id: 'z', modified: ts }); + // Whatever the input order, equal modified times resolve by id ascending. + expect( + sortSnippets([z, x, y], { sortBy: 'modified', sortOrder: 'asc' }).map((s) => s.id), + ).toEqual(['x', 'y', 'z']); + expect( + sortSnippets([y, z, x], { sortBy: 'modified', sortOrder: 'asc' }).map((s) => s.id), + ).toEqual(['x', 'y', 'z']); + }); +}); + +describe('snippetMatchesQuery', () => { + const s = snip({ + id: 'a', + name: 'Sales Bar Chart', + comment: 'Quarterly revenue', + draftSpec: '{"mark":"bar","encoding":{"x":{"field":"category"}}}', + }); + + test('matches across name, comment, and spec content — case-insensitive', () => { + expect(snippetMatchesQuery(s, 'sales')).toBe(true); // name, lowercased query + expect(snippetMatchesQuery(s, 'REVENUE')).toBe(true); // comment, uppercased query + expect(snippetMatchesQuery(s, 'category')).toBe(true); // inside the spec text + expect(snippetMatchesQuery(s, 'bar')).toBe(true); + }); + + test('empty / whitespace query matches everything', () => { + expect(snippetMatchesQuery(s, '')).toBe(true); + expect(snippetMatchesQuery(s, ' ')).toBe(true); + }); + + test('no match returns false', () => { + expect(snippetMatchesQuery(s, 'pie')).toBe(false); + }); +}); + +describe('filterAndSortSnippets', () => { + const a = snip({ + id: 'a', + name: 'Alpha', + comment: '', + draftSpec: '{}', + modified: '2026-01-01T00:00:00Z', + }); + const b = snip({ + id: 'b', + name: 'Beta', + comment: 'alpha note', + draftSpec: '{}', + modified: '2026-01-02T00:00:00Z', + }); + const c = snip({ + id: 'c', + name: 'Gamma', + comment: '', + draftSpec: '{}', + modified: '2026-01-03T00:00:00Z', + }); + + test('filters then sorts', () => { + // "alpha" matches a (name) and b (comment); modified-desc puts b first. + expect( + filterAndSortSnippets([a, b, c], 'alpha', { sortBy: 'modified', sortOrder: 'desc' }).map( + (x) => x.id, + ), + ).toEqual(['b', 'a']); + }); + + test('empty query returns all, sorted', () => { + expect( + filterAndSortSnippets([a, b, c], '', { sortBy: 'modified', sortOrder: 'asc' }).map( + (x) => x.id, + ), + ).toEqual(['a', 'b', 'c']); + }); + + test('no matches returns an empty array', () => { + expect(filterAndSortSnippets([a, b, c], 'zzz', { sortBy: 'name', sortOrder: 'asc' })).toEqual( + [], + ); + }); +}); diff --git a/src/core/snippet-sort.ts b/src/core/snippet-sort.ts new file mode 100644 index 0000000..df6cb40 --- /dev/null +++ b/src/core/snippet-sort.ts @@ -0,0 +1,113 @@ +/** + * Snippet sort + search — pure library-ordering logic (spec §02 → Sort, Search). + * + * Portable core: no browser APIs, no React. The library's filter+sort pipeline + * lives here so it is unit-tested in isolation and the component can derive its + * view with a plain `useMemo` over primitive store values (snippets, query, + * sortBy, sortOrder) — never inside a Zustand selector that builds a fresh array + * (which would loop the app; see MEMORY → "Zustand stable selectors"). + * + * Council resolution (recorded in docs/architecture/10): + * - SORT: fields Modified / Created / Name / Size; re-selecting the active field + * flips direction, a different field resets to descending (spec §02). + * - SEARCH: case-insensitive substring across name + comment + spec content; we + * match the **draft** spec text (`draftSpec`) — the working version the user is + * editing and what the spec §02 calls "the current working/draft spec text". + */ + +import type { Snippet } from './snippet'; + +/** The four sortable fields (spec §02 → Sort). */ +export type SortBy = 'modified' | 'created' | 'name' | 'size'; + +/** Sort direction. */ +export type SortOrder = 'asc' | 'desc'; + +/** The library's default ordering: newest changes first (spec §02 → Sort). */ +export const DEFAULT_SORT_BY: SortBy = 'modified'; +export const DEFAULT_SORT_ORDER: SortOrder = 'desc'; + +export interface SortState { + sortBy: SortBy; + sortOrder: SortOrder; +} + +/** + * "Size" of a snippet for sorting (spec §02 → "Size sorts by stored snippet + * size"). Defined as the **character length of the published spec text** — a + * stable storage proxy: it's the persisted bytes-ish footprint of the record's + * main payload, doesn't fluctuate with un-published in-progress typing, and needs + * no `TextEncoder`. (The library *row* displays the draft's UTF-8 size for an + * at-a-glance hint; sorting uses the published text so the order is stable.) + */ +export function snippetSortSize(snippet: Snippet): number { + return snippet.spec.length; +} + +/** + * Compare two snippets by a field, ascending. Name is case-insensitive + * (locale-aware); timestamps compare lexicographically (ISO-8601 sorts + * chronologically as text); size compares numerically. Ties break by `id` so the + * order is **stable** and deterministic across renders. + */ +function compareAsc(a: Snippet, b: Snippet, by: SortBy): number { + let primary = 0; + switch (by) { + case 'name': + primary = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); + break; + case 'created': + primary = a.created.localeCompare(b.created); + break; + case 'modified': + primary = a.modified.localeCompare(b.modified); + break; + case 'size': + primary = snippetSortSize(a) - snippetSortSize(b); + break; + } + // Stable tiebreak so equal-keyed rows keep a fixed, deterministic order. + return primary !== 0 ? primary : a.id.localeCompare(b.id); +} + +/** + * Return a new array of `snippets` ordered by the given sort state. Pure — never + * mutates the input (sorts a copy). + */ +export function sortSnippets( + snippets: readonly Snippet[], + { sortBy, sortOrder }: SortState, +): Snippet[] { + const dir = sortOrder === 'asc' ? 1 : -1; + return [...snippets].sort((a, b) => dir * compareAsc(a, b, sortBy)); +} + +/** + * Whether a snippet matches a search query (spec §02 → Search). Case-insensitive + * substring across the snippet **name**, **comment**, and **spec content** (the + * working draft text). An empty/whitespace query matches everything (search + * affects visibility only — it never excludes when nothing was asked for). + */ +export function snippetMatchesQuery(snippet: Snippet, query: string): boolean { + const q = query.trim().toLowerCase(); + if (q === '') return true; + return ( + snippet.name.toLowerCase().includes(q) || + snippet.comment.toLowerCase().includes(q) || + snippet.draftSpec.toLowerCase().includes(q) + ); +} + +/** + * The library view: snippets filtered by `query` then ordered by `sort`. This is + * the single function the component memoizes over primitive store values. + */ +export function filterAndSortSnippets( + snippets: readonly Snippet[], + query: string, + sort: SortState, +): Snippet[] { + const filtered = + query.trim() === '' ? [...snippets] : snippets.filter((s) => snippetMatchesQuery(s, query)); + return sortSnippets(filtered, sort); +} diff --git a/src/main.tsx b/src/main.tsx index f8610ac..685e2d9 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,6 +3,7 @@ import { App } from './app/App'; import { initPanes, wirePanes } from './app/orchestration/panes'; import { initPreviewFitMode, wirePreviewFitMode } from './app/orchestration/preferences'; import { initSettings, wireSettings } from './app/orchestration/settings'; +import { initSnippetSort, wireSnippetSort } from './app/orchestration/snippet-sort'; import { initApp } from './app/orchestration/startup'; import { initTheme, wireTheme } from './app/orchestration/theme'; import '../styles/base.css'; @@ -20,6 +21,11 @@ wirePreviewFitMode(); initPanes(); wirePanes(); +// Hydrate + persist the library sort (spec §02 → Sort) so the list opens ordered +// the way the user left it. Hydrate before render; persist on change. +initSnippetSort(); +wireSnippetSort(); + // Hydrate + persist the full UserSettings record (editor / performance / // formatting) before render, so the editor, preview, and library read the user's // settings from the first paint (spec §07 → Startup load).