From 102b699f7a8df7a0bcdafadf70da274d3c5894a0 Mon Sep 17 00:00:00 2001 From: Art Pai Date: Sun, 13 Sep 2026 00:38:46 +1000 Subject: [PATCH] Make report links part of the content and preview changes locally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: ことね --- README.md | 9 + docs/architecture.md | 2 +- fixtures/report-preview.json | 85 +++++++ package.json | 3 +- scripts/dev-report.ts | 31 +++ src/report.ts | 4 +- src/report/client.ts | 96 ++------ src/report/{shell.ts => render.ts} | 77 ++---- test/cli.test.ts | 3 +- test/report-dom.test.ts | 220 ++++++------------ test/report.test.ts | 67 ++---- test/visual/__screenshots__/desktop/print.png | Bin 66365 -> 70813 bytes .../visual/__screenshots__/desktop/report.png | Bin 98724 -> 99007 bytes .../__screenshots__/desktop/section.png | Bin 13491 -> 11382 bytes test/visual/__screenshots__/narrow/print.png | Bin 61155 -> 65971 bytes test/visual/__screenshots__/narrow/report.png | Bin 73701 -> 73908 bytes .../visual/__screenshots__/narrow/section.png | Bin 12670 -> 10315 bytes test/visual/__screenshots__/phone/print.png | Bin 60925 -> 65334 bytes test/visual/__screenshots__/phone/report.png | Bin 74147 -> 73018 bytes test/visual/__screenshots__/phone/section.png | Bin 12536 -> 10596 bytes test/visual/__screenshots__/tablet/print.png | Bin 64464 -> 69030 bytes test/visual/__screenshots__/tablet/report.png | Bin 78005 -> 78475 bytes .../visual/__screenshots__/tablet/section.png | Bin 13149 -> 10946 bytes worker/build-assets.ts | 2 +- worker/index.test.ts | 5 +- worker/index.ts | 2 +- 26 files changed, 280 insertions(+), 326 deletions(-) create mode 100644 fixtures/report-preview.json create mode 100644 scripts/dev-report.ts rename src/report/{shell.ts => render.ts} (86%) diff --git a/README.md b/README.md index 89825fc..56922c9 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,15 @@ installing it. Source: [skills/diffwalk/SKILL.md](skills/diffwalk/SKILL.md). ## Development +Run `pnpm dev:report` to preview the report at `http://localhost:4399/` +(from another device on this LAN: `http://192.168.88.8:4399/`). +The server binds to `0.0.0.0`; use `PORT=4400 pnpm dev:report` for another port. +Edit `fixtures/report-preview.json` for sample content and `scripts/dev-report.ts` +for the preview server. Report layout and interactions come directly from +`src/report/render.ts` and `src/report/client.ts`. Source and sample edits are watched; +refresh the browser to see changes, including rebuilt browser-client code. +Stop with Ctrl+C. This preview does not publish or change saved walks. + Additional dependencies: - Bun 1.3 or newer. diff --git a/docs/architecture.md b/docs/architecture.md index f94d6d2..84bc4a0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -134,7 +134,7 @@ exact corresponding diffs in a deliberate order. client, and tests. - `src/report/markdown.ts`: Markdown rendering with inline HTML passed through. - `src/report.ts`: atomic report writes and client-bundle loading. -- `src/report/shell.ts`: the one report shell, embedded-data escaping, and shell styles, +- `src/report/render.ts`: the one report shell, embedded-data escaping, and shell styles, rendered with inlined assets for the offline file or linked assets for the hosted page. - `src/publish.ts`: review service origin checks, publish credential lookup, the publish, update, and unpublish requests, and adding the Git user name as diff --git a/fixtures/report-preview.json b/fixtures/report-preview.json new file mode 100644 index 0000000..31cb8a4 --- /dev/null +++ b/fixtures/report-preview.json @@ -0,0 +1,85 @@ +{ + "formatVersion": 1, + "title": "A clearer report review experience", + "source": { + "kind": "working-tree", + "capturedAt": "2026-09-12T00:00:00.000Z", + "from": { + "revision": "HEAD", + "commit": "0123456789abcdef" + } + }, + "sections": [ + { + "title": "Make navigation part of the content", + "steps": [ + { + "text": "### Keep the explanation in focus\n\nReaders should be able to follow a change without sorting through a toolbar of identifiers. The section title provides a stable destination, while each step has a light gray LINK on the right.\n\nSelect a few words in this paragraph, then try the LINK on its right. The link should be easy to find without competing with the explanation.", + "changes": [ + "change-037", + "change-040" + ], + "diff": "diff --git a/src/report/navigation.ts b/src/report/navigation.ts\n--- a/src/report/navigation.ts\n+++ b/src/report/navigation.ts\n@@ -1,12 +1,12 @@\n-export const option1 = \"previous value 1\"\n-export const option2 = \"previous value 2\"\n-export const option3 = \"previous value 3\"\n-export const option4 = \"previous value 4\"\n-export const option5 = \"previous value 5\"\n-export const option6 = \"previous value 6\"\n-export const option7 = \"previous value 7\"\n-export const option8 = \"previous value 8\"\n-export const option9 = \"previous value 9\"\n-export const option10 = \"previous value 10\"\n-export const option11 = \"previous value 11\"\n-export const option12 = \"previous value 12\"\n+export const option1 = \"updated value 1\"\n+export const option2 = \"updated value 2\"\n+export const option3 = \"updated value 3\"\n+export const option4 = \"updated value 4\"\n+export const option5 = \"updated value 5\"\n+export const option6 = \"updated value 6\"\n+export const option7 = \"updated value 7\"\n+export const option8 = \"updated value 8\"\n+export const option9 = \"updated value 9\"\n+export const option10 = \"updated value 10\"\n+export const option11 = \"updated value 11\"\n+export const option12 = \"updated value 12\"\n" + }, + { + "text": "### Separate folding from navigation\n\nUse the arrow and number to collapse this section. Clicking its title should navigate instead. These actions need different hit areas so a reader can copy a title or follow a link without accidentally hiding the report.\n\n- Section title: navigate to this section.\n- Arrow and number: expand or collapse.\n- Paragraph anchor: share one step.\n\nTry all three after scrolling down and returning to this section." + }, + { + "text": "The same placement should work across multiple files. Compare the two file headers below and check that the explanation still feels connected to its code.", + "diff": "diff --git a/src/report/actions.ts b/src/report/actions.ts\n--- a/src/report/actions.ts\n+++ b/src/report/actions.ts\n@@ -1,10 +1,10 @@\n-export const option1 = \"previous value 1\"\n-export const option2 = \"previous value 2\"\n-export const option3 = \"previous value 3\"\n-export const option4 = \"previous value 4\"\n-export const option5 = \"previous value 5\"\n-export const option6 = \"previous value 6\"\n-export const option7 = \"previous value 7\"\n-export const option8 = \"previous value 8\"\n-export const option9 = \"previous value 9\"\n-export const option10 = \"previous value 10\"\n+export const option1 = \"updated value 1\"\n+export const option2 = \"updated value 2\"\n+export const option3 = \"updated value 3\"\n+export const option4 = \"updated value 4\"\n+export const option5 = \"updated value 5\"\n+export const option6 = \"updated value 6\"\n+export const option7 = \"updated value 7\"\n+export const option8 = \"updated value 8\"\n+export const option9 = \"updated value 9\"\n+export const option10 = \"updated value 10\"\ndiff --git a/src/report/selection.ts b/src/report/selection.ts\n--- a/src/report/selection.ts\n+++ b/src/report/selection.ts\n@@ -1,8 +1,8 @@\n-export const option1 = \"previous value 1\"\n-export const option2 = \"previous value 2\"\n-export const option3 = \"previous value 3\"\n-export const option4 = \"previous value 4\"\n-export const option5 = \"previous value 5\"\n-export const option6 = \"previous value 6\"\n-export const option7 = \"previous value 7\"\n-export const option8 = \"previous value 8\"\n+export const option1 = \"updated value 1\"\n+export const option2 = \"updated value 2\"\n+export const option3 = \"updated value 3\"\n+export const option4 = \"updated value 4\"\n+export const option5 = \"updated value 5\"\n+export const option6 = \"updated value 6\"\n+export const option7 = \"updated value 7\"\n+export const option8 = \"updated value 8\"\n", + "changes": [ + "change-042", + "change-043" + ] + } + ] + }, + { + "title": "Keep longer explanations readable on narrow screens", + "steps": [ + { + "text": "A report often needs more than one sentence to explain why a change matters. Long paragraphs are useful here because they reveal whether the anchor steals space, whether text wraps cleanly, and whether the content starts at a consistent position.\n\nThe layout should remain readable when a desktop window is narrowed to phone width. Inline code such as `resolveProjectConfiguration(workspaceRoot)` can be wider than ordinary words, and should not push the controls outside the viewport.\n\n> This blockquote is another content shape to review. It should keep its own indentation while the step anchor remains aligned with the beginning of the step.\n\nA final paragraph gives us enough content to check spacing before the next code block." + }, + { + "text": "This intentionally longer diff lets you test navigation after scrolling well below the section heading.", + "diff": "diff --git a/src/config/resolve-project-configuration.ts b/src/config/resolve-project-configuration.ts\n--- a/src/config/resolve-project-configuration.ts\n+++ b/src/config/resolve-project-configuration.ts\n@@ -1,42 +1,42 @@\n-export const option1 = \"previous value 1\"\n-export const option2 = \"previous value 2\"\n-export const option3 = \"previous value 3\"\n-export const option4 = \"previous value 4\"\n-export const option5 = \"previous value 5\"\n-export const option6 = \"previous value 6\"\n-export const option7 = \"previous value 7\"\n-export const option8 = \"previous value 8\"\n-export const option9 = \"previous value 9\"\n-export const option10 = \"previous value 10\"\n-export const option11 = \"previous value 11\"\n-export const option12 = \"previous value 12\"\n-export const option13 = \"previous value 13\"\n-export const option14 = \"previous value 14\"\n-export const option15 = \"previous value 15\"\n-export const option16 = \"previous value 16\"\n-export const option17 = \"previous value 17\"\n-export const option18 = \"previous value 18\"\n-export const option19 = \"previous value 19\"\n-export const option20 = \"previous value 20\"\n-export const option21 = \"previous value 21\"\n-export const option22 = \"previous value 22\"\n-export const option23 = \"previous value 23\"\n-export const option24 = \"previous value 24\"\n-export const option25 = \"previous value 25\"\n-export const option26 = \"previous value 26\"\n-export const option27 = \"previous value 27\"\n-export const option28 = \"previous value 28\"\n-export const option29 = \"previous value 29\"\n-export const option30 = \"previous value 30\"\n-export const option31 = \"previous value 31\"\n-export const option32 = \"previous value 32\"\n-export const option33 = \"previous value 33\"\n-export const option34 = \"previous value 34\"\n-export const option35 = \"previous value 35\"\n-export const option36 = \"previous value 36\"\n-export const option37 = \"previous value 37\"\n-export const option38 = \"previous value 38\"\n-export const option39 = \"previous value 39\"\n-export const option40 = \"previous value 40\"\n-export const option41 = \"previous value 41\"\n-export const option42 = \"previous value 42\"\n+export const option1 = \"updated value 1\"\n+export const option2 = \"updated value 2\"\n+export const option3 = \"updated value 3\"\n+export const option4 = \"updated value 4\"\n+export const option5 = \"updated value 5\"\n+export const option6 = \"updated value 6\"\n+export const option7 = \"updated value 7\"\n+export const option8 = \"updated value 8\"\n+export const option9 = \"updated value 9\"\n+export const option10 = \"updated value 10\"\n+export const option11 = \"updated value 11\"\n+export const option12 = \"updated value 12\"\n+export const option13 = \"updated value 13\"\n+export const option14 = \"updated value 14\"\n+export const option15 = \"updated value 15\"\n+export const option16 = \"updated value 16\"\n+export const option17 = \"updated value 17\"\n+export const option18 = \"updated value 18\"\n+export const option19 = \"updated value 19\"\n+export const option20 = \"updated value 20\"\n+export const option21 = \"updated value 21\"\n+export const option22 = \"updated value 22\"\n+export const option23 = \"updated value 23\"\n+export const option24 = \"updated value 24\"\n+export const option25 = \"updated value 25\"\n+export const option26 = \"updated value 26\"\n+export const option27 = \"updated value 27\"\n+export const option28 = \"updated value 28\"\n+export const option29 = \"updated value 29\"\n+export const option30 = \"updated value 30\"\n+export const option31 = \"updated value 31\"\n+export const option32 = \"updated value 32\"\n+export const option33 = \"updated value 33\"\n+export const option34 = \"updated value 34\"\n+export const option35 = \"updated value 35\"\n+export const option36 = \"updated value 36\"\n+export const option37 = \"updated value 37\"\n+export const option38 = \"updated value 38\"\n+export const option39 = \"updated value 39\"\n+export const option40 = \"updated value 40\"\n+export const option41 = \"updated value 41\"\n+export const option42 = \"updated value 42\"\n", + "changes": [ + "change-045" + ] + } + ] + }, + { + "title": "Review a code-only step without an explanation heading", + "steps": [ + { + "text": "", + "diff": "diff --git a/src/report/anchors.ts b/src/report/anchors.ts\n--- a/src/report/anchors.ts\n+++ b/src/report/anchors.ts\n@@ -1,24 +1,24 @@\n-export const option1 = \"previous value 1\"\n-export const option2 = \"previous value 2\"\n-export const option3 = \"previous value 3\"\n-export const option4 = \"previous value 4\"\n-export const option5 = \"previous value 5\"\n-export const option6 = \"previous value 6\"\n-export const option7 = \"previous value 7\"\n-export const option8 = \"previous value 8\"\n-export const option9 = \"previous value 9\"\n-export const option10 = \"previous value 10\"\n-export const option11 = \"previous value 11\"\n-export const option12 = \"previous value 12\"\n-export const option13 = \"previous value 13\"\n-export const option14 = \"previous value 14\"\n-export const option15 = \"previous value 15\"\n-export const option16 = \"previous value 16\"\n-export const option17 = \"previous value 17\"\n-export const option18 = \"previous value 18\"\n-export const option19 = \"previous value 19\"\n-export const option20 = \"previous value 20\"\n-export const option21 = \"previous value 21\"\n-export const option22 = \"previous value 22\"\n-export const option23 = \"previous value 23\"\n-export const option24 = \"previous value 24\"\n+export const option1 = \"updated value 1\"\n+export const option2 = \"updated value 2\"\n+export const option3 = \"updated value 3\"\n+export const option4 = \"updated value 4\"\n+export const option5 = \"updated value 5\"\n+export const option6 = \"updated value 6\"\n+export const option7 = \"updated value 7\"\n+export const option8 = \"updated value 8\"\n+export const option9 = \"updated value 9\"\n+export const option10 = \"updated value 10\"\n+export const option11 = \"updated value 11\"\n+export const option12 = \"updated value 12\"\n+export const option13 = \"updated value 13\"\n+export const option14 = \"updated value 14\"\n+export const option15 = \"updated value 15\"\n+export const option16 = \"updated value 16\"\n+export const option17 = \"updated value 17\"\n+export const option18 = \"updated value 18\"\n+export const option19 = \"updated value 19\"\n+export const option20 = \"updated value 20\"\n+export const option21 = \"updated value 21\"\n+export const option22 = \"updated value 22\"\n+export const option23 = \"updated value 23\"\n+export const option24 = \"updated value 24\"\n", + "changes": [ + "change-046" + ] + }, + { + "text": "The previous step has no prose. Its anchor should still have an obvious position and should not cover a filename, line number, or changed line.\n\nAfter following its link, collapse this section and open the same URL again to check that the destination becomes visible." + } + ] + }, + { + "title": "Handle long section titles without crowding the fold control or making the entire heading behave like a toggle", + "steps": [ + { + "text": "### Final review\n\nThis final section is deliberately far from the top. Use it to test a copied link in a new tab, browser Back and Forward, and the sticky navigation while scrolling.\n\n1. Select and copy part of this title.\n2. Open a step anchor in a new tab.\n3. Collapse and expand using only the arrow and number.\n4. Resize the viewport and check the same controls again.", + "diff": "diff --git a/src/report/final-review.ts b/src/report/final-review.ts\n--- a/src/report/final-review.ts\n+++ b/src/report/final-review.ts\n@@ -1,16 +1,16 @@\n-export const option1 = \"previous value 1\"\n-export const option2 = \"previous value 2\"\n-export const option3 = \"previous value 3\"\n-export const option4 = \"previous value 4\"\n-export const option5 = \"previous value 5\"\n-export const option6 = \"previous value 6\"\n-export const option7 = \"previous value 7\"\n-export const option8 = \"previous value 8\"\n-export const option9 = \"previous value 9\"\n-export const option10 = \"previous value 10\"\n-export const option11 = \"previous value 11\"\n-export const option12 = \"previous value 12\"\n-export const option13 = \"previous value 13\"\n-export const option14 = \"previous value 14\"\n-export const option15 = \"previous value 15\"\n-export const option16 = \"previous value 16\"\n+export const option1 = \"updated value 1\"\n+export const option2 = \"updated value 2\"\n+export const option3 = \"updated value 3\"\n+export const option4 = \"updated value 4\"\n+export const option5 = \"updated value 5\"\n+export const option6 = \"updated value 6\"\n+export const option7 = \"updated value 7\"\n+export const option8 = \"updated value 8\"\n+export const option9 = \"updated value 9\"\n+export const option10 = \"updated value 10\"\n+export const option11 = \"updated value 11\"\n+export const option12 = \"updated value 12\"\n+export const option13 = \"updated value 13\"\n+export const option14 = \"updated value 14\"\n+export const option15 = \"updated value 15\"\n+export const option16 = \"updated value 16\"\n", + "changes": [ + "change-048", + "change-049" + ] + }, + { + "text": "End of the sample. There should be enough content above to judge vertical rhythm, navigation, and the difference between a short step and a long one. No publication is needed to try another layout." + } + ] + } + ], + "summary": "A longer sample for reviewing navigation, readable explanations, and code changes before release. Scroll through multiple sections and try the section titles and step anchors.\n\nThis is sample content for layout review, not a description of shipped changes." +} diff --git a/package.json b/package.json index cd94b25..3acf494 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,8 @@ "test:visual": "playwright test", "test:visual:update": "playwright test --update-snapshots", "check": "pnpm typecheck && pnpm test && pnpm test:visual", - "prepack": "pnpm build" + "prepack": "pnpm build", + "dev:report": "bun --watch scripts/dev-report.ts" }, "dependencies": { "@pierre/diffs": "1.3.5", diff --git a/scripts/dev-report.ts b/scripts/dev-report.ts new file mode 100644 index 0000000..059e915 --- /dev/null +++ b/scripts/dev-report.ts @@ -0,0 +1,31 @@ +import { renderReport } from '../src/report/render' +import { explainDocumentSchema } from '../src/format' +import sample from '../fixtures/report-preview.json' + +const doc = explainDocumentSchema.parse(sample) + +const server = Bun.serve({ + hostname: '0.0.0.0', + port: Number(process.env.PORT ?? 4399), + async fetch(request) { + if (new URL(request.url).pathname !== '/') { + return new Response('Not found', { status: 404 }) + } + const build = await Bun.build({ + entrypoints: [new URL('../src/report/client.ts', import.meta.url).pathname], + target: 'browser', + format: 'iife', + minify: true, + }) + if (!build.success) { + console.error(build.logs) + return new Response('Report client build failed', { status: 500 }) + } + const client = await build.outputs[0]!.text() + return new Response(renderReport(doc, client), { + headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }, + }) + }, +}) + +console.log(`Report preview: http://localhost:${server.port}/ (LAN: http://192.168.88.8:${server.port}/)`) diff --git a/src/report.ts b/src/report.ts index a91e379..5d130e2 100644 --- a/src/report.ts +++ b/src/report.ts @@ -3,8 +3,8 @@ import { randomUUID } from 'node:crypto' import { basename, dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -export { renderHostedReport, renderReport, shellStyles } from './report/shell' -export type { HostedAssets, ReportLayout, ReportOptions } from './report/shell' +export { renderHostedReport, renderReport, shellStyles } from './report/render' +export type { HostedAssets, ReportLayout, ReportOptions } from './report/render' export async function writeReport(output: string, html: string): Promise { const absolutePath = resolve(output) diff --git a/src/report/client.ts b/src/report/client.ts index ace7c1f..cf5832a 100644 --- a/src/report/client.ts +++ b/src/report/client.ts @@ -206,7 +206,7 @@ function fragmentTarget(): { fragment: string; target: HTMLElement } | null { function revealFragment(final: boolean) { const resolved = fragmentTarget() if (resolved === null) return - const { fragment, target } = resolved + const { target } = resolved for (let parent = target.parentElement; parent; parent = parent.parentElement) { if (parent.matches('details')) (parent as HTMLDetailsElement).open = true @@ -225,66 +225,31 @@ function revealFragment(final: boolean) { // Align the target's leading edge (its title or marker). Centering a target // taller than the viewport would push that edge far off-screen. target.scrollIntoView?.({ block: 'start' }) - const targets = new Set(generatedTargets()) - const focusTarget = [...document.querySelectorAll('[data-copy-fragment]')] - .find((button) => { - if (button.dataset.copyFragment !== fragment) return false - for (let owner = button.parentElement; owner; owner = owner.parentElement) { - if (targets.has(owner)) return owner === target - } - return false - }) - focusTarget?.focus({ preventScroll: true }) -} - -const feedbackTimers = new WeakMap>() - -function showCopyFeedback(button: HTMLButtonElement, success: boolean) { - const label = button.querySelector('[data-copy-label]') - const original = label?.dataset.originalLabel ?? label?.textContent ?? 'Copy' - if (label) { - label.dataset.originalLabel = original - label.textContent = success ? 'Copied' : 'Failed' - } - button.dataset.copyState = success ? 'success' : 'failure' - const status = document.querySelector('[data-copy-status]') - if (status) status.textContent = success ? 'Link copied to clipboard.' : 'Could not copy link.' - - const previous = feedbackTimers.get(button) - if (previous !== undefined) clearTimeout(previous) - feedbackTimers.set( - button, - setTimeout(() => { - if (label) label.textContent = original - delete button.dataset.copyState - }, 2000), - ) + const owner = target.matches('[data-target-kind="change"]') ? target.closest('.step') : target + const link = owner?.querySelector('a.section-title-text, a.permalink') + link?.focus({ preventScroll: true }) } -async function copyText(value: string): Promise { - if (navigator.clipboard?.writeText) { - try { - await navigator.clipboard.writeText(value) - return - } catch {} - } - - const field = document.createElement('textarea') - const active = document.activeElement - field.value = value - field.style.position = 'fixed' - field.style.opacity = '0' - document.body.appendChild(field) - let copied = false - try { - field.focus() - field.select() - copied = document.execCommand?.('copy') ?? false - } finally { - field.remove() - if (active instanceof HTMLElement) active.focus({ preventScroll: true }) +function wireSectionFolds() { + for (const fold of sectionFolds()) { + const summary = fold.querySelector(':scope > summary')! + const button = summary.querySelector('.section-toggle')! + const arrow = button.querySelector('.section-toggle-arrow')! + const sync = () => { + button.setAttribute('aria-expanded', String(fold.open)) + arrow.textContent = fold.open ? '▾' : '▸' + } + summary.addEventListener('click', (event) => { + if (!(event.target as Element).closest('a')) event.preventDefault() + }) + button.addEventListener('click', (event) => { + event.preventDefault() + fold.open = !fold.open + sync() + }) + fold.addEventListener('toggle', sync) + sync() } - if (!copied) throw new Error('Clipboard access is unavailable') } function wireFragments(initialRender: Promise) { @@ -304,18 +269,6 @@ function wireFragments(initialRender: Promise) { } window.addEventListener('hashchange', navigate) - for (const button of document.querySelectorAll('[data-copy-fragment]')) { - button.addEventListener('click', (event) => { - event.preventDefault() - event.stopPropagation() - const url = new URL(window.location.href) - url.hash = button.dataset.copyFragment! - void copyText(url.href).then( - () => showCopyFeedback(button, true), - () => showCopyFeedback(button, false), - ) - }) - } document.addEventListener('click', (event) => { const origin = event.target if (!(origin instanceof Element)) return @@ -399,7 +352,7 @@ function wireGlobalFolds() { for (const fold of folds) fold.open = unfold if (active instanceof HTMLElement && hiddenByClosedFold(active)) { const section = active.closest('details.section-fold') - const summary = section?.querySelector(':scope > summary') + const summary = section?.querySelector('.section-toggle') ;(summary ?? button).focus({ preventScroll: true }) } sync() @@ -429,6 +382,7 @@ export function mountReport( } let finishInitialRender!: () => void const initialRender = new Promise((resolve) => (finishInitialRender = resolve)) + wireSectionFolds() wireFragments(initialRender) const mountedDiffs = mountDiffs(data, layout, createFileDiff) void mountedDiffs.initialRender.then(finishInitialRender) diff --git a/src/report/shell.ts b/src/report/render.ts similarity index 86% rename from src/report/shell.ts rename to src/report/render.ts index d3de778..8559a8e 100644 --- a/src/report/shell.ts +++ b/src/report/render.ts @@ -79,7 +79,6 @@ ${reviewMap} ${sections.map((section) => section.markup).join('\n')} -
` return { title, @@ -154,11 +153,11 @@ function renderSection( .filter((change) => change.canonical) .map( (change) => - `${renderPermalink(change.fragment, `Permalink to change ${change.id}`, change.id)}${renderCopyLink(change.fragment, `Copy link to change ${change.id}`)}`, + ``, ) .join('') const actions = `
- ${renderPermalink(stepTarget.fragment, `Permalink to step ${stepIndex + 1} in ${section.title}`, 'Link')}${renderCopyLink(stepTarget.fragment, `Copy link to step ${stepIndex + 1} in ${section.title}`)}${changeTargets} + ${renderPermalink(stepTarget.fragment, `Permalink to step ${stepIndex + 1} in ${section.title}`, 'LINK')}${changeTargets}
` if (step.diff === undefined) { return `
${actions}${textMarkup}
` @@ -198,7 +197,7 @@ function renderSection( const markup = `
- ${sectionIndex(index)}${escapeHtml(section.title)}${renderPermalink(target.fragment, `Permalink to section ${section.title}`, 'Link')}${renderCopyLink(target.fragment, `Copy link to section ${section.title}`)} + ${escapeHtml(section.title)} ${steps.join('\n')}
` @@ -233,10 +232,6 @@ function renderPermalink(fragment: string, label: string, text: string): string return `` } -function renderCopyLink(fragment: string, label: string, text = 'Copy'): string { - return `` -} - function pluralize(count: number, noun: string): string { return `${count} ${noun}${count === 1 ? '' : 's'}` } @@ -335,12 +330,12 @@ body { grid-template-columns: max-content minmax(0, 1fr); gap: 0 8px; margin: 0; - font-size: 11px; + font-size: 14px; line-height: 1.5; } -.source-metadata dt { color: #7e8d82; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; } +.source-metadata dt { color: #7e8d82; font-weight: 500; } .source-metadata dd { margin: 0; min-width: 0; overflow: hidden; color: #4e5d53; text-overflow: ellipsis; white-space: nowrap; } -.source-metadata code { color: #263a2d; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; } +.source-metadata code { font: inherit; } .attribution-metadata { display: grid; grid-template-columns: max-content minmax(0, 1fr); @@ -437,55 +432,33 @@ main { max-width: none; min-width: 0; margin: 0; padding: 22px 28px 72px; } } .section-fold > summary { display: flex; - align-items: center; - gap: 8px; - cursor: pointer; + align-items: baseline; + gap: 12px; overflow-wrap: anywhere; padding: 12px 16px; border-bottom: 1px solid transparent; color: #17271c; - background: transparent; font-size: 18px; font-weight: 600; list-style: none; - user-select: none; -} -.section-title-index { flex: none; color: var(--accent); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .82em; font-weight: 600; } -.section-title-text { min-width: 0; flex: 1; } + user-select: text; + -webkit-user-select: text; +} +.section-toggle { flex: none; border: 0; padding: 4px 0; background: transparent; color: var(--accent); font: inherit; cursor: pointer; user-select: none; } +.section-title-index { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .82em; } +.section-title-text { min-width: 0; color: inherit; text-decoration: none; } +.section-title-text:hover { text-decoration: underline; } +.section-toggle:focus-visible, .section-title-text:focus-visible, .permalink:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } .section-fold > summary::-webkit-details-marker { display: none; } -.section-fold > summary::before { content: "▾ "; color: var(--accent); } -.section-fold:not([open]) > summary::before { content: "▸ "; } .section-fold[open] > summary { border-bottom-color: var(--border); } .prose { color: #3c4d41; font-size: 14px; } -.step { scroll-margin-top: 18px; } -.step-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 5px; padding: 8px 12px 0; } -.step-text { max-width: 900px; padding: 8px 20px; font-size: 17px; } -.copy-link, .permalink { - padding: 3px 7px; - border: 1px solid #c4d1c6; - border-radius: 5px; - color: #53665a; - background: #f8faf8; - font: 600 10px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace; - cursor: pointer; -} -.permalink { text-decoration: none; } -.copy-link:hover, .permalink:hover { color: var(--accent); border-color: #8eaa95; background: #eef5ef; } -.copy-link:focus-visible, .permalink:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } -.copy-link[data-copy-state="success"] { color: var(--accent); border-color: #8eaa95; } -.copy-link[data-copy-state="failure"] { color: #a1262f; border-color: #d7a4a8; } -.change-target { display: inline-flex; align-items: center; gap: 5px; scroll-margin-top: 18px; } -.copy-status { - position: fixed; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} +.step { position: relative; scroll-margin-top: 18px; } +.step-actions { position: absolute; top: 8px; right: 12px; z-index: 2; } +.step-text { max-width: 900px; padding: 8px 68px 8px 20px; font-size: 17px; } +.permalink { display: block; padding: 2px 3px; color: #98a29c; font: 600 10px/20px ui-monospace, SFMono-Regular, Menlo, monospace; text-decoration: none; } +.permalink:hover, .permalink:focus-visible { color: var(--accent); } +.change-target { position: absolute; top: 0; left: 0; width: 0; height: 0; overflow: hidden; scroll-margin-top: 18px; } +.step:not(:has(.step-text)) .step-files { padding-right: 68px; } .prose strong { color: #142c1d; } .step-files { padding: 12px; display: grid; gap: 9px; } .step + .step { border-top: 1px solid #e3ebe5; } @@ -509,7 +482,7 @@ main { max-width: none; min-width: 0; margin: 0; padding: 22px 28px 72px; } } .report-cover .source-metadata { max-width: 640px; } .report-cover .source-metadata dd { white-space: normal; overflow: visible; } -.cover-summary { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--border); } +.cover-summary { font-size: 17px; margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--border); } .cover-summary > :first-child { margin-top: 0; } /* The card is as wide as a section so a diagram has room, but prose is capped at the same measure as a step's text: a 1400px line is not readable. */ @@ -608,7 +581,7 @@ main { max-width: none; min-width: 0; margin: 0; padding: 22px 28px 72px; } .section-fold > summary { font-size: 17px; } } @media print { - .layout-form, .copy-link, .permalink { display: none; } + .layout-form, .permalink { display: none; } .review-map { display: none; } .review-workspace { display: block; } .report-cover { box-shadow: none; break-inside: avoid; } diff --git a/test/cli.test.ts b/test/cli.test.ts index 5135a95..31da69c 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1092,7 +1092,8 @@ describe('view', () => { expect(response.headers.get('cache-control')).toBe('no-store') const html = await response.text() expect(html).toContain('A change set') - expect(html).toContain('data-copy-fragment="change-001"') + expect(html).toContain('id="change-001" data-target-kind="change"') + expect(html).not.toContain('data-copy-fragment') expect((await fetch(`${previewUrl}/other`)).status).toBe(404) expect(await readdir(await currentWalkDir(repo))).not.toContain('diffwalk.html') diff --git a/test/report-dom.test.ts b/test/report-dom.test.ts index 1b7f394..b320dc3 100644 --- a/test/report-dom.test.ts +++ b/test/report-dom.test.ts @@ -279,7 +279,7 @@ describe('report browser client', () => { await waitFor(() => scrollCount === 1) expect(scrollCount).toBe(1) expect(doc.activeElement).toBe( - doc.querySelector(`[data-copy-fragment="${fragment}"]`), + target.closest('.step')!.querySelector('.step-actions > a'), ) }) @@ -311,7 +311,7 @@ describe('report browser client', () => { await waitFor(() => scrollCount > 0) expect(scrollCount).toBeGreaterThan(0) expect(doc.activeElement).toBe( - doc.querySelector(`[data-copy-fragment="${fragment}"]`), + doc.querySelector(`:is(a.section-title-text, a.permalink)[href="#${fragment}"]`), ) expect(doc.querySelector('.section-fold > summary')).not.toBeNull() expect(doc.querySelector('.file > summary')).not.toBeNull() @@ -342,7 +342,7 @@ describe('report browser client', () => { await waitFor(() => scrolled) expect(scrolled).toBe(true) expect(doc.activeElement).toBe( - doc.querySelector(`[data-copy-fragment="${fragment}"]`), + doc.querySelector(`:is(a.section-title-text, a.permalink)[href="#${fragment}"]`), ) }) @@ -381,7 +381,7 @@ describe('report browser client', () => { expect([...doc.querySelectorAll('[id="change-001"]')]).toEqual([target]) const ids = [...doc.querySelectorAll('[id]')].map((element) => element.id) expect(new Set(ids).size).toBe(ids.length) - expect(doc.activeElement).toBe(target.querySelector('[data-copy-fragment="change-001"]')) + expect(doc.activeElement).toBe(target.closest('.step')!.querySelector('.step-actions > a')) }) test('a direct load aligns a viewport-taller section title instead of centering the section', async () => { @@ -439,7 +439,7 @@ describe('report browser client', () => { runReportClient() await waitFor(() => calls.length === 1) - const anchor = doc.querySelector(`a.permalink[href="#${fragment}"]`) + const anchor = doc.querySelector(`:is(a.section-title-text, a.permalink)[href="#${fragment}"]`) expect(anchor).not.toBeNull() expect(anchor!.tagName).toBe('A') expect(anchor!.getAttribute('href')).toBe(`#${fragment}`) @@ -479,7 +479,7 @@ describe('report browser client', () => { calls.push(options ?? {}) }) as typeof step.scrollIntoView - const anchor = doc.querySelector(`a.permalink[href="#${fragment}"]`)! + const anchor = doc.querySelector(`:is(a.section-title-text, a.permalink)[href="#${fragment}"]`)! expect(anchor.tagName).toBe('A') anchor.dispatchEvent( new dom.window.MouseEvent('click', { bubbles: true, cancelable: true }) as unknown as Event, @@ -532,7 +532,7 @@ describe('report browser client', () => { expect(calls[0]!.options).toEqual({ block: 'start' }) }) - test('focusing either title action leaves the corrected target position alone', async () => { + test('focusing the title link leaves the corrected target position alone', async () => { const value = document([section(simplePatch(), 'Focus target')]) const fragment = reportTargets(value)[0]!.fragment const dom = loadReport(renderReport(value, clientBundle), { @@ -540,31 +540,23 @@ describe('report browser client', () => { }) const doc = dom.document as unknown as Document const sectionTarget = doc.getElementById(fragment) as HTMLElement - const calls: ScrollIntoViewOptions[] = [] - sectionTarget.scrollIntoView = ((options?: ScrollIntoViewOptions) => { - calls.push(options ?? {}) - }) as typeof sectionTarget.scrollIntoView - - const button = doc.querySelector(`[data-copy-fragment="${fragment}"]`)! + let scrolls = 0 + sectionTarget.scrollIntoView = () => { scrolls++ } + const anchor = sectionTarget.querySelector('.section-title-text')! const focusOptions: (FocusOptions | undefined)[] = [] - const originalFocus = button.focus.bind(button) - button.focus = ((options?: FocusOptions) => { + const originalFocus = anchor.focus.bind(anchor) + anchor.focus = (options?: FocusOptions) => { focusOptions.push(options) originalFocus(options) - }) as typeof button.focus + } runReportClient() - await waitFor(() => calls.length === 1) + await waitFor(() => scrolls === 1) - // The client's own correction focuses the copy action without scrolling. expect(focusOptions).toEqual([{ preventScroll: true }]) - expect(doc.activeElement).toBe(button) - - // Focusing the native permalink must not add a competing scroll either. - const anchor = doc.querySelector(`a.permalink[href="#${fragment}"]`)! - anchor.focus() expect(doc.activeElement).toBe(anchor) - expect(calls).toHaveLength(1) + anchor.focus() + expect(scrolls).toBe(1) }) test('waits for every initial render and honors a hashchange while mounting', async () => { @@ -659,7 +651,7 @@ describe('report browser client', () => { expect(firstScrolls).toBe(0) expect(secondScrolls).toBe(1) expect(doc.activeElement).toBe( - second.querySelector(`[data-copy-fragment="${secondFragment}"]`), + second.querySelector(`a[href="#${secondFragment}"]`), ) } finally { globalThis.setTimeout = previousSetTimeout @@ -729,7 +721,7 @@ describe('report browser client', () => { frames[0]!(Date.now()) expect(scrolled).toBe(true) - expect(doc.activeElement).toBe(target.querySelector('[data-copy-fragment]')) + expect(doc.activeElement).toBe(target.querySelector('.step-actions > a')) } finally { globalThis.setTimeout = previousSetTimeout globalThis.clearTimeout = previousClearTimeout @@ -753,7 +745,7 @@ describe('report browser client', () => { runReportClient() await waitFor(() => scrolled) - expect(doc.activeElement).toBe(target.querySelector('[data-copy-fragment]')) + expect(doc.activeElement).toBe(target.querySelector('.step-actions > a')) }) test('finalizes hash navigation when an initial renderer throws', async () => { @@ -775,133 +767,59 @@ describe('report browser client', () => { await waitFor(() => scrolled) expect(doc.querySelector('.diff-error')?.textContent).toContain('renderer failed') - expect(doc.activeElement).toBe(target.querySelector('[data-copy-fragment]')) - }) - - test('copy actions are keyboard-accessible and report clipboard success without folding', async () => { - const value = document([ - { - title: 'Copy target', - steps: [{ text: 'Copy this change.', diff: simplePatch(), changes: ['change-001'] }], - }, - ]) - const dom = loadReport(renderReport(value, clientBundle), { - url: 'https://reports.example/r/report-id?layout=split', - }) - const doc = dom.document as unknown as Document - const copied: string[] = [] - Object.defineProperty(dom.window.navigator, 'clipboard', { - configurable: true, - value: { writeText: async (value: string) => copied.push(value) }, - }) - runReportClient() - - const buttons = [...doc.querySelectorAll('[data-copy-fragment]')] - expect(buttons).toHaveLength(3) - for (const button of buttons) { - expect(button.type).toBe('button') - expect(button.tabIndex).toBe(0) - expect(button.getAttribute('aria-label')).toMatch(/^Copy link to /) - } - - const sectionButton = buttons[0]! - const sectionFold = doc.querySelector('.section-fold')! - sectionButton.focus() - expect(doc.activeElement).toBe(sectionButton) - sectionButton.click() - await waitFor(() => sectionButton.dataset.copyState === 'success') - - const fragment = sectionButton.dataset.copyFragment! - expect(copied).toEqual([ - `https://reports.example/r/report-id?layout=split#${fragment}`, - ]) - expect(sectionFold.open).toBe(true) - expect(sectionButton.textContent).toBe('Copied') - expect(doc.querySelector('[data-copy-status]')?.textContent).toBe( - 'Link copied to clipboard.', - ) + expect(doc.activeElement).toBe(target.querySelector('.step-actions > a')) }) - test('a rejected clipboard write falls back to textarea copying', async () => { - const value = document([section(simplePatch(), 'Copy fallback')]) + test('only the section fold button toggles, while title and step links remain native', async () => { + const value = document([{ + title: 'Linked title', + steps: [{ text: 'Target step.', diff: simplePatch(), changes: ['change-001'] }], + }]) const dom = loadReport(renderReport(value, clientBundle), { url: 'https://reports.example/r/report-id', }) const doc = dom.document as unknown as Document - let clipboardAttempts = 0 - let fallbackValue = '' - Object.defineProperty(dom.window.navigator, 'clipboard', { - configurable: true, - value: { - writeText: async () => { - clipboardAttempts++ - throw new Error('denied') - }, - }, - }) - ;(doc as Document & { execCommand(command: string): boolean }).execCommand = () => { - fallbackValue = doc.querySelector('textarea')?.value ?? '' - return true - } runReportClient() - - const button = doc.querySelector('.step [data-copy-fragment]')! - button.click() - await waitFor(() => button.dataset.copyState === 'success') - - expect(clipboardAttempts).toBe(1) - expect(fallbackValue).toBe( - `https://reports.example/r/report-id#${button.dataset.copyFragment}`, - ) - expect(button.textContent).toBe('Copied') - }) - - test('double clipboard failures are exposed visually and through live status', async () => { - const value = document([section(simplePatch(), 'Copy failure')]) - const dom = loadReport(renderReport(value, clientBundle), { - url: 'https://reports.example/r/report-id', - }) - const doc = dom.document as unknown as Document - Object.defineProperty(dom.window.navigator, 'clipboard', { - configurable: true, - value: { writeText: async () => Promise.reject(new Error('denied')) }, - }) - ;(doc as Document & { execCommand(command: string): boolean }).execCommand = () => false - runReportClient() - - const button = doc.querySelector('.step [data-copy-fragment]')! + const fold = doc.querySelector('.section-fold')! + const summary = fold.querySelector('summary')! + const button = summary.querySelector('.section-toggle')! + const title = summary.querySelector('.section-title-text')! + const stepLink = fold.querySelector('.step-actions > a')! + + expect(summary.tabIndex).toBe(-1) + expect(button.type).toBe('button') + expect(button.tabIndex).toBe(0) + expect(button.textContent).toContain('01') + expect(button.getAttribute('aria-expanded')).toBe('true') + expect(doc.getElementById(button.getAttribute('aria-controls')!)).not.toBeNull() + expect(title.tagName).toBe('A') + expect(title.textContent).toBe('Linked title') + expect(title.tabIndex).toBe(0) + expect(stepLink.textContent).toBe('LINK') + expect(stepLink.tabIndex).toBe(0) + expect(doc.querySelector('[data-copy-fragment]')).toBeNull() + expect(doc.querySelector('a[href="#change-001"]')).toBeNull() + + summary.click() + expect(fold.open).toBe(true) + const click = new dom.window.MouseEvent('click', { bubbles: true, cancelable: true }) + title.dispatchEvent(click as unknown as Event) + expect(click.defaultPrevented).toBe(false) + // happy-dom also applies summary activation to an interactive anchor; + // navigation restores the fold when its native hashchange settles. + await waitFor(() => fold.open) + expect(fold.open).toBe(true) button.click() - await waitFor(() => button.dataset.copyState === 'failure') - - expect(button.textContent).toBe('Failed') - expect(doc.querySelector('[data-copy-status]')?.getAttribute('role')).toBe('status') - expect(doc.querySelector('[data-copy-status]')?.textContent).toBe('Could not copy link.') - }) - - test('file reports use the clipboard fallback and restore keyboard focus', async () => { - const value = document([section(simplePatch(), 'Offline copy')]) - const dom = loadReport(renderReport(value, clientBundle)) - const doc = dom.document as unknown as Document - Object.defineProperty(dom.window.navigator, 'clipboard', { - configurable: true, - value: undefined, - }) - let copied = '' - ;(doc as Document & { execCommand(command: string): boolean }).execCommand = (command) => { - expect(command).toBe('copy') - copied = doc.querySelector('textarea')?.value ?? '' - return true - } - runReportClient() - - const button = doc.querySelector('.step [data-copy-fragment]')! - button.focus() + expect(fold.open).toBe(false) + expect(button.getAttribute('aria-expanded')).toBe('false') button.click() - await waitFor(() => button.dataset.copyState === 'success') + expect(fold.open).toBe(true) + expect(button.getAttribute('aria-expanded')).toBe('true') - expect(copied).toBe(`file:///tmp/diffwalk-report.html#${button.dataset.copyFragment}`) - expect(doc.querySelector('textarea')).toBeNull() - expect(doc.activeElement).toBe(button) + fold.open = false + expect(button.getAttribute('aria-expanded')).toBe('false') + fold.open = true + expect(button.getAttribute('aria-expanded')).toBe('true') }) test( @@ -1140,17 +1058,17 @@ describe('report browser client', () => { expect(dataScript.textContent).toBe(dataBefore) - // Individual sections still fold and unfold natively after the global action, + // Individual section buttons still fold and unfold after the global action, // and their toggle drives the global label. folds[0]! - .querySelector('summary')! + .querySelector('.section-toggle')! .dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true, cancelable: true }) as unknown as Event) expect(folds[0]!.open).toBe(false) expect(folds[1]!.open).toBe(true) expect(label()).toBe('Fold all') folds[1]! - .querySelector('summary')! + .querySelector('.section-toggle')! .dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true, cancelable: true }) as unknown as Event) expect(folds.every((fold) => !fold.open)).toBe(true) expect(label()).toBe('Unfold all') @@ -1182,8 +1100,8 @@ describe('report browser client', () => { runReportClient() const firstSection = doc.querySelector('.section')! - const focused = firstSection.querySelector( - '.step [data-copy-fragment]', + const focused = firstSection.querySelector( + '.step-actions > a', )! focused.focus() expect(doc.activeElement).toBe(focused) @@ -1202,7 +1120,7 @@ describe('report browser client', () => { const folds = [...doc.querySelectorAll('details.section-fold')] expect(folds.every((fold) => !fold.open)).toBe(true) expect(firstSection.querySelector('details')?.open).toBe(false) - const summary = firstSection.querySelector('.section-fold > summary')! + const summary = firstSection.querySelector('.section-toggle')! expect(doc.activeElement).toBe(summary) }, 120000, @@ -1236,7 +1154,7 @@ describe('report browser client', () => { doc.querySelector('[data-fold-all]')!.click() - const sectionSummary = firstSection.querySelector('.section-fold > summary')! + const sectionSummary = firstSection.querySelector('.section-toggle')! expect(doc.activeElement).toBe(sectionSummary) expect(sectionSummary.closest('details')?.open).toBe(false) }, diff --git a/test/report.test.ts b/test/report.test.ts index e59a27e..20692e4 100644 --- a/test/report.test.ts +++ b/test/report.test.ts @@ -6,7 +6,7 @@ import type { ExplainDocument } from '../src/format' import { renderMarkdown } from '../src/report/markdown' import { fileDiffStats, parseSectionPatch } from '../src/report/patches' import { loadReportClient, renderHostedReport, renderReport, writeReport } from '../src/report' -import { sectionIndex } from '../src/report/shell' +import { sectionIndex } from '../src/report/render' import { reportTargets } from '../src/report/targets' const directories: string[] = [] @@ -407,17 +407,19 @@ describe('renderReport shell', () => { expect(sectionIndex(100)).toBe('101') }) - test('the section index stays a distinct label beside the fold control and copy action', () => { - const html = renderReport(document([section(simplePatch(), 'Distinct')]), stubClient) + test('the section index belongs to its fold button and the title is a native link', () => { + const value = document([section(simplePatch(), 'Distinct')]) + const target = reportTargets(value)[0]! + const html = renderReport(value, stubClient) - const summary = html.slice(html.indexOf('')) - expect(summary).toContain('>01<') - expect(summary).toContain('>Distinct<') - expect(summary).toContain('aria-label="Permalink to section Distinct"') - expect(summary).toContain('aria-label="Copy link to section Distinct"') + expect(html).toContain('') + expect(html).toContain('class="section-toggle"') + expect(html).toContain('aria-expanded="true"') + expect(html).toContain('01') + expect(html).toContain(`Distinct`) }) - test('renders canonical copy actions without exposing renderer mounts as fragment IDs', () => { + test('preserves canonical targets without exposing renderer mounts as fragment IDs', () => { const value = document([ { title: 'Linkable', @@ -436,48 +438,25 @@ describe('renderReport shell', () => { expect(html).toContain(`id="${target.fragment}" data-section-index="0"`) expect(html).toContain(`id="${target.steps[0]!.fragment}" data-step-index="0"`) expect(html).toContain('id="change-001" data-target-kind="change"') - expect(html).toContain('aria-label="Copy link to section Linkable"') - expect(html).toContain('aria-label="Copy link to step 1 in Linkable"') - expect(html).toContain('aria-label="Copy link to change change-001"') expect(html).toContain('data-diff-mount="0-0-0"') expect(html).not.toContain('id="section-0-step-0-file-0"') }) - test('section, step, and change titles carry native permalinks beside distinct copy buttons', () => { - const value = document([ - { - title: 'Linkable', - steps: [ - { - text: 'A linkable step.', - diff: simplePatch(), - changes: ['change-001'], - }, - ], - }, - ]) + test('section titles and step markers are links while change targets stay empty', () => { + const value = document([{ + title: 'Linkable', + steps: [{ text: 'A linkable step.', diff: simplePatch(), changes: ['change-001'] }], + }]) const target = reportTargets(value)[0]! const html = renderReport(value, stubClient) - // Native same-document anchors point at the target's stable id. - expect(html).toContain( - ``, - ) - expect(html).toContain( - ``, - ) - // A change's id is its title, so the anchor itself carries that marker. - expect(html).toContain( - ``, - ) - - // Copying stays a separate button with an explicit action, not a navigation. + expect(html).toContain(`Linkable`) expect(html).toContain( - ``, + ``, ) - expect(html).toContain('aria-label="Copy link to step 1 in Linkable"') - expect(html).toContain('aria-label="Copy link to change change-001"') - expect(html).not.toContain('