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**. |
|
||||
| **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 +
|
||||
|
||||
@@ -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_).
|
||||
|
||||
Reference in New Issue
Block a user