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.
package/src/server/mcp.js CHANGED
@@ -8,6 +8,9 @@
8
8
  // Finds the dev server from --url / RETAKE_URL, else <cwd or a parent>/.retake/server.json.
9
9
  import fs from "node:fs"
10
10
  import path from "node:path"
11
+ import { fmt, parseTime, readRecording, report } from "./moments.js"
12
+ import { cleanSource, isCompiled, resolveSource } from "./sourcemap.js"
13
+ import { animFromClip, animationReport, animationSummary, elementBlock, animationBlock, noteText, primaryOf } from "../note-text.js"
11
14
 
12
15
  const PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"]
13
16
  const PKG = JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"))
@@ -17,7 +20,7 @@ function findServerInfo(from = process.cwd()) {
17
20
  const f = path.join(dir, ".retake", "server.json")
18
21
  if (fs.existsSync(f)) {
19
22
  try {
20
- return JSON.parse(fs.readFileSync(f, "utf8"))
23
+ return { ...JSON.parse(fs.readFileSync(f, "utf8")), root: dir }
21
24
  } catch {}
22
25
  }
23
26
  if (path.dirname(dir) === dir) return null
@@ -26,7 +29,7 @@ function findServerInfo(from = process.cwd()) {
26
29
 
27
30
  /** @param {{ url?: string | null, token?: string }} [options] */
28
31
  export function createClient({ url, token } = {}) {
29
- /** @type {{ url: string, token?: string } | null} */
32
+ /** @type {{ url: string, token?: string, root?: string } | null} */
30
33
  let info = null
31
34
  const base = () => {
32
35
  if (url) return url.replace(/\/$/, "")
@@ -60,7 +63,13 @@ export function createClient({ url, token } = {}) {
60
63
  }
61
64
  return {
62
65
  base,
66
+ // The project folder (where .retake/ is), to make source paths relative.
67
+ root: () => {
68
+ info = info || findServerInfo()
69
+ return (info && info.root) || process.cwd()
70
+ },
63
71
  session: () => call("GET", "/__retake/session"),
72
+ recording: (branchId) => call("GET", `/__retake/recording/${encodeURIComponent(branchId)}`),
64
73
  notes: () => call("GET", "/__retake/notes"),
65
74
  note: (id) => call("GET", `/__retake/notes/${encodeURIComponent(id)}`),
66
75
  patch: (id, body) => call("PATCH", `/__retake/notes/${encodeURIComponent(id)}`, body),
@@ -101,11 +110,6 @@ export function createClient({ url, token } = {}) {
101
110
 
102
111
  // ---- presenting notes -----------------------------------------------------------
103
112
 
104
- const fmt = (ms) => {
105
- const s = Math.max(0, Number(ms) || 0) / 1000
106
- return `${String(Math.floor(s / 60)).padStart(2, "0")}:${(s % 60).toFixed(2).padStart(5, "0")}`
107
- }
108
-
109
113
  // Older sessions named timelines "Main" and "Take N"; the dock now shows
110
114
  // those defaults as "Timeline N" (migratedName in shell/10-dock.js).
111
115
  function withNames(session) {
@@ -124,21 +128,49 @@ function summary(n, session) {
124
128
  at: fmt(n.t),
125
129
  selector: n.selector || (n.el && n.el.selector) || null,
126
130
  component: n.component || (n.el && n.el.components && n.el.components[0]) || null,
127
- source: n.source ? `${n.source.file}${n.source.line ? ":" + n.source.line : ""}` : null,
131
+ source: n.source ? (unmapped(n.source) ? "unknown (compiled bundle; get_note tries its source map)" : `${n.source.file}${n.source.line ? ":" + n.source.line : ""}`) : null,
132
+ ...(n.range ? { range: `${fmt(n.range.from)} → ${fmt(n.range.to)}` } : {}),
133
+ ...(animationSummary(n) ? { animation: animationSummary(n) } : {}),
128
134
  replies: (n.replies || []).length,
129
135
  }
130
136
  }
131
137
 
132
- function clipText(c) {
138
+ // A source that is only a compiled bundle's line (no source map read yet).
139
+ const unmapped = (src) => !!src && !!(src.compiled || isCompiled(src.file))
140
+
141
+ function clipText(c, t) {
133
142
  const name = c.label ? `${c.label} ` : ""
134
143
  const at = `${Math.round(Number(c.offset) || 0)}ms into`
135
- return Number(c.duration) > 0 ? `${at} a ${Math.round(c.duration)}ms ${name}animation (clip ${c.id})` : `${at} a running ${name}animation (clip ${c.id})`
144
+ const start = c.start != null ? c.start : t - (Number(c.offset) || 0)
145
+ const end = c.end !== undefined ? c.end : Number(c.duration) > 0 ? start + Number(c.duration) : null
146
+ const span = `; it runs ${fmt(start)} → ${end != null ? fmt(end) : "still running"}`
147
+ return Number(c.duration) > 0 ? `${at} a ${Math.round(c.duration)}ms ${name}animation (clip ${c.id}${span})` : `${at} a running ${name}animation (clip ${c.id}${span})`
148
+ }
149
+
150
+ // Where the element was written, as one line: the note's own source, the
151
+ // original file:line behind a compiled bundle's (see resolveSourceOf), or why
152
+ // it isn't known.
153
+ function sourceLine(n, resolved) {
154
+ const src = n.source
155
+ if (!src) return null
156
+ if (resolved && resolved.file) return `Source: ${resolved.file}${resolved.line ? ":" + resolved.line : ""} (through the source map of the bundle it was served in)`
157
+ if (unmapped(src)) {
158
+ const by = [n.component || (n.el && n.el.components && n.el.components[0]), n.selector || (n.el && n.el.selector)].filter(Boolean)
159
+ return `Source: not known. The note only has a line of a compiled bundle${resolved && resolved.library ? ", which maps into library code" : ", with no source map to read it by"}; find the element by ${by.length > 1 ? `its component (${by[0]}) and selector` : by.length ? by[0] : "its selector"} instead.`
160
+ }
161
+ return `Source: ${src.file}${src.line ? ":" + src.line : ""}`
136
162
  }
137
163
 
138
- // Everything an agent needs to act on one note, as readable text.
139
- function describe(n, session) {
164
+ // Everything an agent needs to act on one note, as readable text. A note
165
+ // that says which animations it is about (anims, from the dock) is written by
166
+ // the same formatter as the dock's Copy for agent (src/note-text.js).
167
+ function describe(n, session, resolved = null) {
140
168
  const b = (session.branches || []).find((x) => String(x.id) === String(n.branchId))
141
169
  const parent = b && (session.branches || []).find((x) => String(x.id) === String(b.parentId))
170
+ if (Array.isArray(n.anims)) {
171
+ const text = noteText(n, { start: 0, timeline: b ? b.name : String(n.branchId), parent: parent ? parent.name : null, forkAt: parent ? b.forkAt : null, sourceLine: sourceLine(n, resolved) || undefined, status: true })
172
+ return `${text}\n\nThe note is pinned to ${fmt(n.range ? n.range.from : n.t)} in the recording of that timeline. get_moment with id "${n.id}" shows what happened around it; get_animation with id "${n.id}" maps any recording time onto the animation's own clock.`
173
+ }
142
174
  const el = n.el || {}
143
175
  const lines = [
144
176
  `Note ${n.id} (${n.status || "pending"}) on timeline "${b ? b.name : n.branchId}" at ${fmt(n.t)}`,
@@ -149,11 +181,13 @@ function describe(n, session) {
149
181
  `Element: ${el.label || ""}${el.text ? ` "${el.text}"` : ""}`.trim(),
150
182
  `Selector: ${n.selector || el.selector || "?"}`,
151
183
  n.component || (el.components && el.components.length) ? `Component: ${n.component || el.components.join(" < ")}` : null,
152
- n.source ? `Source: ${n.source.file}${n.source.line ? ":" + n.source.line : ""}` : null,
184
+ sourceLine(n, resolved),
153
185
  n.classes && n.classes.length ? `Classes: ${[].concat(n.classes).join(" ")}` : null,
154
186
  n.rect ? `Box: ${Math.round(n.rect.w)}×${Math.round(n.rect.h)} at (${Math.round(n.rect.x)}, ${Math.round(n.rect.y)})` : null,
155
- n.clip ? `During an animation: ${clipText(n.clip)}` : null,
187
+ n.clip ? `During an animation: ${clipText(n.clip, n.t)}` : null,
156
188
  el.page ? `Page: ${el.page}` : null,
189
+ "",
190
+ `The note is pinned to ${fmt(n.t)} in the recording of that timeline: the user means what was on screen then. get_moment with id "${n.id}" shows what happened around it (actions, animations on this element with their start and end, requests); check it before asking the user about timing.`,
157
191
  ]
158
192
  if ((n.replies || []).length) {
159
193
  lines.push("", "Conversation:")
@@ -175,6 +209,52 @@ const TOOLS = [
175
209
  description: "Everything about one note: what the user asked, the element (selector, React component, source file:line, classes, size), the moment and timeline, and the conversation so far.",
176
210
  inputSchema: { type: "object", properties: { id: { type: ["string", "number"] } }, required: ["id"] },
177
211
  },
212
+ {
213
+ name: "get_moment",
214
+ description:
215
+ "What happened in the recording around a note's moment (or any moment of a timeline): the user's actions, route changes and requests with their times, the animations on or near the noted element (start, end, duration, what they animate between, how far along at the moment), other animations, and when the screen changed most. Use it before asking the user about timing: a note is pinned to a moment, and the answer is usually in the recording.",
216
+ inputSchema: {
217
+ type: "object",
218
+ properties: {
219
+ id: { type: ["string", "number"], description: "A note id: the moment, timeline and element come from the note" },
220
+ timeline: { type: ["string", "number"], description: "Without a note: a timeline's name or id (default: the active one)" },
221
+ at: { type: ["string", "number"], description: "Without a note: the moment, as the dock shows it (00:10.91)" },
222
+ selector: { type: "string", description: "Without a note: an element to focus on" },
223
+ before_seconds: { type: "number", description: "How far back to look (default 5)" },
224
+ after_seconds: { type: "number", description: "How far ahead to look (default 5)" },
225
+ },
226
+ },
227
+ },
228
+ {
229
+ name: "get_animation",
230
+ description:
231
+ "One animation in detail: what it is and where it is defined, what started it, its timing (delay, duration, iterations, easing) and every keyframe, and recording times mapped onto its own clock (local ms after its delay, progress, eased progress, the keyframe segment) with the conversion to CSS %, Motion `times` and GSAP seconds. Values and boxes are included where the note sampled them. Give a note id (its primary animation, or `clip` to pick another of its animations), or a timeline and a clip id.",
232
+ inputSchema: {
233
+ type: "object",
234
+ properties: {
235
+ id: { type: ["string", "number"], description: "A note id: its primary animation and its moment or range" },
236
+ clip: { type: "string", description: "A clip id (from get_moment / get_timeline_events / the note)" },
237
+ timeline: { type: ["string", "number"], description: "Without a note: a timeline's name or id (default: the active one)" },
238
+ at: { type: ["string", "number"], description: "A recording time to map (00:01.20)" },
239
+ from: { type: ["string", "number"], description: "Start of a range to map" },
240
+ to: { type: ["string", "number"], description: "End of a range to map" },
241
+ },
242
+ },
243
+ },
244
+ {
245
+ name: "get_timeline_events",
246
+ description: "Everything recorded on a timeline between two times: the user's actions, routes, page loads, requests, animations (optionally around one element) and screen changes, in order. Times as the dock shows them (00:10.91).",
247
+ inputSchema: {
248
+ type: "object",
249
+ properties: {
250
+ timeline: { type: ["string", "number"], description: "A timeline's name or id (default: the active one)" },
251
+ from: { type: ["string", "number"], description: "Start of the window (default: the start)" },
252
+ to: { type: ["string", "number"], description: "End of the window (default: the end)" },
253
+ selector: { type: "string", description: "Focus on the animations on or near this element" },
254
+ limit: { type: "number", description: "Most events to list (default 60, max 200)" },
255
+ },
256
+ },
257
+ },
178
258
  {
179
259
  name: "get_active_timeline",
180
260
  description: "Which timeline the user is looking at in the Retake dock, how it relates to the others (branch point, code version) and its open notes.",
@@ -202,6 +282,30 @@ const TOOLS = [
202
282
  },
203
283
  ]
204
284
 
285
+ // The guide every coding agent gets at initialize (also in skills/retake-notes).
286
+ const INSTRUCTIONS = `Notes are change requests the user left on elements of their app in the Retake timeline, each pinned to a moment (or a range) of a recording of the running app. list_notes, then get_note for details; get_moment shows what happened around a note's moment (actions, animations on the element, requests), so check it before asking the user about timing. acknowledge when you start, resolve with a summary when done.
287
+
288
+ Animations. A note on an animated element names the animation (its kind, where it is defined, its timing and every keyframe) and the exact point or range the user meant on that animation's own clock: local ms after its delay, progress (local / duration), eased progress (after the effect's easing; keyframe offsets apply to this), and the keyframe segment it falls in, with the computed values and the element's box there and one frame either side. Numbers the user typed ("at 100ms", "200-400 ms") are local time of that animation unless the note says otherwise. Change only what the note scopes: keep the values at every other point and the total duration.
289
+ Most notes end with an "Exact edit": the animation's keyframes again on plain time, with the note's point or range edges as keyframes of their own and the matching part of each curve on both sides. Put it in place of the original (it moves exactly as before), then change only the marked part. If it says the @keyframes is shared, it gives this element its own copy; keep that unless the user meant every element.
290
+ - CSS @keyframes: add stops at the range edges as % (local / duration) with the edge values given, so the rest doesn't move; change only what is between them. animation-timing-function inside a keyframe applies to the segment after it (default ease). A hold = two stops with the same value. If the duration changes, recompute every %. If the selector matches several elements, scope the change unless all were meant.
291
+ - CSS transition: one segment only. For a hold, a two-step motion or a sub-range, use linear(...) stops or a cubic-bezier, or replace it with a keyframes animation started by the same trigger (given).
292
+ - WAAPI (element.animate): add { offset, ...values, easing } at the edges. With an effect-level easing, offsets apply after it: set the effect easing to linear and move easing onto each keyframe first.
293
+ - Motion: values are arrays with times (0-1 of the duration, delay excluded) and an ease array (one per segment); insert entries at local / duration. A spring has no ms range: convert it to keyframes + times, or tune stiffness/damping/bounce.
294
+ - GSAP: retime with the position parameter ("<", "-=0.4", seconds); change part of a tween by splitting it into two .to() calls at the edge values.
295
+ - Script-driven (inline style writes): edit the code at the given location and gate the change on the same elapsed time, or move it into keyframes.
296
+ - SVG stroke draw: the dash offset is linear in progress; "pause halfway" = two equal stops around 50%.
297
+ - Scroll-driven: the axis is scroll progress, not time; edit the keyframe % or animation-range. For JS parallax, clamp to a scroll range instead of changing the factor.
298
+ - canvas / WebGL: Retake can't see inside; edit the per-frame code, gated on the same elapsed time.
299
+ get_animation maps any recording time onto an animation's clock and into CSS %, Motion times and GSAP seconds. After editing, check the values at the range edges and one frame either side against the note's.`
300
+
301
+ // For get_moment on a note: which recorded clips the note says are its element's.
302
+ function knownOf(note) {
303
+ const rel = new Map()
304
+ for (const a of note.anims || []) if (a.id) rel.set(String(a.id), a.relation === "on" ? "on" : a.relation || "on")
305
+ for (const c of note.inside || []) if (c.id && !rel.has(String(c.id))) rel.set(String(c.id), "inside")
306
+ return { rel, running: (note.anims || []).map((a) => a.name || a.kind) }
307
+ }
308
+
205
309
  const isOpen = (n) => !n.status || n.status === "pending" || n.status === "acknowledged"
206
310
 
207
311
  export function createTools(client) {
@@ -213,6 +317,34 @@ export function createTools(client) {
213
317
  })
214
318
  return note
215
319
  }
320
+ // A timeline by name or id (default: the active one) and its recording.
321
+ const timelineOf = (session, which) => {
322
+ const branches = session.branches || []
323
+ const b = which == null || which === "" ? branches.find((x) => String(x.id) === String(session.activeId)) || branches[0] : branches.find((x) => String(x.id) === String(which)) || branches.find((x) => String(x.name).toLowerCase() === String(which).toLowerCase())
324
+ if (!b) throw new Error(which == null ? "no timelines yet: record something in the dock first" : `no timeline "${which}"; get_active_timeline lists them`)
325
+ return b
326
+ }
327
+ const recordingOf = async (b) => {
328
+ const json = await client.recording(b.id).catch((err) => {
329
+ if (/→ 404/.test(err.message)) throw new Error(`timeline "${b.name}" has no saved recording yet (the dock saves it when paused); ask the user to pause, then try again`)
330
+ throw err
331
+ })
332
+ return readRecording(json)
333
+ }
334
+ const resolved = new Map()
335
+ const resolveSourceOf = async (n) => {
336
+ const src = n.source
337
+ if (!src || !unmapped(src)) return null
338
+ const key = `${src.file}:${src.line}:${src.col || ""}`
339
+ if (!resolved.has(key)) resolved.set(key, resolveSource(src, { base: client.base(), root: client.root() }).catch(() => null))
340
+ return resolved.get(key)
341
+ }
342
+ // An absolute source path inside the project reads better relative to it.
343
+ const relSource = (n) => {
344
+ if (!n.source || !n.source.file || !String(n.source.file).startsWith("/")) return n
345
+ const file = cleanSource(n.source.file, { root: client.root() })
346
+ return file === n.source.file ? n : { ...n, source: { ...n.source, file } }
347
+ }
216
348
  const handlers = {
217
349
  async list_notes({ status = "open" } = {}) {
218
350
  const session = await readSession()
@@ -221,7 +353,71 @@ export function createTools(client) {
221
353
  },
222
354
  async get_note({ id }) {
223
355
  const [session, note] = await Promise.all([readSession(), byId(id)])
224
- return describe(note, session)
356
+ return describe(relSource(note), session, await resolveSourceOf(note))
357
+ },
358
+ async get_moment(/** @type {any} */ { id, timeline, at, selector, before_seconds = 5, after_seconds = 5 } = {}) {
359
+ const session = await readSession()
360
+ const before = Math.min(Math.max(Number(before_seconds) || 0, 0), 120) * 1000
361
+ const after = Math.min(Math.max(Number(after_seconds) || 0, 0), 120) * 1000
362
+ if (id != null && id !== "") {
363
+ const note = await byId(id)
364
+ const b = timelineOf(session, note.branchId)
365
+ const rec = await recordingOf(b)
366
+ const sel = note.selector || (note.el && note.el.selector) || null
367
+ const component = note.component || (note.el && note.el.components && note.el.components[0]) || null
368
+ const title = `Timeline "${b.name}" around note ${note.id} ("${note.text}") on ${sel || "an element"}:`
369
+ if (!Array.isArray(note.anims)) return report(rec, { at: note.t, from: note.t - before, to: note.t + after, selector: sel, component, title })
370
+ // The note knows which animations are its element's (by their recorded
371
+ // path): the element section and the verdict come from it, so this and
372
+ // get_note can't disagree.
373
+ const t0 = note.range ? note.range.from : note.t
374
+ const t1 = note.range ? note.range.to : note.t
375
+ const ctx = { start: rec.start || 0 }
376
+ const head = [...elementBlock(note, ctx), "", ...animationBlock(note, ctx)].join("\n")
377
+ const ctxReport = report(rec, { at: note.t, from: t0 - before, to: t1 + after, selector: sel, component, title, known: knownOf(note) })
378
+ return `Note ${note.id}'s element:\n${head}\n\n${ctxReport}`
379
+ }
380
+ if (at == null || at === "") throw new Error("get_moment needs a note id, or a timeline moment (at, like 00:10.91)")
381
+ const b = timelineOf(session, timeline)
382
+ const rec = await recordingOf(b)
383
+ const t = parseTime(at) + (rec.start || 0)
384
+ return report(rec, { at: t, from: t - before, to: t + after, selector: selector || null, title: `Timeline "${b.name}" around ${fmt(t - (rec.start || 0))}:` })
385
+ },
386
+ async get_animation(/** @type {any} */ { id, clip, timeline, at, from, to } = {}) {
387
+ const session = await readSession()
388
+ const times = (rec) => [at, from, to].filter((v) => v != null && v !== "").map((v) => parseTime(v) + (rec.start || 0))
389
+ if (id != null && id !== "") {
390
+ const note = await byId(id)
391
+ const anims = note.anims || []
392
+ let a = clip ? anims.find((x) => String(x.id) === String(clip)) : primaryOf(note)
393
+ const b = timelineOf(session, note.branchId)
394
+ const rec = await recordingOf(b).catch(() => null)
395
+ if (!a && rec) {
396
+ const c = (rec.clips || []).find((x) => String(x.id) === String(clip || (note.clip && note.clip.id)))
397
+ if (c) a = animFromClip(c)
398
+ }
399
+ if (!a) return `Note ${note.id} has no animation${anims.length ? ` with clip id ${clip}` : ""}: nothing animated on its element at its moment. get_moment with id "${note.id}" lists what animated nearby.`
400
+ const sampled = [a.at, a.from, a.to, ...(a.samples || [])].filter(Boolean)
401
+ const Ts = rec && times(rec).length ? times(rec) : sampled.length ? [...new Set([a.at, a.from, a.to].filter(Boolean).map((p) => p.T))] : []
402
+ return `Animation of note ${note.id} on ${note.selector || (note.el && note.el.selector) || "its element"}:\n${animationReport(a, Ts, { start: rec ? rec.start || 0 : 0, sampled })}`
403
+ }
404
+ if (!clip) throw new Error("get_animation needs a note id, or a clip id (with a timeline)")
405
+ const b = timelineOf(session, timeline)
406
+ const rec = await recordingOf(b)
407
+ const c = (rec.clips || []).find((x) => String(x.id) === String(clip))
408
+ if (!c) throw new Error(`no clip ${clip} on timeline "${b.name}"; get_timeline_events lists them`)
409
+ return `Timeline "${b.name}", clip ${c.id} on ${c.selector || "?"}:\n${animationReport(animFromClip(c), times(rec), { start: rec.start || 0 })}`
410
+ },
411
+ async get_timeline_events(/** @type {any} */ { timeline, from, to, selector, limit = 60 } = {}) {
412
+ const session = await readSession()
413
+ const b = timelineOf(session, timeline)
414
+ const rec = await recordingOf(b)
415
+ const s = rec.start || 0
416
+ const lo = from == null || from === "" ? null : parseTime(from) + s
417
+ const hi = to == null || to === "" ? null : parseTime(to) + s
418
+ if (lo != null && hi != null && hi < lo) throw new Error("`to` is before `from`")
419
+ const n = Math.min(Math.max(Number(limit) || 60, 1), 200)
420
+ return report(rec, { from: lo, to: hi, selector: selector || null, limit: n, title: `Timeline "${b.name}":` })
225
421
  },
226
422
  async get_active_timeline() {
227
423
  const session = await readSession()
@@ -286,7 +482,8 @@ export async function runMcp({ url } = {}) {
286
482
  protocolVersion: PROTOCOLS.includes(asked) ? asked : PROTOCOLS[0],
287
483
  capabilities: { tools: { listChanged: false } },
288
484
  serverInfo: { name: "retake", version: PKG.version },
289
- instructions: "Notes are change requests the user left on elements of their prototype in the Retake timeline. list_notes, then get_note for details; acknowledge when you start, resolve with a summary when done.",
485
+ instructions:
486
+ INSTRUCTIONS,
290
487
  })
291
488
  }
292
489
  if (method === "notifications/initialized" || (method && method.startsWith("notifications/"))) return
@@ -0,0 +1,317 @@
1
+ // What happened over time in a stored recording (.retake/recordings), as
2
+ // short readable text for `retake mcp`: the user's actions, animations on or
3
+ // near an element (with what they animate between), requests, page loads and
4
+ // how much of the screen changed, around a moment or over a window. Times
5
+ // read like the dock's (00:10.91, from the recording's start).
6
+ // The recording's format is the runtime's (src/runtime/30-recorder.js).
7
+
8
+ /** ms → "00:10.91" */
9
+ export const fmt = (ms) => {
10
+ const s = Math.max(0, Number(ms) || 0) / 1000
11
+ return `${String(Math.floor(s / 60)).padStart(2, "0")}:${(s % 60).toFixed(2).padStart(5, "0")}`
12
+ }
13
+ /** ms → "0.21s" / "1.20s" / "350ms" */
14
+ const dur = (ms) => (Math.abs(ms) < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(2)}s`)
15
+
16
+ /** "00:10.91", "10.91", "10.91s", "1:02.5", "350ms" or a number of ms → ms. */
17
+ export function parseTime(v) {
18
+ if (v == null || v === "") return null
19
+ if (typeof v === "number") return v
20
+ const s = String(v).trim()
21
+ let m
22
+ if ((m = /^(\d+):(\d+(?:\.\d+)?)$/.exec(s))) return (Number(m[1]) * 60 + Number(m[2])) * 1000
23
+ if ((m = /^(\d+(?:\.\d+)?)\s*ms$/.exec(s))) return Number(m[1])
24
+ if ((m = /^(\d+(?:\.\d+)?)\s*s?$/.exec(s))) return Number(m[1]) * 1000
25
+ throw new Error(`can't read the time "${v}"; use the dock's format, like 00:10.91`)
26
+ }
27
+
28
+ /** A recording as the server keeps it (v1, or packed v2/v3) → plain v1 form. */
29
+ export function readRecording(json) {
30
+ const o = typeof json === "string" ? JSON.parse(json) : { ...json }
31
+ if (!o || typeof o !== "object") return null
32
+ if (o.v === 2 && o.paths) {
33
+ const P = o.paths
34
+ o.events = (o.events || []).map((ev) => {
35
+ const out = { ...ev }
36
+ if ("p" in out) (out.path = P[out.p]), delete out.p
37
+ if ("rp" in out) (out.related = P[out.rp]), delete out.rp
38
+ return out
39
+ })
40
+ } else if (o.v === 3) {
41
+ const P = o.paths || []
42
+ const S = o.strs || []
43
+ const frames = o.frames || []
44
+ let lastF = 0
45
+ o.events = (o.events || []).map((ev) => {
46
+ const out = { ...ev }
47
+ out.type = S[out.y]
48
+ delete out.y
49
+ if ("p" in out) (out.path = P[out.p]), delete out.p
50
+ if ("rp" in out) (out.related = P[out.rp]), delete out.rp
51
+ if ("c" in out) (out.css = S[out.c]), delete out.c
52
+ if ("f" in out) {
53
+ lastF += out.f
54
+ out.t = frames[lastF]
55
+ delete out.f
56
+ }
57
+ return out
58
+ })
59
+ o.clips = (o.clips || []).map((c) => {
60
+ const out = { ...c }
61
+ if ("s" in out) (out.selector = S[out.s]), delete out.s
62
+ if ("co" in out) (out.component = S[out.co]), delete out.co
63
+ if ("l" in out) (out.label = S[out.l]), delete out.l
64
+ if ("p" in out) (out.path = P[out.p]), delete out.p
65
+ return out
66
+ })
67
+ }
68
+ delete o.paths
69
+ delete o.strs
70
+ o.v = 1
71
+ o.events = o.events || []
72
+ o.clips = o.clips || []
73
+ return o
74
+ }
75
+
76
+ // ---- elements --------------------------------------------------------------------------
77
+ // Clip selectors and note selectors are built the same way (an id, else
78
+ // tag.class chains with :nth-of-type), but classes change over time
79
+ // (".off" → ".on"), so they're also compared without classes.
80
+ const bare = (sel) => String(sel || "").replace(/::?(before|after|marker|placeholder|backdrop)$/, "").replace(/\.[\w-]+(?:\\.[\w-]*)*/g, "")
81
+ const under = (a, b) => a.startsWith(b + " > ") // a is inside b
82
+ /** How a clip's element relates to the noted one: "on" | "inside" | "around" | null. */
83
+ export function relation(clipSel, noteSel) {
84
+ if (!clipSel || !noteSel) return null
85
+ const c = String(clipSel).replace(/::?(before|after|marker|placeholder|backdrop)$/, "")
86
+ const n = String(noteSel)
87
+ if (c === n || bare(c) === bare(n)) return "on"
88
+ if (under(c, n) || under(bare(c), bare(n))) return "inside"
89
+ if (under(n, c) || under(bare(n), bare(c))) {
90
+ const depth = bare(n).slice(bare(c).length).split(" > ").length - 1
91
+ return depth <= 4 ? "around" : null
92
+ }
93
+ return null
94
+ }
95
+
96
+ // ---- what happened -------------------------------------------------------------------
97
+ const SECRET = /pass|pwd|secret|token|card|cvv|cvc|ssn|otp/i
98
+ const MODS = ["ctrlKey", "metaKey", "altKey", "shiftKey"]
99
+ const NAMED_KEYS = /^(Enter|Escape|Tab|Backspace|Delete|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Home|End|PageUp|PageDown| )$/
100
+ const quote = (s, n = 40) => {
101
+ const t = String(s ?? "").replace(/\s+/g, " ").trim()
102
+ return `"${t.length > n ? t.slice(0, n - 1) + "…" : t}"`
103
+ }
104
+ const pathOfUrl = (u) => {
105
+ try {
106
+ const x = new URL(u)
107
+ x.searchParams.delete("__wb")
108
+ return x.pathname + x.search + x.hash
109
+ } catch {
110
+ return String(u || "")
111
+ }
112
+ }
113
+
114
+ /** The user's actions, routes, page loads and requests, as [{ t, end?, kind, text, selector? }]. */
115
+ export function happenings(rec) {
116
+ const out = []
117
+ const start = rec.start || 0
118
+ /** @type {any} */
119
+ let burst = null
120
+ /** @type {any} */
121
+ let keys = null
122
+ /** @type {any} */
123
+ let scroll = null
124
+ for (const ev of rec.events) {
125
+ if (ev.t == null || ev.t < start) continue
126
+ if (ev.type === "click") {
127
+ const prev = out[out.length - 1]
128
+ if (prev && prev.kind === "focus" && prev.selector === ev.css && ev.t - prev.t < 1000) out.pop()
129
+ out.push({ t: ev.t, kind: "click", text: `click ${ev.label ? quote(ev.label) + " " : ""}(${ev.css || "?"})`, selector: ev.css })
130
+ } else if (ev.type === "submit") out.push({ t: ev.t, kind: "submit", text: `submit ${ev.css || "a form"}`, selector: ev.css })
131
+ else if (ev.type === "focusin" && ev.editable) {
132
+ const prev = out[out.length - 1]
133
+ if (!(prev && prev.kind === "click" && prev.selector === ev.css && ev.t - prev.t < 50)) out.push({ t: ev.t, kind: "focus", text: `focus ${ev.label ? quote(ev.label) + " " : ""}(${ev.css || "a field"})`, selector: ev.css })
134
+ } else if (ev.type === "input") {
135
+ const typed = /^insert/.test(ev.inputType || "insertText") ? (ev.data ? ev.data.length : 1) : 0
136
+ const hide = SECRET.test(`${ev.css || ""} ${ev.label || ""}`)
137
+ if (burst && burst.selector === ev.css && ev.t - burst.end < 800) {
138
+ burst.end = ev.t
139
+ burst.chars += typed
140
+ } else {
141
+ burst = { t: ev.t, end: ev.t, kind: "type", chars: typed, selector: ev.css, hide }
142
+ out.push(burst)
143
+ }
144
+ burst.value = ev.value
145
+ } else if (ev.type === "change" && (ev.checked != null || /select/i.test(ev.css || ""))) {
146
+ const text = ev.checked != null ? `${ev.checked ? "checked" : "unchecked"} ${ev.css || "a box"}` : `chose ${quote(ev.value)} in ${ev.css}`
147
+ out.push({ t: ev.t, kind: "change", text, selector: ev.css })
148
+ } else if (ev.type === "keydown" && !ev.inField && ev.key && (NAMED_KEYS.test(ev.key) || MODS.some((m) => ev[m]))) {
149
+ if (/^(Control|Meta|Alt|Shift)$/.test(ev.key)) continue
150
+ const name = [...MODS.filter((m) => ev[m]).map((m) => ({ ctrlKey: "Ctrl", metaKey: "⌘", altKey: "Alt", shiftKey: "Shift" })[m]), ev.key === " " ? "Space" : ev.key].join("+")
151
+ if (keys && keys.name === name && ev.t - keys.end < 1000) {
152
+ keys.end = ev.t
153
+ keys.n++
154
+ } else out.push((keys = { t: ev.t, end: ev.t, kind: "key", name, n: 1 }))
155
+ } else if (ev.type === "nav") out.push({ t: ev.t, kind: "nav", text: `back/forward to ${pathOfUrl(ev.url)}` })
156
+ else if (ev.type === "scroll") {
157
+ const where = ev.path === "d" ? "the page" : "an element"
158
+ if (scroll && scroll.where === where && ev.t - scroll.end < 600) {
159
+ scroll.end = ev.t
160
+ scroll.top = ev.top
161
+ } else out.push((scroll = { t: ev.t, end: ev.t, kind: "scroll", where, top: ev.top }))
162
+ }
163
+ }
164
+ for (const h of out) {
165
+ if (h.kind === "type") {
166
+ h.text = `typed ${h.chars} char${h.chars === 1 ? "" : "s"} in ${h.selector || "a field"}${h.hide || h.value == null || /</.test(h.value) ? "" : ` (now ${quote(h.value)})`}`
167
+ delete h.value
168
+ } else if (h.kind === "key") h.text = `key ${h.name}${h.n > 1 ? ` ×${h.n}` : ""}`
169
+ else if (h.kind === "scroll") h.text = `scrolled ${h.where} to ${Math.round(h.top || 0)}px`
170
+ if (h.end === h.t) delete h.end
171
+ }
172
+ for (const r of rec.routes || []) if (r.t >= start && r.t > 0) out.push({ t: r.t, kind: "route", text: `route → ${r.path}` })
173
+ for (const s of rec.segments || []) out.push({ t: s.t, kind: "load", text: `page loaded: ${pathOfUrl(s.url)}` })
174
+ out.push(...requests(rec))
175
+ // At the same moment, what the user did comes before what it set off.
176
+ const rank = (h) => (h.kind === "request" ? 2 : h.kind === "route" || h.kind === "load" ? 1 : 0)
177
+ return out.sort((a, b) => a.t - b.t || rank(a) - rank(b))
178
+ }
179
+
180
+ // Recorded requests: when they went out, when they finished, how they ended.
181
+ function requests(rec) {
182
+ const ends = new Map()
183
+ for (const ev of rec.events) if (ev.type === "net" && (ev.kind === "end" || ev.kind === "done" || ev.kind === "close")) ends.set(`${ev.list}:${ev.i}`, ev.t)
184
+ const out = []
185
+ const kinds = { fetches: "", xhrs: "XHR ", sses: "EventSource ", sockets: "WebSocket " }
186
+ for (const list of Object.keys(kinds)) {
187
+ ;(rec[list] || []).forEach((e, i) => {
188
+ if (!e || e.t0 == null) return
189
+ const key = String(e.key || "").replace(/ (rsc|next-router-[\w-]+|next-action)=\S+/g, (m) => (/next-action/.test(m) ? " (server action)" : /rsc/.test(m) ? " (RSC)" : ""))
190
+ const end = ends.get(`${list}:${i}`)
191
+ const how = e.fail ? `failed (${e.fail})` : e.error ? "failed" : e.status ? `${e.status}` : list === "fetches" || list === "xhrs" ? "no answer recorded" : "opened"
192
+ out.push({ t: e.t0, end, kind: "request", text: `${kinds[list]}${key} → ${how}${end != null && end > e.t0 ? ` (${dur(end - e.t0)})` : ""}` })
193
+ })
194
+ }
195
+ return out
196
+ }
197
+
198
+ // "css-animation `fade-in` (opacity, transform)", "opacity transition"
199
+ function clipName(c) {
200
+ const what = c.kind === "transition" ? `${c.property || "?"} transition` : c.kind === "css-animation" ? `animation \`${c.label}\`${c.property ? ` (${c.property})` : ""}` : `animation (${c.property || c.label || "waapi"})`
201
+ return what
202
+ }
203
+ function values(c) {
204
+ if (!c.from || !c.to) return ""
205
+ const keys = [...new Set([...Object.keys(c.from), ...Object.keys(c.to)])]
206
+ return keys.map((k) => `${k} ${c.from[k] ?? "?"} → ${c.to[k] ?? "?"}`).join(", ")
207
+ }
208
+ const clipEnd = (c, rec) => (c.end == null ? Math.max(rec.end || 0, c.start) : c.end)
209
+
210
+ function clipLine(c, rec, at) {
211
+ const s = rec.start || 0
212
+ const loops = c.iterations === "infinite"
213
+ const span = c.end == null ? `${fmt(c.start - s)} → ${loops ? `looping${c.dur ? ` (${dur(c.dur)} each)` : ""}` : "still running"}` : `${fmt(c.start - s)} → ${fmt(c.end - s)} (${dur(c.end - c.start)}${c.iterations > 1 ? `, ${c.iterations}×` : ""})`
214
+ const bits = [`${span} ${clipName(c)} on ${c.selector || "?"}${c.component ? ` [${c.component}]` : ""}`]
215
+ const v = values(c)
216
+ if (v) bits.push(v)
217
+ if (c.delay) bits.push(`after a ${dur(c.delay)} delay`)
218
+ if (at != null) {
219
+ const end = clipEnd(c, rec)
220
+ if (at < c.start) bits.push(`starts ${dur(c.start - at)} after the moment`)
221
+ else if (at > end) bits.push(`ended ${dur(at - end)} before the moment`)
222
+ else if (c.end != null && c.end > c.start) bits.push(`running at the moment, ${Math.round(((at - c.start) / (c.end - c.start)) * 100)}% through`)
223
+ else bits.push("running at the moment")
224
+ }
225
+ return `- ${bits.join("; ")} (clip ${c.id})`
226
+ }
227
+
228
+ /**
229
+ * The report. Either a moment (`at`, with a window around it) or a window.
230
+ * @param {any} rec a readRecording() result
231
+ * @param {{ at?: number | null, from?: number | null, to?: number | null, selector?: string | null, component?: string | null,
232
+ * title?: string, limit?: number, known?: { rel: Map<string, string>, running: string[] } | null }} opts times in the recording's own clock (ms);
233
+ * `known`: a note's own word on which clips are its element's (by recorded path) and what was running, used instead of selectors
234
+ */
235
+ export function report(rec, { at = null, from = null, to = null, selector = null, component = null, title = "", limit = 40, known = null } = {}) {
236
+ const s = rec.start || 0
237
+ const end = Math.max(rec.end || 0, s)
238
+ const lo = Math.max(s, from ?? s)
239
+ const hi = Math.min(end, to ?? end)
240
+ const lines = []
241
+ if (title) lines.push(title)
242
+ lines.push(`Window: ${fmt(lo - s)} → ${fmt(hi - s)} of a ${fmt(end - s)} recording${at != null ? `; the moment is ${fmt(at - s)}` : ""}.`)
243
+
244
+ // Animations on or near the element.
245
+ const overlaps = (c) => c.start <= hi && clipEnd(c, rec) >= lo
246
+ if (selector) {
247
+ const relOf = (c) => (known ? known.rel.get(String(c.id)) || null : relation(c.selector, selector) || (component && c.component === component ? "same component" : null))
248
+ const near = rec.clips.map((c) => ({ c, rel: relOf(c) })).filter((x) => x.rel)
249
+ const inWin = near.filter((x) => overlaps(x.c))
250
+ lines.push("", `Animations on or near ${selector}${component ? ` [${component}]` : ""}:`)
251
+ if (!inWin.length) lines.push("- none in this window")
252
+ const order = { on: 0, inside: 1, around: 2, "same component": 3 }
253
+ inWin.sort((a, b) => (order[a.rel] ?? 0.5) - (order[b.rel] ?? 0.5) || a.c.start - b.c.start)
254
+ for (const { c, rel } of inWin.slice(0, 12)) lines.push(clipLine(c, rec, at).replace(/^- /, `- (${rel}) `))
255
+ if (inWin.length > 12) lines.push(`- …and ${inWin.length - 12} more`)
256
+ const outside = near.filter((x) => !overlaps(x.c) && x.rel === "on")
257
+ if (outside.length) lines.push(`Outside the window this element also animates at: ${outside.slice(0, 6).map(({ c }) => `${fmt(c.start - s)} (${c.kind === "transition" ? `${c.property} transition` : c.label})`).join(", ")}${outside.length > 6 ? `, +${outside.length - 6} more` : ""}.`)
258
+ if (known) lines.push(known.running.length ? `At the note's moment it is mid-animation (the note says): ${known.running.join(", ")}.` : "At the note's moment nothing on it is animating (the note says so; see above for what came before and after).")
259
+ else if (at != null) {
260
+ const running = inWin.filter(({ c, rel }) => (rel === "on" || rel === "inside") && c.start <= at && at <= clipEnd(c, rec))
261
+ lines.push(running.length ? `At the moment it is mid-animation: ${running.map(({ c }) => clipName(c)).join(", ")}.` : "At the moment nothing on it is animating (see above for what came before and after).")
262
+ }
263
+ }
264
+
265
+ // What happened, in order.
266
+ const all = happenings(rec).filter((h) => h.t <= hi && (h.end ?? h.t) >= lo)
267
+ let shown = all
268
+ let dropped = 0
269
+ if (all.length > limit) {
270
+ const ref = at ?? (lo + hi) / 2
271
+ shown = [...all].sort((a, b) => Math.abs(a.t - ref) - Math.abs(b.t - ref)).slice(0, limit).sort((a, b) => a.t - b.t)
272
+ dropped = all.length - shown.length
273
+ }
274
+ lines.push("", "What happened (user actions, routes, page loads, requests):")
275
+ /** @type {Array<{ t: number, line: string, moment?: boolean }>} */
276
+ const marks = shown.map((h) => ({ t: h.t, line: `- ${fmt(h.t - s)}${h.end != null && h.kind !== "request" ? `–${fmt(h.end - s)}` : ""} ${h.text}` }))
277
+ if (at != null) marks.push({ t: at, line: `- ${fmt(at - s)} ◆ the moment`, moment: true })
278
+ marks.sort((a, b) => a.t - b.t || (a.moment ? 1 : b.moment ? -1 : 0))
279
+ if (!shown.length) lines.push("- nothing recorded in this window")
280
+ lines.push(...marks.map((m) => m.line))
281
+ if (dropped) lines.push(`(${dropped} more not shown, the furthest from ${at != null ? "the moment" : "the middle"}; ask for a narrower window)`)
282
+ if (at != null) {
283
+ const acts = happenings(rec).filter((h) => ["click", "submit", "type", "key", "change", "focus", "nav"].includes(h.kind))
284
+ const before = acts.filter((h) => h.t <= at).pop()
285
+ const after = acts.find((h) => h.t > at)
286
+ lines.push(`Last action before the moment: ${before ? `${before.text} at ${fmt(before.t - s)} (${dur(at - before.t)} earlier)` : "none"}. Next after: ${after ? `${after.text} at ${fmt(after.t - s)}` : "none"}.`)
287
+ }
288
+
289
+ // Animations elsewhere on the page.
290
+ const others = rec.clips.filter((c) => overlaps(c) && !(selector && (known ? known.rel.has(String(c.id)) : relation(c.selector, selector))))
291
+ if (others.length) {
292
+ const groups = new Map()
293
+ for (const c of others) {
294
+ const key = `${clipName(c)} on ${c.selector}`
295
+ const g = groups.get(key) || { c, n: 0 }
296
+ g.n++
297
+ groups.set(key, g)
298
+ }
299
+ const list = [...groups.values()].sort((a, b) => (at != null ? Math.abs(a.c.start - at) - Math.abs(b.c.start - at) : a.c.start - b.c.start))
300
+ lines.push("", `${selector ? "Other animations" : "Animations"} in this window (${others.length}):`)
301
+ for (const g of list.slice(0, selector ? 6 : 15)) lines.push(clipLine(g.c, rec, at) + (g.n > 1 ? ` ×${g.n}` : ""))
302
+ if (list.length > (selector ? 6 : 15)) lines.push(`- …and ${list.length - (selector ? 6 : 15)} more kinds`)
303
+ }
304
+
305
+ // How much of the screen changed: the biggest moments.
306
+ const act = rec.activity
307
+ if (act && Array.isArray(act.v)) {
308
+ const peaks = act.v
309
+ .map((v, i) => ({ t: act.start + i * act.step, v }))
310
+ .filter((p) => p.t >= lo && p.t <= hi && p.v >= 10)
311
+ .sort((a, b) => b.v - a.v)
312
+ .slice(0, 3)
313
+ .sort((a, b) => a.t - b.t)
314
+ if (peaks.length) lines.push("", `Screen changed most at: ${peaks.map((p) => `${fmt(p.t - s)} (${p.v}% of the view)`).join(", ")}.`)
315
+ }
316
+ return lines.join("\n")
317
+ }