retake-dev 0.4.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 (43) hide show
  1. package/LICENSE +91 -0
  2. package/README.md +198 -0
  3. package/bin/retake.js +319 -0
  4. package/package.json +87 -0
  5. package/src/code-versions.js +357 -0
  6. package/src/core.js +265 -0
  7. package/src/plugin.js +137 -0
  8. package/src/runtime/00-core.js +308 -0
  9. package/src/runtime/10-animations.js +362 -0
  10. package/src/runtime/20-media.js +157 -0
  11. package/src/runtime/30-recorder.js +315 -0
  12. package/src/runtime/32-state.js +315 -0
  13. package/src/runtime/35-network.js +789 -0
  14. package/src/runtime/36-scripts.js +129 -0
  15. package/src/runtime/37-next.js +150 -0
  16. package/src/runtime/38-observers.js +252 -0
  17. package/src/runtime/40-input.js +795 -0
  18. package/src/runtime/45-hover.js +78 -0
  19. package/src/runtime/50-engine.js +341 -0
  20. package/src/runtime/60-preview.js +437 -0
  21. package/src/runtime/65-timeline.js +289 -0
  22. package/src/runtime/66-activity.js +114 -0
  23. package/src/runtime/67-csssource.js +226 -0
  24. package/src/runtime/70-boot.js +460 -0
  25. package/src/server/api.js +285 -0
  26. package/src/server/child.js +174 -0
  27. package/src/server/detect.js +167 -0
  28. package/src/server/front.js +647 -0
  29. package/src/server/mcp.js +332 -0
  30. package/src/shell/00-state.js +85 -0
  31. package/src/shell/05-api.js +136 -0
  32. package/src/shell/10-dock.js +754 -0
  33. package/src/shell/12-checkpoint.js +121 -0
  34. package/src/shell/15-session.js +291 -0
  35. package/src/shell/20-timeline.js +734 -0
  36. package/src/shell/25-input.js +537 -0
  37. package/src/shell/30-notes.js +870 -0
  38. package/src/shell/40-code.js +80 -0
  39. package/src/shell/90-handle.js +15 -0
  40. package/src/shell/shell.css +295 -0
  41. package/src/shell/shell.html +59 -0
  42. package/types/client.d.ts +73 -0
  43. package/types/index.d.ts +111 -0
