@vintasoftware/pr-review-canvas 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +9 -3
  2. package/docs/reference.md +99 -99
  3. package/package.json +12 -3
  4. package/pr-review.config.example.yml +4 -4
  5. package/skills/pr-review-canvas/SKILL.md +8 -1
  6. package/src/acpx/acpx.ts +21 -4
  7. package/src/acpx/events.ts +5 -1
  8. package/src/acpx/ndjson.ts +3 -1
  9. package/src/acpx/preflight.ts +5 -1
  10. package/src/canvas/export.ts +11 -2
  11. package/src/canvas/zip.ts +4 -1
  12. package/src/chat/chat-manager.ts +16 -3
  13. package/src/chat/context.ts +10 -3
  14. package/src/chat/seed.ts +3 -1
  15. package/src/cli.ts +12 -2
  16. package/src/commands.ts +41 -9
  17. package/src/config.ts +15 -3
  18. package/src/contract/comments.ts +3 -1
  19. package/src/contract/generation-context.ts +3 -1
  20. package/src/contract/review-artifact.ts +14 -8
  21. package/src/contract/state.ts +3 -1
  22. package/src/git/diff-collector.ts +2 -1
  23. package/src/git/git.ts +9 -4
  24. package/src/github/capabilities.ts +5 -1
  25. package/src/github/comments.ts +6 -3
  26. package/src/github/gh.ts +18 -3
  27. package/src/github/post-comment.ts +4 -1
  28. package/src/github/pr.ts +4 -1
  29. package/src/github/threads.ts +5 -1
  30. package/src/project-config.ts +12 -9
  31. package/src/prompt-files.ts +4 -3
  32. package/src/review/doctor.ts +9 -2
  33. package/src/review/normalize.ts +3 -1
  34. package/src/review/prepare.ts +24 -8
  35. package/src/review/prompt.ts +8 -2
  36. package/src/review/publish.ts +15 -3
  37. package/src/review/trim-caps.ts +10 -6
  38. package/src/review/validate.ts +65 -13
  39. package/src/server/app.ts +12 -4
  40. package/src/server/bundle.ts +17 -4
  41. package/src/server/context.ts +14 -2
  42. package/src/server/errors.ts +26 -2
  43. package/src/server/html.ts +2 -2
  44. package/src/server/node-server.ts +4 -2
  45. package/src/server/routes/api.ts +29 -5
  46. package/src/server/routes/chat-routes.ts +30 -9
  47. package/src/server/routes/pages.ts +3 -1
  48. package/src/server/routes/review-routes.ts +19 -4
  49. package/src/server/sse.ts +3 -1
  50. package/src/store/atomic-json.ts +5 -1
  51. package/src/store/canvas-store.ts +20 -6
  52. package/src/store/derived-store.ts +5 -1
  53. package/src/store/pr-store.ts +2 -1
  54. package/src/store/state-store.ts +2 -1
  55. package/static/brand.svg +19 -0
  56. package/static/js/api.js +15 -3
  57. package/static/js/app.js +8 -2
  58. package/static/js/chat-panel.js +93 -0
  59. package/static/js/chat.js +38 -15
  60. package/static/js/composer.js +6 -3
  61. package/static/js/diagram.js +2 -1
  62. package/static/js/diff-decorations.js +5 -3
  63. package/static/js/diff-renderer.js +6 -1
  64. package/static/js/dom.js +5 -7
  65. package/static/js/empty-state.js +3 -1
  66. package/static/js/header.js +1 -1
  67. package/static/js/interactions.js +55 -15
  68. package/static/js/keyboard.js +7 -2
  69. package/static/js/layers.js +23 -11
  70. package/static/js/links.js +8 -2
  71. package/static/js/nav.js +5 -2
  72. package/static/js/overview.js +32 -4
  73. package/static/js/points.js +3 -2
  74. package/static/js/progress.js +2 -1
  75. package/static/js/proposed-comment.js +4 -1
  76. package/static/js/quick-questions.js +2 -1
  77. package/static/js/regenerate.js +4 -1
  78. package/static/js/signoff.js +6 -2
  79. package/static/styles/base.css +16 -6
  80. package/static/styles/chat-panel.css +81 -0
  81. package/static/styles/chat-tools.css +28 -0
  82. package/static/styles/chat.css +1 -1
  83. package/static/styles/commands.css +4 -4
  84. package/static/styles/diff.css +1 -1
  85. package/static/styles/header.css +18 -4
  86. package/static/styles/layout.css +4 -4
  87. package/static/styles/responsive.css +1 -15
  88. package/static/styles/review.css +24 -3
  89. package/static/styles/skin-github.css +93 -100
  90. package/static/styles.css +13 -12
