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) => {
>