Make storage History readings inspectable by keyboard

Add focus, bounded sample navigation and polite timestamp/value announcements without changing pointer or target ownership. Keep wrapped tooltip readings inside their box. Validate parent failure and final desktop/phone browser states.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot] 2026-10-02 16:02:38 +01:00
parent cf5d6cdf06
commit 0be430813f
9 changed files with 266 additions and 17 deletions

View file

@ -7686,3 +7686,16 @@ field, polling, supplied-data transitions, locked/empty targets and cleanup.
accessible description in desktop Chromium and phone WebKit, including a late
old-target response and current-target loading/failure. Synthetic response proof
is not native PBS collection or whole-report #1723 acceptance.
### Shared History keyboard inspection
The existing Storage pool and disk canvas charts accept keyboard focus with a
visible focus indicator. Focus selects the latest observed sample; Left/Right
step through actual observations, Home/End select the endpoints, and Escape or
blur clears inspection without trapping focus. Each keyboard-selected timestamp
and formatted value is announced politely. Pointer inspection remains available
without announcing every mouse move. Matching sample refreshes retain inspection;
selection changes and empty data clear stale readings. No readings are invented
for missing data. Tooltips reserve room for a wrapped local timestamp and reading. The mounted HistoryChart regression and history-keyboard browser
fixture cover navigation, bounds, focus exit, refresh and replacement. Synthetic
browser proof does not establish native collector or assistive-device acceptance.

View file

@ -0,0 +1,80 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium, webkit } = require('playwright');
(async () => {
const root = '/workspace/frontend-modern';
process.chdir(root);
const artifacts = path.join(root, 'node_modules/history-keyboard-proof');
fs.mkdirSync(artifacts, { recursive: true });
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({ root, configFile: path.join(root, 'vite.config.ts'), cacheDir: fs.mkdtempSync(path.join(artifacts, 'vite-')), server: { host: '127.0.0.1', port: 5223, strictPort: true } });
let browser;
const observations = [];
try {
await server.listen();
for (const [name, engine, width, height] of [['chromium', chromium, 1365, 900], ['webkit', webkit, 390, 844]]) {
browser = await engine.launch(name === 'chromium' ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } : { headless: true });
const page = await browser.newPage({ viewport: { width, height }, hasTouch: name === 'webkit', isMobile: name === 'webkit' });
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.route('**/*', route => {
const url = new URL(route.request().url());
if (url.origin !== 'http://127.0.0.1:5223') return route.abort();
if (!url.pathname.startsWith('/api/')) return route.continue();
return route.fulfill({ json: url.pathname === '/api/license/runtime-capabilities' ? { capabilities: [], limits: [], max_history_days: 7, hosted_mode: false, runtime: { build: 'community' }, blocked_capabilities: [] } : { data: [], enabled: false } });
});
await page.goto('http://127.0.0.1:5223/browser-tests/history-keyboard.html');
if (name === 'webkit') await page.evaluate(() => document.documentElement.classList.add('dark'));
const chart = page.getByRole('img', { name: 'Usage chart', exact: true });
await chart.waitFor();
if (process.argv.includes('--parent')) {
assert.equal(await chart.getAttribute('tabindex'), null);
await page.getByRole('button', { name: 'Change target' }).focus();
await page.keyboard.press('Tab');
assert.equal(await page.evaluate(() => document.activeElement.textContent), 'After charts');
await page.screenshot({ path: path.join(artifacts, 'parent-skipped.png'), fullPage: true });
console.log('Parent reproduced: Tab skips both storage charts; no keyboard inspection');
break;
}
assert.equal(await chart.getAttribute('tabindex'), '0');
await page.getByRole('button', { name: 'Change target' }).focus();
await page.keyboard.press('Tab');
assert.equal(await chart.evaluate(el => el === document.activeElement), true);
const live = page.locator('[aria-live="polite"]').first();
for (const [phase, key, expected] of [['focus', null, '30.0%'], ['previous', 'ArrowLeft', '20.0%'], ['first', 'Home', '10.0%'], ['lower-bound', 'ArrowLeft', '10.0%'], ['last', 'End', '30.0%'], ['upper-bound', 'ArrowRight', '30.0%'], ['escape', 'Escape', '']]) {
if (key) await page.keyboard.press(key);
await page.waitForTimeout(60);
assert.ok((await live.textContent()).includes(expected));
if (!expected) assert.equal(await live.textContent(), '');
else assert.ok((await page.locator('[data-history-chart-tooltip]').first().textContent()).includes(expected));
if (phase === 'previous') {
assert.equal(await page.locator('[data-history-chart-tooltip]').count(), 2);
for (const tooltip of await page.locator('[data-history-chart-tooltip]').all()) {
assert.ok(await tooltip.evaluate(el => el.scrollHeight <= el.clientHeight));
}
assert.equal(await page.locator('[aria-live="polite"]').nth(1).textContent(), '');
const outline = await chart.evaluate(el => getComputedStyle(el).outlineWidth);
assert.notEqual(outline, '0px');
await page.screenshot({ path: path.join(artifacts, `${name}-keyboard.png`), fullPage: true });
}
observations.push({ name, version: browser.version(), phase, announcement: await live.textContent() });
}
await page.keyboard.press('Tab');
assert.equal(await page.getByRole('img', { name: 'Read chart', exact: true }).evaluate(el => el === document.activeElement), true);
await page.keyboard.press('Tab');
assert.equal(await page.getByRole('button', { name: 'After charts' }).evaluate(el => el === document.activeElement), true);
await page.getByRole('button', { name: 'Toggle empty' }).click();
await chart.focus();
await page.keyboard.press('End');
assert.equal(await live.textContent(), '');
assert.equal(await page.locator('[data-history-chart-tooltip]').count(), 0);
const dimensions = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, width: innerWidth }));
assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions));
assert.deepEqual(errors, []);
await browser.close(); browser = null;
}
fs.writeFileSync(path.join(artifacts, process.argv.includes('--parent') ? 'parent.json' : 'result.json'), JSON.stringify({ playwright: require('playwright/package.json').version, observations }, null, 2));
console.log(JSON.stringify({ result: 'passed', states: observations.length }));
} finally { if (browser) await browser.close(); await server.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });

