retake-dev 0.4.0 → 0.5.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/CHANGELOG.md ADDED
@@ -0,0 +1,50 @@
1
+ # Changelog
2
+
3
+ ## 0.5.0
4
+
5
+ - The dock is open on a first visit, and folds away: drag its divider down (or
6
+ press ⌥T) and the timeline becomes a round button, bottom-right at first. The
7
+ dock follows the pointer all the way down; let go near the bottom and it eases
8
+ into the button, let go higher and it springs back to its minimum height. The
9
+ app gets the whole window and recording carries on. The button (Enter/Space
10
+ too) brings the dock back at its default height. Remembered across reloads.
11
+ - Drag the button anywhere (mouse or finger): it eases to the nearer side edge,
12
+ stays on screen through a resize, and keeps its spot across reloads. A press
13
+ that barely moves is still a click.
14
+ - A new mark on the button (a playhead on a track with an arc back to it), with
15
+ a dot for the phase: green live, blue playing, amber paused. `markSvg()`
16
+ (exported from `retake-dev`) returns it as an SVG string.
17
+ - Timelines are named "Timeline 1", "Timeline 2"... (they were "Main", "Take 2"...).
18
+ Sessions saved with the old default names show the new ones (in the dock and
19
+ through `retake mcp`); names you gave them stay. A new timeline says so:
20
+ "Timeline 2 started".
21
+ - Note pins show on the app only while paused; live or playing, only the count shows.
22
+ - Phones and touch screens: the dock's top row fits at 360px (Start fresh shrinks
23
+ to its icon), its controls are 40px, and the divider takes a finger drag (to
24
+ resize or fold). Before, a touch drag on it left the app ignoring taps.
25
+ - ⌥T typed in a note or a timeline's name no longer folds the dock.
26
+ - The app can leave room for the dock: in its frame, `window.__retakeDockHeight`
27
+ is the height the dock covers (0 when folded), and a `retake:dock` event says
28
+ when it changes.
29
+ - `shellHtml({ server: false })` for a dock with no Retake server behind it: it
30
+ never requests `/__retake/` (no session 404 in the console).
31
+ - Leaving the page while recording (a new address typed in) no longer loses the
32
+ seconds since the last save: they're kept in the tab's sessionStorage and the
33
+ timeline carries on from them.
34
+
35
+ ## 0.4.0
36
+
37
+ First release.
38
+
39
+ - A timeline docked over your dev server's app, recording from page load: inputs,
40
+ a virtual clock (timers, rAF, `Date`, CSS and Web Animations), seeded randomness,
41
+ and server traffic (`fetch`, XHR, `EventSource`, `WebSocket`).
42
+ - Drag back and the app is at that moment: a live preview at once, the real
43
+ moment rebuilt behind it and swapped in.
44
+ - Ctrl-click (or +) to start a new take from any moment; the old one stays a lane.
45
+ - Notes on elements at a moment, with component, source file:line and CSS;
46
+ "Copy for agent", and an MCP server (`retake mcp`) for coding agents.
47
+ - Vite plugin (`retake()`), and a front server for Next.js, React Router, Remix,
48
+ Astro, SvelteKit, Nuxt or any dev server that serves HTML (`retake .`,
49
+ `retake -- <cmd>`, `retake http://…`).
50
+ - `--code-branches`: each timeline keeps its own version of the code (Vite apps).
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Retake
2
2
 
3
3
  A time machine for your dev server: Vite apps, Next.js, React Router and other
4
- frameworks. A timeline docks over the bottom of your app and records everything from page load. Drag it back and the app is at that
4
+ frameworks. A timeline docks over the bottom of your app and records from page load. Drag it back and the app is at that
5
5
  moment. Ctrl-click the timeline to start a new take from there; the old one
6
6
  stays as a lane you can click back into. Dev only: nothing ships in builds.
7
7
 
@@ -81,6 +81,17 @@ Acknowledging and resolving show up on the note in the dock right away.
81
81
  the element, its React component, source file:line and CSS; or let your
82
82
  agent pick it up over MCP.
83
83
 
84
+ ### The dock
85
+ - **Keys**: space or ⌥P plays and pauses, ←/→ step, F fits everything, + starts
86
+ a new timeline at the playhead, M drops a bookmark, ⌥T folds the dock away.
87
+ - **Resize** by dragging the divider at its top. Drag it all the way down and
88
+ the timeline folds into a round button (bottom-right at first): the whole
89
+ window is the app's, and recording carries on. Drag the button anywhere; it
90
+ keeps to the nearer side edge. Click it (or ⌥T) to bring the dock back.
91
+ Folded or not, and where the button sits, are remembered across reloads.
92
+ - **Notes show when paused.** While the app is live or playing, note pins stay
93
+ off it (the count on the notes icon stays). Pause and the notes come back.
94
+
84
95
  ### Uninstall
85
96
  ```sh
86
97
  npm uninstall retake-dev # or pnpm remove / yarn remove / bun remove
@@ -136,9 +147,9 @@ the package manager its lockfile names):
136
147
 
137
148
  Tested means: the app hydrates in the dock with no warning, and recording, scrubbing back, the rebuilt moment, Play, hot
138
149
  updates and reloads all work, started either way (`retake .` or `retake http://localhost:…`). The untested ones go
139
- through the same front server and should work; say so if one doesn't.
150
+ through the same front server and should work; open an issue if one doesn't.
140
151
 
