diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index c3bac1b5c9..560a03e88d 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -401,9 +401,20 @@ priority. Expandable platform summary rows use `getPlatformResourceDetailRowInteractionProps` (or -`createPlatformResourceDetailState`, which owns the same state contract) for -whole-row pointer activation, Enter/Space keyboard activation, focus treatment, -`aria-expanded` / `aria-controls`, and exclusion of embedded links and controls. +`createPlatformResourceDetailState` for their expansion state) for whole-row +pointer activation and exclusion of embedded links and controls. The nested +native disclosure button owns keyboard activation, focus and +`aria-expanded` / `aria-controls`; static table rows are not duplicate controls. +The shared `TableRow` installs a non-activating native click target only while +an `onClick` action exists. WebKit touch must be able to synthesize that row's +click, but actions remain document-delegated so embedded controls can stop +propagation before the row action runs. Do not move the action into the native +compatibility listener or add touch-end activation that opens rows during +scrolling. Explicit caller-owned native listeners remain caller-owned. +`Table.test.tsx` covers native binding, removal, single activation, child +isolation and bound handlers; `PulseDataGrid.test.tsx` checks inheritance. +The production PBS table/drawer browser fixture exercises first-tap disclosure +and History in WebKit phone emulation, not installed collection or devices. `PlatformResourceDetailToggleButton` is the desktop disclosure affordance and is visually removed on phone layouts where the complete row is the touch target; provider tables must not add a second mobile chevron. When row activation diff --git a/frontend-modern/browser-tests/table-row-touch.cjs b/frontend-modern/browser-tests/table-row-touch.cjs new file mode 100644 index 0000000000..025a5cd043 --- /dev/null +++ b/frontend-modern/browser-tests/table-row-touch.cjs @@ -0,0 +1,245 @@ +// Real production table rows and PBS drawers; synthetic bounded HTTP data. +// Do not instrument rows with native listeners: doing so masks the WebKit bug. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createHash } = require('node:crypto'); +const { chromium, firefox, webkit } = require('playwright'); + +(async () => { + const root = '/workspace/frontend-modern'; + process.chdir(root); + const engine = process.argv.find((arg) => arg.startsWith('--engine='))?.split('=')[1] || 'webkit'; + const theme = process.argv.includes('--dark') ? 'dark' : 'light'; + const width = process.argv.includes('--desktop') ? 1365 : 390; + assert.ok(['chromium', 'firefox', 'webkit'].includes(engine)); + const out = path.join(root, 'node_modules', `table-row-touch-${engine}-${width}-${theme}`); + fs.mkdirSync(out, { recursive: true }); + fs.chmodSync(out, 0o755); + 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(out, 'vite-cache-')), + server: { host: '127.0.0.1', port: 5227, strictPort: true }, + }); + const sourceHashes = Object.fromEntries( + ['src/components/shared/Table.tsx', 'package-lock.json'].map((file) => [ + file, + createHash('sha256') + .update(fs.readFileSync(path.join(root, file))) + .digest('hex'), + ]), + ); + const playwrightVersion = require('playwright/package.json').version; + assert.equal( + playwrightVersion, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + let browser, page; + const errors = [], + requests = [], + observations = []; + const snapshot = async (name) => { + const file = path.join(out, name + '.png'); + await page.screenshot({ path: file }); + fs.chmodSync(file, 0o644); + }; + const activate = (locator) => (width === 390 ? locator.tap() : locator.click()); + const noOverflow = async () => + assert.ok( + await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), + 'document overflow', + ); + try { + await server.listen(); + browser = await { chromium, firefox, webkit }[engine].launch( + engine === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + page = await browser.newPage({ + viewport: { width, height: width === 390 ? 844 : 900 }, + locale: 'en-GB', + timezoneId: 'UTC', + ...(width === 390 ? { isMobile: true, hasTouch: true } : {}), + }); + page.setDefaultTimeout(20_000); + page.on('pageerror', (error) => errors.push(error.message)); + await page.route('**/*', async (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(); + requests.push({ path: url.pathname, search: url.search, method: route.request().method() }); + assert.equal(route.request().method(), 'GET'); + if (url.pathname === '/api/license/runtime-capabilities') + return route.fulfill({ + json: { + capabilities: [], + limits: [], + max_history_days: 7, + hosted_mode: false, + runtime: { build: 'community', label: 'Pulse Community runtime' }, + blocked_capabilities: [], + }, + }); + if (url.pathname === '/api/metrics-store/history') { + const id = url.searchParams.get('resourceId'); + assert.ok(['vm-one', 'vm-two', 'agent-three'].includes(id), `unexpected target ${id}`); + const end = Date.UTC(2026, 9, 1, 12); + return route.fulfill({ + json: { + resourceType: url.searchParams.get('resourceType'), + resourceId: id, + range: url.searchParams.get('range'), + start: end - 86400000, + end, + source: 'store', + metrics: { + cpu: [ + { timestamp: end - 600000, value: 20 }, + { timestamp: end - 300000, value: 30 }, + ], + memory: [{ timestamp: end - 300000, value: 40 }], + netin: [ + { timestamp: end - 600000, value: 1024 }, + { timestamp: end - 300000, value: 2048 }, + ], + }, + }, + }); + } + return route.fulfill({ json: { data: [], enabled: false } }); + }); + console.log(JSON.stringify({ stage: 'production-pbs', engine, theme, width, sourceHashes })); + await page.goto('http://127.0.0.1:5227/browser-tests/pbs-identity-boundary.html', { + waitUntil: 'domcontentloaded', + timeout: 120_000, + }); + await page.evaluate( + (dark) => document.documentElement.classList.toggle('dark', dark), + theme === 'dark', + ); + await activate(page.getByRole('button', { name: 'Corroborate links', exact: true })); + for (const suffix of ['one', 'two', 'three']) { + const cell = page.locator(`td[title="backup-connection-${suffix} · tank"]`); + const disclosure = cell.getByRole('button'); + assert.equal(await disclosure.getAttribute('aria-expanded'), 'false'); + // No focus, force, dispatchEvent, synthetic click or added event listeners. + await activate(cell); + await page.locator(`[data-inline-platform-resource-detail-for="pbs-${suffix}"]`).waitFor(); + assert.equal(await disclosure.getAttribute('aria-expanded'), 'true'); + const detail = page.locator(`[data-inline-platform-resource-detail-for="pbs-${suffix}"]`); + observations.push({ suffix, firstActivationOpened: true }); + await noOverflow(); + if (suffix === 'one') await snapshot('pbs-first-activation'); + await activate(detail.getByRole('tab', { name: 'History', exact: true })); + await detail.getByTestId('guest-history-plot').first().locator('path').first().waitFor(); + assert.equal( + await disclosure.getAttribute('aria-expanded'), + 'true', + 'tab must not retoggle row', + ); + if (suffix === 'three') await snapshot('pbs-third-history'); + // Close by the same ordinary cell; nested History input cannot consume it. + await activate(cell); + assert.equal(await disclosure.getAttribute('aria-expanded'), 'false'); + assert.equal(await detail.count(), 0); + } + const keyboardCell = page.locator('td[title="backup-connection-one · tank"]'); + const keyboardDisclosure = keyboardCell.getByRole('button'); + await keyboardDisclosure.focus(); + await page.keyboard.press('Enter'); + assert.equal(await keyboardDisclosure.getAttribute('aria-expanded'), 'true'); + await page.keyboard.press('Space'); + assert.equal(await keyboardDisclosure.getAttribute('aria-expanded'), 'false'); + assert.ok(requests.some((request) => request.search.includes('resourceId=vm-one'))); + assert.ok(requests.some((request) => request.search.includes('resourceId=vm-two'))); + assert.ok(requests.some((request) => request.search.includes('resourceId=agent-three'))); + + console.log(JSON.stringify({ stage: 'shared-controls', engine, theme, width })); + await page.goto('http://127.0.0.1:5227/browser-tests/table-row-touch.html', { + waitUntil: 'domcontentloaded', + timeout: 120_000, + }); + await page.evaluate( + (dark) => document.documentElement.classList.toggle('dark', dark), + theme === 'dark', + ); + const state = async () => + JSON.parse(await page.getByRole('status', { name: 'Action state' }).innerText()); + const name = page.getByText('Shared resource name', { exact: true }); + await name.waitFor(); + await activate(name); + assert.equal((await state()).rowActions, 1); + assert.equal((await state()).expanded, true); + await activate(page.getByRole('button', { name: 'Child action', exact: true })); + await activate(page.getByRole('link', { name: 'Native link' })); + assert.equal(new URL(page.url()).hash, '#native-link'); + await activate(page.getByRole('checkbox', { name: 'Child checkbox' })); + assert.equal(await page.getByRole('checkbox').isChecked(), true); + assert.equal((await state()).rowActions, 1, 'nested controls must not run row action'); + assert.equal((await state()).childActions, 2); + const toggle = page.getByTestId('control-row').locator('[data-row-action="true"]'); + await activate(toggle); + assert.equal((await state()).rowActions, 2, 'nested disclosure must toggle once'); + await toggle.focus(); + await page.keyboard.press('Enter'); + assert.equal((await state()).rowActions, 3); + await page.keyboard.press('Space'); + assert.equal((await state()).rowActions, 4); + await activate(page.getByRole('button', { name: 'Toggle row action' })); + await activate(name); + assert.equal((await state()).rowActions, 4); + await activate(page.getByRole('button', { name: 'Toggle row action' })); + await activate(name); + assert.equal((await state()).rowActions, 5); + await activate(page.getByText('Static row', { exact: true })); + assert.equal((await state()).rowActions, 5); + await activate(page.getByText('Grid resource', { exact: true })); + assert.equal((await state()).gridActions, 1); + await activate(page.getByRole('button', { name: 'Grid child action', exact: true })); + assert.equal((await state()).gridActions, 1); + assert.equal((await state()).childActions, 3); + await noOverflow(); + await snapshot('shared-controls'); + assert.deepEqual(errors, []); + const result = { + engine, + browserVersion: browser.version(), + playwrightVersion, + theme, + width, + sourceHashes, + observations, + requests, + controlState: await state(), + errors, + result: 'passed', + }; + fs.writeFileSync(path.join(out, 'result.json'), JSON.stringify(result, null, 2) + '\n', { + mode: 0o644, + }); + console.log(JSON.stringify(result)); + } catch (error) { + if (page) await snapshot('failure').catch(() => {}); + fs.writeFileSync( + path.join(out, 'failure.json'), + JSON.stringify( + { error: String(error), errors, requests, observations, sourceHashes }, + null, + 2, + ) + '\n', + { mode: 0o644 }, + ); + throw error; + } finally { + await browser?.close(); + await server.close(); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/table-row-touch.html b/frontend-modern/browser-tests/table-row-touch.html new file mode 100644 index 0000000000..9fd7276bf5 --- /dev/null +++ b/frontend-modern/browser-tests/table-row-touch.html @@ -0,0 +1,12 @@ + + + + + + Shared table touch verification + + +
+ + + diff --git a/frontend-modern/browser-tests/table-row-touch.tsx b/frontend-modern/browser-tests/table-row-touch.tsx new file mode 100644 index 0000000000..ab8e033077 --- /dev/null +++ b/frontend-modern/browser-tests/table-row-touch.tsx @@ -0,0 +1,91 @@ +// Real shared table/grid actions with synthetic data, not an installed journey. +import { createSignal } from 'solid-js'; +import { render } from 'solid-js/web'; +import { Table, TableBody, TableCell, TableRow } from '../src/components/shared/Table'; +import { SummaryRowActionButton } from '../src/components/shared/SummaryRowActionButton'; +import { PulseDataGrid } from '../src/components/shared/PulseDataGrid'; +import '../src/index.css'; + +function Fixture() { + const [expanded, setExpanded] = createSignal(false); + const [rowActions, setRowActions] = createSignal(0); + const [childActions, setChildActions] = createSignal(0); + const [enabled, setEnabled] = createSignal(true); + const [gridActions, setGridActions] = createSignal(0); + const toggle = () => { + setRowActions((count) => count + 1); + setExpanded((value) => !value); + }; + const child = (event: MouseEvent) => { + event.stopPropagation(); + setChildActions((count) => count + 1); + }; + return ( +
+

Shared table touch verification

+ + + {JSON.stringify({ + expanded: expanded(), + rowActions: rowActions(), + childActions: childActions(), + enabled: enabled(), + gridActions: gridActions(), + })} + + + + + + + Shared resource name + + + + + Native link + + event.stopPropagation()} + /> + + + + Static row + No action + + +
+ + data={[{ id: 'grid-one', name: 'Grid resource' }]} + keyExtractor={(row) => row.id} + onRowClick={() => setGridActions((count) => count + 1)} + columns={[ + { key: 'name', label: 'Name' }, + { + key: 'action', + label: 'Action', + render: () => ( + + ), + }, + ]} + /> +
+ ); +} +render(() => , document.getElementById('root')!); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 6fd5437339..51ed3966f6 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,124 +1,189 @@ { "version": 1, - "base_sha": "00f82e2afc3cd75035c997e9e245c6e0eeffffa9", - "verified_at": "2026-10-01T01:42:43Z", + "base_sha": "920aa2f27c6cb717cca7eb8e2cd3f1379d0fdf73", + "verified_at": "2026-10-01T03:23:21Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx", - "frontend-modern/src/components/Workloads/guestDrawerModel.ts" + "frontend-modern/src/components/shared/Table.tsx" ], "content_sha256": { - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "a49f3c6496e5515284a94c52b818f78f72d2e3530ae398794ea7432f251f48ec", - "frontend-modern/src/components/Workloads/guestDrawerModel.ts": "5ea99d3d995ddb2fa777273ed3f224b7fbb9e7b10a015bed52d61a8e10dc0b99" + "frontend-modern/src/components/shared/Table.tsx": "4009a751a3fa435cb4067a98989d25cac4e9e6d08acd7b91701fc98fbfd5aaf1" }, "routes": [ - "/browser-tests/pbs-identity-boundary.html (production PBS table, full ResourceDetailDrawer, shared History/query and CSS with synthetic VM/Agent APIs; not installed collection or complete #1723 acceptance)" + "/browser-tests/pbs-identity-boundary.html (production PBS table, full ResourceDetailDrawer and History with synthetic bounded API/Agent/PVE data)", + "/browser-tests/table-row-touch.html (production Table, SummaryRowActionButton and PulseDataGrid with synthetic rows and delegated embedded actions)" ], "viewports": [ - { - "width": 1365, - "height": 900 - }, { "width": 390, "height": 844 + }, + { + "width": 1365, + "height": 900 } ], "states": [ - "light/dark short sample coverage stays at its true position in the fulfilled 24-hour window rather than stretching across each plot", - "all configured utilization/network/disk/thermal panels share dated endpoints, including lone and empty panels", - "three separately mapped synthetic PBS drawers read vm-one, vm-two and agent-three, not unrelated host identities", - "same-time observations occupy the same horizontal coordinate across groups with different sample coverage", - "failed same-source refresh retains samples and window with a sanitised warning; successful refresh advances the window", - "held 1-hour range replacement clears former endpoints, paths and inspection controls; matching data then uses the new window", - "empty window has labelled bounds without a synthetic trend; seven-day bounds remain dated; locked fourteen-day range makes no read" + "WebKit phone light/dark, Chromium phone light and Firefox desktop dark: all three synthetic PBS drawers open with the first ordinary cell activation and close with the next", + "History tabs stay open and read only vm-one, vm-two and agent-three with bounded GETs", + "delegated embedded buttons, native fragment links and checkbox clicks retain their own action without firing the row", + "shared disclosure touch and Enter/Space activate once; disabled/static rows do not act; restoring the row handler works", + "PulseDataGrid row activates once and its embedded button does not activate the row; no page errors or document horizontal overflow" ], "interactions": [ - "desktop native keyboard disclosure and History tab; Chromium phone row touch; WebKit phone native keyboard disclosure (whole-row touch opening not established)", - "focus native observation input, use Home/ArrowRight/End and compare dated values and same-time markers between utilization and network", - "Chromium/WebKit phone-emulated touch taps on the at-least-44px native History input; inspection performs no extra API reads", - "fail a real query refresh; retry with advancing bounds; hold and fulfil a 1-hour replacement read; refresh empty, choose seven days and a licence-locked range" + "phone-emulated locator.tap on ordinary PBS cells without focus, force, synthetic click dispatch or added event instrumentation", + "tap History, inspect real plotted paths, close each drawer from its same name cell; focus native disclosure and use Enter/Space", + "tap embedded button, fragment link and checkbox; tap and keyboard-activate native disclosure; withdraw and restore row action; activate data-grid row and its child button" ], - "command": "pulse-worker-browser frontend-modern/browser-tests/history-window.cjs --engine=chromium --theme=light; --engine=chromium --theme=dark; both themes with --engine=chromium --phone; both themes with --engine=firefox; both themes with --engine=webkit --phone. Each is a separate bounded command from the assigned workspace root. Vite 6.4.3 / Playwright 1.56.1; integration-lock parity checked.", + "command": "pulse-worker-browser frontend-modern/browser-tests/table-row-touch.cjs --engine=webkit; --engine=webkit --dark; --engine=chromium; --engine=firefox --dark --desktop. Four separate bounded executions from the assigned workspace root. Playwright 1.56.1 / integration-lock parity checked; Vite 6.4.3.", "browser_versions": { "chromium": "141.0.7390.37", "firefox": "142.0.1", "webkit": "26.0" }, "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/one-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/one-hour-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/progress.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/retained-window-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/three-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/two-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/one-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/one-hour-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/progress.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/retained-window-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/three-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/two-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/one-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/one-hour-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/progress.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/retained-window-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/three-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/two-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/one-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/one-hour-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/progress.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/retained-window-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/three-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/two-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/one-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/one-hour-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/progress.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/retained-window-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/three-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/two-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/one-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/one-hour-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/progress.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/retained-window-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/three-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/two-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/one-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/one-hour-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/progress.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/retained-window-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/three-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/two-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/one-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/one-hour-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/progress.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/retained-window-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/three-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/two-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-desktop-dark-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-desktop-light-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-phone-dark-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-phone-light-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-firefox-desktop-dark-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-firefox-desktop-light-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-webkit-phone-dark-native.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-webkit-phone-light-native.log" + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/chromium-390-light/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/chromium-390-light/pbs-first-activation.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/chromium-390-light/pbs-third-history.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/chromium-390-light/shared-controls.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/firefox-1365-dark/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/firefox-1365-dark/pbs-first-activation.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/firefox-1365-dark/pbs-third-history.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/firefox-1365-dark/shared-controls.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-dark/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-dark/pbs-first-activation.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-dark/pbs-third-history.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-dark/shared-controls.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-light/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-light/pbs-first-activation.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-light/pbs-third-history.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-light/shared-controls.png" ], - "notes": "All 40 final screenshots inspected. Eight engine/viewport/theme cases pass with no page errors or horizontal overflow and exact final runtime hashes. The ten-case exact-base source-fbz6bjig input fails all ten assertions; an inspected full PBS drawer baseline measures five minutes occupying 265/318 SVG plot units under 24 hours, versus about 1.10 units after repair. Initial navigation timeout, unknown browser-execution capability-unavailable exit, synthetic edge-point fixture mismatch and unsuccessful WebKit row/hidden-button touch attempts remain in internal receipts. WebKit final opening uses native keyboard disclosure, not a claimed touch repair; History touch inspection passes. Six Chromium/Firefox cases ran before the runner added the disclosure-state assertion and WebKit keyboard-opening branch; their runtime source hashes are identical to the final two WebKit cases. This establishes presentation, not real PBS/VirtualBox collection, all reported #1723 drawer/metric outcomes, screen-reader speech, installed Tailscale Serve/SSE update or signed upgrade/rollback, protected integration or release availability.", + "notes": "All 12 final screenshots inspected. This repairs reproduced WebKit-emulated row touch, not physical iPhone/Safari acceptance, installed PBS telemetry, every #1723 metric outcome, screen-reader speech or a Tailscale Serve update. Initial harness working-directory/API-path/locator/output-wrapping failures and the instrumented diagnostic are retained in the output receipts; none is a release failure or passing uninstrumented baseline.", "prior_verifications": [ + { + "version": 1, + "base_sha": "00f82e2afc3cd75035c997e9e245c6e0eeffffa9", + "verified_at": "2026-10-01T01:42:43Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx", + "frontend-modern/src/components/Workloads/guestDrawerModel.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "a49f3c6496e5515284a94c52b818f78f72d2e3530ae398794ea7432f251f48ec", + "frontend-modern/src/components/Workloads/guestDrawerModel.ts": "5ea99d3d995ddb2fa777273ed3f224b7fbb9e7b10a015bed52d61a8e10dc0b99" + }, + "routes": [ + "/browser-tests/pbs-identity-boundary.html (production PBS table, full ResourceDetailDrawer, shared History/query and CSS with synthetic VM/Agent APIs; not installed collection or complete #1723 acceptance)" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "light/dark short sample coverage stays at its true position in the fulfilled 24-hour window rather than stretching across each plot", + "all configured utilization/network/disk/thermal panels share dated endpoints, including lone and empty panels", + "three separately mapped synthetic PBS drawers read vm-one, vm-two and agent-three, not unrelated host identities", + "same-time observations occupy the same horizontal coordinate across groups with different sample coverage", + "failed same-source refresh retains samples and window with a sanitised warning; successful refresh advances the window", + "held 1-hour range replacement clears former endpoints, paths and inspection controls; matching data then uses the new window", + "empty window has labelled bounds without a synthetic trend; seven-day bounds remain dated; locked fourteen-day range makes no read" + ], + "interactions": [ + "desktop native keyboard disclosure and History tab; Chromium phone row touch; WebKit phone native keyboard disclosure (whole-row touch opening not established)", + "focus native observation input, use Home/ArrowRight/End and compare dated values and same-time markers between utilization and network", + "Chromium/WebKit phone-emulated touch taps on the at-least-44px native History input; inspection performs no extra API reads", + "fail a real query refresh; retry with advancing bounds; hold and fulfil a 1-hour replacement read; refresh empty, choose seven days and a licence-locked range" + ], + "command": "pulse-worker-browser frontend-modern/browser-tests/history-window.cjs --engine=chromium --theme=light; --engine=chromium --theme=dark; both themes with --engine=chromium --phone; both themes with --engine=firefox; both themes with --engine=webkit --phone. Each is a separate bounded command from the assigned workspace root. Vite 6.4.3 / Playwright 1.56.1; integration-lock parity checked.", + "browser_versions": { + "chromium": "141.0.7390.37", + "firefox": "142.0.1", + "webkit": "26.0" + }, + "artifacts": [ + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/one-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/one-hour-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/progress.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/retained-window-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/three-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/two-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/one-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/one-hour-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/progress.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/retained-window-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/three-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/two-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/one-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/one-hour-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/progress.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/retained-window-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/three-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/two-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/one-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/one-hour-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/progress.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/retained-window-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/three-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/two-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/one-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/one-hour-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/progress.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/retained-window-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/three-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/two-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/one-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/one-hour-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/progress.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/retained-window-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/three-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/two-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/one-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/one-hour-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/progress.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/retained-window-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/three-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/two-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/one-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/one-hour-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/progress.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/retained-window-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/three-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/two-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-desktop-dark-final.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-desktop-light-final.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-phone-dark-final.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-phone-light-final.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-firefox-desktop-dark-final.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-firefox-desktop-light-final.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-webkit-phone-dark-native.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-webkit-phone-light-native.log" + ], + "notes": "All 40 final screenshots inspected. Eight engine/viewport/theme cases pass with no page errors or horizontal overflow and exact final runtime hashes. The ten-case exact-base source-fbz6bjig input fails all ten assertions; an inspected full PBS drawer baseline measures five minutes occupying 265/318 SVG plot units under 24 hours, versus about 1.10 units after repair. Initial navigation timeout, unknown browser-execution capability-unavailable exit, synthetic edge-point fixture mismatch and unsuccessful WebKit row/hidden-button touch attempts remain in internal receipts. WebKit final opening uses native keyboard disclosure, not a claimed touch repair; History touch inspection passes. Six Chromium/Firefox cases ran before the runner added the disclosure-state assertion and WebKit keyboard-opening branch; their runtime source hashes are identical to the final two WebKit cases. This establishes presentation, not real PBS/VirtualBox collection, all reported #1723 drawer/metric outcomes, screen-reader speech, installed Tailscale Serve/SSE update or signed upgrade/rollback, protected integration or release availability." + }, { "version": 1, "base_sha": "0dc858cb366a394b159d2c911c5d85b3020e7881", "verified_at": "2026-10-01T00:37:51Z", "result": "passed", - "changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"], + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx" + ], "content_sha256": { "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "e1658b5843ab22e0ea83d1bf27e8c1565a16d9f528eb3832524a67318d281d6a" }, @@ -192,7 +257,9 @@ "base_sha": "0d3e5a801ab8fd1c1d224ef561cbf99e169c589f", "verified_at": "2026-09-30T23:28:26Z", "result": "passed", - "changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"], + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx" + ], "content_sha256": { "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "327e75cabb99edffc712302c00645f06d92dc44dbebdd00aef08074e7fa53eb2" }, @@ -332,7 +399,9 @@ "base_sha": "21a35494aecb335755de773dd099e1364e65b3e0", "verified_at": "2026-09-30T20:12:09Z", "result": "passed", - "changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"], + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx" + ], "content_sha256": { "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "b2b31441b19c2e50283ceedf319cd09a1f94791750cd424c052717a4957fdc67" }, @@ -387,7 +456,9 @@ "base_sha": "6ade01cbd4d02cd25b691f9ff5919206cceae764", "verified_at": "2026-09-30T01:40:13Z", "result": "passed", - "changed_paths": ["frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx"], + "changed_paths": [ + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx" + ], "content_sha256": { "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "ed7207456ebbc990ada34b0c739e29565d90cfe1998415a781d87f3067d69c36" }, @@ -436,7 +507,9 @@ "base_sha": "c3e8b6dd6dff36b5396ba2eb147d4e58d23001a6", "verified_at": "2026-09-29T23:04:22Z", "result": "passed", - "changed_paths": ["frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx"], + "changed_paths": [ + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx" + ], "content_sha256": { "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "7172690587f8e642ee30492481abbe76ee79d277aad290c3b50310dd1c0b282b" }, @@ -532,7 +605,9 @@ "base_sha": "fbf17b271abe70ad3e12042506b52f85b6cbc484", "verified_at": "2026-09-29T19:50:45Z", "result": "passed", - "changed_paths": ["frontend-modern/src/pages/Actions.tsx"], + "changed_paths": [ + "frontend-modern/src/pages/Actions.tsx" + ], "content_sha256": { "frontend-modern/src/pages/Actions.tsx": "30e862ecc5125f07cf93f0c1e0b5f58efa023f5fdb56fe4b01721bf9a453e7ce" }, @@ -573,7 +648,9 @@ "base_sha": "c2f7aa8b471d6cbb858e8ec71b4e664fded6549c", "verified_at": "2026-09-29T19:09:52Z", "result": "passed", - "changed_paths": ["frontend-modern/src/features/actions/ActionReviewDialog.tsx"], + "changed_paths": [ + "frontend-modern/src/features/actions/ActionReviewDialog.tsx" + ], "content_sha256": { "frontend-modern/src/features/actions/ActionReviewDialog.tsx": "9a4af522484b42e37b118e8bfde7c1673122037a54b8bfed249818ba644e6b81" }, diff --git a/frontend-modern/src/components/shared/Table.tsx b/frontend-modern/src/components/shared/Table.tsx index 6d2fdbb506..472263d205 100644 --- a/frontend-modern/src/components/shared/Table.tsx +++ b/frontend-modern/src/components/shared/Table.tsx @@ -63,11 +63,17 @@ export function TableBody(props: TableBodyProps) { export type TableRowProps = JSX.HTMLAttributes; +// WebKit touch needs a native click target on otherwise-static table rows. +// Keep actions document-delegated: nested controls must run first and retain +// their existing stopPropagation behaviour instead of opening the row too. +const nativeRowClickTarget = () => undefined; + export function TableRow(props: TableRowProps) { const [local, rest] = splitProps(props, ['class', 'children']); return ( {local.children} diff --git a/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx b/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx index 4691fbf0fa..23ac2ff5e9 100644 --- a/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx @@ -103,6 +103,23 @@ describe('PulseDataGrid', () => { expect(onRowClick).toHaveBeenCalledTimes(1); }); + it('inherits the native touch click target from the shared TableRow', () => { + const nativeListener = vi.spyOn(HTMLTableRowElement.prototype, 'addEventListener'); + try { + render(() => ( + + data={[{ id: '1', name: 'Touch tower' }]} + columns={[{ key: 'name', label: 'Name' }]} + keyExtractor={(row) => row.id} + onRowClick={vi.fn()} + /> + )); + expect(nativeListener.mock.calls.some(([type]) => type === 'click')).toBe(true); + } finally { + nativeListener.mockRestore(); + } + }); + it('does not trigger the row handler when an interactive child is clicked', () => { const onRowClick = vi.fn(); const onRemove = vi.fn(); diff --git a/frontend-modern/src/components/shared/__tests__/Table.test.tsx b/frontend-modern/src/components/shared/__tests__/Table.test.tsx index 484cecc996..a0e7fc3260 100644 --- a/frontend-modern/src/components/shared/__tests__/Table.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/Table.test.tsx @@ -1,5 +1,6 @@ -import { render, screen } from '@solidjs/testing-library'; -import { describe, expect, it } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; +import { createSignal } from 'solid-js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import tableSource from '@/components/shared/Table.tsx?raw'; import { @@ -11,6 +12,127 @@ import { TableRow, } from '@/components/shared/Table'; +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('TableRow touch activation', () => { + it('marks clickable rows as native click targets without changing row semantics', () => { + const nativeListener = vi.spyOn(HTMLTableRowElement.prototype, 'addEventListener'); + const onClick = vi.fn(); + render(() => ( + + + + Tap resource + + +
+ )); + expect(nativeListener.mock.calls.some(([type]) => type === 'click')).toBe(true); + const row = screen.getByRole('row'); + expect(row).not.toHaveAttribute('tabindex'); + expect(row).not.toHaveAttribute('aria-expanded'); + fireEvent.click(screen.getByText('Tap resource')); + expect(onClick).toHaveBeenCalledTimes(1); + fireEvent.keyDown(row, { key: 'Enter' }); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('does not make static rows native click targets', () => { + const nativeListener = vi.spyOn(HTMLTableRowElement.prototype, 'addEventListener'); + render(() => ( + + + + Static resource + + +
+ )); + expect(nativeListener.mock.calls.some(([type]) => type === 'click')).toBe(false); + }); + + it('lets delegated child actions stop the row before its action runs', () => { + const onClick = vi.fn(); + const onChild = vi.fn(); + render(() => ( + + + + + + + + +
+ )); + fireEvent.click(screen.getByRole('button', { name: 'Child action' })); + expect(onChild).toHaveBeenCalledTimes(1); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('removes the native target when the row action is withdrawn', () => { + const removeListener = vi.spyOn(HTMLTableRowElement.prototype, 'removeEventListener'); + const onClick = vi.fn(); + const [enabled, setEnabled] = createSignal(true); + render(() => ( + + + + Changing resource + + +
+ )); + fireEvent.click(screen.getByText('Changing resource')); + setEnabled(false); + expect(removeListener.mock.calls.some(([type]) => type === 'click')).toBe(true); + fireEvent.click(screen.getByText('Changing resource')); + expect(onClick).toHaveBeenCalledTimes(1); + setEnabled(true); + fireEvent.click(screen.getByText('Changing resource')); + expect(onClick).toHaveBeenCalledTimes(2); + }); + + it('preserves bound delegated handlers and caller-owned native handlers', () => { + const onClick = vi.fn(); + const onNative = vi.fn(); + render(() => ( + + + { + event.stopPropagation(); + onNative(); + }} + > + Native action + + + Bound action + + +
+ )); + fireEvent.click(screen.getByText('Native action')); + expect(onNative).toHaveBeenCalledTimes(1); + expect(onClick).not.toHaveBeenCalled(); + fireEvent.click(screen.getByText('Bound action')); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0]).toBe('second-identity'); + }); +}); + describe('TableBody', () => { it('keeps the shared table wrapper CSP-safe', () => { expect(tableSource).toContain('touch-scroll');