Redesign the Storage Monitor as a storage-composition breakdown (snippets · datasets · app)

This commit is contained in:
2026-06-10 01:32:22 +03:00
parent e7de0cbb8a
commit 7599acd25e
12 changed files with 438 additions and 481 deletions
+5 -6
View File
@@ -5,12 +5,11 @@ 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 }),
}));
// StorageMonitor (rendered at the bottom of the pane) measures storage and fetches
// an async estimate on mount; stub the whole component to nothing so its async
// setState doesn't fire outside act() and its legend text doesn't collide with
// library assertions. This suite is about the library, not the monitor.
vi.mock('./StorageMonitor', () => ({ default: () => null }));
// 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;
+57 -58
View File
@@ -1,86 +1,85 @@
/*
* 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.
* A proportional composition bar + a labelled legend; the legend carries the
* meaning so segments never rely on hue alone (WCAG 1.4.1).
*/
.monitor {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: var(--space-2);
gap: var(--space-3);
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. */
/* Stacked composition bar (decorative; the legend is the data). */
.track {
display: flex;
width: 100%;
height: 4px;
height: 8px;
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 {
.segment {
height: 100%;
background: var(--fill-color);
transition: width var(--dur-moderate) var(--ease);
/* 1px separator drawn inside the right edge — distinguishes adjacent segments
without adding layout width, so the percentage widths stay exact. */
box-shadow: inset -1px 0 0 var(--bg);
}
/* The polite live-region announcement (warning / critical copy). Visually
muted; assistive tech reads it because of role="status" + aria-live="polite". */
.announcement {
/* Segment + swatch colours. Snippets = accent (the primary user data); datasets =
a distinct hue; app = a muted neutral (it's overhead, visually de-emphasised). */
.segSnippets {
background: var(--accent);
}
.segDatasets {
background: var(--support-info);
}
.segApp {
background: var(--border-strong);
}
/* Legend — real text, the accessible source of truth. */
.legend {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: var(--space-2) var(--space-4);
font-size: 11px;
color: var(--fill-color);
line-height: 1.4;
color: var(--text-secondary);
}
/* Suppress the fill bar transition for users who prefer reduced motion (arch 09 §3.5). */
@media (prefers-reduced-motion: reduce) {
.fill {
transition: none;
}
.legendItem {
display: flex;
align-items: center;
gap: var(--space-2);
white-space: nowrap;
}
.swatch {
width: 8px;
height: 8px;
border-radius: 2px;
flex: 0 0 auto;
}
.legendLabel {
color: var(--text-secondary);
}
.legendBytes {
color: var(--text);
font-variant-numeric: tabular-nums;
}
.total {
font-size: 11px;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
+76 -141
View File
@@ -1,40 +1,49 @@
/**
* 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.
* Strategy: seed the snippet + dataset stores (the measured inputs) and mock
* readOriginUsage (the async browser adapter) so tests run in happy-dom without a
* real Storage Manager, then assert on the rendered DOM — legend text, the
* decorative bar, and the absence of a meter/threshold-warning (the redesign).
*
* 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.
* CSS Modules are identity-mapped under Vitest, so class tokens come through as-is.
*/
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 type { Snippet } from '@core/snippet';
import type { Dataset } from '@core/dataset';
import StorageMonitor from './StorageMonitor';
import { useSnippetStore } from '../stores/SnippetStore';
import { useDatasetStore } from '../stores/DatasetStore';
// Mock the browser adapter — tests must not touch navigator.storage.
vi.mock('../infrastructure/storage-estimate', () => ({
readStorageEstimate: vi.fn(),
readOriginUsage: vi.fn(),
}));
import { readStorageEstimate } from '../infrastructure/storage-estimate';
const mockEstimate = vi.mocked(readStorageEstimate);
import { readOriginUsage } from '../infrastructure/storage-estimate';
const mockUsage = vi.mocked(readOriginUsage);
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
// ---- helpers ----------------------------------------------------------------
// ---- fixtures ---------------------------------------------------------------
/** Build a complete StorageSummary for test fixtures. */
function makeSummary(
overrides: Partial<StorageSummary> &
Pick<StorageSummary, 'usedBytes' | 'quotaBytes' | 'fraction' | 'level'>,
): StorageSummary {
return {
available: true,
...overrides,
};
/** A snippet is just serialized for its byte size, so a minimal shape suffices. */
function seedSnippets(count: number): void {
const snippets = Array.from(
{ length: count },
(_, i) => ({ id: `s${i}`, name: `snippet ${i}`, draftSpec: '{"x":1}' }) as unknown as Snippet,
);
useSnippetStore.setState({ snippets });
}
/** Datasets carry their own byte `size`; only that field is read. */
function seedDatasets(...sizes: number[]): void {
const datasets = sizes.map(
(size, i) => ({ id: i + 1, name: `data ${i}`, size }) as unknown as Dataset,
);
useDatasetStore.setState({ datasets });
}
// ---- test setup -------------------------------------------------------------
@@ -51,149 +60,75 @@ beforeEach(() => {
afterEach(() => {
act(() => root.unmount());
container.remove();
useSnippetStore.setState({ snippets: [] });
useDatasetStore.setState({ datasets: [] });
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
await Promise.resolve(); // flush the async readOriginUsage() 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',
});
const MB = 1024 * 1024;
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 ');
});
describe('StorageMonitor (composition)', () => {
test('shows Snippets, Datasets and App segments once past the user-data floor', async () => {
seedSnippets(2);
seedDatasets(12 * MB); // 12 MB of datasets — over the 10 MB floor
mockUsage.mockResolvedValue(20 * MB);
await renderMonitor();
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();
});
const root_ = container.querySelector('[aria-label="Storage in use"]');
expect(root_).not.toBeNull();
const legend = root_!.textContent ?? '';
expect(legend).toContain('Snippets');
expect(legend).toContain('Datasets');
expect(legend).toContain('App'); // usage snippets datasets
expect(legend).toContain('in use');
});
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('is not a meter and shows no threshold warning (redesign)', async () => {
seedSnippets(1);
seedDatasets(11 * MB);
mockUsage.mockResolvedValue(20 * MB);
await renderMonitor();
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');
});
expect(container.querySelector('[role="meter"]')).toBeNull();
expect(container.querySelector('[role="status"]')).toBeNull();
// The bar itself is decorative.
expect(container.querySelector('[aria-hidden="true"]')).not.toBeNull();
});
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('omits the App segment when the Storage Manager API is unavailable', async () => {
seedSnippets(1);
seedDatasets(11 * MB);
mockUsage.mockResolvedValue(undefined); // API absent
await renderMonitor();
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%');
});
const text = container.textContent ?? '';
expect(text).toContain('Snippets');
expect(text).toContain('Datasets');
expect(text).not.toContain('App');
});
describe('graceful unavailable case', () => {
const unavailableSummary: StorageSummary = {
available: false,
usedBytes: 0,
quotaBytes: 0,
fraction: 0,
level: 'ok',
};
test('stays hidden below the user-data floor, even with origin usage', async () => {
seedSnippets(3);
seedDatasets(2 * MB); // ~2 MB of user data — under the 10 MB floor
mockUsage.mockResolvedValue(9 * MB);
await renderMonitor();
expect(container.firstElementChild).toBeNull();
});
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();
});
test('renders nothing for a truly empty workspace', async () => {
seedSnippets(0);
seedDatasets();
mockUsage.mockResolvedValue(undefined);
await renderMonitor();
expect(container.firstElementChild).toBeNull();
});
});
+79 -65
View File
@@ -1,93 +1,107 @@
/**
* Storage Monitor — library pane footer (spec §02 → Storage Monitor;
* spec §10 → "Warn before storage failure"; docs/architecture/10 §1 status
* indicator channel).
* docs/architecture/10 → "Resolved — storage composition indicator").
*
* 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
* Shows what local storage is *made of* — Snippets, Datasets, and App (the
* precached shell + overhead) — as a proportional bar plus a labelled legend.
* It is **not** a "used of quota" gauge: the browser's `quota` is an unreliable
* padded approximation, so we show real measured sizes instead and let the genuine
* out-of-room moment surface where it happens (a save-failure toast).
*
* 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.
* Accessibility (council → APG meter / NN/g / WCAG 1.4.1): the bar is **decorative**
* (`aria-hidden`) and carries no role="meter" — a meter needs a meaningful maximum,
* which we don't have. The **legend is the source of truth**: real text labels +
* sizes that assistive tech reads, so meaning never rests on colour alone.
*
* 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).
* Snippet/dataset bytes are measured from our own stores (always available); the
* App segment needs the origin estimate, so it appears only when that resolves.
*/
import { useEffect, useState } from 'react';
import { humanizeBytes, type StorageSummary } from '@core/storage-estimate';
import { readStorageEstimate } from '../infrastructure/storage-estimate';
import { useEffect, useMemo, useState } from 'react';
import {
humanizeBytes,
jsonByteSize,
STORAGE_MONITOR_MIN_USER_BYTES,
summarizeStorage,
} from '@core/storage-estimate';
import { readOriginUsage } from '../infrastructure/storage-estimate';
import { useSnippetStore } from '../stores/SnippetStore';
import { useDatasetStore } from '../stores/DatasetStore';
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.',
};
/** Segment key → its colour class (shared by the bar slice and the legend swatch). */
const SEGMENT_CLASS = {
snippets: styles.segSnippets,
datasets: styles.segDatasets,
app: styles.segApp,
} as const;
export default function StorageMonitor() {
const [summary, setSummary] = useState<StorageSummary | null>(null);
const snippets = useSnippetStore((s) => s.snippets);
const datasets = useDatasetStore((s) => s.datasets);
// Measure our own data synchronously. Snippets are serialized; datasets already
// carry a byte `size`. Memoized so we only re-measure when the lists change.
const snippetBytes = useMemo(
() => snippets.reduce((n, snip) => n + jsonByteSize(snip), 0),
[snippets],
);
const datasetBytes = useMemo(() => datasets.reduce((n, d) => n + (d.size ?? 0), 0), [datasets]);
// Stay hidden until the user's own data is worth a glance — below the floor it's
// ambient noise, and the app shell would dominate the bar anyway (spec §02).
const belowFloor = snippetBytes + datasetBytes < STORAGE_MONITOR_MIN_USER_BYTES;
// The origin total is async + optional. Re-read after our data changes, since a
// save moves usage; undefined when the Storage Manager API is unavailable.
const [usageBytes, setUsageBytes] = useState<number | undefined>(undefined);
useEffect(() => {
let cancelled = false;
void readStorageEstimate().then((s) => {
if (!cancelled) setSummary(s);
void readOriginUsage().then((u) => {
if (!cancelled) setUsageBytes(u);
});
return () => {
cancelled = true;
};
}, []);
}, [snippetBytes, datasetBytes]);
// While loading, or when the API is unavailable, render nothing.
if (!summary || !summary.available) return null;
const composition = useMemo(
() => summarizeStorage({ usageBytes, snippetBytes, datasetBytes }),
[usageBytes, snippetBytes, datasetBytes],
);
const usedText = humanizeBytes(summary.usedBytes);
const quotaText = humanizeBytes(summary.quotaBytes);
const pct = Math.round(summary.fraction * 100);
const announcement = LEVEL_LABEL[summary.level];
// Hidden below the user-data floor, or with nothing measured yet (truly empty).
if (belowFloor || composition.totalBytes <= 0) return null;
const { segments, totalBytes } = composition;
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 className={styles.monitor} aria-label="Storage in use">
{/* Decorative composition bar — the legend below is the accessible source. */}
<div className={styles.track} aria-hidden="true">
{segments.map((s) =>
s.bytes > 0 ? (
<div
key={s.key}
className={`${styles.segment} ${SEGMENT_CLASS[s.key]}`}
style={{ width: `${(s.bytes / totalBytes) * 100}%` }}
/>
) : null,
)}
</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>
{/* Legend: real text labels + sizes — meaning never rests on colour (WCAG 1.4.1). */}
<ul className={styles.legend}>
{segments.map((s) => (
<li key={s.key} className={styles.legendItem}>
<span className={`${styles.swatch} ${SEGMENT_CLASS[s.key]}`} aria-hidden="true" />
<span className={styles.legendLabel}>{s.label}</span>
<span className={styles.legendBytes}>{humanizeBytes(s.bytes)}</span>
</li>
))}
</ul>
{/*
* 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 className={styles.total}>{humanizeBytes(totalBytes)} in use</div>
</div>
);
}
+13 -30
View File
@@ -1,39 +1,22 @@
/**
* Storage estimate adapter (spec §02 → Storage Monitor; docs/architecture/02 §6).
* Storage origin-usage adapter (spec §02 → Storage Monitor; docs/architecture/02 §6).
*
* This is the ONLY module that touches the browser's Storage Manager API
* (`navigator.storage.estimate()`). It feature-detects the API, then defers all
* presentation math to the portable core (`src/core/storage-estimate.ts`). When
* the API is unavailable — older browsers, insecure contexts — it returns an
* unavailable summary rather than throwing, so callers get a total function.
* The ONLY module that touches the Storage Manager API. We read `usage` — the bytes
* actually stored for the origin, which is reliable — and deliberately ignore
* `quota`, a padded, browser-decided approximation that is not a real free-space
* figure (web.dev → storage-for-the-web). Snippet/dataset bytes are measured in the
* component from our own stores; this just supplies the whole-origin total so the
* "App" remainder can be derived. Returns undefined when the API is absent or the
* call rejects; never throws.
*/
import { summarizeStorage, type StorageSummary } from '../../core/storage-estimate';
/** The unavailable summary, returned when the Storage Manager API is absent. */
const UNAVAILABLE: StorageSummary = {
usedBytes: 0,
quotaBytes: 0,
fraction: 0,
level: 'ok',
available: false,
};
/**
* Read the browser's storage estimate and summarize it. Returns an unavailable
* summary when `navigator.storage.estimate` is missing or the call rejects;
* never throws. All math lives in `summarizeStorage`.
*/
export async function readStorageEstimate(): Promise<StorageSummary> {
export async function readOriginUsage(): Promise<number | undefined> {
const storage = typeof navigator !== 'undefined' ? navigator.storage : undefined;
if (!storage || typeof storage.estimate !== 'function') {
return UNAVAILABLE;
}
if (!storage || typeof storage.estimate !== 'function') return undefined;
try {
const { usage, quota } = await storage.estimate();
return summarizeStorage({ usage, quota });
const { usage } = await storage.estimate();
return typeof usage === 'number' ? usage : undefined;
} catch {
return UNAVAILABLE;
return undefined;
}
}