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.
|
app (chrome + Monaco + chart all repaint on theme flip); focus ring visible.
|
||||||
Notes from the build-out: the placeholder `'experimental'` theme was renamed to
|
Notes from the build-out: the placeholder `'experimental'` theme was renamed to
|
||||||
`'dark'` (the settled name); the swappable `[data-accent]` layer landed with
|
`'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.
|
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.
|
- **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
|
- **About & Privacy** and **Donate** modals ~~(§01)~~ ✅ (Donate URL is a placeholder
|
||||||
`DONATE_URL` pending the real link).
|
`DONATE_URL` pending the real link).
|
||||||
- **Offline/installable:** verify the SW + manifest give a working offline + installed app.
|
- **Offline/installable:** the manifest now ships a full SVG icon set (favicon / maskable /
|
||||||
**⏳ Remaining** — needs manual verification in a running/installed app.
|
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
|
- **Council** — ~~seat **web.dev** for the PWA/offline/storage surfaces none of the seated
|
||||||
members cover: the service-worker **update-available** prompt (`registerType: 'prompt'`),
|
members cover: the service-worker **update-available** prompt (`registerType: 'prompt'`),
|
||||||
storage **persistence** (`navigator.storage.persist()`), and the quota **estimate**
|
storage **persistence** (`navigator.storage.persist()`), and the quota **estimate**
|
||||||
(`StorageManager.estimate()`)~~ ✅ seated + backfilled (update-prompt toast + `persist()`
|
(`StorageManager.estimate()`)~~ ✅ seated + backfilled (update-prompt toast + `persist()`
|
||||||
request; estimate already wired). **⏳ Remaining gap:** manifest ships no icons → not yet
|
request; estimate already wired). ~~**⏳ Remaining gap:** manifest ships no icons → not yet
|
||||||
installable (design-asset task). See [`/council`](../.claude/skills/council/SKILL.md) and
|
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).
|
[arch 10](architecture/10-interaction-and-feedback.md).
|
||||||
|
|
||||||
**Manual checks:** keyboard-only run-through; reload restores view from URL;
|
**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,
|
Surfaced by the full-docs consistency review — each needs a deliberate resolution,
|
||||||
not a silent drift:
|
not a silent drift:
|
||||||
|
|
||||||
- **Storage-monitor scope.** Spec §02 frames the indicator as the **snippet** storage
|
- ~~**Storage-monitor scope.** Spec §02 frames the indicator as the **snippet** storage
|
||||||
budget specifically ("datasets are stored separately with far greater capacity"); the
|
budget specifically; the shipped estimate instead reported **whole-origin** usage/quota.~~
|
||||||
shipped `readStorageEstimate` instead reports **whole-origin** usage/quota (snippets +
|
✅ **Resolved** — rather than pick "snippet budget" vs. "whole-origin", the monitor was
|
||||||
datasets + everything). Decide: scope the estimate to the snippet store, or update spec
|
redesigned into a **composition breakdown** (snippets · datasets · app) that drops the
|
||||||
§02 to the whole-origin reading. Noted at [arch 02 §6](architecture/02-persistence.md).
|
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:
|
Astrolabe has three tiers with different capacities and risk profiles:
|
||||||
|
|
||||||
| Tier | Backing | Holds | Budget & behavior |
|
| Tier | Backing | Holds | 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**. |
|
| **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. |
|
| **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. |
|
| **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
|
```ts
|
||||||
// src/core/storage-estimate.ts — pure, unit-tested (no I/O)
|
// src/core/storage-estimate.ts — pure, unit-tested (no I/O)
|
||||||
export type StorageLevel = 'ok' | 'warning' | 'critical';
|
export interface StorageComposition {
|
||||||
|
segments: { key: 'snippets' | 'datasets' | 'app'; label: string; bytes: number }[];
|
||||||
export interface StorageSummary {
|
totalBytes: number; // origin usage when measured, else snippets + datasets
|
||||||
usedBytes: number;
|
originMeasured: boolean; // false when the estimate API is absent
|
||||||
quotaBytes: number;
|
|
||||||
fraction: number; // usedBytes / quotaBytes, clamped 0..1
|
|
||||||
level: StorageLevel; // escalates from `fraction`
|
|
||||||
available: boolean; // false when usage/quota are missing/invalid
|
|
||||||
}
|
}
|
||||||
|
export function summarizeStorage(input: {
|
||||||
export const WARNING_THRESHOLD = 0.8; // begin warning
|
usageBytes?: number;
|
||||||
export const CRITICAL_THRESHOLD = 0.95; // escalate to "nearly full"
|
snippetBytes: number;
|
||||||
|
datasetBytes: number;
|
||||||
export function summarizeStorage(input: { usage?: number; quota?: number }): StorageSummary {
|
}): StorageComposition {
|
||||||
// Clamp the fraction, derive the level, and mark `available: false` when
|
/* app = usage − known, included only when usage ≥ known (else estimate lag) */
|
||||||
// usage/quota are absent or non-finite — we don't warn on data we lack.
|
|
||||||
}
|
}
|
||||||
|
export function jsonByteSize(value: unknown): number; // UTF-8 bytes of JSON.stringify(value)
|
||||||
|
|
||||||
// src/app/infrastructure/storage-estimate.ts — the only browser-touching part
|
// 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;
|
const storage = typeof navigator !== 'undefined' ? navigator.storage : undefined;
|
||||||
if (!storage || typeof storage.estimate !== 'function') return summarizeStorage({});
|
if (!storage || typeof storage.estimate !== 'function') return undefined;
|
||||||
const { usage, quota } = await storage.estimate();
|
const { usage } = await storage.estimate(); // quota deliberately ignored
|
||||||
return summarizeStorage({ usage, quota });
|
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
|
### 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
|
_(Consulted via /council → GOV.UK Tag, Carbon status-indicator-pattern, APG. This bullet is the
|
||||||
contract; cite it, not the external source.)_
|
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
|
**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
|
§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 +
|
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
|
## 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.
|
- 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.
|
||||||
- A fill indicator reflects the percentage used.
|
- When shown, the breakdown is a single proportional bar plus a labelled legend giving each category's size; meaning never rests on colour alone.
|
||||||
- The indicator enters escalating warning states as usage climbs (a cautionary state past roughly 80% and a critical state past roughly 95%).
|
- 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.
|
||||||
- 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.
|
- 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_).
|
- **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.
|
- **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_).
|
- **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_).
|
- **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_).
|
- **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 { useSnippetStore } from '../stores/SnippetStore';
|
||||||
import { SnippetLibrary } from './SnippetLibrary';
|
import { SnippetLibrary } from './SnippetLibrary';
|
||||||
|
|
||||||
// StorageMonitor (rendered at the bottom of the pane) fetches an async storage
|
// StorageMonitor (rendered at the bottom of the pane) measures storage and fetches
|
||||||
// estimate on mount; stub it so its setState doesn't fire outside act() and add
|
// an async estimate on mount; stub the whole component to nothing so its async
|
||||||
// test noise. This suite is about the library, not the monitor.
|
// setState doesn't fire outside act() and its legend text doesn't collide with
|
||||||
vi.mock('../infrastructure/storage-estimate', () => ({
|
// library assertions. This suite is about the library, not the monitor.
|
||||||
readStorageEstimate: () => Promise.resolve({ available: false }),
|
vi.mock('./StorageMonitor', () => ({ default: () => null }));
|
||||||
}));
|
|
||||||
|
|
||||||
// React 19 wants this flag set for act() to drive effects without warnings.
|
// 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;
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|||||||
@@ -1,86 +1,85 @@
|
|||||||
/*
|
/*
|
||||||
* Storage Monitor — token-based styles.
|
* Storage Monitor — token-based styles.
|
||||||
* All colours are role tokens; no raw hexes, no hardcoded hues (arch 09 §3.3).
|
* 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
|
* A proportional composition bar + a labelled legend; the legend carries the
|
||||||
* the root element; the fill bar picks up the level colour from a CSS var
|
* meaning so segments never rely on hue alone (WCAG 1.4.1).
|
||||||
* resolved locally so the cascade stays flat.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.monitor {
|
.monitor {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-2);
|
gap: var(--space-3);
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
border-top: var(--border-width) solid var(--border);
|
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
|
/* Stacked composition bar (decorative; the legend is the data). */
|
||||||
clears 4.5:1 on light surfaces; the raw yellow fails — arch 09 §3.3). */
|
|
||||||
.warning {
|
|
||||||
--fill-color: var(--support-warning-fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Critical level — error red. */
|
|
||||||
.critical {
|
|
||||||
--fill-color: var(--support-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Usage text row: "1.2 MB of 5.0 MB" */
|
|
||||||
.label {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.used {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.separator {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quota {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The track that contains the fill bar. */
|
|
||||||
.track {
|
.track {
|
||||||
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 4px;
|
height: 8px;
|
||||||
background: var(--layer-02, var(--border));
|
background: var(--layer-02, var(--border));
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The coloured fill — width is set inline from fraction; colour via --fill-color. */
|
.segment {
|
||||||
.fill {
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--fill-color);
|
/* 1px separator drawn inside the right edge — distinguishes adjacent segments
|
||||||
transition: width var(--dur-moderate) var(--ease);
|
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
|
/* Segment + swatch colours. Snippets = accent (the primary user data); datasets =
|
||||||
muted; assistive tech reads it because of role="status" + aria-live="polite". */
|
a distinct hue; app = a muted neutral (it's overhead, visually de-emphasised). */
|
||||||
.announcement {
|
.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;
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2) var(--space-4);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--fill-color);
|
color: var(--text-secondary);
|
||||||
line-height: 1.4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Suppress the fill bar transition for users who prefer reduced motion (arch 09 §3.5). */
|
.legendItem {
|
||||||
@media (prefers-reduced-motion: reduce) {
|
display: flex;
|
||||||
.fill {
|
align-items: center;
|
||||||
transition: none;
|
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.
|
* StorageMonitor render tests.
|
||||||
*
|
*
|
||||||
* Strategy: mock readStorageEstimate (the async browser adapter) so tests run
|
* Strategy: seed the snippet + dataset stores (the measured inputs) and mock
|
||||||
* in happy-dom without a real Storage Manager, then assert on the rendered DOM
|
* readOriginUsage (the async browser adapter) so tests run in happy-dom without a
|
||||||
* — text content, ARIA attributes, and CSS-module class presence for each level.
|
* 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
|
* CSS Modules are identity-mapped under Vitest, so class tokens come through as-is.
|
||||||
* come through as-is), so we match on the raw class name tokens from the .module.css.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { act } from 'react';
|
import { act } from 'react';
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
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 StorageMonitor from './StorageMonitor';
|
||||||
|
import { useSnippetStore } from '../stores/SnippetStore';
|
||||||
|
import { useDatasetStore } from '../stores/DatasetStore';
|
||||||
|
|
||||||
// Mock the browser adapter — tests must not touch navigator.storage.
|
// Mock the browser adapter — tests must not touch navigator.storage.
|
||||||
vi.mock('../infrastructure/storage-estimate', () => ({
|
vi.mock('../infrastructure/storage-estimate', () => ({
|
||||||
readStorageEstimate: vi.fn(),
|
readOriginUsage: vi.fn(),
|
||||||
}));
|
}));
|
||||||
import { readStorageEstimate } from '../infrastructure/storage-estimate';
|
import { readOriginUsage } from '../infrastructure/storage-estimate';
|
||||||
const mockEstimate = vi.mocked(readStorageEstimate);
|
const mockUsage = vi.mocked(readOriginUsage);
|
||||||
|
|
||||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
// ---- helpers ----------------------------------------------------------------
|
// ---- fixtures ---------------------------------------------------------------
|
||||||
|
|
||||||
/** Build a complete StorageSummary for test fixtures. */
|
/** A snippet is just serialized for its byte size, so a minimal shape suffices. */
|
||||||
function makeSummary(
|
function seedSnippets(count: number): void {
|
||||||
overrides: Partial<StorageSummary> &
|
const snippets = Array.from(
|
||||||
Pick<StorageSummary, 'usedBytes' | 'quotaBytes' | 'fraction' | 'level'>,
|
{ length: count },
|
||||||
): StorageSummary {
|
(_, i) => ({ id: `s${i}`, name: `snippet ${i}`, draftSpec: '{"x":1}' }) as unknown as Snippet,
|
||||||
return {
|
);
|
||||||
available: true,
|
useSnippetStore.setState({ snippets });
|
||||||
...overrides,
|
}
|
||||||
};
|
|
||||||
|
/** 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 -------------------------------------------------------------
|
// ---- test setup -------------------------------------------------------------
|
||||||
@@ -51,149 +60,75 @@ beforeEach(() => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
container.remove();
|
container.remove();
|
||||||
|
useSnippetStore.setState({ snippets: [] });
|
||||||
|
useDatasetStore.setState({ datasets: [] });
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Render the component and wait for the async estimate to resolve. */
|
|
||||||
async function renderMonitor() {
|
async function renderMonitor() {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(<StorageMonitor />);
|
root.render(<StorageMonitor />);
|
||||||
await Promise.resolve(); // flush the async readStorageEstimate() resolution
|
await Promise.resolve(); // flush the async readOriginUsage() resolution
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- tests ------------------------------------------------------------------
|
// ---- tests ------------------------------------------------------------------
|
||||||
|
|
||||||
describe('StorageMonitor', () => {
|
const MB = 1024 * 1024;
|
||||||
describe('when the estimate is available and at ok level', () => {
|
|
||||||
const okSummary = makeSummary({
|
|
||||||
usedBytes: 1.2 * 1024 * 1024, // ~1.2 MB
|
|
||||||
quotaBytes: 5 * 1024 * 1024, // 5 MB
|
|
||||||
fraction: 0.24,
|
|
||||||
level: 'ok',
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders usage text: used of quota', async () => {
|
describe('StorageMonitor (composition)', () => {
|
||||||
mockEstimate.mockResolvedValue(okSummary);
|
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();
|
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 () => {
|
const root_ = container.querySelector('[aria-label="Storage in use"]');
|
||||||
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_).not.toBeNull();
|
||||||
expect(root_!.className).toContain('ok');
|
const legend = root_!.textContent ?? '';
|
||||||
expect(root_!.className).not.toContain('warning');
|
expect(legend).toContain('Snippets');
|
||||||
expect(root_!.className).not.toContain('critical');
|
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 () => {
|
test('is not a meter and shows no threshold warning (redesign)', async () => {
|
||||||
mockEstimate.mockResolvedValue(okSummary);
|
seedSnippets(1);
|
||||||
|
seedDatasets(11 * MB);
|
||||||
|
mockUsage.mockResolvedValue(20 * MB);
|
||||||
await renderMonitor();
|
await renderMonitor();
|
||||||
|
|
||||||
|
expect(container.querySelector('[role="meter"]')).toBeNull();
|
||||||
expect(container.querySelector('[role="status"]')).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', () => {
|
test('omits the App segment when the Storage Manager API is unavailable', async () => {
|
||||||
const warnSummary = makeSummary({
|
seedSnippets(1);
|
||||||
usedBytes: 4.2 * 1024 * 1024,
|
seedDatasets(11 * MB);
|
||||||
quotaBytes: 5 * 1024 * 1024,
|
mockUsage.mockResolvedValue(undefined); // API absent
|
||||||
fraction: 0.84,
|
|
||||||
level: 'warning',
|
|
||||||
});
|
|
||||||
|
|
||||||
test('applies the warning class to the root element', async () => {
|
|
||||||
mockEstimate.mockResolvedValue(warnSummary);
|
|
||||||
await renderMonitor();
|
await renderMonitor();
|
||||||
const root_ = container.firstElementChild as HTMLElement | null;
|
|
||||||
expect(root_!.className).toContain('warning');
|
const text = container.textContent ?? '';
|
||||||
expect(root_!.className).not.toContain('critical');
|
expect(text).toContain('Snippets');
|
||||||
|
expect(text).toContain('Datasets');
|
||||||
|
expect(text).not.toContain('App');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders a polite live-region with warning copy', async () => {
|
test('stays hidden below the user-data floor, even with origin usage', async () => {
|
||||||
mockEstimate.mockResolvedValue(warnSummary);
|
seedSnippets(3);
|
||||||
|
seedDatasets(2 * MB); // ~2 MB of user data — under the 10 MB floor
|
||||||
|
mockUsage.mockResolvedValue(9 * MB);
|
||||||
await renderMonitor();
|
await renderMonitor();
|
||||||
const status = container.querySelector('[role="status"]');
|
|
||||||
expect(status).not.toBeNull();
|
|
||||||
expect(status!.getAttribute('aria-live')).toBe('polite');
|
|
||||||
expect(status!.textContent).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('meter aria-valuenow reflects the fraction', async () => {
|
|
||||||
mockEstimate.mockResolvedValue(warnSummary);
|
|
||||||
await renderMonitor();
|
|
||||||
const meter = container.querySelector('[role="meter"]');
|
|
||||||
expect(meter!.getAttribute('aria-valuenow')).toBe('84');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when the estimate is at critical level', () => {
|
|
||||||
const critSummary = makeSummary({
|
|
||||||
usedBytes: 4.9 * 1024 * 1024,
|
|
||||||
quotaBytes: 5 * 1024 * 1024,
|
|
||||||
fraction: 0.98,
|
|
||||||
level: 'critical',
|
|
||||||
});
|
|
||||||
|
|
||||||
test('applies the critical class to the root element', async () => {
|
|
||||||
mockEstimate.mockResolvedValue(critSummary);
|
|
||||||
await renderMonitor();
|
|
||||||
const root_ = container.firstElementChild as HTMLElement | null;
|
|
||||||
expect(root_!.className).toContain('critical');
|
|
||||||
expect(root_!.className).not.toContain('warning');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders a polite live-region with critical copy mentioning deletion', async () => {
|
|
||||||
mockEstimate.mockResolvedValue(critSummary);
|
|
||||||
await renderMonitor();
|
|
||||||
const status = container.querySelector('[role="status"]');
|
|
||||||
expect(status).not.toBeNull();
|
|
||||||
expect(status!.getAttribute('aria-live')).toBe('polite');
|
|
||||||
// Critical copy must direct user to delete snippets.
|
|
||||||
expect(status!.textContent?.toLowerCase()).toContain('delete');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('meter aria-valuenow is 98 and aria-valuetext contains the used bytes', async () => {
|
|
||||||
mockEstimate.mockResolvedValue(critSummary);
|
|
||||||
await renderMonitor();
|
|
||||||
const meter = container.querySelector('[role="meter"]');
|
|
||||||
expect(meter!.getAttribute('aria-valuenow')).toBe('98');
|
|
||||||
expect(meter!.getAttribute('aria-valuetext')).toContain('98%');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('graceful unavailable case', () => {
|
|
||||||
const unavailableSummary: StorageSummary = {
|
|
||||||
available: false,
|
|
||||||
usedBytes: 0,
|
|
||||||
quotaBytes: 0,
|
|
||||||
fraction: 0,
|
|
||||||
level: 'ok',
|
|
||||||
};
|
|
||||||
|
|
||||||
test('renders nothing when the Storage Manager API is unavailable', async () => {
|
|
||||||
mockEstimate.mockResolvedValue(unavailableSummary);
|
|
||||||
await renderMonitor();
|
|
||||||
// The component should return null — no DOM output.
|
|
||||||
expect(container.firstElementChild).toBeNull();
|
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;
|
* Storage Monitor — library pane footer (spec §02 → Storage Monitor;
|
||||||
* spec §10 → "Warn before storage failure"; docs/architecture/10 §1 status
|
* docs/architecture/10 → "Resolved — storage composition indicator").
|
||||||
* indicator channel).
|
|
||||||
*
|
*
|
||||||
* Fetches the browser storage estimate on mount (readStorageEstimate), runs it
|
* Shows what local storage is *made of* — Snippets, Datasets, and App (the
|
||||||
* through summarizeStorage, and renders:
|
* precached shell + overhead) — as a proportional bar plus a labelled legend.
|
||||||
* - human-readable "used of quota" text
|
* It is **not** a "used of quota" gauge: the browser's `quota` is an unreliable
|
||||||
* - a fill bar (meter) reflecting the percentage used
|
* padded approximation, so we show real measured sizes instead and let the genuine
|
||||||
* - escalating visual treatment at warning / critical levels via design tokens
|
* out-of-room moment surface where it happens (a save-failure toast).
|
||||||
*
|
*
|
||||||
* When the Storage Manager API is unavailable (feature-detected by the adapter)
|
* Accessibility (council → APG meter / NN/g / WCAG 1.4.1): the bar is **decorative**
|
||||||
* the component renders nothing — a missing ambient indicator is harmless, and
|
* (`aria-hidden`) and carries no role="meter" — a meter needs a meaningful maximum,
|
||||||
* surfacing a "unavailable" line adds noise with no actionable value.
|
* 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
|
* Snippet/dataset bytes are measured from our own stores (always available); the
|
||||||
* technology is informed without interrupting the user mid-task (arch 10 §5 —
|
* App segment needs the origin estimate, so it appears only when that resolves.
|
||||||
* status indicators are ambient, not assertive).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { humanizeBytes, type StorageSummary } from '@core/storage-estimate';
|
import {
|
||||||
import { readStorageEstimate } from '../infrastructure/storage-estimate';
|
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';
|
import styles from './StorageMonitor.module.css';
|
||||||
|
|
||||||
/** Level-to-label for the accessible announcement copy. */
|
/** Segment key → its colour class (shared by the bar slice and the legend swatch). */
|
||||||
const LEVEL_LABEL: Record<StorageSummary['level'], string> = {
|
const SEGMENT_CLASS = {
|
||||||
ok: '',
|
snippets: styles.segSnippets,
|
||||||
warning: 'Storage is getting full.',
|
datasets: styles.segDatasets,
|
||||||
critical: 'Storage is almost full. Delete snippets to free space.',
|
app: styles.segApp,
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
export default function StorageMonitor() {
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void readStorageEstimate().then((s) => {
|
void readOriginUsage().then((u) => {
|
||||||
if (!cancelled) setSummary(s);
|
if (!cancelled) setUsageBytes(u);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [snippetBytes, datasetBytes]);
|
||||||
|
|
||||||
// While loading, or when the API is unavailable, render nothing.
|
const composition = useMemo(
|
||||||
if (!summary || !summary.available) return null;
|
() => summarizeStorage({ usageBytes, snippetBytes, datasetBytes }),
|
||||||
|
[usageBytes, snippetBytes, datasetBytes],
|
||||||
|
);
|
||||||
|
|
||||||
const usedText = humanizeBytes(summary.usedBytes);
|
// Hidden below the user-data floor, or with nothing measured yet (truly empty).
|
||||||
const quotaText = humanizeBytes(summary.quotaBytes);
|
if (belowFloor || composition.totalBytes <= 0) return null;
|
||||||
const pct = Math.round(summary.fraction * 100);
|
|
||||||
const announcement = LEVEL_LABEL[summary.level];
|
const { segments, totalBytes } = composition;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.monitor} ${styles[summary.level]}`} aria-label="Storage usage">
|
<div className={styles.monitor} aria-label="Storage in use">
|
||||||
{/* Usage text */}
|
{/* Decorative composition bar — the legend below is the accessible source. */}
|
||||||
<div className={styles.label}>
|
<div className={styles.track} aria-hidden="true">
|
||||||
<span className={styles.used}>{usedText}</span>
|
{segments.map((s) =>
|
||||||
<span className={styles.separator}> of </span>
|
s.bytes > 0 ? (
|
||||||
<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
|
<div
|
||||||
role="meter"
|
key={s.key}
|
||||||
aria-label="Storage used"
|
className={`${styles.segment} ${SEGMENT_CLASS[s.key]}`}
|
||||||
aria-valuenow={pct}
|
style={{ width: `${(s.bytes / totalBytes) * 100}%` }}
|
||||||
aria-valuemin={0}
|
/>
|
||||||
aria-valuemax={100}
|
) : null,
|
||||||
aria-valuetext={`${usedText} of ${quotaText} (${pct}%)`}
|
)}
|
||||||
className={styles.track}
|
|
||||||
>
|
|
||||||
<div className={styles.fill} style={{ width: `${pct}%` }} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/*
|
{/* Legend: real text labels + sizes — meaning never rests on colour (WCAG 1.4.1). */}
|
||||||
* Polite live region — announces the warning / critical state to assistive
|
<ul className={styles.legend}>
|
||||||
* technology without interrupting ongoing work. Empty for the 'ok' level so
|
{segments.map((s) => (
|
||||||
* there is no announcement when storage is healthy (arch 10 §1 — status
|
<li key={s.key} className={styles.legendItem}>
|
||||||
* indicators are ambient, not assertive; only escalation warrants notice).
|
<span className={`${styles.swatch} ${SEGMENT_CLASS[s.key]}`} aria-hidden="true" />
|
||||||
*/}
|
<span className={styles.legendLabel}>{s.label}</span>
|
||||||
{announcement && (
|
<span className={styles.legendBytes}>{humanizeBytes(s.bytes)}</span>
|
||||||
<p role="status" aria-live="polite" className={styles.announcement}>
|
</li>
|
||||||
{announcement}
|
))}
|
||||||
</p>
|
</ul>
|
||||||
)}
|
|
||||||
|
<div className={styles.total}>{humanizeBytes(totalBytes)} in use</div>
|
||||||
</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
|
* The ONLY module that touches the Storage Manager API. We read `usage` — the bytes
|
||||||
* (`navigator.storage.estimate()`). It feature-detects the API, then defers all
|
* actually stored for the origin, which is reliable — and deliberately ignore
|
||||||
* presentation math to the portable core (`src/core/storage-estimate.ts`). When
|
* `quota`, a padded, browser-decided approximation that is not a real free-space
|
||||||
* the API is unavailable — older browsers, insecure contexts — it returns an
|
* figure (web.dev → storage-for-the-web). Snippet/dataset bytes are measured in the
|
||||||
* unavailable summary rather than throwing, so callers get a total function.
|
* 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';
|
export async function readOriginUsage(): Promise<number | undefined> {
|
||||||
|
|
||||||
/** 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> {
|
|
||||||
const storage = typeof navigator !== 'undefined' ? navigator.storage : undefined;
|
const storage = typeof navigator !== 'undefined' ? navigator.storage : undefined;
|
||||||
if (!storage || typeof storage.estimate !== 'function') {
|
if (!storage || typeof storage.estimate !== 'function') return undefined;
|
||||||
return UNAVAILABLE;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { usage, quota } = await storage.estimate();
|
const { usage } = await storage.estimate();
|
||||||
return summarizeStorage({ usage, quota });
|
return typeof usage === 'number' ? usage : undefined;
|
||||||
} catch {
|
} catch {
|
||||||
return UNAVAILABLE;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,89 +1,67 @@
|
|||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
import {
|
import { humanizeBytes, jsonByteSize, summarizeStorage } from './storage-estimate';
|
||||||
CRITICAL_THRESHOLD,
|
|
||||||
WARNING_THRESHOLD,
|
|
||||||
humanizeBytes,
|
|
||||||
summarizeStorage,
|
|
||||||
} from './storage-estimate';
|
|
||||||
|
|
||||||
describe('summarizeStorage', () => {
|
describe('summarizeStorage (composition)', () => {
|
||||||
test('computes the fraction and passes usage/quota through', () => {
|
test('with origin usage: app = usage − snippets − datasets, total = usage', () => {
|
||||||
const s = summarizeStorage({ usage: 250, quota: 1000 });
|
const c = summarizeStorage({ usageBytes: 1000, snippetBytes: 100, datasetBytes: 300 });
|
||||||
expect(s.available).toBe(true);
|
expect(c.originMeasured).toBe(true);
|
||||||
expect(s.usedBytes).toBe(250);
|
expect(c.totalBytes).toBe(1000);
|
||||||
expect(s.quotaBytes).toBe(1000);
|
expect(c.segments).toEqual([
|
||||||
expect(s.fraction).toBeCloseTo(0.25);
|
{ key: 'snippets', label: 'Snippets', bytes: 100 },
|
||||||
expect(s.level).toBe('ok');
|
{ key: 'datasets', label: 'Datasets', bytes: 300 },
|
||||||
|
{ key: 'app', label: 'App', bytes: 600 },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clamps fraction to 1 when usage exceeds quota', () => {
|
test('without origin usage: snippets + datasets only, total is their sum', () => {
|
||||||
const s = summarizeStorage({ usage: 1500, quota: 1000 });
|
const c = summarizeStorage({ snippetBytes: 100, datasetBytes: 300 });
|
||||||
expect(s.fraction).toBe(1);
|
expect(c.originMeasured).toBe(false);
|
||||||
expect(s.level).toBe('critical');
|
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', () => {
|
test('usage smaller than measured data falls back to snippets+datasets (estimate lag)', () => {
|
||||||
const s = summarizeStorage({ usage: 10, quota: 0 });
|
const c = summarizeStorage({ usageBytes: 50, snippetBytes: 100, datasetBytes: 300 });
|
||||||
expect(s.available).toBe(false);
|
expect(c.originMeasured).toBe(false);
|
||||||
expect(s.fraction).toBe(0);
|
expect(c.totalBytes).toBe(400);
|
||||||
expect(s.usedBytes).toBe(0);
|
expect(c.segments).toHaveLength(2);
|
||||||
expect(s.quotaBytes).toBe(0);
|
|
||||||
expect(s.level).toBe('ok');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('level boundaries', () => {
|
test('usage exactly equal to measured data yields a zero-byte app segment', () => {
|
||||||
test('just below warning (0.79) is ok', () => {
|
const c = summarizeStorage({ usageBytes: 400, snippetBytes: 100, datasetBytes: 300 });
|
||||||
expect(summarizeStorage({ usage: 79, quota: 100 }).level).toBe('ok');
|
expect(c.originMeasured).toBe(true);
|
||||||
|
expect(c.segments.find((s) => s.key === 'app')?.bytes).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('exactly at warning (0.80) is warning', () => {
|
test('invalid / negative / non-finite inputs degrade to 0 bytes', () => {
|
||||||
const s = summarizeStorage({ usage: 80, quota: 100 });
|
const c = summarizeStorage({ usageBytes: NaN, snippetBytes: -5, datasetBytes: Infinity });
|
||||||
expect(s.fraction).toBeCloseTo(WARNING_THRESHOLD);
|
expect(c.segments[0].bytes).toBe(0); // snippets
|
||||||
expect(s.level).toBe('warning');
|
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', () => {
|
test('empty workspace: every segment is zero, total zero', () => {
|
||||||
expect(summarizeStorage({ usage: 94, quota: 100 }).level).toBe('warning');
|
const c = summarizeStorage({ snippetBytes: 0, datasetBytes: 0 });
|
||||||
|
expect(c.totalBytes).toBe(0);
|
||||||
|
expect(c.segments.every((s) => s.bytes === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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('exactly at critical (0.95) is critical', () => {
|
test('counts multi-byte characters by their UTF-8 size', () => {
|
||||||
const s = summarizeStorage({ usage: 95, quota: 100 });
|
// "é" is 2 UTF-8 bytes; JSON.stringify("é") => the 4-byte string «"é"».
|
||||||
expect(s.fraction).toBeCloseTo(CRITICAL_THRESHOLD);
|
expect(jsonByteSize('é')).toBe(4);
|
||||||
expect(s.level).toBe('critical');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('availability', () => {
|
test('an unserializable (circular) value degrades to 0', () => {
|
||||||
test('missing usage -> unavailable', () => {
|
const a: Record<string, unknown> = {};
|
||||||
const s = summarizeStorage({ quota: 1000 });
|
a.self = a;
|
||||||
expect(s.available).toBe(false);
|
expect(jsonByteSize(a)).toBe(0);
|
||||||
expect(s.fraction).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('missing quota -> unavailable', () => {
|
|
||||||
const s = summarizeStorage({ usage: 1000 });
|
|
||||||
expect(s.available).toBe(false);
|
|
||||||
expect(s.fraction).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,89 +1,104 @@
|
|||||||
/**
|
/**
|
||||||
* Storage estimate summarization (spec §02 → Storage Monitor; spec §10 → "Warn
|
* Storage composition summarization (spec §02 → Storage Monitor; docs/architecture/02 §6).
|
||||||
* before storage failure"; docs/architecture/02 §6).
|
|
||||||
*
|
*
|
||||||
* Portable core: no browser APIs, no React. The browser's Storage Manager API
|
* Portable core: no browser APIs, no React. The Storage Monitor is **not** a "used
|
||||||
* (`navigator.storage.estimate()`) yields a raw `{ usage, quota }` pair in
|
* of quota" gauge — the browser's `quota` from `navigator.storage.estimate()` is a
|
||||||
* bytes — both optional, since a browser may omit them. This module turns that
|
* padded, browser-decided approximation, not a real free-space figure, so a budget
|
||||||
* raw pair into presentation-ready facts: a clamped fraction, an escalating
|
* fraction would be false precision (web.dev → storage-for-the-web). Instead we show
|
||||||
* level, and an availability flag. The infrastructure adapter
|
* what storage is *made of*: Snippets, Datasets, and App (everything else — the
|
||||||
* (`src/app/infrastructure/storage-estimate.ts`) feature-detects the API and
|
* precached app shell + IndexedDB overhead). This module turns three measured byte
|
||||||
* feeds its output through here; all the math lives in this pure function so it
|
* counts into ordered segments; the math lives here so it is trivially testable.
|
||||||
* is trivially testable.
|
|
||||||
*
|
*
|
||||||
* Thresholds: warning at >= 0.8 is the documented value (arch 02 §6 `WARN_AT`).
|
* The only reliable figure from the estimate is `usage` (bytes actually stored for
|
||||||
* Critical at >= 0.95 is not specified by spec/arch; it is this module's default
|
* the whole origin). Snippet and dataset bytes we measure ourselves, so
|
||||||
* for the second escalation step ("nearly full") and is the single source of
|
* `App = usage − snippets − datasets`. When the estimate is missing we still show
|
||||||
* truth here.
|
* snippets + datasets from our own data.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Raw input shape, mirroring the browser's `StorageEstimate` (bytes, both optional). */
|
/** A category of stored data and its measured size in bytes. */
|
||||||
export interface StorageEstimateInput {
|
export interface StorageSegment {
|
||||||
/** Bytes currently used, or undefined when the browser omits it. */
|
key: 'snippets' | 'datasets' | 'app';
|
||||||
usage?: number;
|
/** User-facing label, centralized here so the component stays presentational. */
|
||||||
/** Total bytes available (quota), or undefined when the browser omits it. */
|
label: string;
|
||||||
quota?: number;
|
bytes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Escalating fullness level driving the storage monitor's warning copy/colour. */
|
/** Measured inputs: snippet/dataset bytes (always known) + origin usage (when the API is present). */
|
||||||
export type StorageLevel = 'ok' | 'warning' | 'critical';
|
export interface StorageInput {
|
||||||
|
/** Whole-origin bytes from `navigator.storage.estimate().usage`, if available. */
|
||||||
/** Presentation-ready summary derived from a raw estimate. */
|
usageBytes?: number;
|
||||||
export interface StorageSummary {
|
/** Summed serialized size of all snippets. */
|
||||||
/** Bytes used (0 when unusable). */
|
snippetBytes: number;
|
||||||
usedBytes: number;
|
/** Summed size of all datasets (each `Dataset` carries its byte `size`). */
|
||||||
/** Quota in bytes (0 when unusable). */
|
datasetBytes: number;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fraction at which the monitor begins warning (arch 02 §6 `WARN_AT`). */
|
/** Presentation-ready storage composition. */
|
||||||
export const WARNING_THRESHOLD = 0.8;
|
export interface StorageComposition {
|
||||||
/** Fraction at which the monitor escalates to critical ("nearly full"). */
|
/** Ordered segments to display: Snippets, Datasets, then App when measurable. */
|
||||||
export const CRITICAL_THRESHOLD = 0.95;
|
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 {
|
function usableBytes(n: number | undefined): number | null {
|
||||||
if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return null;
|
if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return null;
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map a clamped fraction to its escalation level. */
|
/** A finite, non-negative byte count; junk degrades to 0. */
|
||||||
function levelFor(fraction: number): StorageLevel {
|
function nonNeg(n: number): number {
|
||||||
if (fraction >= CRITICAL_THRESHOLD) return 'critical';
|
return usableBytes(n) ?? 0;
|
||||||
if (fraction >= WARNING_THRESHOLD) return 'warning';
|
|
||||||
return 'ok';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turn a raw storage estimate into a presentation-ready summary. Pure: no I/O.
|
* Decompose measured storage into ordered segments. The `app` segment (everything
|
||||||
* When usage is missing/invalid, or quota is missing/invalid/zero, the estimate
|
* beyond our snippets+datasets) is included only when the origin `usage` is available
|
||||||
* is unusable — `available: false`, everything zeroed, level `ok` (we don't warn
|
* and at least our measured data — a smaller estimate is the browser's approximation
|
||||||
* on data we don't have). Otherwise the fraction is `usage / quota` clamped to
|
* lagging, not real, so we fall back to a snippets+datasets-only view.
|
||||||
* 0..1 and the level escalates at the thresholds above.
|
|
||||||
*/
|
*/
|
||||||
export function summarizeStorage(input: StorageEstimateInput): StorageSummary {
|
export function summarizeStorage(input: StorageInput): StorageComposition {
|
||||||
const usage = usableBytes(input.usage);
|
const snippets = nonNeg(input.snippetBytes);
|
||||||
const quota = usableBytes(input.quota);
|
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.
|
const segments: StorageSegment[] = [
|
||||||
if (usage === null || quota === null || quota === 0) {
|
{ key: 'snippets', label: 'Snippets', bytes: snippets },
|
||||||
return { usedBytes: 0, quotaBytes: 0, fraction: 0, level: 'ok', available: false };
|
{ 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);
|
/** UTF-8 byte length of a value's JSON serialization (0 when it can't be serialized). */
|
||||||
return {
|
export function jsonByteSize(value: unknown): number {
|
||||||
usedBytes: usage,
|
try {
|
||||||
quotaBytes: quota,
|
return new TextEncoder().encode(JSON.stringify(value) ?? '').length;
|
||||||
fraction,
|
} catch {
|
||||||
level: levelFor(fraction),
|
return 0;
|
||||||
available: true,
|
}
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;
|
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;
|
||||||
|
|||||||
Reference in New Issue
Block a user