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:
+16
-10
@@ -173,7 +173,7 @@ and the companion `visual-specimen.html`.
|
||||
app (chrome + Monaco + chart all repaint on theme flip); focus ring visible.
|
||||
Notes from the build-out: the placeholder `'experimental'` theme was renamed to
|
||||
`'dark'` (the settled name); the swappable `[data-accent]` layer landed with
|
||||
indigo as the robust default (no switcher UI until M5); Monaco's `fontFamily` is
|
||||
deep teal as the robust default (no switcher UI until M5); Monaco's `fontFamily` is
|
||||
set to Plex Mono explicitly since it can't read the CSS token.
|
||||
|
||||
---
|
||||
@@ -451,14 +451,18 @@ through `services/transfer.ts` (→ `normalizeImport` / envelope build), no moda
|
||||
- **A11y:** modal focus trap + return, labelled icon buttons, contrast in both themes (§10) — ✅ in place.
|
||||
- **About & Privacy** and **Donate** modals ~~(§01)~~ ✅ (Donate URL is a placeholder
|
||||
`DONATE_URL` pending the real link).
|
||||
- **Offline/installable:** verify the SW + manifest give a working offline + installed app.
|
||||
**⏳ Remaining** — needs manual verification in a running/installed app.
|
||||
- **Offline/installable:** the manifest now ships a full SVG icon set (favicon / maskable /
|
||||
monochrome) + `theme_color`, and the SW precaches the shell — the app is **installable**.
|
||||
**⏳ Remaining** — manual verification in a running/installed app (checklist:
|
||||
[manual-verification.md](manual-verification.md)); iOS home-screen still wants a PNG
|
||||
`apple-touch-icon` (logged residual).
|
||||
- **Council** — ~~seat **web.dev** for the PWA/offline/storage surfaces none of the seated
|
||||
members cover: the service-worker **update-available** prompt (`registerType: 'prompt'`),
|
||||
storage **persistence** (`navigator.storage.persist()`), and the quota **estimate**
|
||||
(`StorageManager.estimate()`)~~ ✅ seated + backfilled (update-prompt toast + `persist()`
|
||||
request; estimate already wired). **⏳ Remaining gap:** manifest ships no icons → not yet
|
||||
installable (design-asset task). See [`/council`](../.claude/skills/council/SKILL.md) and
|
||||
request; estimate already wired). ~~**⏳ Remaining gap:** manifest ships no icons → not yet
|
||||
installable~~ ✅ SVG icon set added + wired (favicon / maskable / monochrome). See
|
||||
[`/council`](../.claude/skills/council/SKILL.md) and
|
||||
[arch 10](architecture/10-interaction-and-feedback.md).
|
||||
|
||||
**Manual checks:** keyboard-only run-through; reload restores view from URL;
|
||||
@@ -488,11 +492,13 @@ offline reload works; install as standalone; reduced-motion honored. **⏳ Still
|
||||
Surfaced by the full-docs consistency review — each needs a deliberate resolution,
|
||||
not a silent drift:
|
||||
|
||||
- **Storage-monitor scope.** Spec §02 frames the indicator as the **snippet** storage
|
||||
budget specifically ("datasets are stored separately with far greater capacity"); the
|
||||
shipped `readStorageEstimate` instead reports **whole-origin** usage/quota (snippets +
|
||||
datasets + everything). Decide: scope the estimate to the snippet store, or update spec
|
||||
§02 to the whole-origin reading. Noted at [arch 02 §6](architecture/02-persistence.md).
|
||||
- ~~**Storage-monitor scope.** Spec §02 frames the indicator as the **snippet** storage
|
||||
budget specifically; the shipped estimate instead reported **whole-origin** usage/quota.~~
|
||||
✅ **Resolved** — rather than pick "snippet budget" vs. "whole-origin", the monitor was
|
||||
redesigned into a **composition breakdown** (snippets · datasets · app) that drops the
|
||||
unreliable browser quota entirely and shows real measured sizes. Spec §02 + §10 and
|
||||
[arch 02 §6](architecture/02-persistence.md) updated; council resolution recorded in
|
||||
[arch 10](architecture/10-interaction-and-feedback.md).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -344,52 +344,54 @@ const KEY = 'astrolabe:ux-prefs'; // { panelLayout: { libraryWidth, previewWidth
|
||||
|
||||
---
|
||||
|
||||
## 6. Storage Tiers, Budgets & Quota Monitoring
|
||||
## 6. Storage Tiers & the Composition Monitor
|
||||
|
||||
Astrolabe has three tiers with different capacities and risk profiles:
|
||||
|
||||
| Tier | Backing | Holds | Budget & behavior |
|
||||
| -------------------- | -------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Snippet store** | IndexedDB `snippets` | All snippet records | Practical budget ~**5 MB**. A storage monitor estimates usage and surfaces a warning as it fills. Snippets are user-authored and irreplaceable, so we fail **loudly**. |
|
||||
| Tier | Backing | Holds | Behavior |
|
||||
| -------------------- | -------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| **Snippet store** | IndexedDB `snippets` | All snippet records | Snippets are user-authored and irreplaceable, so writes fail **loudly** on quota (below). |
|
||||
| **Dataset store** | IndexedDB `datasets` | All dataset payloads | Separate, **high-capacity**; suited to large payloads. Loaded in full today; lazy loading is a §3 target. |
|
||||
| **Settings & prefs** | localStorage | `UserSettings` + app/UI prefs (§5) | Small; effectively unbounded for this use. |
|
||||
|
||||
Splitting snippets and datasets into separate stores means a few large datasets can't crowd out the snippet budget, and the snippet monitor can report a meaningful "how full is my library" number without summing dataset bytes.
|
||||
Splitting snippets and datasets into separate stores means a few large datasets can't crowd out snippets, and lets the storage monitor break usage down **by tier**.
|
||||
|
||||
### Estimating usage
|
||||
### Composing the storage breakdown
|
||||
|
||||
Read the origin's **Storage Manager** estimate (`navigator.storage.estimate()` → `usage`/`quota`) and derive a presentation-ready summary in the **pure core**, so the thresholds are unit-tested without a browser and the adapter only does I/O. When the API is missing or rejects, the summary is marked unusable rather than guessed.
|
||||
The monitor shows what storage is **made of** — Snippets · Datasets · App — not a "used of quota" gauge. The browser's `quota` is a padded, deliberately fuzzed approximation, not a real free-space figure (web.dev → _storage-for-the-web_), so a budget fraction is false precision. We use only the reliable `usage` (bytes actually stored for the origin) and measure our own tiers, deriving the rest:
|
||||
|
||||
- **Snippets / Datasets** — measured directly: snippets serialized (`jsonByteSize`), datasets summed from each record's `size`. Always available, no API needed.
|
||||
- **App** = `usage − snippets − datasets` — the precached app shell + IndexedDB overhead. Shown only when the Storage Manager API yields `usage`; otherwise the breakdown is just snippets + datasets.
|
||||
- **Hidden below a floor.** The whole monitor stays hidden until _user_ data (snippets + datasets) reaches `STORAGE_MONITOR_MIN_USER_BYTES` (10 MB). Keyed off user bytes, not total: the ~precache baseline is roughly constant, so gating on total would make it always-visible and the bar App-dominated.
|
||||
|
||||
The pure summarizer lives in core (unit-tested without a browser); the adapter only does I/O.
|
||||
|
||||
```ts
|
||||
// src/core/storage-estimate.ts — pure, unit-tested (no I/O)
|
||||
export type StorageLevel = 'ok' | 'warning' | 'critical';
|
||||
|
||||
export interface StorageSummary {
|
||||
usedBytes: number;
|
||||
quotaBytes: number;
|
||||
fraction: number; // usedBytes / quotaBytes, clamped 0..1
|
||||
level: StorageLevel; // escalates from `fraction`
|
||||
available: boolean; // false when usage/quota are missing/invalid
|
||||
export interface StorageComposition {
|
||||
segments: { key: 'snippets' | 'datasets' | 'app'; label: string; bytes: number }[];
|
||||
totalBytes: number; // origin usage when measured, else snippets + datasets
|
||||
originMeasured: boolean; // false when the estimate API is absent
|
||||
}
|
||||
|
||||
export const WARNING_THRESHOLD = 0.8; // begin warning
|
||||
export const CRITICAL_THRESHOLD = 0.95; // escalate to "nearly full"
|
||||
|
||||
export function summarizeStorage(input: { usage?: number; quota?: number }): StorageSummary {
|
||||
// Clamp the fraction, derive the level, and mark `available: false` when
|
||||
// usage/quota are absent or non-finite — we don't warn on data we lack.
|
||||
export function summarizeStorage(input: {
|
||||
usageBytes?: number;
|
||||
snippetBytes: number;
|
||||
datasetBytes: number;
|
||||
}): StorageComposition {
|
||||
/* app = usage − known, included only when usage ≥ known (else estimate lag) */
|
||||
}
|
||||
export function jsonByteSize(value: unknown): number; // UTF-8 bytes of JSON.stringify(value)
|
||||
|
||||
// src/app/infrastructure/storage-estimate.ts — the only browser-touching part
|
||||
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 summarizeStorage({});
|
||||
const { usage, quota } = await storage.estimate();
|
||||
return summarizeStorage({ usage, quota });
|
||||
if (!storage || typeof storage.estimate !== 'function') return undefined;
|
||||
const { usage } = await storage.estimate(); // quota deliberately ignored
|
||||
return typeof usage === 'number' ? usage : undefined;
|
||||
}
|
||||
```
|
||||
|
||||
> **Open divergence (spec §02 ↔ code).** Spec §02 frames the monitor as the **snippet** storage budget specifically ("datasets are stored separately with far greater capacity"). The shipped `readStorageEstimate` instead reports **whole-origin** `usage`/`quota` (snippets + datasets + everything the origin holds). Reconcile deliberately — either scope the estimate to the snippet store, or update spec §02 to describe the whole-origin reading.
|
||||
**The bar is decorative; the legend is the data.** There is no `role="meter"` — a meter needs a meaningful maximum, which a composition with no fixed ceiling lacks (APG → meter). The legend's text labels + sizes are the accessible source of truth, so meaning never rests on hue (WCAG 1.4.1). Dropping the old 0.8/0.95 "almost full" thresholds is deliberate: they keyed off the untrustworthy quota; the genuine out-of-room event surfaces at save time (below), where it is accurate. (Resolves the former spec §02 ↔ code "whole-origin vs. snippet budget" divergence; council resolution recorded in `docs/architecture/10`.)
|
||||
|
||||
### Fail loudly, never silently lose data
|
||||
|
||||
|
||||
@@ -263,6 +263,31 @@ 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 — storage composition indicator.** The library-footer Storage Monitor (spec §02)
|
||||
shows what storage is **made of** — Snippets · Datasets · App — as a proportional bar plus a
|
||||
labelled legend, **not** a "used of quota" gauge. The browser quota is a padded, unreliable
|
||||
approximation (web.dev → _storage-for-the-web_), so a budget fraction is false precision; we
|
||||
show real measured sizes instead.
|
||||
|
||||
- **Not a meter.** No `role="meter"`/`progressbar`: a meter needs a meaningful maximum, and a
|
||||
composition with no trustworthy ceiling has none (APG → `meter`: _"should not be used to
|
||||
represent a value … [without] a meaningful maximum"_). The visual bar is **decorative**
|
||||
(`aria-hidden`); the **legend's text labels + sizes are the accessible source of truth**, so
|
||||
meaning never rests on hue (WCAG 1.4.1).
|
||||
- **Part-to-whole in a tiny space.** A single proportional stacked bar suits a **few** segments
|
||||
(we have three) — FT Visual Vocabulary (Part-to-whole) + Datawrapper (stacked bar for "a few
|
||||
shares"; bar "when precise reading matters") — paired with absolute byte labels for the precise read.
|
||||
- **Unavailable degrades, not disappears.** Snippets + datasets are measured from our own data, so
|
||||
they always show; only the **App** segment (which needs the origin estimate) drops out when the
|
||||
Storage Manager API is absent.
|
||||
- **No proactive "almost full" warning.** Dropping the old 0.8/0.95 thresholds is intentional —
|
||||
they keyed off the untrustworthy quota, and a fake fuel gauge fails NN/g #1 (_visibility of system
|
||||
status_) more than it serves it. The genuine out-of-room event surfaces at **save time** as an
|
||||
actionable error (`services/storage-errors.ts` → recover by deleting), satisfying NN/g #9.
|
||||
|
||||
_(Consulted via /council → WAI-ARIA APG `meter`, FT Visual Vocabulary + Datawrapper (part-to-whole),
|
||||
web.dev storage, NN/g #1/#9, WCAG 1.4.1. This bullet is the contract; cite it, not the sources.)_
|
||||
|
||||
**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 +
|
||||
|
||||
@@ -87,9 +87,10 @@ New snippets get a sensible default name, and a tag field exists on each snippet
|
||||
|
||||
## Storage Monitor
|
||||
|
||||
A small indicator at the bottom of the library shows how much of the snippet storage budget is in use, warning the user before they run out of room. This concerns snippet storage specifically; datasets are stored separately with far greater capacity (see _Datasets_ / _Data Model_).
|
||||
A small indicator at the bottom of the library shows what the app's local storage is **made of** — a compact breakdown of how much space is taken by **snippets**, by **datasets**, and by the **app itself** (its offline-cached code and assets). It is informational: it helps the user see where space is going, not a fuel gauge counting down to a limit.
|
||||
|
||||
- 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 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.
|
||||
- The indicator stays **hidden until the user's own data — snippets + datasets — reaches a meaningful size** (about 10 MB). Below that there is nothing worth managing, so it adds no clutter; the app's own cached footprint does not count toward this threshold.
|
||||
- When shown, the breakdown is a single proportional bar plus a labelled legend giving each category's size; meaning never rests on colour alone.
|
||||
- There is **no "X of Y free" figure**. Browsers report only an unreliable, padded storage _quota_, so a precise "free space" number would mislead; the app shows real measured sizes instead.
|
||||
- Snippet and dataset sizes are always shown — the app measures them directly. The **app** portion is shown when the browser exposes an overall usage figure; when it does not, the breakdown simply omits it.
|
||||
- The genuine "out of room" moment is handled where it happens: if a save fails because storage is full, the system warns that the snippet could not be saved rather than silently losing data, so the user can delete snippets or datasets to free space (see _Import & Export_ error handling).
|
||||
|
||||
@@ -40,7 +40,7 @@ Astrolabe is specified as a standalone single-page app that owns its whole viewp
|
||||
|
||||
- **No silent data loss**: edits are auto-saved as drafts; a known-good published version is always preserved separately (see _Spec Editor & Draft/Published Workflow_).
|
||||
- **Confirm destructive actions**: deleting snippets or datasets, reverting a draft, and resetting settings require explicit confirmation.
|
||||
- **Warn before storage failure**: snippet storage usage is surfaced with escalating warnings as it fills, and the user is told when a save fails rather than losing data silently (see _Snippet Library_).
|
||||
- **Surface storage, warn on failure**: storage use is surfaced as a composition breakdown (snippets / datasets / app) rather than a budget gauge — browsers expose no reliable free-space figure to count down from — and the user is told when a save fails rather than losing data silently (see _Snippet Library_).
|
||||
- **Non-destructive import**: importing always merges with existing data and never overwrites or removes it; on failure the existing workspace is left unchanged (see _Import & Export_).
|
||||
- **Resilient rendering**: an invalid or unrenderable spec produces a readable error and recovers automatically when fixed; it never leaves the app in a broken state (see _Live Preview_).
|
||||
- **State survives reload**: the current selection/view is restored from the URL, and all data persists across reloads and sessions (see _Application Shell & Navigation_, _Data Model & Persistence_).
|
||||
|
||||
@@ -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);
|
||||
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();
|
||||
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;
|
||||
const root_ = container.querySelector('[aria-label="Storage in use"]');
|
||||
expect(root_).not.toBeNull();
|
||||
expect(root_!.className).toContain('ok');
|
||||
expect(root_!.className).not.toContain('warning');
|
||||
expect(root_!.className).not.toContain('critical');
|
||||
const legend = root_!.textContent ?? '';
|
||||
expect(legend).toContain('Snippets');
|
||||
expect(legend).toContain('Datasets');
|
||||
expect(legend).toContain('App'); // usage − snippets − datasets
|
||||
expect(legend).toContain('in use');
|
||||
});
|
||||
|
||||
test('does not render a live-region announcement at ok level', async () => {
|
||||
mockEstimate.mockResolvedValue(okSummary);
|
||||
test('is not a meter and shows no threshold warning (redesign)', async () => {
|
||||
seedSnippets(1);
|
||||
seedDatasets(11 * MB);
|
||||
mockUsage.mockResolvedValue(20 * MB);
|
||||
await renderMonitor();
|
||||
|
||||
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 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);
|
||||
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();
|
||||
const root_ = container.firstElementChild as HTMLElement | null;
|
||||
expect(root_!.className).toContain('warning');
|
||||
expect(root_!.className).not.toContain('critical');
|
||||
|
||||
const text = container.textContent ?? '';
|
||||
expect(text).toContain('Snippets');
|
||||
expect(text).toContain('Datasets');
|
||||
expect(text).not.toContain('App');
|
||||
});
|
||||
|
||||
test('renders a polite live-region with warning copy', async () => {
|
||||
mockEstimate.mockResolvedValue(warnSummary);
|
||||
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();
|
||||
const status = container.querySelector('[role="status"]');
|
||||
expect(status).not.toBeNull();
|
||||
expect(status!.getAttribute('aria-live')).toBe('polite');
|
||||
expect(status!.textContent).toBeTruthy();
|
||||
expect(container.firstElementChild).toBeNull();
|
||||
});
|
||||
|
||||
test('meter aria-valuenow reflects the fraction', async () => {
|
||||
mockEstimate.mockResolvedValue(warnSummary);
|
||||
test('renders nothing for a truly empty workspace', async () => {
|
||||
seedSnippets(0);
|
||||
seedDatasets();
|
||||
mockUsage.mockResolvedValue(undefined);
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/*
|
||||
* 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 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
|
||||
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}%` }} />
|
||||
key={s.key}
|
||||
className={`${styles.segment} ${SEGMENT_CLASS[s.key]}`}
|
||||
style={{ width: `${(s.bytes / totalBytes) * 100}%` }}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
{/* 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>
|
||||
|
||||
<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('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);
|
||||
});
|
||||
|
||||
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('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('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('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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('availability', () => {
|
||||
test('missing usage -> unavailable', () => {
|
||||
const s = summarizeStorage({ quota: 1000 });
|
||||
expect(s.available).toBe(false);
|
||||
expect(s.fraction).toBe(0);
|
||||
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('missing quota -> unavailable', () => {
|
||||
const s = summarizeStorage({ usage: 1000 });
|
||||
expect(s.available).toBe(false);
|
||||
expect(s.fraction).toBe(0);
|
||||
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('both missing (empty input) -> unavailable', () => {
|
||||
const s = summarizeStorage({});
|
||||
expect(s.available).toBe(false);
|
||||
});
|
||||
|
||||
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('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