diff --git a/.igniter/workflow/deliver.md b/.igniter/workflow/deliver.md index 0373bf2..c46a018 100644 --- a/.igniter/workflow/deliver.md +++ b/.igniter/workflow/deliver.md @@ -1,8 +1,9 @@ # Deliver Deliver one owner-approved checkpoint to `main` through GitHub. The pull -request, required `Project check`, native rebase auto-merge, and landed commit -are one delivery; do not stop after opening the pull request. +request, required `Project check`, native rebase auto-merge, landed commit, +and production website deployments are one delivery; do not stop after opening +the pull request or merging it. ## Prepare @@ -36,12 +37,30 @@ are one delivery; do not stop after opening the pull request. - If `main` advances before merge, rebase onto the new `origin/main`, rerun `pnpm check`, push with `--force-with-lease`, and watch the replacement check. +## Publish the websites + +- After merge, fetch and verify the landed commit on `origin/main`. Deploy + from that clean landed revision, never from unmerged feature changes. +- Use the Cloudflare and Wrangler skills. Read credentials from environment + variables; keep authentication noninteractive and never print tokens. +- Run `pnpm run deploy` to build and deploy the `diffwalk-reports` Worker and + shared report assets at `https://review.diffwalk.dev`. +- Run `pnpm exec wrangler deploy --config website/wrangler.jsonc` to deploy + the `diffwalk-site` website at `https://diffwalk.dev`. +- Verify the homepage and an existing published review over HTTPS, including + the delivered feature and its shared assets. Use read-only checks; do not + create or delete production review data for deployment verification. +- Record both deployment version IDs, source commit, production URLs, and + verification results. A failed deployment or verification leaves delivery + incomplete; report the concrete blocker. + ## Finish After merge, fetch and verify that remote `main` contains the landed commit. Record the pull request URL, accepted checkpoint, any rebased checkpoint, successful check run, auto-merge result, landed commit, final lineage, -working-tree state, remaining owner actions, and blockers or `none`. +website deployment versions and verification, working-tree state, remaining +owner actions, and blockers or `none`. -Do not deploy the Cloudflare Worker or publish the npm package unless the owner -separately authorizes it. +Do not publish the npm package or run infrastructure provisioning unless the +owner separately authorizes it. diff --git a/src/report/client.ts b/src/report/client.ts index 1aaf058..582c32a 100644 --- a/src/report/client.ts +++ b/src/report/client.ts @@ -35,6 +35,7 @@ export function mountReport( let finishInitialRender!: () => void const initialRender = new Promise((resolve) => (finishInitialRender = resolve)) wireSectionFolds() + wireCopyPaths() wireFragments(initialRender) const mountedDiffs = mountDiffs(data, layout, createFileDiff) void mountedDiffs.initialRender.then(finishInitialRender) @@ -287,6 +288,32 @@ function wireSectionFolds() { }) } +function wireCopyPaths() { + document.querySelectorAll('[data-copy-path-form]').forEach((form) => { + form.addEventListener('click', (event) => event.stopPropagation()) + form.addEventListener('submit', async (event) => { + event.preventDefault() + event.stopPropagation() + const button = form.querySelector('[data-copy-path]') + if (!button) return + event.preventDefault() + const path = button.dataset.copyPath + if (path === undefined) return + try { + await navigator.clipboard.writeText(path) + } catch { + return + } + button.dataset.copyState = 'copied' + button.setAttribute('aria-label', `Copied relative path ${path}`) + setTimeout(() => { + button.dataset.copyState = 'ready' + button.setAttribute('aria-label', `Copy relative path ${path}`) + }, 1500) + }) + }) +} + function wireFragments(initialRender: Promise) { let rendersComplete = false let finalRevealPending = false diff --git a/src/report/render.ts b/src/report/render.ts index 0b4ac10..07900a5 100644 --- a/src/report/render.ts +++ b/src/report/render.ts @@ -173,14 +173,16 @@ function renderSection( const pureRename = file.type === 'rename-pure' && file.hunks.length === 0 const label = escapeHtml(fileDiffLabel(file)) if (pureRename) { - return `
-
${label} Renamed · content unchanged
+ return `
+
${renderFileLabel(label, 'Renamed · content unchanged', file.name)}
+
` } - return `
- ${label} +${stats.additions} −${stats.deletions} + return `
+ ${renderFileLabel(label, `+${stats.additions} −${stats.deletions}`, file.name)}
-
` +
+
` }) .join('\n') @@ -224,15 +226,24 @@ function renderBinaryCard(change: BinaryChangeBlock): string { const moved = movedStatusLabel(change.status) const stats = moved === undefined ? `Binary · ${change.status}` : `${moved} · excluded content omitted` const excluded = excludedSide(change.status) - return `
-
${escapeHtml(label)} ${escapeHtml(stats)}
+ return `
+
${renderFileLabel(escapeHtml(label), stats, change.path)}
${renderBinarySide('Before', change.before, excluded === 'old')} ${renderBinarySide('After', change.after, excluded === 'new')}
+
` } +function renderFileLabel(label: string, stats: string, path: string): string { + return `
${label} ${escapeHtml(stats)}${renderCopyForm(path)}
` +} + +function renderCopyForm(path: string): string { + return `
` +} + function renderBinarySide(label: string, side: BinaryChangeBlock['before'], excluded: boolean): string { const detail = excluded ? 'excluded' @@ -490,7 +501,7 @@ main { max-width: none; min-width: 0; margin: 0; padding: 22px 28px 72px; } .section-fold-controls { display: flex; gap: 6px; margin-left: auto; font-size: 12px; font-weight: 500; } .section-fold-all { border: 1px solid #cbd7cd; border-radius: 5px; padding: 4px 7px; color: #4d6654; background: #f7faf7; cursor: pointer; font: inherit; white-space: nowrap; } .section-fold-all:hover { color: var(--accent); border-color: #8eaa95; background: #eef5ef; } -.section-toggle:focus-visible, .section-title-text:focus-visible, .section-fold-all:focus-visible, .permalink:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } +.section-toggle:focus-visible, .section-title-text:focus-visible, .section-fold-all:focus-visible, .copy-path:focus-visible, .permalink:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } .section-fold > summary::-webkit-details-marker { display: none; } .section-fold[open] > summary { border-bottom-color: var(--border); } .prose { color: #3c4d41; font-size: 14px; } @@ -571,15 +582,29 @@ main { max-width: none; min-width: 0; margin: 0; padding: 22px 28px 72px; } color: #314439; background: #f3f7f3; } +.file-with-copy { min-width: 0; } +.file-summary-content { display: flex; align-items: center; gap: 10px; min-width: 0; width: 100%; line-height: 1.35; } +.file-name { min-width: 0; overflow-wrap: anywhere; } +.copy-path-form { display: inline-flex; flex: none; margin: 0; } +.copy-path { display: inline-grid; place-items: center; width: 23px; height: 21px; padding: 3px; border: 0; border-radius: 4px; color: #53665a; background: transparent; cursor: pointer; line-height: 1; } +.copy-icon { display: block; width: 14px; height: 14px; } +.copy-icon svg { display: block; width: 100%; height: 100%; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.4; } +.copy-icon-success { display: none; } +.copy-path[data-copy-state="copied"] .copy-icon-copy { display: none; } +.copy-path[data-copy-state="copied"] .copy-icon-success { display: block; color: var(--accent); } +.copy-path:hover { color: var(--accent); background: #eef5ef; } .file > summary { + display: flex; + align-items: center; + gap: 8px; cursor: pointer; list-style: none; user-select: none; } .file > summary::-webkit-details-marker { display: none; } -.file > summary::before { content: "▸ "; } -.file[open] > summary::before { content: "▾ "; } -.file-stats { float: right; color: #6d7d72; } +.file > summary::before { flex: none; content: "▸"; } +.file[open] > summary::before { content: "▾"; } +.file-stats { flex: none; margin-left: auto; color: #6d7d72; white-space: nowrap; } .binary-sides { display: grid; gap: 4px; margin: 0; padding: 10px 12px; border-top: 1px solid #d2ddd4; } .binary-side { display: grid; grid-template-columns: 58px minmax(0, 1fr); gap: 8px; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; } .binary-side dt { color: #7e8d82; font-weight: 600; } @@ -625,6 +650,8 @@ main { max-width: none; min-width: 0; margin: 0; padding: 22px 28px 72px; } .layout-form label { padding: 4px 7px; font-size: 11px; } .fold-all { padding: 4px 7px; font-size: 11px; } .section-fold > summary { font-size: 17px; } + .file-summary-content { gap: 7px; } + .copy-path { width: 22px; height: 20px; } } @media print { .layout-form, .permalink { display: none; } diff --git a/test/report-dom.test.ts b/test/report-dom.test.ts index ba9dacc..2e2c2f3 100644 --- a/test/report-dom.test.ts +++ b/test/report-dom.test.ts @@ -14,11 +14,11 @@ afterEach(() => { windows.splice(0).forEach((dom) => dom.happyDOM.cancelAsync()) }) -function simplePatch(oldLine = 'old', newLine = 'new'): string { +function simplePatch(oldLine = 'old', newLine = 'new', path = 'example.ts'): string { return [ - 'diff --git a/example.ts b/example.ts', - '--- a/example.ts', - '+++ b/example.ts', + `diff --git a/${path} b/${path}`, + `--- a/${path}`, + `+++ b/${path}`, '@@ -1 +1 @@', `-${oldLine}`, `+${newLine}`, @@ -822,6 +822,51 @@ describe('report browser client', () => { expect(button.getAttribute('aria-expanded')).toBe('true') }) + test('copies a relative file path through a form without toggling its file fold', async () => { + const dom = loadReport(renderReport(document([section(simplePatch('old', 'new', 'src/file.ts'), 'Copy path')]), clientBundle)) + const writes: string[] = [] + Object.defineProperty(dom.window.navigator, 'clipboard', { + configurable: true, + value: { writeText: async (value: string) => writes.push(value) }, + }) + const doc = dom.document as unknown as Document + runReportClient() + + const fold = doc.querySelector('details.file')! + fold.open = true + const form = doc.querySelector('[data-copy-path-form]')! + const button = form.querySelector('button')! + expect(form.method).toBe('get') + expect(button.type).toBe('submit') + expect(button.getAttribute('aria-label')).toBe('Copy relative path src/file.ts') + + button.focus() + form.requestSubmit() + await waitFor(() => writes.length === 1) + + expect(writes).toEqual(['src/file.ts']) + expect(fold.open).toBe(true) + expect(button.dataset.copyState).toBe('copied') + expect(button.getAttribute('aria-label')).toBe('Copied relative path src/file.ts') + }) + + test('does not show copy success when the clipboard rejects the write', async () => { + const dom = loadReport(renderReport(document([section(simplePatch(), 'Copy failure')]), clientBundle)) + Object.defineProperty(dom.window.navigator, 'clipboard', { + configurable: true, + value: { writeText: async () => { throw new Error('clipboard unavailable') } }, + }) + const doc = dom.document as unknown as Document + runReportClient() + + const button = doc.querySelector('[data-copy-path]')! + button.form!.requestSubmit() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(button.dataset.copyState).toBe('ready') + expect(button.getAttribute('aria-label')).toBe('Copy relative path example.ts') + }) + test( 'narrow viewport defaults split reports to unified but preserves explicit switching', async () => { diff --git a/test/report.test.ts b/test/report.test.ts index 5b74f4a..2f39432 100644 --- a/test/report.test.ts +++ b/test/report.test.ts @@ -174,6 +174,33 @@ describe('renderReport shell', () => { expect(html).toContain('data-diff-mount="0-0-0"') }) + test('renders an accessible relative-path copy form beside each file name', () => { + const html = renderReport( + document([ + section( + [ + 'diff --git a/src/file.ts b/src/file.ts', + '--- a/src/file.ts', + '+++ b/src/file.ts', + '@@ -1 +1 @@', + '-old', + '+new', + '', + ].join('\n'), + 'Copy path', + ), + ]), + stubClient, + ) + + expect(html).toContain('
') + expect(html).toContain('data-copy-path="src/file.ts"') + expect(html).toContain('aria-label="Copy relative path src/file.ts"') + expect(html).toContain('class="copy-icon copy-icon-copy"') + expect(html).not.toContain('>Copy') + expect(html).not.toContain('data-copy-path="/home/') + }) + test('steps interleave text and diffs in the order they were authored', () => { const html = renderReport( document([ diff --git a/test/visual/__screenshots__/desktop/focused-control.png b/test/visual/__screenshots__/desktop/focused-control.png index 7741f12..6e5e237 100644 Binary files a/test/visual/__screenshots__/desktop/focused-control.png and b/test/visual/__screenshots__/desktop/focused-control.png differ diff --git a/test/visual/__screenshots__/desktop/print.png b/test/visual/__screenshots__/desktop/print.png index 66dafac..6103737 100644 Binary files a/test/visual/__screenshots__/desktop/print.png and b/test/visual/__screenshots__/desktop/print.png differ diff --git a/test/visual/__screenshots__/desktop/report.png b/test/visual/__screenshots__/desktop/report.png index 064ac43..4614315 100644 Binary files a/test/visual/__screenshots__/desktop/report.png and b/test/visual/__screenshots__/desktop/report.png differ diff --git a/test/visual/__screenshots__/desktop/section.png b/test/visual/__screenshots__/desktop/section.png index 64afbb8..89196d8 100644 Binary files a/test/visual/__screenshots__/desktop/section.png and b/test/visual/__screenshots__/desktop/section.png differ diff --git a/test/visual/__screenshots__/narrow/focused-control.png b/test/visual/__screenshots__/narrow/focused-control.png index bc1d309..813b02e 100644 Binary files a/test/visual/__screenshots__/narrow/focused-control.png and b/test/visual/__screenshots__/narrow/focused-control.png differ diff --git a/test/visual/__screenshots__/narrow/print.png b/test/visual/__screenshots__/narrow/print.png index 01a6df9..e35613d 100644 Binary files a/test/visual/__screenshots__/narrow/print.png and b/test/visual/__screenshots__/narrow/print.png differ diff --git a/test/visual/__screenshots__/narrow/report.png b/test/visual/__screenshots__/narrow/report.png index 9173ab0..3899629 100644 Binary files a/test/visual/__screenshots__/narrow/report.png and b/test/visual/__screenshots__/narrow/report.png differ diff --git a/test/visual/__screenshots__/narrow/section.png b/test/visual/__screenshots__/narrow/section.png index 2716a0d..82e6164 100644 Binary files a/test/visual/__screenshots__/narrow/section.png and b/test/visual/__screenshots__/narrow/section.png differ diff --git a/test/visual/__screenshots__/phone/focused-control.png b/test/visual/__screenshots__/phone/focused-control.png index 38a9836..eac6424 100644 Binary files a/test/visual/__screenshots__/phone/focused-control.png and b/test/visual/__screenshots__/phone/focused-control.png differ diff --git a/test/visual/__screenshots__/phone/print.png b/test/visual/__screenshots__/phone/print.png index 31baafa..1a47bf6 100644 Binary files a/test/visual/__screenshots__/phone/print.png and b/test/visual/__screenshots__/phone/print.png differ diff --git a/test/visual/__screenshots__/phone/report.png b/test/visual/__screenshots__/phone/report.png index 646e085..8efb8ab 100644 Binary files a/test/visual/__screenshots__/phone/report.png and b/test/visual/__screenshots__/phone/report.png differ diff --git a/test/visual/__screenshots__/phone/section.png b/test/visual/__screenshots__/phone/section.png index e586417..d1e2163 100644 Binary files a/test/visual/__screenshots__/phone/section.png and b/test/visual/__screenshots__/phone/section.png differ diff --git a/test/visual/__screenshots__/tablet/focused-control.png b/test/visual/__screenshots__/tablet/focused-control.png index 838a5db..0d7a9e4 100644 Binary files a/test/visual/__screenshots__/tablet/focused-control.png and b/test/visual/__screenshots__/tablet/focused-control.png differ diff --git a/test/visual/__screenshots__/tablet/print.png b/test/visual/__screenshots__/tablet/print.png index a9271fb..972b29d 100644 Binary files a/test/visual/__screenshots__/tablet/print.png and b/test/visual/__screenshots__/tablet/print.png differ diff --git a/test/visual/__screenshots__/tablet/report.png b/test/visual/__screenshots__/tablet/report.png index 2291de1..2fc941e 100644 Binary files a/test/visual/__screenshots__/tablet/report.png and b/test/visual/__screenshots__/tablet/report.png differ diff --git a/test/visual/__screenshots__/tablet/section.png b/test/visual/__screenshots__/tablet/section.png index 667d984..8843b96 100644 Binary files a/test/visual/__screenshots__/tablet/section.png and b/test/visual/__screenshots__/tablet/section.png differ