From 0be430813f16c4bd4e75cd1f0c679a86940a188f Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:02:38 +0100 Subject: [PATCH] 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 --- .../subsystems/frontend-primitives.md | 13 +++ .../browser-tests/history-keyboard.cjs | 80 +++++++++++++++++++ .../browser-tests/history-keyboard.html | 1 + .../browser-tests/history-keyboard.tsx | 48 +++++++++++ frontend-modern/browser-verification.json | 31 +++---- .../src/components/shared/HistoryChart.tsx | 15 +++- .../shared/__tests__/HistoryChart.test.tsx | 50 ++++++++++++ .../components/shared/historyChartModel.ts | 3 +- .../components/shared/useHistoryChartState.ts | 42 ++++++++++ 9 files changed, 266 insertions(+), 17 deletions(-) create mode 100644 frontend-modern/browser-tests/history-keyboard.cjs create mode 100644 frontend-modern/browser-tests/history-keyboard.html create mode 100644 frontend-modern/browser-tests/history-keyboard.tsx diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 14db02fad..c213daa7b 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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. diff --git a/frontend-modern/browser-tests/history-keyboard.cjs b/frontend-modern/browser-tests/history-keyboard.cjs new file mode 100644 index 000000000..419182ab6 --- /dev/null +++ b/frontend-modern/browser-tests/history-keyboard.cjs @@ -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; }); diff --git a/frontend-modern/browser-tests/history-keyboard.html b/frontend-modern/browser-tests/history-keyboard.html new file mode 100644 index 000000000..f04472ecf --- /dev/null +++ b/frontend-modern/browser-tests/history-keyboard.html @@ -0,0 +1 @@ +History keyboard verification
diff --git a/frontend-modern/browser-tests/history-keyboard.tsx b/frontend-modern/browser-tests/history-keyboard.tsx new file mode 100644 index 000000000..5747fb707 --- /dev/null +++ b/frontend-modern/browser-tests/history-keyboard.tsx @@ -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 ( +
+

Storage History keyboard inspection

+ + + + + + + +
+ ); +}; +render(() => , document.getElementById('root')!); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 5342ba6fd..d0574b1b3 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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" ] } diff --git a/frontend-modern/src/components/shared/HistoryChart.tsx b/frontend-modern/src/components/shared/HistoryChart.tsx index 6448b34d2..8a37f456b 100644 --- a/frontend-modern/src/components/shared/HistoryChart.tsx +++ b/frontend-modern/src/components/shared/HistoryChart.tsx @@ -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 = (props) => { > = (props) => { onMouseLeave={chart.handleMouseLeave} />

- {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. +

+

+ {chart.keyboardInspecting() && chart.hoveredPoint() + ? `${new Date(chart.hoveredPoint()!.timestamp).toLocaleString()}: ${formatHistoryChartTooltipValue(chart.hoveredPoint()!.value, props.unit)}` + : ''}

{ 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(() => ( + + )); + 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(() => ); diff --git a/frontend-modern/src/components/shared/historyChartModel.ts b/frontend-modern/src/components/shared/historyChartModel.ts index 4f8a6d251..1e9cab000 100644 --- a/frontend-modern/src/components/shared/historyChartModel.ts +++ b/frontend-modern/src/components/shared/historyChartModel.ts @@ -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; diff --git a/frontend-modern/src/components/shared/useHistoryChartState.ts b/frontend-modern/src/components/shared/useHistoryChartState.ts index 51328aaa4..0ad5c2033 100644 --- a/frontend-modern/src/components/shared/useHistoryChartState.ts +++ b/frontend-modern/src/components/shared/useHistoryChartState.ts @@ -34,6 +34,7 @@ export function useHistoryChartState( ) { const [range, setRange] = createSignal(props.range || '24h'); const [data, setData] = createSignal(props.data ?? []); + const [keyboardInspecting, setKeyboardInspecting] = createSignal(false); const [loading, setLoading] = createSignal(false); const [error, setError] = createSignal(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,