diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index 2eec02ad8..ba2e532c1 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -20,6 +20,18 @@
## Purpose
+### Shipped documentation table headers
+
+After sanitization, the documentation renderer assigns trusted `scope=col` to
+native `thead th` cells without expanding the attribute allowlist. Document
+roles, scope and classes remain untrusted, and body cells are not promoted.
+Table-local horizontal scrolling is preserved. Renderer regression tests cover
+trusted column scope and rejected author-supplied attributes. The production
+Docs/router/styles fixture checks all eight current plan-table columnheader
+roles in desktop Chromium and phone WebKit, including local scrolling and
+page-width containment. This establishes browser roles, not screen-reader
+speech or release availability.
+
### Shipped documentation fragment navigation
The shared documentation renderer assigns GitHub-compatible, document-local
diff --git a/frontend-modern/browser-tests/docs-fragment-navigation.html b/frontend-modern/browser-tests/docs-fragment-navigation.html
index db161db13..29edc2c5d 100644
--- a/frontend-modern/browser-tests/docs-fragment-navigation.html
+++ b/frontend-modern/browser-tests/docs-fragment-navigation.html
@@ -2,6 +2,7 @@
+
diff --git a/frontend-modern/browser-tests/docs-fragment-navigation.tsx b/frontend-modern/browser-tests/docs-fragment-navigation.tsx
index 5a9d65b9f..5c11587d7 100644
--- a/frontend-modern/browser-tests/docs-fragment-navigation.tsx
+++ b/frontend-modern/browser-tests/docs-fragment-navigation.tsx
@@ -16,6 +16,7 @@ const scenario = new URLSearchParams(window.location.search).get('scenario') ??
const targets: Record = {
plain: '/docs/API',
+ plans: '/docs/PULSE_PRO',
direct: '/docs/API#resource-maintenance-and-operator-state',
reload: '/docs/API#resource-maintenance-and-operator-state',
malformed: '/docs/API#%invalid',
diff --git a/frontend-modern/browser-tests/docs-table-headers.cjs b/frontend-modern/browser-tests/docs-table-headers.cjs
new file mode 100644
index 000000000..e7ba98eab
--- /dev/null
+++ b/frontend-modern/browser-tests/docs-table-headers.cjs
@@ -0,0 +1,124 @@
+// Production Docs/router/styles and shipped plan Markdown, no backend.
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const { chromium, webkit } = require('playwright');
+(async () => {
+ const observe = process.argv.includes('observe');
+ const root = '/workspace/frontend-modern';
+ process.chdir(root);
+ const artifacts = path.join(root, 'node_modules', 'docs-header-browser');
+ 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: path.join(root, 'node_modules', '.vite-docs-table-headers'),
+ server: { host: '127.0.0.1', port: 5243, strictPort: true },
+ optimizeDeps: { noDiscovery: true, include: [] },
+ });
+ let browser;
+ const results = [];
+ try {
+ await server.listen();
+ for (const [engine, width, tone] of [
+ ['chromium', 1280, 'light'],
+ ['webkit', 390, 'dark'],
+ ]) {
+ browser = await { chromium, webkit }[engine].launch(
+ engine === 'chromium'
+ ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
+ : { headless: true },
+ );
+ const page = await browser.newPage({
+ viewport: { width, height: 900 },
+ ...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}),
+ });
+ const errors = [];
+ page.on('response', (response) => {
+ if (response.status() >= 400)
+ console.log('FAILED_RESPONSE', response.status(), response.url());
+ });
+ page.on('pageerror', (e) => errors.push(e.message));
+ page.on('console', (m) => {
+ if (m.type() === 'error') {
+ console.log('CONSOLE_ERROR', m.text(), m.location());
+ errors.push(m.text());
+ }
+ });
+ await page.goto(
+ 'http://127.0.0.1:5243/browser-tests/docs-fragment-navigation.html?scenario=plans',
+ { waitUntil: 'domcontentloaded', timeout: 60_000 },
+ );
+ await page.locator('article table').first().waitFor();
+ if (tone === 'dark')
+ await page.evaluate(() => document.documentElement.classList.add('dark'));
+ const table = page.locator('article table').filter({ hasText: 'Relay (legacy)' }).first();
+ const th = await table.locator('thead th').allTextContents();
+ const headers = await table.getByRole('columnheader').allTextContents();
+ const snapshot = await table.ariaSnapshot();
+ const scopes = await table
+ .locator('thead th')
+ .evaluateAll((nodes) => nodes.map((n) => n.getAttribute('scope')));
+ const scroll = await table.evaluate((t) => ({
+ wrapper: t.parentElement.dataset.docTableScroll !== undefined,
+ outer: document.documentElement.scrollWidth,
+ viewport: window.innerWidth,
+ width: t.parentElement.clientWidth,
+ content: t.parentElement.scrollWidth,
+ }));
+ if (!observe) {
+ assert.equal(th.length, 8);
+ assert.deepEqual(headers, th);
+ assert.deepEqual(scopes, Array(th.length).fill('col'));
+ assert.ok(snapshot.includes('columnheader "Relay (legacy)"'));
+ assert.ok(scroll.wrapper);
+ assert.ok(scroll.outer <= scroll.viewport + 1);
+ if (width === 390) {
+ assert.ok(scroll.content > scroll.width);
+ const moved = await table.evaluate((t) => {
+ t.parentElement.scrollLeft = 180;
+ return t.parentElement.scrollLeft;
+ });
+ assert.ok(moved > 0);
+ }
+ assert.deepEqual(errors, []);
+ }
+ await table.locator('thead').scrollIntoViewIfNeeded();
+ await page.screenshot({ path: path.join(artifacts, `${engine}-headers.png`) });
+ await page.screenshot({
+ path: path.join(artifacts, `${engine}-${observe ? 'before' : 'after'}.png`),
+ fullPage: true,
+ });
+ results.push({
+ engine,
+ version: browser.version(),
+ width,
+ tone,
+ th,
+ headers,
+ scopes,
+ scroll,
+ snapshot,
+ errors,
+ });
+ await browser.close();
+ browser = undefined;
+ }
+ fs.writeFileSync(
+ path.join(artifacts, observe ? 'before.json' : 'after.json'),
+ JSON.stringify(
+ { playwright: require('playwright/package.json').version, observe, results },
+ null,
+ 2,
+ ),
+ );
+ console.log(JSON.stringify({ result: observe ? 'observed' : 'passed', results, artifacts }));
+ } finally {
+ if (browser) await browser.close();
+ await server.close();
+ }
+})().catch((e) => {
+ console.error(e);
+ process.exitCode = 1;
+});
diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json
index b6c48716f..45fb502a2 100644
--- a/frontend-modern/browser-verification.json
+++ b/frontend-modern/browser-verification.json
@@ -1,18 +1,17 @@
{
"version": 1,
- "base_sha": "53e69c06fe24e3c07deaddb5e40418d07bb0b34b",
- "verified_at": "2026-10-01T18:25:48.680085Z",
+ "base_sha": "1a0cd7c9d5aa02de754535a293dc0cacd4ef6fa0",
+ "verified_at": "2026-10-01T18:27:14.912695Z",
"result": "passed",
"changed_paths": [
- "frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx",
- "frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx"
+ "frontend-modern/src/features/docs/docMarkdown.ts"
],
"content_sha256": {
- "frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx": "f9b44049c02a5e5146d882befeefe8aa5bab221a2c4a0951e529c274a416010c",
- "frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx": "1f04fe06cfbb1874c657883e07b4c7ce0992399696b5bed05f3ea029b1f00c0f"
+ "frontend-modern/src/features/docs/docMarkdown.ts": "572a3b4bc5bc1114adef5aa64bb0ea833c0dbe5865bbd57d3dc767fd1965aa55"
},
"routes": [
- "/browser-tests/unix-private-install.html"
+ "/docs/PULSE_PRO",
+ "/browser-tests/docs-fragment-navigation.html?scenario=plans"
],
"viewports": [
{
@@ -25,27 +24,26 @@
}
],
"states": [
- "Production installer and Agent Doctor with synthetic APIs, required authentication and separately issued token; not native installation or enrollment",
- "Private-file note preserves trusted editor/upload, root 0600 file and 0700 directory, no GUI secret and console/SSH fallback; command preserves the user-owned file",
- "Removed agent note preserves host-local detachment, silent prompt and no token in copied command; command retains exact removed-agent identity and no new-binary preflight",
- "Desktop Chromium light and phone-emulated WebKit dark; four affected-note screenshots inspected, outer page width contained"
+ "Production Docs page/router/styles with the current shipped eight-column plan comparison, not a replacement table fixture",
+ "Every sanitized thead TH has trusted scope=col and all eight native columnheader roles; document role/scope/class remain stripped",
+ "Desktop Chromium light and phone-emulated WebKit dark; table-local horizontal scrolling and outer-page containment; four final full-page/header screenshots inspected",
+ "This establishes browser accessibility roles, not screen-reader speech or published availability"
],
"interactions": [
- "Generate separate token and dismiss token dialog",
- "Copy the private-file installation command; verify fixed private path and token secrecy",
- "Expand removed agent and copy its scoped uninstall command; verify token secrecy and no preflight"
+ "Navigate to the real plans document and inspect table DOM and accessibility snapshot",
+ "On phone, scroll the table horizontally without moving the outer page; bring its header into view"
],
- "command": "pulse-worker-browser frontend-modern/browser-tests/unix-guidance-copy.cjs",
+ "command": "pulse-worker-browser frontend-modern/browser-tests/docs-table-headers.cjs",
"browser_versions": {
"playwright": "1.56.1",
"chromium": "141.0.7390.37",
"webkit": "26.0"
},
"artifacts": [
- "/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/unix/chromium-private-file.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/unix/chromium-removed-note.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/unix/webkit-private-file.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/unix/webkit-removed-note.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/unix/result.json"
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/docs/chromium-after.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/docs/chromium-headers.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/docs/webkit-after.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/docs/webkit-headers.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/docs/after.json"
]
}
diff --git a/frontend-modern/src/features/docs/__tests__/docMarkdown.test.ts b/frontend-modern/src/features/docs/__tests__/docMarkdown.test.ts
index 56b5aaed0..45610a272 100644
--- a/frontend-modern/src/features/docs/__tests__/docMarkdown.test.ts
+++ b/frontend-modern/src/features/docs/__tests__/docMarkdown.test.ts
@@ -238,3 +238,32 @@ describe('extractDocTitle', () => {
expect(extractDocTitle('Intro\n\n# Later heading', 'FAQ')).toBe('FAQ');
});
});
+
+describe('documentation table column semantics', () => {
+ it('assigns column scope to every sanitized Markdown header without promoting data cells', () => {
+ const container = document.createElement('div');
+ container.innerHTML = renderDocMarkdown(
+ '| Feature | Community | Pro |\n| --- | --- | --- |\n| Metrics | Yes | Yes |',
+ 'PULSE_PRO',
+ );
+ const headers = Array.from(container.querySelectorAll('thead th'));
+ expect(headers).toHaveLength(3);
+ expect(headers.map((header) => header.getAttribute('scope'))).toEqual(['col', 'col', 'col']);
+ expect(container.querySelectorAll('tbody [scope]')).toHaveLength(0);
+ expect(
+ container.querySelector('table')?.parentElement?.hasAttribute('data-doc-table-scroll'),
+ ).toBe(true);
+ });
+
+ it('does not trust document-supplied roles or scope while assigning trusted column scope', () => {
+ const container = document.createElement('div');
+ container.innerHTML = renderDocMarkdown(
+ '',
+ 'PULSE_PRO',
+ );
+ const header = container.querySelector('thead th');
+ expect(header?.getAttribute('scope')).toBe('col');
+ expect(container.querySelector('[role], [onclick], th[class]')).toBeNull();
+ expect(container.querySelector('tbody th')?.hasAttribute('scope')).toBe(false);
+ });
+});
diff --git a/frontend-modern/src/features/docs/docMarkdown.ts b/frontend-modern/src/features/docs/docMarkdown.ts
index 5938c20eb..8402db43a 100644
--- a/frontend-modern/src/features/docs/docMarkdown.ts
+++ b/frontend-modern/src/features/docs/docMarkdown.ts
@@ -130,6 +130,10 @@ export function renderDocMarkdown(markdown: string, currentDocPath: string): str
*/
export function wrapTables(container: HTMLElement): void {
container.querySelectorAll('table').forEach((table) => {
+ // Sanitised Markdown has no author-controlled roles/scope. Assign trusted
+ // column semantics to header cells: browsers otherwise expose these THs
+ // as ordinary cells, leaving readers without table header associations.
+ table.querySelectorAll('thead th').forEach((header) => header.setAttribute('scope', 'col'));
const parent = table.parentElement;
if (parent?.dataset?.docTableScroll !== undefined) return;
const wrapper = window.document.createElement('div');