Add snippet-library search, sort, empty states, storage monitor (M6, §02/§09D)

This commit is contained in:
2026-06-07 20:00:40 +03:00
parent 9f7bf27b7a
commit 800a313be2
19 changed files with 1461 additions and 16 deletions
@@ -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
+2 -2
View File
@@ -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.
+5
View File
@@ -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<IconName, ReactNode> = {
<polygon points="17.4141 16 24 9.4141 22.5859 8 16 14.5859 9.4143 8 8 9.4141 14.5859 16 8 22.5859 9.4143 24 16 17.4141 22.5859 24 24 22.5859 17.4141 16" />
),
add: <polygon points="17,15 17,8 15,8 15,15 8,15 8,17 15,17 15,24 17,24 17,17 24,17 24,15" />,
// Carbon Search — magnifying glass, the active-search affordance (council SEARCH).
search: (
<path d="M29,27.5859l-7.5521-7.5521a11.0177,11.0177,0,1,0-1.4141,1.4141L27.5859,29ZM4,13a9,9,0,1,1,9,9A9.01,9.01,0,0,1,4,13Z" />
),
settings: (
<>
<path d="M27,16.76c0-.25,0-.5,0-.76s0-.51,0-.77l1.92-1.68A2,2,0,0,0,29.3,11L26.94,7a2,2,0,0,0-1.73-1,2,2,0,0,0-.64.1l-2.43.82a11.35,11.35,0,0,0-1.31-.75l-.51-2.52a2,2,0,0,0-2-1.61H13.64a2,2,0,0,0-2,1.61l-.51,2.52a11.48,11.48,0,0,0-1.32.75L7.43,6.06A2,2,0,0,0,6.79,6,2,2,0,0,0,5.06,7L2.7,11a2,2,0,0,0,.41,2.51L5,15.24c0,.25,0,.5,0,.76s0,.51,0,.77L3.11,18.45A2,2,0,0,0,2.7,21L5.06,25a2,2,0,0,0,1.73,1,2,2,0,0,0,.64-.1l2.43-.82a11.35,11.35,0,0,0,1.31.75l.51,2.52a2,2,0,0,0,2,1.61h4.72a2,2,0,0,0,2-1.61l.51-2.52a11.48,11.48,0,0,0,1.32-.75l2.42.82a2,2,0,0,0,.64.1,2,2,0,0,0,1.73-1L29.3,21a2,2,0,0,0-.41-2.51ZM25.21,24l-3.43-1.16a8.86,8.86,0,0,1-2.71,1.57L18.36,28H13.64l-.71-3.55a9.36,9.36,0,0,1-2.7-1.57L6.79,24,4.43,20l2.72-2.4a8.9,8.9,0,0,1,0-3.13L4.43,12,6.79,8l3.43,1.16a8.86,8.86,0,0,1,2.71-1.57L13.64,4h4.72l.71,3.55a9.36,9.36,0,0,1,2.7,1.57L25.21,8,27.57,12l-2.72,2.4a8.9,8.9,0,0,1,0,3.13L27.57,20Z" />
+111 -1
View File
@@ -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;
+92 -2
View File
@@ -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(<SnippetLibrary />);
});
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(<SnippetLibrary />);
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(<SnippetLibrary />);
});
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(<SnippetLibrary />);
});
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(<SnippetLibrary />);
});
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');
+90 -10
View File
@@ -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<HTMLInputElement>(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 (
<div className={styles.library}>
{/* 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. */}
<div className={styles.toolbar}>
<span className={styles.heading}>Snippets</span>
<LibrarySettings />
@@ -234,16 +256,70 @@ export function SnippetLibrary() {
<Icon name="add" /> Create New Snippet
</button>
{/* 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. */}
<div className={styles.controls}>
<div className={styles.search}>
<Icon name="search" className={styles.searchIcon} />
<input
ref={searchRef}
type="search"
className={styles.searchInput}
// No visible label — the magnifier + placeholder name it (council SEARCH).
aria-label="Search snippets"
placeholder="Search snippets"
value={searchQuery}
onChange={(e) => setSearch(e.target.value)}
spellCheck={false}
/>
{searching && (
<button
type="button"
className={styles.searchClear}
aria-label="Clear search"
title="Clear search"
onClick={clearSearch}
>
<Icon name="close" />
</button>
)}
</div>
<SortControl />
</div>
{/* 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. */}
<div className={styles.srOnly} role="status" aria-live="polite">
{searching ? `${ordered.length} ${ordered.length === 1 ? 'result' : 'results'}` : ''}
</div>
<ul className={styles.list}>
{ordered.length === 0 && (
{noMatches && searching && (
// Empty state (b): a search that matched nothing (council EMPTY STATES).
<li className={styles.empty}>
No snippets yet create your first one with the button above.
<span className={styles.emptyTitle}>No snippets match your search</span>
<span className={styles.emptyHint}>Try a different term.</span>
</li>
)}
{noMatches && !searching && (
// Empty state (a): a genuinely empty library — guide to Create.
<li className={styles.empty}>
<span className={styles.emptyTitle}>No snippets yet</span>
<span className={styles.emptyHint}>Create your first one with the button above.</span>
</li>
)}
{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 (
<li key={s.id} className={`${styles.item} ${s.id === activeId ? styles.active : ''}`}>
{/* The row's selectable area is a real <button> so it's keyboard
@@ -309,6 +385,10 @@ export function SnippetLibrary() {
onDelete={() => void handleDelete(activeSnippet.id, activeSnippet.name)}
/>
)}
{/* Storage Monitor (spec §02 → Storage Monitor) — pinned at the bottom of
the library pane, below the metadata panel. Built by a parallel agent. */}
<StorageMonitor />
</div>
);
}
+96
View File
@@ -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 <body>, 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;
}
+166
View File
@@ -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 <body> 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<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement | null>(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<HTMLElement>('[aria-checked="true"], button')?.focus();
}
},
[place],
);
return (
<div className={styles.wrap}>
<button
ref={triggerRef}
type="button"
className={styles.trigger}
aria-expanded={open}
aria-controls={ID}
// Trigger shows current state for recognition (council SORT, NN/g #6).
title={`Sort by ${labelFor(sortBy)}, ${sortOrder === 'desc' ? 'descending' : 'ascending'}`}
onClick={() => toggle(ID)}
>
Sort: {labelFor(sortBy)} <span aria-hidden="true">{arrow}</span>
</button>
{open &&
createPortal(
<div
ref={setPopNode}
id={ID}
className={styles.pop}
role="group"
aria-label="Sort snippets"
>
<h4 className={styles.title}>Sort by</h4>
{FIELDS.map((f) => {
const active = f.value === sortBy;
return (
<button
key={f.value}
type="button"
className={`${styles.field} ${active ? styles.active : ''}`}
// The active field carries the direction in its accessible name
// so AT hears "Modified, descending", not just "Modified".
aria-label={
active
? `${f.label}, ${sortOrder === 'desc' ? 'descending' : 'ascending'}`
: f.label
}
onClick={() => setSort(f.value)}
>
<span>{f.label}</span>
{active && <span aria-hidden="true">{arrow}</span>}
</button>
);
})}
</div>,
document.body,
)}
</div>
);
}
@@ -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;
}
}
+199
View File
@@ -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<StorageSummary> &
Pick<StorageSummary, 'usedBytes' | 'quotaBytes' | 'fraction' | 'level'>,
): 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(<StorageMonitor />);
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();
});
});
});
+93
View File
@@ -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<StorageSummary['level'], string> = {
ok: '',
warning: 'Storage is getting full.',
critical: 'Storage is almost full. Delete snippets to free space.',
};
export default function StorageMonitor() {
const [summary, setSummary] = useState<StorageSummary | null>(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 (
<div className={`${styles.monitor} ${styles[summary.level]}`} aria-label="Storage usage">
{/* Usage text */}
<div className={styles.label}>
<span className={styles.used}>{usedText}</span>
<span className={styles.separator}> of </span>
<span className={styles.quota}>{quotaText}</span>
</div>
{/*
* 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.
*/}
<div
role="meter"
aria-label="Storage used"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
aria-valuetext={`${usedText} of ${quotaText} (${pct}%)`}
className={styles.track}
>
<div className={styles.fill} style={{ width: `${pct}%` }} />
</div>
{/*
* 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 && (
<p role="status" aria-live="polite" className={styles.announcement}>
{announcement}
</p>
)}
</div>
);
}
+32
View File
@@ -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' });
});
});
+41 -1
View File
@@ -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<SnippetSortPref>;
[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 } });
}
+32
View File
@@ -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 });
});
}
+52
View File
@@ -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');
});
});
+48
View File
@@ -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<SnippetState>((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<SnippetState>((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<SnippetState>((set, get) => ({
draftText: '',
editorView: 'draft',
bufferEpoch: 0,
searchQuery: '',
sortBy: DEFAULT_SORT_BY,
sortOrder: DEFAULT_SORT_ORDER,
}),
}));
+158
View File
@@ -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<Snippet> & { 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(
[],
);
});
});
+113
View File
@@ -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);
}
+6
View File
@@ -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).