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 +50 -0
- package/README.md +34 -5
- package/package.json +2 -1
- package/src/core.js +9 -0
- package/src/mark.svg +1 -0
- package/src/plugin.js +1 -1
- package/src/runtime/70-boot.js +20 -1
- package/src/server/mcp.js +14 -5
- package/src/shell/00-state.js +7 -0
- package/src/shell/10-dock.js +181 -8
- package/src/shell/15-session.js +55 -5
- package/src/shell/20-timeline.js +17 -11
- package/src/shell/25-input.js +4 -0
- package/src/shell/30-notes.js +5 -3
- package/src/shell/shell.css +55 -8
- package/src/shell/shell.html +9 -3
- package/types/client.d.ts +9 -0
- package/types/index.d.ts +14 -0
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
|
|
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;
|
|
150
|
+
through the same front server and should work; open an issue if one doesn't.
|
|
140
151
|
|
|
141
|
-
- **
|
|
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
|
-
- **
|
|
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
|
|
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.
|
|
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()
|
package/src/runtime/70-boot.js
CHANGED
|
@@ -112,7 +112,7 @@ function record() {
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
Object.assign(PT, {
|
|
115
|
-
version: "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
|
|
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([
|
|
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
|
|
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
|
|
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
|
|
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)) })) }
|
package/src/shell/00-state.js
CHANGED
|
@@ -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
|
|
package/src/shell/10-dock.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
754
|
+
lastY = ev.clientY
|
|
755
|
+
if (!raf) raf = requestAnimationFrame(paint)
|
|
713
756
|
}
|
|
714
|
-
|
|
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
|
-
|
|
717
|
-
|
|
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
|
-
|
|
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()
|
package/src/shell/15-session.js
CHANGED
|
@@ -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),
|
|
6
|
-
//
|
|
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:
|
|
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) {
|
package/src/shell/20-timeline.js
CHANGED
|
@@ -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
|
|
314
|
-
if (isMajor
|
|
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(
|
|
317
|
-
labels.push(
|
|
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
|
-
|
|
707
|
-
|
|
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"
|
|
714
|
+
hintEl.classList.add("show")
|
|
715
|
+
hintEl.classList.toggle("warn", warn)
|
|
710
716
|
}
|
|
711
717
|
|
|
712
718
|
// ---- a new timeline from a chosen moment -------------------------------------------
|
package/src/shell/25-input.js
CHANGED
|
@@ -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()
|
package/src/shell/30-notes.js
CHANGED
|
@@ -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) {
|
package/src/shell/shell.css
CHANGED
|
@@ -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
|
-
|
|
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; } }
|
package/src/shell/shell.html
CHANGED
|
@@ -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"
|
|
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
|