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.
- package/LICENSE +91 -0
- package/README.md +198 -0
- package/bin/retake.js +319 -0
- package/package.json +87 -0
- package/src/code-versions.js +357 -0
- package/src/core.js +265 -0
- package/src/plugin.js +137 -0
- package/src/runtime/00-core.js +308 -0
- package/src/runtime/10-animations.js +362 -0
- package/src/runtime/20-media.js +157 -0
- package/src/runtime/30-recorder.js +315 -0
- package/src/runtime/32-state.js +315 -0
- package/src/runtime/35-network.js +789 -0
- package/src/runtime/36-scripts.js +129 -0
- package/src/runtime/37-next.js +150 -0
- package/src/runtime/38-observers.js +252 -0
- package/src/runtime/40-input.js +795 -0
- package/src/runtime/45-hover.js +78 -0
- package/src/runtime/50-engine.js +341 -0
- package/src/runtime/60-preview.js +437 -0
- package/src/runtime/65-timeline.js +289 -0
- package/src/runtime/66-activity.js +114 -0
- package/src/runtime/67-csssource.js +226 -0
- package/src/runtime/70-boot.js +460 -0
- package/src/server/api.js +285 -0
- package/src/server/child.js +174 -0
- package/src/server/detect.js +167 -0
- package/src/server/front.js +647 -0
- package/src/server/mcp.js +332 -0
- package/src/shell/00-state.js +85 -0
- package/src/shell/05-api.js +136 -0
- package/src/shell/10-dock.js +754 -0
- package/src/shell/12-checkpoint.js +121 -0
- package/src/shell/15-session.js +291 -0
- package/src/shell/20-timeline.js +734 -0
- package/src/shell/25-input.js +537 -0
- package/src/shell/30-notes.js +870 -0
- package/src/shell/40-code.js +80 -0
- package/src/shell/90-handle.js +15 -0
- package/src/shell/shell.css +295 -0
- package/src/shell/shell.html +59 -0
- package/types/client.d.ts +73 -0
- 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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
|
+
}
|