View file

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>History keyboard verification</title></head><body><div id="root"></div><script type="module" src="./history-keyboard.tsx"></script></body></html>

View file

@ -0,0 +1,48 @@
import { createSignal } from 'solid-js';
import { render } from 'solid-js/web';
import { HistoryChart, HistoryChartHoverGroup } from '../src/components/shared/HistoryChart';
import '../src/index.css';
const Fixture = () => {
const [empty, setEmpty] = createSignal(false);
const [target, setTarget] = createSignal('a');
const points = () =>
empty()
? []
: [10, 20, 30].map((value, i) => ({
timestamp: 1790942400000 + i * 60000,
value,
min: value,
max: value,
}));
return (
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
<h1>Storage History keyboard inspection</h1>
<button onClick={() => setEmpty(!empty())}>Toggle empty</button>
<button onClick={() => setTarget(target() === 'a' ? 'b' : 'a')}>Change target</button>
<HistoryChartHoverGroup>
<HistoryChart
resourceType="disk"
resourceId={target()}
metric="usage"
label="Usage"
unit="%"
hideSelector
range="1h"
data={points()}
/>
<HistoryChart
resourceType="disk"
resourceId={target()}
metric="diskread"
label="Read"
unit="B/s"
hideSelector
range="1h"
data={points()}
/>
</HistoryChartHoverGroup>
<button>After charts</button>
</main>
);
};
render(() => <Fixture />, document.getElementById('root')!);

View file

