mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add live-preview busy indicator for slow renders (M6, §04/§10)
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
}
|
||||
|
||||
.body {
|
||||
position: relative; /* establishes stacking context for the busy overlay */
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
@@ -88,3 +89,46 @@
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/*
|
||||
* Busy overlay — non-blocking; floats over the chart body only (arch §10.2;
|
||||
* spec §04/§10). position:absolute relative to .body's position:relative.
|
||||
* pointer-events:none ensures the editor and other controls stay fully
|
||||
* interactive while the overlay is visible.
|
||||
*
|
||||
* The spinner animation is a pure CSS transform-based rotation so it respects
|
||||
* the global prefers-reduced-motion rule in base.css (animation-duration → 0.01ms).
|
||||
*/
|
||||
.busyOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
background: color-mix(in srgb, var(--bg) 75%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.busySpinner {
|
||||
display: block;
|
||||
width: var(--icon-lg);
|
||||
height: var(--icon-lg);
|
||||
border: 2px solid var(--border-strong);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: busySpin 600ms linear infinite;
|
||||
}
|
||||
|
||||
.busyLabel {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@keyframes busySpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* LivePreview — busy overlay guard (spec §04; arch §10.2).
|
||||
*
|
||||
* The render pipeline is integration-heavy (vega-embed, IndexedDB, Monaco); the
|
||||
* busy OVERLAY itself is purely a function of `PreviewStore.busy`. These tests
|
||||
* set that flag directly and assert the DOM result — no timing, no mocking of the
|
||||
* async render path.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { usePreviewStore } from '../stores/PreviewStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
import { LivePreview } from './LivePreview';
|
||||
|
||||
// Heavy services and sub-components the LivePreview wires — not under test here.
|
||||
vi.mock('../services/chart-renderer', () => ({
|
||||
renderSpec: () => Promise.resolve({ destroy() {}, resize() {} }),
|
||||
}));
|
||||
vi.mock('./SettingsPopover', () => ({
|
||||
SettingsPopover: () => null,
|
||||
SettingRow: () => null,
|
||||
RangeControl: () => null,
|
||||
}));
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
usePreviewStore.setState({ error: null, busy: false });
|
||||
useSnippetStore.getState().reset();
|
||||
useDatasetStore.getState().reset();
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => root.render(<LivePreview />));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
usePreviewStore.setState({ error: null, busy: false });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('LivePreview busy overlay', () => {
|
||||
test('does not render the busy overlay when busy=false', () => {
|
||||
// The overlay element should not be in the DOM at all during normal operation.
|
||||
expect(container.querySelector('[aria-hidden="true"]')).toBeNull();
|
||||
});
|
||||
|
||||
test('renders the busy overlay when PreviewStore.busy=true', () => {
|
||||
act(() => usePreviewStore.setState({ busy: true }));
|
||||
const overlay = container.querySelector('[aria-hidden="true"]');
|
||||
expect(overlay).not.toBeNull();
|
||||
});
|
||||
|
||||
test('overlay carries a visible label for sighted users', () => {
|
||||
act(() => usePreviewStore.setState({ busy: true }));
|
||||
const label = container.querySelector('[aria-hidden="true"]')?.textContent;
|
||||
expect(label).toMatch(/rendering/i);
|
||||
});
|
||||
|
||||
test('the preview body carries aria-busy=true when busy', () => {
|
||||
act(() => usePreviewStore.setState({ busy: true }));
|
||||
// The body element has aria-busy when the store says busy.
|
||||
const busyEl = container.querySelector('[aria-busy="true"]');
|
||||
expect(busyEl).not.toBeNull();
|
||||
});
|
||||
|
||||
test('aria-busy is absent when busy=false (no aria-busy="false" noise)', () => {
|
||||
// aria-busy="false" is technically valid but needlessly verbose; we omit it.
|
||||
expect(container.querySelector('[aria-busy]')).toBeNull();
|
||||
});
|
||||
|
||||
test('overlay disappears when busy returns to false', () => {
|
||||
act(() => usePreviewStore.setState({ busy: true }));
|
||||
expect(container.querySelector('[aria-hidden="true"]')).not.toBeNull();
|
||||
act(() => usePreviewStore.setState({ busy: false }));
|
||||
expect(container.querySelector('[aria-hidden="true"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -110,11 +110,14 @@ export function LivePreview() {
|
||||
const lastLoadRef = useRef({ bufferEpoch: -1, editorView });
|
||||
const error = usePreviewStore((s) => s.error);
|
||||
const setError = usePreviewStore((s) => s.setError);
|
||||
const busy = usePreviewStore((s) => s.busy);
|
||||
const setBusy = usePreviewStore((s) => s.setBusy);
|
||||
|
||||
// Busy-indication timer ref: if a render exceeds ~1s we surface a non-blocking
|
||||
// overlay (arch §10.2 NN/g: >1s owes a busy indication; <1s shows nothing to
|
||||
// avoid flicker on typical fast renders).
|
||||
const busyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// TODO (council backfill, doc §10.2): a render that exceeds ~1s owes a
|
||||
// non-blocking busy indication (overlay + aria-busy), gated by a threshold so
|
||||
// sub-1s renders show nothing. Deferred — it pairs with the heavier M3 dataset
|
||||
// renders the budget flags; today's inline-data renders are effectively instant.
|
||||
useEffect(() => {
|
||||
const node = hostRef.current;
|
||||
if (!node) return;
|
||||
@@ -147,6 +150,25 @@ export function LivePreview() {
|
||||
}
|
||||
|
||||
const mine = ++generationRef.current;
|
||||
|
||||
// Arm the busy indicator: if the render hasn't settled within ~1s, flip the
|
||||
// flag. Sub-1s renders (the common case) never show the overlay — no flicker
|
||||
// (arch §10.2; NN/g ≤1s = uninterrupted thought, >1s = noticeably waiting).
|
||||
if (busyTimerRef.current !== null) clearTimeout(busyTimerRef.current);
|
||||
busyTimerRef.current = setTimeout(() => {
|
||||
// Only arm for the current generation; a superseded render doesn't own busy.
|
||||
if (mine === generationRef.current) setBusy(true);
|
||||
}, 1000);
|
||||
|
||||
/** Clear busy and the timer unconditionally — called on settle or error. */
|
||||
const clearBusy = () => {
|
||||
if (busyTimerRef.current !== null) {
|
||||
clearTimeout(busyTimerRef.current);
|
||||
busyTimerRef.current = null;
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
try {
|
||||
const prepared = prepareSpecForRender(parsed, { fitMode, datasets });
|
||||
const config = chartConfigFor(uiTheme);
|
||||
@@ -165,8 +187,10 @@ export function LivePreview() {
|
||||
}
|
||||
handleRef.current = handle;
|
||||
setError(null);
|
||||
clearBusy();
|
||||
} catch (e) {
|
||||
if (mine === generationRef.current) {
|
||||
clearBusy();
|
||||
// A missing dataset reference is not a JSON/spec problem, so it gets a
|
||||
// tailored, fixable message instead of the generic syntax hint (council:
|
||||
// GOV.UK error-message + NN/g #9 — name the problem, give the real fix).
|
||||
@@ -187,7 +211,17 @@ export function LivePreview() {
|
||||
}, delay);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [shownText, fitMode, uiTheme, datasets, setError, bufferEpoch, editorView, renderDebounce]);
|
||||
}, [
|
||||
shownText,
|
||||
fitMode,
|
||||
uiTheme,
|
||||
datasets,
|
||||
setError,
|
||||
setBusy,
|
||||
bufferEpoch,
|
||||
editorView,
|
||||
renderDebounce,
|
||||
]);
|
||||
|
||||
// Re-fit the chart when its container resizes (e.g. a pane drag). Vega doesn't
|
||||
// observe the element, so we do: one observer on the stable host node for the
|
||||
@@ -205,13 +239,15 @@ export function LivePreview() {
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Finalize the live view on unmount, and clear the shared error so a stale
|
||||
// message never outlives this pane.
|
||||
// Finalize the live view on unmount, and clear the shared error + busy state so
|
||||
// stale transient state never outlives this pane.
|
||||
useEffect(
|
||||
() => () => {
|
||||
handleRef.current?.destroy();
|
||||
handleRef.current = null;
|
||||
if (busyTimerRef.current !== null) clearTimeout(busyTimerRef.current);
|
||||
usePreviewStore.getState().setError(null);
|
||||
usePreviewStore.getState().setBusy(false);
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -222,7 +258,12 @@ export function LivePreview() {
|
||||
<FitControl />
|
||||
<PreviewSettings />
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
{/*
|
||||
* aria-busy on the chart region tells AT the area is being updated (arch §10.2;
|
||||
* spec §04). The overlay is a non-blocking sibling inside the relative-positioned
|
||||
* body; it never covers the header or editor, so editing stays fully interactive.
|
||||
*/}
|
||||
<div className={styles.body} aria-busy={busy || undefined}>
|
||||
{/* Frame is React-owned and carries the fit-sizing class; the inner host
|
||||
is owned by vega-embed (it brands it `.vega-embed` and mutates its
|
||||
classList at runtime), so its className stays static and React never
|
||||
@@ -234,6 +275,18 @@ export function LivePreview() {
|
||||
editor pane's role="alert" (one producer, two subscribers; doc §10.1),
|
||||
so adding one here would double-announce it. */}
|
||||
{error !== null && <pre className={styles.error}>{error}</pre>}
|
||||
{/*
|
||||
* Busy overlay: non-blocking, overlays only the chart body, never the header
|
||||
* or the editor (arch §10.2; spec §04/§10). Shown only after the ~1s threshold
|
||||
* so sub-1s renders produce no flicker. The spinner animation is suppressed
|
||||
* under prefers-reduced-motion (base.css *{animation-duration:0.01ms}).
|
||||
*/}
|
||||
{busy && (
|
||||
<div className={styles.busyOverlay} aria-hidden="true">
|
||||
<span className={styles.busySpinner} />
|
||||
<span className={styles.busyLabel}>Rendering…</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user