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

This commit is contained in:
2026-06-10 01:32:22 +03:00
parent e7de0cbb8a
commit 7599acd25e
12 changed files with 438 additions and 481 deletions
+31 -29
View File
@@ -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**. |
| **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. |
| 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 +