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,734 @@
1
+ // The timeline, drawn on one canvas. A ruler on top; under it one lane per
2
+ // timeline, each in its own colour. A timeline made by Control-clicking the
3
+ // track grows out of its parent's lane at the moment it split, along a short
4
+ // curve. The active lane is in full colour (dimmer over its recorded future);
5
+ // the others are the same hue, faint, and show only their line.
6
+ //
7
+ // What's drawn on the active lane, in three layers:
8
+ // 1. your actions: small blue marks (clicks, keys, submits, routes); a burst
9
+ // of typing is one short blue bar.
10
+ // 2. a quiet waveform of how much the screen changed (timeline().activity),
11
+ // just under the lane: steady ambient motion is nearly flat, real changes
12
+ // are bumps.
13
+ // 3. on demand, while ⌘ is held over an element in the app: that element's
14
+ // own animations, as bright capsules ("lens"); everything else dims.
15
+ //
16
+ // The view is a window of time [from, to], never starting before the
17
+ // recording. While playing, it follows: the playhead stays near the right
18
+ // edge and the track scrolls with time. Zooming or scrolling by hand stops
19
+ // that ("Live →" brings it back). Paused, nothing moves by itself.
20
+ // Drawing only happens when something visible changed.
21
+
22
+ const PAD_L = 12
23
+ const PAD_R = 18
24
+ const RULER = 20 // ruler height
25
+ const LANE = 32 // row height
26
+ const MIN_PITCH = 10 // lanes closer than this can't be told apart
27
+ const BEND = 20 // how wide a fork's S-curve is
28
+ const STUB = 24 // a new, empty timeline: a short line after its curve
29
+ const GROW_MS = 250
30
+ const FOLLOW_AT = 0.85 // where the playhead sits while following
31
+ const WAVE_H = 10 // waveform height under the active lane
32
+
33
+ // One colour per timeline, by id: distinct on dark glass, none of them the
34
+ // blue of actions or the amber of notes.
35
+ // (OKLCH L 0.78, C 0.12: the same perceived brightness on dark glass.)
36
+ const TL_COLORS = ["#bda8fc", "#3bcfcf", "#f197c2", "#85cc87", "#efa464", "#fb9797"]
37
+ const colorOf = (b) => TL_COLORS[((b ? b.id : 1) - 1) % TL_COLORS.length]
38
+ const INK = { blue: "#60a5fa", white: "#ffffff", dim: "rgba(255,255,255,0.55)", faint: "rgba(255,255,255,0.24)", hair: "rgba(255,255,255,0.08)", page: "#16161a", lens: "#fde68a" }
39
+ const NOTE_FILL = { pending: "#ffb224", acknowledged: "#60a5fa", resolved: "#4cc38a", dismissed: "rgba(255,255,255,0.4)" }
40
+ const MONO = '10px "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace'
41
+
42
+ const tip = $("#wb-tip")
43
+ const gutter = $(".gutter")
44
+ const liveBtn = $(".live-btn")
45
+ const readoutT = $(".readout .t")
46
+ // Minutes and seconds, then the hundredths dimmer.
47
+ readoutT.innerHTML = '<span class="ms"></span><span class="cs"></span>'
48
+ const [readoutMs, readoutCs] = readoutT.children
49
+ let readoutText = ""
50
+ const readoutPhase = $(".readout .phase")
51
+ const hintEl = $(".hint")
52
+ const playBtn = $('[data-a="play"]')
53
+ const ctx = cv.getContext("2d")
54
+
55
+ // ---- the view ---------------------------------------------------------------------
56
+
57
+ // fit: show everything; followSpan: the window's width while following;
58
+ // detached: the user moved the view while it was following.
59
+ D.view = { from: 0, to: 1000, fit: true, target: null, at: 0, followSpan: 20000, detached: false, wasPlaying: false }
60
+ let sceneKey = ""
61
+ // The active row's band crossfades when the active timeline changes.
62
+ const BAND_MS = 150
63
+ const band = { id: null, prev: null, since: -1e9 }
64
+ let pendingFork = null
65
+ let flashUntil = 0
66
+
67
+ // Everything recorded, on every timeline, from its first moment.
68
+ function fullRange(s) {
69
+ let end = s.end
70
+ for (const b of D.branches) end = Math.max(end, b.id === D.activeId ? Math.max(b.end, s.end) : b.end)
71
+ const len = Math.max(end - s.start, 1000)
72
+ return { from: s.start, to: s.start + len * 1.03 }
73
+ }
74
+
75
+ // While following: the playhead at FOLLOW_AT of the width, or, early on, the
76
+ // recording from its start.
77
+ function followRange(s, now) {
78
+ // Early on the window only grows to fit (the playhead at FOLLOW_AT of it);
79
+ // once it reaches the chosen width, the track scrolls.
80
+ const span = clamp((now - s.start) / FOLLOW_AT, 2000, D.view.followSpan)
81
+ let to = now + span * (1 - FOLLOW_AT)
82
+ let from = to - span
83
+ if (from < s.start) {
84
+ from = s.start
85
+ to = from + span
86
+ }
87
+ return { from, to }
88
+ }
89
+
90
+ // The start of the recording is pinned: the view never begins before it.
91
+ function pinStart(v, s) {
92
+ if (!s || v.from >= s.start) return v
93
+ return { from: s.start, to: s.start + (v.to - v.from) }
94
+ }
95
+
96
+ // The user set the view (zoom, pan, fit a clip): stop following.
97
+ function setView(from, to, { animate = true } = {}) {
98
+ const span = Math.max(to - from, 20)
99
+ const mid = (from + to) / 2
100
+ const r = pinStart({ from: mid - span / 2, to: mid + span / 2 }, D.last)
101
+ D.view.fit = false
102
+ D.view.followSpan = r.to - r.from
103
+ if (D.last && D.last.playing) D.view.detached = true
104
+ if (animate) D.view.target = r
105
+ else {
106
+ D.view.from = r.from
107
+ D.view.to = r.to
108
+ D.view.target = null
109
+ }
110
+ }
111
+ function fitAll() {
112
+ D.view.fit = true
113
+ D.view.detached = false
114
+ if (D.last) D.view.followSpan = Math.max(4000, fullRange(D.last).to - D.last.start)
115
+ }
116
+ function fitRange(a, b) {
117
+ const pad = Math.max((b - a) * 0.15, 10)
118
+ setView(a - pad, b + pad)
119
+ }
120
+ function followLive() {
121
+ D.view.detached = false
122
+ D.view.fit = false
123
+ }
124
+
125
+ // Where the view is going this frame, then eased toward it in ~200ms (a
126
+ // followed view moves with time exactly, so it stays smooth).
127
+ function stepView(s) {
128
+ const now = performance.now()
129
+ const dt = Math.min(64, now - (D.view.at || now))
130
+ D.view.at = now
131
+ const v = D.view
132
+ // Started playing: follow again. Paused: stay exactly where it is.
133
+ if (s.playing && !v.wasPlaying) v.detached = false
134
+ if (!s.playing && v.wasPlaying) {
135
+ v.fit = false
136
+ v.target = null
137
+ }
138
+ v.wasPlaying = !!s.playing
139
+ const following = s.playing && !v.detached && D.dragT == null
140
+ if (following) v.target = followRange(s, s.now)
141
+ else if (v.fit) v.target = fullRange(s)
142
+ const t = v.target
143
+ if (!t) {
144
+ const p = pinStart(v, s)
145
+ v.from = p.from
146
+ v.to = p.to
147
+ return
148
+ }
149
+ const span = v.to - v.from
150
+ const k = following && Math.abs(t.to - t.from - span) < 1 ? 1 : 1 - Math.exp(-dt / 45)
151
+ v.from += (t.from - v.from) * k
152
+ v.to += (t.to - v.to) * k
153
+ if (Math.abs(t.from - v.from) < span / 4000 && Math.abs(t.to - v.to) < span / 4000) {
154
+ v.from = t.from
155
+ v.to = t.to
156
+ if (!v.fit && !following) v.target = null
157
+ }
158
+ }
159
+
160
+ const geom = () => {
161
+ const r = cv.getBoundingClientRect()
162
+ return { w: r.width, h: r.height, left: r.left, top: r.top }
163
+ }
164
+ const innerW = (g) => Math.max(1, g.w - PAD_L - PAD_R)
165
+ const pxPerMs = (g) => innerW(g) / (D.view.to - D.view.from)
166
+ const xOf = (t, g) => clamp(PAD_L + (t - D.view.from) * pxPerMs(g), -4000, g.w + 4000)
167
+ const timeAt = (clientX, g = geom()) => D.view.from + (clientX - g.left - PAD_L) / pxPerMs(g)
168
+ const r1 = (n) => Math.round(n * 10) / 10
169
+
170
+ // ---- lanes ------------------------------------------------------------------------
171
+
172
+ // Where each lane sits: rows from right under the ruler, any spare height
173
+ // below them. When they don't fit at full height, they close up evenly
174
+ // ("compact": names become dots).
175
+ // Rows in tree order, like a git graph: each timeline directly under its
176
+ // parent's row group. Siblings that split off later sit nearer the parent, so
177
+ // at the moment an earlier sibling splits, every row it drops past is still
178
+ // empty (those timelines begin later): no fork ever crosses a lane.
179
+ function treeOrder() {
180
+ const kids = new Map()
181
+ for (const b of D.branches) {
182
+ const k = kids.get(b.parentId) || []
183
+ k.push(b)
184
+ kids.set(b.parentId, k)
185
+ }
186
+ const out = []
187
+ const visit = (b) => {
188
+ out.push(b)
189
+ const k = (kids.get(b.id) || []).slice().sort((x, y) => y.forkAt - x.forkAt || x.id - y.id)
190
+ k.forEach(visit)
191
+ }
192
+ const roots = D.branches.filter((b) => !b.parentId || !branchById(b.parentId))
193
+ roots.forEach(visit)
194
+ return out
195
+ }
196
+
197
+ function layoutLanes(g) {
198
+ const avail = g.h - RULER - 4
199
+ const order = treeOrder()
200
+ const n = order.length
201
+ const compact = n * LANE > avail
202
+ const pitch = compact ? Math.max(MIN_PITCH, Math.floor(avail / n)) : LANE
203
+ D.compact = compact
204
+ D.rowPitch = pitch
205
+ const lanes = new Map()
206
+ let y = RULER + 4
207
+ order.forEach((b, row) => {
208
+ lanes.set(b.id, { y: Math.round(y + pitch / 2) + 0.5, thin: compact, row })
209
+ y += pitch
210
+ })
211
+ D.rowOrder = order.map((b) => b.id)
212
+ return lanes
213
+ }
214
+
215
+ const growth = (b) => {
216
+ const p = clamp((performance.now() - b.born) / GROW_MS, 0, 1)
217
+ return 1 - Math.pow(1 - p, 3)
218
+ }
219
+
220
+ // A lane: straight from its start, or out of its parent's lane at the exact
221
+ // moment it split, along a short curve, then along its own row.
222
+ // A lane: straight from its start; or, for a timeline that split off, down
223
+ // from its parent's row at the moment it split (a straight drop past rows
224
+ // that are empty there) and one smooth S-curve into its own row. A timeline
225
+ // with nothing recorded yet ends in a short stub.
226
+ function laneShape(b, g, lanes, s, until) {
227
+ const L = lanes.get(b.id)
228
+ const parent = branchById(b.parentId)
229
+ const empty = until - (b.parentId ? b.forkAt : s.start) < 1
230
+ if (!parent || !lanes.get(parent.id)) {
231
+ const x0 = xOf(b.parentId ? b.forkAt : s.start, g)
232
+ const x1 = empty ? x0 + STUB : Math.max(xOf(until, g), x0 + 0.5)
233
+ return { x0, py: L.y, dy: L.y, xb: x0, y: L.y, x1, empty }
234
+ }
235
+ const x0 = xOf(b.forkAt, g)
236
+ const py = lanes.get(parent.id).y
237
+ const dy = L.y - D.rowPitch // the row just above: the curve drops one step
238
+ const xb = x0 + BEND
239
+ const x1 = empty ? xb + STUB : Math.max(xOf(until, g), xb)
240
+ return { x0, py, dy: Math.max(py, dy), xb, y: L.y, x1, empty }
241
+ }
242
+ function tracePath(p) {
243
+ ctx.beginPath()
244
+ ctx.moveTo(p.x0, p.py)
245
+ if (p.xb !== p.x0) {
246
+ if (p.dy > p.py) ctx.lineTo(p.x0, p.dy)
247
+ ctx.bezierCurveTo(p.x0 + BEND * 0.5, p.dy, p.xb - BEND * 0.5, p.y, p.xb, p.y)
248
+ }
249
+ ctx.lineTo(p.x1, p.y)
250
+ }
251
+ // The end of a lane: a small dot where its recording ends; a hollow circle
252
+ // on a new, empty one ("ready").
253
+ function laneEnd(p, color, alpha) {
254
+ ctx.save()
255
+ ctx.globalAlpha = alpha
256
+ ctx.beginPath()
257
+ ctx.arc(p.x1, p.y, p.empty ? 3.5 : 2.5, 0, Math.PI * 2)
258
+ if (p.empty) {
259
+ ctx.fillStyle = INK.page
260
+ ctx.fill()
261
+ ctx.strokeStyle = color
262
+ ctx.lineWidth = 1.5
263
+ ctx.stroke()
264
+ } else {
265
+ ctx.fillStyle = color
266
+ ctx.fill()
267
+ }
268
+ ctx.restore()
269
+ }
270
+ // Drawn partway while it grows (the first 250ms of a new timeline).
271
+ function strokeLane(p, color, alpha, width, grow = 1) {
272
+ ctx.save()
273
+ ctx.globalAlpha = alpha
274
+ ctx.strokeStyle = color
275
+ ctx.lineWidth = width
276
+ ctx.lineCap = "round"
277
+ if (grow < 0.999) {
278
+ const len = Math.abs(p.dy - p.py) + BEND + Math.abs(p.y - p.dy) + (p.x1 - p.xb)
279
+ ctx.setLineDash([len * grow, len * 2])
280
+ }
281
+ tracePath(p)
282
+ ctx.stroke()
283
+ ctx.restore()
284
+ }
285
+
286
+ // ---- the ruler --------------------------------------------------------------------
287
+
288
+ const STEPS = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 15000, 30000, 60000, 120000, 300000, 600000]
289
+
290
+ function drawRuler(g, s, xNow) {
291
+ const ppm = pxPerMs(g)
292
+ const major = STEPS.find((st) => st * ppm >= 72) || STEPS[STEPS.length - 1]
293
+ const minor = STEPS.slice().reverse().find((st) => st < major && major % st === 0 && st * ppm >= 7) || major
294
+ const dec = major >= 1000 ? 0 : major >= 100 ? 1 : major >= 10 ? 2 : 3
295
+ const label = (t) => {
296
+ const ms = t - s.start
297
+ if (major >= 60000) return `${Math.floor(ms / 60000)}:${String(Math.round((ms % 60000) / 1000)).padStart(2, "0")}`
298
+ return (ms / 1000).toFixed(dec) + "s"
299
+ }
300
+ ctx.fillStyle = INK.hair
301
+ ctx.fillRect(0, RULER - 1, g.w, 1)
302
+ ctx.font = MONO
303
+ ctx.textBaseline = "alphabetic"
304
+ const labels = []
305
+ const first = Math.ceil((D.view.from - s.start) / minor) * minor + s.start
306
+ for (let t = first, n = 0; t <= D.view.to && n < 800; t += minor, n++) {
307
+ const rel = Math.round(t - s.start)
308
+ if (rel < 0) continue
309
+ const x = Math.round(xOf(t, g)) + 0.5
310
+ const isMajor = rel % major === 0
311
+ ctx.fillStyle = isMajor ? INK.dim : INK.faint
312
+ ctx.fillRect(x - 0.5, RULER - (isMajor ? 6 : 3), 1, isMajor ? 5 : 2)
313
+ // A label the playhead's cap would sit on is left out.
314
+ if (isMajor && !(x + 3 < xNow + 8 && x + 36 > xNow - 8)) {
315
+ ctx.fillStyle = INK.dim
316
+ ctx.fillText(label(t), x + 3, RULER - 8)
317
+ labels.push(label(t))
318
+ }
319
+ }
320
+ return labels
321
+ }
322
+
323
+ // ---- layers on the active lane ---------------------------------------------------
324
+
325
+ // Things you did: blue. The app's own doings (fetches, reloads) aren't shown.
326
+ const ACTION_KINDS = new Set(["click", "key", "input", "submit", "route"])
327
+
328
+ // First marker at or after t (markers are in time order).
329
+ function firstFrom(list, t) {
330
+ let lo = 0
331
+ let hi = list.length
332
+ while (lo < hi) {
333
+ const mid = (lo + hi) >> 1
334
+ if (list[mid].t < t) lo = mid + 1
335
+ else hi = mid
336
+ }
337
+ return lo
338
+ }
339
+
340
+ function drawActions(g, markers, y, lo, hi, dim) {
341
+ const out = []
342
+ const from = D.view.from - 2000 // a typing bar can start before the view
343
+ ctx.save()
344
+ ctx.globalAlpha = dim ? 0.35 : 1
345
+ ctx.fillStyle = INK.blue
346
+ for (let i = firstFrom(markers, from); i < markers.length; i++) {
347
+ const m = markers[i]
348
+ if (m.t > D.view.to) break
349
+ if (!ACTION_KINDS.has(m.kind) || m.t < lo - 1 || m.t > hi + 1) continue
350
+ const x = xOf(m.t, g)
351
+ if (m.kind === "input" && m.end != null && m.end > m.t) {
352
+ // A burst of typing: one short bar.
353
+ const x1 = Math.max(x + 3, xOf(m.end, g))
354
+ if (x1 < 0 || x > g.w) continue
355
+ roundRect(x, y - 1.5, x1 - x, 3, 1.5)
356
+ ctx.fill()
357
+ out.push({ i, kind: "typing", x, x1 })
358
+ } else {
359
+ if (x < -4 || x > g.w + 4) continue
360
+ ctx.beginPath()
361
+ ctx.arc(x, y, 2.5, 0, Math.PI * 2)
362
+ ctx.fill()
363
+ out.push({ i, kind: m.kind, x })
364
+ }
365
+ }
366
+ ctx.restore()
367
+ return out
368
+ }
369
+
370
+ // timeline().activity: { step, values[, t0] } (0..1 per step of `step` ms
371
+ // from t0, or from the recording's start). Anything else: nothing drawn.
372
+ function activityOf(tl, s) {
373
+ const a = tl.activity
374
+ if (!a || !Array.isArray(a.values) || !(a.step > 0)) return null
375
+ return { step: a.step, values: a.values, t0: a.t0 != null ? a.t0 : s.start }
376
+ }
377
+
378
+ // A filled waveform under the lane, the max of each pixel's slice of time.
379
+ function drawWave(g, act, y, color, lo, hi, dim) {
380
+ const xa = Math.max(0, Math.floor(xOf(Math.max(lo, act.t0), g)))
381
+ const xb = Math.min(g.w, Math.ceil(xOf(Math.min(hi, act.t0 + act.values.length * act.step), g)))
382
+ if (xb <= xa) return 0
383
+ const top = y + 3
384
+ ctx.save()
385
+ ctx.globalAlpha = dim ? 0.12 : 0.3
386
+ ctx.fillStyle = color
387
+ ctx.beginPath()
388
+ ctx.moveTo(xa, top)
389
+ let cols = 0
390
+ for (let x = xa; x <= xb; x++) {
391
+ const i0 = Math.max(0, Math.floor((timeAt(x + geom().left) - act.t0) / act.step))
392
+ const i1 = Math.min(act.values.length - 1, Math.floor((timeAt(x + 1 + geom().left) - act.t0) / act.step))
393
+ let v = 0
394
+ for (let i = i0; i <= i1; i++) v = Math.max(v, act.values[i] || 0)
395
+ ctx.lineTo(x, top + clamp(v, 0, 1) * WAVE_H)
396
+ cols++
397
+ }
398
+ ctx.lineTo(xb, top)
399
+ ctx.closePath()
400
+ ctx.fill()
401
+ ctx.restore()
402
+ return cols
403
+ }
404
+
405
+ function roundRect(x, y, w, h, r) {
406
+ ctx.beginPath()
407
+ ctx.roundRect ? ctx.roundRect(x, y, w, h, r) : ctx.rect(x, y, w, h)
408
+ }
409
+
410
+ // The lens: the hovered element's own animations, as bright capsules.
411
+ function drawLens(g, clips, y) {
412
+ const out = []
413
+ ctx.save()
414
+ ctx.font = '500 10px Geist, ui-sans-serif, system-ui, sans-serif'
415
+ ctx.textBaseline = "middle"
416
+ for (const c of clips) {
417
+ const end = c.end == null ? (D.last ? D.last.end : c.start) : c.end
418
+ if (end < D.view.from || c.start > D.view.to) continue
419
+ const x0 = xOf(c.start, g)
420
+ const x1 = Math.max(x0 + 6, xOf(end, g))
421
+ ctx.fillStyle = INK.lens
422
+ roundRect(x0, y - 7, x1 - x0, 14, 7)
423
+ ctx.fill()
424
+ const label = `${clipName(c)} · ${msWord(end - c.start)}`
425
+ if (ctx.measureText(label).width + 12 <= x1 - x0) {
426
+ ctx.fillStyle = INK.page
427
+ ctx.fillText(label, x0 + 6, y + 0.5)
428
+ }
429
+ out.push({ clip: c, x0, x1, y })
430
+ }
431
+ ctx.restore()
432
+ return out
433
+ }
434
+
435
+ // ---- drawing ------------------------------------------------------------------------
436
+
437
+ function sizeCanvas(g) {
438
+ const dpr = window.devicePixelRatio || 1
439
+ const W = Math.round(g.w * dpr)
440
+ const H = Math.round(g.h * dpr)
441
+ if (cv.width !== W || cv.height !== H) {
442
+ cv.width = W
443
+ cv.height = H
444
+ sceneKey = ""
445
+ }
446
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
447
+ }
448
+
449
+ function renderTimeline(s, shownT) {
450
+ const g = geom()
451
+ if (!g.w || !g.h) return
452
+ stepView(s)
453
+ const active = activeBranch()
454
+ const activeEnd = Math.max(active.end, s.end)
455
+ const tl = timeline()
456
+ const lanes = layoutLanes(g)
457
+ D.lanes = lanes
458
+ renderGutter(lanes)
459
+ const following = s.playing && !D.view.detached
460
+ liveBtn.hidden = !(s.playing && !s.future && D.view.detached)
461
+ checkPendingFork(s)
462
+
463
+ if (band.id !== D.activeId) {
464
+ band.prev = band.id
465
+ band.id = D.activeId
466
+ band.since = performance.now()
467
+ }
468
+ const bandFading = performance.now() - band.since < BAND_MS + 20
469
+ const lensClips = D.lens ? D.lens.clips : null
470
+ const growing = D.branches.some((b) => performance.now() - b.born < GROW_MS + 20)
471
+ const act = activityOf(tl, s)
472
+ // Only draw when something that shows has changed.
473
+ const key = [
474
+ g.w, g.h, D.view.from.toFixed(2), D.view.to.toFixed(2), shownT.toFixed(2), D.activeId, activeEnd.toFixed(1),
475
+ D.branches.map((b) => `${b.id}.${b.end.toFixed(0)}.${b.forkAt}`).join(","),
476
+ tl.markers.length, act ? act.values.length : -1, D.notes.map((n) => `${n.id}.${n.status}.${n.branchId}`).join(","),
477
+ D.markers.length, lensClips ? D.lens.key : "", D.branchT, D.snapT, D.dragT, growing || bandFading ? performance.now() : 0, D.hoverRow,
478
+ ].join("|")
479
+ if (key === sceneKey) return
480
+ sceneKey = key
481
+ const t0 = performance.now()
482
+ sizeCanvas(g)
483
+ ctx.clearRect(0, 0, g.w, g.h)
484
+ const xNow = Math.round(xOf(shownT, g))
485
+ const labels = drawRuler(g, s, xNow)
486
+ const dim = !!lensClips
487
+ const scene = { lanes: [], actions: [], waveCols: 0, capsules: [], notes: [], bookmarks: [], labels, following }
488
+
489
+ // Row bands: a very faint one behind the active row (crossfading in 150ms
490
+ // when it changes) and the hovered row.
491
+ const pitch = D.rowPitch
492
+ const fade = clamp((performance.now() - band.since) / BAND_MS, 0, 1)
493
+ const rowBand = (id, alpha) => {
494
+ const L = lanes.get(id)
495
+ if (!L || alpha <= 0) return
496
+ ctx.fillStyle = `rgba(255,255,255,${alpha.toFixed(4)})`
497
+ ctx.fillRect(0, L.y - pitch / 2, g.w, pitch)
498
+ }
499
+ if (D.hoverRow != null && D.hoverRow !== D.activeId) rowBand(D.hoverRow, 0.02)
500
+ if (band.prev != null && band.prev !== D.activeId) rowBand(band.prev, 0.03 * (1 - fade))
501
+ rowBand(D.activeId, 0.03 * fade)
502
+
503
+ // Lanes: the others at 35% of their colour; then the active one, full up to
504
+ // the playhead and 40% over its recorded future.
505
+ for (const b of D.branches) {
506
+ if (b.id === D.activeId) continue
507
+ const p = laneShape(b, g, lanes, s, b.end)
508
+ const a = dim ? 0.15 : 0.35
509
+ const gr = growth(b)
510
+ strokeLane(p, colorOf(b), a, D.compact ? 1.5 : 2, gr)
511
+ if (gr >= 0.999) laneEnd(p, colorOf(b), a)
512
+ scene.lanes.push({ id: b.id, y: p.y, x0: p.x0, x1: p.x1, color: colorOf(b), active: false, empty: p.empty })
513
+ }
514
+ const color = colorOf(active)
515
+ const A = lanes.get(active.id)
516
+ const p = laneShape(active, g, lanes, s, activeEnd)
517
+ const lo = active.forkAt
518
+ if (act) scene.waveCols = drawWave(g, act, A.y, color, lo, activeEnd, dim)
519
+ const gr = growth(active)
520
+ strokeLane(p, color, dim ? 0.2 : 0.4, 2, gr)
521
+ ctx.save()
522
+ ctx.beginPath()
523
+ ctx.rect(-10, 0, xNow + 10, g.h)
524
+ ctx.clip()
525
+ strokeLane(p, color, dim ? 0.45 : 1, 2, gr)
526
+ ctx.restore()
527
+ if (gr >= 0.999) laneEnd(p, color, p.empty || p.x1 <= xNow + 1 ? 1 : 0.4)
528
+ scene.lanes.push({ id: active.id, y: p.y, x0: p.x0, x1: p.x1, color, active: true, empty: p.empty })
529
+ scene.actions = drawActions(g, tl.markers, A.y, lo, activeEnd, dim)
530
+ if (lensClips) scene.capsules = drawLens(g, lensClips, A.y)
531
+
532
+ // Bookmarks, on the ruler.
533
+ ctx.fillStyle = "#f2f2f5"
534
+ for (const m of D.markers) {
535
+ if (m.branchId !== D.activeId || m.t < D.view.from || m.t > D.view.to) continue
536
+ const x = xOf(m.t, g)
537
+ ctx.beginPath()
538
+ ctx.moveTo(x - 3, RULER - 1)
539
+ ctx.lineTo(x, RULER - 5)
540
+ ctx.lineTo(x + 3, RULER - 1)
541
+ ctx.fill()
542
+ scene.bookmarks.push({ id: m.id, x })
543
+ }
544
+
545
+ // Control held over the track: where a click would branch the active
546
+ // timeline, in its colour, with the time.
547
+ if (D.branchT != null && D.dragT == null && pendingFork == null) {
548
+ const xg = Math.round(xOf(D.branchT, g)) + 0.5
549
+ ctx.fillStyle = color
550
+ ctx.fillRect(xg - 0.5, RULER, 1, g.h - RULER)
551
+ const lx = clamp(xg + 6, 2, g.w - 58)
552
+ roundRect(lx, RULER + 3, 54, 14, 7)
553
+ ctx.fill()
554
+ ctx.fillStyle = INK.page
555
+ ctx.font = '500 9px "Geist Mono", ui-monospace, Menlo, monospace'
556
+ ctx.textAlign = "center"
557
+ ctx.fillText(fmt(D.branchT - s.start), lx + 27, RULER + 13)
558
+ ctx.textAlign = "start"
559
+ scene.guide = { x: xg, t: D.branchT }
560
+ }
561
+
562
+ // Playhead.
563
+ ctx.fillStyle = INK.white
564
+ ctx.fillRect(xNow, 0, 1, g.h)
565
+ ctx.beginPath()
566
+ ctx.moveTo(xNow - 4, 0)
567
+ ctx.lineTo(xNow + 5, 0)
568
+ ctx.lineTo(xNow + 5, 4)
569
+ ctx.lineTo(xNow + 0.5, 8)
570
+ ctx.lineTo(xNow - 4, 4)
571
+ ctx.fill()
572
+ scene.playhead = xNow
573
+ if (D.snapT != null && D.dragT != null) {
574
+ const xs = Math.round(xOf(D.snapT, g)) + 0.5
575
+ ctx.save()
576
+ ctx.strokeStyle = INK.white
577
+ ctx.setLineDash([1, 2])
578
+ ctx.beginPath()
579
+ ctx.moveTo(xs, RULER)
580
+ ctx.lineTo(xs, g.h)
581
+ ctx.stroke()
582
+ ctx.restore()
583
+ }
584
+
585
+ // Notes, last, so they stay on top. A note made before its timeline split
586
+ // off sits on the parent's lane, where that moment is drawn.
587
+ D.notes.forEach((n, i) => {
588
+ let b = branchById(n.branchId)
589
+ while (b && b.parentId && n.t < b.forkAt - 30) b = branchById(b.parentId)
590
+ const L = b && lanes.get(b.id)
591
+ if (!L || n.t < D.view.from || n.t > D.view.to) return
592
+ const x = xOf(n.t, g)
593
+ ctx.fillStyle = NOTE_FILL[n.status] || NOTE_FILL.pending
594
+ if (L.thin) {
595
+ ctx.beginPath()
596
+ ctx.arc(x, L.y, 3, 0, Math.PI * 2)
597
+ ctx.fill()
598
+ scene.notes.push({ id: n.id, x, y: L.y, r: 5 })
599
+ return
600
+ }
601
+ const label = String(i + 1)
602
+ const w = 8 + label.length * 5
603
+ roundRect(x - w / 2, L.y - 17, w, 12, 6)
604
+ ctx.fill()
605
+ ctx.fillStyle = INK.page
606
+ ctx.font = '600 8px "Geist Mono", ui-monospace, Menlo, monospace'
607
+ ctx.textAlign = "center"
608
+ ctx.fillText(label, x, L.y - 8)
609
+ ctx.textAlign = "start"
610
+ scene.notes.push({ id: n.id, x, y: L.y - 11, r: 7 })
611
+ })
612
+ scene.drawMs = performance.now() - t0
613
+ D.scene = scene
614
+ D.draws = (D.draws || 0) + 1
615
+ }
616
+
617
+ // What's under a point on the track: a note, a lens capsule, a bookmark, or
618
+ // a lane (by id).
619
+ function hitAt(clientX, clientY) {
620
+ const g = geom()
621
+ const x = clientX - g.left
622
+ const y = clientY - g.top
623
+ const sc = D.scene
624
+ if (!sc) return null
625
+ for (const n of sc.notes) if (Math.abs(x - n.x) <= n.r && Math.abs(y - n.y) <= n.r) return { kind: "note", id: n.id }
626
+ for (const c of sc.capsules) if (x >= c.x0 - 2 && x <= c.x1 + 2 && Math.abs(y - c.y) <= 8) return { kind: "capsule", clip: c.clip }
627
+ if (y < RULER) for (const b of sc.bookmarks) if (Math.abs(x - b.x) <= 5) return { kind: "bookmark", id: b.id }
628
+ let best = null
629
+ for (const l of sc.lanes) {
630
+ const d = Math.abs(y - l.y)
631
+ if (d <= 8 && x >= l.x0 - 4 && x <= l.x1 + 4 && (!best || d < best.d)) best = { kind: "lane", id: l.id, active: l.active, d }
632
+ }
633
+ return best
634
+ }
635
+
636
+ // Lane names, in the gutter beside their lanes, each with its colour.
637
+ let gutterKey = ""
638
+ const invalidateGutter = () => (gutterKey = "")
639
+ function renderGutter(lanes) {
640
+ if (gutter.querySelector("input")) return // renaming
641
+ const key = D.compact + ":" + D.rowPitch + D.branches.map((b) => `${b.id}:${b.name}:${lanes.get(b.id).y}:${b.id === D.activeId}`).join("|")
642
+ if (key === gutterKey) return
643
+ gutterKey = key
644
+ // Short dock: just the colour dots (the name shows on hover).
645
+ gutter.classList.toggle("dots", !!D.compact)
646
+ gutter.innerHTML = D.branches
647
+ .map((b) => {
648
+ const L = lanes.get(b.id)
649
+ const on = b.id === D.activeId
650
+ const cls = `lane-name${on ? " active" : ""}`
651
+ const title = D.compact ? ` title="${esc(b.name)}"` : ""
652
+ return `<div class="${cls}" data-lane="${b.id}" style="top:${L.y}px;height:${D.rowPitch}px;margin-top:${-D.rowPitch / 2}px;--c:${colorOf(b)}"${title}><i></i><span>${esc(b.name)}</span></div>`
653
+ })
654
+ .join("")
655
+ }
656
+
657
+ // ---- the header ---------------------------------------------------------------------
658
+
659
+ // A moment being built behind only shows here when the user is waiting for
660
+ // it (Play or + before it's in, a moment the preview can't show, a timeline
661
+ // switch, an API seek); otherwise the preview is the moment, and it's Paused.
662
+ function phaseOf(s) {
663
+ if (D.dragT != null || D.keyT != null || (s.previewing && D.scopeEl)) return "Scrubbing"
664
+ if (waits(D.building)) return `Building${buildProgress()}`
665
+ if (s.seeking) return "Loading"
666
+ if (s.playing) return s.future ? "Playing" : "Live"
667
+ return "Paused"
668
+ }
669
+
670
+ // How far the frame being built behind the visible one has got: " 43%",
671
+ // counted from where this build's replay started.
672
+ function buildProgress() {
673
+ try {
674
+ const b = D.building.pt && D.building.pt.state()
675
+ if (!b || b.target == null || !b.seeking) return ""
676
+ const from = b.from != null ? b.from : b.start
677
+ const pct = clamp((b.now - from) / Math.max(1, b.target - from), 0, 0.99)
678
+ return ` ${Math.floor(pct * 100)}%`
679
+ } catch {
680
+ return ""
681
+ }
682
+ }
683
+
684
+ function renderHead(s, shownT) {
685
+ const text = fmt(shownT - s.start)
686
+ if (text !== readoutText) {
687
+ readoutText = text
688
+ const dot = text.lastIndexOf(".")
689
+ readoutMs.textContent = dot < 0 ? text : text.slice(0, dot)
690
+ readoutCs.textContent = dot < 0 ? "" : text.slice(dot)
691
+ }
692
+ const phase = phaseOf(s)
693
+ if (readoutPhase.textContent !== phase) readoutPhase.textContent = phase
694
+ readoutPhase.dataset.phase = phase.split(" ")[0].toLowerCase()
695
+ // Waiting to play a moment being built reads as playing (space again: don't).
696
+ const playing = (!!s.playing || !!(D.building && D.building.play)) && D.dragT == null
697
+ playBtn.classList.toggle("playing", playing)
698
+ playBtn.setAttribute("aria-label", playing ? "Pause" : "Play")
699
+ // The hint area is only for a short warning (see flash()).
700
+ if (performance.now() < flashUntil) return
701
+ if (hintEl.textContent) hintEl.textContent = ""
702
+ hintEl.classList.remove("show", "warn")
703
+ }
704
+
705
+ // A short message in the hint's place (a refused checkout, say).
706
+ function flash(msg) {
707
+ flashUntil = performance.now() + 3500
708
+ hintEl.textContent = msg
709
+ hintEl.classList.add("show", "warn")
710
+ }
711
+
712
+ // ---- a new timeline from a chosen moment -------------------------------------------
713
+
714
+ // Right here if the frame on show is at that moment; else it goes there first
715
+ // and the fork happens once the moment is in (goTo).
716
+ function newTimelineAt(t) {
717
+ const s = state()
718
+ if (!D.PT || !s || !s.started) return
719
+ goTo(t, { fork: true })
720
+ }
721
+
722
+ // The new timeline is made and selected, paused at the moment it split off.
723
+ // Nothing plays until you press play.
724
+ function forkNow() {
725
+ D.PT.pause()
726
+ D.PT.forkHere()
727
+ }
728
+
729
+ function checkPendingFork(s) {
730
+ if (pendingFork == null || D.building || !D.PT || s.seeking || s.previewing) return
731
+ if (Math.abs(s.now - pendingFork) > 40) return
732
+ pendingFork = null
733
+ forkNow()
734
+ }