retake-dev 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +91 -0
  2. package/README.md +198 -0
  3. package/bin/retake.js +319 -0
  4. package/package.json +87 -0
  5. package/src/code-versions.js +357 -0
  6. package/src/core.js +265 -0
  7. package/src/plugin.js +137 -0
  8. package/src/runtime/00-core.js +308 -0
  9. package/src/runtime/10-animations.js +362 -0
  10. package/src/runtime/20-media.js +157 -0
  11. package/src/runtime/30-recorder.js +315 -0
  12. package/src/runtime/32-state.js +315 -0
  13. package/src/runtime/35-network.js +789 -0
  14. package/src/runtime/36-scripts.js +129 -0
  15. package/src/runtime/37-next.js +150 -0
  16. package/src/runtime/38-observers.js +252 -0
  17. package/src/runtime/40-input.js +795 -0
  18. package/src/runtime/45-hover.js +78 -0
  19. package/src/runtime/50-engine.js +341 -0
  20. package/src/runtime/60-preview.js +437 -0
  21. package/src/runtime/65-timeline.js +289 -0
  22. package/src/runtime/66-activity.js +114 -0
  23. package/src/runtime/67-csssource.js +226 -0
  24. package/src/runtime/70-boot.js +460 -0
  25. package/src/server/api.js +285 -0
  26. package/src/server/child.js +174 -0
  27. package/src/server/detect.js +167 -0
  28. package/src/server/front.js +647 -0
  29. package/src/server/mcp.js +332 -0
  30. package/src/shell/00-state.js +85 -0
  31. package/src/shell/05-api.js +136 -0
  32. package/src/shell/10-dock.js +754 -0
  33. package/src/shell/12-checkpoint.js +121 -0
  34. package/src/shell/15-session.js +291 -0
  35. package/src/shell/20-timeline.js +734 -0
  36. package/src/shell/25-input.js +537 -0
  37. package/src/shell/30-notes.js +870 -0
  38. package/src/shell/40-code.js +80 -0
  39. package/src/shell/90-handle.js +15 -0
  40. package/src/shell/shell.css +295 -0
  41. package/src/shell/shell.html +59 -0
  42. package/types/client.d.ts +73 -0
  43. package/types/index.d.ts +111 -0
