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,