diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index 0bf42b1a70..7760e00f05 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -623,6 +623,20 @@ scrolling. Explicit caller-owned native listeners remain caller-owned.
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.
+The custom Workloads `GuestRow` shell reuses `Table`'s exported
+`nativeRowClickTarget` marker, conditional on its own action, without inheriting
+shared row styling or changing alert/highlight treatment. Its name and row body
+must open the full guest drawer on the first touch even when the compact-layout
+disclosure button is visually hidden. Keep the action document-delegated;
+nested disclosure SVGs, external links and keyboard controls must still perform
+only their own action. Removing the action removes the native marker, and
+re-enabling it must work without replacing the row. Pointer-up and touch-end
+alone never perform the action. `GuestRow.test.tsx` covers native binding,
+non-activation, dynamic removal and child ordering;
+`browser-tests/guest-row-touch.cjs` covers trusted row/name taps, the production
+full drawer's backup/read guidance, nested controls and shared-row compatibility
+on Chromium desktop/phone and WebKit phone VM/CT fixtures. This is emulated
+input/presentation proof, not physical-device, native thaw or released acceptance.
`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/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
index 1aa8041768..db65ab3598 100644
--- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
+++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
@@ -198,6 +198,15 @@ poller, per-agent goroutine, inventory scan, or action-session lookup to normal
request routing; local assets are streamed once and published fallbacks retain
the existing bounded HTTP client and artifact-size checks.
+The custom Workloads guest row shares `Table`'s singleton non-activating native
+click marker for WebKit touch eligibility. Bind it only while a row action
+exists, without changing row identity, alert/highlight classes, metric derivation
+or delegated child-control ordering. The marker must not perform actions, read
+telemetry, start History work, add polling/timers or activate on pointer-up or
+touch-end. `GuestRow.test.tsx` verifies its conditional lifecycle and
+non-activation; the production row/full-drawer touch fixture verifies first
+activation and unchanged identity independently of native collector acceptance.
+
## Canonical Files
1. `pkg/metrics/store.go`
diff --git a/frontend-modern/browser-tests/guest-row-touch.cjs b/frontend-modern/browser-tests/guest-row-touch.cjs
new file mode 100644
index 0000000000..b75faf4c31
--- /dev/null
+++ b/frontend-modern/browser-tests/guest-row-touch.cjs
@@ -0,0 +1,299 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const crypto = require('node:crypto');
+const { chromium, webkit } = require('playwright');
+
+const root = '/workspace/frontend-modern';
+const output = '/workspace/tmp/guest-row-touch';
+const origin = 'http://127.0.0.1:5313';
+const runtime = [
+ 'frontend-modern/src/components/Workloads/GuestRow.tsx',
+ 'frontend-modern/src/components/shared/Table.tsx',
+];
+const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
+
+(async () => {
+ fs.mkdirSync(output, { recursive: true });
+ const result = {
+ playwright: require('playwright/package.json').version,
+ runtime_hashes: Object.fromEntries(runtime.map((file) => [file, sha256('/workspace/' + file)])),
+ results: [],
+ screenshots: [],
+ result: 'incomplete',
+ };
+ assert.equal(
+ result.playwright,
+ JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
+ 'node_modules/@playwright/test'
+ ].version,
+ );
+ process.chdir(root);
+ 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: output + '/cache',
+ server: { host: '127.0.0.1', port: 5313, strictPort: true },
+ });
+ let browser;
+ try {
+ await server.listen();
+ for (const [name, engine, width, dark, kinds] of [
+ ['webkit-phone', webkit, 390, true, ['qemu', 'lxc']],
+ ['chromium-desktop', chromium, 1365, false, ['qemu', 'lxc']],
+ ['chromium-phone', chromium, 390, false, ['qemu']],
+ ['webkit-narrow', webkit, 360, false, ['lxc']],
+ ]) {
+ browser = await engine.launch(
+ engine === chromium
+ ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
+ : { headless: true },
+ );
+ const context = await browser.newContext({
+ viewport: { width, height: width <= 768 ? 844 : 900 },
+ isMobile: width <= 768,
+ hasTouch: width <= 768,
+ locale: 'en-GB',
+ timezoneId: 'UTC',
+ });
+ for (const kind of kinds) {
+ const page = await context.newPage();
+ page.setDefaultTimeout(15000);
+ page.setDefaultNavigationTimeout(60000);
+ const record = {
+ name,
+ kind,
+ browser: browser.version(),
+ viewport: { width, height: width <= 768 ? 844 : 900 },
+ dark,
+ errors: [],
+ requests: [],
+ events: [],
+ checks: [],
+ };
+ result.results.push(record);
+ page.on('pageerror', (error) => record.errors.push(error.message));
+ await page.route('**/*', (route) => {
+ const request = route.request();
+ const url = new URL(request.url());
+ record.requests.push({
+ method: request.method(),
+ origin: url.origin,
+ path: url.pathname,
+ });
+ if (url.origin !== origin) return route.abort();
+ if (url.pathname.startsWith('/api/'))
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ success: true,
+ data: [],
+ alerts: [],
+ anomalies: [],
+ config: null,
+ }),
+ });
+ return route.continue();
+ });
+ await page.addInitScript((isDark) => {
+ window.__inputEvents = [];
+ for (const type of ['pointerdown', 'pointerup', 'touchstart', 'touchend', 'click'])
+ document.addEventListener(
+ type,
+ (event) =>
+ window.__inputEvents.push({
+ type,
+ tag: event.target.tagName,
+ trusted: event.isTrusted,
+ pointerType: event.pointerType,
+ }),
+ true,
+ );
+ document.addEventListener('DOMContentLoaded', () => {
+ if (isDark) document.documentElement.classList.add('dark');
+ });
+ }, dark);
+ await page.goto(`${origin}/browser-tests/guest-row-touch.html?kind=${kind}`);
+ await page.waitForFunction(() => window.__guestRowTouch);
+ assert.equal(await page.evaluate(() => innerWidth), width);
+ const row = page.locator('[data-guest-id]');
+ const nameTarget = row.getByText('backup-guest', { exact: true });
+ const drawer = page.getByRole('region', { name: 'Guest details' });
+ const disclosure = row.locator('[data-row-action]');
+ await page.evaluate(() => {
+ window.__originalGuestRow = document.querySelector('[data-guest-id]');
+ });
+ const state = () =>
+ page.evaluate(() => ({
+ count: window.__guestRowTouch.actionCount(),
+ expanded: window.__guestRowTouch.expanded(),
+ closeCount: window.__guestRowTouch.closeCount(),
+ disclosureExpanded: document
+ .querySelector('[data-row-action]')
+ ?.getAttribute('aria-expanded'),
+ sameRow: document.querySelector('[data-guest-id]') === window.__originalGuestRow,
+ }));
+ const activate = async (target) => {
+ if (width <= 768) await target.tap();
+ else await target.click();
+ await page.waitForTimeout(650);
+ };
+ const expectState = async (count, expanded) => {
+ const value = await state();
+ record.state = value;
+ record.events = await page.evaluate(() => window.__inputEvents);
+ assert.equal(value.count, count, `${name}/${kind}: single row activation`);
+ assert.equal(value.expanded, expanded);
+ assert.equal(value.disclosureExpanded, String(expanded));
+ assert.equal(value.sameRow, true);
+ };
+ try {
+ await activate(width === 360 ? row : nameTarget);
+ await expectState(1, true);
+ assert(record.events.some((event) => event.type === 'click' && event.trusted));
+ if (width <= 768) {
+ assert(record.events.some((event) => event.type === 'touchend' && event.trusted));
+ assert.equal(await row.getAttribute('data-history-lens-active'), null);
+ }
+ await drawer.getByText('No completed backup found', { exact: true }).waitFor();
+ await drawer.getByText('Running ยท not completed yet', { exact: true }).waitFor();
+ if (kind === 'qemu')
+ await drawer.getByText(/Using last known disk stats\. Guest reads paused/).waitFor();
+ assert.equal(
+ await disclosure.getAttribute('aria-controls'),
+ await drawer.getAttribute('id'),
+ );
+ assert.equal(await row.getAttribute('tabindex'), null);
+ assert.equal(await row.getAttribute('aria-expanded'), null);
+ assert.equal(
+ await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
+ true,
+ );
+ record.checks.push(
+ `first trusted ${width === 360 ? 'row-body' : 'name'} activation opens production full drawer with safe backup/read context`,
+ );
+ const screenshot = `${output}/${name}-${kind}-open.png`;
+ await page.screenshot({ path: screenshot, fullPage: true });
+ result.screenshots.push({
+ path: path.relative('/workspace', screenshot),
+ sha256: sha256(screenshot),
+ });
+ await activate(width === 360 ? row : nameTarget);
+ await expectState(2, false);
+ await activate(row);
+ await expectState(3, true);
+ await activate(page.getByRole('button', { name: 'Collapse backup-guest details' }));
+ await expectState(3, false);
+ assert.equal((await state()).closeCount, 1);
+ record.checks.push(
+ 'repeated name and row-centre activation toggle once; nested full-drawer collapse stays independent',
+ );
+
+ await disclosure.focus();
+ await page.keyboard.press('Enter');
+ await expectState(4, true);
+ await page.keyboard.press('Space');
+ await expectState(5, false);
+ await disclosure.locator('svg').click();
+ await expectState(6, true);
+ // Keyboard focus makes the compact disclosure button visible and
+ // can consume the name's space; the row body stays the touch target.
+ await activate(row);
+ await expectState(7, false);
+ record.checks.push(
+ 'native disclosure keyboard Enter/Space and nested SVG click do not double-activate',
+ );
+
+ if (width >= 1024) {
+ // The adjacent link is intentionally hidden in condensed phone
+ // identity. Exercise its real wide-layout action, preventing only
+ // external navigation, not production propagation handling.
+ const link = row.getByRole('link', { name: 'Open web interface for backup-guest' });
+ await link.evaluate((anchor) =>
+ anchor.addEventListener('click', (event) => event.preventDefault()),
+ );
+ await activate(link.locator('svg'));
+ await expectState(7, false);
+ record.checks.push(
+ 'real external-link SVG retains its separate action; navigation prevented in fixture only',
+ );
+ }
+
+ await page.evaluate(() => window.__guestRowTouch.enabled(false));
+ assert.equal(await disclosure.count(), 0);
+ await activate(row);
+ assert.equal((await state()).count, 7);
+ assert.equal((await state()).expanded, false);
+ await page.evaluate(() => window.__guestRowTouch.enabled(true));
+ await activate(row);
+ await expectState(8, true);
+ record.checks.push(
+ 'disabling/re-enabling the row action preserves node identity and first-tap activation',
+ );
+ assert.deepEqual(record.errors, []);
+ assert(
+ record.requests.every(
+ (request) => request.origin === origin && request.method === 'GET',
+ ),
+ );
+ record.result = 'passed';
+ } catch (error) {
+ record.result = 'failed';
+ record.error = error.message;
+ const screenshot = `${output}/${name}-${kind}-failed.png`;
+ await page.screenshot({ path: screenshot, fullPage: true });
+ result.screenshots.push({
+ path: path.relative('/workspace', screenshot),
+ sha256: sha256(screenshot),
+ });
+ throw error;
+ } finally {
+ await page.close();
+ }
+ }
+ // Exporting the compatibility marker must preserve the original shared
+ // TableRow's own single-action and embedded-control ordering.
+ const page = await context.newPage();
+ await page.goto(`${origin}/browser-tests/table-row-touch.html`);
+ const sharedState = () => page.getByRole('status', { name: 'Action state' }).textContent();
+ const shared = page.locator('[data-testid="control-row"]');
+ if (width <= 768) await shared.getByText('Shared resource name', { exact: true }).tap();
+ else await shared.getByText('Shared resource name', { exact: true }).click();
+ const sharedValue = JSON.parse(await sharedState());
+ assert.equal(sharedValue.rowActions, 1);
+ await shared.getByRole('button', { name: 'Child action' }).click();
+ const childValue = JSON.parse(await sharedState());
+ assert.equal(childValue.rowActions, 1);
+ assert.equal(childValue.childActions, 1);
+ result.results.push({
+ name,
+ control: 'shared-TableRow',
+ single: sharedValue,
+ child: childValue,
+ result: 'passed',
+ });
+ await page.close();
+ await context.close();
+ await browser.close();
+ browser = null;
+ }
+ result.result = 'passed';
+ result.verified_at = new Date().toISOString();
+ console.log(
+ 'PASS: trusted guest row/disclosure activation, full drawer safety context and child isolation',
+ );
+ } catch (error) {
+ result.result = 'failed';
+ result.error = error.message;
+ throw error;
+ } finally {
+ fs.writeFileSync(output + '/result.json', JSON.stringify(result, null, 2) + '\n');
+ if (browser) await browser.close();
+ await server.close();
+ }
+})().catch((error) => {
+ console.error(error.stack);
+ process.exitCode = 1;
+});
diff --git a/frontend-modern/browser-tests/guest-row-touch.html b/frontend-modern/browser-tests/guest-row-touch.html
new file mode 100644
index 0000000000..c3c2c52ed3
--- /dev/null
+++ b/frontend-modern/browser-tests/guest-row-touch.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Guest row touch verification
+
+
+
+
+
+
diff --git a/frontend-modern/browser-tests/guest-row-touch.tsx b/frontend-modern/browser-tests/guest-row-touch.tsx
new file mode 100644
index 0000000000..539499c43e
--- /dev/null
+++ b/frontend-modern/browser-tests/guest-row-touch.tsx
@@ -0,0 +1,123 @@
+// Production guest row and full drawer with synthetic observations.
+// No collector, backup command, QGA/thaw, installed device or release proof.
+import { createSignal, Show } from 'solid-js';
+import { render } from 'solid-js/web';
+import { Router, Route } from '@solidjs/router';
+import { GuestRow } from '../src/components/Workloads/GuestRow';
+import { buildSummaryDisclosureControlsId } from '../src/components/shared/summaryInteractionA11y';
+import type { WorkloadTableLayoutMode } from '../src/components/Workloads/guestRowModel';
+import { GuestDrawer } from '../src/components/Workloads/GuestDrawer';
+import { DarkModeContext } from '../src/contexts/appRuntime';
+import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState';
+import type { WorkloadGuest } from '../src/types/workloads';
+import '../src/index.css';
+
+syncAIRuntimeSettings({ discovery_enabled: false });
+
+function Fixture() {
+ const [observation, setObservation] = createSignal({
+ lastBackup: 0,
+ running: true,
+ reason: 'prev-vm-locked',
+ });
+ const [closeCount, setCloseCount] = createSignal(0);
+ const [actionCount, setActionCount] = createSignal(0);
+ const [expanded, setExpanded] = createSignal(false);
+ const [enabled, setEnabled] = createSignal(true);
+ const [layout, setLayout] = createSignal(
+ window.innerWidth <= 768 ? 'phone' : 'wide',
+ );
+ const toggle = () => {
+ setActionCount((count) => count + 1);
+ setExpanded((value) => !value);
+ };
+ const type = new URLSearchParams(window.location.search).get('kind') === 'lxc' ? 'lxc' : 'qemu';
+ const guest = (): WorkloadGuest => ({
+ id: 'fixture-pve:pve-a:101',
+ vmid: 101,
+ name: 'backup-guest',
+ node: 'pve-a',
+ instance: 'fixture-pve',
+ status: 'running',
+ type,
+ cpu: 0.1,
+ cpus: 2,
+ memory: { total: 1024 ** 3, used: 256 * 1024 ** 2, free: 768 * 1024 ** 2, usage: 25 },
+ disk: { total: 10 * 1024 ** 3, used: 5 * 1024 ** 3, usage: 50 },
+ disks: [
+ { mountpoint: '/data', type: 'ext4', total: 10 * 1024 ** 3, used: 5 * 1024 ** 3, usage: 50 },
+ ],
+ diskStatusReason: observation().reason,
+ networkIn: 0,
+ networkOut: 0,
+ diskRead: 0,
+ diskWrite: 0,
+ uptime: 3600,
+ template: false,
+ lastBackup: observation().lastBackup,
+ backupInProgress: observation().running,
+ tags: [],
+ lock: observation().running ? 'backup' : '',
+ lastSeen: '2026-10-03T12:00:00Z',
+ });
+ (window as any).__guestRowTouch = {
+ update: setObservation,
+ closeCount,
+ actionCount,
+ expanded,
+ enabled: setEnabled,
+ layout: setLayout,
+ identity: () => guest().id,
+ };
+ return (
+
+ Guest row touch verification
+ Synthetic observations; no backup command is sent.
+
+
+
+ {
+ setCloseCount((count) => count + 1);
+ setExpanded(false);
+ }}
+ />
+
+
+
+ );
+}
+
+render(
+ () => (
+
+ (
+ document.documentElement.classList.contains('dark')}
+ >
+
+
+ )}
+ />
+
+ ),
+ document.getElementById('root')!,
+);
diff --git a/frontend-modern/browser-verification/7139f2120612aedc.json b/frontend-modern/browser-verification/7139f2120612aedc.json
new file mode 100644
index 0000000000..604657fe02
--- /dev/null
+++ b/frontend-modern/browser-verification/7139f2120612aedc.json
@@ -0,0 +1,45 @@
+{
+ "version": 1,
+ "base_sha": "888d918ba1a0a4bf448542630bc0f47af72e4572",
+ "verified_at": "2026-10-03T23:27:41.013Z",
+ "result": "passed",
+ "changed_paths": [
+ "frontend-modern/src/components/Workloads/GuestRow.tsx",
+ "frontend-modern/src/components/shared/Table.tsx"
+ ],
+ "content_sha256": {
+ "frontend-modern/src/components/Workloads/GuestRow.tsx": "1096ad3669baea8bec5c6a80b016b64f2c428d2e5a0b7c85746ff6bf21e103fa",
+ "frontend-modern/src/components/shared/Table.tsx": "014d9c4382d4de03a547f171e410fb9207686bf00dd76695b50c98249d6eacbd"
+ },
+ "routes": [
+ "/browser-tests/guest-row-touch.html?kind=qemu",
+ "/browser-tests/guest-row-touch.html?kind=lxc",
+ "/browser-tests/table-row-touch.html"
+ ],
+ "viewports": [
+ {
+ "width": 1365,
+ "height": 900
+ },
+ {
+ "width": 390,
+ "height": 844
+ },
+ {
+ "width": 360,
+ "height": 844
+ }
+ ],
+ "states": [
+ "Production GuestRow and full GuestDrawer, production CSS and delegated actions, synthetic VM/CT observations. Playwright 1.56.1 matches integration lock; Chromium 141.0.7390.37 desktop/phone and WebKit 26.0 phone. Six screenshots opened and inspected.",
+ "Trusted first name/row-body activation reveals missing completed backup, separately cautionary running activity and retained VM-lock disk guidance. The 360px CT control uses the row body. Full drawer safety copy wraps without page overflow; the two-column fixture is not full Workloads table layout acceptance.",
+ "Row remains a table row, not an extra tab stop or duplicate expanded control. Native disclosure retains matching aria-expanded and aria-controls, conditional action removal/re-enable preserves the same row node.",
+ "Emulated input and presentation only: no physical phone, real guest collector, QGA/thaw, installed recovery or released-availability assertion. Earlier failed parent activation and two adverse harness runs remain retained separately."
+ ],
+ "interactions": [
+ "Trusted Playwright touch taps emit click and toggle the same row exactly once; repeated name/row taps and production drawer collapse preserve independent action counts. Touch entry does not activate a hover history lens.",
+ "Focused native disclosure Enter/Space and nested SVG click each toggle once. The row body remains usable while keyboard focus makes compact disclosure visible. No touch-end or pointer-up action is added.",
+ "Real adjacent external-link SVG is exercised on the actual wide layout with only navigation prevented; production propagation isolation keeps the row closed. Phone identity intentionally hides that link; it is not forced into a non-condensed phone layout.",
+ "Removing the row action prevents activation and removes disclosure; re-enabling restores first-tap activation without remount. Exported compatibility marker preserves shared TableRow single action and delegated child-button isolation. Guest fixtures issue only local GET requests and report no page errors."
+ ]
+}
diff --git a/frontend-modern/browser-verification/9596a94267f10b6d.json b/frontend-modern/browser-verification/9596a94267f10b6d.json
new file mode 100644
index 0000000000..b18aa6a289
--- /dev/null
+++ b/frontend-modern/browser-verification/9596a94267f10b6d.json
@@ -0,0 +1,44 @@
+{
+ "version": 1,
+ "base_sha": "d856689f5b3b200c942db4151a0b657cfdacf1c4",
+ "verified_at": "2026-10-03T23:44:57.288Z",
+ "result": "passed",
+ "changed_paths": [
+ "frontend-modern/src/components/Workloads/GuestRow.tsx"
+ ],
+ "content_sha256": {
+ "frontend-modern/src/components/Workloads/GuestRow.tsx": "e531b8c4584a8a8f4c76a3205b472ae6cc41c15780cf70a0205b06f7bdf553ed"
+ },
+ "routes": [
+ "/browser-tests/guest-row-touch.html?kind=qemu",
+ "/browser-tests/guest-row-touch.html?kind=lxc",
+ "/browser-tests/table-row-touch.html"
+ ],
+ "viewports": [
+ {
+ "width": 1365,
+ "height": 900
+ },
+ {
+ "width": 390,
+ "height": 844
+ },
+ {
+ "width": 360,
+ "height": 844
+ }
+ ],
+ "states": [
+ "Production GuestRow and full GuestDrawer, production CSS and delegated actions, synthetic VM/CT observations. Playwright 1.56.1 matches integration lock; Chromium 141.0.7390.37 desktop/phone and WebKit 26.0 phone. Six screenshots opened and inspected.",
+ "Trusted first name/row-body activation reveals missing completed backup, separately cautionary running activity and retained VM-lock disk guidance. The 360px CT control uses the row body. Full drawer safety copy wraps without page overflow; the two-column fixture is not full Workloads table layout acceptance.",
+ "Row remains a table row, not an extra tab stop or duplicate expanded control. Native disclosure retains matching aria-expanded and aria-controls, conditional action removal/re-enable preserves the same row node.",
+ "Emulated input and presentation only: no physical phone, real guest collector, QGA/thaw, installed recovery or released-availability assertion. Earlier failed parent activation and two adverse harness runs remain retained separately.",
+ "Final GuestRow uses typed splitProps forwarding, matching the shared TableRow native/delegated pattern after the initial JSX lint failure. No lint rule was disabled; all browser interaction checks rerun on these exact final bytes."
+ ],
+ "interactions": [
+ "Trusted Playwright touch taps emit click and toggle the same row exactly once; repeated name/row taps and production drawer collapse preserve independent action counts. Touch entry does not activate a hover history lens.",
+ "Focused native disclosure Enter/Space and nested SVG click each toggle once. The row body remains usable while keyboard focus makes compact disclosure visible. No touch-end or pointer-up action is added.",
+ "Real adjacent external-link SVG is exercised on the actual wide layout with only navigation prevented; production propagation isolation keeps the row closed. Phone identity intentionally hides that link; it is not forced into a non-condensed phone layout.",
+ "Removing the row action prevents activation and removes disclosure; re-enabling restores first-tap activation without remount. Exported compatibility marker preserves shared TableRow single action and delegated child-button isolation. Guest fixtures issue only local GET requests and report no page errors."
+ ]
+}
diff --git a/frontend-modern/src/components/Workloads/GuestRow.tsx b/frontend-modern/src/components/Workloads/GuestRow.tsx
index 2622930599..784665c66b 100644
--- a/frontend-modern/src/components/Workloads/GuestRow.tsx
+++ b/frontend-modern/src/components/Workloads/GuestRow.tsx
@@ -1,4 +1,4 @@
-import { createMemo, createSignal, Show } from 'solid-js';
+import { createMemo, createSignal, Show, splitProps } from 'solid-js';
import BoxIcon from 'lucide-solid/icons/box';
import type { VM } from '@/types/api';
import type { WorkloadGuest } from '@/types/workloads';
@@ -27,6 +27,7 @@ import {
createSummaryInteractiveRowPreviewHandlers,
} from '@/components/shared/summaryInteractionA11y';
import { SummaryRowActionButton } from '@/components/shared/SummaryRowActionButton';
+import { nativeRowClickTarget } from '@/components/shared/Table';
import { DiscoveryReadinessBadge } from '@/components/shared/DiscoveryReadinessBadge';
import { getWorkloadGuestDiskStatusMessage } from '@/utils/workloadGuestPresentation';
import { ResourceNameWithWebInterfaceLink } from '@/components/shared/WebInterfaceLink';
@@ -49,6 +50,7 @@ export type { GuestRowProps, WorkloadIOEmphasis } from './guestRowModel';
import { getGuestColumnStyle } from './guestRowModel';
export function GuestRow(props: GuestRowProps) {
+ const [rowActionProps] = splitProps(props, ['onClick']);
const {
agentVersion,
appContainerRuntimeBadge,
@@ -241,7 +243,8 @@ export function GuestRow(props: GuestRowProps) {
: undefined
}
data-summary-row-active={props.isSummaryHighlighted && !props.isExpanded ? 'true' : 'false'}
- onClick={props.onClick}
+ on:click={rowActionProps.onClick ? nativeRowClickTarget : undefined}
+ {...rowActionProps}
{...interactiveRowHandlers}
>
{/* Name - always visible */}
diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx
index 42b1032ce3..16dd981390 100644
--- a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx
+++ b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx
@@ -212,6 +212,7 @@ afterEach(() => {
cleanup();
vi.useRealTimers();
vi.clearAllMocks();
+ vi.restoreAllMocks();
isMobileMock.mockReturnValue(false);
});
@@ -835,6 +836,70 @@ describe('GuestRow', () => {
expect(onClick).toHaveBeenCalledOnce();
});
+ it('marks only actionable guest rows as native touch click targets', () => {
+ const addListener = vi.spyOn(HTMLTableRowElement.prototype, 'addEventListener');
+ const removeListener = vi.spyOn(HTMLTableRowElement.prototype, 'removeEventListener');
+ const onClick = vi.fn();
+ const [enabled, setEnabled] = createSignal(false);
+ const { container } = render(() => (
+
+ ));
+ const row = container.querySelector('tr')!;
+ const nativeClickCalls = () => addListener.mock.calls.filter(([type]) => type === 'click');
+
+ expect(nativeClickCalls()).toHaveLength(0);
+ setEnabled(true);
+ expect(nativeClickCalls()).toHaveLength(1);
+ expect(row).not.toHaveAttribute('tabindex');
+ expect(row).not.toHaveAttribute('role', 'button');
+ expect(row).not.toHaveAttribute('aria-expanded');
+
+ // The native compatibility target must never perform the action itself.
+ const nativeClick = nativeClickCalls()[0][1] as EventListener;
+ nativeClick.call(row, new MouseEvent('click'));
+ fireEvent.touchEnd(row);
+ fireEvent.pointerUp(row, { pointerType: 'touch' });
+ expect(onClick).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByText('test-vm'));
+ expect(onClick).toHaveBeenCalledTimes(1);
+
+ setEnabled(false);
+ expect(removeListener.mock.calls.some(([type]) => type === 'click')).toBe(true);
+ fireEvent.click(row);
+ expect(onClick).toHaveBeenCalledTimes(1);
+ setEnabled(true);
+ fireEvent.click(screen.getByText('test-vm'));
+ expect(onClick).toHaveBeenCalledTimes(2);
+ });
+
+ it('keeps child disclosure and external-link actions ahead of guest row activation', () => {
+ const onClick = vi.fn();
+ const { container } = renderGuestRow({
+ guest: makeGuest(),
+ customUrl: 'https://guest.example.invalid/',
+ onClick,
+ });
+ fireEvent.click(screen.getByRole('link', { name: 'Open web interface for test-vm' }));
+ expect(onClick).not.toHaveBeenCalled();
+ const button = screen.getByRole('button', { name: 'Expand test-vm' });
+ fireEvent.click(button.querySelector('svg')!);
+ expect(onClick).toHaveBeenCalledTimes(1);
+ fireEvent.keyDown(button, { key: 'Enter' });
+ expect(onClick).toHaveBeenCalledTimes(2);
+ fireEvent.keyDown(button, { key: ' ', code: 'Space' });
+ expect(onClick).toHaveBeenCalledTimes(3);
+ fireEvent.keyDown(container.querySelector('tr')!, { key: 'Enter' });
+ expect(onClick).toHaveBeenCalledTimes(3);
+ });
+
it('calls onHoverChange with canonical guestId on fine-pointer preview', () => {
const onHoverChange = vi.fn();
const { container } = renderGuestRow({
diff --git a/frontend-modern/src/components/shared/Table.tsx b/frontend-modern/src/components/shared/Table.tsx
index a39c4ebabc..7b87a7d588 100644
--- a/frontend-modern/src/components/shared/Table.tsx
+++ b/frontend-modern/src/components/shared/Table.tsx
@@ -66,7 +66,8 @@ 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;
+// Custom row shells reuse this marker without inheriting TableRow's styling.
+export const nativeRowClickTarget = () => undefined;
export function TableRow(props: TableRowProps) {
const [local, rest] = splitProps(props, ['class', 'children']);
diff --git a/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx b/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx
index b42076dff2..b3da84eeb9 100644
--- a/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx
+++ b/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx
@@ -5,7 +5,7 @@ import pulseDataGridSource from '@/components/shared/PulseDataGrid.tsx?raw';
import pulseDataGridModelSource from '@/components/shared/pulseDataGridModel.ts?raw';
import pulseDataGridStateSource from '@/components/shared/usePulseDataGridState.ts?raw';
import { PulseDataGrid } from '@/components/shared/PulseDataGrid';
-import { TableCell } from '@/components/shared/Table';
+import { TableCell, nativeRowClickTarget } from '@/components/shared/Table';
import tableSource from '@/components/shared/Table.tsx?raw';
type TestRow = {
@@ -105,16 +105,23 @@ describe('PulseDataGrid', () => {
it('inherits the native touch click target from the shared TableRow', () => {
const nativeListener = vi.spyOn(HTMLTableRowElement.prototype, 'addEventListener');
+ const onRowClick = vi.fn();
try {
render(() => (
data={[{ id: '1', name: 'Touch tower' }]}
columns={[{ key: 'name', label: 'Name' }]}
keyExtractor={(row) => row.id}
- onRowClick={vi.fn()}
+ onRowClick={onRowClick}
/>
));
- expect(nativeListener.mock.calls.some(([type]) => type === 'click')).toBe(true);
+ expect(nativeListener.mock.calls.find(([type]) => type === 'click')?.[1]).toBe(
+ nativeRowClickTarget,
+ );
+ nativeRowClickTarget();
+ expect(onRowClick).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByText('Touch tower'));
+ expect(onRowClick).toHaveBeenCalledTimes(1);
} finally {
nativeListener.mockRestore();
}