@@ -0,0 +1,332 @@
1
+ // `retake mcp`: an MCP server (stdio, JSON-RPC 2.0, one message per line) that
2
+ // lets a coding agent read the notes left in the dock and answer them. It
3
+ // talks to the running dev server over HTTP (CONTRACT.md "Server HTTP"), so
4
+ // changes show up in the dock live.
5
+ //
6
+ // npx -y retake-dev mcp (registered as an MCP server in the coding agent)
7
+ //
8
+ // Finds the dev server from --url / RETAKE_URL, else <cwd or a parent>/.retake/server.json.
9
+ import fs from "node:fs"
10
+ import path from "node:path"
11
+
12
+ const PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"]
13
+ const PKG = JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"))
14
+
15
+ function findServerInfo(from = process.cwd()) {
16
+ for (let dir = path.resolve(from); ; dir = path.dirname(dir)) {
17
+ const f = path.join(dir, ".retake", "server.json")
18
+ if (fs.existsSync(f)) {
19
+ try {
20
+ return JSON.parse(fs.readFileSync(f, "utf8"))
21
+ } catch {}
22
+ }
23
+ if (path.dirname(dir) === dir) return null
24
+ }
25
+ }
26
+
27
+ /** @param {{ url?: string | null, token?: string }} [options] */
28
+ export function createClient({ url, token } = {}) {
29
+ /** @type {{ url: string, token?: string } | null} */
30
+ let info = null
31
+ const base = () => {
32
+ if (url) return url.replace(/\/$/, "")
33
+ info = info || findServerInfo()
34
+ if (!info) throw new Error("no running Retake dev server found. Start one with `npx retake-dev .` in the project (or pass --url)")
35
+ return info.url.replace(/\/$/, "")
36
+ }
37
+ async function getToken() {
38
+ if (token) return token
39
+ info = info || findServerInfo()
40
+ if (info && info.token && (!url || info.url.replace(/\/$/, "") === base())) return (token = info.token)
41
+ const html = await (await fetch(base() + "/")).text()
42
+ const m = html.match(/__RETAKE_TOKEN = "([0-9a-f]+)"/)
43
+ if (!m) throw new Error(`${base()} doesn't look like a Retake dev server`)
44
+ return (token = m[1])
45
+ }
46
+ async function call(method, p, body) {
47
+ let res
48
+ try {
49
+ res = await fetch(base() + p, {
50
+ method,
51
+ headers: body !== undefined || method !== "GET" ? { "content-type": "application/json", "x-retake-token": await getToken() } : {},
52
+ body: body === undefined ? undefined : JSON.stringify(body),
53
+ })
54
+ } catch (err) {
55
+ throw new Error(`can't reach the Retake dev server at ${base()} (${err.cause ? err.cause.code || err.cause.message : err.message}). Is it running?`)
56
+ }
57
+ const text = await res.text()
58
+ if (!res.ok) throw new Error(`${method} ${p} → ${res.status}: ${text.slice(0, 200)}`)
59
+ return text ? JSON.parse(text) : null
60
+ }
61
+ return {
62
+ base,
63
+ session: () => call("GET", "/__retake/session"),
64
+ notes: () => call("GET", "/__retake/notes"),
65
+ note: (id) => call("GET", `/__retake/notes/${encodeURIComponent(id)}`),
66
+ patch: (id, body) => call("PATCH", `/__retake/notes/${encodeURIComponent(id)}`, body),
67
+ // Resolves on the next matching SSE event (or null after `ms`).
68
+ async nextEvent(types, ms) {
69
+ const ac = new AbortController()
70
+ const timer = setTimeout(() => ac.abort(), ms)
71
+ try {
72
+ const res = await fetch(base() + "/__retake/events", { signal: ac.signal })
73
+ if (!res.body) return null
74
+ const reader = res.body.pipeThrough(new TextDecoderStream()).getReader()
75
+ let buf = ""
76
+ for (;;) {
77
+ const { done, value } = await reader.read()
78
+ if (done) return null
79
+ buf += value
80
+ let i
81
+ while ((i = buf.indexOf("\n\n")) >= 0) {
82
+ const block = buf.slice(0, i)
83
+ buf = buf.slice(i + 2)
84
+ const ev = /^event: (.*)$/m.exec(block)
85
+ const data = /^data: (.*)$/m.exec(block)
86
+ if (ev && types.includes(ev[1])) {
87
+ ac.abort()
88
+ return { event: ev[1], data: data ? JSON.parse(data[1]) : null }
89
+ }
90
+ }
91
+ }
92
+ } catch (err) {
93
+ if (err.name === "AbortError") return null
94
+ throw err
95
+ } finally {
96
+ clearTimeout(timer)
97
+ }
98
+ },
99
+ }
100
+ }
101
+
102
+ // ---- presenting notes -----------------------------------------------------------
103
+
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
+ function summary(n, session) {
110
+ const b = (session.branches || []).find((x) => String(x.id) === String(n.branchId))
111
+ return {
112
+ id: n.id,
113
+ status: n.status || "pending",
114
+ text: n.text,
115
+ timeline: b ? b.name : n.branchId,
116
+ at: fmt(n.t),
117
+ selector: n.selector || (n.el && n.el.selector) || null,
118
+ component: n.component || (n.el && n.el.components && n.el.components[0]) || null,
119
+ source: n.source ? `${n.source.file}${n.source.line ? ":" + n.source.line : ""}` : null,
120
+ replies: (n.replies || []).length,
121
+ }
122
+ }
123
+
124
+ function clipText(c) {
125
+ const name = c.label ? `${c.label} ` : ""
126
+ const at = `${Math.round(Number(c.offset) || 0)}ms into`
127
+ 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})`
128
+ }
129
+
130
+ // Everything an agent needs to act on one note, as readable text.
131
+ function describe(n, session) {
132
+ const b = (session.branches || []).find((x) => String(x.id) === String(n.branchId))
133
+ const parent = b && (session.branches || []).find((x) => String(x.id) === String(b.parentId))
134
+ const el = n.el || {}
135
+ const lines = [
136
+ `Note ${n.id} (${n.status || "pending"}) on timeline "${b ? b.name : n.branchId}" at ${fmt(n.t)}`,
137
+ parent ? `That timeline branched from "${parent.name}" at ${fmt(b.forkAt)}${b.codeVersion ? `; it runs code version ${b.codeVersion}` : ""}.` : null,
138
+ "",
139
+ `What the user wants: ${n.text}`,
140
+ "",
141
+ `Element: ${el.label || ""}${el.text ? ` "${el.text}"` : ""}`.trim(),
142
+ `Selector: ${n.selector || el.selector || "?"}`,
143
+ n.component || (el.components && el.components.length) ? `Component: ${n.component || el.components.join(" < ")}` : null,
144
+ n.source ? `Source: ${n.source.file}${n.source.line ? ":" + n.source.line : ""}` : null,
145
+ n.classes && n.classes.length ? `Classes: ${[].concat(n.classes).join(" ")}` : null,
146
+ 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,
147
+ n.clip ? `During an animation: ${clipText(n.clip)}` : null,
148
+ el.page ? `Page: ${el.page}` : null,
149
+ ]
150
+ if ((n.replies || []).length) {
151
+ lines.push("", "Conversation:")
152
+ for (const r of n.replies) lines.push(`- ${r.from}: ${r.text}`)
153
+ }
154
+ return lines.filter((l) => l != null).join("\n")
155
+ }
156
+
157
+ // ---- tools --------------------------------------------------------------------------
158
+
159
+ const TOOLS = [
160
+ {
161
+ name: "list_notes",
162
+ description: "List the notes left in the Retake timeline (things the user wants changed in the prototype). Defaults to notes still waiting on you (pending or acknowledged).",
163
+ inputSchema: { type: "object", properties: { status: { type: "string", enum: ["open", "pending", "acknowledged", "resolved", "dismissed", "all"], description: "Filter; 'open' (default) = pending + acknowledged" } } },
164
+ },
165
+ {
166
+ name: "get_note",
167
+ 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.",
168
+ inputSchema: { type: "object", properties: { id: { type: ["string", "number"] } }, required: ["id"] },
169
+ },
170
+ {
171
+ name: "get_active_timeline",
172
+ 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.",
173
+ inputSchema: { type: "object", properties: {} },
174
+ },
175
+ {
176
+ name: "acknowledge",
177
+ description: "Tell the user you've seen a note and are working on it (its pin turns to 'acknowledged' in the dock). Optionally add a short message.",
178
+ inputSchema: { type: "object", properties: { id: { type: ["string", "number"] }, message: { type: "string" } }, required: ["id"] },
179
+ },
180
+ {
181
+ name: "resolve",
182
+ description: "Mark a note done, with a one or two sentence summary of what you changed. The summary shows in the dock.",
183
+ inputSchema: { type: "object", properties: { id: { type: ["string", "number"] }, summary: { type: "string" } }, required: ["id", "summary"] },
184
+ },
185
+ {
186
+ name: "reply",
187
+ description: "Reply on a note without changing its status (ask a question, explain a trade-off).",
188
+ inputSchema: { type: "object", properties: { id: { type: ["string", "number"] }, text: { type: "string" } }, required: ["id", "text"] },
189
+ },
190
+ {
191
+ name: "watch_notes",
192
+ description: "Wait until the user adds a note or replies to one, then return what changed. Returns an empty list on timeout.",
193
+ inputSchema: { type: "object", properties: { timeout_seconds: { type: "number", description: "How long to wait (default 60, max 600)" } } },
194
+ },
195
+ ]
196
+
197
+ const isOpen = (n) => !n.status || n.status === "pending" || n.status === "acknowledged"
198
+
199
+ export function createTools(client) {
200
+ const byId = async (id) => {
201
+ const note = await client.note(id).catch((err) => {
202
+ if (/→ 404/.test(err.message)) throw new Error(`no note with id ${id}; list_notes shows the ids`)
203
+ throw err
204
+ })
205
+ return note
206
+ }
207
+ const handlers = {
208
+ async list_notes({ status = "open" } = {}) {
209
+ const session = await client.session()
210
+ const notes = (session.notes || []).filter((n) => (status === "all" ? true : status === "open" ? isOpen(n) : (n.status || "pending") === status))
211
+ return { count: notes.length, notes: notes.map((n) => summary(n, session)) }
212
+ },
213
+ async get_note({ id }) {
214
+ const [session, note] = await Promise.all([client.session(), byId(id)])
215
+ return describe(note, session)
216
+ },
217
+ async get_active_timeline() {
218
+ const session = await client.session()
219
+ const branches = session.branches || []
220
+ const active = branches.find((b) => String(b.id) === String(session.activeId)) || null
221
+ const parent = active && branches.find((b) => String(b.id) === String(active.parentId))
222
+ return {
223
+ active: active && { id: active.id, name: active.name, forkAt: fmt(active.forkAt), parent: parent ? parent.name : null, codeVersion: active.codeVersion || null },
224
+ timelines: branches.map((b) => ({ id: b.id, name: b.name, parent: b.parentId, forkAt: fmt(b.forkAt), codeVersion: b.codeVersion || null })),
225
+ openNotes: (session.notes || []).filter((n) => isOpen(n) && active && String(n.branchId) === String(active.id)).map((n) => summary(n, session)),
226
+ }
227
+ },
228
+ async acknowledge({ id, message }) {
229
+ await byId(id)
230
+ const n = await client.patch(id, { status: "acknowledged", ...(message ? { reply: message } : {}) })
231
+ return `Acknowledged note ${n.id}.`
232
+ },
233
+ async resolve({ id, summary: text }) {
234
+ if (!text) throw new Error("resolve needs a summary of what you changed")
235
+ await byId(id)
236
+ const n = await client.patch(id, { status: "resolved", reply: text })
237
+ return `Resolved note ${n.id}.`
238
+ },
239
+ async reply({ id, text }) {
240
+ if (!text) throw new Error("reply needs text")
241
+ await byId(id)
242
+ await client.patch(id, { reply: text })
243
+ return `Replied on note ${id}.`
244
+ },
245
+ async watch_notes({ timeout_seconds = 60 } = {}) {
246
+ const ms = Math.min(Math.max(Number(timeout_seconds) || 60, 1), 600) * 1000
247
+ const key = (n) => `${n.status || "pending"}|${(n.replies || []).filter((r) => r.from === "user").length}|${n.text}`
248
+ const before = new Map(((await client.session()).notes || []).map((n) => [String(n.id), key(n)]))
249
+ const deadline = Date.now() + ms
250
+ while (Date.now() < deadline) {
251
+ const got = await client.nextEvent(["session", "note-updated"], deadline - Date.now())
252
+ if (!got) break
253
+ const session = await client.session()
254
+ const changed = (session.notes || []).filter((n) => before.get(String(n.id)) !== key(n))
255
+ // Ignore what agents did (replies from "agent" don't change the key).
256
+ if (changed.length) return { changed: changed.map((n) => ({ ...summary(n, session), new: !before.has(String(n.id)) })) }
257
+ }
258
+ return { changed: [] }
259
+ },
260
+ }
261
+ return { list: TOOLS, handlers }
262
+ }
263
+
264
+ /** @param {{ url?: string }} [options] */
265
+ export async function runMcp({ url } = {}) {
266
+ const client = createClient({ url: url || null })
267
+ const tools = createTools(client)
268
+ const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\n")
269
+ const reply = (id, result) => send({ jsonrpc: "2.0", id, result })
270
+ const error = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, message } })
271
+
272
+ async function handle(msg) {
273
+ const { id, method, params } = msg
274
+ if (method === "initialize") {
275
+ const asked = params && params.protocolVersion
276
+ return reply(id, {
277
+ protocolVersion: PROTOCOLS.includes(asked) ? asked : PROTOCOLS[0],
278
+ capabilities: { tools: { listChanged: false } },
279
+ serverInfo: { name: "retake", version: PKG.version },
280
+ 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.",
281
+ })
282
+ }
283
+ if (method === "notifications/initialized" || (method && method.startsWith("notifications/"))) return
284
+ if (method === "ping") return reply(id, {})
285
+ if (method === "tools/list") return reply(id, { tools: tools.list })
286
+ if (method === "tools/call") {
287
+ const h = tools.handlers[params && params.name]
288
+ if (!h) return error(id, -32602, `unknown tool ${params && params.name}`)
289
+ try {
290
+ const out = await h(params.arguments || {})
291
+ return reply(id, { content: [{ type: "text", text: typeof out === "string" ? out : JSON.stringify(out, null, 2) }] })
292
+ } catch (err) {
293
+ return reply(id, { content: [{ type: "text", text: err.message }], isError: true })
294
+ }
295
+ }
296
+ if (id !== undefined) error(id, -32601, `method not found: ${method}`)
297
+ }
298
+
299
+ let buf = ""
300
+ let inFlight = 0
301
+ let ended = false
302
+ const maybeExit = () => ended && inFlight === 0 && process.exit(0)
303
+ process.stdin.setEncoding("utf8")
304
+ process.stdin.on("data", (chunk) => {
305
+ buf += chunk
306
+ let i
307
+ while ((i = buf.indexOf("\n")) >= 0) {
308
+ const line = buf.slice(0, i).trim()
309
+ buf = buf.slice(i + 1)
310
+ if (!line) continue
311
+ let msg
312
+ try {
313
+ msg = JSON.parse(line)
314
+ } catch {
315
+ error(null, -32700, "parse error")
316
+ continue
317
+ }
318
+ inFlight++
319
+ handle(msg)
320
+ .catch((err) => msg.id !== undefined && error(msg.id, -32603, err.message))
321
+ .finally(() => {
322
+ inFlight--
323
+ maybeExit()
324
+ })
325
+ }
326
+ })
327
+ // The client closed its end: answer what's still in flight, then leave.
328
+ process.stdin.on("end", () => {
329
+ ended = true
330
+ maybeExit()
331
+ })
332
+ }
@@ -0,0 +1,85 @@
1
+ // Shared pieces of the dock. The shell files are concatenated into one scope;
2
+ // everything they share that changes over time lives on `D`, so a file never
3
+ // reaches into another file's variables.
4
+ const $ = (s) => document.querySelector(s)
5
+ const KEY = "retake:"
6
+ const store = {
7
+ get(k, fallback) {
8
+ try {
9
+ const v = localStorage.getItem(KEY + k)
10
+ return v == null ? fallback : JSON.parse(v)
11
+ } catch {
12
+ return fallback
13
+ }
14
+ },
15
+ set(k, v) {
16
+ try {
17
+ localStorage.setItem(KEY + k, JSON.stringify(v))
18
+ } catch {}
19
+ },
20
+ }
21
+
22
+ const D = {
23
+ // frames
24
+ frame: null, // the visible prototype frame
25
+ PT: null, // its runtime (window.__retake inside it)
26
+ // The one frame being built behind it (10-dock.js): { id, frame, pt, stash,
27
+ // url, viewport, branchId, sig, target, play, fork, visible, startedAt, via,
28
+ // restarts }. play / fork: do that once it's in. visible: nothing on screen
29
+ // shows its moment, so the user is waiting for it (waits()).
30
+ building: null,
31
+ buildSeq: 0, // numbers builds (and checkpoints), so a frame that reloaded itself is told apart
32
+ holdSwap: false, // tests: keep a finished build behind (it doesn't swap in)
33
+ buildWatchdogMs: 10000, // a build whose page loaded without starting it is tried again after this (tests shorten it)
34
+ cp: null, // a hidden paused frame at an earlier moment, to rewind from (12-checkpoint.js)
35
+ last: null, // the last runtime state seen
36
+ // timelines
37
+ branches: [], // { id, name, forkAt, parentId, json, end, born, version }
38
+ activeId: 0,
39
+ branchSeq: 0,
40
+ switching: false,
41
+ frameBranch: 0, // the timeline the visible frame is playing
42
+ // what's been said about them
43
+ notes: [],
44
+ markers: [], // bookmarks dropped with M: { id, t, branchId }
45
+ markerSeq: 0,
46
+ // pointer and tools
47
+ dragT: null, // the time under the playhead while scrubbing
48
+ dragX0: 0, // where the press was, and whether the pointer has moved off it (3px)
49
+ dragMoved: false,
50
+ frameNo: 0, // dock frames drawn (one direct scrub per frame)
51
+ branchT: null, // where a Control-click on the track would branch
52
+ hot: null, // what the pointer is over: { kind: "clip" | "mark", i }
53
+ keyT: null, // where arrow keys have taken the playhead, until it's built
54
+ snapT: null, // what the playhead snapped to while scrubbing
55
+ lanes: null, // lane id → { y, thin }, from the last draw
56
+ clipBars: [],
57
+ picking: null, // "comment" | "select" | null
58
+ metaHeld: false,
59
+ scopeEl: null, // the element the Select tool scoped scrubbing to
60
+ // dock size
61
+ height: store.get("height", 200),
62
+ view: null, // the window of time on the timeline (20-timeline.js)
63
+ }
64
+
65
+ const fmt = (ms) => {
66
+ const s = Math.max(0, ms) / 1000
67
+ return `${String(Math.floor(s / 60)).padStart(2, "0")}:${(s % 60).toFixed(2).padStart(5, "0")}`
68
+ }
69
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c])
70
+ const clamp = (v, a, b) => Math.min(Math.max(v, a), b)
71
+
72
+ const activeBranch = () => D.branches.find((b) => b.id === D.activeId)
73
+ const branchById = (id) => D.branches.find((b) => b.id === id)
74
+
75
+ // Is the user waiting for this build (the readout says Building)?
76
+ const waits = (b) => !!b && !!(b.play || b.fork || b.visible)
77
+ // Do two moments of the active recording look exactly the same (no frame or
78
+ // input between them)? The runtime knows; an older one is asked for 1ms.
79
+ function samePlace(a, b) {
80
+ if (a == null || b == null) return false
81
+ try {
82
+ if (D.PT && typeof D.PT.sameMoment === "function") return D.PT.sameMoment(a, b)
83
+ } catch {}
84
+ return Math.abs(a - b) < 1
85
+ }
@@ -0,0 +1,136 @@
1
+ // What the dock asks the runtime, through one door. The runtime's own
2
+ // timeline() / clipAt() / isInteractive() are used when it has them
3
+ // (CONTRACT.md). With an older runtime the same answers are derived from what
4
+ // it does have (activity() and history()), so the dock always has something
5
+ // true to draw.
6
+ const MARKER_KINDS = { pointerdown: "click", click: "click", keydown: "key", input: "input", submit: "submit", popstate: "route", hashchange: "route" }
7
+
8
+ let timelineMemo = { at: -1, key: "", value: null }
9
+
10
+ function derivedTimeline(pt, s) {
11
+ const h = pt.history()
12
+ const markers = []
13
+ for (const ev of h.events || []) {
14
+ const kind = MARKER_KINDS[ev.type]
15
+ if (!kind || ev.t < s.start) continue
16
+ // One marker per click, not per pointerdown/click pair.
17
+ if (ev.type === "click" && markers.some((m) => m.kind === "click" && Math.abs(m.t - ev.t) < 60)) continue
18
+ markers.push({ t: ev.t, kind, label: kind === "key" ? ev.key || "" : "" })
19
+ }
20
+ // Each animation the runtime drove, from its debug log; failing that, the
21
+ // merged bands of activity().
22
+ let clips = []
23
+ try {
24
+ clips = pt.debug().anims.map((a, i) => ({
25
+ id: "a" + i,
26
+ start: a.vStart,
27
+ end: a.vEnd == null || a.vEnd === 0 ? (a.state === "running" ? null : a.vStart) : a.vEnd,
28
+ kind: "animation",
29
+ label: a.target ? String(a.target) : "animation",
30
+ selector: a.target && /^[\w-]+$/.test(a.target) ? "#" + a.target : "",
31
+ }))
32
+ } catch {
33
+ const act = pt.activity ? pt.activity() : { bands: [] }
34
+ clips = act.bands.map(([a, b], i) => ({ id: "band" + i, start: a, end: b, kind: "animation", label: "animation", selector: "" }))
35
+ }
36
+ const vp = h.viewport || null
37
+ return { now: s.now, end: s.end, viewport: vp, markers, clips, derived: true }
38
+ }
39
+
40
+ // Cached for a few frames; timeline() walks the whole recording.
41
+ function timeline() {
42
+ const pt = D.PT
43
+ const s = D.last
44
+ if (!pt || !s) return { now: 0, end: 0, viewport: null, markers: [], clips: [] }
45
+ const key = `${D.activeId}:${s.end}:${s.now}:${s.seeking}`
46
+ const now = performance.now()
47
+ if (timelineMemo.value && timelineMemo.pt === pt && (timelineMemo.key === key || now - timelineMemo.at < 120)) return timelineMemo.value
48
+ let value
49
+ try {
50
+ value = typeof pt.timeline === "function" ? pt.timeline() : derivedTimeline(pt, s)
51
+ } catch (err) {
52
+ value = timelineMemo.value || { now: s.now, end: s.end, viewport: null, markers: [], clips: [] }
53
+ }
54
+ value.markers = value.markers || []
55
+ value.clips = value.clips || []
56
+ timelineMemo = { at: now, key, value, pt }
57
+ return value
58
+ }
59
+
60
+ // The clip playing at t (on an element, if given): { clip, offset } or null.
61
+ function clipAt(t, selector) {
62
+ const pt = D.PT
63
+ if (pt && typeof pt.clipAt === "function") {
64
+ try {
65
+ return pt.clipAt(t, selector) || null
66
+ } catch {}
67
+ }
68
+ const end = (c) => (c.end == null ? (D.last ? D.last.end : t) : c.end)
69
+ const all = timeline().clips.filter((c) => c.start <= t && t <= end(c))
70
+ const pick = (selector && all.find((c) => c.selector && c.selector === selector)) || all[all.length - 1]
71
+ return pick ? { clip: pick, offset: t - pick.start } : null
72
+ }
73
+
74
+ // Can the user act in the app right now? Only at the live edge. In the past
75
+ // the app is view-only, like a paused video.
76
+ function isInteractive(s = D.last) {
77
+ const pt = D.PT
78
+ if (pt && typeof pt.isInteractive === "function") {
79
+ try {
80
+ return !!pt.isInteractive()
81
+ } catch {}
82
+ }
83
+ if (!s || !s.started) return true
84
+ return !!s.playing && !s.future && !s.previewing
85
+ }
86
+
87
+ function setToolActive(on) {
88
+ const pt = D.PT
89
+ if (pt && typeof pt.setToolActive === "function") {
90
+ try {
91
+ pt.setToolActive(!!on)
92
+ } catch {}
93
+ }
94
+ }
95
+
96
+ // Play: from the past it replays, and at the end it's live again.
97
+ function play() {
98
+ const pt = D.PT
99
+ const s = state()
100
+ if (!pt || !s) return
101
+ // The moment on show is still being built behind: it plays once it's in.
102
+ const b = D.building
103
+ if (b && b.target != null) {
104
+ b.play = true
105
+ return
106
+ }
107
+ // A preview with nothing building (a scoped one): play from that moment.
108
+ if (s.previewing && typeof pt.buildAt === "function") return goTo(s.previewAt, { play: true })
109
+ if (typeof pt.play === "function") {
110
+ quietCheckpoint()
111
+ return pt.play()
112
+ }
113
+ // (A seek to exactly where it is doesn't move, so nudge it a millisecond.)
114
+ if (s.previewing) return pt.seek(Math.min(s.previewAt + 1, s.end), true)
115
+ if (s.future) return pt.seek(Math.min(s.now + 1, s.end), true)
116
+ pt.record()
117
+ }
118
+ function togglePlay() {
119
+ const s = state()
120
+ if (!D.PT || !s) return
121
+ if (D.dragT != null) return // mid-drag: letting go decides
122
+ // Keys just stepped somewhere: play from there.
123
+ if (D.keyT != null) {
124
+ const t = D.keyT
125
+ D.keyT = null
126
+ clearTimeout(keyTimer)
127
+ return goTo(t, { play: true })
128
+ }
129
+ // Waiting to play a moment being built: stop waiting (it carries on behind).
130
+ if (D.building && D.building.play) {
131
+ D.building.play = false
132
+ return
133
+ }
134
+ if (s.playing) D.PT.pause()
135
+ else play()
136
+ }