@@ -0,0 +1,870 @@
1
+ // Picking elements in the prototype. Two tools share it:
2
+ // - Comment (or hold ⌘): click an element, down to the innermost child, and
3
+ // write a note. Notes keep their moment and branch, show as numbered pins on
4
+ // the prototype and tags on the timeline, and copy as a prompt.
5
+ // - Select: pick one component; scrubbing then rewinds only that component.
6
+
7
+ let hovered = null
8
+ let lastPointer = null
9
+ let draft = null
10
+ let openNote = null
11
+ // A note is being written (a frame built behind waits to swap in until it's done).
12
+ const composing = () => !!draft
13
+ const hl = $("#wb-hl")
14
+ const scopeBox = $("#wb-scope")
15
+ const card = $("#wb-note")
16
+ // Select and Comment work on the past (the app is view-only there).
17
+ const isStill = () => !!(D.last && D.last.started && !isInteractive())
18
+ const mode = () => (!isStill() ? null : D.metaHeld ? "comment" : D.picking)
19
+
20
+ const shell = window.__retakeShell
21
+ shell.inspecting = false
22
+ // A disabled form control gets no click (pointer events still come): its
23
+ // pointerup picks it instead.
24
+ const noClick = (el) => {
25
+ try {
26
+ return !!(el && el.closest && el.closest(":is(button, input, select, textarea, option, optgroup):disabled"))
27
+ } catch {
28
+ return false
29
+ }
30
+ }
31
+ shell.inspect = (e) => {
32
+ const el = e.target.nodeType === 1 ? e.target : e.target.parentElement
33
+ if (e.type === "pointermove" || e.type === "pointerover") {
34
+ buildLayers(e.clientX, e.clientY, usable(el))
35
+ } else if (e.type === "wheel") {
36
+ if (Math.abs(e.deltaY) >= 4) cycleLayer(e.deltaY > 0 ? 1 : -1)
37
+ } else if (e.type === "keydown" && e.key === "Tab") {
38
+ cycleLayer(e.shiftKey ? -1 : 1)
39
+ } else if ((e.type === "click" || (e.type === "pointerup" && noClick(el))) && usable(el)) {
40
+ const p = pick()
41
+ if (mode() === "select") setScope(p ? p.el : usable(el))
42
+ else openComposer(p ? p.el : usable(el), { x: e.clientX, y: e.clientY }, p)
43
+ } else if (e.type === "keydown" && e.key === "Escape") {
44
+ setPicking(null)
45
+ setScope(null)
46
+ }
47
+ }
48
+ // ⌘ is read from every pointer move too, so a missed keyup (a ⌘-shortcut that
49
+ // took focus away, like a screenshot) can't leave the tool stuck on.
50
+ shell.appPointerDown = () => {
51
+ if (!card.hidden && !draft) closeCard()
52
+ }
53
+ shell.pointer = (x, y, meta) => {
54
+ lastPointer = { x, y }
55
+ if (meta !== undefined && meta !== D.metaHeld) shell.meta(meta)
56
+ }
57
+ shell.meta = (down) => {
58
+ if (D.metaHeld === down) return
59
+ D.metaHeld = down
60
+ syncPicking()
61
+ }
62
+
63
+ // ---- the layer picker: what's under the pointer, including pseudo-elements ----------
64
+ // ⌘ over the app lists the layers at that point, topmost first: each element,
65
+ // and after it any animation running on its ::before/::after (a shimmer on a
66
+ // skeleton, say). Wheel or Tab moves through them; a click picks the
67
+ // highlighted one.
68
+
69
+ let layers = [] // [{ el, pseudo, anim, name, label }]
70
+ let layerIdx = 0
71
+ let layersKey = ""
72
+ const layersEl = $("#wb-layers")
73
+ const pick = () => layers[layerIdx] || null
74
+
75
+ const shortLabel = (el) => {
76
+ const tag = el.tagName.toLowerCase()
77
+ if (el.id) return `${tag}#${el.id}`
78
+ const c = [...el.classList][0]
79
+ return c ? `${tag}.${c}` : tag
80
+ }
81
+ function animTiming(a) {
82
+ try {
83
+ const t = a.effect.getComputedTiming ? a.effect.getComputedTiming() : a.effect.getTiming()
84
+ const d = Number(t.duration) || 0
85
+ const it = t.iterations
86
+ return `${msWord(d)}${it === Infinity ? " loop" : it > 1 ? ` ×${it}` : ""}`
87
+ } catch {
88
+ return ""
89
+ }
90
+ }
91
+ const animName = (a) => a.animationName || (a.transitionProperty ? `${a.transitionProperty} transition` : "animation")
92
+
93
+ function buildLayers(x, y, fallback) {
94
+ let els = []
95
+ try {
96
+ els = D.frame.contentDocument.elementsFromPoint(x, y)
97
+ } catch {}
98
+ const seen = new Set()
99
+ const out = []
100
+ for (const raw of els) {
101
+ const el = usable(raw)
102
+ if (!el || seen.has(el)) continue
103
+ seen.add(el)
104
+ if (seen.size > 4) break
105
+ out.push({ el, pseudo: null, anim: null, name: null, label: shortLabel(el) })
106
+ let anims = []
107
+ try {
108
+ anims = el.getAnimations({ subtree: true })
109
+ } catch {}
110
+ const names = new Set()
111
+ for (const a of anims) {
112
+ const pe = a.effect && a.effect.pseudoElement
113
+ if (!pe || a.effect.target !== el) continue
114
+ const name = animName(a)
115
+ if (names.has(pe + name)) continue
116
+ names.add(pe + name)
117
+ out.push({ el, pseudo: pe, anim: a, name, label: `${pe} · ${name} · ${animTiming(a)}` })
118
+ }
119
+ }
120
+ if (!out.length && fallback) out.push({ el: fallback, pseudo: null, anim: null, name: null, label: shortLabel(fallback) })
121
+ const key = out.map((l) => l.label).join("|")
122
+ if (key !== layersKey) {
123
+ layersKey = key
124
+ layerIdx = 0
125
+ }
126
+ layers = out
127
+ hovered = pick() ? pick().el : null
128
+ }
129
+
130
+ function cycleLayer(dir) {
131
+ if (layers.length < 2) return
132
+ layerIdx = (layerIdx + dir + layers.length) % layers.length
133
+ hovered = pick().el
134
+ }
135
+
136
+ // The chip beside the pointer: the top layers, the pick highlighted.
137
+ let layersHtml = ""
138
+ function renderLayers() {
139
+ const show = mode() === "comment" && layers.length > 0 && lastPointer
140
+ layersEl.hidden = !show
141
+ if (!show) return
142
+ const top = layers.slice(0, 4)
143
+ const html = top.map((l, i) => `<div class="layer${i === layerIdx ? " on" : ""}${l.pseudo ? " pseudo" : ""}">${esc(l.label)}</div>`).join("") + (layers.length > 4 ? `<div class="more">+${layers.length - 4}</div>` : "")
144
+ if (html !== layersHtml) {
145
+ layersHtml = html
146
+ layersEl.innerHTML = html
147
+ }
148
+ const f = D.frame.getBoundingClientRect()
149
+ const w = layersEl.offsetWidth
150
+ const h = layersEl.offsetHeight
151
+ layersEl.style.left = clamp(f.left + lastPointer.x + 16, 8, innerWidth - w - 8) + "px"
152
+ layersEl.style.top = clamp(f.top + lastPointer.y + 16, 8, dock.getBoundingClientRect().top - h - 8) + "px"
153
+ }
154
+
155
+ // The page itself isn't a thing to comment on.
156
+ // Inside an SVG icon, the thing you mean is the icon's control (a button or
157
+ // link), or at least the whole <svg>, not one of its paths.
158
+ function usable(el) {
159
+ if (!el || el.tagName === "HTML" || el.tagName === "BODY") return null
160
+ if (el instanceof el.ownerDocument.defaultView.SVGElement) {
161
+ const svgRoot = el.ownerSVGElement ? el.closest("svg:not(svg svg)") || el.ownerSVGElement : el
162
+ const control = svgRoot.parentElement && svgRoot.parentElement.closest("button, a, [role=button], [role=link], label, [id]")
163
+ return control && control.tagName !== "BODY" ? control : svgRoot
164
+ }
165
+ return el
166
+ }
167
+
168
+ // Leaving the Select tool lets go of the selected component.
169
+ function setPicking(m) {
170
+ if (m !== "select") setScope(null)
171
+ D.picking = m
172
+ syncPicking()
173
+ }
174
+
175
+ function syncPicking() {
176
+ shell.inspecting = !!mode()
177
+ setToolActive(!!mode())
178
+ hovered = null
179
+ try {
180
+ const doc = D.frame.contentDocument
181
+ doc.documentElement.style.cursor = mode() ? "crosshair" : ""
182
+ if (mode() && lastPointer) hovered = usable(doc.elementFromPoint(lastPointer.x, lastPointer.y))
183
+ } catch {}
184
+ }
185
+
186
+ // Selecting the same component again clears it.
187
+ function setScope(el) {
188
+ D.scopeEl = el && el === D.scopeEl ? null : el
189
+ if (D.PT && D.last && D.last.previewing) D.PT.preview(D.last.previewAt, D.scopeEl)
190
+ }
191
+
192
+ // ---- describing an element ------------------------------------------------------
193
+
194
+ const cssEsc = (v) => (window.CSS && CSS.escape ? CSS.escape(v) : String(v).replace(/[^\w-]/g, (c) => "\\" + c))
195
+
196
+ // A valid selector for the element (ids like React's ":r1:" or "1st" are
197
+ // escaped): an id if there is one, else tag.class chains with :nth-of-type.
198
+ function cssPath(el) {
199
+ const parts = []
200
+ for (let n = el; n && n.nodeType === 1 && n.tagName !== "HTML"; n = n.parentElement) {
201
+ if (n.id) {
202
+ parts.unshift(`#${cssEsc(n.id)}`)
203
+ break
204
+ }
205
+ let part = n.tagName.toLowerCase()
206
+ const cls = [...n.classList].filter((c) => /^[a-z][\w-]*$/i.test(c)).slice(0, 2)
207
+ if (cls.length) part += "." + cls.map(cssEsc).join(".")
208
+ const same = n.parentElement ? [...n.parentElement.children].filter((c) => c.tagName === n.tagName) : []
209
+ if (same.length > 1) part += `:nth-of-type(${same.indexOf(n) + 1})`
210
+ parts.unshift(part)
211
+ if (n.tagName === "BODY") break
212
+ }
213
+ return parts.join(" > ")
214
+ }
215
+
216
+ const fiberOf = (el) => {
217
+ const key = Object.keys(el).find((k) => k.startsWith("__reactFiber$") || k.startsWith("__reactInternalInstance$"))
218
+ return key ? el[key] : null
219
+ }
220
+
221
+ // A component's name, through memo() and forwardRef() wrappers.
222
+ function componentName(type) {
223
+ for (let t = type, i = 0; t && i < 4; i++) {
224
+ if (typeof t === "function") return t.displayName || t.name || null
225
+ if (typeof t !== "object") return null
226
+ if (t.displayName) return t.displayName
227
+ t = t.render || t.type // forwardRef keeps render, memo keeps type
228
+ }
229
+ return null
230
+ }
231
+
232
+ // Library wrappers that sit between an element and the component that wrote
233
+ // it (framer-motion presence, Radix slots, providers). Not what a note means.
234
+ const INTERNAL = /^(PopChild|PopChildMeasure|PresenceChild|AnimatePresence|MotionComponent|MotionDOMComponent|LayoutGroup|LazyMotion|MotionConfig|Slot|SlotClone|Slottable|Primitive\b.*|Presence|Portal|DismissableLayer|FocusScope|RemoveScroll|Suspense|StrictMode|Fragment|.*Provider|.*Consumer|.*Context)$/
235
+
236
+ // Nearest components, innermost first: the components that rendered the
237
+ // element (React's owner chain), which skips wrappers it was merely passed
238
+ // through; failing that, its ancestors.
239
+ function reactComponents(el) {
240
+ const names = []
241
+ const add = (name) => name && /^[A-Z]/.test(name) && !INTERNAL.test(name) && !names.includes(name) && names.push(name)
242
+ const f0 = fiberOf(el)
243
+ for (let o = f0 && f0._debugOwner, i = 0; o && i < 30 && names.length < 4; o = o._debugOwner || o.owner, i++) add(componentName(o.type) || o.name)
244
+ for (let f = f0; f && names.length < 4; f = f.return) if (!names.length || names.length < 2) add(componentName(f.type))
245
+ return names
246
+ }
247
+
248
+ // Where the element was written: React's debug info. React 18 and older keep
249
+ // _debugSource (already source lines). React 19 keeps a stack (_debugStack)
250
+ // whose first app frame is the JSX; those are lines of the file as served
251
+ // (types stripped, JSX compiled), which mapSource() turns back into source
252
+ // lines with the module's own source map.
253
+ function sourceOf(el) {
254
+ for (let f = fiberOf(el), i = 0; f && i < 12; f = f.return, i++) {
255
+ const src = f._debugSource
256
+ if (src && src.fileName) return { file: shortPath(src.fileName), line: src.lineNumber || null, mapped: true }
257
+ const stack = f._debugStack && (f._debugStack.stack || String(f._debugStack))
258
+ if (stack) {
259
+ for (const line of stack.split("\n").slice(1)) {
260
+ const m = line.match(/(\S+?):(\d+):(\d+)\)?\s*$/)
261
+ if (!m || /node_modules|\/\.vite\/deps\/|react-dom|react\.development|jsx-dev-runtime/.test(m[1])) continue
262
+ const url = m[1].replace(/^.*?\(/, "")
263
+ // A server component's frame (React 19 dev: about://React/Server/file:///…/.next/…chunk.js)
264
+ // is a compiled chunk on the server: nothing the dock can fetch or map (F76).
265
+ if (!/^https?:\/\//.test(url)) continue
266
+ const src = { file: shortPath(url), line: Number(m[2]), col: Number(m[3]), url, mapped: false }
267
+ mapSource(src)
268
+ return src
269
+ }
270
+ }
271
+ }
272
+ return null
273
+ }
274
+ function shortPath(p) {
275
+ try {
276
+ if (/^https?:/.test(p)) p = new URL(p).pathname
277
+ } catch {}
278
+ return p.replace(/\?.*$/, "").replace(/^\/@fs/, "")
279
+ }
280
+
281
+ // ---- source maps: served line → source line ----------------------------------------
282
+
283
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
284
+ function decodeMappings(mappings) {
285
+ const lines = []
286
+ let src = 0, oLine = 0, oCol = 0
287
+ for (const text of mappings.split(";")) {
288
+ const segs = []
289
+ let gCol = 0
290
+ for (const seg of text.split(",")) {
291
+ if (!seg) continue
292
+ const v = []
293
+ for (let i = 0, shift = 0, acc = 0; i < seg.length; i++) {
294
+ const d = B64.indexOf(seg[i])
295
+ acc += (d & 31) << shift
296
+ if (d & 32) shift += 5
297
+ else {
298
+ v.push(acc & 1 ? -(acc >> 1) : acc >> 1)
299
+ acc = shift = 0
300
+ }
301
+ }
302
+ gCol += v[0]
303
+ if (v.length >= 4) {
304
+ src += v[1]
305
+ oLine += v[2]
306
+ oCol += v[3]
307
+ segs.push([gCol, src, oLine, oCol])
308
+ }
309
+ }
310
+ lines.push(segs)
311
+ }
312
+ return lines
313
+ }
314
+
315
+ const sourceMaps = new Map() // module url (without query) → Promise<{ map, lines } | null>
316
+ function loadMap(url) {
317
+ const key = url.replace(/\?.*$/, "")
318
+ if (!sourceMaps.has(key))
319
+ sourceMaps.set(
320
+ key,
321
+ (async () => {
322
+ try {
323
+ if (!/^https?:\/\//.test(url)) return null
324
+ const code = await (await fetch(url)).text()
325
+ const m = code.match(/\/\/[#@] sourceMappingURL=(\S+)\s*$/)
326
+ if (!m) return null
327
+ let json
328
+ if (m[1].startsWith("data:")) {
329
+ const b64 = m[1].slice(m[1].indexOf(",") + 1)
330
+ json = /;base64/.test(m[1].slice(0, m[1].indexOf(","))) ? new TextDecoder().decode(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))) : decodeURIComponent(b64)
331
+ } else json = await (await fetch(new URL(m[1], url))).text()
332
+ const map = JSON.parse(json)
333
+ return { map, lines: decodeMappings(map.mappings || "") }
334
+ } catch {
335
+ return null
336
+ }
337
+ })(),
338
+ )
339
+ return sourceMaps.get(key)
340
+ }
341
+
342
+ // Rewrites src.line (and file, if the map names another) in place, so the
343
+ // note that holds it is saved with the source line.
344
+ async function mapSource(src) {
345
+ const m = await loadMap(src.url)
346
+ const segs = m && m.lines[src.line - 1]
347
+ if (segs && segs.length) {
348
+ let best = segs[0]
349
+ for (const s of segs) if (s[0] <= src.col - 1) best = s
350
+ src.line = best[2] + 1
351
+ const name = m.map.sources && m.map.sources[best[1]]
352
+ if (name && !name.startsWith("\0") && shortPath(name).split("/").pop() !== src.file.split("/").pop()) src.file = shortPath(name)
353
+ }
354
+ src.mapped = true
355
+ delete src.col
356
+ delete src.url
357
+ }
358
+
359
+ // The computed styles an agent would ask about first. Defaults are left out.
360
+ const KEY_STYLES = ["display", "position", "width", "height", "margin", "padding", "color", "background-color", "font-size", "font-weight", "border-radius", "opacity", "transform", "transition", "animation-name", "z-index", "gap"]
361
+ const BORING = new Set(["none", "normal", "auto", "0px", "static", "visible", "rgba(0, 0, 0, 0)", "all 0s ease 0s", "0s", ""])
362
+ function keyStyles(el) {
363
+ const out = {}
364
+ try {
365
+ const cs = el.ownerDocument.defaultView.getComputedStyle(el)
366
+ for (const p of KEY_STYLES) {
367
+ const v = cs.getPropertyValue(p)
368
+ if (!BORING.has(v) && !(p === "opacity" && v === "1")) out[p] = p === "transition" ? summariseTransition(v) : v
369
+ }
370
+ } catch {}
371
+ return out
372
+ }
373
+
374
+ // Tailwind's transition-colors lists ten properties with one timing; say
375
+ // that once: "10 properties 0.15s cubic-bezier(0.4, 0, 0.2, 1)".
376
+ function summariseTransition(v) {
377
+ const parts = v.split(/,(?![^(]*\))/).map((x) => x.trim())
378
+ if (parts.length <= 3) return v
379
+ const groups = new Map()
380
+ for (const part of parts) {
381
+ const [prop, ...rest] = part.split(/\s+(?![^(]*\))/)
382
+ const timing = rest.join(" ")
383
+ groups.set(timing, [...(groups.get(timing) || []), prop])
384
+ }
385
+ return [...groups].map(([timing, props]) => (props.length > 2 ? `${props.length} properties ${timing}` : `${props.join(", ")} ${timing}`)).join("; ")
386
+ }
387
+
388
+ function describe(el) {
389
+ const tag = el.tagName.toLowerCase()
390
+ // Tag plus its id or first class: short enough to read whole.
391
+ const id = el.id ? `#${el.id}` : ""
392
+ const cls = id ? "" : [...el.classList].slice(0, 1).map((c) => `.${c}`).join("")
393
+ const text = (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 80)
394
+ const r = el.getBoundingClientRect()
395
+ let page = "/"
396
+ try {
397
+ const u = new URL(D.frame.contentWindow.location.href)
398
+ u.searchParams.delete("__wb")
399
+ page = u.pathname + u.search
400
+ } catch {}
401
+ return {
402
+ label: `<${tag}${id}${cls}>`,
403
+ text,
404
+ selector: cssPath(el),
405
+ components: reactComponents(el),
406
+ source: sourceOf(el),
407
+ classes: [...el.classList],
408
+ styles: keyStyles(el),
409
+ rect: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) },
410
+ page,
411
+ }
412
+ }
413
+
414
+ // What was moving on the element at t: on it or inside it, else on one of its
415
+ // nearest ancestors (a label inside a sliding button). Never a clip from
416
+ // elsewhere on the page: a note on a still element says nothing is animating.
417
+ const CLIP_ANCESTORS = 4
418
+ function clipFor(t, selector, el) {
419
+ let hit = clipAt(t, selector)
420
+ for (let n = el && el.parentElement, i = 0; !hit && n && n.tagName !== "BODY" && i < CLIP_ANCESTORS; n = n.parentElement, i++) hit = clipAt(t, cssPath(n))
421
+ if (!hit || !hit.clip) return null
422
+ const c = hit.clip
423
+ const end = c.end == null ? null : c.end
424
+ return { id: c.id, offset: Math.round(hit.offset), duration: end == null ? null : Math.round(end - c.start), label: c.label || c.property || c.kind, kind: c.kind, property: c.property || null, selector: c.selector || null }
425
+ }
426
+ const clipPhrase = (c) =>
427
+ `${c.offset}ms into ${c.duration != null ? `a ${c.duration}ms` : "a running"} ${c.property ? `${c.property} ` : ""}${c.kind === "transition" ? "transition" : c.kind === "css-animation" ? `animation (${c.label})` : c.kind === "waapi" ? "animation" : c.label || "animation"}${c.selector ? ` on ${c.selector}` : ""}`
428
+
429
+ // The note as a prompt for a coding agent: what to change, where it is in
430
+ // the code, and the moment it's about.
431
+ function prompt(n) {
432
+ const b = branchById(n.branchId)
433
+ const start = D.last ? D.last.start : 0
434
+ const parent = b && branchById(b.parentId)
435
+ const el = n.el
436
+ const lines = [`## ${n.text}`, "", `Page: ${el.page}`, `Element: ${el.label}${el.text ? ` "${el.text}"` : ""}`, `Selector: ${el.selector}`]
437
+ if (el.components.length) lines.push(`Component: ${el.components.join(" < ")}`)
438
+ if (el.pseudo) lines.push(`Animation: ${pseudoSentence(el)}`)
439
+ if (el.cssSource) lines.push(`CSS: ${el.cssSource.file}${el.cssSource.line ? `:${el.cssSource.line}` : ""}${el.cssSource.keyframes && el.cssSource.keyframes.line ? ` (@keyframes ${el.cssSource.keyframes.name} at ${el.cssSource.keyframes.file}:${el.cssSource.keyframes.line})` : ""}`)
440
+ if (el.source) lines.push(`Source: ${el.source.file}${el.source.line ? `:${el.source.line}` : ""}`)
441
+ if (el.classes && el.classes.length) lines.push(`Classes: ${el.classes.join(" ")}`)
442
+ const st = el.styles || {}
443
+ if (Object.keys(st).length) lines.push(`Computed: ${Object.entries(st).map(([k, v]) => `${k}: ${v}`).join("; ")}`)
444
+ lines.push(`Size: ${el.rect.w}×${el.rect.h} at (${el.rect.x}, ${el.rect.y})`)
445
+ lines.push(`Moment: ${fmt(n.t - start)} into the recording${n.clip ? `, ${clipPhrase(n.clip)}` : ", nothing animating"}`)
446
+ lines.push(`Timeline: "${b ? b.name : "Timeline"}"${parent ? `, branched from "${parent.name}" at ${fmt(b.forkAt - start)}` : ""}`)
447
+ const agent = (n.replies || []).filter((r) => r.from === "agent")
448
+ if (agent.length) lines.push("", "Earlier replies:", ...agent.map((r) => `- ${r.text}`))
449
+ return lines.join("\n")
450
+ }
451
+
452
+ async function copy(text, btn) {
453
+ try {
454
+ await navigator.clipboard.writeText(text)
455
+ } catch {
456
+ const ta = document.createElement("textarea")
457
+ ta.value = text
458
+ document.body.appendChild(ta)
459
+ ta.select()
460
+ document.execCommand("copy")
461
+ ta.remove()
462
+ }
463
+ if (btn) {
464
+ const was = btn.textContent
465
+ btn.textContent = "Copied"
466
+ setTimeout(() => (btn.textContent = was), 1200)
467
+ }
468
+ }
469
+
470
+ // ---- notes as the server keeps them (CONTRACT.md "Note") --------------------------
471
+
472
+ const newNoteId = () => "n" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
473
+
474
+ function noteForServer(n) {
475
+ return {
476
+ id: n.id,
477
+ branchId: n.branchId,
478
+ t: n.t,
479
+ clip: n.clip || null,
480
+ selector: n.el.selector,
481
+ component: n.el.components[0] || null,
482
+ source: n.el.source || null,
483
+ classes: n.el.classes || [],
484
+ rect: n.el.rect,
485
+ text: n.text,
486
+ status: n.status || "pending",
487
+ replies: n.replies || [],
488
+ el: n.el,
489
+ }
490
+ }
491
+
492
+ function noteFromServer(n) {
493
+ const el = n.el || {
494
+ label: n.selector || "element",
495
+ text: "",
496
+ selector: n.selector || "",
497
+ components: n.component ? [n.component] : [],
498
+ rect: n.rect || { x: 0, y: 0, w: 0, h: 0 },
499
+ page: "/",
500
+ classes: n.classes || [],
501
+ source: n.source || null,
502
+ }
503
+ return { id: n.id, branchId: n.branchId, t: n.t, text: n.text || "", el, clip: n.clip || null, status: n.status || "pending", replies: n.replies || [] }
504
+ }
505
+
506
+ // A change from outside (an agent replied, or marked it resolved).
507
+ function mergeNote(n) {
508
+ const mine = D.notes.find((x) => String(x.id) === String(n.id))
509
+ if (!mine) {
510
+ if (branchById(n.branchId)) D.notes.push(noteFromServer(n))
511
+ return
512
+ }
513
+ if (n.status) mine.status = n.status
514
+ if (Array.isArray(n.replies)) mine.replies = n.replies
515
+ else if (n.reply) mine.replies = [...(mine.replies || []), typeof n.reply === "string" ? { from: "agent", text: n.reply, at: Date.now() } : n.reply]
516
+ if (openNote === mine) showNote(mine)
517
+ }
518
+
519
+ // ---- the note card -----------------------------------------------------------------
520
+
521
+ // Where a note points, on screen: the spot that was clicked, kept relative to
522
+ // its element (so it follows the element if that moved), else to where the
523
+ // element was.
524
+ function anchorOf(el) {
525
+ const f = D.frame.getBoundingClientRect()
526
+ let r = null
527
+ try {
528
+ const node = el.selector && D.frame.contentDocument.querySelector(el.selector)
529
+ if (node) r = node.getBoundingClientRect()
530
+ } catch {}
531
+ if (!r || (!r.width && !r.height)) r = { x: el.rect.x, y: el.rect.y, width: el.rect.w, height: el.rect.h }
532
+ const at = el.at || { dx: 0.5, dy: 0.5 }
533
+ return { x: f.left + r.x + at.dx * r.width, y: f.top + r.y + at.dy * r.height }
534
+ }
535
+
536
+ // Next to its spot, with the arrow on it: below if it fits, else above,
537
+ // right, left; always kept on screen and clear of the dock.
538
+ function placeCard(point) {
539
+ card.insertAdjacentHTML("afterbegin", '<i class="arrow"></i>')
540
+ const arrow = card.querySelector(".arrow")
541
+ const w = card.offsetWidth
542
+ const h = card.offsetHeight
543
+ const M = 8
544
+ const GAP = 14
545
+ const bottom = dock.getBoundingClientRect().top - M
546
+ const right = innerWidth - M
547
+ const cx = clamp(point.x - w / 2, M, right - w)
548
+ const cy = clamp(point.y - 32, M, bottom - h)
549
+ const sides = [
550
+ { side: "below", left: cx, top: point.y + GAP, fits: (p) => p.top + h <= bottom },
551
+ { side: "above", left: cx, top: point.y - GAP - h, fits: (p) => p.top >= M },
552
+ { side: "right", left: point.x + GAP, top: cy, fits: (p) => p.left + w <= right },
553
+ { side: "left", left: point.x - GAP - w, top: cy, fits: (p) => p.left >= M },
554
+ ]
555
+ const pick = sides.find((p) => p.fits(p)) || { side: "below", left: cx, top: clamp(point.y + GAP, M, bottom - h) }
556
+ card.dataset.side = pick.side
557
+ card.style.left = pick.left + "px"
558
+ card.style.top = pick.top + "px"
559
+ if (pick.side === "below" || pick.side === "above") {
560
+ arrow.style.left = clamp(point.x - pick.left - 6, 12, w - 24) + "px"
561
+ arrow.style.top = ""
562
+ } else {
563
+ arrow.style.top = clamp(point.y - pick.top - 6, 12, h - 24) + "px"
564
+ arrow.style.left = ""
565
+ }
566
+ }
567
+
568
+ function pseudoInfo(layer) {
569
+ const a = layer.anim
570
+ let timing = {}
571
+ try {
572
+ timing = a.effect.getTiming()
573
+ } catch {}
574
+ return {
575
+ pseudoElement: layer.pseudo,
576
+ animationName: layer.name,
577
+ keyframes: a.animationName || null,
578
+ duration: Number(timing.duration) || 0,
579
+ iterations: timing.iterations === Infinity ? "infinite" : timing.iterations || 1,
580
+ delay: Number(timing.delay) || 0,
581
+ }
582
+ }
583
+ // The clip of that pseudo-element animation at t (the runtime keys it by the
584
+ // host's selector plus the pseudo-element).
585
+ function pseudoClip(t, desc) {
586
+ const want = (c) => (c.pseudoElement || null) === desc.pseudo.pseudoElement
587
+ const clips = timeline().clips.filter((c) => want(c) && c.start <= t && (c.end == null || c.end >= t))
588
+ const c = clips.find((c) => c.selector === desc.selector + desc.pseudo.pseudoElement) || clips[0]
589
+ if (!c) return null
590
+ return { id: c.id, offset: Math.round(t - c.start), duration: c.end == null ? null : Math.round(c.end - c.start), label: c.label || desc.pseudo.animationName, kind: c.kind, property: c.property || null, selector: c.selector || null }
591
+ }
592
+ // Where the animation is written in the CSS (the runtime reads Vite's
593
+ // source maps); filled in when it answers.
594
+ function lookUpCss(desc, anim) {
595
+ const pt = D.PT
596
+ if (!pt || typeof pt.cssSourceFor !== "function") return
597
+ Promise.resolve()
598
+ .then(() => pt.cssSourceFor(anim))
599
+ .then((r) => {
600
+ if (!r || (!r.file && !r.line)) return
601
+ // No file: it's in a <style> in the page itself.
602
+ const where = (f) => (f ? shortPath(f) : `${desc.page || "/"} <style>`)
603
+ desc.cssSource = { file: where(r.file), line: r.line || null, keyframes: r.keyframes ? { name: r.keyframes.name, file: where(r.keyframes.file || r.file), line: r.keyframes.line || null } : null }
604
+ })
605
+ .catch(() => {})
606
+ }
607
+ // "The ::after shimmer animation (keyframes `shimmer`, 1.2s, infinite) on .card"
608
+ function pseudoSentence(el) {
609
+ const p = el.pseudo
610
+ const host = el.classes && el.classes.length ? `.${el.classes[0]}` : el.selector
611
+ const bits = [p.keyframes ? `keyframes \`${p.keyframes}\`` : null, p.duration ? msWord(p.duration) : null, p.iterations === "infinite" ? "infinite" : p.iterations > 1 ? `${p.iterations} times` : null].filter(Boolean)
612
+ return `The ${p.pseudoElement} ${p.animationName} animation${bits.length ? ` (${bits.join(", ")})` : ""} on ${host}`
613
+ }
614
+
615
+ // The card's header: the timeline's colour, the moment, the element.
616
+ function meta(t, el, branchId = D.activeId) {
617
+ const start = D.last ? D.last.start : 0
618
+ const b = branchById(branchId)
619
+ return `<div class="note-meta"><span class="dot" style="background:${colorOf(b)}" title="${esc(b ? b.name : "")}"></span><span class="time">${fmt(t - start)}</span><span class="el" title="${esc(el.selector)}">${esc(el.label)}</span></div>`
620
+ }
621
+
622
+ // "140ms into the opacity transition", "2.3s into fm-note-rise (opacity, transform)".
623
+ function shortClip(c) {
624
+ const at = msWord(Math.max(0, c.offset || 0))
625
+ if (c.kind === "transition") return `${at} into the ${c.property ? c.property + " " : ""}transition`
626
+ const name = c.label || "an animation"
627
+ return `${at} into ${name}${c.property && c.property !== name ? ` (${c.property})` : ""}`
628
+ }
629
+
630
+ // Everything else about the element, folded away.
631
+ function details(el) {
632
+ const rows = [["Selector", el.selector]]
633
+ if (el.components && el.components.length) rows.push(["Component", el.components.join(" < ")])
634
+ if (el.source) rows.push(["Source", `${el.source.file}${el.source.line ? ":" + el.source.line : ""}`])
635
+ if (el.pseudo) rows.push(["Animation", pseudoSentence(el)])
636
+ if (el.cssSource) rows.push(["CSS", `${el.cssSource.file}${el.cssSource.line ? ":" + el.cssSource.line : ""}`])
637
+ if (el.classes && el.classes.length) rows.push(["Classes", el.classes.join(" ")])
638
+ return `<details class="details"><summary>Details</summary><dl>${rows.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join("")}</dl></details>`
639
+ }
640
+
641
+ function openComposer(el, point, layer) {
642
+ const s = D.last
643
+ if (!D.PT || !s || !isStill()) return
644
+ const t = s.previewing ? s.previewAt : s.now
645
+ const desc = describe(el)
646
+ // The spot that was clicked, as a fraction of the element.
647
+ const r = el.getBoundingClientRect()
648
+ desc.at = point && r.width && r.height ? { dx: clamp((point.x - r.x) / r.width, 0, 1), dy: clamp((point.y - r.y) / r.height, 0, 1) } : { dx: 0.5, dy: 0.5 }
649
+ // A picked ::before/::after animation: the note is about that.
650
+ if (layer && layer.pseudo) {
651
+ desc.pseudo = pseudoInfo(layer)
652
+ desc.label = `<${shortLabel(el)}>${layer.pseudo}`
653
+ lookUpCss(desc, layer.anim)
654
+ }
655
+ const clip = desc.pseudo ? pseudoClip(t, desc) : clipFor(t, desc.selector, el)
656
+ draft = { el: desc, t, clip }
657
+ openNote = null
658
+ card.innerHTML = `${meta(t, draft.el)}<textarea rows="3" placeholder="What should change here?"></textarea>
659
+ <div class="note-actions"><button data-note-a="cancel">Cancel</button><button data-note-a="save" class="primary">Add note</button></div>`
660
+ card.hidden = false
661
+ placeCard(anchorOf(draft.el))
662
+ const ta = card.querySelector("textarea")
663
+ setTimeout(() => ta.focus())
664
+ // Enter saves and folds the note down to its pin; Shift+Enter is a new line.
665
+ ta.addEventListener("keydown", (e) => {
666
+ if (e.key === "Enter" && !e.shiftKey) {
667
+ e.preventDefault()
668
+ saveDraft()
669
+ }
670
+ if (e.key === "Escape") closeCard()
671
+ })
672
+ }
673
+
674
+ function saveDraft() {
675
+ const text = card.querySelector("textarea").value.trim()
676
+ if (draft && text) D.notes.push({ id: newNoteId(), t: draft.t, branchId: D.activeId, text, el: draft.el, clip: draft.clip, status: "pending", replies: [] })
677
+ closeCard()
678
+ // Back to the Hand so the next click is on the prototype, not another note.
679
+ setPicking(null)
680
+ }
681
+
682
+ function showNote(n) {
683
+ // Re-showing the same note (a reply came in) keeps Details as it was.
684
+ const keepOpen = openNote === n && !!card.querySelector(".details[open]")
685
+ openNote = n
686
+ draft = null
687
+ const replies = (n.replies || [])
688
+ .map((r) => `<div class="reply ${r.from === "agent" ? "agent" : "user"}"><span class="who">${r.from === "agent" ? "Agent" : "You"}</span>${esc(r.text)}</div>`)
689
+ .join("")
690
+ const status = n.status || "pending"
691
+ card.innerHTML = `${meta(n.t, n.el, n.branchId)}<p class="note-text">${esc(n.text)}</p>
692
+ ${n.clip ? `<div class="note-clip">${esc(shortClip(n.clip))}</div>` : ""}
693
+ ${details(n.el)}
694
+ ${replies ? `<div class="replies">${replies}</div>` : ""}
695
+ <div class="note-actions"><span class="status s-${esc(status)}">${esc(status[0].toUpperCase() + status.slice(1))}</span>
696
+ <button data-note-a="delete" class="quiet">Delete</button>
697
+ <button data-note-a="resolve">${status === "resolved" ? "Reopen" : "Resolve"}</button>
698
+ <button data-note-a="copy" class="primary">Copy for agent</button></div>`
699
+ if (keepOpen) card.querySelector(".details").open = true
700
+ card.hidden = false
701
+ placeCard(anchorOf(n.el))
702
+ }
703
+
704
+ // Tell the server about a status change (the session save carries it too).
705
+ function patchNote(n, body) {
706
+ if (!net.on) return
707
+ api("PATCH", `notes/${encodeURIComponent(n.id)}`, body).catch(() => {})
708
+ }
709
+
710
+ function closeCard() {
711
+ card.hidden = true
712
+ draft = null
713
+ openNote = null
714
+ }
715
+
716
+ // Returns true when the click was one of ours.
717
+ function handleNoteClick(b) {
718
+ const act = b.dataset.noteA
719
+ if (act === "save") saveDraft()
720
+ if (act === "cancel") closeCard()
721
+ if (act === "copy" && openNote) {
722
+ copy(prompt(openNote), b)
723
+ // Copied: fold the note back to its pin.
724
+ setTimeout(closeCard, 700)
725
+ }
726
+ if (act === "resolve" && openNote) {
727
+ openNote.status = openNote.status === "resolved" ? "pending" : "resolved"
728
+ patchNote(openNote, { status: openNote.status })
729
+ showNote(openNote)
730
+ }
731
+ if (act === "delete" && openNote) {
732
+ D.notes = D.notes.filter((n) => n !== openNote)
733
+ closeCard()
734
+ }
735
+ if (b.dataset.note) goToNoteId(b.dataset.note)
736
+ return !!(act || b.dataset.note)
737
+ }
738
+
739
+ // From a pin, the timeline or the list: go to the note's moment and open it.
740
+ function goToNoteId(id) {
741
+ const n = D.notes.find((x) => String(x.id) === String(id))
742
+ if (!n) return
743
+ if (n.branchId !== D.activeId) switchTo(n.branchId, n.t)
744
+ else if (D.PT && D.last && Math.abs(shownTime(D.last) - n.t) > 5) goTo(n.t)
745
+ showNote(n)
746
+ }
747
+
748
+ // ---- the lens: ⌘ held over an element shows its own animations on the lane ----------
749
+
750
+ // The runtime's clipsFor(element) when it has it; else the clips whose
751
+ // element is this one or inside it.
752
+ function clipsOfElement(el) {
753
+ const pt = D.PT
754
+ if (pt && typeof pt.clipsFor === "function") {
755
+ try {
756
+ return pt.clipsFor(el) || []
757
+ } catch {}
758
+ }
759
+ const out = []
760
+ const doc = el.ownerDocument
761
+ for (const c of timeline().clips) {
762
+ if (!c.selector) continue
763
+ let node = null
764
+ try {
765
+ node = doc.querySelector(c.selector)
766
+ } catch {}
767
+ if (node && (node === el || el.contains(node))) out.push(c)
768
+ }
769
+ return out
770
+ }
771
+
772
+ function updateLens() {
773
+ const p = pick()
774
+ const target = D.metaHeld && mode() === "comment" && hovered && hovered.isConnected ? hovered : null
775
+ if (!target) {
776
+ D.lens = null
777
+ return
778
+ }
779
+ const want = `${cssPath(target)}${p && p.el === target && p.pseudo ? p.pseudo + p.name : ""}`
780
+ if (D.lens && D.lens.want === want) return
781
+ let clips = clipsOfElement(target)
782
+ // A picked ::after shimmer: only its own activity.
783
+ if (p && p.el === target && p.pseudo) clips = clips.filter((c) => (c.pseudoElement || null) === p.pseudo && (!p.name || !c.label || c.label === p.name || c.label.startsWith(p.name)))
784
+ D.lens = { el: target, clips, want, key: `${want}:${clips.length}` }
785
+ }
786
+
787
+ // ---- drawing on the prototype --------------------------------------------------------
788
+
789
+ const pinEls = new Map()
790
+ const boxAt = (el, box) => {
791
+ const f = D.frame.getBoundingClientRect()
792
+ const r = el.getBoundingClientRect()
793
+ Object.assign(box.style, { display: "block", left: f.left + r.left + "px", top: f.top + r.top + "px", width: r.width + "px", height: r.height + "px" })
794
+ }
795
+
796
+ function renderExtras(s) {
797
+ // Leaving a still moment (recording) drops back to the Hand.
798
+ if (D.picking && !isStill()) setPicking(null)
799
+ for (const t of /** @type {NodeListOf<HTMLButtonElement>} */ (document.querySelectorAll('[data-tool="select"], [data-tool="comment"]'))) {
800
+ t.disabled = !isStill()
801
+ t.classList.toggle("disabled", !isStill())
802
+ }
803
+ const tool = mode() || "hand"
804
+ for (const t of /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll("[data-tool]"))) {
805
+ t.classList.toggle("on", t.dataset.tool === tool)
806
+ t.setAttribute("aria-selected", String(t.dataset.tool === tool))
807
+ }
808
+
809
+ updateLens()
810
+ renderLayers()
811
+ const target = mode() && hovered && hovered.isConnected ? hovered : null
812
+ hl.style.display = target ? "block" : "none"
813
+ if (target) {
814
+ boxAt(target, hl)
815
+ // A picked ::before/::after: its host, dashed, labelled with the pseudo.
816
+ const p = pick()
817
+ const pseudo = p && p.el === target && p.pseudo
818
+ hl.className = (mode() === "select" ? "select" : "") + (pseudo ? " pseudo" : "")
819
+ hl.dataset.label = pseudo ? `${shortLabel(target)}${p.pseudo}` : `<${shortLabel(target)}>`
820
+ }
821
+ if (D.scopeEl && D.scopeEl.isConnected) boxAt(D.scopeEl, scopeBox)
822
+ else scopeBox.style.display = "none"
823
+
824
+ // Numbered pins on the notes of the branch in view.
825
+ let doc = null
826
+ try {
827
+ doc = D.frame.contentDocument
828
+ } catch {}
829
+ const f = D.frame.getBoundingClientRect()
830
+ const seen = new Set()
831
+ D.notes.forEach((n, i) => {
832
+ if (n.branchId !== D.activeId || !doc || s.seeking) return
833
+ const p = anchorOf(n.el)
834
+ let pin = pinEls.get(n.id)
835
+ if (!pin) {
836
+ pin = document.createElement("button")
837
+ pin.className = "canvas-pin"
838
+ pin.dataset.note = n.id
839
+ $("#wb-pins").appendChild(pin)
840
+ pinEls.set(n.id, pin)
841
+ }
842
+ pin.textContent = String(i + 1)
843
+ // A note made at another moment: its pin is dimmed (the element may look
844
+ // different now); clicking it goes there.
845
+ pin.classList.toggle("away", Math.abs(n.t - shownTime(s)) > 60)
846
+ pin.style.background = NOTE_FILL[n.status] || NOTE_FILL.pending
847
+ pin.title = n.text
848
+ pin.style.left = p.x + "px"
849
+ pin.style.top = p.y + "px"
850
+ seen.add(n.id)
851
+ })
852
+ paintCount(D.notes.filter((n) => n.branchId === D.activeId).length)
853
+ for (const [id, pin] of pinEls) {
854
+ if (seen.has(id)) continue
855
+ pin.remove()
856
+ pinEls.delete(id)
857
+ }
858
+ }
859
+
860
+ // ---- the count: how many notes this timeline has, as a badge on the notes icon ----------
861
+
862
+ const countEl = $(".notes-count")
863
+ let shownCount = -1
864
+ function paintCount(n) {
865
+ if (n === shownCount) return
866
+ shownCount = n
867
+ countEl.querySelector(".n").textContent = n > 99 ? "99+" : String(n)
868
+ countEl.classList.toggle("has", n > 0)
869
+ countEl.setAttribute("aria-label", n === 0 ? "No notes on this timeline" : n === 1 ? "1 note on this timeline" : `${n} notes on this timeline`)
870
+ }