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( + '
Heading
Body labelValue
', + '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');