mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Redesign the Storage Monitor as a storage-composition breakdown (snippets · datasets · app)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,89 +1,67 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
CRITICAL_THRESHOLD,
|
||||
WARNING_THRESHOLD,
|
||||
humanizeBytes,
|
||||
summarizeStorage,
|
||||
} from './storage-estimate';
|
||||
import { humanizeBytes, jsonByteSize, summarizeStorage } from './storage-estimate';
|
||||
|
||||
describe('summarizeStorage', () => {
|
||||
test('computes the fraction and passes usage/quota through', () => {
|
||||
const s = summarizeStorage({ usage: 250, quota: 1000 });
|
||||
expect(s.available).toBe(true);
|
||||
expect(s.usedBytes).toBe(250);
|
||||
expect(s.quotaBytes).toBe(1000);
|
||||
expect(s.fraction).toBeCloseTo(0.25);
|
||||
expect(s.level).toBe('ok');
|
||||
describe('summarizeStorage (composition)', () => {
|
||||
test('with origin usage: app = usage − snippets − datasets, total = usage', () => {
|
||||
const c = summarizeStorage({ usageBytes: 1000, snippetBytes: 100, datasetBytes: 300 });
|
||||
expect(c.originMeasured).toBe(true);
|
||||
expect(c.totalBytes).toBe(1000);
|
||||
expect(c.segments).toEqual([
|
||||
{ key: 'snippets', label: 'Snippets', bytes: 100 },
|
||||
{ key: 'datasets', label: 'Datasets', bytes: 300 },
|
||||
{ key: 'app', label: 'App', bytes: 600 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('clamps fraction to 1 when usage exceeds quota', () => {
|
||||
const s = summarizeStorage({ usage: 1500, quota: 1000 });
|
||||
expect(s.fraction).toBe(1);
|
||||
expect(s.level).toBe('critical');
|
||||
test('without origin usage: snippets + datasets only, total is their sum', () => {
|
||||
const c = summarizeStorage({ snippetBytes: 100, datasetBytes: 300 });
|
||||
expect(c.originMeasured).toBe(false);
|
||||
expect(c.totalBytes).toBe(400);
|
||||
expect(c.segments.map((s) => s.key)).toEqual(['snippets', 'datasets']);
|
||||
});
|
||||
|
||||
test('guards divide-by-zero: quota of 0 is unusable', () => {
|
||||
const s = summarizeStorage({ usage: 10, quota: 0 });
|
||||
expect(s.available).toBe(false);
|
||||
expect(s.fraction).toBe(0);
|
||||
expect(s.usedBytes).toBe(0);
|
||||
expect(s.quotaBytes).toBe(0);
|
||||
expect(s.level).toBe('ok');
|
||||
test('usage smaller than measured data falls back to snippets+datasets (estimate lag)', () => {
|
||||
const c = summarizeStorage({ usageBytes: 50, snippetBytes: 100, datasetBytes: 300 });
|
||||
expect(c.originMeasured).toBe(false);
|
||||
expect(c.totalBytes).toBe(400);
|
||||
expect(c.segments).toHaveLength(2);
|
||||
});
|
||||
|
||||
describe('level boundaries', () => {
|
||||
test('just below warning (0.79) is ok', () => {
|
||||
expect(summarizeStorage({ usage: 79, quota: 100 }).level).toBe('ok');
|
||||
});
|
||||
|
||||
test('exactly at warning (0.80) is warning', () => {
|
||||
const s = summarizeStorage({ usage: 80, quota: 100 });
|
||||
expect(s.fraction).toBeCloseTo(WARNING_THRESHOLD);
|
||||
expect(s.level).toBe('warning');
|
||||
});
|
||||
|
||||
test('just below critical (0.94) is warning', () => {
|
||||
expect(summarizeStorage({ usage: 94, quota: 100 }).level).toBe('warning');
|
||||
});
|
||||
|
||||
test('exactly at critical (0.95) is critical', () => {
|
||||
const s = summarizeStorage({ usage: 95, quota: 100 });
|
||||
expect(s.fraction).toBeCloseTo(CRITICAL_THRESHOLD);
|
||||
expect(s.level).toBe('critical');
|
||||
});
|
||||
test('usage exactly equal to measured data yields a zero-byte app segment', () => {
|
||||
const c = summarizeStorage({ usageBytes: 400, snippetBytes: 100, datasetBytes: 300 });
|
||||
expect(c.originMeasured).toBe(true);
|
||||
expect(c.segments.find((s) => s.key === 'app')?.bytes).toBe(0);
|
||||
});
|
||||
|
||||
describe('availability', () => {
|
||||
test('missing usage -> unavailable', () => {
|
||||
const s = summarizeStorage({ quota: 1000 });
|
||||
expect(s.available).toBe(false);
|
||||
expect(s.fraction).toBe(0);
|
||||
});
|
||||
test('invalid / negative / non-finite inputs degrade to 0 bytes', () => {
|
||||
const c = summarizeStorage({ usageBytes: NaN, snippetBytes: -5, datasetBytes: Infinity });
|
||||
expect(c.segments[0].bytes).toBe(0); // snippets
|
||||
expect(c.segments[1].bytes).toBe(0); // datasets
|
||||
expect(c.originMeasured).toBe(false); // usage NaN → unusable
|
||||
expect(c.totalBytes).toBe(0);
|
||||
});
|
||||
|
||||
test('missing quota -> unavailable', () => {
|
||||
const s = summarizeStorage({ usage: 1000 });
|
||||
expect(s.available).toBe(false);
|
||||
expect(s.fraction).toBe(0);
|
||||
});
|
||||
test('empty workspace: every segment is zero, total zero', () => {
|
||||
const c = summarizeStorage({ snippetBytes: 0, datasetBytes: 0 });
|
||||
expect(c.totalBytes).toBe(0);
|
||||
expect(c.segments.every((s) => s.bytes === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('both missing (empty input) -> unavailable', () => {
|
||||
const s = summarizeStorage({});
|
||||
expect(s.available).toBe(false);
|
||||
});
|
||||
describe('jsonByteSize', () => {
|
||||
test('measures the UTF-8 byte length of the JSON serialization', () => {
|
||||
expect(jsonByteSize({ a: 1 })).toBe(new TextEncoder().encode('{"a":1}').length);
|
||||
});
|
||||
|
||||
test('non-finite or negative values -> unavailable', () => {
|
||||
expect(summarizeStorage({ usage: NaN, quota: 1000 }).available).toBe(false);
|
||||
expect(summarizeStorage({ usage: Infinity, quota: 1000 }).available).toBe(false);
|
||||
expect(summarizeStorage({ usage: -1, quota: 1000 }).available).toBe(false);
|
||||
expect(summarizeStorage({ usage: 10, quota: -5 }).available).toBe(false);
|
||||
});
|
||||
test('counts multi-byte characters by their UTF-8 size', () => {
|
||||
// "é" is 2 UTF-8 bytes; JSON.stringify("é") => the 4-byte string «"é"».
|
||||
expect(jsonByteSize('é')).toBe(4);
|
||||
});
|
||||
|
||||
test('zero usage with a real quota is available and ok', () => {
|
||||
const s = summarizeStorage({ usage: 0, quota: 1000 });
|
||||
expect(s.available).toBe(true);
|
||||
expect(s.fraction).toBe(0);
|
||||
expect(s.level).toBe('ok');
|
||||
});
|
||||
test('an unserializable (circular) value degrades to 0', () => {
|
||||
const a: Record<string, unknown> = {};
|
||||
a.self = a;
|
||||
expect(jsonByteSize(a)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,89 +1,104 @@
|
||||
/**
|
||||
* Storage estimate summarization (spec §02 → Storage Monitor; spec §10 → "Warn
|
||||
* before storage failure"; docs/architecture/02 §6).
|
||||
* Storage composition summarization (spec §02 → Storage Monitor; docs/architecture/02 §6).
|
||||
*
|
||||
* Portable core: no browser APIs, no React. The browser's Storage Manager API
|
||||
* (`navigator.storage.estimate()`) yields a raw `{ usage, quota }` pair in
|
||||
* bytes — both optional, since a browser may omit them. This module turns that
|
||||
* raw pair into presentation-ready facts: a clamped fraction, an escalating
|
||||
* level, and an availability flag. The infrastructure adapter
|
||||
* (`src/app/infrastructure/storage-estimate.ts`) feature-detects the API and
|
||||
* feeds its output through here; all the math lives in this pure function so it
|
||||
* is trivially testable.
|
||||
* Portable core: no browser APIs, no React. The Storage Monitor is **not** a "used
|
||||
* of quota" gauge — the browser's `quota` from `navigator.storage.estimate()` is a
|
||||
* padded, browser-decided approximation, not a real free-space figure, so a budget
|
||||
* fraction would be false precision (web.dev → storage-for-the-web). Instead we show
|
||||
* what storage is *made of*: Snippets, Datasets, and App (everything else — the
|
||||
* precached app shell + IndexedDB overhead). This module turns three measured byte
|
||||
* counts into ordered segments; the math lives here so it is trivially testable.
|
||||
*
|
||||
* Thresholds: warning at >= 0.8 is the documented value (arch 02 §6 `WARN_AT`).
|
||||
* Critical at >= 0.95 is not specified by spec/arch; it is this module's default
|
||||
* for the second escalation step ("nearly full") and is the single source of
|
||||
* truth here.
|
||||
* The only reliable figure from the estimate is `usage` (bytes actually stored for
|
||||
* the whole origin). Snippet and dataset bytes we measure ourselves, so
|
||||
* `App = usage − snippets − datasets`. When the estimate is missing we still show
|
||||
* snippets + datasets from our own data.
|
||||
*/
|
||||
|
||||
/** Raw input shape, mirroring the browser's `StorageEstimate` (bytes, both optional). */
|
||||
export interface StorageEstimateInput {
|
||||
/** Bytes currently used, or undefined when the browser omits it. */
|
||||
usage?: number;
|
||||
/** Total bytes available (quota), or undefined when the browser omits it. */
|
||||
quota?: number;
|
||||
/** A category of stored data and its measured size in bytes. */
|
||||
export interface StorageSegment {
|
||||
key: 'snippets' | 'datasets' | 'app';
|
||||
/** User-facing label, centralized here so the component stays presentational. */
|
||||
label: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
/** Escalating fullness level driving the storage monitor's warning copy/colour. */
|
||||
export type StorageLevel = 'ok' | 'warning' | 'critical';
|
||||
|
||||
/** Presentation-ready summary derived from a raw estimate. */
|
||||
export interface StorageSummary {
|
||||
/** Bytes used (0 when unusable). */
|
||||
usedBytes: number;
|
||||
/** Quota in bytes (0 when unusable). */
|
||||
quotaBytes: number;
|
||||
/** usedBytes / quotaBytes, clamped to 0..1; 0 when quota is 0/undefined. */
|
||||
fraction: number;
|
||||
/** Escalating fullness level derived from `fraction`. */
|
||||
level: StorageLevel;
|
||||
/** False when usage/quota are missing or non-finite — the estimate is unusable. */
|
||||
available: boolean;
|
||||
/** Measured inputs: snippet/dataset bytes (always known) + origin usage (when the API is present). */
|
||||
export interface StorageInput {
|
||||
/** Whole-origin bytes from `navigator.storage.estimate().usage`, if available. */
|
||||
usageBytes?: number;
|
||||
/** Summed serialized size of all snippets. */
|
||||
snippetBytes: number;
|
||||
/** Summed size of all datasets (each `Dataset` carries its byte `size`). */
|
||||
datasetBytes: number;
|
||||
}
|
||||
|
||||
/** Fraction at which the monitor begins warning (arch 02 §6 `WARN_AT`). */
|
||||
export const WARNING_THRESHOLD = 0.8;
|
||||
/** Fraction at which the monitor escalates to critical ("nearly full"). */
|
||||
export const CRITICAL_THRESHOLD = 0.95;
|
||||
/** Presentation-ready storage composition. */
|
||||
export interface StorageComposition {
|
||||
/** Ordered segments to display: Snippets, Datasets, then App when measurable. */
|
||||
segments: StorageSegment[];
|
||||
/** Sum of the segments shown — origin `usage` when measured, else snippets+datasets. */
|
||||
totalBytes: number;
|
||||
/**
|
||||
* True when the origin estimate was available, so the `app` segment (the precached
|
||||
* shell + overhead) is meaningful and `totalBytes` is whole-origin usage. False when
|
||||
* the Storage Manager API is absent — we still show snippets + datasets, and
|
||||
* `totalBytes` is just their sum.
|
||||
*/
|
||||
originMeasured: boolean;
|
||||
}
|
||||
|
||||
/** A non-finite or negative number is not a usable byte count. */
|
||||
/**
|
||||
* The monitor stays hidden until the user's own data — snippets + datasets — reaches
|
||||
* this size (spec §02). Below it storage is noise (nothing to manage), and the bar
|
||||
* would be dominated by the immovable precached app shell anyway. Deliberately keyed
|
||||
* off *user* bytes, not total: the ~precache baseline is roughly constant, so gating
|
||||
* on total would make the monitor always-visible and the breakdown unbalanced.
|
||||
*/
|
||||
export const STORAGE_MONITOR_MIN_USER_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
/** A finite, non-negative byte count, or null when the value is unusable. */
|
||||
function usableBytes(n: number | undefined): number | null {
|
||||
if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Map a clamped fraction to its escalation level. */
|
||||
function levelFor(fraction: number): StorageLevel {
|
||||
if (fraction >= CRITICAL_THRESHOLD) return 'critical';
|
||||
if (fraction >= WARNING_THRESHOLD) return 'warning';
|
||||
return 'ok';
|
||||
/** A finite, non-negative byte count; junk degrades to 0. */
|
||||
function nonNeg(n: number): number {
|
||||
return usableBytes(n) ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a raw storage estimate into a presentation-ready summary. Pure: no I/O.
|
||||
* When usage is missing/invalid, or quota is missing/invalid/zero, the estimate
|
||||
* is unusable — `available: false`, everything zeroed, level `ok` (we don't warn
|
||||
* on data we don't have). Otherwise the fraction is `usage / quota` clamped to
|
||||
* 0..1 and the level escalates at the thresholds above.
|
||||
* Decompose measured storage into ordered segments. The `app` segment (everything
|
||||
* beyond our snippets+datasets) is included only when the origin `usage` is available
|
||||
* and at least our measured data — a smaller estimate is the browser's approximation
|
||||
* lagging, not real, so we fall back to a snippets+datasets-only view.
|
||||
*/
|
||||
export function summarizeStorage(input: StorageEstimateInput): StorageSummary {
|
||||
const usage = usableBytes(input.usage);
|
||||
const quota = usableBytes(input.quota);
|
||||
export function summarizeStorage(input: StorageInput): StorageComposition {
|
||||
const snippets = nonNeg(input.snippetBytes);
|
||||
const datasets = nonNeg(input.datasetBytes);
|
||||
const usage = usableBytes(input.usageBytes);
|
||||
const known = snippets + datasets;
|
||||
|
||||
// Quota of 0 can't yield a meaningful fraction; treat as unusable.
|
||||
if (usage === null || quota === null || quota === 0) {
|
||||
return { usedBytes: 0, quotaBytes: 0, fraction: 0, level: 'ok', available: false };
|
||||
const segments: StorageSegment[] = [
|
||||
{ key: 'snippets', label: 'Snippets', bytes: snippets },
|
||||
{ key: 'datasets', label: 'Datasets', bytes: datasets },
|
||||
];
|
||||
|
||||
if (usage !== null && usage >= known) {
|
||||
segments.push({ key: 'app', label: 'App', bytes: usage - known });
|
||||
return { segments, totalBytes: usage, originMeasured: true };
|
||||
}
|
||||
return { segments, totalBytes: known, originMeasured: false };
|
||||
}
|
||||
|
||||
const fraction = Math.min(1, usage / quota);
|
||||
return {
|
||||
usedBytes: usage,
|
||||
quotaBytes: quota,
|
||||
fraction,
|
||||
level: levelFor(fraction),
|
||||
available: true,
|
||||
};
|
||||
/** UTF-8 byte length of a value's JSON serialization (0 when it can't be serialized). */
|
||||
export function jsonByteSize(value: unknown): number {
|
||||
try {
|
||||
return new TextEncoder().encode(JSON.stringify(value) ?? '').length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;
|
||||
|
||||
Reference in New Issue
Block a user