@ -1,16 +1,20 @@
{
"version": 1,
"base_sha": "93531c9cd3bfc9278633476ba26a1c77e66bc802",
"verified_at": "2026-10-02T14:23:47.145834Z",
"base_sha": "cf5d6cdf066539b6e29f589e42d408edb1ceb73a",
"verified_at": "2026-10-02T15:02:34.899181Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/shared/useHistoryChartState.ts"
"frontend-modern/src/components/shared/HistoryChart.tsx",
"frontend-modern/src/components/shared/useHistoryChartState.ts",
"frontend-modern/src/components/shared/historyChartModel.ts"
],
"content_sha256": {
"frontend-modern/src/components/shared/useHistoryChartState.ts": "f10b5cfc7cb296c60ad7e87bf7c3001c4308d29af702b8c7a7e77e7c4c4c3fb6"
"frontend-modern/src/components/shared/HistoryChart.tsx": "24cd863fb6288bc2c13ef2d26e02aad95d3e4f54278f3078b1703fabf8785f8c",
"frontend-modern/src/components/shared/useHistoryChartState.ts": "b1ebd93d5fee328b78f8f88f1837514a30d12be069d852d08f5d7f2b6ce999ec",
"frontend-modern/src/components/shared/historyChartModel.ts": "bd9e8954ef7e274a1eb026752d304556df303f612c4485369f8c90a7183cc751"
},
"routes": [
"/browser-tests/history-selection.html"
"/browser-tests/history-keyboard.html"
],
"viewports": [
{
@ -23,23 +27,22 @@
}
],
"states": [
"Synthetic delayed transport using the production shared chart, with hidden selector matching storage drawers; not native PBS acceptance",
"Selected B remains 80% after old A completes at 10%; new C clears readings while loading and displays its own failure",
"Desktop Chromium light and phone WebKit dark: eight states, no page errors or overflow in storage layout"
"Populated, empty and linked charts in Chromium light and WebKit phone dark; synthetic data, not native PBS acceptance",
"Visible keyboard focus, wrapped timestamp and reading contained within tooltip; no horizontal overflow or page errors"
],
"interactions": [
"Select B, complete B then delayed A",
"Select C then fail its request; verify accessible description and plotted extrema"
"Tab into and out of both charts; arrows, Home/End, endpoint clamping, Escape; only focused chart announces keyboard readings",
"Parent Tab skips both charts; final empty chart invents no reading"
],
"command": "pulse-worker-browser frontend-modern/browser-tests/history-selection.cjs",
"command": "pulse-worker-browser frontend-modern/browser-tests/history-keyboard.cjs",
"browser_versions": {
"playwright": "1.56.1",
"chromium": "141.0.7390.37",
"webkit": "26.0"
},
"artifacts": [
"/var/lib/pulse-maintainer/worker-outputs/web-product-wwhurxzu/browser/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-wwhurxzu/browser/webkit-old-a-finished.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-wwhurxzu/browser/chromium-c-loading.png"
"/var/lib/pulse-maintainer/worker-outputs/web-product-tuqfk84d/browser/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-tuqfk84d/browser/webkit-keyboard.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-tuqfk84d/browser/chromium-keyboard.png"
]
}

View file

