From 7c48430d5a3ed0eefc677b6a5ccc50ee4bc0920a Mon Sep 17 00:00:00 2001
From: "pulse-triage[bot]"
<249995291+pulse-triage[bot]@users.noreply.github.com>
Date: Fri, 2 Oct 2026 21:45:50 +0100
Subject: [PATCH] Make stored History readings inspectable by touch
Real Chromium and WebKit first taps selected the latest reading instead of the tapped observation. Select and retain the actual sample across compatibility focus and mouse events, shared-chart focus transfer and matching refreshes. Reject pan, cancellation and multiple contacts without preventing native scrolling. Preserve mouse and keyboard inspection, source ownership, units and access.
Change-source: pulse-maintainer
---
.../subsystems/frontend-primitives.md | 23 +++
.../internal/subsystems/storage-recovery.md | 9 +
.../browser-tests/history-touch.cjs | 181 ++++++++++++++++
.../browser-tests/history-touch.html | 1 +
.../browser-tests/history-touch.tsx | 60 ++++++
frontend-modern/browser-verification.json | 54 +++--
.../src/components/shared/HistoryChart.tsx | 9 +-
.../shared/__tests__/HistoryChart.test.tsx | 193 ++++++++++++++++++
.../components/shared/useHistoryChartState.ts | 79 ++++++-
9 files changed, 574 insertions(+), 35 deletions(-)
create mode 100644 frontend-modern/browser-tests/history-touch.cjs
create mode 100644 frontend-modern/browser-tests/history-touch.html
create mode 100644 frontend-modern/browser-tests/history-touch.tsx
diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index d945c80542..e0d9578876 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -7729,3 +7729,26 @@ actual series pixels and tooltips in desktop Chromium and phone WebKit, using
the production chart and API transport with synthetic stored samples. This
does not establish native collection, whole-report resolution or availability
in a release. Existing request, source, units and access boundaries are unchanged.
+
+### Shared History touch inspection preserves native scrolling
+
+A primary touch tap inside a Storage History plot selects the nearest actual
+observation at that horizontal position. Compatibility mouse focus must not
+replace that reading with the latest sample or announce it as keyboard input.
+The existing hover group and timestamp reconciliation remain shared with mouse
+and keyboard inspection. Single measured-zero observations remain inspectable;
+empty data and a changed selection cannot inherit an old touch.
+
+Movement beyond ten CSS pixels, cancellation, multiple contacts and release
+outside the plot are not taps. Inspection neither prevents native touch defaults
+nor captures the pointer or changes touch-action: page scrolling and pinch zoom
+remain browser-owned. Blur and Escape clear inspection, and supported keyboard
+keys resume the existing dated, politely announced sample navigation.
+
+Mounted `HistoryChart.test.tsx` regressions cover focus ordering, gesture
+rejection, selection ownership, refresh, empty/zero and input coexistence.
+`browser-tests/history-touch.cjs` exercises actual touchscreen taps through the
+production disk/pool details and API transport in phone Chromium/WebKit, native
+vertical panning in Chromium, and desktop mouse/keyboard controls. Synthetic
+sample presentation is not native collector, physical device, assistive-device
+or installed whole-report acceptance.
diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md
index 4806fe41a8..ad7c1b1ee9 100644
--- a/docs/release-control/v6/internal/subsystems/storage-recovery.md
+++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md
@@ -6295,3 +6295,12 @@ This changes no collector, health thresholds, history policy or server identity.
`frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx` exercises
snapshot replacement, in-place reconciliation, missing evidence, target changes
and fault/recovery using the real keyed renderer.
+
+Touch inspection in the existing pool and physical-disk History charts selects
+an actual stored reading rather than substituting the latest sample on focus.
+Page pan/cancel and multi-contact gestures do not imply a selected observation;
+browser-owned scrolling remains available. This changes no source target, API,
+units, collection, retention, access or recovery boundary. The production
+disk/pool fixture `frontend-modern/browser-tests/history-touch.cjs` and mounted
+`HistoryChart.test.tsx` regressions verify presentation/input behaviour only,
+not live appliance collection or recovery success.
diff --git a/frontend-modern/browser-tests/history-touch.cjs b/frontend-modern/browser-tests/history-touch.cjs
new file mode 100644
index 0000000000..d00d447fb3
--- /dev/null
+++ b/frontend-modern/browser-tests/history-touch.cjs
@@ -0,0 +1,181 @@
+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-touch-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: 5227, strictPort: true },
+ });
+ const parent = false;
+ let browser;
+ const observations = [];
+ const timestamp = Date.parse('2026-10-02T10:00:00Z');
+ const point = (value, index = 0) => ({ timestamp: timestamp + index * 60000, value, min: value, max: value });
+ const playwright = require('playwright/package.json').version;
+ const expectedPlaywright = JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json', 'utf8')).packages['node_modules/@playwright/test'].version;
+ assert.equal(playwright, expectedPlaywright);
+ try {
+ await server.listen();
+ const configs = [
+ ['chromium-desktop', chromium, false, false],
+ ['chromium-phone', chromium, true, false],
+ ['webkit-phone', webkit, true, true],
+ ];
+ for (const [name, engine, touch, dark] of configs) {
+ if (parent && !touch) continue;
+ browser = await engine.launch(engine === chromium ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } : { headless: true });
+ const page = await browser.newPage({
+ viewport: touch ? { width: 390, height: 844 } : { width: 1365, height: 900 },
+ hasTouch: touch, isMobile: touch, timezoneId: 'Europe/London',
+ });
+ page.setDefaultTimeout(15000);
+ const errors = [];
+ const requests = [];
+ page.on('pageerror', error => errors.push(error.message));
+ let samples = [point(10), point(20, 1), point(30, 2)];
+ await page.route('**/*', route => {
+ const url = new URL(route.request().url());
+ if (url.origin !== 'http://127.0.0.1:5227') return route.abort();
+ if (!url.pathname.startsWith('/api/')) return route.continue();
+ if (url.pathname === '/api/metrics-store/history') {
+ requests.push(Object.fromEntries(url.searchParams));
+ return route.fulfill({ json: { points: samples, source: 'store' } });
+ }
+ 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:5227/browser-tests/history-touch.html');
+ if (dark) await page.evaluate(() => document.documentElement.classList.add('dark'));
+ const disk = page.getByRole('region', { name: 'Disk fixture' });
+ const pool = page.getByRole('region', { name: 'Pool fixture' });
+ await disk.getByRole('tab', { name: 'History', exact: true }).click();
+ const busy = disk.getByRole('img', { name: 'Busy chart', exact: true });
+ const description = chart => chart.evaluate(el => document.getElementById(el.getAttribute('aria-describedby')).textContent);
+ await page.waitForFunction(() => [...document.querySelectorAll('canvas')].some(el => el.getAttribute('aria-label') === 'Busy chart' && document.getElementById(el.getAttribute('aria-describedby')).textContent.includes('3 data points')));
+ const tooltip = chart => chart.locator('..').locator('[data-history-chart-tooltip]');
+ const live = chart => chart.locator('..').locator('[aria-live="polite"]');
+ async function inspect(chart, fraction) {
+ await chart.scrollIntoViewIfNeeded();
+ const box = await chart.boundingBox();
+ // Percent labels reserve 40px at left; the time label reserves less than
+ // 25px at right. These interior positions are nearest to each real sample.
+ const x = box.x + 45 + (box.width - 70) * fraction;
+ const y = box.y + box.height / 2;
+ if (touch) await page.touchscreen.tap(x, y);
+ else await page.mouse.move(x, y);
+ await page.waitForTimeout(80);
+ return (await tooltip(chart).count()) ? await tooltip(chart).textContent() : null;
+ }
+ async function record(phase, chart, text) {
+ const dimensions = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, width: innerWidth }));
+ assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions));
+ observations.push({ name, browser: browser.version(), touch, dark, phase, text, description: await description(chart), dimensions });
+ await page.screenshot({ path: path.join(artifacts, `${parent ? 'parent-' : ''}${name}-${phase}.png`) });
+ }
+ if (parent) {
+ for (const [phase, fraction, expected] of [['first', 0.04, '10.0%'], ['middle', 0.5, '20.0%']]) {
+ const text = await inspect(busy, fraction);
+ if (phase === 'first') assert.ok(!text?.includes(expected), `predecessor unexpectedly inspected the first tap: ${text}`);
+ await record(phase, busy, text);
+ }
+ } else {
+ for (const [phase, fraction, expected] of [['first', 0.04, '10.0%'], ['middle', 0.5, '20.0%'], ['last', 0.96, '30.0%']]) {
+ const text = await inspect(busy, fraction);
+ assert.ok(text?.includes(expected), `${name} ${phase}: ${text}`);
+ assert.equal(await live(busy).textContent(), '', 'pointer inspection must not speak keyboard announcements');
+ assert.equal(await disk.locator('[data-history-chart-tooltip]').count(), 3, 'live I/O group shares inspection');
+ await record(phase, busy, text);
+ }
+ if (touch) {
+ const read = disk.getByRole('img', { name: 'Read chart', exact: true });
+ assert.ok((await inspect(read, 0.5))?.includes('20.0 B/s'));
+ assert.ok((await tooltip(busy).textContent()).includes('20.0%'), 'a peer blur must not clear the new group tap');
+ assert.equal(await live(read).textContent(), '');
+ await inspect(busy, 0.5);
+ // A hybrid device may switch back to a real mouse after a tap.
+ await busy.scrollIntoViewIfNeeded();
+ const box = await busy.boundingBox();
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ assert.ok((await tooltip(busy).textContent()).includes('20.0%'));
+ await page.mouse.move(0, 0);
+ assert.equal(await tooltip(busy).count(), 0);
+ }
+ await busy.focus();
+ for (const [key, expected] of [['Home', '10.0%'], ['ArrowRight', '20.0%'], ['End', '30.0%']]) {
+ await page.keyboard.press(key);
+ assert.ok((await live(busy).textContent()).includes(expected));
+ }
+ await page.keyboard.press('Escape');
+ assert.equal(await tooltip(busy).count(), 0);
+ await page.getByRole('button', { name: 'After charts' }).focus();
+ assert.equal(await live(busy).textContent(), '');
+
+ if (touch && engine === chromium) {
+ await busy.scrollIntoViewIfNeeded();
+ await inspect(busy, 0.5);
+ const box = await busy.boundingBox();
+ const session = await page.context().newCDPSession(page);
+ await busy.evaluate(el => {
+ el.dataset.cancelCount = '0';
+ el.addEventListener('pointercancel', () => el.dataset.cancelCount = String(Number(el.dataset.cancelCount) + 1));
+ });
+ const scrollMetrics = await page.evaluate(() => ({ before: scrollY, maximum: document.documentElement.scrollHeight - innerHeight }));
+ assert.ok(scrollMetrics.maximum > scrollMetrics.before + 200, 'the fixture must have room to scroll');
+ const before = scrollMetrics.before;
+ const x = box.x + box.width / 2, y = box.y + box.height / 2;
+ await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y, id: 1 }] });
+ for (let step = 1; step <= 5; step++) {
+ await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x, y: y - step * 20, id: 1 }] });
+ await page.waitForTimeout(50);
+ }
+ await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
+ await page.waitForTimeout(300);
+ const after = await page.evaluate(() => scrollY);
+ fs.writeFileSync(path.join(artifacts, 'pan-control.json'), JSON.stringify({before, after, pointerCancels: await busy.getAttribute('data-cancel-count'), touchAction: await busy.evaluate(el => getComputedStyle(el).touchAction)},null,2));
+ assert.ok(after > before + 40, `native pan must scroll: ${before} -> ${after}`);
+ assert.ok(Number(await busy.getAttribute('data-cancel-count')) > 0);
+ assert.equal(await tooltip(busy).count(), 0, 'native scrolling must not select a reading');
+ observations.push({ name, phase: 'native-vertical-pan', before, after, maximum: scrollMetrics.maximum, pointerCancels: Number(await busy.getAttribute('data-cancel-count')), touchAction: await busy.evaluate(el => getComputedStyle(el).touchAction) });
+ await session.detach();
+ }
+
+ await pool.getByRole('tab', { name: 'History', exact: true }).click();
+ const usage = pool.getByRole('img', { name: 'Usage chart', exact: true });
+ await usage.waitFor();
+ await page.waitForFunction(() => [...document.querySelectorAll('canvas')].some(el => el.getAttribute('aria-label') === 'Usage chart' && document.getElementById(el.getAttribute('aria-describedby')).textContent.includes('3 data points')));
+ assert.ok((await inspect(usage, 0.04))?.includes('10.0%'));
+ await record('pool-first', usage, await tooltip(usage).textContent());
+ assert.equal(await pool.getByLabel('Capacity history range').inputValue(), '7d');
+ samples = [point(0)];
+ await pool.getByLabel('Capacity history range').selectOption('24h');
+ await page.waitForFunction(() => [...document.querySelectorAll('canvas')].some(el => el.getAttribute('aria-label') === 'Usage chart' && document.getElementById(el.getAttribute('aria-describedby')).textContent.includes('1 data point')));
+ assert.ok((await inspect(usage, 0.5))?.includes('0.0%'));
+ await record('pool-zero-singleton', usage, await tooltip(usage).textContent());
+ samples = [];
+ await pool.getByLabel('Capacity history range').selectOption('7d');
+ await pool.getByText('No history samples in this time range.', { exact: true }).waitFor();
+ assert.equal(await inspect(usage, 0.5), null);
+ assert.equal(await live(usage).textContent(), '');
+ await record('pool-empty', usage, null);
+ assert.ok(requests.some(request => request.resourceType === 'disk' && request.resourceId === 'fixture-disk'));
+ assert.ok(requests.some(request => request.resourceType === 'storage' && request.resourceId === 'fixture-pool'));
+ }
+ assert.deepEqual(errors, []);
+ await browser.close(); browser = null;
+ }
+ fs.writeFileSync(path.join(artifacts, parent ? 'parent.json' : 'result.json'), JSON.stringify({ playwright, expectedPlaywright, parent, observations }, null, 2));
+ console.log(JSON.stringify({ result: 'passed', parent, observations: 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-touch.html b/frontend-modern/browser-tests/history-touch.html
new file mode 100644
index 0000000000..ec56435f82
--- /dev/null
+++ b/frontend-modern/browser-tests/history-touch.html
@@ -0,0 +1 @@
+
Storage History touch verification
diff --git a/frontend-modern/browser-tests/history-touch.tsx b/frontend-modern/browser-tests/history-touch.tsx
new file mode 100644
index 0000000000..c5eef346b3
--- /dev/null
+++ b/frontend-modern/browser-tests/history-touch.tsx
@@ -0,0 +1,60 @@
+import { render } from 'solid-js/web';
+import { DiskDetail } from '../src/components/Storage/DiskDetail';
+import { StoragePoolDetail } from '../src/components/Storage/StoragePoolDetail';
+import type { StorageRecord } from '../src/features/storageBackups/models';
+import type { Resource } from '../src/types/resource';
+import '../src/index.css';
+
+const disk = {
+ id: 'fixture-disk',
+ type: 'physical_disk',
+ name: 'Archive HDD',
+ displayName: 'Archive HDD',
+ platformType: 'proxmox-pve',
+ platformId: 'fixture',
+ sourceType: 'api',
+ status: 'online',
+ lastSeen: Date.now(),
+ identity: { hostname: 'storage-host' },
+ metricsTarget: { resourceType: 'disk', resourceId: 'fixture-disk' },
+ physicalDisk: { devPath: '/dev/sda', model: 'Archive HDD', diskType: 'hdd', temperature: 42 },
+} as Resource;
+
+const pool: StorageRecord = {
+ id: 'fixture-pool',
+ name: 'Archive',
+ category: 'datastore',
+ health: 'unknown',
+ location: { label: 'archive-host', scope: 'host' },
+ source: {
+ platform: 'proxmox-pbs',
+ family: 'onprem',
+ origin: 'resource',
+ adapterId: 'resource-storage',
+ },
+ capacity: { totalBytes: 1024, usedBytes: 512, freeBytes: 512, usagePercent: 50 },
+ capabilities: ['capacity'],
+ observedAt: Date.now(),
+};
+
+render(
+ () => (
+
+ Storage History touch verification
+ Synthetic stored samples in production disk and pool details.
+
+
+ After charts
+
+
+ ),
+ document.getElementById('root')!,
+);
diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json
index d836db5f31..395e434f7a 100644
--- a/frontend-modern/browser-verification.json
+++ b/frontend-modern/browser-verification.json
@@ -1,18 +1,18 @@
{
"version": 1,
- "base_sha": "ad7f7a1063d45e26f77faf35bacbca6877f41cac",
- "verified_at": "2026-10-02T18:37:50.309899Z",
+ "base_sha": "e5d0843851a3837a90d71afaced3a009c0606bbf",
+ "verified_at": "2026-10-02T20:44:41.220410Z",
"result": "passed",
"changed_paths": [
- "frontend-modern/src/components/shared/historyChartModel.ts",
+ "frontend-modern/src/components/shared/HistoryChart.tsx",
"frontend-modern/src/components/shared/useHistoryChartState.ts"
],
"content_sha256": {
- "frontend-modern/src/components/shared/historyChartModel.ts": "c96437081022b073dca0c730a8617954987f712ee84e058c6078ae3cad05f162",
- "frontend-modern/src/components/shared/useHistoryChartState.ts": "38af5f416fb95874e708b08d66fdbcf27b85660840e0096cb1dd24d9bc744991"
+ "frontend-modern/src/components/shared/HistoryChart.tsx": "74fb7e6b466e1c2b2d2efb13e0a124d6c301896a7c9423432e903908cc9ee25b",
+ "frontend-modern/src/components/shared/useHistoryChartState.ts": "d6e8317019c8dc616bd9a4d3c6c71cb5329297ca0341e6ba28a6cc4c84db36b0"
},
"routes": [
- "/browser-tests/history-sparse.html"
+ "/browser-tests/history-touch.html"
],
"viewports": [
{
@@ -25,37 +25,33 @@
}
],
"states": [
- "Single stored 42% before inspection, pointer inspection, measured zero, multiple observations, successful empty response, restored singleton",
- "Desktop Chromium light and phone WebKit dark-class presentation; 12 observations, no page errors or horizontal overflow"
+ "Production disk/pool details with stored multi-point, measured-zero singleton and empty history; 19 observed presentations across desktop Chromium and phone Chromium/WebKit",
+ "Actual touchscreen first/middle/last taps, shared I/O group focus transfer, keyboard navigation/exit and real mouse coexistence; phone WebKit dark-class presentation"
],
"interactions": [
- "Pointer at three plot positions resolves to the real singleton timestamp; no mouse live announcements",
- "Tab, Home/End, Left/Right, Escape and focus exit; matching API refreshes across sparse, populated and empty results",
- "Both actual predecessor engines reproduce zero series pixels and missing pointer tooltip. Parent phone and final phone singleton/tooltip/zero/empty plus final desktop multi screenshots inspected."
+ "Native touchscreen taps select the actual reading at that position instead of the latest sample; touch compatibility focus/mouse movement/leave cannot overwrite it",
+ "Primary contact cancellation, non-tap gesture rejection and native Chromium vertical pan with room-to-scroll precondition; no prevented touch default, pointer capture or touch-action change",
+ "Desktop/phone keyboard Home/Right/End/Escape and focus exit, pointer announcements remain quiet, range replacement, zero/empty responses and no horizontal overflow",
+ "Predecessor native Chromium/WebKit first tap near 10% instead displays 30%; final first/middle/last taps display 10/20/30%. Inspected predecessor and final phone, zero/empty and desktop screenshots"
],
- "command": "pulse-worker-browser frontend-modern/browser-tests/history-sparse.cjs",
+ "command": "pulse-worker-browser frontend-modern/browser-tests/history-touch.cjs",
"browser_versions": {
"playwright": "1.56.1",
"chromium": "141.0.7390.37",
"webkit": "26.0"
},
"artifacts": [
- "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/result.json",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/parent.json",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/parent-chromium-single.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/parent-webkit-single.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-single.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-pointer.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-zero.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-multiple.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-empty.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-recovered-single.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-single.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-pointer.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-zero.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-multiple.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-empty.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-recovered-single.png"
+ "browser/result.json",
+ "browser/chromium-phone-first.png",
+ "browser/webkit-phone-first.png",
+ "browser/webkit-phone-middle.png",
+ "browser/webkit-phone-pool-first.png",
+ "browser/webkit-phone-pool-zero-singleton.png",
+ "browser/webkit-phone-pool-empty.png",
+ "browser/chromium-desktop-middle.png",
+ "browser/parent.json",
+ "browser/parent-chromium-phone-first.png",
+ "browser/parent-webkit-phone-first.png"
],
- "limitations": "Synthetic samples, not native PBS/TrueNAS collection, assistive-device testing or whole-application theme-transition acceptance."
+ "limitations": "Synthetic stored APIs, not native PBS/TrueNAS collection, physical phones or assistive-device speech. Native pan is Chromium-only; no physical/WebKit pan, pinch-zoom or whole-app theme-transition claim. Initial fixture and pan-driver failures are retained separately."
}
diff --git a/frontend-modern/src/components/shared/HistoryChart.tsx b/frontend-modern/src/components/shared/HistoryChart.tsx
index ac36f7f4e7..47841fccb2 100644
--- a/frontend-modern/src/components/shared/HistoryChart.tsx
+++ b/frontend-modern/src/components/shared/HistoryChart.tsx
@@ -78,10 +78,15 @@ export const HistoryChart: Component = (props) => {
aria-describedby={descriptionId}
onMouseMove={chart.handleMouseMove}
onMouseLeave={chart.handleMouseLeave}
+ onPointerDown={chart.handlePointerDown}
+ onPointerMove={chart.handlePointerMove}
+ onPointerUp={chart.handlePointerUp}
+ onPointerCancel={chart.handlePointerCancel}
/>
- {refreshStatus()} {accessibleDescription()} Use Left and Right arrow keys to inspect
- readings, Home and End for the first and last reading, and Escape to clear inspection.
+ {refreshStatus()} {accessibleDescription()} Tap the chart to inspect a reading, or use
+ Left and Right arrow keys, Home and End for the first and last reading, and Escape to
+ clear inspection.
{chart.keyboardInspecting() && chart.hoveredPoint()
diff --git a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx
index d2eb0a84b9..8729c8a2ba 100644
--- a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx
+++ b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx
@@ -15,6 +15,63 @@ import {
HISTORY_CHART_RANGES,
} from '@/components/shared/historyChartModel';
+function touchPointer(canvas: Element, type: string, fields: Partial = {}) {
+ const event = Object.assign(new Event(type, { bubbles: true, cancelable: true }), {
+ pointerId: 1,
+ pointerType: 'touch',
+ isPrimary: true,
+ clientX: 60,
+ clientY: 60,
+ ...fields,
+ });
+ fireEvent(canvas, event);
+ // Inspection must never consume native page panning or pinch zoom.
+ expect(event.defaultPrevented).toBe(false);
+}
+
+function mountTouchHistory() {
+ const [target, setTarget] = createSignal('disk-a');
+ const [data, setData] = createSignal(
+ [10, 20, 30].map((value, index) => ({
+ timestamp: (index + 1) * 1000,
+ value,
+ min: value,
+ max: value,
+ })),
+ );
+ const view = render(() => (
+
+ ));
+ const canvas = view.container.querySelector('canvas')!;
+ vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
+ x: 0,
+ y: 0,
+ left: 0,
+ top: 0,
+ right: 400,
+ bottom: 200,
+ width: 400,
+ height: 200,
+ toJSON: () => ({}),
+ });
+ return {
+ ...view,
+ canvas,
+ setTarget,
+ data,
+ setData,
+ tooltip: () => view.container.querySelector('[data-history-chart-tooltip]'),
+ announcement: view.container.querySelector('[aria-live="polite"]')!,
+ };
+}
+
if (typeof globalThis.ResizeObserver === 'undefined') {
globalThis.ResizeObserver = class ResizeObserver {
observe() {}
@@ -181,6 +238,142 @@ describe('HistoryChart', () => {
expect(announcement.textContent).toBe('');
});
+ it('inspects the tapped sample without compatibility focus selecting the latest reading', () => {
+ const view = mountTouchHistory();
+ touchPointer(view.canvas, 'pointerdown');
+ touchPointer(view.canvas, 'pointerup');
+ fireEvent.focus(view.canvas);
+ expect(view.tooltip()).toHaveTextContent('10.0%');
+ expect(view.announcement.textContent).toBe('');
+ fireEvent.mouseMove(view.canvas, { clientX: 370 });
+ fireEvent.mouseLeave(view.canvas);
+ expect(view.tooltip()).toHaveTextContent('10.0%');
+ touchPointer(view.canvas, 'pointerdown', { clientX: 200 });
+ touchPointer(view.canvas, 'pointerup', { clientX: 200 });
+ expect(view.tooltip()).toHaveTextContent('20.0%');
+ fireEvent.keyDown(view.canvas, { key: 'ArrowRight' });
+ expect(view.announcement).toHaveTextContent('30.0%');
+ fireEvent.keyDown(view.canvas, { key: 'Escape' });
+ expect(view.tooltip()).toBeNull();
+ fireEvent.blur(view.canvas);
+ fireEvent.focus(view.canvas);
+ expect(view.announcement).toHaveTextContent('30.0%');
+ });
+
+ it.each(['moved', 'release-displaced', 'cancelled', 'second-finger', 'different-pointer'])(
+ 'does not turn a %s touch into inspection',
+ (gesture) => {
+ const view = mountTouchHistory();
+ touchPointer(view.canvas, 'pointerdown');
+ if (gesture === 'moved') {
+ touchPointer(view.canvas, 'pointermove', { clientY: 90 });
+ // Returning to the origin is still a drag, not a new tap.
+ touchPointer(view.canvas, 'pointermove');
+ } else if (gesture === 'cancelled') {
+ touchPointer(view.canvas, 'pointercancel');
+ } else if (gesture === 'second-finger') {
+ touchPointer(view.canvas, 'pointerdown', { pointerId: 2, isPrimary: false });
+ }
+ touchPointer(view.canvas, 'pointerup', {
+ ...(gesture === 'release-displaced' ? { clientY: 90 } : {}),
+ ...(gesture === 'different-pointer' ? { pointerId: 2 } : {}),
+ });
+ fireEvent.focus(view.canvas);
+ expect(view.tooltip()).toBeNull();
+ expect(view.announcement.textContent).toBe('');
+ },
+ );
+
+ it.each([{ clientX: 0 }, { clientX: 400 }, { clientY: -1 }, { clientY: 201 }])(
+ 'does not inspect a tap outside the plot: %j',
+ (position) => {
+ const view = mountTouchHistory();
+ touchPointer(view.canvas, 'pointerdown', position);
+ touchPointer(view.canvas, 'pointerup', position);
+ expect(view.tooltip()).toBeNull();
+ },
+ );
+
+ it('invalidates an in-flight touch when the resource changes', () => {
+ const view = mountTouchHistory();
+ touchPointer(view.canvas, 'pointerdown');
+ view.setTarget('disk-b');
+ touchPointer(view.canvas, 'pointerup');
+ fireEvent.focus(view.canvas);
+ expect(view.tooltip()).toBeNull();
+ expect(view.announcement.textContent).toBe('');
+ touchPointer(view.canvas, 'pointerdown', { clientX: 200 });
+ touchPointer(view.canvas, 'pointerup', { clientX: 200 });
+ expect(view.tooltip()).toHaveTextContent('20.0%');
+ });
+
+ it('keeps the tapped time on refresh and distinguishes a lone zero from empty history', () => {
+ const view = mountTouchHistory();
+ touchPointer(view.canvas, 'pointerdown');
+ touchPointer(view.canvas, 'pointerup');
+ view.setData(view.data().map((point) => ({ ...point, value: point.value + 1 })));
+ expect(view.tooltip()).toHaveTextContent('11.0%');
+ view.setData([{ timestamp: 1000, value: 0, min: 0, max: 0 }]);
+ touchPointer(view.canvas, 'pointerdown', { clientX: 200 });
+ touchPointer(view.canvas, 'pointerup', { clientX: 200 });
+ expect(view.tooltip()).toHaveTextContent('0.0%');
+ view.setData([]);
+ touchPointer(view.canvas, 'pointerdown');
+ touchPointer(view.canvas, 'pointerup');
+ fireEvent.focus(view.canvas);
+ expect(view.tooltip()).toBeNull();
+ expect(view.announcement.textContent).toBe('');
+ });
+
+ it('leaves mouse hover available after touch inspection', () => {
+ const view = mountTouchHistory();
+ touchPointer(view.canvas, 'pointerdown');
+ touchPointer(view.canvas, 'pointerup');
+ touchPointer(view.canvas, 'pointermove', { pointerType: 'mouse', clientX: 200 });
+ fireEvent.mouseMove(view.canvas, { clientX: 200 });
+ expect(view.tooltip()).toHaveTextContent('20.0%');
+ fireEvent.mouseLeave(view.canvas);
+ expect(view.tooltip()).toBeNull();
+ });
+
+ it('keeps a new group chart tap when the previously focused chart blurs', () => {
+ const points = [10, 20, 30].map((value, index) => ({
+ timestamp: (index + 1) * 1000,
+ value,
+ min: value,
+ max: value,
+ }));
+ const { container } = render(() => (
+
+
+
+
+ ));
+ const [previous, next] = container.querySelectorAll('canvas');
+ vi.spyOn(next, 'getBoundingClientRect').mockReturnValue({
+ x: 0,
+ y: 0,
+ left: 0,
+ top: 0,
+ right: 400,
+ bottom: 200,
+ width: 400,
+ height: 200,
+ toJSON: () => ({}),
+ });
+ fireEvent.focus(previous);
+ touchPointer(next, 'pointerdown');
+ touchPointer(next, 'pointerup');
+ fireEvent.blur(previous);
+ fireEvent.focus(next);
+ const tooltips = container.querySelectorAll('[data-history-chart-tooltip]');
+ expect(tooltips).toHaveLength(2);
+ for (const tooltip of tooltips) expect(tooltip).toHaveTextContent('10.0%');
+ for (const live of container.querySelectorAll('[aria-live="polite"]')) {
+ expect(live.textContent).toBe('');
+ }
+ });
+
it('renders the default history label', () => {
render(() => );
diff --git a/frontend-modern/src/components/shared/useHistoryChartState.ts b/frontend-modern/src/components/shared/useHistoryChartState.ts
index 93d36881d5..2ccfa69c54 100644
--- a/frontend-modern/src/components/shared/useHistoryChartState.ts
+++ b/frontend-modern/src/components/shared/useHistoryChartState.ts
@@ -48,6 +48,9 @@ export function useHistoryChartState(
const chartHeight = createMemo(() => props.height || 200);
let chartLeftInset = HISTORY_CHART_MIN_LEFT_INSET;
let chartRightInset = 0;
+ let touchFocus = false;
+ let touchTimestamp: number | null = null;
+ let pendingTouch: { pointerId: number; x: number; y: number; moved: boolean } | undefined;
const hoveredTimestamp = hoverGroup?.hoveredTimestamp ?? localHoveredTimestamp;
const setHoveredTimestamp = hoverGroup?.setHoveredTimestamp ?? setLocalHoveredTimestamp;
@@ -132,6 +135,8 @@ export function useHistoryChartState(
suppliedData !== undefined,
]);
if (selection !== previousSelection) {
+ pendingTouch = undefined;
+ touchTimestamp = null;
setHoveredPoint(null);
setHoveredTimestamp(null);
}
@@ -376,12 +381,21 @@ export function useHistoryChartState(
});
const handleFocus = () => {
+ // Touch's compatibility mouse events can focus the canvas after pointerup.
+ // That focus must not replace the tapped reading with the latest sample.
+ if (touchFocus) {
+ setHoveredTimestamp(touchTimestamp);
+ return;
+ }
setKeyboardInspecting(true);
const points = data();
setHoveredTimestamp(points.length ? points[points.length - 1].timestamp : null);
};
const handleBlur = () => {
+ touchFocus = false;
+ touchTimestamp = null;
+ pendingTouch = undefined;
setKeyboardInspecting(false);
setHoveredTimestamp(null);
};
@@ -389,12 +403,18 @@ export function useHistoryChartState(
const handleKeyDown = (event: KeyboardEvent) => {
if (event.altKey || event.ctrlKey || event.metaKey) return;
if (event.key === 'Escape') {
+ touchFocus = false;
+ touchTimestamp = null;
+ pendingTouch = undefined;
setHoveredTimestamp(null);
return;
}
const points = data();
if (!points.length || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
+ touchFocus = false;
+ touchTimestamp = null;
+ pendingTouch = undefined;
setKeyboardInspecting(true);
const timestamp = hoveredTimestamp();
const index =
@@ -410,14 +430,14 @@ export function useHistoryChartState(
setHoveredTimestamp(points[next].timestamp);
};
- const handleMouseMove = (event: MouseEvent) => {
+ const inspectAtClientX = (clientX: number, snapToSample = false) => {
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 x = clientX - rect.left;
const width = rect.width;
if (x < chartLeftInset || x > width - chartRightInset) {
setHoveredTimestamp(null);
@@ -434,11 +454,58 @@ export function useHistoryChartState(
leftInset: chartLeftInset,
rightInset: chartRightInset,
});
- setHoveredTimestamp(geometry.getTimestamp(x));
+ const timestamp = geometry.getTimestamp(x);
+ const inspectedTimestamp = snapToSample
+ ? findHistoryChartClosestPoint(points, timestamp).timestamp
+ : timestamp;
+ setHoveredTimestamp(inspectedTimestamp);
+ return inspectedTimestamp;
+ };
+
+ const handleMouseMove = (event: MouseEvent) => {
+ if (!touchFocus) inspectAtClientX(event.clientX);
+ };
+
+ const handlePointerDown = (event: PointerEvent) => {
+ if (event.pointerType === 'mouse') touchFocus = false;
+ if (event.pointerType !== 'touch') return;
+ touchFocus = true;
+ touchTimestamp = null;
+ setKeyboardInspecting(false);
+ setHoveredTimestamp(null);
+ pendingTouch = event.isPrimary
+ ? { pointerId: event.pointerId, x: event.clientX, y: event.clientY, moved: false }
+ : undefined;
+ };
+
+ const handlePointerMove = (event: PointerEvent) => {
+ if (event.pointerType === 'mouse') touchFocus = false;
+ if (event.pointerType !== 'touch' || pendingTouch?.pointerId !== event.pointerId) return;
+ if (Math.hypot(event.clientX - pendingTouch.x, event.clientY - pendingTouch.y) > 10) {
+ pendingTouch.moved = true;
+ }
+ };
+
+ const handlePointerUp = (event: PointerEvent) => {
+ if (event.pointerType !== 'touch') return;
+ const touch = pendingTouch;
+ pendingTouch = undefined;
+ if (!touch || !event.isPrimary || touch.pointerId !== event.pointerId || touch.moved) return;
+ if (Math.hypot(event.clientX - touch.x, event.clientY - touch.y) > 10) return;
+ const rect = refs.getCanvas()?.getBoundingClientRect();
+ if (!rect || event.clientY < rect.top || event.clientY > rect.bottom) return;
+ touchTimestamp = inspectAtClientX(event.clientX, true) ?? null;
+ };
+
+ const handlePointerCancel = (event: PointerEvent) => {
+ if (event.pointerType !== 'touch') return;
+ pendingTouch = undefined;
+ touchTimestamp = null;
+ setHoveredTimestamp(null);
};
const handleMouseLeave = () => {
- if (keyboardInspecting()) return;
+ if (keyboardInspecting() || touchFocus) return;
setHoveredTimestamp(null);
};
@@ -454,6 +521,10 @@ export function useHistoryChartState(
keyboardInspecting,
handleMouseLeave,
handleMouseMove,
+ handlePointerDown,
+ handlePointerMove,
+ handlePointerUp,
+ handlePointerCancel,
chartHeight,
chartWidth,
hoveredPoint,