141
- - **Which port?** Retake sets `PORT` to a free port for the dev command (or keeps
152
+ - **Ports.** Retake sets `PORT` to a free port for the dev command (or keeps
142
153
  yours), and otherwise uses the first `http://localhost:…` the command prints, so
143
154
  tools that ignore `PORT` work too. Ctrl-C stops the dev server with it.
144
155
  - **The plugin in a Vite-based framework.** With `retake()` in `vite.config` and
@@ -160,9 +171,10 @@ through the same front server and should work; say so if one doesn't.
160
171
  is left out of the recording, and a rebuilt moment gets the page's HTML as it
161
172
  was recorded (kept in `.retake/docs/`), not rendered again. Native `import()`
162
173
  (Vite's lazy routes, Astro islands) can't be held to its moment.
163
- - **Bottom of the app hidden by the dock?** The dock floats over the bottom of
174
+ - **The dock covers the bottom of the app.** It floats over the bottom of
164
175
  the app (a cookie banner's buttons, Next's dev badge). Drag the dock's divider
165
- down, or open the app with `?retake=0`.
176
+ down (all the way down folds it into a button in the corner, as does ⌥T), or
177
+ open the app with `?retake=0`.
166
178
  - **Not rewound.** Retake rewinds the browser, not your server: database writes,
167
179
  server sessions and server-action side effects stay as they are (replays answer
168
180
  from the recording). Service workers are off while Retake is in front, and
@@ -173,6 +185,23 @@ you're on takes the new code; the others keep theirs. Stepping into a timeline
173
185
  checks its code out on disk (snapshots are kept in `.retake/`, and the newest
174
186
  code is put back when the server stops), but use it on prototypes, not shared repos.
175
187
 
188
+ ## The dock without a Retake server
189
+
190
+ `shellHtml(options)` (exported from `retake-dev`) returns the dock page, for a
191
+ host that serves it itself, such as a deployed demo. With no Retake server
192
+ behind it, pass `server: false`: the dock then never requests `/__retake/` (no
193
+ session fetch, so no 404 in the console) and keeps timelines and notes in memory
194
+ for the page.
195
+
196
+ ```js
197
+ import { shellHtml } from "retake-dev"
198
+ const html = shellHtml({ marker: "header", server: false })
199
+ ```
200
+
201
+ `markSvg()` returns Retake's mark (the folded button's icon) as an SVG string:
202
+ a 24×24 viewBox, the arc and the played part in `currentColor`, the rest white,
203
+ for a dark background.
204
+
176
205
  ## How going back works
177
206
 
178
207
  It doesn't snapshot the DOM. It records every input (pointer, keys, typing,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "retake-dev",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Retake: a time machine for your dev server (Vite, Next.js, React Router, Remix, Astro, SvelteKit, Nuxt). Drag back on a timeline and your app is at that moment; branch a new take from there.",
5
5
  "homepage": "https://retake-omega.vercel.app",
6
6
  "repository": {
@@ -33,6 +33,7 @@
33
33
  "!src/shell/mock",
34
34
  "types",
35
35
  "README.md",
36
+ "CHANGELOG.md",
36
37
  "LICENSE"
37
38
  ],
38
39
  "engines": {
package/src/core.js CHANGED
@@ -39,6 +39,11 @@ export function runtimeSource(rt = {}) {
39
39
  return `;(function () {\n"use strict";\nif (window.__retake) return;\nconst RT = Object.freeze(${config});\n${body}\n})();`
40
40
  }
41
41
 
42
+ // Retake's mark (src/mark.svg): a playhead on a track with an arc back to it.
43
+ // 24×24, the arc and the played part in `currentColor`, the rest white. The
44
+ // dock's folded button uses it; so can a favicon (on a dark tile).
45
+ export const markSvg = () => read("mark.svg").trim()
46
+
42
47
  /** @param {import("../types/index.js").ShellOptions} [options] */
43
48
  export function shellHtml(options = {}) {
44
49
  const config = {
@@ -48,12 +53,16 @@ export function shellHtml(options = {}) {
48
53
  ...(options.marker && options.marker !== "url" ? { marker: options.marker } : {}),
49
54
  // Behind the front server: rebuilds ask for the page as it was recorded (F56).
50
55
  ...(options.docs ? { docs: true } : {}),
56
+ // No Retake server behind it (a static or serverless deploy): the dock
57
+ // never asks /__retake/ for anything and keeps the session in memory.
58
+ ...(options.server === false ? { server: false } : {}),
51
59
  }
52
60
  return read("shell", "shell.html")
53
61
  .replace(
54
62
  "/*CONFIG*/",
55
63
  () => `window.__retakeConfig = ${JSON.stringify(config)};` + (options.token ? `window.__RETAKE_TOKEN = ${JSON.stringify(options.token)};` : ""),
56
64
  )
65
+ .replace("<!--MARK-->", () => markSvg().replace("<svg ", '<svg width="22" height="22" aria-hidden="true" '))
57
66
  .replace("/*CSS*/", () => read("shell", "shell.css"))
58
67
  .replace("/*JS*/", () => {
59
68
  const files = setFiles("shell")
package/src/mark.svg ADDED
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M3 17h18" stroke="#fff" stroke-opacity=".28" stroke-width="2"/><path d="M3 17h7" stroke="currentColor" stroke-width="2"/><path d="M19 14c0-5.5-9-7.5-9-1" stroke="currentColor" stroke-width="1.8"/><path d="M7.6 10.9 10 13.3l2.4-2.4" stroke="currentColor" stroke-width="1.8"/><circle cx="10" cy="17" r="2.6" fill="#fff"/></svg>
package/src/plugin.js CHANGED
@@ -12,7 +12,7 @@ import { createBus, sessionApi } from "./server/api.js"
12
12
  import { frontRuntime } from "./server/detect.js"
13
13
 
14
14
  // The site's deployed build (apps/site/app/retake-dock, retake-runtime) and the tests import these from here.
15
- export { runtimeSource, shellHtml, runtimeTag, injectHtml } from "./core.js"
15
+ export { runtimeSource, shellHtml, runtimeTag, injectHtml, markSvg } from "./core.js"
16
16
 
17
17
  const NESTED_DEST = new Set(["iframe", "frame", "embed", "object"])
18
18
  const pageKind = new AsyncLocalStorage()
@@ -112,7 +112,7 @@ function record() {
112
112
  }
113
113
 
114
114
  Object.assign(PT, {
115
- version: "0.4.0",
115
+ version: "0.5.0",
116
116
  now: () => clock.now,
117
117
  record,
118
118
  // Play from here: replays the recorded future, then carries on live.
@@ -363,6 +363,25 @@ try {
363
363
  }
364
364
  } catch {}
365
365
 
366
+ // A link or redirect to another site (GitHub, npm, a sign-in page) can't load
367
+ // inside the dock's frame: most sites refuse to be framed. A click the user
368
+ // makes opens it in a new tab, so the timeline stays; anything else (a
369
+ // redirect) goes to the top window, as it would without Retake. Frames being
370
+ // built or replayed never leave.
371
+ try {
372
+ if (W.navigation && shell) {
373
+ W.navigation.addEventListener("navigate", (e) => {
374
+ if (!e.cancelable || e.downloadRequest || !e.destination) return
375
+ const url = new URL(e.destination.url)
376
+ if (url.origin === location.origin || !/^https?:$/.test(url.protocol)) return
377
+ e.preventDefault()
378
+ if (clock.seeking || !onShow()) return
379
+ if (e.userInitiated) W.open(url.href, "_blank", "noopener")
380
+ else W.top.location.assign(url.href)
381
+ })
382
+ }
383
+ } catch {}
384
+
366
385
  // A reload (or navigation) fires beforeunload first; a frame the dock removes
367
386
  // doesn't. Only a reload leaves its recording on the shell to resume.
368
387
  let unloading = false
package/src/server/mcp.js CHANGED
@@ -106,6 +106,14 @@ const fmt = (ms) => {
106
106
  return `${String(Math.floor(s / 60)).padStart(2, "0")}:${(s % 60).toFixed(2).padStart(5, "0")}`
107
107
  }
108
108
 
109
+ // Older sessions named timelines "Main" and "Take N"; the dock now shows
110
+ // those defaults as "Timeline N" (migratedName in shell/10-dock.js).
111
+ function withNames(session) {
112
+ if (!session || !Array.isArray(session.branches)) return session
113
+ const old = (b) => (String(b.id) === "1" ? "Main" : `Take ${b.id}`)
114
+ return { ...session, branches: session.branches.map((b) => (b.name === old(b) ? { ...b, name: `Timeline ${b.id}` } : b)) }
115
+ }
116
+
109
117
  function summary(n, session) {
110
118
  const b = (session.branches || []).find((x) => String(x.id) === String(n.branchId))
111
119
  return {
@@ -197,6 +205,7 @@ const TOOLS = [
197
205
  const isOpen = (n) => !n.status || n.status === "pending" || n.status === "acknowledged"
198
206
 
199
207
  export function createTools(client) {
208
+ const readSession = async () => withNames(await client.session())
200
209
  const byId = async (id) => {
201
210
  const note = await client.note(id).catch((err) => {
202
211
  if (/→ 404/.test(err.message)) throw new Error(`no note with id ${id}; list_notes shows the ids`)
@@ -206,16 +215,16 @@ export function createTools(client) {
206
215
  }
207
216
  const handlers = {
208
217
  async list_notes({ status = "open" } = {}) {
209
- const session = await client.session()
218
+ const session = await readSession()
210
219
  const notes = (session.notes || []).filter((n) => (status === "all" ? true : status === "open" ? isOpen(n) : (n.status || "pending") === status))
211
220
  return { count: notes.length, notes: notes.map((n) => summary(n, session)) }
212
221
  },
213
222
  async get_note({ id }) {
214
- const [session, note] = await Promise.all([client.session(), byId(id)])
223
+ const [session, note] = await Promise.all([readSession(), byId(id)])
215
224
  return describe(note, session)
216
225
  },
217
226
  async get_active_timeline() {
218
- const session = await client.session()
227
+ const session = await readSession()
219
228
  const branches = session.branches || []
220
229
  const active = branches.find((b) => String(b.id) === String(session.activeId)) || null
221
230
  const parent = active && branches.find((b) => String(b.id) === String(active.parentId))
@@ -245,12 +254,12 @@ export function createTools(client) {
245
254
  async watch_notes({ timeout_seconds = 60 } = {}) {
246
255
  const ms = Math.min(Math.max(Number(timeout_seconds) || 60, 1), 600) * 1000
247
256
  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)]))
257
+ const before = new Map(((await readSession()).notes || []).map((n) => [String(n.id), key(n)]))
249
258
  const deadline = Date.now() + ms
250
259
  while (Date.now() < deadline) {
251
260
  const got = await client.nextEvent(["session", "note-updated"], deadline - Date.now())
252
261
  if (!got) break
253
- const session = await client.session()
262
+ const session = await readSession()
254
263
  const changed = (session.notes || []).filter((n) => before.get(String(n.id)) !== key(n))
255
264
  // Ignore what agents did (replies from "agent" don't change the key).
256
265
  if (changed.length) return { changed: changed.map((n) => ({ ...summary(n, session), new: !before.has(String(n.id)) })) }
@@ -19,6 +19,9 @@ const store = {
19
19
  },
20
20
  }
21
21
 
22
+ const fabPos = (v) =>
23
+ v && (v.side === "left" || v.side === "right") && Number.isFinite(v.y) ? { side: v.side, y: Math.min(1, Math.max(0, v.y)) } : { side: "right", y: 1 }
24
+
22
25
  const D = {
23
26
  // frames
24
27
  frame: null, // the visible prototype frame
@@ -59,6 +62,10 @@ const D = {
59
62
  scopeEl: null, // the element the Select tool scoped scrubbing to
60
63
  // dock size
61
64
  height: store.get("height", 200),
65
+ collapsed: store.get("collapsed", false) === true, // folded into the corner button (10-dock.js)
66
+ // Where that button sits: the side it's snapped to and how far down the
67
+ // window (0 top … 1 bottom), so it stays put through a resize (10-dock.js).
68
+ fab: fabPos(store.get("fab", null)),
62
69
  view: null, // the window of time on the timeline (20-timeline.js)
63
70
  }
64
71
 
@@ -8,9 +8,14 @@ const dock = $("#wb-dock")
8
8
  const track = $(".track")
9
9
  const cv = $(".lines") // the timeline canvas
10
10
 
11
+ // Timelines are "Timeline 1", "Timeline 2"... until the user renames one.
12
+ const defaultName = (id) => `Timeline ${id}`
13
+ // Older sessions named them "Main" and "Take N": those defaults
14
+ // (and only those) read as the new ones.
15
+ const migratedName = (b) => (b.name === (b.id === 1 ? "Main" : `Take ${b.id}`) ? defaultName(b.id) : b.name)
11
16
  const newBranch = (forkAt, parentId = null) => {
12
17
  const id = ++D.branchSeq
13
- const b = { id, name: id === 1 ? "Main" : `Take ${id}`, forkAt, parentId, json: null, end: forkAt, born: performance.now() }
18
+ const b = { id, name: defaultName(id), forkAt, parentId, json: null, end: forkAt, born: performance.now() }
14
19
  D.branches.push(b)
15
20
  return b
16
21
  }
@@ -257,6 +262,7 @@ window.__retakeShell = {
257
262
  b.version = old.version // a new timeline starts on its parent's code
258
263
  D.activeId = b.id
259
264
  D.frameBranch = b.id // the visible frame carries on as the new timeline
265
+ flash(`${b.name} started`, { warn: false })
260
266
  },
261
267
  }
262
268
 
@@ -548,6 +554,7 @@ function render() {
548
554
  const dh = dockHeight()
549
555
  dock.style.height = dh + "px"
550
556
  document.body.style.setProperty("--dock-h", dh + "px")
557
+ tellDock(D.collapsed ? 0 : dh)
551
558
  if (!s || !D.frame) return
552
559
  autoStart(s)
553
560
  if (D.perfNoDraw) return // (perf measurements: the runtime alone)
@@ -561,6 +568,20 @@ function render() {
561
568
  renderShield(s)
562
569
  if (s.started) renderTimeline(s, shownT)
563
570
  renderExtras(s)
571
+ if (D.collapsed && fab.dataset.phase !== readoutPhase.dataset.phase) fab.dataset.phase = readoutPhase.dataset.phase
572
+ }
573
+
574
+ // The app is full-window under the dock. So that it can leave room, the frame
575
+ // on show gets `window.__retakeDockHeight` (the px the dock covers at the
576
+ // bottom, 0 when folded) and a `retake:dock` event ({ detail: { height } })
577
+ // whenever that changes.
578
+ function tellDock(height) {
579
+ try {
580
+ const win = /** @type {any} */ (D.frame && D.frame.contentWindow)
581
+ if (!win || win.__retakeDockHeight === height) return
582
+ win.__retakeDockHeight = height
583
+ win.dispatchEvent(new win.CustomEvent("retake:dock", { detail: { height } }))
584
+ } catch {}
564
585
  }
565
586
 
566
587
  // A frame built behind can take the window's focus (its replay focused a
@@ -672,6 +693,7 @@ document.addEventListener("click", (e) => {
672
693
  return
673
694
  }
674
695
  const a = b.dataset.a
696
+ if (a === "expand") return setCollapsed(false)
675
697
  if (a === "play") togglePlay()
676
698
  if (a === "fresh") return startFresh()
677
699
  if (a === "undo") return undoFresh()
@@ -695,34 +717,185 @@ document.addEventListener("click", (e) => {
695
717
  // The dock never grows by itself (that would resize the app mid-recording);
696
718
  // only the divider changes it.
697
719
  const MIN_H = 96
720
+ const DEFAULT_H = 200
698
721
  // Never so short that the lanes (at their closest) don't fit under the ruler.
722
+ const minDockHeight = () => Math.max(MIN_H, 56 + 24 + D.branches.length * 10 + 16)
699
723
  const dockHeight = () => {
700
- const min = Math.max(MIN_H, 56 + 24 + D.branches.length * 10 + 16)
724
+ const min = minDockHeight()
701
725
  return Math.round(clamp(D.height, min, Math.max(min, innerHeight * 0.7)))
702
726
  }
703
727
 
704
- // Resize by dragging the top edge, like docked DevTools.
728
+ // Resize by dragging the top edge, like docked DevTools. Below the minimum
729
+ // height the dock keeps following the pointer: it slides down (a transform, so
730
+ // neither its layout nor the app's changes mid-drag) all the way off the edge.
731
+ // Let go far enough down and it settles into the corner icon; anywhere higher
732
+ // it springs back up to the minimum.
705
733
  const divider = $(".divider")
706
734
  divider.addEventListener("pointerdown", (e) => {
707
735
  divider.setPointerCapture(e.pointerId)
708
736
  document.body.classList.add("dragging")
709
737
  const startY = e.clientY
710
738
  const startH = dock.offsetHeight
739
+ const min = minDockHeight()
740
+ let fold = false
741
+ let lastY = startY
742
+ let raf = 0
743
+ // One write per frame, whatever the pointer's rate.
744
+ const paint = () => {
745
+ raf = 0
746
+ const want = startH + startY - lastY
747
+ D.height = Math.round(clamp(want, min, innerHeight * 0.7))
748
+ const drop = clamp(min - want, 0, min + 24)
749
+ fold = drop > min / 2 || lastY >= innerHeight - FOLD_EDGE
750
+ dock.style.transform = drop ? `translateY(${drop}px)` : ""
751
+ dock.classList.toggle("will-collapse", fold)
752
+ }
711
753
  const move = (ev) => {
712
- D.height = Math.round(clamp(startH + startY - ev.clientY, MIN_H, innerHeight * 0.7))
754
+ lastY = ev.clientY
755
+ if (!raf) raf = requestAnimationFrame(paint)
713
756
  }
714
- const up = () => {
757
+ // A touch the browser takes back (pointercancel) ends the drag too, or the
758
+ // app would stay unclickable under body.dragging.
759
+ const up = (ev) => {
760
+ for (const type of ["pointermove", "pointerup", "pointercancel", "lostpointercapture"]) divider.removeEventListener(type, type === "pointermove" ? move : up)
761
+ if (raf) cancelAnimationFrame(raf)
762
+ if (ev.type !== "pointercancel" && ev.type !== "lostpointercapture") {
763
+ lastY = ev.clientY
764
+ paint()
765
+ }
766
+ // From where the finger left it: on into the icon, or back up.
715
767
  document.body.classList.remove("dragging")
716
- divider.removeEventListener("pointermove", move)
717
- divider.removeEventListener("pointerup", up)
768
+ dock.classList.remove("will-collapse")
769
+ dock.style.transform = ""
718
770
  store.set("height", D.height)
771
+ if (fold && ev.type !== "pointercancel") setCollapsed(true)
719
772
  }
720
773
  divider.addEventListener("pointermove", move)
721
- divider.addEventListener("pointerup", up)
774
+ for (const type of ["pointerup", "pointercancel", "lostpointercapture"]) divider.addEventListener(type, up)
775
+ })
776
+
777
+ // Folded: the timeline is a round button (bottom-right at first) and the app
778
+ // has the whole window. It keeps recording; the button (or ⌥T) brings the dock
779
+ // back at its default height. Remembered across reloads.
780
+ const FOLD_EDGE = 48 // px from the bottom edge: let go there and it folds
781
+ const fab = $("#wb-fab")
782
+ function applyCollapsed() {
783
+ document.body.classList.toggle("collapsed", D.collapsed)
784
+ fab.hidden = !D.collapsed
785
+ dock.inert = D.collapsed
786
+ if (D.collapsed) placeFab()
787
+ }
788
+
789
+ // The button can be dragged anywhere (mouse or finger). Past a few px it's a
790
+ // drag, not a click; let go and it eases to the nearer side edge. Where it is
791
+ // (`D.fab`: that side, and how far down) is stored, so a reload or a resized
792
+ // window puts it back in the same place, always fully on screen.
793
+ const FAB_SIZE = 44
794
+ const FAB_MARGIN = 16
795
+ const FAB_SLOP = 5 // px a press may move and still be a click
796
+ let fabDragged = false // the press that just ended was a drag: not a click
797
+ // The notch and the home bar (viewport-fit=cover), measured once per layout.
798
+ const safeProbe = document.createElement("div")
799
+ safeProbe.style.cssText =
800
+ "position:fixed;visibility:hidden;pointer-events:none;padding:env(safe-area-inset-top,0px) env(safe-area-inset-right,0px) env(safe-area-inset-bottom,0px) env(safe-area-inset-left,0px)"
801
+ document.body.appendChild(safeProbe)
802
+ function fabBounds() {
803
+ const cs = getComputedStyle(safeProbe)
804
+ const left = FAB_MARGIN + (parseFloat(cs.paddingLeft) || 0)
805
+ const top = FAB_MARGIN + (parseFloat(cs.paddingTop) || 0)
806
+ const right = Math.max(left, innerWidth - FAB_SIZE - FAB_MARGIN - (parseFloat(cs.paddingRight) || 0))
807
+ const bottom = Math.max(top, innerHeight - FAB_SIZE - FAB_MARGIN - (parseFloat(cs.paddingBottom) || 0))
808
+ return { left, top, right, bottom }
809
+ }
810
+ function moveFab(x, y) {
811
+ fab.style.left = Math.round(x) + "px"
812
+ fab.style.top = Math.round(y) + "px"
813
+ }
814
+ function placeFab() {
815
+ const b = fabBounds()
816
+ moveFab(D.fab.side === "left" ? b.left : b.right, b.top + D.fab.y * (b.bottom - b.top))
817
+ }
818
+ applyCollapsed()
819
+ addEventListener("resize", () => D.collapsed && !fab.classList.contains("moving") && placeFab())
820
+
821
+ fab.addEventListener("pointerdown", (e) => {
822
+ fabDragged = false
823
+ if (e.button !== 0) return
824
+ const r = fab.getBoundingClientRect()
825
+ const grabX = e.clientX - r.left
826
+ const grabY = e.clientY - r.top
827
+ const x0 = e.clientX
828
+ const y0 = e.clientY
829
+ let moving = false
830
+ fab.setPointerCapture(e.pointerId)
831
+ const move = (ev) => {
832
+ if (!moving && Math.hypot(ev.clientX - x0, ev.clientY - y0) < FAB_SLOP) return
833
+ if (!moving) {
834
+ moving = true
835
+ fab.classList.remove("snapping")
836
+ fab.classList.add("moving")
837
+ }
838
+ const b = fabBounds()
839
+ moveFab(clamp(ev.clientX - grabX, b.left, b.right), clamp(ev.clientY - grabY, b.top, b.bottom))
840
+ }
841
+ const up = () => {
842
+ for (const type of ["pointermove", "pointerup", "pointercancel"]) fab.removeEventListener(type, type === "pointermove" ? move : up)
843
+ if (!moving) return
844
+ fabDragged = true
845
+ fab.classList.remove("moving")
846
+ // Where it was let go (its left/top: the rect would include the lift's scale).
847
+ const b = fabBounds()
848
+ const x = parseFloat(fab.style.left) || 0
849
+ const y = parseFloat(fab.style.top) || 0
850
+ D.fab = {
851
+ side: x + FAB_SIZE / 2 < innerWidth / 2 ? "left" : "right",
852
+ y: b.bottom > b.top ? clamp((y - b.top) / (b.bottom - b.top), 0, 1) : 1,
853
+ }
854
+ store.set("fab", D.fab)
855
+ fab.classList.add("snapping")
856
+ placeFab()
857
+ setTimeout(() => fab.classList.remove("snapping"), 400)
858
+ }
859
+ fab.addEventListener("pointermove", move)
860
+ fab.addEventListener("pointerup", up)
861
+ fab.addEventListener("pointercancel", up)
722
862
  })
863
+ // The click that ends a drag doesn't open the dock.
864
+ fab.addEventListener(
865
+ "click",
866
+ (e) => {
867
+ if (!fabDragged) return
868
+ fabDragged = false
869
+ e.stopPropagation()
870
+ e.preventDefault()
871
+ },
872
+ true,
873
+ )
874
+ function setCollapsed(on) {
875
+ if (on === D.collapsed) return
876
+ const hadFocus = on ? dock.contains(document.activeElement) : document.activeElement === fab
877
+ if (on) {
878
+ setPicking(null)
879
+ closeCard()
880
+ } else {
881
+ D.height = DEFAULT_H
882
+ store.set("height", D.height)
883
+ }
884
+ D.collapsed = on
885
+ store.set("collapsed", on)
886
+ applyCollapsed()
887
+ if (hadFocus) (on ? fab : playBtn).focus({ preventScroll: true })
888
+ }
723
889
 
724
890
  window.addEventListener("keydown", (e) => {
725
891
  if (e.key === "Meta") return window.__retakeShell.meta(true)
892
+ // The corner button is a real button: Enter and Space press it.
893
+ if (e.target === fab && (e.key === "Enter" || e.code === "Space")) return
894
+ // Not while typing in the dock (a note, a timeline's name): ⌥T types there († on a Mac).
895
+ if (e.altKey && e.code === "KeyT" && !typing(e.target)) {
896
+ e.preventDefault()
897
+ return setCollapsed(!D.collapsed)
898
+ }
726
899
  if (e.key === "Escape") {
727
900
  setPicking(null)
728
901
  closeCard()
@@ -2,11 +2,13 @@
2
2
  // (/__retake/session, kept on disk under <project>/.retake/) so a reload or a
3
3
  // restart doesn't lose them; each timeline's recording goes to
4
4
  // /__retake/recording/:id. Agents reply to notes over /__retake/events.
5
- // When the server has no session API (it answers 404), everything stays in
6
- // memory for this page.
5
+ // When the server has no session API (it answers 404), or the page says
6
+ // there's no server at all (`__retakeConfig.server === false`, a static
7
+ // deploy), everything stays in memory for this page.
7
8
  const TOKEN = window.__RETAKE_TOKEN || ""
9
+ const SERVER = !(window.__retakeConfig && window.__retakeConfig.server === false)
8
10
  const net = {
9
- on: true, // false: in-memory only
11
+ on: SERVER, // false: in-memory only
10
12
  restoring: true, // nothing is saved until what's on disk has been read
11
13
  savedSession: null, // the last session body the server has
12
14
  savedRec: new Map(), // branch id → the recording string the server has
@@ -40,7 +42,7 @@ function sessionBody() {
40
42
  }
41
43
 
42
44
  function applySession(s) {
43
- D.branches = s.branches.map((b) => ({ ...b, version: b.codeVersion || undefined, json: null, end: b.end ?? b.forkAt, born: -1e9 }))
45
+ D.branches = s.branches.map((b) => ({ ...b, name: migratedName(b), version: b.codeVersion || undefined, json: null, end: b.end ?? b.forkAt, born: -1e9 }))
44
46
  D.branchSeq = Math.max(0, ...D.branches.map((b) => b.id))
45
47
  D.activeId = D.branches.some((b) => b.id === s.activeId) ? s.activeId : D.branches[0].id
46
48
  D.markers = Array.isArray(s.markers) ? s.markers : []
@@ -83,6 +85,7 @@ async function restore() {
83
85
  return b.id
84
86
  }
85
87
  try {
88
+ if (!SERVER) return
86
89
  const r = await api("GET", "session")
87
90
  if (r.missing) {
88
91
  net.on = false
@@ -93,7 +96,7 @@ async function restore() {
93
96
  applySession(s)
94
97
  net.savedSession = JSON.stringify(sessionBody())
95
98
  known = true
96
- const json = await recordingOf(activeBranch())
99
+ const json = newerThanSaved(await recordingOf(activeBranch()))
97
100
  if (D.frame) {
98
101
  // Opened fresh already (the session was slow): that visit is its own.
99
102
  D.frameBranch = ownTimeline()
@@ -119,6 +122,53 @@ async function restore() {
119
122
  }
120
123
  }
121
124
 
125
+ // Leaving the page (a new address typed in, a reload of the dock) while
126
+ // recording: the server has the recording as it was at the last save, up to
127
+ // five seconds ago. What came after is kept in this tab's sessionStorage on
128
+ // the way out, and restore() carries on from it (F78).
129
+ const LIVE_KEY = KEY + "live"
130
+ function takeLiveStash() {
131
+ try {
132
+ const v = sessionStorage.getItem(LIVE_KEY)
133
+ sessionStorage.removeItem(LIVE_KEY) // the app's frame shares this storage
134
+ return v ? JSON.parse(v) : null
135
+ } catch {
136
+ return null
137
+ }
138
+ }
139
+ const liveStash = SERVER ? takeLiveStash() : null
140
+ // The saved recording, or what this tab kept of the same recording (same
141
+ // timeline, same epoch) when it's further along.
142
+ function newerThanSaved(json) {
143
+ const kept = liveStash
144
+ if (!json || !kept || kept.branchId !== D.activeId || typeof kept.rec !== "string") return json
145
+ try {
146
+ const saved = JSON.parse(json)
147
+ const rec = JSON.parse(kept.rec)
148
+ if (rec.epoch !== saved.epoch || !(rec.end > saved.end)) return json
149
+ } catch {
150
+ return json
151
+ }
152
+ activeBranch().json = kept.rec
153
+ return kept.rec
154
+ }
155
+ window.addEventListener("pagehide", () => {
156
+ if (!net.on || net.restoring || net.hold || !D.PT || D.building || D.frameBranch !== D.activeId) return
157
+ try {
158
+ const s = D.PT.state()
159
+ if (!s.started || s.seeking) return
160
+ const rec = JSON.stringify(D.PT.history())
161
+ if (rec === net.savedRec.get(D.activeId)) return
162
+ sessionStorage.setItem(LIVE_KEY, JSON.stringify({ branchId: D.activeId, rec }))
163
+ } catch {}
164
+ })
165
+ // Back from the back/forward cache: this page carries on, nothing to keep.
166
+ window.addEventListener("pageshow", (e) => {
167
+ try {
168
+ if (e.persisted) sessionStorage.removeItem(LIVE_KEY)
169
+ } catch {}
170
+ })
171
+
122
172
  // The active timeline's recording is saved when it's still (paused), and every
123
173
  // few seconds while it's live.
124
174
  function activeSig(s) {
@@ -41,7 +41,6 @@ const MONO = '10px "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace'
41
41
 
42
42
  const tip = $("#wb-tip")
43
43
  const gutter = $(".gutter")
44
- const liveBtn = $(".live-btn")
45
44
  const readoutT = $(".readout .t")
46
45
  // Minutes and seconds, then the hundredths dimmer.
47
46
  readoutT.innerHTML = '<span class="ms"></span><span class="cs"></span>'
@@ -100,7 +99,7 @@ function setView(from, to, { animate = true } = {}) {
100
99
  const r = pinStart({ from: mid - span / 2, to: mid + span / 2 }, D.last)
101
100
  D.view.fit = false
102
101
  D.view.followSpan = r.to - r.from
103
- if (D.last && D.last.playing) D.view.detached = true
102
+ if (D.last && D.last.playing) (D.view.detached = true), (D.view.detachedAt = performance.now())
104
103
  if (animate) D.view.target = r
105
104
  else {
106
105
  D.view.from = r.from
@@ -117,6 +116,8 @@ function fitRange(a, b) {
117
116
  const pad = Math.max((b - a) * 0.15, 10)
118
117
  setView(a - pad, b + pad)
119
118
  }
119
+ // How long a manual scroll of a live timeline holds the view before it follows again.
120
+ const FOLLOW_AGAIN_MS = 3000
120
121
  function followLive() {
121
122
  D.view.detached = false
122
123
  D.view.fit = false
@@ -131,6 +132,8 @@ function stepView(s) {
131
132
  const v = D.view
132
133
  // Started playing: follow again. Paused: stay exactly where it is.
133
134
  if (s.playing && !v.wasPlaying) v.detached = false
135
+ // Scrolled away while it records: it follows again once you leave it alone.
136
+ if (s.playing && !s.future && v.detached && now - (v.detachedAt || 0) > FOLLOW_AGAIN_MS) v.detached = false
134
137
  if (!s.playing && v.wasPlaying) {
135
138
  v.fit = false
136
139
  v.target = null
@@ -310,11 +313,13 @@ function drawRuler(g, s, xNow) {
310
313
  const isMajor = rel % major === 0
311
314
  ctx.fillStyle = isMajor ? INK.dim : INK.faint
312
315
  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)) {
316
+ // A label that would run past the right edge is left out.
317
+ if (isMajor) {
318
+ const text = label(t)
319
+ if (x + 3 + ctx.measureText(text).width > g.w) continue
315
320
  ctx.fillStyle = INK.dim
316
- ctx.fillText(label(t), x + 3, RULER - 8)
317
- labels.push(label(t))
321
+ ctx.fillText(text, x + 3, RULER - 8)
322
+ labels.push(text)
318
323
  }
319
324
  }
320
325
  return labels
@@ -457,7 +462,6 @@ function renderTimeline(s, shownT) {
457
462
  D.lanes = lanes
458
463
  renderGutter(lanes)
459
464
  const following = s.playing && !D.view.detached
460
- liveBtn.hidden = !(s.playing && !s.future && D.view.detached)
461
465
  checkPendingFork(s)
462
466
 
463
467
  if (band.id !== D.activeId) {
@@ -702,11 +706,13 @@ function renderHead(s, shownT) {
702
706
  hintEl.classList.remove("show", "warn")
703
707
  }
704
708
 
705
- // A short message in the hint's place (a refused checkout, say).
706
- function flash(msg) {
707
- flashUntil = performance.now() + 3500
709
+ // A short message in the hint's place: a warning (a refused checkout, say),
710
+ // or with `warn: false` news ("Timeline 2 started").
711
+ function flash(msg, { warn = true } = {}) {
712
+ flashUntil = performance.now() + (warn ? 3500 : 2500)
708
713
  hintEl.textContent = msg
709
- hintEl.classList.add("show", "warn")
714
+ hintEl.classList.add("show")
715
+ hintEl.classList.toggle("warn", warn)
710
716
  }
711
717
 
712
718
  // ---- a new timeline from a chosen moment -------------------------------------------
@@ -520,6 +520,10 @@ function dockKey(e, fromApp) {
520
520
  stepBy(e.key === "ArrowRight" ? 1 : -1, e.shiftKey)
521
521
  return true
522
522
  }
523
+ if (e.altKey && e.code === "KeyT") {
524
+ setCollapsed(!D.collapsed)
525
+ return true
526
+ }
523
527
  if (e.altKey) return false
524
528
  if (e.code === "KeyF") {
525
529
  fitAll()
@@ -821,15 +821,17 @@ function renderExtras(s) {
821
821
  if (D.scopeEl && D.scopeEl.isConnected) boxAt(D.scopeEl, scopeBox)
822
822
  else scopeBox.style.display = "none"
823
823
 
824
- // Numbered pins on the notes of the branch in view.
824
+ // Numbered pins on the notes of the branch in view, only while it's paused:
825
+ // live or playing, the app is left alone (the count still says how many).
826
+ const paused = !s.playing && !(D.building && D.building.play)
827
+ if (!paused && openNote) closeCard()
825
828
  let doc = null
826
829
  try {
827
830
  doc = D.frame.contentDocument
828
831
  } catch {}
829
- const f = D.frame.getBoundingClientRect()
830
832
  const seen = new Set()
831
833
  D.notes.forEach((n, i) => {
832
- if (n.branchId !== D.activeId || !doc || s.seeking) return
834
+ if (n.branchId !== D.activeId || !doc || s.seeking || !paused) return
833
835
  const p = anchorOf(n.el)
834
836
  let pin = pinEls.get(n.id)
835
837
  if (!pin) {
@@ -54,11 +54,40 @@ body.dragging #wb-stage, body.scrubbing #wb-stage { pointer-events: none; }
54
54
  background: linear-gradient(to bottom, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0) 36px), var(--glass);
55
55
  -webkit-backdrop-filter: blur(28px) saturate(160%); backdrop-filter: blur(28px) saturate(160%);
56
56
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.10), 0 -8px 32px rgba(0, 0, 0, 0.35); }
57
+ /* Folded away (the divider dragged to the bottom edge, or ⌥T): it slides down
58
+ out of sight and the round button in the corner brings it back. */
59
+ #wb-dock { padding-bottom: env(safe-area-inset-bottom, 0px);
60
+ transition: transform .24s var(--ease), opacity .2s var(--ease), visibility 0s linear 0s; }
61
+ #wb-dock.will-collapse { opacity: .55; }
62
+ /* Mid-drag it follows the pointer exactly (translateY below the minimum height,
63
+ see 10-dock.js); let go and it eases from there into the icon or back up. */
64
+ body.dragging #wb-dock { transition: opacity .12s var(--ease); will-change: transform; }
65
+ body.collapsed #wb-dock { transform: translateY(calc(100% + 24px)); opacity: 0; visibility: hidden; pointer-events: none;
66
+ transition: transform .24s var(--ease), opacity .2s var(--ease), visibility 0s linear .24s; }
67
+ /* Placed by 10-dock.js (left/top: dragged anywhere, snapped to a side edge). */
68
+ #wb-fab { position: fixed; z-index: 10; left: calc(100vw - 60px); top: calc(100vh - 60px); touch-action: none; cursor: grab;
69
+ width: 44px; height: 44px; border-radius: 999px; color: var(--blue);
70
+ background: linear-gradient(to bottom, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0)), var(--glass);
71
+ -webkit-backdrop-filter: blur(28px) saturate(160%); backdrop-filter: blur(28px) saturate(160%);
72
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.10), inset 0 0 0 1px var(--hair), var(--shadow); animation: fab-in .22s var(--ease); }
73
+ #wb-fab[hidden] { display: none; }
74
+ #wb-fab.moving { cursor: grabbing; transform: scale(1.06); transition: transform .12s var(--ease); }
75
+ #wb-fab.snapping { transition: left .28s var(--ease), top .28s var(--ease), transform .12s var(--ease); }
76
+ @media (hover: hover) { #wb-fab:hover { background: linear-gradient(to bottom, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.02)), var(--glass); } }
77
+ #wb-fab svg { display: block; }
78
+ /* The phase, as the readout shows it: green live, blue playing, amber paused. */
79
+ #wb-fab .dot { position: absolute; top: 9px; right: 9px; width: 6px; height: 6px; border-radius: 999px; background: var(--dim); box-shadow: 0 0 0 2px rgba(22, 22, 26, 0.9); }
80
+ #wb-fab[data-phase="live"] .dot { background: var(--green); }
81
+ #wb-fab[data-phase="playing"] .dot { background: var(--blue); }
82
+ #wb-fab[data-phase="paused"] .dot, #wb-fab[data-phase="scrubbing"] .dot { background: var(--amber); }
83
+ @keyframes fab-in { from { opacity: 0; transform: scale(.6) translateY(8px); } }
57
84
  @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
85
+ #wb-fab { background: rgba(20, 20, 24, 0.92); }
58
86
  #wb-dock { background: linear-gradient(to bottom, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0) 36px), rgba(20, 20, 24, 0.92); }
59
87
  }
60
88
  /* The grab handle at the top centre: always there, brighter on hover. */
61
- .divider { position: absolute; left: 0; right: 0; top: 0; height: 12px; cursor: ns-resize; z-index: 3; display: flex; justify-content: center; }
89
+ /* touch-action: a finger drag on it is the dock's, never the page's scroll. */
90
+ .divider { position: absolute; left: 0; right: 0; top: 0; height: 12px; cursor: ns-resize; z-index: 3; display: flex; justify-content: center; touch-action: none; }
62
91
  .divider span { margin-top: 4px; width: 36px; height: 4px; border-radius: 999px; background: rgba(255, 255, 255, 0.18); transition: background .15s var(--ease), width .15s var(--ease); }
63
92
  .divider:hover span, body.dragging .divider span { background: rgba(255, 255, 255, 0.45); width: 44px; }
64
93
 
@@ -71,12 +100,11 @@ button:active { transform: scale(0.96); }
71
100
  .head { position: relative; flex: none; height: 48px; margin-top: 8px; display: flex; align-items: center; gap: 8px; padding: 0 16px; }
72
101
  .left, .right { display: flex; align-items: center; gap: 8px; flex: none; }
73
102
  .right { margin-left: auto; }
74
- /* Phone width: the tools scroll sideways rather than run off the edge. */
75
- @media (max-width: 520px) { .head { padding: 0 10px; gap: 6px; } .readout .phase { display: none; } .right { flex: 0 1 auto; min-width: 0; overflow-x: auto; scrollbar-width: none; } .right::-webkit-scrollbar { display: none; } }
76
103
  /* One button style for the text controls: 32px, full round, a ghost fill. */
77
104
  .btn { flex: none; height: 32px; padding: 0 14px; gap: 8px; border-radius: 999px; background: var(--fill); color: var(--muted);
78
105
  font: 500 13px/1 var(--sans); box-shadow: inset 0 0 0 1px var(--hair); }
79
106
  @media (hover: hover) { .btn:hover { color: var(--white); background: var(--fill-2); } }
107
+ .btn.fresh svg { display: none; }
80
108
 
81
109
  /* Transport: play and the clock in one capsule. */
82
110
  .transport { display: flex; align-items: center; gap: 10px; height: 36px; padding: 0 14px 0 4px; border-radius: 999px;
@@ -118,6 +146,29 @@ button:active { transform: scale(0.96); }
118
146
  box-shadow: 0 0 0 2px rgba(22, 22, 26, 0.9); transform: scale(0); opacity: 0; transition: transform .2s var(--ease), opacity .2s var(--ease); }
119
147
  .notes-count.has .n { transform: none; opacity: 1; }
120
148
 
149
+ /* Phone width: the phase word goes, the row tightens, and Start fresh becomes
150
+ its icon; anything still too wide scrolls sideways rather than run off the edge. */
151
+ @media (max-width: 520px) { .head { padding: 0 10px; gap: 6px; } .readout .phase { display: none; } .right { gap: 6px; flex: 0 1 auto; min-width: 0; overflow-x: auto; scrollbar-width: none; } .right::-webkit-scrollbar { display: none; } }
152
+ @media (max-width: 440px) { .btn.fresh { width: 32px; padding: 0; } .btn.fresh span { display: none; } .btn.fresh svg { display: block; } }
153
+ /* Fingers (and phone widths): 40px targets. The handle's strip reaches 16px
154
+ above the dock; lane names get a narrower gutter so the track keeps its
155
+ width; the glass is denser, so the page under it doesn't muddy the dock. */
156
+ @media (pointer: coarse), (max-width: 520px) {
157
+ .divider { top: -16px; height: 32px; }
158
+ .divider span { margin-top: 20px; }
159
+ .head { height: 56px; }
160
+ .transport { height: 44px; gap: 8px; padding: 0 12px 0 2px; }
161
+ .play { width: 40px; height: 40px; }
162
+ .tools { height: 44px; padding: 2px; }
163
+ .tool { width: 40px; height: 40px; }
164
+ .btn { height: 40px; }
165
+ .notes-count { width: 32px; }
166
+ }
167
+ @media (max-width: 440px) { .btn.fresh { width: 40px; } }
168
+ @media (max-width: 520px) {
169
+ :root { --glass: rgba(20, 20, 24, 0.9); }
170
+ .gutter { width: 76px; }
171
+ }
121
172
  /* The one hint: a small pill. */
122
173
  /* Warnings only, centred on the dock. */
123
174
  .hint { position: absolute; left: 50%; top: 12px; transform: translateX(-50%); height: 24px; padding: 0 12px; display: inline-flex; align-items: center; border-radius: 999px;
@@ -163,10 +214,6 @@ body.scrubbing, body.scrubbing .track { cursor: ew-resize; }
163
214
  body.branching .track, body.branching .track * { cursor: crosshair !important; }
164
215
 
165
216
  /* Following was stopped by hand: this brings it back. */
166
- .live-btn { position: absolute; right: 8px; top: 24px; z-index: 2; height: 24px; padding: 0 10px; border-radius: 999px;
167
- background: var(--fill-2); color: var(--white); font: 500 12px/1 var(--sans); }
168
- @media (hover: hover) { .live-btn:hover { background: rgba(255, 255, 255, 0.18); } }
169
- .live-btn[hidden] { display: none; }
170
217
 
171
218
  /* ruler: numbers are monospace */
172
219
  .lines .rule { stroke: var(--hair); stroke-width: 1; }
@@ -292,4 +339,4 @@ body.branching .track, body.branching .track * { cursor: crosshair !important; }
292
339
  .ctx button.danger { color: var(--red); }
293
340
  .ctx .menu-note { padding: 8px; color: var(--dim); font-size: 12px; line-height: 16px; }
294
341
 
295
- @media (prefers-reduced-motion: reduce) { * { animation-duration: 0s !important; transition-duration: 0s !important; } }
342
+ @media (prefers-reduced-motion: reduce) { * { animation-duration: 0s !important; transition-duration: 0s !important; transition-delay: 0s !important; } }
@@ -2,7 +2,7 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <title>Prototype</title>
7
7
  <style>/*CSS*/</style>
8
8
  </head>
@@ -35,17 +35,23 @@
35
35
  <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 9a2 2 0 0 1-2 2H6l-4 4V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2z"/><path d="M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1"/></svg>
36
36
  <span class="n">0</span>
37
37
  </span>
38
- <button class="btn fresh" data-a="fresh">Start fresh</button>
38
+ <button class="btn fresh" data-a="fresh" aria-label="Start fresh" title="Start fresh">
39
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
40
+ <span>Start fresh</span>
41
+ </button>
39
42
  </div>
40
43
  </div>
41
44
  <div class="body">
42
45
  <div class="gutter" aria-label="Timelines"></div>
43
46
  <div class="track">
44
47
  <canvas class="lines" aria-label="Timeline"></canvas>
45
- <button class="live-btn" data-a="live" hidden>Live →</button>
46
48
  </div>
47
49
  </div>
48
50
  </div>
51
+ <button id="wb-fab" data-a="expand" aria-label="Show timeline" title="Show timeline (⌥T)" hidden>
52
+ <!--MARK-->
53
+ <i class="dot" aria-hidden="true"></i>
54
+ </button>
49
55
  <div id="wb-tip" class="tip" hidden></div>
50
56
  <div id="wb-layers" hidden></div>
51
57
  <div id="wb-hl"></div>
package/types/client.d.ts CHANGED
@@ -69,5 +69,14 @@ export interface RetakeRuntime {
69
69
  declare global {
70
70
  interface Window {
71
71
  __retake?: RetakeRuntime | { readonly inert: true }
72
+ /**
73
+ * In the dock's frame: how many px the dock covers at the bottom of the
74
+ * page (0 when it's folded away). A `retake:dock` event on `window`
75
+ * (`detail: { height }`) says when it changes.
76
+ */
77
+ __retakeDockHeight?: number
78
+ }
79
+ interface WindowEventMap {
80
+ "retake:dock": CustomEvent<{ height: number }>
72
81
  }
73
82
  }
package/types/index.d.ts CHANGED
@@ -78,6 +78,13 @@ export interface ShellOptions {
78
78
  marker?: "url" | "header"
79
79
  /** Rebuilds ask the server for the page as it was recorded (front server only). */
80
80
  docs?: boolean
81
+ /**
82
+ * `false`: there's no Retake server behind the dock (a static or serverless
83
+ * deploy). It never requests `/__retake/` (no session, no events) and keeps
84
+ * the session in memory for the page. Default: a server is assumed, and a 404
85
+ * from it falls back to memory.
86
+ */
87
+ server?: boolean
81
88
  /** The token mutating `/__retake/` requests carry. */
82
89
  token?: string
83
90
  }
@@ -85,6 +92,13 @@ export interface ShellOptions {
85
92
  /** The dock page: a complete HTML document with the app in a frame. */
86
93
  export function shellHtml(options?: ShellOptions): string
87
94
 
95
+ /**
96
+ * Retake's mark as an SVG string (24×24 viewBox, no width or height): a
97
+ * playhead on a track with an arc back to it. The arc and the played part are
98
+ * `currentColor`, the rest white, so it reads on a dark background.
99
+ */
100
+ export function markSvg(): string
101
+
88
102
  export interface RuntimeTagOptions {
89
103
  /** The runtime's setup. */
90
104
  rt?: RuntimeConfig