@ -1,5 +1,6 @@
import { Component, createMemo, createUniqueId } from 'solid-js';
import {
formatHistoryChartTooltipValue,
getHistoryChartAccessibleDescription,
getHistoryChartAccessibleLabel,
type HistoryChartProps,
@ -56,7 +57,11 @@ export const HistoryChart: Component<HistoryChartProps> = (props) => {
>
<canvas
ref={canvasRef}
class="block w-full h-full cursor-crosshair"
class="block w-full h-full cursor-crosshair rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-500"
tabIndex={0}
onFocus={chart.handleFocus}
onBlur={chart.handleBlur}
onKeyDown={chart.handleKeyDown}
role="img"
aria-label={getHistoryChartAccessibleLabel(props.label)}
aria-describedby={descriptionId}
@ -64,7 +69,13 @@ export const HistoryChart: Component<HistoryChartProps> = (props) => {
onMouseLeave={chart.handleMouseLeave}
/>
<p id={descriptionId} class="sr-only">
{accessibleDescription()}
{accessibleDescription()} Use Left and Right arrow keys to inspect readings, Home and End
for the first and last reading, and Escape to clear inspection.
</p>
<p class="sr-only" aria-live="polite" aria-atomic="true">
{chart.keyboardInspecting() && chart.hoveredPoint()
? `${new Date(chart.hoveredPoint()!.timestamp).toLocaleString()}: ${formatHistoryChartTooltipValue(chart.hoveredPoint()!.value, props.unit)}`
: ''}
</p>
<HistoryChartOverlay chart={chart} hideLock={props.hideLock} />
<HistoryChartTooltip

View file

@ -130,6 +130,56 @@ describe('HistoryChart', () => {
expect(historyChartTooltipSource).not.toContain('ChartsAPI.getMetricsHistory');
});
it('inspects actual readings with the keyboard and clears on escape, blur and selection changes', () => {
const [target, setTarget] = createSignal('a');
const [points, setPoints] = createSignal([
{ timestamp: 1_000, value: 10, min: 10, max: 10 },
{ timestamp: 2_000, value: 20, min: 20, max: 20 },
{ timestamp: 3_000, value: 30, min: 30, max: 30 },
]);
const { container } = render(() => (
<HistoryChart
resourceType="disk"
resourceId={target()}
metric="usage"
unit="%"
hideSelector
data={points()}
/>
));
const canvas = screen.getByRole('img', { name: 'History chart' });
const announcement = container.querySelector('[aria-live="polite"]')!;
expect(canvas).toHaveAttribute('tabindex', '0');
expect(announcement).toBeEmptyDOMElement();
fireEvent.focus(canvas);
expect(announcement).toHaveTextContent('30.0%');
fireEvent.keyDown(canvas, { key: 'ArrowLeft' });
expect(announcement).toHaveTextContent('20.0%');
fireEvent.keyDown(canvas, { key: 'Home' });
fireEvent.keyDown(canvas, { key: 'ArrowLeft' });
expect(announcement).toHaveTextContent('10.0%');
fireEvent.keyDown(canvas, { key: 'ArrowRight', ctrlKey: true });
expect(announcement).toHaveTextContent('10.0%');
fireEvent.keyDown(canvas, { key: 'End' });
fireEvent.keyDown(canvas, { key: 'ArrowRight' });
expect(announcement).toHaveTextContent('30.0%');
fireEvent.keyDown(canvas, { key: 'Escape' });
expect(announcement).toBeEmptyDOMElement();
expect(container.querySelector('[data-history-chart-tooltip]')).toBeNull();
fireEvent.keyDown(canvas, { key: 'Home' });
setPoints(points().map((point) => ({ ...point, value: point.value + 1 })));
expect(announcement).toHaveTextContent('11.0%');
setTarget('b');
expect(announcement).toBeEmptyDOMElement();
fireEvent.keyDown(canvas, { key: 'Home' });
fireEvent.blur(canvas);
expect(announcement).toBeEmptyDOMElement();
setPoints([]);
fireEvent.focus(canvas);
fireEvent.keyDown(canvas, { key: 'End' });
expect(announcement).toBeEmptyDOMElement();
});
it('renders the default history label', () => {
render(() => <HistoryChart resourceType="agent" resourceId="node-1" metric="cpu" />);

View file

@ -279,7 +279,8 @@ export function getHistoryChartTooltipLayout({
chartHeight: number;
}): HistoryChartTooltipLayout {
const width = 156;
const height = 46;
// Allow a wrapped local timestamp plus the reading at normal text size.
const height = 64;
const margin = 8;
const pointGap = 12;
const minX = margin;

View file

@ -34,6 +34,7 @@ export function useHistoryChartState(
) {
const [range, setRange] = createSignal<HistoryTimeRange>(props.range || '24h');
const [data, setData] = createSignal(props.data ?? []);
const [keyboardInspecting, setKeyboardInspecting] = createSignal(false);
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [source, setSource] = createSignal<'store' | 'memory' | 'live' | 'mock_synthetic' | null>(
@ -351,11 +352,47 @@ export function useHistoryChartState(
onCleanup(() => resizeObserver.disconnect());
});
const handleFocus = () => {
setKeyboardInspecting(true);
const points = data();
setHoveredTimestamp(points.length ? points[points.length - 1].timestamp : null);
};
const handleBlur = () => {
setKeyboardInspecting(false);
setHoveredTimestamp(null);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.altKey || event.ctrlKey || event.metaKey) return;
if (event.key === 'Escape') {
setHoveredTimestamp(null);
return;
}
const points = data();
if (!points.length || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
setKeyboardInspecting(true);
const timestamp = hoveredTimestamp();
const index =
timestamp === null
? points.length - 1
: points.indexOf(findHistoryChartClosestPoint(points, timestamp));
const next =
event.key === 'Home'
? 0
: event.key === 'End'
? points.length - 1
: Math.max(0, Math.min(points.length - 1, index + (event.key === 'ArrowLeft' ? -1 : 1)));
setHoveredTimestamp(points[next].timestamp);
};
const handleMouseMove = (event: MouseEvent) => {
const canvas = refs.getCanvas();
const points = data();
if (!canvas || points.length === 0) return;
setKeyboardInspecting(false);
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const width = rect.width;
@ -370,6 +407,7 @@ export function useHistoryChartState(
};
const handleMouseLeave = () => {
if (keyboardInspecting()) return;
setHoveredTimestamp(null);
};
@ -378,6 +416,10 @@ export function useHistoryChartState(
dataMax,
dataMin,
error,
handleFocus,
handleBlur,
handleKeyDown,
keyboardInspecting,
handleMouseLeave,
handleMouseMove,
chartHeight,