@@ -69,7 +69,11 @@ export function createDerivedStore(canvases: CanvasStore, git: Git, now: () => D
69
69
  await writeTextAtomic(path.join(dir, 'patches', `${f.key}.diff`), `${labelPatch(f.key, f.patch)}\n`)
70
70
  }
71
71
  await materialize(git, { headSha, mergeBaseSha, files: collected, outDir: dir })
72
- await writeJsonAtomic(path.join(dir, 'meta.json'), { headSha, mergeBaseSha, builtAt: now().toISOString() })
72
+ await writeJsonAtomic(path.join(dir, 'meta.json'), {
73
+ headSha,
74
+ mergeBaseSha,
75
+ builtAt: now().toISOString(),
76
+ })
73
77
  return { files, patches }
74
78
  },
75
79
  read,
@@ -39,7 +39,8 @@ export function createPrStore(repoRoot: string): PrStore {
39
39
  writeComments: (number, comments) => writeJsonAtomic(path.join(prDir(number), 'comments.json'), comments),
40
40
  readDiscovery: number =>
41
41
  readJsonOrDefault(path.join(prDir(number), 'discovery.json'), DiscoveryCacheSchema, () => null),
42
- writeDiscovery: (number, discovery) => writeJsonAtomic(path.join(prDir(number), 'discovery.json'), discovery),
42
+ writeDiscovery: (number, discovery) =>
43
+ writeJsonAtomic(path.join(prDir(number), 'discovery.json'), discovery),
43
44
  listRecent: async limit => {
44
45
  let names: string[]
45
46
  try {
@@ -93,7 +93,8 @@ export function createStateStore(prs: PrStore, now: () => Date): StateStore {
93
93
  update(number, state => {
94
94
  const next = { ...state.dismissed }
95
95
  if (dismissed) {
96
- next[fingerprint] = reason === undefined ? { at: now().toISOString() } : { at: now().toISOString(), reason }
96
+ next[fingerprint] =
97
+ reason === undefined ? { at: now().toISOString() } : { at: now().toISOString(), reason }
97
98
  } else {
98
99
  delete next[fingerprint]
99
100
  }
@@ -0,0 +1,19 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" shape-rendering="crispEdges">
2
+ <title>PR review canvas</title>
3
+ <path fill="#7661a8" d="M12 7h31v35H12z"/>
4
+ <path fill="#aa96d2" d="M39 9h2v24h-2z"/>
5
+ <path fill="#292b3d" d="M3 3h35v41H3z"/>
6
+ <path fill="#fff0cf" d="M5 12h31v30H5z"/>
7
+ <path fill="#45465c" d="M5 5h31v5H5z"/>
8
+ <path fill="#ffe08a" d="M22 8h3v1h-3z"/>
9
+ <path fill="#ffffff" d="M27 6h3v3h-3zm1 1v1h1V7z" fill-rule="evenodd"/>
10
+ <path fill="#ffb3ab" d="M32 6h1v1h1V6h1v1h-1v1h1v1h-1V8h-1v1h-1V8h1V7h-1z"/>
11
+ <path fill="#b44a45" d="M9 17h9v3H9z"/>
12
+ <path fill="#e8ddc4" d="M20 15h1v19h-1z"/>
13
+ <path fill="#827b70" d="M23 17h10v2H23zM23 26h10v2H23zM9 36h16v2H9z"/>
14
+ <path fill="#437d68" d="M12 23h3v3h3v3h-3v3h-3v-3H9v-3h3z"/>
15
+ <g transform="scale(0.5)" shape-rendering="geometricPrecision">
16
+ <path fill="#292b3d" d="M56 68H58V66H60V64H62V62H68V64H70V66H72V68H76V64H78V60H80V56H82V52H84V50H90V52H92V54H94V56H96V62H94V66H92V70H90V74H88V78H86V82H84V86H82V90H80V92H72V90H70V88H68V86H66V84H64V82H62V80H60V78H58V76H56z"/>
17
+ <path fill="#8bc79a" d="M58 70H60V68H62V66H64V64H66V66H68V68H70V70H72V72H74V74H76V70H78V66H80V62H82V58H84V54H86V52H88V54H90V56H92V58H94V60H92V64H90V68H88V72H86V76H84V80H82V84H80V88H78V90H74V88H72V86H70V84H68V82H66V80H64V78H62V76H60V74H58z"/>
18
+ </g>
19
+ </svg>
package/static/js/api.js CHANGED
@@ -161,7 +161,11 @@ export function fetchCapabilities(prNumber, opts = {}) {
161
161
  * @returns {Promise<import('./contract-types.js').PostCommentResponse>}
162
162
  */
163
163
  export function postComment(prNumber, input, opts = {}) {
164
- return fetchJson(`/api/prs/${prNumber}/comments`, { method: 'POST', body: input, fetchImpl: opts.fetchImpl })
164
+ return fetchJson(`/api/prs/${prNumber}/comments`, {
165
+ method: 'POST',
166
+ body: input,
167
+ fetchImpl: opts.fetchImpl,
168
+ })
165
169
  }
166
170
 
167
171
  /**
@@ -388,7 +392,11 @@ export function fetchThreads(prNumber, opts = {}) {
388
392
  * @returns {Promise<import('./contract-types.js').ChatThreadsResponse>}
389
393
  */
390
394
  export function createThread(prNumber, opts = {}) {
391
- return fetchJson(`/api/prs/${prNumber}/chat/threads`, { method: 'POST', body: {}, fetchImpl: opts.fetchImpl })
395
+ return fetchJson(`/api/prs/${prNumber}/chat/threads`, {
396
+ method: 'POST',
397
+ body: {},
398
+ fetchImpl: opts.fetchImpl,
399
+ })
392
400
  }
393
401
 
394
402
  /**
@@ -409,7 +417,11 @@ export function fetchThreadHistory(prNumber, name, opts = {}) {
409
417
  * @returns {Promise<{ cancelled: boolean }>}
410
418
  */
411
419
  export function cancelChat(prNumber, opts = {}) {
412
- return fetchJson(`/api/prs/${prNumber}/chat/cancel`, { method: 'POST', body: {}, fetchImpl: opts.fetchImpl })
420
+ return fetchJson(`/api/prs/${prNumber}/chat/cancel`, {
421
+ method: 'POST',
422
+ body: {},
423
+ fetchImpl: opts.fetchImpl,
424
+ })
413
425
  }
414
426
 
415
427
  /**
package/static/js/app.js CHANGED
@@ -62,7 +62,9 @@ function footerHtml(version, bundle) {
62
62
 
63
63
  /** @param {ReadonlyArray<string>} warnings */
64
64
  function bannerHtml(warnings) {
65
- return warnings.length === 0 ? '' : `<div class="banner" role="status">${warnings.map(w => esc(w)).join(' · ')}</div>`
65
+ return warnings.length === 0
66
+ ? ''
67
+ : `<div class="banner" role="status">${warnings.map(w => esc(w)).join(' · ')}</div>`
66
68
  }
67
69
 
68
70
  /**
@@ -369,7 +371,11 @@ export class PrAppElement extends HTMLElement {
369
371
  this.viewStale = true
370
372
  void this.render(bundle)
371
373
  })
372
- wireDropZone(this, { prNumber: boot.prNumber, importImpl: importCanvas, onImported: () => void this.reload() })
374
+ wireDropZone(this, {
375
+ prNumber: boot.prNumber,
376
+ importImpl: importCanvas,
377
+ onImported: () => void this.reload(),
378
+ })
373
379
  }
374
380
 
375
381
  /**
@@ -0,0 +1,93 @@
1
+ // @ts-check
2
+
3
+ /** Keeps the same chat mounted while switching between the sidebar and floating panel.
4
+ * @param {HTMLElement} root
5
+ * @param {HTMLElement} pane
6
+ */
7
+ export function wireChatPanel(root, pane) {
8
+ const dialog = /** @type {HTMLDialogElement} */ (root.querySelector('#chat-dialog'))
9
+ const launcher = /** @type {HTMLButtonElement} */ (root.querySelector('#chat-launcher'))
10
+ const minimize = /** @type {HTMLButtonElement} */ (root.querySelector('#chat-minimize'))
11
+ const narrow = window.matchMedia('(max-width: 1360px)')
12
+ const mobile = window.matchMedia('(max-width: 600px)')
13
+ let expanded = false
14
+
15
+ function sync() {
16
+ if (dialog.open) dialog.close()
17
+ if (narrow.matches) {
18
+ dialog.append(pane)
19
+ if (expanded) {
20
+ if (mobile.matches) dialog.showModal()
21
+ else dialog.show()
22
+ }
23
+ } else {
24
+ dialog.before(pane)
25
+ }
26
+ launcher.setAttribute('aria-expanded', String(narrow.matches && expanded))
27
+ launcher.hidden = narrow.matches && expanded
28
+ }
29
+
30
+ function open() {
31
+ if (narrow.matches && !expanded) {
32
+ expanded = true
33
+ sync()
34
+ }
35
+ pane.querySelector('textarea')?.focus()
36
+ }
37
+
38
+ function close() {
39
+ expanded = false
40
+ sync()
41
+ launcher.focus()
42
+ }
43
+
44
+ /** @param {Event} event */
45
+ function cancel(event) {
46
+ event.preventDefault()
47
+ close()
48
+ }
49
+
50
+ /** @param {KeyboardEvent} event */
51
+ function trapFocus(event) {
52
+ if (event.key !== 'Tab' || !mobile.matches || !dialog.open) return
53
+ const controls = [
54
+ ...dialog.querySelectorAll('button, select, textarea, input, a[href], [tabindex]'),
55
+ ].filter(
56
+ el =>
57
+ el instanceof HTMLElement &&
58
+ el.tabIndex >= 0 &&
59
+ !el.matches(':disabled') &&
60
+ el.getClientRects().length
61
+ )
62
+ const first = /** @type {HTMLElement | undefined} */ (controls[0])
63
+ const last = /** @type {HTMLElement | undefined} */ (controls.at(-1))
64
+ if (event.shiftKey && document.activeElement === first) {
65
+ event.preventDefault()
66
+ last?.focus()
67
+ } else if (!event.shiftKey && document.activeElement === last) {
68
+ event.preventDefault()
69
+ first?.focus()
70
+ }
71
+ }
72
+
73
+ launcher.addEventListener('click', open)
74
+ minimize.addEventListener('click', close)
75
+ dialog.addEventListener('cancel', cancel)
76
+ dialog.addEventListener('keydown', trapFocus)
77
+ narrow.addEventListener('change', sync)
78
+ mobile.addEventListener('change', sync)
79
+ sync()
80
+
81
+ return {
82
+ open,
83
+ stop() {
84
+ if (dialog.open) dialog.close()
85
+ launcher.removeEventListener('click', open)
86
+ minimize.removeEventListener('click', close)
87
+ dialog.removeEventListener('cancel', cancel)
88
+ dialog.removeEventListener('keydown', trapFocus)
89
+ narrow.removeEventListener('change', sync)
90
+ mobile.removeEventListener('change', sync)
91
+ },
92
+ }
93
+ }
package/static/js/chat.js CHANGED
@@ -8,6 +8,7 @@
8
8
  /** @typedef {import('./review-session.js').ReviewSession} ReviewSession */
9
9
  import { cancelChat, createThread, fetchThreadHistory, fetchThreads, streamChat } from './api.js'
10
10
  import { chatContextAttrs, chatContextLabel, sameChatContext, WHOLE_PR } from './chat-context.js'
11
+ import { wireChatPanel } from './chat-panel.js'
11
12
  import {
12
13
  canScrollBy,
13
14
  chatScrollReducer,
@@ -43,7 +44,8 @@ export function renderChatShell(opts) {
43
44
  `<button class="handle" type="button" id="chat-handle" role="separator" aria-orientation="vertical" aria-label="Resize AI Chat" aria-valuenow="${width}" aria-valuemin="${CHAT_WIDTH_MIN}" aria-valuemax="${CHAT_WIDTH_MAX}"></button>` +
44
45
  '<div class="chat-h"><h2 id="chat-h">AI Chat</h2><label class="sr" for="thread">Thread</label>' +
45
46
  '<select id="thread"></select>' +
46
- '<button class="cmd" type="button" id="new-thread">new thread</button></div>' +
47
+ '<button class="cmd" type="button" id="new-thread">new thread</button>' +
48
+ '<button class="cmd chat-minimize" type="button" id="chat-minimize" aria-label="Minimize AI Chat">minimize</button></div>' +
47
49
  '<p class="ctx-line" id="chat-ctx-line">Context: <span class="ctx-chip" id="chat-ctx">whole PR</span>' +
48
50
  ' <button class="cmd" type="button" id="chat-clear" hidden>clear</button></p>' +
49
51
  '<div class="transcript" id="chat-log" role="log" aria-label="Transcript" aria-live="polite" tabindex="0">' +
@@ -55,7 +57,9 @@ export function renderChatShell(opts) {
55
57
  '<div class="tbtns"><button class="cmd fill" type="submit" id="chat-send">send</button>' +
56
58
  '<button class="cmd" type="button" id="chat-stop" hidden>stop</button>' +
57
59
  '<span class="muted small">enter to send</span></div></form>' +
58
- '</aside>'
60
+ '</aside>' +
61
+ '<dialog class="chat-dialog" id="chat-dialog" aria-labelledby="chat-h"></dialog>' +
62
+ '<button class="chat-launcher" id="chat-launcher" type="button" aria-controls="chat-dialog" aria-expanded="false">AI Chat</button>'
59
63
  )
60
64
  }
61
65
 
@@ -97,12 +101,13 @@ export function writeChatWidth(storage, width) {
97
101
  storage?.setItem(CHAT_WIDTH_KEY, String(clampWidth(width)))
98
102
  }
99
103
 
100
- /** The four questions the quick menu offers, and the one that just focuses the box. */
104
+ /** The questions the quick menu offers, and the one that just focuses the box. */
101
105
  export const QUICK_QUESTIONS = [
102
- 'Is this covered by tests?',
103
- 'What could break?',
106
+ 'Suggestion to solve this?',
104
107
  'Why this way, and what were the alternatives?',
108
+ 'What could break?',
105
109
  'Does this follow how the codebase already does it?',
110
+ 'Is this covered by tests?',
106
111
  ]
107
112
 
108
113
  /**
@@ -148,7 +153,9 @@ function turnHtml(role, bodyHtml, opts = {}) {
148
153
  */
149
154
  function turnInnerHtml(role, bodyHtml, opts = {}) {
150
155
  const note =
151
- opts.incomplete === undefined ? '' : `<p class="muted small">the answer stopped early (${esc(opts.incomplete)})</p>`
156
+ opts.incomplete === undefined
157
+ ? ''
158
+ : `<p class="muted small">the answer stopped early (${esc(opts.incomplete)})</p>`
152
159
  return (
153
160
  `<span class="role">${role === 'assistant' ? 'AI Chat' : 'You'}</span>` +
154
161
  `<div class="prose">${bodyHtml}</div>${note}`
@@ -235,7 +242,11 @@ export function wireChat(options) {
235
242
  const { root, prNumber, session } = options
236
243
  const api = withDefaults(options.api)
237
244
  const storage =
238
- options.storage === undefined ? (typeof localStorage === 'undefined' ? null : localStorage) : options.storage
245
+ options.storage === undefined
246
+ ? typeof localStorage === 'undefined'
247
+ ? null
248
+ : localStorage
249
+ : options.storage
239
250
  const paths = new Set(session.artifact.files.map(f => f.path))
240
251
  const targets = targetsFromFiles(session.artifact.files)
241
252
  const reducedMotion =
@@ -304,7 +315,8 @@ export function wireChat(options) {
304
315
  applyWidth(readChatWidth(storage))
305
316
 
306
317
  const layerTitle = /** @param {string} id */ id => session.artifact.layers.find(l => l.id === id)?.title
307
- const pointTitle = /** @param {string} fp */ fp => session.artifact.points.find(p => p.fingerprint === fp)?.title
318
+ const pointTitle = /** @param {string} fp */ fp =>
319
+ session.artifact.points.find(p => p.fingerprint === fp)?.title
308
320
 
309
321
  const drawContext = () => {
310
322
  chip.textContent = chatContextLabel(context, layerTitle, pointTitle)
@@ -416,7 +428,8 @@ export function wireChat(options) {
416
428
  activeThread = data.activeThread
417
429
  select.innerHTML = data.threads
418
430
  .map(
419
- t => `<option value="${esc(t.name)}"${t.name === data.activeThread ? ' selected' : ''}>${esc(t.title)}</option>`
431
+ t =>
432
+ `<option value="${esc(t.name)}"${t.name === data.activeThread ? ' selected' : ''}>${esc(t.title)}</option>`
420
433
  )
421
434
  .join('')
422
435
  if (data.threads.length === 0) {
@@ -510,7 +523,7 @@ export function wireChat(options) {
510
523
  const started = Date.now()
511
524
  const updateActivity = () => {
512
525
  const elapsed = Date.now() - started
513
- activity.textContent = `Preparing answer${'.'.repeat(Math.floor(elapsed / 400) % 3 + 1)} · ${Math.floor(elapsed / 1000)}s`
526
+ activity.textContent = `Preparing answer${'.'.repeat((Math.floor(elapsed / 400) % 3) + 1)} · ${Math.floor(elapsed / 1000)}s`
514
527
  }
515
528
  updateActivity()
516
529
  const timer = setInterval(updateActivity, 400)
@@ -544,7 +557,10 @@ export function wireChat(options) {
544
557
  const previous = tools.get(id)
545
558
  const title = String(field('title') ?? 'tool')
546
559
  tools.delete(id)
547
- tools.set(id, { title: title === 'tool' && previous ? previous.title : title, status: String(field('status') ?? 'pending') })
560
+ tools.set(id, {
561
+ title: title === 'tool' && previous ? previous.title : title,
562
+ status: String(field('status') ?? 'pending'),
563
+ })
548
564
  toolDetails.hidden = false
549
565
  const latest = tools.get(id)
550
566
  toolDetails.innerHTML = `<summary>${esc(latest.title)} · ${esc(latest.status)} (${tools.size} tool calls)</summary><ul>${[...tools.values()].map(t => `<li>${esc(t.title)} · ${esc(t.status)}</li>`).join('')}</ul>`
@@ -578,9 +594,13 @@ export function wireChat(options) {
578
594
  const toolSummary = toolDetails.querySelector('summary')
579
595
  if (toolSummary) toolSummary.textContent = `${tools.size} tool calls`
580
596
  activity.textContent = `Elapsed: ${Math.floor((Date.now() - started) / 1000)}s`
581
- if (frame !== 0) { cancelFrame(frame); frame = 0 }
597
+ if (frame !== 0) {
598
+ cancelFrame(frame)
599
+ frame = 0
600
+ }
582
601
  setStreaming(false)
583
- answer.body.innerHTML = text === '' ? '<span class="muted">no answer</span>' : renderAnswer(text, turnKey)
602
+ answer.body.innerHTML =
603
+ text === '' ? '<span class="muted">no answer</span>' : renderAnswer(text, turnKey)
584
604
  if (scroll.pinned) {
585
605
  scrollToBottom('follow')
586
606
  }
@@ -728,6 +748,7 @@ export function wireChat(options) {
728
748
  }
729
749
 
730
750
  const stopResize = wireResize(pane, root, storage, applyWidth)
751
+ const panel = wireChatPanel(root, pane)
731
752
 
732
753
  form.addEventListener('submit', onSubmit)
733
754
  root.addEventListener('click', onClick)
@@ -744,12 +765,12 @@ export function wireChat(options) {
744
765
  },
745
766
  setContext,
746
767
  focusInput() {
747
- box.focus()
768
+ panel.open()
748
769
  },
749
770
  /** @param {ChatContext} next */
750
771
  ask(next) {
751
772
  setContext(next)
752
- box.focus()
773
+ panel.open()
753
774
  },
754
775
  /**
755
776
  * Sends one of the quick questions about a target.
@@ -758,6 +779,7 @@ export function wireChat(options) {
758
779
  */
759
780
  askQuestion(next, question) {
760
781
  setContext(next)
782
+ panel.open()
761
783
  box.value = question
762
784
  void runCommand(sendButton, send, { pendingLabel: 'sending…' })
763
785
  },
@@ -775,6 +797,7 @@ export function wireChat(options) {
775
797
  select.removeEventListener('change', onThreadChange)
776
798
  box.removeEventListener('keydown', /** @type {EventListener} */ (onKeyDown))
777
799
  stopResize()
800
+ panel.stop()
778
801
  },
779
802
  }
780
803
  }
@@ -61,8 +61,8 @@ export function composerHtml(opts) {
61
61
  */
62
62
  export function composerRowHtml(opts) {
63
63
  return (
64
- `<tr class="composer" data-decoration="composer"><td class="ln" colspan="3"></td>` +
65
- `<td class="code x">${composerHtml(opts)}</td></tr>`
64
+ `<tr class="composer" data-decoration="composer">` +
65
+ `<td class="code x" colspan="4">${composerHtml(opts)}</td></tr>`
66
66
  )
67
67
  }
68
68
 
@@ -232,7 +232,10 @@ export function toggleMarkdownPreview(button, preview) {
232
232
  const textarea = host?.querySelector('textarea')
233
233
  const output = host?.querySelector('.markdown-preview')
234
234
  if (!(textarea instanceof HTMLTextAreaElement) || !(output instanceof HTMLElement)) return
235
- if (preview) output.innerHTML = textarea.value.trim() ? renderMarkdown(textarea.value, { github: true }) : '<p class="muted">Nothing to preview.</p>'
235
+ if (preview)
236
+ output.innerHTML = textarea.value.trim()
237
+ ? renderMarkdown(textarea.value, { github: true })
238
+ : '<p class="muted">Nothing to preview.</p>'
236
239
  textarea.hidden = preview
237
240
  output.hidden = !preview
238
241
  host?.querySelector('[data-act="markdown-write"]')?.setAttribute('aria-pressed', String(!preview))
@@ -554,7 +554,8 @@ export function initDiagrams(root, opts = {}) {
554
554
  if (activateNodeLink(event)) {
555
555
  return
556
556
  }
557
- const chevron = event.target instanceof Element ? event.target.closest('.diagram > .diagram-h > .chev') : null
557
+ const chevron =
558
+ event.target instanceof Element ? event.target.closest('.diagram > .diagram-h > .chev') : null
558
559
  const node = chevron?.closest('.diagram')
559
560
  if (node instanceof HTMLElement) {
560
561
  void toggleDiagram(node, pass)
@@ -41,7 +41,7 @@ export function insertNoteRow(card, key, a) {
41
41
  }
42
42
  const range = a.startLine === a.endLine ? `line ${a.startLine}` : `lines ${a.startLine}–${a.endLine}`
43
43
  const html =
44
- `<tr class="annot ann${approx ? ' is-approx' : ''}" ${DECORATION}="note"><td class="ln" colspan="3"></td><td class="code x">` +
44
+ `<tr class="annot ann${approx ? ' is-approx' : ''}" ${DECORATION}="note"><td class="code x" colspan="4">` +
45
45
  `<span class="lbl">Annotation · ${esc(range)}${a.side === 'old' ? ' (old)' : ''}</span>` +
46
46
  `<div class="prose">${renderMarkdown(a.text)}</div></td></tr>`
47
47
  last.insertAdjacentElement('afterend', firstRow(html))
@@ -102,7 +102,7 @@ export function threadRowHtml(t, opts) {
102
102
  : `<button class="cmd" type="button" data-act="thread-hide" data-thread="${rootId}">hide</button>`
103
103
  return (
104
104
  `<tr class="thread${t.resolved ? ' resolved' : ''}${hidden ? ' hidden-thread' : ''}" data-thread="${rootId}">` +
105
- '<td class="ln" colspan="3"></td><td class="code x">' +
105
+ '<td class="code x" colspan="4">' +
106
106
  `<div class="thread-full"${collapsed ? ' hidden' : ''}>${comments}` +
107
107
  `<span class="tbtns"><button class="cmd" type="button" data-act="thread-reply" data-thread="${rootId}" data-needs-post>reply</button>${hide}</span></div>` +
108
108
  `<div class="thread-collapsed"${collapsed ? '' : ' hidden'}>${label} · ` +
@@ -186,7 +186,9 @@ export function applyDecorations(card, key, data) {
186
186
  }
187
187
  }
188
188
  for (const t of [...data.threads].reverse()) {
189
- count(insertThreadRow(card, key, t, { now: data.now, hidden: data.hiddenThreads?.has(t.root.id) ?? false }))
189
+ count(
190
+ insertThreadRow(card, key, t, { now: data.now, hidden: data.hiddenThreads?.has(t.root.id) ?? false })
191
+ )
190
192
  }
191
193
  for (const p of [...data.points].reverse()) {
192
194
  count(
@@ -113,7 +113,12 @@ export function parsePatch(patch) {
113
113
  prevDel = true
114
114
  prevAdd = false
115
115
  } else {
116
- cur.entries.push({ ...base, code: line.startsWith(' ') ? line.slice(1) : line, oldLine: oL, newLine: nL })
116
+ cur.entries.push({
117
+ ...base,
118
+ code: line.startsWith(' ') ? line.slice(1) : line,
119
+ oldLine: oL,
120
+ newLine: nL,
121
+ })
117
122
  oL++
118
123
  nL++
119
124
  prevAdd = false
package/static/js/dom.js CHANGED
@@ -20,7 +20,7 @@ export function esc(value) {
20
20
  * in interactions.js reads `data-act` and toggles the card this button belongs to.
21
21
  * @param {string} label
22
22
  * @param {boolean} [expanded]
23
- * @param {{ act?: string }} [opts] `act` is omitted inside a <details>, which toggles itself
23
+ * @param {{ act?: string }} [opts]
24
24
  */
25
25
  export function chevronHtml(label, expanded = true, opts = {}) {
26
26
  const act = opts.act === undefined ? '' : ` data-act="${esc(opts.act)}"`
@@ -29,14 +29,12 @@ export function chevronHtml(label, expanded = true, opts = {}) {
29
29
 
30
30
  /**
31
31
  * The <summary> of a collapsible <details> block: a chevron, then the title. The block relies on the
32
- * native <details> toggle, so a click anywhere on the summary opens or closes it. Pass `open` when
33
- * the <details> carries the `open` attribute so the chevron reports the same state.
32
+ * native <details> toggle. The decorative chevron lets clicks activate the summary.
34
33
  * @param {string} titleHtml already escaped
35
- * @param {string} label the chevron's accessible name
36
- * @param {{ open?: boolean }} [opts]
34
+ * @param {string} label the summary's accessible name
37
35
  */
38
- export function detailsSummaryHtml(titleHtml, label, opts = {}) {
39
- return `<summary>${chevronHtml(label, opts.open === true)}${titleHtml}</summary>`
36
+ export function detailsSummaryHtml(titleHtml, label) {
37
+ return `<summary aria-label="${esc(label)}"><span class="chev" aria-hidden="true">&gt;</span>${titleHtml}</summary>`
40
38
  }
41
39
 
42
40
  /**
@@ -50,7 +50,9 @@ export function validateCanvasFilename(filename) {
50
50
  if (!filename.toLowerCase().endsWith('.zip')) {
51
51
  return 'that is not a zip file'
52
52
  }
53
- return /^(?:pr-[1-9]\d*|ref)-.+-canvas\.zip$/i.test(filename) ? null : 'that zip is not a review canvas export'
53
+ return /^(?:pr-[1-9]\d*|ref)-.+-canvas\.zip$/i.test(filename)
54
+ ? null
55
+ : 'that zip is not a review canvas export'
54
56
  }
55
57
 
56
58
  /**
@@ -65,7 +65,7 @@ export function renderHeader(bundle, opts) {
65
65
  const risk = ready ? riskLineHtml(artifact.risk) : ''
66
66
  return (
67
67
  '<header class="hdr">' +
68
- `<div class="hdr-bar"><div class="brand"><span class="box">PR review canvas</span><span class="mono muted">${esc(opts.host)}</span></div>` +
68
+ `<div class="hdr-bar"><div class="brand"><span class="brand-wordmark"><img class="brand-icon" src="/static/brand.svg" width="32" height="32" alt="">PR review canvas</span><span class="mono muted">${esc(opts.host)}</span></div>` +
69
69
  '<div class="hdr-actions" role="group" aria-label="Canvas actions">' +
70
70
  `<button class="cmd" type="button" id="regenerate" title="Generate a new canvas for this PR" aria-haspopup="dialog"${hasCanvas ? '' : ' disabled'}>regenerate</button>` +
71
71
  `<button class="cmd" type="button" id="export-zip" title="Download this canvas as a zip to share on GitHub"${hasCanvas ? '' : ' disabled'}>export zip</button>` +