agent-canvas 0.2.0 → 0.6.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 (98) hide show
  1. package/README.md +42 -17
  2. package/bin/agent-canvas.js +7 -7
  3. package/bin/agent-canvas.ts +48 -37
  4. package/bin/lib/commands/daemon.ts +46 -43
  5. package/bin/lib/commands/exec.ts +100 -100
  6. package/bin/lib/commands/fetch.ts +15 -15
  7. package/bin/lib/commands/install.ts +36 -36
  8. package/bin/lib/commands/instructions.ts +51 -51
  9. package/bin/lib/commands/push.ts +115 -97
  10. package/bin/lib/commands/review.ts +190 -0
  11. package/bin/lib/commands/watch.ts +24 -24
  12. package/bin/lib/config.ts +12 -12
  13. package/bin/lib/daemon-lifecycle.ts +47 -51
  14. package/bin/lib/helpers.ts +82 -76
  15. package/bin/lib/instructions/app-review.md +88 -0
  16. package/bin/lib/instructions/component-checklist.md +7 -5
  17. package/bin/lib/instructions/component-custom.md +20 -14
  18. package/bin/lib/instructions/component-diff.md +4 -3
  19. package/bin/lib/instructions/component-item.md +18 -8
  20. package/bin/lib/instructions/component-markdown.md +4 -2
  21. package/bin/lib/instructions/component-mermaid.md +12 -6
  22. package/bin/lib/instructions/component-multichoice.md +6 -2
  23. package/bin/lib/instructions/component-note.md +1 -1
  24. package/bin/lib/instructions/component-priority.md +1 -1
  25. package/bin/lib/instructions/component-rangeinput.md +9 -2
  26. package/bin/lib/instructions/component-secretinput.md +12 -11
  27. package/bin/lib/instructions/component-svg.md +62 -16
  28. package/bin/lib/instructions/component-table.md +6 -6
  29. package/bin/lib/instructions/component-usefeedback.md +8 -8
  30. package/bin/lib/instructions/component-userinput.md +6 -2
  31. package/bin/lib/instructions/flow-feature.md +22 -5
  32. package/bin/lib/instructions/flow-plan.md +5 -5
  33. package/bin/lib/instructions/flow-runbook.md +28 -25
  34. package/bin/lib/instructions/overview.md +27 -18
  35. package/bin/lib/instructions/theming.md +5 -0
  36. package/daemon/build.ts +171 -152
  37. package/daemon/client/App.tsx +1205 -1077
  38. package/daemon/client/CompareView.tsx +478 -458
  39. package/daemon/client/FileBrowser.tsx +241 -203
  40. package/daemon/client/FileViewer.tsx +220 -211
  41. package/daemon/client/OverviewView.tsx +283 -267
  42. package/daemon/client/ReviewerIdentityDialog.tsx +72 -71
  43. package/daemon/client/RevisionSelect.tsx +111 -95
  44. package/daemon/client/SessionSwitcher.tsx +81 -81
  45. package/daemon/client/ShareDialog.tsx +211 -212
  46. package/daemon/client/apiSchemas.ts +78 -0
  47. package/daemon/client/canvas-runtime.ts +1 -1
  48. package/daemon/client/clientApi.ts +194 -206
  49. package/daemon/client/components/SecretInput.tsx +138 -138
  50. package/daemon/client/components/index.ts +2 -2
  51. package/daemon/client/domDiff.ts +341 -333
  52. package/daemon/client/errorMessage.ts +3 -0
  53. package/daemon/client/exportMarkdown.ts +222 -201
  54. package/daemon/client/lcs.ts +19 -14
  55. package/daemon/client/shareCrypto.ts +57 -57
  56. package/daemon/client/unifiedDiff.ts +660 -649
  57. package/daemon/dist/annotations-runtime.js +1 -1
  58. package/daemon/dist/client.css +2 -2
  59. package/daemon/dist/client.js +77 -80
  60. package/daemon/dist/components.js +59 -59
  61. package/daemon/dist/preact-compat.js +1 -1
  62. package/daemon/dist/review.js +361 -0
  63. package/daemon/dist/runtime.js +1 -1
  64. package/daemon/langMap.ts +24 -6
  65. package/daemon/review-widget/anchor.ts +187 -0
  66. package/daemon/review-widget/client.ts +95 -0
  67. package/daemon/review-widget/index.tsx +36 -0
  68. package/daemon/review-widget/markup.ts +71 -0
  69. package/daemon/review-widget/screenshot.ts +91 -0
  70. package/daemon/review-widget/source.ts +103 -0
  71. package/daemon/review-widget/styles.css +359 -0
  72. package/daemon/review-widget/text-modules.d.ts +4 -0
  73. package/daemon/review-widget/tsconfig.json +16 -0
  74. package/daemon/review-widget/widget.tsx +436 -0
  75. package/daemon/src/components.ts +3 -3
  76. package/daemon/src/handlers/api.ts +330 -328
  77. package/daemon/src/handlers/files.ts +112 -105
  78. package/daemon/src/handlers/static.ts +47 -46
  79. package/daemon/src/handlers/uploads.ts +61 -56
  80. package/daemon/src/paths.ts +13 -10
  81. package/daemon/src/remote-feedback.ts +192 -192
  82. package/daemon/src/review/markdown.ts +52 -0
  83. package/daemon/src/review/model.ts +132 -0
  84. package/daemon/src/review/proxy.ts +177 -0
  85. package/daemon/src/review/server.ts +199 -0
  86. package/daemon/src/review/store.ts +151 -0
  87. package/daemon/src/server.ts +127 -86
  88. package/daemon/src/share.ts +163 -169
  89. package/daemon/src/stubFetch.ts +7 -0
  90. package/daemon/tsconfig.json +19 -19
  91. package/package.json +47 -47
  92. package/skills/canvas/SKILL.md +9 -1
  93. package/bin/lib/commands/exec.test.ts +0 -27
  94. package/bin/lib/helpers.test.ts +0 -79
  95. package/daemon/src/remote-feedback.test.ts +0 -165
  96. package/daemon/src/secrets-api.test.ts +0 -123
  97. package/daemon/src/share-e2e.test.ts +0 -277
  98. package/daemon/src/share.test.ts +0 -289
