retake-dev 0.5.0 → 0.5.1

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.
@@ -28,16 +28,30 @@ const noClick = (el) => {
28
28
  return false
29
29
  }
30
30
  }
31
+ // The element an event is really about: inside an open shadow root, the
32
+ // element clicked (the event's target is retargeted to the host).
33
+ const innerTarget = (e) => {
34
+ try {
35
+ const p = e.composedPath && e.composedPath()
36
+ if (p && p[0] && p[0].nodeType) return p[0].nodeType === 1 ? p[0] : p[0].parentElement
37
+ } catch {}
38
+ return e.target && (e.target.nodeType === 1 ? e.target : e.target.parentElement)
39
+ }
31
40
  shell.inspect = (e) => {
32
- const el = e.target.nodeType === 1 ? e.target : e.target.parentElement
41
+ const el = innerTarget(e)
33
42
  if (e.type === "pointermove" || e.type === "pointerover") {
43
+ lastPointer = { x: e.clientX, y: e.clientY }
34
44
  buildLayers(e.clientX, e.clientY, usable(el))
35
45
  } else if (e.type === "wheel") {
36
46
  if (Math.abs(e.deltaY) >= 4) cycleLayer(e.deltaY > 0 ? 1 : -1)
37
47
  } else if (e.type === "keydown" && e.key === "Tab") {
38
48
  cycleLayer(e.shiftKey ? -1 : 1)
39
49
  } else if ((e.type === "click" || (e.type === "pointerup" && noClick(el))) && usable(el)) {
50
+ // The list the highlight showed, unless it was built somewhere else (F90):
51
+ // then the one at the click.
52
+ if (!layersAt || layersAt.x !== e.clientX || layersAt.y !== e.clientY) buildLayers(e.clientX, e.clientY, usable(el))
40
53
  const p = pick()
54
+ if (p) p.picked = pickedInfo(p)
41
55
  if (mode() === "select") setScope(p ? p.el : usable(el))
42
56
  else openComposer(p ? p.el : usable(el), { x: e.clientX, y: e.clientY }, p)
43
57
  } else if (e.type === "keydown" && e.key === "Escape") {
@@ -50,27 +64,43 @@ shell.inspect = (e) => {
50
64
  shell.appPointerDown = () => {
51
65
  if (!card.hidden && !draft) closeCard()
52
66
  }
67
+ // ⌘ held over the live app: the note tools work on a paused moment (said
68
+ // once per press).
69
+ let liveHinted = false
53
70
  shell.pointer = (x, y, meta) => {
54
71
  lastPointer = { x, y }
55
- if (meta !== undefined && meta !== D.metaHeld) shell.meta(meta)
72
+ if (meta !== undefined && meta !== D.metaHeld) setMeta(meta)
73
+ if (meta && !liveHinted && D.last && D.last.started && D.last.playing && !isStill()) {
74
+ liveHinted = true
75
+ flash("Pause (Space) to comment", { warn: false })
76
+ }
56
77
  }
57
- shell.meta = (down) => {
78
+ // From a frame's runtime: ⌘ down or up, or the window on show lost focus
79
+ // (a frame built behind or being swapped out doesn't say so, F99).
80
+ shell.meta = (down) => setMeta(down)
81
+ function setMeta(down) {
58
82
  if (D.metaHeld === down) return
59
83
  D.metaHeld = down
84
+ if (!down) liveHinted = false
60
85
  syncPicking()
61
86
  }
62
87
 
63
88
  // ---- 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.
89
+ // ⌘ over the app lists the layers at that point: each element, and after it
90
+ // any animation running on its ::before/::after (a shimmer on a skeleton,
91
+ // say). What the user can see comes first: an element with its own text,
92
+ // media or a control, or that paints something; then the rest in z-order;
93
+ // then decorative layers (an empty overlay: a glow, a stretched link, a
94
+ // scrim) and invisible ones (opacity 0, a closed menu sheet). Wheel or Tab
95
+ // moves through them; a click picks the highlighted one.
68
96
 
69
- let layers = [] // [{ el, pseudo, anim, name, label }]
97
+ let layers = [] // [{ el, pseudo, anim, name, label, rank, why, z }]
70
98
  let layerIdx = 0
71
99
  let layersKey = ""
100
+ let layersAt = null // the point the list was built at
72
101
  const layersEl = $("#wb-layers")
73
102
  const pick = () => layers[layerIdx] || null
103
+ const MAX_LAYERS = 8
74
104
 
75
105
  const shortLabel = (el) => {
76
106
  const tag = el.tagName.toLowerCase()
@@ -90,19 +120,128 @@ function animTiming(a) {
90
120
  }
91
121
  const animName = (a) => a.animationName || (a.transitionProperty ? `${a.transitionProperty} transition` : "animation")
92
122
 
93
- function buildLayers(x, y, fallback) {
123
+ const MEDIA = "img, svg, video, canvas, picture, iframe, object, embed"
124
+ const CONTROL = "button, a[href], input, select, textarea, summary, label, [role=button], [role=link], [role=checkbox], [role=switch], [role=tab], [role=menuitem], [contenteditable=true]"
125
+ const isSvg = (el) => !!el && el instanceof el.ownerDocument.defaultView.SVGElement
126
+ const svgRootOf = (el) => (el.tagName.toLowerCase() === "svg" && !el.ownerSVGElement ? el : el.closest("svg:not(svg svg)") || el.ownerSVGElement || el)
127
+ // The control an svg is the icon of: within two levels above it.
128
+ function svgControl(svg) {
129
+ for (let n = svg.parentElement, i = 0; n && i < 2; n = n.parentElement, i++) if (n.matches(CONTROL.replace("a[href]", "a"))) return n
130
+ return null
131
+ }
132
+ const ownText = (el) => [...el.childNodes].some((n) => n.nodeType === 3 && /\S/.test(n.data))
133
+ const textOf = (el) => (el.innerText || el.textContent || "").trim()
134
+ function painted(cs) {
135
+ const bg = cs.backgroundColor
136
+ if (bg && !/rgba\(.*,\s*0\)$/.test(bg) && bg !== "transparent") return true
137
+ if (cs.backgroundImage && cs.backgroundImage !== "none") return true
138
+ if (cs.boxShadow && cs.boxShadow !== "none") return true
139
+ return bordered(cs)
140
+ }
141
+ const bordered = (cs) => ["Top", "Right", "Bottom", "Left"].some((s) => parseFloat(cs[`border${s}Width`]) > 0 && cs[`border${s}Style`] !== "none" && cs[`border${s}Style`] !== "hidden")
142
+ // Is it on screen at all (opacity 0 on it or an ancestor, visibility hidden)?
143
+ function seen(el) {
144
+ try {
145
+ if (typeof el.checkVisibility === "function") return el.checkVisibility({ opacityProperty: true, visibilityProperty: true })
146
+ for (let n = el; n && n.nodeType === 1; n = n.parentElement) if (el.ownerDocument.defaultView.getComputedStyle(n).opacity === "0") return false
147
+ } catch {}
148
+ return true
149
+ }
150
+
151
+ // Everything at the point, topmost first: inside open shadow roots (before
152
+ // their host), and embedded iframes (out of hit testing while the app is
153
+ // view-only: no click reaches them) on top.
154
+ function hitList(doc, x, y) {
94
155
  let els = []
95
156
  try {
96
- els = D.frame.contentDocument.elementsFromPoint(x, y)
157
+ els = doc.elementsFromPoint(x, y)
97
158
  } catch {}
98
- const seen = new Set()
99
159
  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) })
160
+ const walk = (list, depth) => {
161
+ for (const el of list) {
162
+ const root = el.shadowRoot
163
+ if (root && depth < 3) {
164
+ let inner = []
165
+ try {
166
+ inner = root.elementsFromPoint(x, y).filter((n) => n.getRootNode() === root)
167
+ } catch {}
168
+ walk(inner, depth + 1)
169
+ }
170
+ out.push(el)
171
+ }
172
+ }
173
+ walk(els, 0)
174
+ try {
175
+ for (const f of doc.querySelectorAll("iframe")) {
176
+ const r = f.getBoundingClientRect()
177
+ if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom && r.width && r.height && !out.includes(f)) out.unshift(f)
178
+ }
179
+ } catch {}
180
+ return out
181
+ }
182
+
183
+ // The elements an element at the point stands for: itself; for a shape inside
184
+ // an svg, the shape (stroke animations live there), the whole <svg>, and the
185
+ // control the svg is the icon of.
186
+ function candidates(raw) {
187
+ if (!raw || raw.tagName === "HTML" || raw.tagName === "BODY") return []
188
+ if (!isSvg(raw)) return [raw]
189
+ const svg = svgRootOf(raw)
190
+ const list = raw === svg ? [svg] : [raw, svg]
191
+ const control = svgControl(svg)
192
+ if (control) list.push(control)
193
+ return list
194
+ }
195
+
196
+ // 0 content (text, media, a control, or it paints something), 1 an empty box,
197
+ // 2 decorative, 3 invisible.
198
+ function rankOf(el, below) {
199
+ if (el.tagName === "IFRAME") return { rank: 0 }
200
+ if (!seen(el)) return { rank: 3, why: "hidden" }
201
+ let cs = null
202
+ try {
203
+ cs = el.ownerDocument.defaultView.getComputedStyle(el)
204
+ } catch {}
205
+ const media = isSvg(el) || el.matches(MEDIA) || !!el.querySelector(MEDIA)
206
+ const text = !!textOf(el)
207
+ if (!media && !text && el.getAttribute("aria-hidden") === "true") return { rank: 2, why: "decorative" }
208
+ if (!media && !text && cs && (cs.position === "absolute" || cs.position === "fixed") && !bordered(cs)) {
209
+ const r = el.getBoundingClientRect()
210
+ const W = el.ownerDocument.defaultView
211
+ const big = r.width * r.height > 0.5 * W.innerWidth * W.innerHeight
212
+ const covers = below.some((b) => {
213
+ const q = b.getBoundingClientRect()
214
+ return q.left >= r.left - 1 && q.top >= r.top - 1 && q.right <= r.right + 1 && q.bottom <= r.bottom + 1
215
+ })
216
+ if (big || covers) return { rank: 2, why: "decorative" }
217
+ }
218
+ if (text || media || el.matches(CONTROL) || (cs && painted(cs))) return { rank: 0 }
219
+ return { rank: 1 }
220
+ }
221
+
222
+ function buildLayers(x, y, fallback) {
223
+ let doc = null
224
+ try {
225
+ doc = D.frame.contentDocument
226
+ } catch {}
227
+ const els = []
228
+ const seenEls = new Set()
229
+ for (const raw of doc ? hitList(doc, x, y) : []) {
230
+ for (const el of candidates(raw)) {
231
+ if (seenEls.has(el)) continue
232
+ seenEls.add(el)
233
+ els.push(el)
234
+ }
235
+ if (els.length >= MAX_LAYERS) break
236
+ }
237
+ els.length = Math.min(els.length, MAX_LAYERS)
238
+ // Each element's rank, knowing what's under it (an empty overlay that
239
+ // covers the content under it is decorative).
240
+ const groups = els.map((el, z) => {
241
+ const below = els.slice(z + 1).filter((b) => b.tagName !== "IFRAME" && (ownText(b) || b.matches(MEDIA)) && !b.contains(el))
242
+ const { rank, why } = rankOf(el, below)
243
+ const host = { el, pseudo: null, anim: null, name: null, label: layerLabel(el), rank, why: why || null, z }
244
+ const pseudos = []
106
245
  let anims = []
107
246
  try {
108
247
  anims = el.getAnimations({ subtree: true })
@@ -114,33 +253,67 @@ function buildLayers(x, y, fallback) {
114
253
  const name = animName(a)
115
254
  if (names.has(pe + name)) continue
116
255
  names.add(pe + name)
117
- out.push({ el, pseudo: pe, anim: a, name, label: `${pe} · ${name} · ${animTiming(a)}` })
256
+ pseudos.push({ el, pseudo: pe, anim: a, name, label: `${pe} · ${name} · ${animTiming(a)}`, rank, why: null, z })
118
257
  }
119
- }
120
- if (!out.length && fallback) out.push({ el: fallback, pseudo: null, anim: null, name: null, label: shortLabel(fallback) })
258
+ return [host, ...pseudos]
259
+ })
260
+ const order = groups.map((g, i) => ({ g, i })).sort((a, b) => a.g[0].rank - b.g[0].rank || a.i - b.i)
261
+ const out = order.flatMap((o) => o.g)
262
+ if (!out.length && fallback) out.push({ el: fallback, pseudo: null, anim: null, name: null, label: layerLabel(fallback), rank: 0, why: null, z: 0 })
121
263
  const key = out.map((l) => l.label).join("|")
122
264
  if (key !== layersKey) {
123
265
  layersKey = key
124
- layerIdx = 0
266
+ layerIdx = defaultLayer(out)
125
267
  }
126
268
  layers = out
269
+ layersAt = { x, y }
127
270
  hovered = pick() ? pick().el : null
128
271
  }
129
272
 
273
+ // The first content layer; for a shape in an svg, the svg itself, or the
274
+ // control it's the icon of.
275
+ function defaultLayer(list) {
276
+ let i = list.findIndex((l) => l.rank === 0)
277
+ if (i < 0) i = list.findIndex((l) => l.rank < 2)
278
+ if (i < 0) return 0
279
+ const l = list[i]
280
+ if (isSvg(l.el)) {
281
+ const svg = svgRootOf(l.el)
282
+ const control = svgControl(svg)
283
+ const want = control || svg
284
+ const j = list.findIndex((x) => x.el === want && !x.pseudo)
285
+ if (j >= 0) return j
286
+ }
287
+ return i
288
+ }
289
+
290
+ // "button.inner in <fancy-card>'s shadow root"
291
+ function layerLabel(el) {
292
+ const root = el.getRootNode && el.getRootNode()
293
+ const host = root && root.host
294
+ return host ? `${shortLabel(el)} in <${host.tagName.toLowerCase()}>'s shadow root` : shortLabel(el)
295
+ }
296
+
130
297
  function cycleLayer(dir) {
131
298
  if (layers.length < 2) return
132
299
  layerIdx = (layerIdx + dir + layers.length) % layers.length
133
300
  hovered = pick().el
134
301
  }
135
302
 
136
- // The chip beside the pointer: the top layers, the pick highlighted.
303
+ // The chip beside the pointer: the top layers, the pick highlighted, a
304
+ // layer that's skipped dimmed with why (so it's clear the wheel reaches it).
137
305
  let layersHtml = ""
138
306
  function renderLayers() {
139
307
  const show = mode() === "comment" && layers.length > 0 && lastPointer
140
308
  layersEl.hidden = !show
141
309
  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>` : "")
310
+ const shown = layers.map((l, i) => ({ l, i })).filter((x) => x.l.rank < 2 || x.i === layerIdx)
311
+ const skipped = layers.map((l, i) => ({ l, i })).filter((x) => x.l.rank >= 2 && x.i !== layerIdx)
312
+ const top = shown.slice(0, skipped.length ? 3 : 4).concat(skipped.slice(0, 1))
313
+ const rest = layers.length - top.length
314
+ const html =
315
+ top.map(({ l, i }) => `<div class="layer${i === layerIdx ? " on" : ""}${l.pseudo ? " pseudo" : ""}${l.rank >= 2 ? " skipped" : ""}">${esc(l.label)}${l.rank >= 2 && l.why ? ` · ${esc(l.why)}` : ""}</div>`).join("") +
316
+ (rest > 0 ? `<div class="more">+${rest}</div>` : "")
144
317
  if (html !== layersHtml) {
145
318
  layersHtml = html
146
319
  layersEl.innerHTML = html
@@ -152,15 +325,22 @@ function renderLayers() {
152
325
  layersEl.style.top = clamp(f.top + lastPointer.y + 16, 8, dock.getBoundingClientRect().top - h - 8) + "px"
153
326
  }
154
327
 
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.
328
+ // How the pick was made, for the note: on top, or a layer down, and what was
329
+ // skipped above it.
330
+ function pickedInfo(p) {
331
+ if (!p) return { via: "topmost", skipped: [] }
332
+ const above = layers.filter((l) => !l.pseudo && l.z < p.z)
333
+ return { via: p.z === 0 ? "topmost" : `layer ${p.z + 1}`, skipped: above.map((l) => `${l.label}${l.why ? ` (${l.why})` : ""}`) }
334
+ }
335
+
336
+ // The page itself isn't a thing to comment on. Inside an SVG icon, the thing
337
+ // you mean is the icon's control (a button or link, within two levels), or
338
+ // at least the whole <svg>.
158
339
  function usable(el) {
159
340
  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
341
+ if (isSvg(el)) {
342
+ const svg = svgRootOf(el)
343
+ return svgControl(svg) || svg
164
344
  }
165
345
  return el
166
346
  }
@@ -172,14 +352,47 @@ function setPicking(m) {
172
352
  syncPicking()
173
353
  }
174
354
 
355
+ // The layer list is always the one at the pointer (F90): rebuilt when a tool
356
+ // turns on, dropped when it turns off.
175
357
  function syncPicking() {
176
358
  shell.inspecting = !!mode()
177
359
  setToolActive(!!mode())
360
+ // Picking, a click on a pinned spot is a new note there (F108): pins let it through.
361
+ document.body.classList.toggle("picking-notes", mode() === "comment")
178
362
  hovered = null
179
363
  try {
180
364
  const doc = D.frame.contentDocument
181
365
  doc.documentElement.style.cursor = mode() ? "crosshair" : ""
182
- if (mode() && lastPointer) hovered = usable(doc.elementFromPoint(lastPointer.x, lastPointer.y))
366
+ } catch {}
367
+ if (mode() && lastPointer) buildLayers(lastPointer.x, lastPointer.y, null)
368
+ else if (!mode()) {
369
+ layers = []
370
+ layersKey = ""
371
+ layersAt = null
372
+ }
373
+ }
374
+
375
+ // Embedded iframes get no input while the app is view-only (F97): a sheet
376
+ // adopted by the frame on show (no DOM change, nothing recorded).
377
+ let embedSheet = null // { doc, sheet }
378
+ function syncEmbeds() {
379
+ let doc = null
380
+ try {
381
+ doc = D.frame && D.frame.contentDocument
382
+ } catch {}
383
+ const want = !!doc && isStill()
384
+ if (embedSheet && (embedSheet.doc !== doc || !want)) {
385
+ try {
386
+ embedSheet.doc.adoptedStyleSheets = embedSheet.doc.adoptedStyleSheets.filter((x) => x !== embedSheet.sheet)
387
+ } catch {}
388
+ embedSheet = null
389
+ }
390
+ if (!want || embedSheet) return
391
+ try {
392
+ const sheet = new doc.defaultView.CSSStyleSheet()
393
+ sheet.replaceSync("iframe { pointer-events: none !important; }")
394
+ doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, sheet]
395
+ embedSheet = { doc, sheet }
183
396
  } catch {}
184
397
  }
185
398
 
@@ -193,8 +406,8 @@ function setScope(el) {
193
406
 
194
407
  const cssEsc = (v) => (window.CSS && CSS.escape ? CSS.escape(v) : String(v).replace(/[^\w-]/g, (c) => "\\" + c))
195
408
 
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.
409
+ // The runtime's own selector for an element (the old way: an id, else
410
+ // tag.class chains with :nth-of-type), which older clips carry.
198
411
  function cssPath(el) {
199
412
  const parts = []
200
413
  for (let n = el; n && n.nodeType === 1 && n.tagName !== "HTML"; n = n.parentElement) {
@@ -213,9 +426,150 @@ function cssPath(el) {
213
426
  return parts.join(" > ")
214
427
  }
215
428
 
429
+ // ---- stable selectors (F96) ----------------------------------------------------------
430
+ // A selector that still finds the element on a fresh load of the page: no
431
+ // generated ids, no classes the recording saw come and go (`in`, `go`,
432
+ // `is-open`, `opacity-100`), no Tailwind state variants; test ids, labels,
433
+ // hrefs and names where they're unique; :nth-of-type only where needed.
434
+
435
+ // Class names added or removed on any element while the dock watched (every
436
+ // frame it has shown or built: the recording, its replays and previews).
437
+ const toggledClasses = new Set()
438
+ const watchedDocs = new WeakSet()
439
+ const classSet = (v) => new Set(String(v || "").split(/\s+/).filter(Boolean))
440
+ function watchClasses(frame) {
441
+ let doc = null
442
+ try {
443
+ doc = frame && frame.contentDocument
444
+ } catch {}
445
+ if (!doc || !doc.documentElement || watchedDocs.has(doc)) return
446
+ watchedDocs.add(doc)
447
+ try {
448
+ new MutationObserver((records) => {
449
+ for (const r of records) {
450
+ const was = classSet(r.oldValue)
451
+ const now = classSet(/** @type {Element} */ (r.target).getAttribute("class"))
452
+ for (const c of was) if (!now.has(c)) toggledClasses.add(c)
453
+ for (const c of now) if (!was.has(c)) toggledClasses.add(c)
454
+ }
455
+ }).observe(doc, { subtree: true, attributes: true, attributeFilter: ["class"], attributeOldValue: true })
456
+ } catch {}
457
+ }
458
+
459
+ const GENERATED_ID = /^(:r|«r|:R|radix-|headlessui-|react-aria|mui-|rc-|ember\d)|\d{5,}$|:/
460
+ const goodId = (id) => !!id && !GENERATED_ID.test(id)
461
+ const stableClass = (c) => !toggledClasses.has(c) && !c.includes(":") && /^-?[_a-zA-Z][\w-]*$|^[\w-]*\[[^\]]+\][\w-]*$/.test(c)
462
+ // "_title_18lyj_1" (a CSS module's hashed class) → "title".
463
+ const moduleClass = (c) => {
464
+ const m = /^_?([A-Za-z][\w-]*?)_[a-z0-9]{5}(_\d+)?$/.exec(c)
465
+ return m ? m[1] : null
466
+ }
467
+
468
+ const attrEsc = (v) => String(v).replace(/["\\]/g, "\\$&")
469
+ // One step of a selector for n, the most specific stable thing it has.
470
+ function selectorStep(n, unique) {
471
+ const tag = n.tagName.toLowerCase()
472
+ if (goodId(n.id)) return { sel: `#${cssEsc(n.id)}`, anchor: true }
473
+ for (const a of ["data-testid", "data-test", "data-cy"]) {
474
+ const v = n.getAttribute(a)
475
+ if (v) return { sel: `${tag}[${a}="${attrEsc(v)}"]`, anchor: true }
476
+ }
477
+ const tries = []
478
+ const label = n.getAttribute("aria-label")
479
+ if (label) tries.push(`${tag}[aria-label="${attrEsc(label)}"]`)
480
+ if (tag === "a" && n.getAttribute("href")) tries.push(`a[href="${attrEsc(n.getAttribute("href"))}"]`)
481
+ if (/^(input|select|textarea|button)$/.test(tag) && n.getAttribute("name")) tries.push(`${tag}[name="${attrEsc(n.getAttribute("name"))}"]`)
482
+ for (const t of tries) if (unique(t)) return { sel: t, anchor: true }
483
+ const cls = [...n.classList].filter(stableClass).slice(0, 3)
484
+ return { sel: tag + cls.map((c) => "." + cssEsc(c)).join(""), anchor: false, bare: !cls.length }
485
+ }
486
+
487
+ // { selector, matches, hint } for an element. In a shadow root: the host's
488
+ // selector, " >>> ", and the selector inside it.
489
+ function stableSelector(el) {
490
+ const root = el.getRootNode ? el.getRootNode() : el.ownerDocument
491
+ const all = (s) => {
492
+ try {
493
+ return root.querySelectorAll(s)
494
+ } catch {
495
+ return []
496
+ }
497
+ }
498
+ const unique = (s) => {
499
+ const m = all(s)
500
+ return m.length === 1 && m[0] === el
501
+ }
502
+ let sel = ""
503
+ for (let n = el, depth = 0; n && n.nodeType === 1 && n.tagName !== "HTML" && depth < 10; n = n.parentElement, depth++) {
504
+ const step = selectorStep(n, (s) => all(sel ? `${s} > ${sel}` : s).length === 1)
505
+ let part = step.sel
506
+ let cand = sel ? `${part} > ${sel}` : part
507
+ if (unique(cand) && (!step.bare || depth > 0)) return finish(cand)
508
+ if (!step.anchor && n.parentElement) {
509
+ const same = [...n.parentElement.children].filter((c) => c.tagName === n.tagName)
510
+ if (same.length > 1) {
511
+ const nth = `${part}:nth-of-type(${same.indexOf(n) + 1})`
512
+ const withNth = sel ? `${nth} > ${sel}` : nth
513
+ if (unique(withNth) && (!step.bare || depth > 0)) return finish(withNth)
514
+ // Kept only where it narrows things down.
515
+ if (all(withNth).length < all(cand).length) cand = withNth
516
+ }
517
+ }
518
+ sel = cand
519
+ if (step.anchor || n.tagName === "BODY") break
520
+ }
521
+ return finish(sel || el.tagName.toLowerCase())
522
+ function finish(s) {
523
+ const host = root && /** @type {any} */ (root).host
524
+ if (host) {
525
+ const outer = stableSelector(host)
526
+ return { selector: `${outer.selector} >>> ${s}`, matches: all(s).length, shadowHost: outer.selector }
527
+ }
528
+ return { selector: s, matches: all(s).length }
529
+ }
530
+ }
531
+
532
+ // The runtime's selectorOf (shared with clips) when it has one.
533
+ function selectorInfo(el) {
534
+ const pt = D.PT
535
+ const inShadow = el.getRootNode && el.getRootNode() !== el.ownerDocument
536
+ if (pt && typeof pt.selectorOf === "function" && !inShadow) {
537
+ try {
538
+ const r = pt.selectorOf(el)
539
+ if (r && typeof r === "object" && r.selector) return { selector: r.selector, matches: r.matches || 1, hint: r.hint || null }
540
+ if (typeof r === "string" && r) return { selector: r, matches: findAll(r, el.ownerDocument).length || 1 }
541
+ } catch {}
542
+ }
543
+ return stableSelector(el)
544
+ }
545
+
546
+ // Elements a selector finds, through " >>> " into open shadow roots.
547
+ function findAll(selector, doc) {
548
+ try {
549
+ const parts = String(selector).split(" >>> ")
550
+ let scope = [doc]
551
+ for (let i = 0; i < parts.length; i++) {
552
+ const found = scope.flatMap((r) => [...r.querySelectorAll(parts[i])])
553
+ scope = i < parts.length - 1 ? found.map((n) => n.shadowRoot).filter(Boolean) : found
554
+ }
555
+ return /** @type {Element[]} */ (scope)
556
+ } catch {
557
+ return []
558
+ }
559
+ }
560
+ const findEl = (selector, doc) => (selector ? findAll(selector, doc)[0] || null : null)
561
+
562
+ // The element's fiber in the tree on screen: the node keeps the fiber it was
563
+ // created with, which after an update may be the stale one of the pair (its
564
+ // props, and its parents' props, from an earlier render). The current props
565
+ // sit on the node (__reactProps$), so the fiber holding them is the current one.
216
566
  const fiberOf = (el) => {
217
- const key = Object.keys(el).find((k) => k.startsWith("__reactFiber$") || k.startsWith("__reactInternalInstance$"))
218
- return key ? el[key] : null
567
+ const keys = Object.keys(el)
568
+ const key = keys.find((k) => k.startsWith("__reactFiber$") || k.startsWith("__reactInternalInstance$"))
569
+ const f = key ? el[key] : null
570
+ const pk = keys.find((k) => k.startsWith("__reactProps$"))
571
+ if (f && pk && f.alternate && f.memoizedProps !== el[pk] && f.alternate.memoizedProps === el[pk]) return f.alternate
572
+ return f
219
573
  }
220
574
 
221
575
  // A component's name, through memo() and forwardRef() wrappers.
@@ -231,7 +585,7 @@ function componentName(type) {
231
585
 
232
586
  // Library wrappers that sit between an element and the component that wrote
233
587
  // 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)$/
588
+ const INTERNAL = /^(SegmentViewNode|InnerLayoutRouter|OuterLayoutRouter|RenderFromTemplateContext|ScrollAndFocusHandler|InnerScrollAndFocusHandler|RedirectBoundary|RedirectErrorBoundary|NotFoundBoundary|HTTPAccessFallbackBoundary|HTTPAccessFallbackErrorBoundary|ErrorBoundary|ErrorBoundaryHandler|LoadingBoundary|ClientPageRoot|ClientSegmentRoot|AppRouter|ServerRoot|HotReload|DevRootHTTPAccessFallbackBoundary|Router|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
589
 
236
590
  // Nearest components, innermost first: the components that rendered the
237
591
  // element (React's owner chain), which skips wrappers it was merely passed
@@ -247,44 +601,104 @@ function reactComponents(el) {
247
601
 
248
602
  // Where the element was written: React's debug info. React 18 and older keep
249
603
  // _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.
604
+ // whose app frames lead to the JSX; those are lines of the file as served
605
+ // (types stripped, JSX compiled; or a whole bundle, as Next's Turbopack and
606
+ // webpack chunks are), which mapSource() turns back into source lines with
607
+ // the served file's source map. Frames that map into library code (the JSX
608
+ // runtime bundled into the same chunk) are skipped. With no map at all the
609
+ // source stays unmapped (mapped: false) and says it's a compiled bundle.
610
+ // (Vite's pre-bundled deps carry a ?v= hash wherever its cacheDir is.)
611
+ const LIB_URL = /node_modules|\/\.vite\/deps\/|\/deps\/[^/?]+\.js\?v=[0-9a-f]+|react-dom|react\.development|jsx-dev-runtime/
612
+ const LIB_FN = /\bat (?:exports\.|Object\.)?(?:jsxDEV|jsxs?|createElement|jsxWithValidation\w*)\b/
253
613
  function sourceOf(el) {
254
614
  for (let f = fiberOf(el), i = 0; f && i < 12; f = f.return, i++) {
615
+ // The element's own JSX (exact), or a parent's, where it was passed in (owner).
616
+ const confidence = i === 0 ? "exact" : "owner"
255
617
  const src = f._debugSource
256
- if (src && src.fileName) return { file: shortPath(src.fileName), line: src.lineNumber || null, mapped: true }
618
+ if (src && src.fileName) return { file: shortPath(src.fileName), line: src.lineNumber || null, mapped: true, confidence }
257
619
  const stack = f._debugStack && (f._debugStack.stack || String(f._debugStack))
258
620
  if (stack) {
621
+ const frames = []
622
+ let server = null
259
623
  for (const line of stack.split("\n").slice(1)) {
260
624
  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
625
+ if (!m || LIB_FN.test(line)) continue
262
626
  const url = m[1].replace(/^.*?\(/, "")
627
+ if (LIB_URL.test(url)) continue
628
+ // webpack's eval'd modules (Next on webpack): the module's own path, its line is the compiled one.
629
+ if (/^webpack-internal:/.test(url)) {
630
+ frames.push({ file: cleanSource(url.replace(/^webpack-internal:\/\/\/(\([^)]*\)\/)?/, "")), line: null })
631
+ continue
632
+ }
263
633
  // 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)
634
+ // is a compiled chunk on the server: the dev server maps it (F95, /__retake/map).
635
+ if (!/^https?:\/\//.test(url)) {
636
+ if (!server) server = { chunk: url.replace(/^about:\/\/React\/Server\//, ""), line: Number(m[2]), col: Number(m[3]) }
637
+ continue
638
+ }
639
+ frames.push({ url, line: Number(m[2]), col: Number(m[3]) })
640
+ if (frames.length >= 8) break
641
+ }
642
+ const first = frames[0]
643
+ if (first && !first.url) return { file: first.file, line: null, mapped: true, confidence }
644
+ if (first) {
645
+ const src = { file: shortPath(first.url), line: first.line, col: first.col, url: first.url, mapped: false, confidence }
646
+ mapSource(src, frames)
647
+ return src
648
+ }
649
+ // Written by a server component: its line, never the client parent's.
650
+ if (server && i === 0) {
651
+ const src = { file: null, line: null, server: true, chunk: server.chunk, col: server.col, chunkLine: server.line, confidence: "unknown" }
652
+ mapServerSource(src)
268
653
  return src
269
654
  }
270
655
  }
271
656
  }
272
657
  return null
273
658
  }
659
+
660
+ // The dev server reads the chunk's source map from disk: fills in the file
661
+ // and line of a server component's element, in place.
662
+ async function mapServerSource(src) {
663
+ if (!net.on) return
664
+ try {
665
+ const q = new URLSearchParams({ url: src.chunk, line: String(src.chunkLine), col: String(src.col || 0) })
666
+ const r = await api("GET", `map?${q}`)
667
+ const v = r && r.value && typeof r.value === "object" ? r.value : null
668
+ if (v && v.file) {
669
+ src.file = v.file
670
+ src.line = v.line || null
671
+ src.mapped = true
672
+ src.confidence = "exact"
673
+ }
674
+ } catch {}
675
+ }
274
676
  function shortPath(p) {
275
677
  try {
276
- if (/^https?:/.test(p)) p = new URL(p).pathname
678
+ // A dev server's URL path is from the project's root (Vite's /@fs/ is an absolute path).
679
+ if (/^https?:/.test(p)) {
680
+ p = new URL(p).pathname
681
+ if (!p.startsWith("/@fs/")) return p.replace(/^\/+/, "")
682
+ }
277
683
  } catch {}
278
- return p.replace(/\?.*$/, "").replace(/^\/@fs/, "")
684
+ return projectPath(p.replace(/\?.*$/, "").replace(/^\/@fs/, ""))
685
+ }
686
+ // An absolute path inside the project, relative to it (macOS's /var is /private/var).
687
+ function projectPath(p) {
688
+ const root = String((window.__retakeConfig && window.__retakeConfig.root) || "").replace(/\/+$/, "")
689
+ if (!root || !p || p[0] !== "/") return p
690
+ for (const r of new Set([root, root.replace(/^\/private\//, "/"), root.startsWith("/private/") ? root : "/private" + root])) if (p.startsWith(r + "/")) return p.slice(r.length + 1)
691
+ return p
279
692
  }
280
693
 
281
694
  // ---- source maps: served line → source line ----------------------------------------
695
+ // (server/sourcemap.js does the same for the MCP server.)
282
696
 
283
697
  const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
284
698
  function decodeMappings(mappings) {
285
699
  const lines = []
286
700
  let src = 0, oLine = 0, oCol = 0
287
- for (const text of mappings.split(";")) {
701
+ for (const text of String(mappings || "").split(";")) {
288
702
  const segs = []
289
703
  let gCol = 0
290
704
  for (const seg of text.split(",")) {
@@ -292,6 +706,7 @@ function decodeMappings(mappings) {
292
706
  const v = []
293
707
  for (let i = 0, shift = 0, acc = 0; i < seg.length; i++) {
294
708
  const d = B64.indexOf(seg[i])
709
+ if (d < 0) break
295
710
  acc += (d & 31) << shift
296
711
  if (d & 32) shift += 5
297
712
  else {
@@ -299,7 +714,7 @@ function decodeMappings(mappings) {
299
714
  acc = shift = 0
300
715
  }
301
716
  }
302
- gCol += v[0]
717
+ gCol += v[0] || 0
303
718
  if (v.length >= 4) {
304
719
  src += v[1]
305
720
  oLine += v[2]
@@ -312,7 +727,61 @@ function decodeMappings(mappings) {
312
727
  return lines
313
728
  }
314
729
 
315
- const sourceMaps = new Map() // module url (without query) → Promise<{ map, lines } | null>
730
+ // Where a served line/column (1-based) came from: { source, line } or null.
731
+ // Index maps (`sections`, Turbopack's) are read section by section.
732
+ const decodedMaps = new WeakMap()
733
+ function originalPosition(map, line, col) {
734
+ if (!map || !(line > 0)) return null
735
+ if (Array.isArray(map.sections)) {
736
+ const L = line - 1
737
+ const C = col > 0 ? col - 1 : null
738
+ let hit = null
739
+ for (const s of map.sections) {
740
+ const o = s.offset || { line: 0, column: 0 }
741
+ if (o.line < L || (o.line === L && (C == null || o.column <= C))) hit = s
742
+ else break
743
+ }
744
+ if (!hit || !hit.map) return null
745
+ const o = hit.offset || { line: 0, column: 0 }
746
+ return originalPosition(hit.map, L - o.line + 1, C == null ? null : (L === o.line ? C - o.column : C) + 1)
747
+ }
748
+ let lines = decodedMaps.get(map)
749
+ if (!lines) decodedMaps.set(map, (lines = decodeMappings(map.mappings)))
750
+ const segs = lines[line - 1]
751
+ if (!segs || !segs.length) return null
752
+ let best = segs[0]
753
+ if (col > 0) for (const s of segs) if (s[0] <= col - 1) best = s
754
+ let source = map.sources && map.sources[best[1]]
755
+ if (source && map.sourceRoot && !/^[a-z]+:/i.test(source) && !source.startsWith("/")) source = map.sourceRoot.replace(/\/?$/, "/") + source
756
+ return source ? { source, line: best[2] + 1 } : null
757
+ }
758
+
759
+ // A map's source name as a path (webpack://app/./src/a.tsx → src/a.tsx,
760
+ // turbopack:///[project]/app/page.tsx → app/page.tsx, file:///… → /…),
761
+ // relative to the project when the map's node_modules sources show where it is.
762
+ function cleanSource(name, map) {
763
+ let p = String(name || "").replace(/[?#].*$/, "")
764
+ p = p.replace(/^webpack:\/\/[^/]*\//, "").replace(/^turbopack:\/\/\/?(\[project\]\/)?/, "").replace(/^\[project\]\//, "")
765
+ if (p.startsWith("file://")) {
766
+ try {
767
+ p = decodeURIComponent(new URL(p).pathname)
768
+ } catch {
769
+ p = p.slice(7)
770
+ }
771
+ }
772
+ p = projectPath(p.replace(/^\.\//, ""))
773
+ if (p.startsWith("/") && map) {
774
+ const all = Array.isArray(map.sections) ? map.sections.flatMap((s) => (s.map && s.map.sources) || []) : map.sources || []
775
+ for (const s of all) {
776
+ const m = /^(?:file:\/\/)?(\/.*?)\/node_modules\//.exec(String(s || ""))
777
+ if (m && p.startsWith(m[1] + "/")) return p.slice(m[1].length + 1)
778
+ }
779
+ }
780
+ return p
781
+ }
782
+ const libraryFile = (f) => /(^|\/)node_modules\/|\/\.vite\/deps\/|react-dom|jsx-dev-runtime|jsx-runtime|\[turbopack\]|turbopack\/|webpack\/(bootstrap|runtime)/.test(f)
783
+
784
+ const sourceMaps = new Map() // served url (without query) → Promise<map | null>
316
785
  function loadMap(url) {
317
786
  const key = url.replace(/\?.*$/, "")
318
787
  if (!sourceMaps.has(key))
@@ -321,16 +790,22 @@ function loadMap(url) {
321
790
  (async () => {
322
791
  try {
323
792
  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
793
+ const res = await fetch(url)
794
+ if (!res.ok) return null
795
+ const code = await res.text()
796
+ const header = res.headers.get("sourcemap") || res.headers.get("x-sourcemap")
797
+ const refs = [...code.matchAll(/\/\/[#@] sourceMappingURL=(\S+)/g)]
798
+ const ref = header || (refs.length ? refs[refs.length - 1][1] : null)
327
799
  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 || "") }
800
+ if (ref && ref.startsWith("data:")) {
801
+ const b64 = ref.slice(ref.indexOf(",") + 1)
802
+ json = /;base64/.test(ref.slice(0, ref.indexOf(","))) ? new TextDecoder().decode(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))) : decodeURIComponent(b64)
803
+ } else {
804
+ const r = await fetch(ref ? new URL(ref, url) : key + ".map")
805
+ if (!r.ok) return null
806
+ json = await r.text()
807
+ }
808
+ return JSON.parse(json)
334
809
  } catch {
335
810
  return null
336
811
  }
@@ -339,21 +814,37 @@ function loadMap(url) {
339
814
  return sourceMaps.get(key)
340
815
  }
341
816
 
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)
817
+ // Rewrites src (file, line) in place from the first frame that maps into the
818
+ // app's own code, so the note that holds it is saved with the source line.
819
+ async function mapSource(src, frames = [src]) {
820
+ for (const fr of frames) {
821
+ if (!fr.url) {
822
+ Object.assign(src, { file: fr.file, line: null, mapped: true })
823
+ delete src.col
824
+ delete src.url
825
+ return
826
+ }
827
+ const map = await loadMap(fr.url)
828
+ if (!map) {
829
+ if (fr === frames[0]) break // nothing to read this one with: unmapped
830
+ continue
831
+ }
832
+ const pos = originalPosition(map, fr.line, fr.col)
833
+ if (!pos) continue
834
+ const file = cleanSource(pos.source, map)
835
+ if (libraryFile(file)) continue
836
+ // A module served as itself (Vite) keeps its served path; a bundle names the source.
837
+ const served = shortPath(fr.url)
838
+ src.file = file.split("/").pop() === served.split("/").pop() ? served : file
839
+ src.line = pos.line
840
+ src.mapped = true
841
+ delete src.col
842
+ delete src.url
843
+ delete src.compiled
844
+ return
353
845
  }
354
- src.mapped = true
355
- delete src.col
356
- delete src.url
846
+ // No map: a compiled bundle's line is no use to anyone; say so (the component and selector still are).
847
+ if (/\/_next\/static\/|\.vite\/deps|\._\.js$|[-_.](?=[0-9a-z]*\d)[0-9a-z]{6,}\.m?js$/i.test(src.file)) src.compiled = true
357
848
  }
358
849
 
359
850
  // The computed styles an agent would ask about first. Defaults are left out.
@@ -398,10 +889,14 @@ function describe(el) {
398
889
  u.searchParams.delete("__wb")
399
890
  page = u.pathname + u.search
400
891
  } catch {}
892
+ const sel = selectorInfo(el)
401
893
  return {
402
894
  label: `<${tag}${id}${cls}>`,
403
895
  text,
404
- selector: cssPath(el),
896
+ selector: sel.selector,
897
+ matches: sel.matches || 1,
898
+ shadowHost: sel.shadowHost || undefined,
899
+ hint: selectorHint(el, text),
405
900
  components: reactComponents(el),
406
901
  source: sourceOf(el),
407
902
  classes: [...el.classList],
@@ -411,42 +906,83 @@ function describe(el) {
411
906
  }
412
907
  }
413
908
 
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.
909
+ // What grep finds the element by: its text, the CSS-module classes it has
910
+ // (`_title_18lyj_1` is `.title` in a *.module.css), its component.
911
+ function selectorHint(el, text) {
912
+ const parts = []
913
+ if (text) parts.push(`text "${text.length > 40 ? text.slice(0, 40) + "…" : text}"`)
914
+ const mods = [...el.classList].map((c) => [c, moduleClass(c)]).filter(([, m]) => m)
915
+ if (mods.length) parts.push(mods.map(([c, m]) => `${c} = .${m} in a CSS module`).join(", "))
916
+ const comp = reactComponents(el)[0]
917
+ if (comp) parts.push(`in <${comp}>`)
918
+ return parts.join("; ") || null
919
+ }
920
+
921
+ // What was moving on the element at t, by the clip's exact element (never a
922
+ // selector match, F91): on it, else on one of its nearest ancestors, and then
923
+ // only if what that animates moves the element (a label inside a sliding
924
+ // button). A note on a still element says nothing is animating.
417
925
  const CLIP_ANCESTORS = 4
926
+ const MOVES_IT = /^(all|transform|translate|scale|rotate|opacity|filter|clip-?path|top|left|right|bottom|inset|width|height|offset)/i
418
927
  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
928
+ if (!el) {
929
+ try {
930
+ el = findEl(selector, D.frame.contentDocument)
931
+ } catch {}
932
+ }
933
+ if (!el) return null
934
+ const at = (c) => c.start <= t + 0.5 && (c.end == null || t <= c.end + 0.5)
935
+ const latest = (list) => list.filter(at).sort((a, b) => b.start - a.start)[0] || null
936
+ let node = el
937
+ let c = latest(clipsOn(el).filter((x) => !x.pseudoElement))
938
+ for (let n = el.parentElement, i = 0; !c && n && n.tagName !== "BODY" && n.tagName !== "HTML" && i < CLIP_ANCESTORS; n = n.parentElement, i++) {
939
+ c = latest(clipsOn(n).filter((x) => !x.pseudoElement && clipMoves(x)))
940
+ node = n
941
+ }
942
+ if (!c) return null
423
943
  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 }
944
+ return { id: c.id, offset: Math.round(t - c.start), duration: end == null ? null : Math.round(end - c.start), start: Math.round(c.start), end: end == null ? null : Math.round(end), label: c.label || c.property || c.kind, kind: c.kind, property: c.property || null, selector: selectorInfo(node).selector || c.selector || null }
945
+ }
946
+ // The clips on the element itself: in its subtree (clipsFor, by recorded
947
+ // path), and not in any child's.
948
+ function clipsOn(el) {
949
+ const all = clipsOfElement(el)
950
+ if (!all.length) return []
951
+ const inKids = new Set()
952
+ for (const k of el.children) for (const c of clipsOfElement(k)) inKids.add(c)
953
+ return all.filter((c) => !inKids.has(c))
425
954
  }
955
+ const clipMoves = (c) => [c.property, ...Object.keys(c.from || {}), ...Object.keys(c.to || {})].filter(Boolean).flatMap((p) => String(p).split(/,\s*/)).some((p) => MOVES_IT.test(p))
426
956
  const clipPhrase = (c) =>
427
957
  `${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
958
 
959
+ // Where the element was written, or why that isn't known.
960
+ const sourceText = (s) =>
961
+ s.compiled ? "not mapped (only a compiled bundle, no source map): find it by the component and selector" : `${s.file}${s.line ? `:${s.line}` : ""}`
962
+
429
963
  // 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.
964
+ // the code, the moment and, for an animation, the exact point or range on its
965
+ // own clock. One formatter for this and `retake mcp` (src/note-text.js, NT).
431
966
  function prompt(n) {
432
967
  const b = branchById(n.branchId)
433
- const start = D.last ? D.last.start : 0
434
968
  const parent = b && branchById(b.parentId)
435
969
  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
970
  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")
971
+ const extra = []
972
+ if (el.pseudo) extra.push(`Animation: ${pseudoSentence(el)}`)
973
+ // What an animation changes is given at the note's own point below; the
974
+ // styles read when the composer opened would only contradict it.
975
+ const moving = n.anims && n.anims.length ? /^(transform|opacity|animation-name|transition)$/ : null
976
+ const shown = Object.entries(st).filter(([k]) => !moving || !moving.test(k))
977
+ if (shown.length) extra.push(`Computed: ${shown.map(([k, v]) => `${k}: ${v}`).join("; ")}`)
978
+ return NT.noteText(noteForServer(n), {
979
+ start: D.last ? D.last.start : 0,
980
+ timeline: b ? b.name : "Timeline",
981
+ parent: parent ? parent.name : null,
982
+ forkAt: b ? b.forkAt : null,
983
+ sourceLine: el.source ? `Source: ${sourceText(el.source)}` : undefined,
984
+ computed: extra.join("\n") || undefined,
985
+ })
450
986
  }
451
987
 
452
988
  async function copy(text, btn) {
@@ -486,6 +1022,11 @@ function noteForServer(n) {
486
1022
  status: n.status || "pending",
487
1023
  replies: n.replies || [],
488
1024
  el: n.el,
1025
+ range: n.range || null,
1026
+ target: n.target || null,
1027
+ anims: n.anims || null,
1028
+ inside: n.inside || [],
1029
+ asked: n.asked || null,
489
1030
  }
490
1031
  }
491
1032
 
@@ -500,7 +1041,7 @@ function noteFromServer(n) {
500
1041
  classes: n.classes || [],
501
1042
  source: n.source || null,
502
1043
  }
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 || [] }
1044
+ 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 || [], range: n.range || null, target: n.target || null, anims: n.anims || null, inside: n.inside || [], asked: n.asked || null }
504
1045
  }
505
1046
 
506
1047
  // A change from outside (an agent replied, or marked it resolved).
@@ -525,7 +1066,7 @@ function anchorOf(el) {
525
1066
  const f = D.frame.getBoundingClientRect()
526
1067
  let r = null
527
1068
  try {
528
- const node = el.selector && D.frame.contentDocument.querySelector(el.selector)
1069
+ const node = findEl(el.selector, D.frame.contentDocument)
529
1070
  if (node) r = node.getBoundingClientRect()
530
1071
  } catch {}
531
1072
  if (!r || (!r.width && !r.height)) r = { x: el.rect.x, y: el.rect.y, width: el.rect.w, height: el.rect.h }
@@ -585,9 +1126,24 @@ function pseudoInfo(layer) {
585
1126
  function pseudoClip(t, desc) {
586
1127
  const want = (c) => (c.pseudoElement || null) === desc.pseudo.pseudoElement
587
1128
  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]
1129
+ let host = null
1130
+ try {
1131
+ host = findEl(desc.selector, D.frame.contentDocument)
1132
+ } catch {}
1133
+ const onHost = (c) => !!host && (c.selector === cssPath(host) + desc.pseudo.pseudoElement || nodeOfPseudoClip(c) === host)
1134
+ const c = clips.find((c) => c.selector === desc.selector + desc.pseudo.pseudoElement) || clips.find(onHost) || clips[0]
589
1135
  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 }
1136
+ return { id: c.id, offset: Math.round(t - c.start), duration: c.end == null ? null : Math.round(c.end - c.start), start: Math.round(c.start), end: c.end == null ? null : Math.round(c.end), label: c.label || desc.pseudo.animationName, kind: c.kind, property: c.property || null, selector: c.selector || null }
1137
+ }
1138
+ // The element a ::before/::after clip is on (its selector without the pseudo-element).
1139
+ function nodeOfPseudoClip(c) {
1140
+ const pe = c.pseudoElement || ""
1141
+ const sel = c.selector && pe && c.selector.endsWith(pe) ? c.selector.slice(0, -pe.length) : c.selector
1142
+ try {
1143
+ return findEl(sel, D.frame.contentDocument)
1144
+ } catch {
1145
+ return null
1146
+ }
591
1147
  }
592
1148
  // Where the animation is written in the CSS (the runtime reads Vite's
593
1149
  // source maps); filled in when it answers.
@@ -628,13 +1184,14 @@ function shortClip(c) {
628
1184
  }
629
1185
 
630
1186
  // Everything else about the element, folded away.
631
- function details(el) {
1187
+ function details(el, geo = null) {
632
1188
  const rows = [["Selector", el.selector]]
633
1189
  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 : ""}`])
1190
+ if (el.source) rows.push(["Source", sourceText(el.source)])
635
1191
  if (el.pseudo) rows.push(["Animation", pseudoSentence(el)])
636
1192
  if (el.cssSource) rows.push(["CSS", `${el.cssSource.file}${el.cssSource.line ? ":" + el.cssSource.line : ""}`])
637
1193
  if (el.classes && el.classes.length) rows.push(["Classes", el.classes.join(" ")])
1194
+ if (geo && geo.page) rows.push(["Box", `x ${Math.round(geo.page.x)} y ${Math.round(geo.page.y)} · ${Math.round(geo.page.w)}×${Math.round(geo.page.h)} (page)${geo.decomposed && (geo.decomposed.tx || geo.decomposed.ty) ? ` · translate ${geo.decomposed.tx}, ${geo.decomposed.ty}` : ""}${geo.opacity != null && geo.opacity !== 1 ? ` · opacity ${+Number(geo.opacity).toFixed(2)}` : ""}`])
638
1195
  return `<details class="details"><summary>Details</summary><dl>${rows.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join("")}</dl></details>`
639
1196
  }
640
1197
 
@@ -653,27 +1210,48 @@ function openComposer(el, point, layer) {
653
1210
  lookUpCss(desc, layer.anim)
654
1211
  }
655
1212
  const clip = desc.pseudo ? pseudoClip(t, desc) : clipFor(t, desc.selector, el)
656
- draft = { el: desc, t, clip }
1213
+ draft = { el: desc, t, clip, picked: layer && layer.picked ? layer.picked : null, asked: null, askedReading: null }
657
1214
  openNote = null
658
- card.innerHTML = `${meta(t, draft.el)}<textarea rows="3" placeholder="What should change here?"></textarea>
1215
+ // The element becomes the dock's focus: its own animations get a row on the track (32-anims.js).
1216
+ focusOn(el, layer, t)
1217
+ card.innerHTML = `${meta(t, draft.el)}<div class="note-at" hidden></div><textarea rows="3" placeholder="What should change here?"></textarea>
1218
+ <button class="note-asked" data-note-a="asked" hidden></button>
659
1219
  <div class="note-actions"><button data-note-a="cancel">Cancel</button><button data-note-a="save" class="primary">Add note</button></div>`
660
1220
  card.hidden = false
1221
+ refreshComposer()
661
1222
  placeCard(anchorOf(draft.el))
662
1223
  const ta = card.querySelector("textarea")
663
1224
  setTimeout(() => ta.focus())
1225
+ ta.addEventListener("input", updateAsked)
664
1226
  // Enter saves and folds the note down to its pin; Shift+Enter is a new line.
665
1227
  ta.addEventListener("keydown", (e) => {
666
1228
  if (e.key === "Enter" && !e.shiftKey) {
667
1229
  e.preventDefault()
668
1230
  saveDraft()
669
1231
  }
670
- if (e.key === "Escape") closeCard()
1232
+ if (e.key === "Escape") {
1233
+ e.stopPropagation()
1234
+ if (!closeFocusClip()) closeCard()
1235
+ }
671
1236
  })
672
1237
  }
673
1238
 
674
1239
  function saveDraft() {
675
1240
  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: [] })
1241
+ if (draft && text) {
1242
+ updateAsked()
1243
+ let extra = { t: draft.t, range: null, target: null, anims: null, inside: [], asked: null }
1244
+ try {
1245
+ extra = notePayload(draft)
1246
+ } catch (err) {
1247
+ console.error("[retake] couldn't describe the note's animations", err)
1248
+ }
1249
+ // A note moved to another moment on the element's row: its clip summary follows.
1250
+ const clip = extra.t !== draft.t && !draft.el.pseudo ? clipFor(extra.t, draft.el.selector, D.focus && D.focus.el) : draft.clip
1251
+ // A dragged range wins over numbers typed in the note.
1252
+ const asked = extra.range && D.focus && D.focus.open ? null : extra.asked
1253
+ D.notes.push({ id: newNoteId(), t: extra.t, branchId: D.activeId, text, el: draft.el, clip, status: "pending", replies: [], range: extra.range, target: extra.target, anims: extra.anims, inside: extra.inside, asked })
1254
+ }
677
1255
  closeCard()
678
1256
  // Back to the Hand so the next click is on the prototype, not another note.
679
1257
  setPicking(null)
@@ -688,9 +1266,11 @@ function showNote(n) {
688
1266
  .map((r) => `<div class="reply ${r.from === "agent" ? "agent" : "user"}"><span class="who">${r.from === "agent" ? "Agent" : "You"}</span>${esc(r.text)}</div>`)
689
1267
  .join("")
690
1268
  const status = n.status || "pending"
1269
+ const at = n.anims ? NT.atPhrase(noteForServer(n)) : null
1270
+ const clipLine = at || (n.clip ? shortClip(n.clip) : "")
691
1271
  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)}
1272
+ ${clipLine ? `<div class="note-clip">${esc(clipLine)}</div>` : ""}
1273
+ ${details(n.el, n.target && n.target.geometry)}
694
1274
  ${replies ? `<div class="replies">${replies}</div>` : ""}
695
1275
  <div class="note-actions"><span class="status s-${esc(status)}">${esc(status[0].toUpperCase() + status.slice(1))}</span>
696
1276
  <button data-note-a="delete" class="quiet">Delete</button>
@@ -699,6 +1279,11 @@ function showNote(n) {
699
1279
  if (keepOpen) card.querySelector(".details").open = true
700
1280
  card.hidden = false
701
1281
  placeCard(anchorOf(n.el))
1282
+ // Its element row, opened clip and range come back with it.
1283
+ if (n.anims && n.anims.length && (!D.focus || D.focus.note !== n)) {
1284
+ focusFromNote(n)
1285
+ if (D.focus) D.focus.note = n
1286
+ }
702
1287
  }
703
1288
 
704
1289
  // Tell the server about a status change (the session save carries it too).
@@ -711,6 +1296,7 @@ function closeCard() {
711
1296
  card.hidden = true
712
1297
  draft = null
713
1298
  openNote = null
1299
+ clearFocus()
714
1300
  }
715
1301
 
716
1302
  // Returns true when the click was one of ours.
@@ -718,6 +1304,10 @@ function handleNoteClick(b) {
718
1304
  const act = b.dataset.noteA
719
1305
  if (act === "save") saveDraft()
720
1306
  if (act === "cancel") closeCard()
1307
+ if (act === "asked" && draft) {
1308
+ draft.askedReading = draft.askedReading === "recording" ? null : "recording"
1309
+ updateAsked()
1310
+ }
721
1311
  if (act === "copy" && openNote) {
722
1312
  copy(prompt(openNote), b)
723
1313
  // Copied: fold the note back to its pin.
@@ -805,6 +1395,10 @@ function renderExtras(s) {
805
1395
  t.classList.toggle("on", t.dataset.tool === tool)
806
1396
  t.setAttribute("aria-selected", String(t.dataset.tool === tool))
807
1397
  }
1398
+ syncEmbeds()
1399
+ watchClasses(D.frame)
1400
+ if (D.building) watchClasses(D.building.frame)
1401
+ document.body.classList.toggle("picking-notes", mode() === "comment")
808
1402
 
809
1403
  updateLens()
810
1404
  renderLayers()