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.
@@ -0,0 +1,844 @@
1
+ // A note as text for a coding agent, written once for both readers: the dock's
2
+ // "Copy for agent" (core.js inlines this file into the dock, `export`s
3
+ // stripped) and `retake mcp` (get_note, get_animation, list_notes). Plain
4
+ // functions, no DOM, no Node.
5
+ //
6
+ // The words it uses (CONTRACT.md "Note"):
7
+ // recording time ms on the recording's clock, shown as 00:01.23 from its start
8
+ // local time ms on one animation's own clock: 0 = the end of its delay
9
+ // progress local / duration (0-1, linear); eased = after the effect's easing
10
+ // segment the two keyframes the eased progress is between
11
+ // Keyframe offsets apply to the eased progress, so a keyframe's local time goes
12
+ // through the inverse of the effect's easing (localOfOffset).
13
+
14
+ const FRAME = 1000 / 60
15
+
16
+ /** ms → "00:10.91" */
17
+ const clockText = (ms) => {
18
+ const s = Math.max(0, Number(ms) || 0) / 1000
19
+ return `${String(Math.floor(s / 60)).padStart(2, "0")}:${(s % 60).toFixed(2).padStart(5, "0")}`
20
+ }
21
+ /** ms → "200ms" / "1210ms" (local times stay in ms: what CSS, WAAPI and Motion write) */
22
+ const msText = (ms) => `${Math.round(Number(ms) || 0)}ms`
23
+ const num = (v, d = 2) => {
24
+ const n = Number(v)
25
+ if (!Number.isFinite(n)) return String(v)
26
+ const s = n.toFixed(d)
27
+ return s.includes(".") ? s.replace(/0+$/, "").replace(/\.$/, "") || "0" : s
28
+ }
29
+ const pct = (p) => `${num((Number(p) || 0) * 100, 1)}%`
30
+ const kebab = (p) => String(p).replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()).replace(/^webkit-/, "-webkit-")
31
+
32
+ // ---- easing -------------------------------------------------------------------------
33
+ // CSS easing functions, evaluated without a browser (the MCP server has none):
34
+ // linear, ease*, cubic-bezier(), steps(), step-start/end, linear(…).
35
+
36
+ const NAMED = { ease: [0.25, 0.1, 0.25, 1], "ease-in": [0.42, 0, 1, 1], "ease-out": [0, 0, 0.58, 1], "ease-in-out": [0.42, 0, 0.58, 1] }
37
+
38
+ function bezier(x1, y1, x2, y2) {
39
+ const at = (a, b, t) => 3 * a * (1 - t) * (1 - t) * t + 3 * b * (1 - t) * t * t + t * t * t
40
+ return (x) => {
41
+ if (x <= 0) return 0
42
+ if (x >= 1) return 1
43
+ let lo = 0
44
+ let hi = 1
45
+ for (let i = 0; i < 40; i++) {
46
+ const mid = (lo + hi) / 2
47
+ if (at(x1, x2, mid) < x) lo = mid
48
+ else hi = mid
49
+ }
50
+ return at(y1, y2, (lo + hi) / 2)
51
+ }
52
+ }
53
+
54
+ function steps(n, pos) {
55
+ const jumps = pos === "jump-none" ? n - 1 : pos === "jump-both" ? n + 1 : n
56
+ const start = pos === "start" || pos === "jump-start" || pos === "jump-both"
57
+ return (x) => {
58
+ if (x >= 1) return 1
59
+ let step = Math.floor(x * n)
60
+ if (start) step += 1
61
+ return Math.min(Math.max(step / Math.max(jumps, 1), 0), 1)
62
+ }
63
+ }
64
+
65
+ // linear(0, 0.25 40%, 1): points with optional input percentages.
66
+ function linearFn(body) {
67
+ const pts = []
68
+ for (const part of body.split(/,(?![^(]*\))/)) {
69
+ const bits = part.trim().split(/\s+/)
70
+ const y = Number(bits[0])
71
+ const xs = bits.slice(1).map((b) => parseFloat(b) / 100)
72
+ if (!xs.length) pts.push({ y, x: null })
73
+ for (const x of xs) pts.push({ y, x })
74
+ }
75
+ if (!pts.length) return (x) => x
76
+ if (pts[0].x == null) pts[0].x = 0
77
+ if (pts[pts.length - 1].x == null) pts[pts.length - 1].x = 1
78
+ for (let i = 1; i < pts.length; i++) if (pts[i].x != null && pts[i].x < pts[i - 1].x) pts[i].x = pts[i - 1].x
79
+ for (let i = 1; i < pts.length; i++) {
80
+ if (pts[i].x != null) continue
81
+ let j = i
82
+ while (pts[j].x == null) j++
83
+ const a = pts[i - 1].x
84
+ const step = (pts[j].x - a) / (j - i + 1)
85
+ for (let k = i; k < j; k++) pts[k].x = a + step * (k - i + 1)
86
+ }
87
+ return (x) => {
88
+ if (x <= pts[0].x) return pts[0].y
89
+ for (let i = 1; i < pts.length; i++) {
90
+ const a = pts[i - 1]
91
+ const b = pts[i]
92
+ if (x <= b.x) return b.x === a.x ? b.y : a.y + ((x - a.x) / (b.x - a.x)) * (b.y - a.y)
93
+ }
94
+ return pts[pts.length - 1].y
95
+ }
96
+ }
97
+
98
+ const easings = new Map()
99
+ /** An easing string → x ↦ y. Anything it can't read is linear. */
100
+ export function easingFn(easing) {
101
+ const e = String(easing || "linear").trim()
102
+ if (easings.has(e)) return easings.get(e)
103
+ let fn = (x) => x
104
+ let m
105
+ if (NAMED[e]) fn = bezier(...NAMED[e])
106
+ else if ((m = /^cubic-bezier\(([^)]*)\)$/.exec(e))) {
107
+ const [a, b, c, d] = m[1].split(",").map(Number)
108
+ if ([a, b, c, d].every(Number.isFinite)) fn = bezier(a, b, c, d)
109
+ } else if ((m = /^steps\(\s*(\d+)\s*(?:,\s*([\w-]+)\s*)?\)$/.exec(e))) fn = steps(Number(m[1]), m[2] || "end")
110
+ else if (e === "step-start") fn = steps(1, "start")
111
+ else if (e === "step-end") fn = steps(1, "end")
112
+ else if ((m = /^linear\((.*)\)$/.exec(e))) fn = linearFn(m[1])
113
+ easings.set(e, fn)
114
+ return fn
115
+ }
116
+ const isLinear = (e) => !e || e === "linear" || e === "linear(0, 1)" || e === "cubic-bezier(0, 0, 1, 1)"
117
+
118
+ // ---- an animation's own clock -----------------------------------------------------------
119
+
120
+ const iterCount = (t) => (t.iterations === "infinite" || t.iterations === Infinity ? Infinity : t.iterations == null ? 1 : Number(t.iterations))
121
+
122
+ /**
123
+ * Where recording time T falls on the animation's own clock.
124
+ * @param {any} anim { timing: { delay, duration, iterations, direction, easing, playbackRate, start, activeStart }, keyframes }
125
+ * @param {number} T
126
+ */
127
+ export function pointOf(anim, T) {
128
+ const t = anim.timing || {}
129
+ const rate = Number(t.playbackRate) || 1
130
+ const delay = Number(t.delay) || 0
131
+ const dur = Math.max(Number(t.duration) || 0, 0)
132
+ const activeStart = t.activeStart != null ? Number(t.activeStart) : (Number(t.start) || 0) + delay / rate
133
+ const iters = iterCount(t)
134
+ const local = (T - activeStart) * rate
135
+ const activeDur = dur * iters
136
+ let phase = "active"
137
+ let iteration = 0
138
+ let iterLocal = local
139
+ if (local < 0) {
140
+ phase = "delay"
141
+ iterLocal = 0
142
+ } else if (local >= activeDur && Number.isFinite(activeDur)) {
143
+ phase = "after"
144
+ iteration = Math.max(0, Math.ceil(iters) - 1)
145
+ iterLocal = activeDur - iteration * dur
146
+ } else if (dur > 0) {
147
+ iteration = Math.floor(local / dur)
148
+ iterLocal = local - iteration * dur
149
+ }
150
+ const dir = t.direction || "normal"
151
+ const reversed = dir === "reverse" || (dir === "alternate" && iteration % 2 === 1) || (dir === "alternate-reverse" && iteration % 2 === 0)
152
+ const directed = reversed ? dur - iterLocal : iterLocal
153
+ const progress = dur > 0 ? Math.min(Math.max(directed / dur, 0), 1) : 1
154
+ const eased = easingFn(t.easing)(progress)
155
+ return { T: Math.round(T * 10) / 10, local: Math.round(local * 10) / 10, iteration, phase, progress: round(progress, 4), eased: round(eased, 4), segment: segmentOf(anim.keyframes, eased) }
156
+ }
157
+ const round = (v, d) => Math.round(v * 10 ** d) / 10 ** d
158
+
159
+ /** The keyframe pair an eased progress is between: { index, fromOffset, toOffset, easing, progress }. */
160
+ export function segmentOf(keyframes, eased) {
161
+ const kf = (keyframes || []).filter((k) => k && k.offset != null)
162
+ if (kf.length < 2) return null
163
+ const x = Math.min(Math.max(eased, 0), 1)
164
+ let i = 0
165
+ while (i < kf.length - 2 && x >= kf[i + 1].offset) i++
166
+ const a = kf[i].offset
167
+ const b = kf[i + 1].offset
168
+ const easing = kf[i].easing || "linear"
169
+ return { index: i, fromOffset: a, toOffset: b, easing, progress: round(b > a ? (x - a) / (b - a) : 1, 4) }
170
+ }
171
+
172
+ /**
173
+ * Numeric keyframe values at recording time T (Motion keyframes read off its
174
+ * props: x, y, scale, rotate, opacity), through each segment's easing.
175
+ * @returns {Record<string, string>}
176
+ */
177
+ export function valuesAt(anim, T) {
178
+ const p = pointOf(anim, T)
179
+ const kf = (anim.keyframes || []).filter((k) => k && k.offset != null)
180
+ const s = p.segment
181
+ if (!s || kf.length < 2) return {}
182
+ const a = kf[s.index].values || {}
183
+ const b = kf[s.index + 1].values || {}
184
+ const y = easingFn(s.easing)(Math.min(Math.max(s.progress, 0), 1))
185
+ const out = {}
186
+ for (const k of Object.keys(a)) {
187
+ const va = Number(a[k])
188
+ const vb = Number(b[k] != null ? b[k] : a[k])
189
+ if (Number.isFinite(va) && Number.isFinite(vb)) out[k] = num(va + (vb - va) * y, 2)
190
+ }
191
+ return out
192
+ }
193
+
194
+ /** Local ms at which the effect reaches a keyframe offset (through the inverse of its easing; first iteration). */
195
+ export function localOfOffset(anim, offset) {
196
+ const t = anim.timing || {}
197
+ const dur = Number(t.duration) || 0
198
+ const f = easingFn(t.easing)
199
+ if (isLinear(t.easing)) return offset * dur
200
+ let lo = 0
201
+ let hi = 1
202
+ for (let i = 0; i < 40; i++) {
203
+ const mid = (lo + hi) / 2
204
+ if (f(mid) < offset) lo = mid
205
+ else hi = mid
206
+ }
207
+ return ((lo + hi) / 2) * dur
208
+ }
209
+
210
+ /** Recording time of a local time (first iteration). */
211
+ export const recordingOf = (anim, local) => {
212
+ const t = anim.timing || {}
213
+ const rate = Number(t.playbackRate) || 1
214
+ const activeStart = t.activeStart != null ? Number(t.activeStart) : (Number(t.start) || 0) + (Number(t.delay) || 0) / rate
215
+ return activeStart + local / rate
216
+ }
217
+
218
+ // ---- reading numbers the user typed ------------------------------------------------------
219
+ // "at 100ms", "200–400 ms", "between 200 and 400", "after 300", "from 1s to 1.5s",
220
+ // "20%–40%", "first 300ms", "last 200 ms". With a primary animation they are its
221
+ // local time; without, recording time from the note's moment.
222
+
223
+ const unitMs = (v, u) => (/^s/.test(u || "") && !/^ms/.test(u || "") ? Number(v) * 1000 : Number(v))
224
+ /**
225
+ * @param {string} text
226
+ * @param {any} [primary] the note's primary animation (with timing)
227
+ * @returns {{ text: string, reading: "local" | "recording", from: number, to: number | null } | null}
228
+ */
229
+ export function readAsked(text, primary) {
230
+ const s = String(text || "")
231
+ const N = "(\\d+(?:\\.\\d+)?)"
232
+ const U = "\\s*(ms|milliseconds?|s|secs?|seconds?)?"
233
+ const dur = primary && primary.timing ? Number(primary.timing.duration) || 0 : 0
234
+ /** @type {"local" | "recording"} */
235
+ const reading = primary ? "local" : "recording"
236
+ const make = (m, from, to) => ({ text: m[0].trim(), reading, from: Math.round(from), to: to == null ? null : Math.round(to) })
237
+ let m
238
+ if ((m = new RegExp(`${N}\\s*%\\s*(?:-|–|—|to)\\s*${N}\\s*%`, "i").exec(s)) && dur) return make(m, (Number(m[1]) / 100) * dur, (Number(m[2]) / 100) * dur)
239
+ if ((m = new RegExp(`between\\s+${N}${U}\\s+and\\s+${N}${U}`, "i").exec(s))) return make(m, unitMs(m[1], m[2] || m[4]), unitMs(m[3], m[4] || m[2]))
240
+ if ((m = new RegExp(`from\\s+${N}${U}\\s+to\\s+${N}${U}`, "i").exec(s))) return make(m, unitMs(m[1], m[2] || m[4]), unitMs(m[3], m[4] || m[2]))
241
+ if ((m = new RegExp(`${N}${U}\\s*(?:-|–|—|to)\\s*${N}${U}`, "i").exec(s)) && (m[2] || m[4])) return make(m, unitMs(m[1], m[2] || m[4]), unitMs(m[3], m[4] || m[2]))
242
+ if ((m = new RegExp(`first\\s+${N}${U}`, "i").exec(s))) return make(m, 0, unitMs(m[1], m[2]))
243
+ if ((m = new RegExp(`last\\s+${N}${U}`, "i").exec(s)) && dur) return make(m, dur - unitMs(m[1], m[2]), dur)
244
+ if ((m = new RegExp(`after\\s+${N}\\s*%`, "i").exec(s)) && dur) return make(m, (Number(m[1]) / 100) * dur, dur)
245
+ if ((m = new RegExp(`after\\s+${N}${U}`, "i").exec(s)) && (m[2] || primary)) return make(m, unitMs(m[1], m[2]), dur || null)
246
+ if ((m = new RegExp(`at\\s+${N}\\s*%`, "i").exec(s)) && dur) return make(m, (Number(m[1]) / 100) * dur, null)
247
+ if ((m = new RegExp(`(?:at|@)\\s*${N}${U}`, "i").exec(s)) && (m[2] || primary)) return make(m, unitMs(m[1], m[2]), null)
248
+ return null
249
+ }
250
+
251
+ // ---- describing ----------------------------------------------------------------------------
252
+
253
+ const KIND_WORD = {
254
+ "css-animation": "CSS animation",
255
+ "css-transition": "CSS transition",
256
+ waapi: "element.animate()",
257
+ js: "script-driven motion (inline style writes)",
258
+ "scroll-driven": "scroll-driven animation",
259
+ smil: "SVG (SMIL) animation",
260
+ }
261
+ const LIB_WORD = { motion: "Motion (framer-motion)", gsap: "GSAP", anime: "anime.js", "react-spring": "react-spring" }
262
+ /** "@keyframes rise", "opacity transition", "element.animate()" … */
263
+ const animTitle = (a) => {
264
+ if (a.kind === "css-animation") return `@keyframes ${a.name || "?"}`
265
+ if (a.kind === "css-transition") return `${a.name || "transition"}`
266
+ if (a.kind === "js" && a.motionKeyframes) return `Motion keyframes (${a.name || "values"}) from the motion element's animate / transition props, run from script`
267
+ if (a.kind === "js") return `${a.lib && a.lib !== "raf" ? `${LIB_WORD[a.lib] || a.lib}, ` : ""}${KIND_WORD.js}`
268
+ if (a.kind === "waapi" && a.lib) return `${LIB_WORD[a.lib] || a.lib} animation (it runs as element.animate())`
269
+ return a.name && a.kind !== "waapi" ? `${a.name} (${KIND_WORD[a.kind] || a.kind})` : KIND_WORD[a.kind] || a.name || "animation"
270
+ }
271
+ const whereText = (d) => (d && d.file ? `${d.file}${d.line ? ":" + d.line : ""}` : null)
272
+ // A computed transform (always a matrix) read the way it would be written:
273
+ // matrix(1, 0, 0, 1, 19.72, 0) → translate(19.7px, 0px).
274
+ function prettyTransform(v) {
275
+ const m = /^matrix\(([^)]*)\)$/.exec(String(v).trim())
276
+ if (!m) return String(v)
277
+ const [a, b, c, d, e, f] = m[1].split(",").map(Number)
278
+ if (![a, b, c, d, e, f].every(Number.isFinite)) return String(v)
279
+ const parts = []
280
+ if (e || f) parts.push(`translate(${num(e, 1)}px, ${num(f, 1)}px)`)
281
+ const rot = (Math.atan2(b, a) * 180) / Math.PI
282
+ if (Math.abs(rot) >= 0.05) parts.push(`rotate(${num(rot, 1)}deg)`)
283
+ const sx = Math.hypot(a, b)
284
+ const sy = (a * d - b * c) / (sx || 1)
285
+ if (Math.abs(sx - 1) >= 0.001 || Math.abs(sy - 1) >= 0.001) parts.push(Math.abs(sx - sy) < 0.001 ? `scale(${num(sx, 3)})` : `scale(${num(sx, 3)}, ${num(sy, 3)})`)
286
+ return parts.length ? parts.join(" ") : "none (identity)"
287
+ }
288
+ const pretty = (k, x) => (/^(transform|webkitTransform)$/.test(k) ? prettyTransform(x) : /^opacity$/.test(k) && Number.isFinite(Number(x)) ? num(x, 3) : x)
289
+ const valuesText = (v, max = 6) => {
290
+ const e = Object.entries(v || {}).slice(0, max)
291
+ return e.length ? e.map(([k, x]) => `${kebab(k)} ${pretty(k, x)}`).join("; ") : ""
292
+ }
293
+ const boxText = (g) => {
294
+ if (!g || !g.page) return ""
295
+ const p = g.page
296
+ return `box x ${Math.round(p.x)} y ${Math.round(p.y)} ${Math.round(p.w)}×${Math.round(p.h)}`
297
+ }
298
+ const deltaText = (g, g0) => {
299
+ if (!g || !g0 || !g.page || !g0.page) return ""
300
+ const dx = Math.round(g.page.x - g0.page.x)
301
+ const dy = Math.round(g.page.y - g0.page.y)
302
+ return dx || dy ? ` (Δ ${dx >= 0 ? "+" : ""}${dx}, ${dy >= 0 ? "+" : ""}${dy} from the range start)` : ""
303
+ }
304
+ const pointLine = (label, p, g0) => {
305
+ const bits = [valuesText(p.values), boxText(p.geometry) + deltaText(p.geometry, g0)].filter(Boolean)
306
+ return `${label}: ${bits.join(" · ") || "(values not sampled)"}`
307
+ }
308
+
309
+ /** The animation an element note is about first, or null. */
310
+ export const primaryOf = (note) => ((note && note.anims) || []).find((a) => a.primary) || ((note && note.anims) || [])[0] || null
311
+
312
+ /** The element block: page, element, selector, component, source, classes, box. */
313
+ export function elementBlock(note, ctx = {}) {
314
+ const el = note.el || {}
315
+ const tg = note.target || {}
316
+ const lines = []
317
+ if (el.page) lines.push(`Page: ${el.page}`)
318
+ const label = tg.tag || el.label
319
+ const text = tg.text != null ? tg.text : el.text
320
+ if (label) lines.push(`Element: ${label}${text ? ` "${text}"` : ""}${tg.shadowHost ? ` (inside ${tg.shadowHost}'s shadow root)` : ""}`)
321
+ const sel = tg.selector || note.selector || el.selector
322
+ if (sel) lines.push(`Selector: ${sel}${tg.matches > 1 ? ` (matches ${tg.matches} elements; this is the ${ordinal(tg.index || 1)})` : tg.matches === 1 ? " (1 match)" : ""}`)
323
+ const comps = el.components && el.components.length ? el.components : note.component ? [note.component] : []
324
+ if (comps.length) lines.push(`Component: ${comps.join(" < ")}`)
325
+ if (ctx.sourceLine) lines.push(ctx.sourceLine)
326
+ else {
327
+ const src = (tg.source && tg.source.file ? tg.source : null) || note.source
328
+ if (src && src.file) {
329
+ const how = src.confidence === "owner" ? " (its component's call site)" : src.confidence === "exact" ? " (the element's JSX)" : ""
330
+ lines.push(src.compiled ? `Source: not mapped (only a compiled bundle); find it by the component and selector` : `Source: ${src.file}${src.line ? ":" + src.line : ""}${how}`)
331
+ } else if (src && src.confidence === "unknown") lines.push(`Source: unknown${src.component ? ` (server component ${src.component})` : ""}`)
332
+ }
333
+ if (el.cssSource && el.cssSource.file) lines.push(`CSS: ${el.cssSource.file}${el.cssSource.line ? ":" + el.cssSource.line : ""}`)
334
+ const classes = note.classes || el.classes
335
+ if (classes && classes.length) lines.push(`Classes: ${[].concat(classes).join(" ")}`)
336
+ if (ctx.computed) lines.push(ctx.computed)
337
+ // The box where the note is: the primary animation's point (or range start), sampled there; else as picked.
338
+ const pa = primaryOf(note)
339
+ const pp = pa && (pa.from || pa.at)
340
+ const g = (pp && pp.geometry && pp.geometry.page ? pp.geometry : null) || tg.geometry
341
+ if (g && g.page) {
342
+ const at = note.range ? ` at the range start` : ""
343
+ const tr = g.transform && g.transform !== "none" ? `; transform ${prettyTransform(g.transform)}` : ""
344
+ const vp = g.view && g.scroll ? ` (page px; viewport ${Math.round(g.view.w)}×${Math.round(g.view.h)}, scrolled ${Math.round(g.scroll.x)}, ${Math.round(g.scroll.y)})` : " (page px)"
345
+ lines.push(`Box${at}: x ${Math.round(g.page.x)} y ${Math.round(g.page.y)}, ${Math.round(g.page.w)}×${Math.round(g.page.h)}${vp}${tr}${g.opacity != null && Number(g.opacity) !== 1 ? `; opacity ${num(g.opacity)}` : ""}`)
346
+ if (tr && g.decomposed && (g.decomposed.tx || g.decomposed.ty)) lines.push(` (layout position = this box minus the translate ${num(g.decomposed.tx, 1)}, ${num(g.decomposed.ty, 1)})`)
347
+ } else if (note.rect) lines.push(`Box: ${Math.round(note.rect.w)}×${Math.round(note.rect.h)} at (${Math.round(note.rect.x)}, ${Math.round(note.rect.y)}) in the viewport`)
348
+ return lines
349
+ }
350
+ const ordinal = (n) => `${n}${n % 10 === 1 && n % 100 !== 11 ? "st" : n % 10 === 2 && n % 100 !== 12 ? "nd" : n % 10 === 3 && n % 100 !== 13 ? "rd" : "th"}`
351
+
352
+ function timingLine(a) {
353
+ const t = a.timing || {}
354
+ if (a.kind === "js" && a.motionKeyframes) return `${msText(t.duration)}, delay ${msText(t.delay)}, ${iterCount(t) === Infinity ? "looping" : `${num(iterCount(t))} iteration${iterCount(t) === 1 ? "" : "s"}`}; each segment has its own ease (below; Motion applies a single ease to every segment); local 0 placed from its first style write${a.leadMs ? ` (${num(a.leadMs, 1)}ms before it)` : ""}, ±1 frame`
355
+ if (a.kind === "js") return `${msText(t.duration)} of inline style writes (the curve is in the script: Retake saw the values it wrote, not its easing)`
356
+ const iters = iterCount(t)
357
+ const bits = [
358
+ `${msText(t.duration)}`,
359
+ `delay ${msText(t.delay)}`,
360
+ iters === Infinity ? "looping" : `${num(iters)} iteration${iters === 1 ? "" : "s"}`,
361
+ t.direction && t.direction !== "normal" ? `direction ${t.direction}` : null,
362
+ `fill ${t.fill || "none"}`,
363
+ `easing ${t.easing || "linear"} on the whole effect`,
364
+ t.playbackRate && Number(t.playbackRate) !== 1 ? `playback rate ${t.playbackRate}` : null,
365
+ ].filter(Boolean)
366
+ return bits.join(", ")
367
+ }
368
+
369
+ function keyframeLines(a) {
370
+ const kf = a.keyframes || []
371
+ if (!kf.length) return []
372
+ if (a.kind === "js" && !a.motionKeyframes) {
373
+ const side = (v) => Object.entries(v || {}).map(([k, x]) => `${kebab(k)}: ${x || "(not set)"}`).join("; ") || "?"
374
+ return [` inline style: ${side(kf[0].values)} → ${side(kf[kf.length - 1].values)} (first and last write)`]
375
+ }
376
+ const out = [` keyframes (offset · values · easing to the next)${a.approx ? ", only the first and last were recorded" : ""}:`]
377
+ kf.forEach((k, i) => out.push(` ${num(k.offset, 3).padEnd(5)} ${valuesText(k.values, 8).padEnd(40)} ${i < kf.length - 1 ? k.easing || "linear" : ""}`.trimEnd()))
378
+ return out
379
+ }
380
+
381
+ const segText = (s) => (s ? `segment ${num(s.fromOffset, 3)} → ${num(s.toOffset, 3)} (${s.easing})` : "")
382
+
383
+ // ---- an exact edit -------------------------------------------------------------------------
384
+ // The keyframes again on plain time (the effect's easing moved into them),
385
+ // with the note's point or range edges as keyframes of their own. Each piece
386
+ // keeps the exact part of the curve it had (part of a cubic-bezier is a
387
+ // cubic-bezier; anything else is written as linear() stops), so the motion
388
+ // outside the asked part stays what it was and only the marked piece changes.
389
+
390
+ const bezierOf = (e) => {
391
+ const s = String(e || "linear").trim()
392
+ if (isLinear(s)) return [0, 0, 1, 1]
393
+ if (NAMED[s]) return NAMED[s]
394
+ const m = /^cubic-bezier\(([^)]*)\)$/.exec(s)
395
+ const v = m ? m[1].split(",").map(Number) : []
396
+ return v.length === 4 && v.every(Number.isFinite) ? v : null
397
+ }
398
+ // A computed value as something to write back: a transform's matrix as translate/rotate/scale.
399
+ const cssValue = (k, v) => {
400
+ if (!/^(transform|webkitTransform)$/.test(k)) return pretty(k, v)
401
+ const s = prettyTransform(v)
402
+ return s === "none (identity)" ? "translate(0px, 0px)" : s
403
+ }
404
+ const isSteps = (e) => /^(steps\(|step-)/.test(String(e || "").trim())
405
+
406
+ // The part of a cubic-bezier timing curve between inputs xa and xb, as a timing curve of its own.
407
+ function subCurve(p, xa, xb) {
408
+ if (xb - xa < 1e-6) return "linear"
409
+ const X = [0, p[0], p[2], 1]
410
+ const Y = [0, p[1], p[3], 1]
411
+ const at = (c, t) => (1 - t) ** 3 * c[0] + 3 * (1 - t) ** 2 * t * c[1] + 3 * (1 - t) * t * t * c[2] + t ** 3 * c[3]
412
+ const tOf = (x) => {
413
+ if (x <= 0) return 0
414
+ if (x >= 1) return 1
415
+ let lo = 0
416
+ let hi = 1
417
+ for (let i = 0; i < 50; i++) {
418
+ const mid = (lo + hi) / 2
419
+ if (at(X, mid) < x) lo = mid
420
+ else hi = mid
421
+ }
422
+ return (lo + hi) / 2
423
+ }
424
+ // de Casteljau: [left, right] of control points c split at t.
425
+ const split = (c, t) => {
426
+ const l = (a, b) => a + (b - a) * t
427
+ const a1 = l(c[0], c[1]), b1 = l(c[1], c[2]), c1 = l(c[2], c[3])
428
+ const a2 = l(a1, b1), b2 = l(b1, c1)
429
+ const m = l(a2, b2)
430
+ return [[c[0], a1, a2, m], [m, b2, c1, c[3]]]
431
+ }
432
+ const ta = tOf(xa)
433
+ const tb = tOf(xb)
434
+ let cx = split(X, tb)[0]
435
+ let cy = split(Y, tb)[0]
436
+ const s = tb > 0 ? ta / tb : 0
437
+ cx = split(cx, s)[1]
438
+ cy = split(cy, s)[1]
439
+ const dx = cx[3] - cx[0]
440
+ const dy = cy[3] - cy[0]
441
+ if (Math.abs(dy) < 1e-9 || dx < 1e-9) return "linear"
442
+ const q = [(cx[1] - cx[0]) / dx, (cy[1] - cy[0]) / dy, (cx[2] - cx[0]) / dx, (cy[2] - cy[0]) / dy]
443
+ if (Math.abs(q[0] - q[1]) < 1e-3 && Math.abs(q[2] - q[3]) < 1e-3) return "linear"
444
+ return `cubic-bezier(${q.map((v) => num(v, 4)).join(", ")})`
445
+ }
446
+ // Any curve on [0, 1] as linear() stops, as few as keep it within 0.2% of its rise.
447
+ function stopsCurve(f) {
448
+ for (const n of [8, 12, 16, 24, 32]) {
449
+ const ys = Array.from({ length: n + 1 }, (_, i) => f(i / n))
450
+ let worst = 0
451
+ for (let i = 0; i < n; i++) worst = Math.max(worst, Math.abs(f((i + 0.5) / n) - (ys[i] + ys[i + 1]) / 2))
452
+ if (worst <= 0.002 || n === 32) return `linear(${ys.map((y) => num(y, 4)).join(", ")})`
453
+ }
454
+ return "linear"
455
+ }
456
+
457
+ /**
458
+ * The keyframes with the note's point or range edges put in, on plain time: [{ offset, values, easing, mark }],
459
+ * or null when it can't be written exactly (steps(), several iterations, reversed, nothing sampled).
460
+ * @param {any} a an anims[] entry with keyframes, timing and at or from/to
461
+ */
462
+ export function splitKeyframes(a) {
463
+ const t = a.timing || {}
464
+ const kf = (a.keyframes || []).filter((k) => k && k.offset != null)
465
+ const dur = Number(t.duration) || 0
466
+ if (kf.length < 2 || dur <= 0 || iterCount(t) !== 1 || (t.direction && t.direction !== "normal")) return null
467
+ if (isSteps(t.easing) || kf.some((k) => isSteps(k.easing))) return null
468
+ const edges = (a.from && a.to ? [[a.from, "from"], [a.to, "to"]] : a.at ? [[a.at, "at"]] : []).filter(([p]) => p && p.phase !== "delay" && p.values && Object.keys(p.values).length)
469
+ if (!edges.length) return null
470
+ const E = easingFn(t.easing)
471
+ const effectCurve = bezierOf(t.easing)
472
+ const timeOf = (o) => localOfOffset(a, o) / dur
473
+ /** @type {{ offset: number, eased: number, values: any, seg: number, mark: string | null }[]} */
474
+ const stops = kf.map((k, i) => ({ offset: timeOf(k.offset), eased: k.offset, values: k.values || {}, seg: Math.min(i, kf.length - 2), mark: null }))
475
+ for (const [p, mark] of edges) {
476
+ const q = Math.min(Math.max(Number(p.progress) || 0, 0), 1)
477
+ const near = stops.find((s) => Math.abs(s.offset - q) < 5e-4)
478
+ if (near) {
479
+ near.mark = near.mark ? `${near.mark}+${mark}` : mark
480
+ continue
481
+ }
482
+ const e = E(q)
483
+ let seg = 0
484
+ while (seg < kf.length - 2 && e >= kf[seg + 1].offset) seg++
485
+ const values = {}
486
+ for (const [k, v] of Object.entries(p.values)) values[k] = cssValue(k, v)
487
+ stops.push({ offset: q, eased: e, values, seg, mark })
488
+ }
489
+ stops.sort((x, y) => x.offset - y.offset)
490
+ return stops.map((s, i) => {
491
+ const next = stops[i + 1]
492
+ /** @type {string | null} */
493
+ let easing = null
494
+ if (next) {
495
+ const k = kf[s.seg]
496
+ const o0 = kf[s.seg].offset
497
+ const o1 = kf[s.seg + 1].offset
498
+ const segOf = (p) => (o1 > o0 ? (E(p) - o0) / (o1 - o0) : 0)
499
+ const kCurve = bezierOf(k.easing)
500
+ const effectLinear = isLinear(t.easing)
501
+ const kLinear = isLinear(k.easing)
502
+ const whole = Math.abs(segOf(s.offset)) < 1e-6 && Math.abs(segOf(next.offset) - 1) < 1e-6
503
+ if (effectLinear && kLinear) easing = "linear"
504
+ else if (effectLinear && whole) easing = k.easing || "linear"
505
+ else if (effectLinear && kCurve) easing = subCurve(kCurve, segOf(s.offset), segOf(next.offset))
506
+ else if (kLinear && effectCurve) easing = subCurve(effectCurve, s.offset, next.offset)
507
+ else {
508
+ const kf2 = easingFn(k.easing)
509
+ const y0 = kf2(segOf(s.offset))
510
+ const y1 = kf2(segOf(next.offset))
511
+ easing = Math.abs(y1 - y0) < 1e-9 ? "linear" : stopsCurve((u) => (kf2(segOf(s.offset + u * (next.offset - s.offset))) - y0) / (y1 - y0))
512
+ }
513
+ }
514
+ return { offset: Math.round(s.offset * 10000) / 10000, values: s.values, easing, mark: s.mark }
515
+ })
516
+ }
517
+
518
+ const MARK_WORD = { from: "range start", to: "range end", at: "the note's point", "from+to": "range" }
519
+ const markText = (s, a) => {
520
+ if (!s.mark) return ""
521
+ const p = s.mark.startsWith("to") ? a.to : s.mark.startsWith("at") ? a.at : a.from
522
+ if (p && p.phase === "after") return ` ← its end state (the note is ${msText(p.local - (Number((a.timing || {}).duration) || 0) * iterCount(a.timing || {}))} after it ended)`
523
+ return ` ← ${MARK_WORD[s.mark] || s.mark}, local ${msText(p && p.local)}`
524
+ }
525
+ const jsValue = (v) => JSON.stringify(String(v))
526
+
527
+ // A CSS transition's timing function as linear() stops, with the note's
528
+ // point or range edges as stops of their own: [{ x, y, mark }]. Each stop is
529
+ // "output (0 = the start value, 1 = the end value) at input %".
530
+ function transitionStops(a) {
531
+ const t = a.timing || {}
532
+ const E = easingFn(t.easing)
533
+ if (isSteps(t.easing)) return null
534
+ const edges = (a.from && a.to ? [[a.from, "from"], [a.to, "to"]] : a.at ? [[a.at, "at"]] : []).filter(([p]) => p && p.phase === "active" || (p && p.phase === "after"))
535
+ const marks = edges.map(([p, mark]) => ({ x: p.phase === "after" ? 1 : Math.min(Math.max(Number(p.progress) || 0, 0), 1), mark }))
536
+ const cuts = [...new Set([0, 1, ...marks.map((m) => Math.round(m.x * 10000) / 10000)])].sort((x, y) => x - y)
537
+ const out = []
538
+ for (let i = 0; i < cuts.length - 1; i++) {
539
+ const a0 = cuts[i]
540
+ const a1 = cuts[i + 1]
541
+ let n = 2
542
+ for (; n < 24; n += 2) {
543
+ let worst = 0
544
+ for (let k = 0; k < n; k++) {
545
+ const xa = a0 + ((a1 - a0) * k) / n
546
+ const xb = a0 + ((a1 - a0) * (k + 1)) / n
547
+ worst = Math.max(worst, Math.abs(E((xa + xb) / 2) - (E(xa) + E(xb)) / 2))
548
+ }
549
+ if (worst <= 0.002) break
550
+ }
551
+ for (let k = i === 0 ? 0 : 1; k <= n; k++) out.push({ x: a0 + ((a1 - a0) * k) / n, y: 0 })
552
+ }
553
+ for (const s of out) {
554
+ s.y = E(s.x)
555
+ const m = marks.find((m) => Math.abs(m.x - s.x) < 1e-4)
556
+ s.mark = m ? m.mark : null
557
+ }
558
+ return out
559
+ }
560
+
561
+ /** The exact-edit block for an animation (CSS @keyframes, element.animate(), Motion, a CSS transition), or []. */
562
+ export function editPlan(a) {
563
+ if (a.kind === "css-transition" && !a.approx) {
564
+ const stops = transitionStops(a)
565
+ const kf = a.keyframes || []
566
+ if (!stops || kf.length < 2) return []
567
+ const t = a.timing || {}
568
+ const prop = String(a.name || "").replace(/ transition$/, "") || "the property"
569
+ const v0 = valuesText(kf[0].values)
570
+ const v1 = valuesText(kf[kf.length - 1].values)
571
+ const where = a.rule && a.rule.file ? ` (the rule ${a.rule.selector || ""} at ${whereText(a.rule)})` : ""
572
+ const lines = [`Exact edit (CSS transition): the same timing as now (${t.easing}) written as linear() stops, with the note's ${a.from && a.to ? "range edges" : "point"} as stops of their own (marked below). Each stop is "output input%": output 0 = the start value (${v0}), 1 = the end value (${v1}); input = local / duration. Use it as the timing function of ${prop} only${where}, keep the other properties' transitions as they are, then change only the stops ${a.from && a.to ? "between the marks" : "at the mark"}. A jump is two stops at the same input%.`]
573
+ lines.push(` ${prop} ${msText(t.duration)}${Number(t.delay) ? ` ${msText(t.delay)}` : ""} linear(${stops.map((s) => `${num(s.y, 4)} ${num(s.x * 100, 2)}%`).join(", ")}) (its entry in the transition list)`)
574
+ for (const s of stops.filter((x) => x.mark)) {
575
+ const p = s.mark === "to" ? a.to : s.mark === "at" ? a.at : a.from
576
+ lines.push(` marked: ${num(s.y, 4)} ${num(s.x * 100, 2)}% = ${MARK_WORD[s.mark] || s.mark}, local ${msText(p && p.local)}${p && p.values ? ` (${valuesText(p.values)})` : ""}`)
577
+ }
578
+ return lines
579
+ }
580
+ if (a.approx) return []
581
+ if (!["css-animation", "waapi", "js"].includes(a.kind) || (a.kind === "js" && a.lib !== "motion")) return []
582
+ const stops = splitKeyframes(a)
583
+ if (!stops) return []
584
+ const t = a.timing || {}
585
+ const range = a.from && a.to
586
+ const what = range ? "between the two marked keyframes" : "at the marked keyframe (and as little around it as the request allows)"
587
+ const lines = []
588
+ const easedNote = isLinear(t.easing) ? "" : ` The effect's easing (${t.easing}) is moved into the keyframes, so offsets are plain time: local ms / ${msText(t.duration)}.`
589
+ if (a.kind === "css-animation") {
590
+ const shared = a.shared && a.shared.length
591
+ const last = String(a.selector || "").split(/\s*>\s*|\s+/).pop() || ""
592
+ const nth = (/:nth-[\w-]+\((\d+)\)/.exec(last) || [])[1] || ""
593
+ const name = shared ? `${a.name}-${(last.replace(/:[\w-]+(\([^)]*\))?/g, "").split(/[.#[\]=]+/).filter(Boolean).pop() || "own").replace(/[^\w-]/g, "")}${nth}` : a.name
594
+ lines.push(`Exact edit: the same motion as now, with the note's ${range ? "range edges" : "point"} as stops of their own (marked).${easedNote} ${shared ? `Add these as @keyframes ${name} and point only this element at it (${a.selector || "its selector"} { animation-name: ${name} }); @keyframes ${a.name} is shared.` : `Replace @keyframes ${a.name} with them.`} Then change only what is ${what}:`)
595
+ lines.push(` @keyframes ${name} {`)
596
+ for (const s of stops) {
597
+ const decl = Object.entries(s.values).map(([k, v]) => `${kebab(k)}: ${v}`)
598
+ if (s.easing) decl.push(`animation-timing-function: ${s.easing}`)
599
+ lines.push(` ${num(s.offset * 100, 2)}% { ${decl.join("; ")} }${markText(s, a) ? ` /*${markText(s, a)} */` : ""}`)
600
+ }
601
+ lines.push(" }")
602
+ } else if (a.lib === "motion") {
603
+ const props = [...new Set(stops.flatMap((s) => Object.keys(s.values)))]
604
+ lines.push(`Exact edit (Motion): the same motion as now, with the note's ${range ? "range edges" : "point"} as keyframes of their own.${isLinear(t.easing) ? "" : ` The single ease (${t.easing}) becomes one ease per segment, so times are plain time: local ms / ${msText(t.duration)}.`} Then change only what is ${what} (marked: ${stops.map((s, i) => (s.mark ? `index ${i}` : null)).filter(Boolean).join(", ")}):`)
605
+ lines.push(` animate={{ ${props.map((p) => `${p}: [${stops.map((s) => (s.values[p] == null ? "null" : /^-?[\d.]+$/.test(String(s.values[p])) ? String(s.values[p]) : jsValue(s.values[p]))).join(", ")}]`).join(", ")} }} (other values in animate as they are)`)
606
+ const ease = stops.slice(0, -1).map((s) => {
607
+ const e = s.easing || "linear"
608
+ return e === "linear" ? '"linear"' : /^cubic-bezier/.test(e) ? `[${e.slice(13, -1)}]` : jsValue(e)
609
+ })
610
+ const own = `{ duration: ${num(t.duration / 1000, 3)}, delay: ${num((Number(t.delay) || 0) / 1000, 3)}, times: [${stops.map((s) => num(s.offset, 4)).join(", ")}], ease: [${ease.join(", ")}] }`
611
+ lines.push(` transition={{ ...the transition as it is, ${props.length === 1 ? props[0] : `${props.join(" and ")} each`}: ${own} }} (a transition of ${props.length === 1 ? "its" : "their"} own, so other values keep their times)`)
612
+ } else {
613
+ lines.push(`Exact edit (element.animate): the same motion as now, with the note's ${range ? "range edges" : "point"} as keyframes of their own (marked).${easedNote} Then change only what is ${what}:`)
614
+ lines.push(" [")
615
+ for (const s of stops) lines.push(` { offset: ${num(s.offset, 4)}, ${Object.entries(s.values).map(([k, v]) => `${k}: ${jsValue(v)}`).join(", ")}${s.easing ? `, easing: ${jsValue(s.easing)}` : ""} },${markText(s, a) ? ` //${markText(s, a)}` : ""}`)
616
+ lines.push(` ], { duration: ${num(t.duration, 1)}, delay: ${num(Number(t.delay) || 0, 1)}, easing: "linear", fill: ${jsValue(t.fill || "none")} }`)
617
+ }
618
+ return lines
619
+ }
620
+
621
+ function motionHow(a, at, check) {
622
+ const t = a.timing || {}
623
+ const eased = !isLinear(t.easing)
624
+ const where = a.from && a.to ? `${num(a.from.progress, 3)} and ${num(a.to.progress, 3)}` : a.at ? num(a.at.progress, 3) : "the point"
625
+ return `How (Motion): values are arrays with times (0-1 of the duration, delay excluded) and an ease per segment (a single ease is used for every segment).${eased ? ` This one runs as element.animate() with one easing on the whole effect (${t.easing}), applied before times, so times are not plain time here: the exact edit below gives one ease per segment instead, with times = local / duration.` : ""} Insert entries at ${where} (local / duration) with the values given there, so the motion outside doesn't move; each new segment needs the matching part of the curve it splits (the exact edit below has it). To change other values (y, scale...) over only that part, give them arrays of their own with the same times, holding their current value outside it. A spring has no ms range: convert it to keyframes + times, or change stiffness/damping/bounce. ${check}`
626
+ }
627
+
628
+ /** How to change only the part the note means, for each kind of animation. */
629
+ export function howTo(a) {
630
+ const t = a.timing || {}
631
+ const easedEffect = !isLinear(t.easing)
632
+ const check = "Check the values at the range edges and one frame either side against the ones above."
633
+ const at = a.from && a.to ? `${num(a.from.progress * 100, 1)}% and ${num(a.to.progress * 100, 1)}%` : a.at && a.at.phase === "after" ? "100% (its end: the note is after it ended)" : a.at ? `${num(a.at.progress * 100, 1)}%` : "the point"
634
+ switch (a.kind) {
635
+ case "css-animation":
636
+ return `How (CSS @keyframes): add stops at ${at} (local / duration) with the edge values above, so nothing outside moves, and change only what is between them. animation-timing-function inside a keyframe applies to the segment after it (default ease), so a stop added inside a curved segment needs the matching part of that curve on both sides (the exact edit below has it); a hold is two stops with the same value. If the duration changes, recompute every %. ${a.matches > 1 ? "The selector matches more than one element: scope the change (:nth-child, a modifier class) unless all were meant. " : ""}${a.shared && a.shared.length ? `@keyframes ${a.name} also runs on other elements (above): give this element its own copy unless the user meant all of them. ` : ""}${check}`
637
+ case "css-transition":
638
+ return `How (CSS transition): a transition has one segment. For a hold, a two-step motion or a change over part of it, use linear(…) stops or a cubic-bezier in transition-timing-function, or replace it with a keyframes animation started by the same trigger${a.trigger ? ` (${a.trigger.what})` : ""}. ${check}`
639
+ case "waapi":
640
+ if (a.lib === "motion") return motionHow(a, at, check)
641
+ return `How (WAAPI): edit the keyframe array: add { offset, ...values, easing } at ${at}.${easedEffect ? ` The effect has one easing (${t.easing}), so keyframe offsets apply after it, not to time: the exact edit below moves that easing into the keyframes (effect easing linear, offsets = local / duration) with the edges as keyframes of their own.` : ""} ${check}`
642
+ case "js":
643
+ if (a.lib === "gsap") return `How (GSAP): tweens sit at positions on a timeline; retime with the position parameter ("<", "-=0.4", absolute seconds). Change part of a tween by splitting it into two .to() calls at the edge values above.${a.gsap && a.gsap.tweens ? " The tweens Retake read are listed above." : ""} ${check}`
644
+ if (a.lib === "motion") return motionHow(a, at, check)
645
+ return `How (script-driven): Retake saw inline style writes, not the definition. Edit the code${whereText(a.defined) ? ` at ${whereText(a.defined)}` : ""} that writes them and gate the change on the same elapsed time, or move it into keyframes. ${check}`
646
+ case "scroll-driven":
647
+ return `How (scroll-driven): the axis is scroll progress, not time. Edit the keyframe % or animation-range; the scroll position and progress at the note are given. For JS parallax, clamp to a scroll range instead of changing the factor. ${check}`
648
+ case "smil":
649
+ return `How (SVG SMIL): edit keyTimes/values on the <animate> element: add entries at ${at} with the edge values. ${check}`
650
+ default:
651
+ return check
652
+ }
653
+ }
654
+
655
+ // One animation: what, where it is defined, its timing and keyframes, then the
656
+ // note's point or range on its own clock.
657
+ function animLines(a, ctx, note) {
658
+ const start = ctx.start || 0
659
+ const t = a.timing || {}
660
+ const rel = a.relation === "on" ? "this element" : a.relation && a.relation.startsWith("pseudo") ? `this element's ${a.relation.replace(/^pseudo\s*/, "")}` : `${a.selector || "an ancestor"} (an ancestor that moves this element)`
661
+ const lines = []
662
+ lines.push(`Animation: ${animTitle(a)} on ${rel}${a.id ? ` (clip ${a.id})` : ""}${whereText(a.defined) ? `, defined at ${whereText(a.defined)}${a.defined.what ? ` (${a.defined.what})` : ""}` : ""}`)
663
+ if (a.rule && a.rule.file) lines.push(` applied by ${a.rule.selector ? `rule ${a.rule.selector} at ` : ""}${whereText(a.rule)}`)
664
+ if (a.motionProps && a.motionProps.props) lines.push(` ${a.motionProps.component} props: ${Object.entries(a.motionProps.props).map(([k, v]) => `${k}=${v}`).join(" ")}`)
665
+ if (a.shared && a.shared.length) lines.push(` shared: the same ${a.kind === "css-animation" ? `@keyframes ${a.name}` : "animation"} also runs on ${a.shared.length} other element${a.shared.length > 1 ? "s" : ""} (${a.shared.slice(0, 4).map((x) => x.selector || "?").join(", ")}${a.shared.length > 4 ? ", …" : ""}); editing it changes ${a.shared.length > 1 ? "them" : "it"} too`)
666
+ if (a.trigger && a.trigger.what) lines.push(` started by ${a.trigger.what}${a.trigger.t != null ? ` at ${clockText(a.trigger.t - start)} (recording)` : ""}`)
667
+ lines.push(` ${timingLine(a)}`)
668
+ lines.push(` runs recording ${clockText(t.start - start)} → ${t.end != null ? clockText(t.end - start) : "still running"}; local 0 = recording ${clockText(t.activeStart - start)}`)
669
+ lines.push(...keyframeLines(a))
670
+ if (a.gsap && a.gsap.tweens) for (const tw of a.gsap.tweens.slice(0, 6)) lines.push(` gsap tween ${num(tw.start, 3)}s → ${num(tw.end, 3)}s ${tw.ease || ""} ${valuesText(tw.props)}`.trimEnd())
671
+ if (a.scroll) lines.push(` scroll: ${a.scroll.timeline || "scroll"} timeline${a.scroll.range ? `, range ${a.scroll.range}` : ""}${a.scroll.scrollY != null ? `, scrollY ${Math.round(a.scroll.scrollY)}` : ""}${a.scroll.progress != null ? `, progress ${pct(a.scroll.progress)}` : ""}`)
672
+ const dur = Number(t.duration) || 0
673
+ if (a.from && a.to) {
674
+ const f = a.from
675
+ const to = a.to
676
+ const end = note && note.range && a.openEnd ? " (to the end)" : ""
677
+ lines.push(`Range: local ${msText(f.local)} → ${msText(to.local)}${end} of ${msText(dur)} = progress ${num(f.progress, 3)} → ${num(to.progress, 3)} (eased ${num(f.eased, 3)} → ${num(to.eased, 3)}); recording ${clockText(f.T - start)} → ${clockText(to.T - start)}`)
678
+ const segs = f.segment && to.segment ? (f.segment.index === to.segment.index ? `inside ${segText(f.segment)}` : `from ${segText(f.segment)} to ${segText(to.segment)}`) : ""
679
+ const inside = a.keyframesInside || []
680
+ lines.push(` ${[segs, inside.length ? `keyframe${inside.length > 1 ? "s" : ""} inside the range at ${inside.map((o) => num(o, 3)).join(", ")}` : "no keyframe inside the range"].filter(Boolean).join("; ")}`)
681
+ const g0 = (a.samples && a.samples[0] && a.samples[0].geometry) || f.geometry
682
+ lines.push(" " + pointLine(`at ${msText(f.local)}`, f, null))
683
+ for (const s of (a.samples || []).slice(1, -1)) lines.push(" " + pointLine(`at ${msText(s.local)}`, s, g0))
684
+ lines.push(" " + pointLine(`at ${msText(to.local)}`, to, g0))
685
+ if (f.frame && f.frame.before) lines.push(` one frame before ${msText(f.local)}: ${valuesText(f.frame.before) || "?"}; one frame after ${msText(to.local)}: ${valuesText(to.frame && to.frame.after) || "?"}`)
686
+ } else if (a.at) {
687
+ const p = a.at
688
+ const ended = t.end != null ? ` (at recording ${clockText(t.end - start)}, ${msText(p.T - t.end)} before the note's moment)` : ""
689
+ const phase = p.phase === "delay" ? `in its delay, ${msText(-p.local)} before local 0` : p.phase === "after" ? `after it ended${ended}: its end state` : `local ${msText(p.local)} of ${msText(dur)}`
690
+ const iter = p.iteration ? `iteration ${p.iteration + 1}, ` : ""
691
+ const sampled = a.kind !== "js" || a.motionKeyframes
692
+ const detail = p.phase === "after" ? "" : !sampled ? ` = progress ${num(p.progress, 3)}` : ` = progress ${num(p.progress, 3)} (eased ${num(p.eased, 3)})`
693
+ lines.push(`At: ${iter}${phase}${detail}; recording ${clockText(p.T - start)}${p.segment && p.phase === "active" && sampled ? `; in ${segText(p.segment)}, ${pct(p.segment.progress)} through it` : ""}`)
694
+ lines.push(" " + pointLine("values", p, null))
695
+ if (p.frame) lines.push(` one frame before: ${valuesText(p.frame.before) || "?"}; one frame after: ${valuesText(p.frame.after) || "?"}`)
696
+ }
697
+ return lines
698
+ }
699
+
700
+ function scopeLine(note, a) {
701
+ if (!a) return note.range ? `Scope: the user means recording ${clockText(note.range.from - 0)} → ${clockText(note.range.to - 0)} on this element.` : null
702
+ const dur = Number((a.timing || {}).duration) || 0
703
+ if (a.from && a.to) return `Scope: change only local ${Math.round(a.from.local)}–${Math.round(a.to.local)}ms of this animation; keep the values at every other point and the total duration (${msText(dur)}).`
704
+ if (a.at && a.at.phase === "after") return `Scope: the note is after this animation ended, so the user means where it ends (its last values above). Change the end values; keep the start and the timing unless asked.`
705
+ if (a.at) return `Scope: the user means local ${msText(a.at.local)} (progress ${num(a.at.progress, 3)}) of this animation. Change it there and as little around it as the request allows; keep the start, the end and the total duration unless asked.`
706
+ return null
707
+ }
708
+
709
+ /** The animation block: the primary animation first, then the others, the scope and the How line. */
710
+ export function animationBlock(note, ctx = {}) {
711
+ const anims = note.anims || []
712
+ const lines = []
713
+ if (!anims.length) {
714
+ lines.push(note.range ? "Nothing animates on this element in this range." : "Nothing animates on this element at this moment.")
715
+ } else {
716
+ const p = primaryOf(note)
717
+ lines.push(...animLines(p, ctx, note))
718
+ const sc = scopeLine(note, p)
719
+ if (sc) lines.push(sc)
720
+ lines.push(howTo({ ...p, matches: note.target && note.target.matches }))
721
+ lines.push(...editPlan(p.relation === "on" && note.target && note.target.selector ? { ...p, selector: note.target.selector } : p))
722
+ for (const a of anims.filter((x) => x !== p)) lines.push("", ...animLines(a, ctx, note).map((l, i) => (i === 0 ? l.replace(/^Animation:/, "Also running:") : l)))
723
+ }
724
+ const inside = note.inside || []
725
+ lines.push(`Also inside the element (not this note's subject): ${inside.length ? inside.slice(0, 6).map((c) => `${c.name || "animation"} on ${c.selector || "?"}${c.id ? ` (clip ${c.id})` : ""}`).join(", ") + (inside.length > 6 ? `, +${inside.length - 6} more` : "") : "none"}`)
726
+ return lines
727
+ }
728
+
729
+ /** One line for list_notes: "fadeUp @ 100ms (20%)", "ticker 200–400ms (10–20%)". */
730
+ export function animationSummary(note) {
731
+ const a = primaryOf(note)
732
+ if (!a) return note.anims ? "nothing animating" : note.clip ? `${note.clip.label || "animation"} +${Math.round(note.clip.offset || 0)}ms` : null
733
+ const name = a.name || a.kind
734
+ if (a.from && a.to) return `${name} ${Math.round(a.from.local)}–${Math.round(a.to.local)}ms (${num(a.from.progress * 100, 0)}–${num(a.to.progress * 100, 0)}%)`
735
+ if (a.at) return `${name} @ ${Math.round(a.at.local)}ms (${num(a.at.progress * 100, 0)}%)`
736
+ return name
737
+ }
738
+
739
+ /** The note's moment in words, for headers: "at 100ms (20%) of fadeUp on <h1.title>". */
740
+ export function atPhrase(note) {
741
+ const a = primaryOf(note)
742
+ const el = (note.target && note.target.tag) || (note.el && note.el.label) || note.selector || "the element"
743
+ if (!a) return null
744
+ if (a.from && a.to) return `${Math.round(a.from.local)}–${Math.round(a.to.local)}ms (${num(a.from.progress * 100, 0)}–${num(a.to.progress * 100, 0)}%) of ${a.name || "its animation"} on ${el}`
745
+ if (a.at) return a.at.phase === "delay" ? `in the delay of ${a.name || "its animation"} on ${el}` : `at ${Math.round(a.at.local)}ms (${num(a.at.progress * 100, 0)}%) of ${a.name || "its animation"} on ${el}`
746
+ return null
747
+ }
748
+
749
+ /**
750
+ * The whole note as text for an agent (Copy for agent, get_note).
751
+ * @param {any} note the note as the server keeps it (CONTRACT.md "Note")
752
+ * @param {{ start?: number, timeline?: string, parent?: string | null, forkAt?: number | null, sourceLine?: string, computed?: string, status?: boolean }} [ctx]
753
+ */
754
+ export function noteText(note, ctx = {}) {
755
+ const start = ctx.start || 0
756
+ const lines = [
757
+ `> Retake note ${note.id}: pinned to a moment in a Retake recording of the running app. "Moment" is the time into that recording; times marked "local" are on the animation's own clock (0 = the end of its delay).`,
758
+ `> To see what happened before and after it, use the Retake MCP tools (get_note, get_moment, get_animation, get_timeline_events), or ask the user to replay that moment in the Retake dock.`,
759
+ "",
760
+ `## ${note.text}`,
761
+ "",
762
+ ]
763
+ if (ctx.status) lines.push(`Status: ${note.status || "pending"}`)
764
+ lines.push(...elementBlock(note, ctx), "")
765
+ // A note saved before notes knew their animations has only the clip it was in.
766
+ if (Array.isArray(note.anims)) lines.push(...animationBlock(note, ctx))
767
+ else if (note.clip) lines.push(`During an animation: ${Math.round(Number(note.clip.offset) || 0)}ms into ${note.clip.label || "an animation"} (clip ${note.clip.id}; counted from its start, delay included)`)
768
+ else lines.push("Nothing was animating on it at this moment (as recorded).")
769
+ if (note.asked) lines.push(`Read "${note.asked.text}" as ${note.asked.reading === "local" ? `local time of ${primaryOf(note) && primaryOf(note).id ? `clip ${primaryOf(note).id}` : "the animation"}` : "recording time from the note's moment"}${note.asked.from != null ? ` (${Math.round(note.asked.from)}${note.asked.to != null ? `–${Math.round(note.asked.to)}` : ""}ms)` : ""}.`)
770
+ const moment = note.range ? `${clockText(note.range.from - start)} → ${clockText(note.range.to - start)}` : clockText(note.t - start)
771
+ lines.push(`Moment: ${moment} into the recording · Timeline: "${ctx.timeline || "Timeline"}"${ctx.parent ? `, branched from "${ctx.parent}" at ${clockText((ctx.forkAt || 0) - start)}` : ""}`)
772
+ const replies = note.replies || []
773
+ if (replies.length) lines.push("", "Conversation:", ...replies.map((r) => `- ${r.from}: ${r.text}`))
774
+ return lines.join("\n")
775
+ }
776
+
777
+ /**
778
+ * get_animation: one animation's identity, timing, keyframes, and recording times
779
+ * mapped onto its clock, with the conversion to each mechanism's units.
780
+ * @param {any} a an anims[] entry, or a clip turned into one (animFromClip)
781
+ * @param {number[]} Ts recording times
782
+ * @param {{ start?: number, sampled?: any[] }} [ctx]
783
+ */
784
+ export function animationReport(a, Ts, ctx = {}) {
785
+ const start = ctx.start || 0
786
+ const lines = animLines({ ...a, at: null, from: null, to: null }, ctx, null)
787
+ const dur = Number((a.timing || {}).duration) || 0
788
+ const delay = Number((a.timing || {}).delay) || 0
789
+ if (a.keyframes && a.keyframes.length > 2) {
790
+ lines.push(" keyframes on its own clock:")
791
+ for (const k of a.keyframes) lines.push(` ${num(k.offset, 3)} → local ${msText(localOfOffset(a, k.offset))} = recording ${clockText(recordingOf(a, localOfOffset(a, k.offset)) - start)}`)
792
+ }
793
+ for (const T of Ts) {
794
+ const p = pointOf(a, T)
795
+ const sampled = (ctx.sampled || []).find((s) => s && Math.abs(s.T - T) < FRAME)
796
+ lines.push("", `Recording ${clockText(T - start)}: ${p.phase === "delay" ? `in the delay (${msText(-p.local)} before local 0)` : p.phase === "after" ? "after the end" : `local ${msText(p.local)}`}, progress ${num(p.progress, 4)}, eased ${num(p.eased, 4)}${p.segment ? `, ${segText(p.segment)} at ${pct(p.segment.progress)}` : ""}`)
797
+ const eased = !isLinear((a.timing || {}).easing)
798
+ lines.push(
799
+ eased
800
+ ? ` as a keyframe offset with the effect's easing (${a.timing.easing}) kept: ${num(p.eased, 4)} · on plain time (that easing moved into the keyframes): ${num(p.progress, 4)} · as GSAP: ${num((p.local + delay) / 1000, 3)}s into the tween (${num(p.local / 1000, 3)}s after its delay)`
801
+ : ` as CSS: ${num(p.progress * 100, 2)}% of the keyframes · as Motion times: ${num(p.progress, 4)} · as GSAP: ${num((p.local + delay) / 1000, 3)}s into the tween (${num(p.local / 1000, 3)}s after its delay)`,
802
+ )
803
+ if (sampled) lines.push(" " + pointLine("sampled", sampled, null))
804
+ }
805
+ if (!Ts.length) lines.push("", `(pass at, from/to as recording times, like 00:01.20, to map them onto this animation; its duration is ${msText(dur)})`)
806
+ return lines.join("\n")
807
+ }
808
+
809
+ /** A recorded clip (rec.clips / timeline().clips) as an anims[] entry, as far as the clip knows it. */
810
+ export function animFromClip(c) {
811
+ const kindOf = { transition: "css-transition", "css-animation": "css-animation", waapi: "waapi" }
812
+ const iterations = c.iterations === "infinite" ? Infinity : Number(c.iterations) || 1
813
+ const timing = c.timing
814
+ ? { ...c.timing }
815
+ : {
816
+ delay: Number(c.delay) || 0,
817
+ duration: c.dur != null ? Number(c.dur) : c.end != null ? Math.max(0, (c.end - c.start - (Number(c.delay) || 0)) / (iterations === Infinity ? 1 : iterations)) : 0,
818
+ iterations,
819
+ direction: "normal",
820
+ fill: "none",
821
+ easing: "linear",
822
+ playbackRate: 1,
823
+ }
824
+ timing.start = c.start
825
+ timing.end = c.end == null ? null : c.end
826
+ if (timing.activeStart == null) timing.activeStart = c.activeStart != null ? c.activeStart : c.start + (Number(timing.delay) || 0) / (Number(timing.playbackRate) || 1)
827
+ const keyframes = c.kf || (c.from && c.to ? [{ offset: 0, easing: "linear", values: c.from }, { offset: 1, easing: "linear", values: c.to }] : [])
828
+ return {
829
+ id: c.id,
830
+ relation: "on",
831
+ selector: c.selector || null,
832
+ kind: kindOf[c.kind] || c.kind || "waapi",
833
+ ...(c.lib ? { lib: c.lib } : {}),
834
+ name: c.kind === "transition" ? `${c.property || "?"} transition` : c.label || c.property || "animation",
835
+ defined: c.def || null,
836
+ ...(c.trigger ? { trigger: c.trigger } : {}),
837
+ timing,
838
+ keyframes,
839
+ approx: !c.kf,
840
+ ...(c.gsap ? { gsap: c.gsap } : {}),
841
+ }
842
+ }
843
+
844
+ export { clockText, msText, FRAME }