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,754 @@
1
+ // The dock. Lives in the top window; the prototype runs in a frame on the
2
+ // stage above it, with the time runtime inside. Going to another moment builds
3
+ // it in a second frame behind the visible one (invisible, one at a time) and
4
+ // swaps it in when it's ready, so there's no flash; going back, the visible
5
+ // frame previews the moment meanwhile (goTo() in 25-input.js).
6
+ const stage = $("#wb-stage")
7
+ const dock = $("#wb-dock")
8
+ const track = $(".track")
9
+ const cv = $(".lines") // the timeline canvas
10
+
11
+ const newBranch = (forkAt, parentId = null) => {
12
+ const id = ++D.branchSeq
13
+ const b = { id, name: id === 1 ? "Main" : `Take ${id}`, forkAt, parentId, json: null, end: forkAt, born: performance.now() }
14
+ D.branches.push(b)
15
+ return b
16
+ }
17
+ function resetBranches() {
18
+ D.branches = []
19
+ D.branchSeq = 0
20
+ D.activeId = newBranch(0).id
21
+ }
22
+ resetBranches()
23
+
24
+ // The Vite plugin knows the dock's frame by `?__wb=app` in its URL. Behind the
25
+ // front server (marker "header") the URL stays the app's own: the server knows
26
+ // a frame by its Sec-Fetch-Dest, and the runtime by isAppFrame() below.
27
+ const MARKER = (window.__retakeConfig && window.__retakeConfig.marker) || "url"
28
+ const appUrl = (() => {
29
+ const u = new URL(location.href)
30
+ if (MARKER !== "header") u.searchParams.set("__wb", "app")
31
+ return u.pathname + u.search + u.hash
32
+ })()
33
+ // Every frame the dock makes (the one on show, one being built, checkpoints).
34
+ const appFrames = new WeakSet()
35
+
36
+ // A static build has no server to tell `?__wb=app` from the dock's own page,
37
+ // so it ships the app as a file of its own and the frame always loads that.
38
+ const APP_PAGE = window.__retakeConfig && window.__retakeConfig.appPage
39
+
40
+ function makeFrame(src, stash) {
41
+ if (APP_PAGE) {
42
+ const u = new URL(src, location.href)
43
+ u.pathname = APP_PAGE
44
+ src = u.pathname + u.search + u.hash
45
+ }
46
+ const f = document.createElement("iframe")
47
+ appFrames.add(f)
48
+ f.title = "Prototype"
49
+ f.className = "building"
50
+ if (stash) f.__retakeStash = stash
51
+ f.src = src
52
+ stage.prepend(f)
53
+ return f
54
+ }
55
+
56
+ const frameRuntime = (f) => {
57
+ try {
58
+ return f.contentWindow.__retake || null
59
+ } catch {
60
+ return null
61
+ }
62
+ }
63
+
64
+ // The frame built behind takes the visible one's place, in one task: it shows
65
+ // the moment the old one was previewing, scrolled where the user had it, so
66
+ // nothing on screen moves (a page with a canvas, whose pixels the preview
67
+ // couldn't take back, fades in over 120ms).
68
+ function swapIn(b) {
69
+ const f = b.frame
70
+ const pt = b.pt
71
+ const old = D.frame && D.frame !== f ? D.frame : null
72
+ // What the user scrolled to look at, copied into this window (the old frame
73
+ // is going) and taken by the new one in its own (no frame keeps another alive).
74
+ // Only into a moment of a recording: a fresh frame (Start fresh) is a live
75
+ // page recording from its first moment, where a scroll it didn't record
76
+ // would be lost on the next rebuild.
77
+ let view = []
78
+ try {
79
+ if (old && b.target != null && D.PT && typeof D.PT.viewScroll === "function") view = JSON.parse(JSON.stringify(D.PT.viewScroll()))
80
+ } catch {}
81
+ // Built at the recorded size; the visible frame fills the stage again.
82
+ f.style.width = f.style.height = ""
83
+ try {
84
+ if (view.length && typeof pt.applyView === "function") pt.applyView(view)
85
+ } catch {}
86
+ f.className = "live"
87
+ f.removeAttribute("aria-hidden")
88
+ if (old) {
89
+ let canvas = false
90
+ try {
91
+ canvas = !!old.contentDocument.querySelector("canvas")
92
+ } catch {}
93
+ if (canvas) {
94
+ f.dataset.enter = ""
95
+ old.className = "leaving"
96
+ setTimeout(() => old.remove(), 140)
97
+ setTimeout(() => delete f.dataset.enter, 200)
98
+ } else old.remove()
99
+ }
100
+ D.frame = f
101
+ D.PT = pt
102
+ D.frameBranch = b.branchId != null ? b.branchId : D.activeId
103
+ D.building = null
104
+ window.__retakeShell.rebuilding = false
105
+ D.scopeEl = null // an element of the old frame
106
+ noteBuilt(b)
107
+ hookFrameKeys(f.contentWindow)
108
+ if (b.fork) forkNow()
109
+ else if (b.play) pt.play()
110
+ }
111
+
112
+ window.__retakeShell = {
113
+ rebuilding: false,
114
+ storageOwner: null, // which frame's app last ran (see takeStorage in the runtime)
115
+ fastReplay: true, // false: every replay settle is a full task round trip (a kill switch)
116
+ // The runtime hands over keys it blocks in the view-only past (or that a
117
+ // frame built behind got because its replay focused a field). True if the
118
+ // dock used it.
119
+ key(e) {
120
+ try {
121
+ return dockKey(e, true)
122
+ } catch {
123
+ return false
124
+ }
125
+ },
126
+ // Did the dock make this iframe? The injected runtime asks before it runs
127
+ // (an iframe the app embeds gets `__retake = { inert: true }`).
128
+ isAppFrame(el) {
129
+ return !!el && appFrames.has(el)
130
+ },
131
+ // Is this window the frame on show? (One built behind, a checkpoint or one
132
+ // fading out isn't: its keys are the dock's, ⌥P too.)
133
+ shows(win) {
134
+ try {
135
+ return !!D.frame && D.frame.contentWindow === win
136
+ } catch {
137
+ return false
138
+ }
139
+ },
140
+ // A booting runtime asks for the history it should build. Several frames
141
+ // can be booting (a rewind, a checkpoint), so each keeps its own; the one
142
+ // asking is the one whose runtime exists and hasn't taken it yet.
143
+ take() {
144
+ for (const f of stage.querySelectorAll("iframe")) {
145
+ let mine = false
146
+ try {
147
+ mine = "__retakeStash" in f && !!f.contentWindow.__retake
148
+ } catch {}
149
+ if (!mine) continue
150
+ const p = f.__retakeStash
151
+ delete f.__retakeStash
152
+ return p
153
+ }
154
+ return null
155
+ },
156
+ attach(pt) {
157
+ const b = D.building
158
+ const cp = D.cp
159
+ if (b && b.frame && frameRuntime(b.frame) === pt) {
160
+ // A frame that reloaded itself mid-build (a Vite full reload) comes back
161
+ // as something else: build it again, never swap that in.
162
+ if (b.id != null && pt.buildId !== b.id) return restartBuild(b)
163
+ b.pt = pt
164
+ try {
165
+ if (b.target != null && typeof pt.retarget === "function") pt.retarget(b.target)
166
+ if (typeof pt.setBackground === "function") pt.setBackground(D.dragT != null || D.keyT != null)
167
+ } catch {}
168
+ return
169
+ }
170
+ if (cp && frameRuntime(cp.frame) === pt) {
171
+ if (cp.id != null && pt.buildId !== cp.id) return dropCheckpoint()
172
+ cp.pt = pt
173
+ try {
174
+ if (typeof pt.setBackground === "function") pt.setBackground(true)
175
+ } catch {}
176
+ return
177
+ }
178
+ if (!D.frame || frameRuntime(D.frame) === pt) {
179
+ // The frame on show started again (it reloaded itself under a preview):
180
+ // the page it shows now is the truth, and its recording has grown, so a
181
+ // moment of this timeline being built behind is out of date.
182
+ if (D.PT && D.PT !== pt && b && b.target != null && b.branchId === D.frameBranch) cancelBuild()
183
+ D.PT = pt
184
+ if (D.frame) hookFrameKeys(D.frame.contentWindow)
185
+ }
186
+ },
187
+ // Build a moment (a history and a time) in a frame behind the visible one,
188
+ // swap it in when it's ready. There's only ever one: a request for the
189
+ // moment already building keeps it, a later moment of the same recording on
190
+ // the same page moves it on (retarget), anything else replaces it.
191
+ rebuild(payload) {
192
+ const b = D.building
193
+ const sameRec = !!b && b.target != null && b.branchId === D.activeId && payload.sig != null && b.sig === payload.sig
194
+ if (sameRec && samePlace(b.target, payload.target)) {
195
+ b.play = b.play || !!payload.play
196
+ b.visible = true // asked for directly (the API): the dock's goTo() says otherwise if a preview shows it
197
+ return
198
+ }
199
+ if (sameRec && retargetBuild(b, payload.target)) {
200
+ b.via = "retarget"
201
+ b.play = !!payload.play
202
+ b.visible = true
203
+ return
204
+ }
205
+ cancelBuild()
206
+ // A frame built behind never plays by itself: the dock plays it once it's
207
+ // in (Play pressed meanwhile can be taken back).
208
+ const play = !!payload.play
209
+ payload = { ...payload, play: false }
210
+ if (useCheckpoint(payload)) {
211
+ Object.assign(D.building, { play, fork: false, visible: true, sig: payload.sig != null ? payload.sig : null })
212
+ return
213
+ }
214
+ // Copied into this window: the payload object was made in the old frame,
215
+ // and the new frame keeps what take() gives it for its whole life, so
216
+ // passing it on would keep every earlier frame alive in a chain.
217
+ const stash = own(payload)
218
+ stash.buildId = ++D.buildSeq
219
+ let rec = null
220
+ const parsed = () => rec || (rec = JSON.parse(payload.rec))
221
+ // The page the target moment was on (the runtime says, per segment).
222
+ const u = new URL(payload.url || urlAt(parsed(), payload.target), location.href)
223
+ // Replay at the size it was recorded at, or layout, media queries and
224
+ // virtual lists come out differently (F18).
225
+ const vp = okViewport(stash.viewport) || recordedViewport(parsed())
226
+ window.__retakeShell.rebuilding = true
227
+ // It's built for whichever timeline is active now (a switch sets that
228
+ // before it loads the target's recording).
229
+ D.building = {
230
+ id: stash.buildId,
231
+ frame: null,
232
+ pt: null,
233
+ stash,
234
+ url: u.pathname + u.search + u.hash,
235
+ viewport: vp,
236
+ branchId: D.activeId,
237
+ sig: payload.sig != null ? payload.sig : null,
238
+ target: payload.target,
239
+ play,
240
+ fork: false,
241
+ visible: true,
242
+ startedAt: performance.now(),
243
+ via: "replay",
244
+ restarts: 0,
245
+ }
246
+ startBuildFrame(D.building)
247
+ },
248
+ // The runtime is about to cut off its future at `at`: keep it as a branch.
249
+ branchOff(json, end, at) {
250
+ // A moment still being built belongs to the timeline we're leaving; if it
251
+ // landed now it would bring that timeline's future onto the new one.
252
+ cancelBuild()
253
+ const old = activeBranch()
254
+ old.json = json
255
+ old.end = end
256
+ const b = newBranch(at, old.id)
257
+ b.version = old.version // a new timeline starts on its parent's code
258
+ D.activeId = b.id
259
+ D.frameBranch = b.id // the visible frame carries on as the new timeline
260
+ },
261
+ }
262
+
263
+ // The page a recording was on at t: its segment's (each reload or navigation
264
+ // of the app starts one), else where it began.
265
+ function urlAt(rec, t) {
266
+ let url = rec.url
267
+ for (const n of rec.segments || rec.navs || []) if (n.t <= t && n.url) url = new URL(n.url, rec.url).href
268
+ return url
269
+ }
270
+
271
+ // A plain copy made in the dock's realm (values only).
272
+ function own(payload) {
273
+ const out = {}
274
+ for (const k of Object.keys(payload)) {
275
+ const v = payload[k]
276
+ out[k] = v !== null && typeof v === "object" ? JSON.parse(JSON.stringify(v)) : v
277
+ }
278
+ return out
279
+ }
280
+
281
+ // The viewport a recording was made at: the recording says, or the runtime's
282
+ // timeline() does.
283
+ const okViewport = (v) => (v && v.w > 0 && v.h > 0 ? { w: v.w, h: v.h } : null)
284
+ function recordedViewport(rec) {
285
+ if (okViewport(rec.viewport)) return okViewport(rec.viewport)
286
+ try {
287
+ const v = D.PT && D.PT.timeline && D.PT.timeline().viewport
288
+ if (okViewport(v)) return okViewport(v)
289
+ } catch {}
290
+ return null
291
+ }
292
+
293
+ // Behind the front server (config.docs) every page the frame loads is kept as
294
+ // it came, and a build asks for the copy its recording was made on with a
295
+ // one-shot cookie (the server takes it out as it serves it), so a server whose
296
+ // data or clock has moved on doesn't change the rebuilt page (F56).
297
+ const DOCS = !!(window.__retakeConfig && window.__retakeConfig.docs)
298
+ function askStoredDoc(doc, url) {
299
+ if (!DOCS || !doc || !/^[0-9a-f]{16}$/.test(doc)) return
300
+ try {
301
+ document.cookie = `__retake_doc=${doc}; path=${new URL(url, location.href).pathname}; max-age=10; samesite=strict`
302
+ } catch {}
303
+ }
304
+
305
+ // The frame for a build (again, after a restart), hidden behind the visible one.
306
+ function startBuildFrame(b) {
307
+ askStoredDoc(b.stash && b.stash.doc, b.url)
308
+ b.frame = makeFrame(b.url, b.stash)
309
+ b.frame.setAttribute("aria-hidden", "true")
310
+ if (b.viewport) {
311
+ b.frame.style.width = b.viewport.w + "px"
312
+ b.frame.style.height = b.viewport.h + "px"
313
+ }
314
+ b.pt = null
315
+ b.startedAt = performance.now()
316
+ }
317
+
318
+ // Does the frame on show (its state vs) show the moment build b is making:
319
+ // previewing it, or standing at it (a build of its own recording: an API seek,
320
+ // or a frame whose IndexedDB another frame has changed since)?
321
+ function showsMoment(vs, b) {
322
+ if (!vs || b.branchId !== D.frameBranch) return false
323
+ if (vs.previewing) return samePlace(vs.previewAt, b.target)
324
+ return b.sig != null && !vs.seeking && vs.target == null && samePlace(vs.now, b.target)
325
+ }
326
+
327
+ // Which page (segment) of the active recording a moment is on.
328
+ function segOf(t) {
329
+ let i = 0
330
+ try {
331
+ for (const s of D.PT.history().segments || []) if (s.t <= t) i++
332
+ } catch {}
333
+ return i
334
+ }
335
+
336
+ // Move the build in flight on to t, if it can get there (same page, not
337
+ // past it already). Before its runtime starts that's just a new target in
338
+ // the stash it will take (if the dock can tell it's the same page: it's the
339
+ // recording on show); after, the runtime decides (PT.retarget).
340
+ function retargetBuild(b, t) {
341
+ if (!b.frame) return false
342
+ if (!b.pt) {
343
+ if (!b.stash || b.sig == null || segOf(t) !== segOf(b.target)) return false
344
+ b.stash.target = b.target = t
345
+ return true
346
+ }
347
+ let ok = false
348
+ try {
349
+ ok = typeof b.pt.retarget === "function" && !!b.pt.retarget(t)
350
+ } catch {}
351
+ if (ok) b.target = t
352
+ return ok
353
+ }
354
+
355
+ // The same build from the start in a new frame (its frame reloaded itself,
356
+ // never started, or ran by itself). Twice at most.
357
+ function restartBuild(b) {
358
+ if (D.building !== b) return
359
+ if (b.restarts >= 2 || (!b.stash && !(b.target != null && b.branchId === D.frameBranch && D.PT && typeof D.PT.buildAt === "function"))) {
360
+ cancelBuild()
361
+ flash("Couldn't build that moment")
362
+ return
363
+ }
364
+ if (!b.stash) {
365
+ // A checkpoint's frame (there's no stash to start it from again): the
366
+ // frame on show asks for the moment afresh, and the user's wishes carry over.
367
+ const { target, play, fork, restarts } = b
368
+ cancelBuild()
369
+ try {
370
+ D.PT.buildAt(target)
371
+ } catch {}
372
+ const nb = D.building
373
+ if (nb && nb !== b) Object.assign(nb, { play, fork, restarts: restarts + 1 })
374
+ return
375
+ }
376
+ b.restarts++
377
+ try {
378
+ b.frame.remove()
379
+ } catch {}
380
+ b.stash = { ...own(b.stash), target: b.target, buildId: b.id }
381
+ startBuildFrame(b)
382
+ }
383
+
384
+ // A build whose frame hasn't started after a while (D.buildWatchdogMs): a page
385
+ // that finished loading without starting it (an error page, a page without the
386
+ // runtime) never will. One still loading (a slow module script, a cold dev
387
+ // server optimizing its dependencies) is left to load, unless it takes
388
+ // absurdly long.
389
+ const BUILD_GIVE_UP = 60000
390
+ function buildStalled(b) {
391
+ const waited = performance.now() - b.startedAt
392
+ if (waited <= D.buildWatchdogMs) return false
393
+ let doc = null
394
+ try {
395
+ doc = b.frame.contentDocument
396
+ } catch {}
397
+ if (!doc) return true // not this app's page any more (another origin, an error page)
398
+ if (doc.readyState === "complete" && doc.URL !== "about:blank") return true
399
+ return waited > BUILD_GIVE_UP
400
+ }
401
+
402
+ // Drop the build in flight (and a checkpoint still being built: one frame
403
+ // runs the app at a time). A checkpoint that's ready stays.
404
+ function cancelBuild() {
405
+ if (D.cp && !checkpointReady(D.cp)) dropCheckpoint()
406
+ const b = D.building
407
+ if (!b) return
408
+ D.building = null
409
+ window.__retakeShell.rebuilding = false
410
+ if (b.frame && b.frame !== D.frame) b.frame.remove()
411
+ }
412
+
413
+ // A fresh prototype: no history.
414
+ function freshFrame() {
415
+ cancelBuild()
416
+ dropCheckpoint()
417
+ D.building = { id: null, frame: makeFrame(appUrl), pt: null, branchId: D.activeId, target: null, play: false, fork: false, visible: true, startedAt: performance.now(), via: "fresh", restarts: 0 }
418
+ window.__retakeShell.rebuilding = true
419
+ }
420
+
421
+ // The first frame is opened by restore() (15-session.js) once it knows what
422
+ // was saved: always at the page the user asked for, live, never a rebuild.
423
+ function openFirstFrame(stash, branchId) {
424
+ if (D.frame) return
425
+ D.frame = makeFrame(appUrl, stash)
426
+ D.frame.className = "live"
427
+ D.frameBranch = branchId
428
+ }
429
+
430
+ // Each dock frame: keep the build in flight honest, and swap it in once it's
431
+ // at its moment. Unless the user is waiting for it, that also waits for
432
+ // nothing to be in hand: a drag, a key step, a note being written, a picking
433
+ // tool, a scoped preview (they all point into the frame on show).
434
+ function checkBuilding() {
435
+ const b = D.building
436
+ if (!b) return
437
+ let vs = null
438
+ try {
439
+ vs = D.PT && D.PT.state()
440
+ } catch {}
441
+ if (b.target != null) {
442
+ // Built for a timeline we've since left.
443
+ if (b.branchId !== D.activeId) return cancelBuild()
444
+ // The frame on show runs its app again (played, ⌥P, an in-place seek
445
+ // somewhere; not one just stopped for a preview): that's where the user
446
+ // is now, not this moment.
447
+ if (vs && (vs.playing || (vs.seeking && vs.target != null && vs.target > vs.now))) return cancelBuild()
448
+ // The Select tool scoped a preview: it stays a picture.
449
+ if (D.scopeEl && !waits(b)) return cancelBuild()
450
+ }
451
+ // Is the moment on screen meanwhile (the frame on show previews it, or is
452
+ // at it)? If not (the preview was ended from elsewhere, or it's on an
453
+ // earlier page), the user is waiting for it.
454
+ if (b.target != null && D.dragT == null && D.keyT == null) b.visible = !showsMoment(vs, b)
455
+ if (!b.pt) {
456
+ if (buildStalled(b)) restartBuild(b)
457
+ return
458
+ }
459
+ let s
460
+ try {
461
+ s = b.pt.state()
462
+ } catch {
463
+ return
464
+ }
465
+ // A moment built behind never runs by itself (only the dock plays a frame,
466
+ // once it's in): one that does has left its moment. Build it again. (A fresh
467
+ // frame does: it records from its first moment.)
468
+ if (b.target != null && s.playing) {
469
+ try {
470
+ b.pt.pause()
471
+ } catch {}
472
+ return restartBuild(b)
473
+ }
474
+ if (!s.booted || s.seeking || s.target != null) return
475
+ if (b.target != null && !samePlace(s.now, b.target)) return
476
+ if (D.holdSwap) return
477
+ if (!waits(b) && (D.dragT != null || D.keyT != null || composing() || mode() || D.scopeEl)) return
478
+ if (frameRuntime(b.frame) !== b.pt) return // it reloaded itself; attach() starts it again
479
+ swapIn(b)
480
+ }
481
+
482
+ // Returns true only if the switch happened.
483
+ async function switchTo(id, t) {
484
+ const target = branchById(id)
485
+ if (!D.PT || !target || D.switching || id === D.activeId) return false
486
+ D.switching = true
487
+ try {
488
+ const json = await recordingOf(target)
489
+ if (!json || !D.PT) return false
490
+ const cur = activeBranch()
491
+ // A timeline made on other code runs on its own version of the code.
492
+ if (target.version && cur && cur.version && target.version !== cur.version) {
493
+ const r = await checkoutCode(target.version)
494
+ if (!r.ok) return false
495
+ await settleLeftVersion(cur, r)
496
+ }
497
+ if (cur) {
498
+ cur.json = JSON.stringify(D.PT.history())
499
+ cur.end = D.PT.state().end
500
+ }
501
+ D.activeId = id
502
+ D.PT.load(json, clamp(t, target.forkAt, target.end))
503
+ return true
504
+ } finally {
505
+ D.switching = false
506
+ }
507
+ }
508
+
509
+ // Bookmarks: dropped on a timeline at a moment, to come back to.
510
+ function addFlag() {
511
+ const s = state()
512
+ if (!s || !s.started) return
513
+ D.markers.push({ id: ++D.markerSeq, t: shownTime(s), branchId: D.activeId })
514
+ }
515
+
516
+ // Keep the address bar and title in step with the prototype's own route (the
517
+ // route at the moment previewed, while a preview is on show).
518
+ setInterval(() => {
519
+ try {
520
+ const inner = new URL(D.frame.contentWindow.location.href)
521
+ inner.searchParams.delete("__wb")
522
+ const s = D.PT && D.PT.state()
523
+ const next = (s && s.previewing && s.route) || inner.pathname + inner.search + inner.hash
524
+ if (next !== location.pathname + location.search + location.hash) history.replaceState(null, "", next)
525
+ if (D.frame.contentDocument.title) document.title = D.frame.contentDocument.title
526
+ } catch {}
527
+ }, 400)
528
+
529
+ const state = () => {
530
+ try {
531
+ if (D.PT) D.last = D.PT.state()
532
+ } catch {
533
+ D.PT = null
534
+ }
535
+ return D.last
536
+ }
537
+
538
+ function render() {
539
+ D.frameNo++
540
+ checkBuilding()
541
+ dropStaleResume()
542
+ tendCheckpoint()
543
+ guardFocus()
544
+ // The active timeline can vanish under us (a delete racing a switch); stand
545
+ // on the first one rather than draw nothing.
546
+ if (!activeBranch()) D.activeId = D.branches[0].id
547
+ const s = state()
548
+ const dh = dockHeight()
549
+ dock.style.height = dh + "px"
550
+ document.body.style.setProperty("--dock-h", dh + "px")
551
+ if (!s || !D.frame) return
552
+ autoStart(s)
553
+ if (D.perfNoDraw) return // (perf measurements: the runtime alone)
554
+ const active = activeBranch()
555
+ // Only the active timeline's own frame says how far it goes: mid-switch the
556
+ // visible frame is still the timeline we're leaving.
557
+ // Its recording is the truth, which also mends an end saved wrongly before.
558
+ if (s.started && D.frameBranch === D.activeId && !s.seeking) active.end = s.end
559
+ const shownT = shownTime(s)
560
+ renderHead(s, shownT)
561
+ renderShield(s)
562
+ if (s.started) renderTimeline(s, shownT)
563
+ renderExtras(s)
564
+ }
565
+
566
+ // A frame built behind can take the window's focus (its replay focused a
567
+ // field). If the user was typing in the dock (a note, a timeline's name),
568
+ // they get it back.
569
+ let dockField = null
570
+ document.addEventListener("focusin", (e) => {
571
+ const el = /** @type {HTMLElement | null} */ (e.target)
572
+ if (el && (el.tagName === "TEXTAREA" || el.tagName === "INPUT" || el.isContentEditable)) dockField = el
573
+ })
574
+ function guardFocus() {
575
+ if (!dockField) return
576
+ const a = document.activeElement
577
+ if (a === dockField) return
578
+ if (dockField.isConnected && a && a.tagName === "IFRAME" && (a.classList.contains("building") || a.classList.contains("checkpoint"))) {
579
+ try {
580
+ dockField.focus({ preventScroll: true })
581
+ return
582
+ } catch {}
583
+ }
584
+ dockField = null
585
+ }
586
+
587
+ // A frame that reloads itself leaves shell.__resume for its next document.
588
+ // A frame we removed can leave one too (pagehide runs as it goes), and that
589
+ // object, made in its realm, would keep the whole old window alive.
590
+ function dropStaleResume() {
591
+ const r = window.__retakeShell.__resume
592
+ if (r && (!r.frame || !r.frame.isConnected)) window.__retakeShell.__resume = null
593
+ }
594
+
595
+ // Recording is always on from page load: a runtime that waits for Record gets
596
+ // it once, as soon as it has booted.
597
+ // (A WeakSet, so a runtime we've moved on from can be collected.)
598
+ const autoStarted = new WeakSet()
599
+ function autoStart(s) {
600
+ if (s.started || !s.booted || D.building || autoStarted.has(D.PT)) return
601
+ autoStarted.add(D.PT)
602
+ D.PT.record()
603
+ }
604
+
605
+ // In the past the app is view-only: a clear shield takes its pointer events
606
+ // and keyboard focus stays with the dock. The comment and select tools see
607
+ // through it (they work on the past).
608
+ const shield = $("#wb-shield")
609
+ function renderShield(s) {
610
+ const block = !!s.started && !isInteractive(s) && !mode()
611
+ shield.hidden = !block
612
+ // In the past, keys belong to the dock, so the visible frame shouldn't keep
613
+ // focus. Never touch the frame being built: its replay moves focus around
614
+ // (a replayed click on a textarea) and the keys it replays need that focus.
615
+ if (block && D.frame && document.activeElement === D.frame && !D.building) {
616
+ D.frame.blur()
617
+ window.focus()
618
+ }
619
+ if (!block) {
620
+ if (wasBlocked) restoreFocus()
621
+ else noteFocus(D.frame)
622
+ }
623
+ wasBlocked = block
624
+ }
625
+ // Pausing takes focus off the app's field (clicking the dock alone does);
626
+ // playing again gives it back, with the caret where it was. Remembered while
627
+ // the app is interactive, and only given back to that same document: a
628
+ // rebuilt frame's focus is its replay's business.
629
+ let wasBlocked = false
630
+ let lastFocus = null
631
+ function noteFocus(frame) {
632
+ try {
633
+ const doc = frame && frame.contentDocument
634
+ const el = doc && doc.activeElement
635
+ // Focus on the page itself: the user left the field (keep what we had
636
+ // only if focus went out to the dock instead).
637
+ if (!el || el === doc.body) {
638
+ if (doc.hasFocus()) lastFocus = null
639
+ return
640
+ }
641
+ lastFocus = { frame, doc, el, sel: typeof el.selectionStart === "number" ? [el.selectionStart, el.selectionEnd] : null }
642
+ } catch {}
643
+ }
644
+ function restoreFocus() {
645
+ const f = lastFocus
646
+ lastFocus = null
647
+ try {
648
+ if (!f || f.frame !== D.frame || f.frame.contentDocument !== f.doc || !f.el.isConnected) return
649
+ D.frame.focus()
650
+ f.el.focus({ preventScroll: true })
651
+ if (f.sel && f.el.setSelectionRange) f.el.setSelectionRange(f.sel[0], f.sel[1])
652
+ } catch {}
653
+ }
654
+ // One bad frame must never stop the dock: log it and keep going.
655
+ let renderErrors = 0
656
+ requestAnimationFrame(function loop() {
657
+ try {
658
+ render()
659
+ } catch (err) {
660
+ if (renderErrors++ < 5) console.error("[retake] dock render failed", err)
661
+ }
662
+ requestAnimationFrame(loop)
663
+ })
664
+
665
+ const refocus = () => D.frame && D.frame.contentWindow && D.frame.contentWindow.focus()
666
+
667
+ document.addEventListener("click", (e) => {
668
+ const target = /** @type {Element} */ (e.target)
669
+ const b = /** @type {HTMLButtonElement | null} */ (target.closest("button, [data-branch], [data-note]"))
670
+ if (!b) {
671
+ if (!target.closest(".card")) closeCard()
672
+ return
673
+ }
674
+ const a = b.dataset.a
675
+ if (a === "play") togglePlay()
676
+ if (a === "fresh") return startFresh()
677
+ if (a === "undo") return undoFresh()
678
+ if (a === "live") return followLive()
679
+ if (b.dataset.deleteTimeline) {
680
+ menuEl.hidden = true
681
+ deleteTimeline(Number(b.dataset.deleteTimeline)).then((ok) => ok || flash("Couldn't delete that timeline right now"))
682
+ return
683
+ }
684
+ if (b.dataset.renameTimeline) {
685
+ menuEl.hidden = true
686
+ renameLane(Number(b.dataset.renameTimeline))
687
+ return
688
+ }
689
+ // Tools: Hand (nothing picked, just use the prototype), Select, Comment.
690
+ if (b.dataset.tool && !b.disabled) setPicking(b.dataset.tool === "hand" ? null : b.dataset.tool)
691
+ if (handleNoteClick(b)) return
692
+ if (isInteractive()) refocus()
693
+ })
694
+
695
+ // The dock never grows by itself (that would resize the app mid-recording);
696
+ // only the divider changes it.
697
+ const MIN_H = 96
698
+ // Never so short that the lanes (at their closest) don't fit under the ruler.
699
+ const dockHeight = () => {
700
+ const min = Math.max(MIN_H, 56 + 24 + D.branches.length * 10 + 16)
701
+ return Math.round(clamp(D.height, min, Math.max(min, innerHeight * 0.7)))
702
+ }
703
+
704
+ // Resize by dragging the top edge, like docked DevTools.
705
+ const divider = $(".divider")
706
+ divider.addEventListener("pointerdown", (e) => {
707
+ divider.setPointerCapture(e.pointerId)
708
+ document.body.classList.add("dragging")
709
+ const startY = e.clientY
710
+ const startH = dock.offsetHeight
711
+ const move = (ev) => {
712
+ D.height = Math.round(clamp(startH + startY - ev.clientY, MIN_H, innerHeight * 0.7))
713
+ }
714
+ const up = () => {
715
+ document.body.classList.remove("dragging")
716
+ divider.removeEventListener("pointermove", move)
717
+ divider.removeEventListener("pointerup", up)
718
+ store.set("height", D.height)
719
+ }
720
+ divider.addEventListener("pointermove", move)
721
+ divider.addEventListener("pointerup", up)
722
+ })
723
+
724
+ window.addEventListener("keydown", (e) => {
725
+ if (e.key === "Meta") return window.__retakeShell.meta(true)
726
+ if (e.key === "Escape") {
727
+ setPicking(null)
728
+ closeCard()
729
+ return
730
+ }
731
+ if (e.altKey && e.code === "KeyP") {
732
+ e.preventDefault()
733
+ return togglePlay()
734
+ }
735
+ if (dockKey(e)) e.preventDefault()
736
+ })
737
+ // Keys pressed inside a view-only app are the dock's too.
738
+ function hookFrameKeys(win) {
739
+ try {
740
+ if (!win || win.__retakeKeys) return
741
+ win.__retakeKeys = true
742
+ win.addEventListener(
743
+ "keydown",
744
+ (e) => {
745
+ if (isInteractive() || !dockKey(e)) return
746
+ e.preventDefault()
747
+ e.stopImmediatePropagation()
748
+ },
749
+ true,
750
+ )
751
+ } catch {}
752
+ }
753
+ window.addEventListener("keyup", (e) => e.key === "Meta" && window.__retakeShell.meta(false))
754
+ window.addEventListener("blur", () => window.__retakeShell.meta(false))