mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 10:12:34 +00:00
Redesign the Storage Monitor as a storage-composition breakdown (snippets · datasets · app)
This commit is contained in:
@@ -1,93 +1,107 @@
|
||||
/**
|
||||
* Storage Monitor — library pane footer (spec §02 → Storage Monitor;
|
||||
* spec §10 → "Warn before storage failure"; docs/architecture/10 §1 status
|
||||
* indicator channel).
|
||||
* docs/architecture/10 → "Resolved — storage composition indicator").
|
||||
*
|
||||
* Fetches the browser storage estimate on mount (readStorageEstimate), runs it
|
||||
* through summarizeStorage, and renders:
|
||||
* - human-readable "used of quota" text
|
||||
* - a fill bar (meter) reflecting the percentage used
|
||||
* - escalating visual treatment at warning / critical levels via design tokens
|
||||
* Shows what local storage is *made of* — Snippets, Datasets, and App (the
|
||||
* precached shell + overhead) — as a proportional bar plus a labelled legend.
|
||||
* It is **not** a "used of quota" gauge: the browser's `quota` is an unreliable
|
||||
* padded approximation, so we show real measured sizes instead and let the genuine
|
||||
* out-of-room moment surface where it happens (a save-failure toast).
|
||||
*
|
||||
* When the Storage Manager API is unavailable (feature-detected by the adapter)
|
||||
* the component renders nothing — a missing ambient indicator is harmless, and
|
||||
* surfacing a "unavailable" line adds noise with no actionable value.
|
||||
* Accessibility (council → APG meter / NN/g / WCAG 1.4.1): the bar is **decorative**
|
||||
* (`aria-hidden`) and carries no role="meter" — a meter needs a meaningful maximum,
|
||||
* which we don't have. The **legend is the source of truth**: real text labels +
|
||||
* sizes that assistive tech reads, so meaning never rests on colour alone.
|
||||
*
|
||||
* Critical state is announced politely via `aria-live="polite"` so assistive
|
||||
* technology is informed without interrupting the user mid-task (arch 10 §5 —
|
||||
* status indicators are ambient, not assertive).
|
||||
* Snippet/dataset bytes are measured from our own stores (always available); the
|
||||
* App segment needs the origin estimate, so it appears only when that resolves.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { humanizeBytes, type StorageSummary } from '@core/storage-estimate';
|
||||
import { readStorageEstimate } from '../infrastructure/storage-estimate';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
humanizeBytes,
|
||||
jsonByteSize,
|
||||
STORAGE_MONITOR_MIN_USER_BYTES,
|
||||
summarizeStorage,
|
||||
} from '@core/storage-estimate';
|
||||
import { readOriginUsage } from '../infrastructure/storage-estimate';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
import styles from './StorageMonitor.module.css';
|
||||
|
||||
/** Level-to-label for the accessible announcement copy. */
|
||||
const LEVEL_LABEL: Record<StorageSummary['level'], string> = {
|
||||
ok: '',
|
||||
warning: 'Storage is getting full.',
|
||||
critical: 'Storage is almost full. Delete snippets to free space.',
|
||||
};
|
||||
/** Segment key → its colour class (shared by the bar slice and the legend swatch). */
|
||||
const SEGMENT_CLASS = {
|
||||
snippets: styles.segSnippets,
|
||||
datasets: styles.segDatasets,
|
||||
app: styles.segApp,
|
||||
} as const;
|
||||
|
||||
export default function StorageMonitor() {
|
||||
const [summary, setSummary] = useState<StorageSummary | null>(null);
|
||||
const snippets = useSnippetStore((s) => s.snippets);
|
||||
const datasets = useDatasetStore((s) => s.datasets);
|
||||
|
||||
// Measure our own data synchronously. Snippets are serialized; datasets already
|
||||
// carry a byte `size`. Memoized so we only re-measure when the lists change.
|
||||
const snippetBytes = useMemo(
|
||||
() => snippets.reduce((n, snip) => n + jsonByteSize(snip), 0),
|
||||
[snippets],
|
||||
);
|
||||
const datasetBytes = useMemo(() => datasets.reduce((n, d) => n + (d.size ?? 0), 0), [datasets]);
|
||||
|
||||
// Stay hidden until the user's own data is worth a glance — below the floor it's
|
||||
// ambient noise, and the app shell would dominate the bar anyway (spec §02).
|
||||
const belowFloor = snippetBytes + datasetBytes < STORAGE_MONITOR_MIN_USER_BYTES;
|
||||
|
||||
// The origin total is async + optional. Re-read after our data changes, since a
|
||||
// save moves usage; undefined when the Storage Manager API is unavailable.
|
||||
const [usageBytes, setUsageBytes] = useState<number | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void readStorageEstimate().then((s) => {
|
||||
if (!cancelled) setSummary(s);
|
||||
void readOriginUsage().then((u) => {
|
||||
if (!cancelled) setUsageBytes(u);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [snippetBytes, datasetBytes]);
|
||||
|
||||
// While loading, or when the API is unavailable, render nothing.
|
||||
if (!summary || !summary.available) return null;
|
||||
const composition = useMemo(
|
||||
() => summarizeStorage({ usageBytes, snippetBytes, datasetBytes }),
|
||||
[usageBytes, snippetBytes, datasetBytes],
|
||||
);
|
||||
|
||||
const usedText = humanizeBytes(summary.usedBytes);
|
||||
const quotaText = humanizeBytes(summary.quotaBytes);
|
||||
const pct = Math.round(summary.fraction * 100);
|
||||
const announcement = LEVEL_LABEL[summary.level];
|
||||
// Hidden below the user-data floor, or with nothing measured yet (truly empty).
|
||||
if (belowFloor || composition.totalBytes <= 0) return null;
|
||||
|
||||
const { segments, totalBytes } = composition;
|
||||
|
||||
return (
|
||||
<div className={`${styles.monitor} ${styles[summary.level]}`} aria-label="Storage usage">
|
||||
{/* Usage text */}
|
||||
<div className={styles.label}>
|
||||
<span className={styles.used}>{usedText}</span>
|
||||
<span className={styles.separator}> of </span>
|
||||
<span className={styles.quota}>{quotaText}</span>
|
||||
<div className={styles.monitor} aria-label="Storage in use">
|
||||
{/* Decorative composition bar — the legend below is the accessible source. */}
|
||||
<div className={styles.track} aria-hidden="true">
|
||||
{segments.map((s) =>
|
||||
s.bytes > 0 ? (
|
||||
<div
|
||||
key={s.key}
|
||||
className={`${styles.segment} ${SEGMENT_CLASS[s.key]}`}
|
||||
style={{ width: `${(s.bytes / totalBytes) * 100}%` }}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* Fill bar — ARIA meter (WAI-ARIA 1.1).
|
||||
* role="meter" conveys a scalar value within a known range; aria-valuetext
|
||||
* gives a human-readable reading that matches the visible label.
|
||||
*/}
|
||||
<div
|
||||
role="meter"
|
||||
aria-label="Storage used"
|
||||
aria-valuenow={pct}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuetext={`${usedText} of ${quotaText} (${pct}%)`}
|
||||
className={styles.track}
|
||||
>
|
||||
<div className={styles.fill} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
{/* Legend: real text labels + sizes — meaning never rests on colour (WCAG 1.4.1). */}
|
||||
<ul className={styles.legend}>
|
||||
{segments.map((s) => (
|
||||
<li key={s.key} className={styles.legendItem}>
|
||||
<span className={`${styles.swatch} ${SEGMENT_CLASS[s.key]}`} aria-hidden="true" />
|
||||
<span className={styles.legendLabel}>{s.label}</span>
|
||||
<span className={styles.legendBytes}>{humanizeBytes(s.bytes)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/*
|
||||
* Polite live region — announces the warning / critical state to assistive
|
||||
* technology without interrupting ongoing work. Empty for the 'ok' level so
|
||||
* there is no announcement when storage is healthy (arch 10 §1 — status
|
||||
* indicators are ambient, not assertive; only escalation warrants notice).
|
||||
*/}
|
||||
{announcement && (
|
||||
<p role="status" aria-live="polite" className={styles.announcement}>
|
||||
{announcement}
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.total}>{humanizeBytes(totalBytes)} in use</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user