package/daemon/langMap.ts CHANGED
@@ -1,8 +1,26 @@
1
1
  /** File extension → highlight.js language name mapping. */
2
2
  export const LANG_MAP: Record<string, string> = {
3
- ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript",
4
- py: "python", rs: "rust", go: "go", rb: "ruby", java: "java",
5
- json: "json", yaml: "yaml", yml: "yaml", md: "markdown",
6
- css: "css", html: "html", sh: "bash", bash: "bash", toml: "toml",
7
- sql: "sql", xml: "xml", c: "c", cpp: "cpp", h: "c",
8
- };
3
+ ts: 'typescript',
4
+ tsx: 'typescript',
5
+ js: 'javascript',
6
+ jsx: 'javascript',
7
+ py: 'python',
8
+ rs: 'rust',
9
+ go: 'go',
10
+ rb: 'ruby',
11
+ java: 'java',
12
+ json: 'json',
13
+ yaml: 'yaml',
14
+ yml: 'yaml',
15
+ md: 'markdown',
16
+ css: 'css',
17
+ html: 'html',
18
+ sh: 'bash',
19
+ bash: 'bash',
20
+ toml: 'toml',
21
+ sql: 'sql',
22
+ xml: 'xml',
23
+ c: 'c',
24
+ cpp: 'cpp',
25
+ h: 'c',
26
+ }
@@ -0,0 +1,187 @@
1
+ import type { ReviewAnchor } from '../src/review/model'
2
+ import { contextOf, htmlOf } from './markup'
3
+
4
+ const MAX_TEXT = 120
5
+ const QUOTE_CONTEXT = 40
6
+ const MAX_COMPONENTS = 8
7
+
8
+ function shorten(text: string, max = MAX_TEXT): string {
9
+ const collapsed = text.replace(/\s+/g, ' ').trim()
10
+ return collapsed.length > max ? `${collapsed.slice(0, max - 1)}…` : collapsed
11
+ }
12
+
13
+ const IMPLICIT_ROLES: Record<string, string> = {
14
+ button: 'button',
15
+ select: 'combobox',
16
+ textarea: 'textbox',
17
+ h1: 'heading',
18
+ h2: 'heading',
19
+ h3: 'heading',
20
+ h4: 'heading',
21
+ h5: 'heading',
22
+ h6: 'heading',
23
+ img: 'img',
24
+ nav: 'navigation',
25
+ main: 'main',
26
+ dialog: 'dialog',
27
+ table: 'table',
28
+ tr: 'row',
29
+ th: 'columnheader',
30
+ td: 'cell',
31
+ li: 'listitem',
32
+ ul: 'list',
33
+ ol: 'list',
34
+ form: 'form',
35
+ }
36
+
37
+ const INPUT_ROLES: Record<string, string> = { checkbox: 'checkbox', radio: 'radio', button: 'button', submit: 'button', range: 'slider' }
38
+
39
+ export function roleOf(element: Element): string | undefined {
40
+ const explicit = element.getAttribute('role')
41
+ if (explicit !== null && explicit !== '') return explicit.split(' ')[0]
42
+ const tag = element.tagName.toLowerCase()
43
+ if (tag === 'a') return element.hasAttribute('href') ? 'link' : undefined
44
+ if (tag === 'input') return INPUT_ROLES[element.getAttribute('type') ?? 'text'] ?? 'textbox'
45
+ return IMPLICIT_ROLES[tag]
46
+ }
47
+
48
+ /** The accessible name, approximated: the sources a reviewer's element usually has, in the order the spec reads them. */
49
+ export function nameOf(element: Element): string | undefined {
50
+ const label = element.getAttribute('aria-label')
51
+ if (label !== null && label.trim() !== '') return shorten(label)
52
+ const labelledBy = element.getAttribute('aria-labelledby')
53
+ if (labelledBy !== null) {
54
+ const text = labelledBy.split(/\s+/).map((id) => element.ownerDocument.getElementById(id)?.textContent ?? '').join(' ')
55
+ if (text.trim() !== '') return shorten(text)
56
+ }
57
+ if (element.id !== '') {
58
+ const forLabel = element.ownerDocument.querySelector(`label[for="${CSS.escape(element.id)}"]`)
59
+ if (forLabel?.textContent != null && forLabel.textContent.trim() !== '') return shorten(forLabel.textContent)
60
+ }
61
+ for (const attribute of ['alt', 'title', 'placeholder']) {
62
+ const value = element.getAttribute(attribute)
63
+ if (value !== null && value.trim() !== '') return shorten(value)
64
+ }
65
+ const role = roleOf(element)
66
+ const namedByContent = role === 'button' || role === 'link' || role === 'heading' || role === 'cell' || role === 'columnheader' || role === 'tab'
67
+ || role === 'menuitem' || role === 'option'
68
+ if (namedByContent && element.textContent !== null && element.textContent.trim() !== '') return shorten(element.textContent)
69
+ return undefined
70
+ }
71
+
72
+ /** A CSS path from the nearest ancestor with an id, or from `body`, using `:nth-of-type` only where siblings share a tag. */
73
+ export function selectorOf(element: Element): string {
74
+ const parts: string[] = []
75
+ let current: Element | null = element
76
+ while (current !== null && current.tagName.toLowerCase() !== 'body' && current.tagName.toLowerCase() !== 'html') {
77
+ if (current.id !== '' && current.ownerDocument.querySelectorAll(`#${CSS.escape(current.id)}`).length === 1) {
78
+ parts.unshift(`#${CSS.escape(current.id)}`)
79
+ return parts.join(' > ')
80
+ }
81
+ const tag = current.tagName.toLowerCase()
82
+ const parent: Element | null = current.parentElement
83
+ const sameTag = parent === null ? [] : [...parent.children].filter((child) => child.tagName === current?.tagName)
84
+ parts.unshift(sameTag.length > 1 ? `${tag}:nth-of-type(${sameTag.indexOf(current) + 1})` : tag)
85
+ current = parent
86
+ }
87
+ parts.unshift('body')
88
+ return parts.join(' > ')
89
+ }
90
+
91
+ function isRecord(value: unknown): value is Record<string, unknown> {
92
+ return typeof value === 'object' && value !== null
93
+ }
94
+
95
+ function componentName(type: unknown): string | undefined {
96
+ if (typeof type === 'function') {
97
+ const displayName: unknown = Reflect.get(type, 'displayName')
98
+ if (typeof displayName === 'string' && displayName !== '') return displayName
99
+ return type.name === '' ? undefined : type.name
100
+ }
101
+ if (isRecord(type)) {
102
+ // memo() wraps the component in `type`, forwardRef() in `render`.
103
+ return componentName(type['type']) ?? componentName(type['render'])
104
+ }
105
+ return undefined
106
+ }
107
+
108
+ /** Owner components of a React development build, outermost first; empty for other renderers and production builds. */
109
+ export function componentsOf(element: Element): string[] {
110
+ const fiberKey = Object.keys(element).find((key) => key.startsWith('__reactFiber$'))
111
+ if (fiberKey === undefined) return []
112
+ const names: string[] = []
113
+ let fiber: unknown = Reflect.get(element, fiberKey)
114
+ while (isRecord(fiber) && names.length < 40) {
115
+ // Bundlers suffix a name that collides in their scope (`Card` → `Card13`); the source still says `Card`.
116
+ const name = componentName(fiber['type'])?.replace(/(?<=[a-z])\d+$/, '')
117
+ // Context providers wrap whole apps and say nothing about where the element is.
118
+ if (name !== undefined && /^[A-Z]/.test(name) && !name.endsWith('Provider') && names[0] !== name) names.unshift(name)
119
+ fiber = fiber['return']
120
+ }
121
+ return names.slice(-MAX_COMPONENTS)
122
+ }
123
+
124
+ function quoteOf(element: Element, range: Range): ReviewAnchor['quote'] {
125
+ const exact = range.toString().trim()
126
+ if (exact === '') return undefined
127
+ const before = element.ownerDocument.createRange()
128
+ before.selectNodeContents(element)
129
+ before.setEnd(range.startContainer, range.startOffset)
130
+ const after = element.ownerDocument.createRange()
131
+ after.selectNodeContents(element)
132
+ after.setStart(range.endContainer, range.endOffset)
133
+ return {
134
+ exact: exact.slice(0, 2000),
135
+ before: before.toString().replace(/\s+/g, ' ').slice(-QUOTE_CONTEXT),
136
+ after: after.toString().replace(/\s+/g, ' ').slice(0, QUOTE_CONTEXT),
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Describes the element, or the selection inside it, so the agent can find it in code and the widget in the page.
142
+ * Source positions need the page's source maps, so the caller resolves them separately.
143
+ */
144
+ export function describe(element: Element, url: string, range?: Range): ReviewAnchor {
145
+ const role = roleOf(element)
146
+ const name = nameOf(element)
147
+ const text = element.textContent === null ? '' : shorten(element.textContent)
148
+ const components = componentsOf(element)
149
+ const quote = range === undefined ? undefined : quoteOf(element, range)
150
+ const context = contextOf(element)
151
+ return {
152
+ url,
153
+ selector: selectorOf(element),
154
+ ...(role === undefined ? {} : { role }),
155
+ ...(name === undefined ? {} : { name }),
156
+ ...(text === '' ? {} : { text }),
157
+ ...(components.length === 0 ? {} : { components }),
158
+ ...(quote === undefined ? {} : { quote }),
159
+ ...(context === undefined ? {} : { context }),
160
+ html: htmlOf(element),
161
+ }
162
+ }
163
+
164
+ function matches(element: Element, anchor: ReviewAnchor): boolean {
165
+ if (anchor.role !== undefined && roleOf(element) !== anchor.role) return false
166
+ if (anchor.name !== undefined && nameOf(element) !== anchor.name) return false
167
+ return true
168
+ }
169
+
170
+ /**
171
+ * Re-finds the anchored element after the page re-rendered: the CSS path when it still points at an element with the
172
+ * same role and name, otherwise the only element with that role and name.
173
+ */
174
+ export function find(document: Document, anchor: ReviewAnchor): Element | null {
175
+ let bySelector: Element | null = null
176
+ try {
177
+ bySelector = document.querySelector(anchor.selector)
178
+ } catch {
179
+ bySelector = null
180
+ }
181
+ if (bySelector !== null && matches(bySelector, anchor)) return bySelector
182
+ if (anchor.role !== undefined && anchor.name !== undefined) {
183
+ const candidates = [...document.body.querySelectorAll('*')].filter((element) => matches(element, anchor))
184
+ if (candidates.length === 1) return candidates[0] ?? null
185
+ }
186
+ return bySelector
187
+ }
@@ -0,0 +1,95 @@
1
+ import { z } from 'zod'
2
+ import { type ReviewAnchor, type ReviewBrowserFrame, type ReviewThread, reviewThreadSchema } from '../src/review/model'
3
+
4
+ const threadListSchema = z.object({ threads: z.array(reviewThreadSchema) })
5
+
6
+ export interface ReviewEvents {
7
+ threads(threads: readonly ReviewThread[]): void
8
+ agentWatching(watching: boolean): void
9
+ connected(connected: boolean): void
10
+ }
11
+
12
+ function isRecord(value: unknown): value is Record<string, unknown> {
13
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
14
+ }
15
+
16
+ /** The daemon only ever sends these frames; anything else is ignored rather than trusted. */
17
+ function readFrame(data: unknown): ReviewBrowserFrame | undefined {
18
+ if (typeof data !== 'string') return undefined
19
+ let frame: unknown
20
+ try {
21
+ frame = JSON.parse(data)
22
+ } catch {
23
+ return undefined
24
+ }
25
+ if (!isRecord(frame)) return undefined
26
+ if (frame['type'] === 'agent-status' && typeof frame['watching'] === 'boolean') return { type: 'agent-status', watching: frame['watching'] }
27
+ if (frame['type'] !== 'threads') return undefined
28
+ const parsed = threadListSchema.safeParse(frame)
29
+ return parsed.success ? { type: 'threads', threads: parsed.data.threads } : undefined
30
+ }
31
+
32
+ /** The widget's side of the daemon: thread mutations over HTTP, the thread list and agent presence over a WebSocket. */
33
+ export class ReviewClient {
34
+ private socket: WebSocket | null = null
35
+ private closed = false
36
+
37
+ constructor(private readonly daemonOrigin: string, private readonly project: string) {}
38
+
39
+ connect(events: ReviewEvents): void {
40
+ const url = `${this.daemonOrigin.replace(/^http/, 'ws')}/ws/review/${encodeURIComponent(this.project)}`
41
+ const socket = new WebSocket(url)
42
+ this.socket = socket
43
+ socket.onopen = () => {
44
+ events.connected(true)
45
+ void this.request('GET', '/threads').then((body) => {
46
+ const parsed = threadListSchema.safeParse(body)
47
+ if (parsed.success) events.threads(parsed.data.threads)
48
+ })
49
+ }
50
+ socket.onmessage = (event) => {
51
+ const frame = readFrame(event.data)
52
+ if (frame?.type === 'threads') events.threads(frame.threads)
53
+ if (frame?.type === 'agent-status') events.agentWatching(frame.watching)
54
+ }
55
+ socket.onclose = () => {
56
+ events.connected(false)
57
+ if (!this.closed) setTimeout(() => this.connect(events), 2000)
58
+ }
59
+ }
60
+
61
+ close(): void {
62
+ this.closed = true
63
+ this.socket?.close()
64
+ }
65
+
66
+ createThread(anchor: ReviewAnchor, body: string, send: boolean, screenshot?: string): Promise<unknown> {
67
+ return this.request('POST', '/threads', { anchor, body, send, ...(screenshot === undefined ? {} : { screenshot }) })
68
+ }
69
+
70
+ comment(threadId: string, body: string, send: boolean): Promise<unknown> {
71
+ return this.request('POST', `/threads/${threadId}/comments`, { body, send })
72
+ }
73
+
74
+ send(threadId: string): Promise<unknown> {
75
+ return this.request('POST', `/threads/${threadId}/send`)
76
+ }
77
+
78
+ setResolved(threadId: string, resolved: boolean): Promise<unknown> {
79
+ return this.request('POST', `/threads/${threadId}/${resolved ? 'resolve' : 'reopen'}`)
80
+ }
81
+
82
+ delete(threadId: string): Promise<unknown> {
83
+ return this.request('DELETE', `/threads/${threadId}`)
84
+ }
85
+
86
+ private async request(method: string, path: string, body?: unknown): Promise<unknown> {
87
+ const response = await fetch(`${this.daemonOrigin}/api/review/${encodeURIComponent(this.project)}${path}`, {
88
+ method,
89
+ ...(body === undefined ? {} : { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
90
+ })
91
+ const result: unknown = await response.json()
92
+ if (!response.ok) throw new Error(isRecord(result) && typeof result['error'] === 'string' ? result['error'] : `Request failed (${response.status})`)
93
+ return result
94
+ }
95
+ }
@@ -0,0 +1,36 @@
1
+ import { render } from 'preact'
2
+ import { PROJECT_RE } from '../src/review/model'
3
+ import { ReviewClient } from './client'
4
+ import styles from './styles.css' with { type: 'text' }
5
+ import { ReviewWidget } from './widget'
6
+
7
+ const HOST_TAG = 'agent-canvas-review'
8
+
9
+ /**
10
+ * `<script src="http://localhost:19400/assets/review.js" data-project="my-app"></script>` in the application under
11
+ * review mounts the widget. Its own origin is the daemon's.
12
+ */
13
+ function mount(script: HTMLScriptElement): void {
14
+ const project = script.dataset['project'] ?? ''
15
+ if (!PROJECT_RE.test(project)) {
16
+ console.error(`agent-canvas review: data-project must match ${PROJECT_RE}, got "${project}"`)
17
+ return
18
+ }
19
+ if (document.querySelector(HOST_TAG) !== null) return
20
+
21
+ const pageStyle = document.createElement('style')
22
+ pageStyle.textContent = 'html.agent-canvas-review-picking, html.agent-canvas-review-picking * { cursor: crosshair !important; }'
23
+ document.head.append(pageStyle)
24
+
25
+ const host = document.createElement(HOST_TAG)
26
+ document.documentElement.append(host)
27
+ const root = host.attachShadow({ mode: 'open' })
28
+ const style = document.createElement('style')
29
+ style.textContent = styles
30
+ root.append(style)
31
+ render(<ReviewWidget client={new ReviewClient(new URL(script.src).origin, project)} host={host} />, root)
32
+ }
33
+
34
+ const script = document.currentScript
35
+ if (script instanceof HTMLScriptElement) mount(script)
36
+ else console.error('agent-canvas review: load review.js with a classic <script> tag')
@@ -0,0 +1,71 @@
1
+ import { MAX_HTML } from '../src/review/model'
2
+
3
+ const MAX_CHILDREN = 8
4
+ const MAX_TEXT = 80
5
+ const DROPPED_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE'])
6
+
7
+ function trimTree(node: Element, depth: number): void {
8
+ if (node.tagName.toLowerCase() === 'svg') {
9
+ node.replaceChildren()
10
+ return
11
+ }
12
+ let elements = 0
13
+ for (const child of [...node.childNodes]) {
14
+ if (child.nodeType === Node.TEXT_NODE) {
15
+ const text = child.textContent ?? ''
16
+ if (text.trim() === '') child.remove()
17
+ else if (text.length > MAX_TEXT) child.textContent = `${text.slice(0, MAX_TEXT)}…`
18
+ continue
19
+ }
20
+ if (!(child instanceof Element) || DROPPED_TAGS.has(child.tagName)) {
21
+ child.remove()
22
+ continue
23
+ }
24
+ elements++
25
+ if (depth === 0 || elements > MAX_CHILDREN) child.remove()
26
+ else trimTree(child, depth - 1)
27
+ }
28
+ if (depth === 0 && elements > 0) node.append(node.ownerDocument.createComment(` ${elements} child elements cut `))
29
+ else if (elements > MAX_CHILDREN) node.append(node.ownerDocument.createComment(` ${elements - MAX_CHILDREN} more `))
30
+ }
31
+
32
+ /** The element's markup, shallower until it fits: the agent sees structure, classes and attributes, not whole subtrees. */
33
+ export function htmlOf(element: Element): string {
34
+ for (let depth = 5; depth >= 0; depth--) {
35
+ const clone = element.cloneNode(true)
36
+ if (!(clone instanceof Element)) return ''
37
+ trimTree(clone, depth)
38
+ const html = clone.outerHTML
39
+ if (html.length <= MAX_HTML) return html
40
+ }
41
+ const shallow = element.cloneNode(false)
42
+ return shallow instanceof Element ? shallow.outerHTML.slice(0, MAX_HTML) : ''
43
+ }
44
+
45
+ function headingIn(container: Element): string | undefined {
46
+ const label = container.getAttribute('aria-label')
47
+ if (label !== null && label.trim() !== '') return label.trim()
48
+ const labelledBy = container.getAttribute('aria-labelledby')
49
+ const labelElement = labelledBy === null ? null : container.ownerDocument.getElementById(labelledBy.split(/\s+/)[0] ?? '')
50
+ const heading = labelElement ?? container.querySelector('h1, h2, h3, h4, legend, [role="heading"]')
51
+ const text = heading?.textContent?.replace(/\s+/g, ' ').trim()
52
+ return text === undefined || text === '' ? undefined : text.slice(0, 80)
53
+ }
54
+
55
+ const LANDMARKS: readonly { selector: string; kind: string }[] = [
56
+ { selector: 'dialog, [role="dialog"], [role="alertdialog"], [aria-modal="true"]', kind: 'Dialog' },
57
+ { selector: 'section, article, fieldset, form, [role="region"], [role="tabpanel"]', kind: 'Section' },
58
+ ]
59
+
60
+ /** The page heading and the nearest section and dialog around the element, outermost first. */
61
+ export function contextOf(element: Element): string | undefined {
62
+ const parts: string[] = []
63
+ const pageHeading = element.ownerDocument.querySelector('h1')?.textContent?.replace(/\s+/g, ' ').trim()
64
+ if (pageHeading !== undefined && pageHeading !== '') parts.push(`Page "${pageHeading.slice(0, 80)}"`)
65
+ for (const landmark of LANDMARKS) {
66
+ const container = element.closest(landmark.selector)
67
+ const heading = container === null ? undefined : headingIn(container)
68
+ if (heading !== undefined && !parts.includes(`Page "${heading}"`)) parts.push(`${landmark.kind} "${heading}"`)
69
+ }
70
+ return parts.length === 0 ? undefined : parts.join(' › ')
71
+ }
@@ -0,0 +1,91 @@
1
+ /** The smallest surroundings worth showing around a picked element, and the tallest an ancestor may be to count as them. */
2
+ const MIN_CONTEXT = { width: 480, height: 160 }
3
+ const MAX_CONTEXT_HEIGHT = 900
4
+ const OUTLINE = '#e11d48'
5
+
6
+ /** The nearest ancestor large enough to show where the element sits, but not a whole scrolling page. */
7
+ function surroundingsOf(element: Element): Element {
8
+ let context = element
9
+ for (let parent = element.parentElement; parent !== null && parent !== element.ownerDocument.body; parent = parent.parentElement) {
10
+ const box = context.getBoundingClientRect()
11
+ if (box.width >= MIN_CONTEXT.width && box.height >= MIN_CONTEXT.height) break
12
+ if (parent.getBoundingClientRect().height > MAX_CONTEXT_HEIGHT) break
13
+ context = parent
14
+ }
15
+ return context
16
+ }
17
+
18
+ /** Resolves once the stream has delivered `count` new frames, so the frame drawn shows the page after our changes. */
19
+ function streamFrames(video: HTMLVideoElement, count: number): Promise<void> {
20
+ return new Promise((resolve) => {
21
+ const fallback = setTimeout(resolve, 1000)
22
+ const wait = (left: number) => {
23
+ if (left === 0) {
24
+ clearTimeout(fallback)
25
+ resolve()
26
+ return
27
+ }
28
+ video.requestVideoFrameCallback(() => wait(left - 1))
29
+ }
30
+ wait(count)
31
+ })
32
+ }
33
+
34
+ function clip(box: DOMRect): { left: number; top: number; width: number; height: number } {
35
+ const left = Math.max(0, box.left)
36
+ const top = Math.max(0, box.top)
37
+ return { left, top, width: Math.min(innerWidth, box.right) - left, height: Math.min(innerHeight, box.bottom) - top }
38
+ }
39
+
40
+ /**
41
+ * Real pixels of the element in its surroundings, the element outlined, the widget hidden: the browser asks the
42
+ * reviewer to share this tab for one frame. Rendering the DOM into a picture instead gets fonts and text wrapping
43
+ * wrong, which is exactly what a visual comment is about.
44
+ *
45
+ * Call it straight from the click handler: the browser lets a page ask for the tab only during a user gesture.
46
+ */
47
+ export async function captureElement(element: Element, widgetHost: HTMLElement): Promise<string> {
48
+ // Hidden before the stream starts: its first frames already lack the widget, later ones can lag behind the page.
49
+ const previousVisibility = widgetHost.style.visibility
50
+ widgetHost.style.visibility = 'hidden'
51
+ element.scrollIntoView({ block: 'nearest', inline: 'nearest' })
52
+ let stream: MediaStream
53
+ try {
54
+ stream = await navigator.mediaDevices.getDisplayMedia({
55
+ video: { displaySurface: 'browser', width: { ideal: innerWidth * devicePixelRatio }, height: { ideal: innerHeight * devicePixelRatio } },
56
+ audio: false,
57
+ // Chrome's options for sharing the page's own tab without a picker; other browsers ignore them.
58
+ ...{ preferCurrentTab: true, selfBrowserSurface: 'include' },
59
+ })
60
+ } catch (error) {
61
+ widgetHost.style.visibility = previousVisibility
62
+ throw error
63
+ }
64
+ try {
65
+ const [track] = stream.getVideoTracks()
66
+ if (track === undefined || track.getSettings().displaySurface !== 'browser') throw new Error('Share this tab to attach a screenshot')
67
+ const video = document.createElement('video')
68
+ video.muted = true
69
+ video.srcObject = stream
70
+ await video.play()
71
+ await streamFrames(video, 2)
72
+
73
+ const context = clip(surroundingsOf(element).getBoundingClientRect())
74
+ const target = element.getBoundingClientRect()
75
+ // The captured frame is the tab's viewport at the device's pixel density.
76
+ const ratio = video.videoWidth / innerWidth
77
+ const canvas = document.createElement('canvas')
78
+ canvas.width = Math.round(context.width * ratio)
79
+ canvas.height = Math.round(context.height * ratio)
80
+ const drawing = canvas.getContext('2d')
81
+ if (drawing === null) throw new Error('Canvas is not available')
82
+ drawing.drawImage(video, context.left * ratio, context.top * ratio, canvas.width, canvas.height, 0, 0, canvas.width, canvas.height)
83
+ drawing.strokeStyle = OUTLINE
84
+ drawing.lineWidth = 3 * ratio
85
+ drawing.strokeRect((target.left - context.left) * ratio, (target.top - context.top) * ratio, target.width * ratio, target.height * ratio)
86
+ return canvas.toDataURL('image/png')
87
+ } finally {
88
+ widgetHost.style.visibility = previousVisibility
89
+ for (const track of stream.getTracks()) track.stop()
90
+ }
91
+ }
@@ -0,0 +1,103 @@
1
+ import { originalPositionFor, TraceMap } from '@jridgewell/trace-mapping'
2
+ import type { ReviewSource } from '../src/review/model'
3
+
4
+ /** A frame of a development stack, still in the bundle the page loaded. */
5
+ export interface BundleFrame {
6
+ label: string
7
+ url: string
8
+ line: number
9
+ column: number
10
+ }
11
+
12
+ const MAX_SOURCES = 6
13
+ const RESOLVE_TIMEOUT_MS = 3000
14
+ /** Frames inside React's element factory and scheduler, which every stack starts with. */
15
+ const REACT_INTERNAL =
16
+ /\bat (?:[\w$]+\.)*(?:jsxDEV|jsxs?|createElement|react_stack_bottom_frame)\b|\/node_modules\/(?:\.vite\/deps\/)?react(?:-dom)?[/_]/
17
+
18
+ function isRecord(value: unknown): value is Record<string, unknown> {
19
+ return typeof value === 'object' && value !== null
20
+ }
21
+
22
+ function nameOf(type: unknown): string | undefined {
23
+ if (typeof type === 'string') return type
24
+ if (typeof type === 'function') return type.name === '' ? undefined : type.name.replace(/(?<=[a-z])\d+$/, '')
25
+ if (isRecord(type)) return nameOf(type['type']) ?? nameOf(type['render'])
26
+ return undefined
27
+ }
28
+
29
+ /** The first application frame of a stack: where the JSX was written. */
30
+ function creationFrame(stack: string): Omit<BundleFrame, 'label'> | undefined {
31
+ for (const line of stack.split('\n').slice(1)) {
32
+ if (REACT_INTERNAL.test(line)) continue
33
+ const match = line.match(/\(?((?:https?|file):\/\/[^\s)]+):(\d+):(\d+)\)?\s*$/)
34
+ if (match?.[1] !== undefined && match[2] !== undefined && match[3] !== undefined) {
35
+ return { url: match[1], line: Number(match[2]), column: Number(match[3]) }
36
+ }
37
+ }
38
+ return undefined
39
+ }
40
+
41
+ /**
42
+ * Where the element and its nearest components were created, read off React 19 development fibers (`_debugStack`).
43
+ * Empty for other renderers and production builds.
44
+ */
45
+ export function bundleFrames(element: Element): BundleFrame[] {
46
+ const fiberKey = Object.keys(element).find((key) => key.startsWith('__reactFiber$'))
47
+ if (fiberKey === undefined) return []
48
+ const frames: BundleFrame[] = []
49
+ let fiber: unknown = Reflect.get(element, fiberKey)
50
+ for (let depth = 0; isRecord(fiber) && frames.length < MAX_SOURCES && depth < 60; depth++, fiber = fiber['return']) {
51
+ const name = nameOf(fiber['type'])
52
+ // Host elements other than the picked one, and components without a name, say nothing new.
53
+ if (name === undefined || (frames.length > 0 && typeof fiber['type'] === 'string')) continue
54
+ const stack = isRecord(fiber['_debugStack']) ? fiber['_debugStack']['stack'] : undefined
55
+ if (typeof stack !== 'string') continue
56
+ const frame = creationFrame(stack)
57
+ if (frame === undefined) continue
58
+ const owner = isRecord(fiber['_debugOwner']) ? nameOf(fiber['_debugOwner']['type']) : undefined
59
+ frames.push({ label: owner === undefined ? `<${name}>` : `<${name}> in ${owner}`, ...frame })
60
+ }
61
+ return frames
62
+ }
63
+
64
+ const traceMaps = new Map<string, Promise<TraceMap | null>>()
65
+
66
+ async function loadTraceMap(scriptUrl: string): Promise<TraceMap | null> {
67
+ const script = await fetch(scriptUrl).then((response) => response.text())
68
+ const reference = [...script.matchAll(/\/\/[#@] sourceMappingURL=(\S+)/g)].at(-1)?.[1]
69
+ if (reference === undefined) return null
70
+ const mapUrl = new URL(reference, scriptUrl).href
71
+ const map: unknown = await fetch(mapUrl).then((response) => response.json())
72
+ if (!isRecord(map) || map['version'] !== 3) return null
73
+ return new TraceMap(JSON.stringify(map), mapUrl)
74
+ }
75
+
76
+ function traceMapFor(scriptUrl: string): Promise<TraceMap | null> {
77
+ let map = traceMaps.get(scriptUrl)
78
+ if (map === undefined) {
79
+ map = loadTraceMap(scriptUrl).catch(() => null)
80
+ traceMaps.set(scriptUrl, map)
81
+ }
82
+ return map
83
+ }
84
+
85
+ function sourcePath(source: string): string {
86
+ return source.startsWith('file://') ? decodeURIComponent(new URL(source).pathname) : source
87
+ }
88
+
89
+ async function resolveFrame(frame: BundleFrame): Promise<ReviewSource | null> {
90
+ const map = await traceMapFor(frame.url)
91
+ if (map === null) return null
92
+ // Stack columns count from 1, source map columns from 0.
93
+ const position = originalPositionFor(map, { line: frame.line, column: frame.column - 1 })
94
+ if (position.source === null || position.line === null) return null
95
+ return { label: frame.label, file: sourcePath(position.source), line: position.line, column: position.column + 1 }
96
+ }
97
+
98
+ /** Source positions of the frames, through the bundles' source maps; frames a map does not cover are left out. */
99
+ export async function resolveSources(frames: readonly BundleFrame[]): Promise<ReviewSource[]> {
100
+ const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), RESOLVE_TIMEOUT_MS))
101
+ const resolved = await Promise.race([Promise.all(frames.map((frame) => resolveFrame(frame).catch(() => null))), timeout])
102
+ return resolved === null ? [] : resolved.filter((source) => source !== null)
103
+ }