retake-dev 0.4.0 → 0.5.1

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.
@@ -0,0 +1,317 @@
1
+ // What happened over time in a stored recording (.retake/recordings), as
2
+ // short readable text for `retake mcp`: the user's actions, animations on or
3
+ // near an element (with what they animate between), requests, page loads and
4
+ // how much of the screen changed, around a moment or over a window. Times
5
+ // read like the dock's (00:10.91, from the recording's start).
6
+ // The recording's format is the runtime's (src/runtime/30-recorder.js).
7
+
8
+ /** ms → "00:10.91" */
9
+ export const fmt = (ms) => {
10
+ const s = Math.max(0, Number(ms) || 0) / 1000
11
+ return `${String(Math.floor(s / 60)).padStart(2, "0")}:${(s % 60).toFixed(2).padStart(5, "0")}`
12
+ }
13
+ /** ms → "0.21s" / "1.20s" / "350ms" */
14
+ const dur = (ms) => (Math.abs(ms) < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(2)}s`)
15
+
16
+ /** "00:10.91", "10.91", "10.91s", "1:02.5", "350ms" or a number of ms → ms. */
17
+ export function parseTime(v) {
18
+ if (v == null || v === "") return null
19
+ if (typeof v === "number") return v
20
+ const s = String(v).trim()
21
+ let m
22
+ if ((m = /^(\d+):(\d+(?:\.\d+)?)$/.exec(s))) return (Number(m[1]) * 60 + Number(m[2])) * 1000
23
+ if ((m = /^(\d+(?:\.\d+)?)\s*ms$/.exec(s))) return Number(m[1])
24
+ if ((m = /^(\d+(?:\.\d+)?)\s*s?$/.exec(s))) return Number(m[1]) * 1000
25
+ throw new Error(`can't read the time "${v}"; use the dock's format, like 00:10.91`)
26
+ }
27
+
28
+ /** A recording as the server keeps it (v1, or packed v2/v3) → plain v1 form. */
29
+ export function readRecording(json) {
30
+ const o = typeof json === "string" ? JSON.parse(json) : { ...json }
31
+ if (!o || typeof o !== "object") return null
32
+ if (o.v === 2 && o.paths) {
33
+ const P = o.paths
34
+ o.events = (o.events || []).map((ev) => {
35
+ const out = { ...ev }
36
+ if ("p" in out) (out.path = P[out.p]), delete out.p
37
+ if ("rp" in out) (out.related = P[out.rp]), delete out.rp
38
+ return out
39
+ })
40
+ } else if (o.v === 3) {
41
+ const P = o.paths || []
42
+ const S = o.strs || []
43
+ const frames = o.frames || []
44
+ let lastF = 0
45
+ o.events = (o.events || []).map((ev) => {
46
+ const out = { ...ev }
47
+ out.type = S[out.y]
48
+ delete out.y
49
+ if ("p" in out) (out.path = P[out.p]), delete out.p
50
+ if ("rp" in out) (out.related = P[out.rp]), delete out.rp
51
+ if ("c" in out) (out.css = S[out.c]), delete out.c
52
+ if ("f" in out) {
53
+ lastF += out.f
54
+ out.t = frames[lastF]
55
+ delete out.f
56
+ }
57
+ return out
58
+ })
59
+ o.clips = (o.clips || []).map((c) => {
60
+ const out = { ...c }
61
+ if ("s" in out) (out.selector = S[out.s]), delete out.s
62
+ if ("co" in out) (out.component = S[out.co]), delete out.co
63
+ if ("l" in out) (out.label = S[out.l]), delete out.l
64
+ if ("p" in out) (out.path = P[out.p]), delete out.p
65
+ return out
66
+ })
67
+ }
68
+ delete o.paths
69
+ delete o.strs
70
+ o.v = 1
71
+ o.events = o.events || []
72
+ o.clips = o.clips || []
73
+ return o
74
+ }
75
+
76
+ // ---- elements --------------------------------------------------------------------------
77
+ // Clip selectors and note selectors are built the same way (an id, else
78
+ // tag.class chains with :nth-of-type), but classes change over time
79
+ // (".off" → ".on"), so they're also compared without classes.
80
+ const bare = (sel) => String(sel || "").replace(/::?(before|after|marker|placeholder|backdrop)$/, "").replace(/\.[\w-]+(?:\\.[\w-]*)*/g, "")
81
+ const under = (a, b) => a.startsWith(b + " > ") // a is inside b
82
+ /** How a clip's element relates to the noted one: "on" | "inside" | "around" | null. */
83
+ export function relation(clipSel, noteSel) {
84
+ if (!clipSel || !noteSel) return null
85
+ const c = String(clipSel).replace(/::?(before|after|marker|placeholder|backdrop)$/, "")
86
+ const n = String(noteSel)
87
+ if (c === n || bare(c) === bare(n)) return "on"
88
+ if (under(c, n) || under(bare(c), bare(n))) return "inside"
89
+ if (under(n, c) || under(bare(n), bare(c))) {
90
+ const depth = bare(n).slice(bare(c).length).split(" > ").length - 1
91
+ return depth <= 4 ? "around" : null
92
+ }
93
+ return null
94
+ }
95
+
96
+ // ---- what happened -------------------------------------------------------------------
97
+ const SECRET = /pass|pwd|secret|token|card|cvv|cvc|ssn|otp/i
98
+ const MODS = ["ctrlKey", "metaKey", "altKey", "shiftKey"]
99
+ const NAMED_KEYS = /^(Enter|Escape|Tab|Backspace|Delete|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Home|End|PageUp|PageDown| )$/
100
+ const quote = (s, n = 40) => {
101
+ const t = String(s ?? "").replace(/\s+/g, " ").trim()
102
+ return `"${t.length > n ? t.slice(0, n - 1) + "…" : t}"`
103
+ }
104
+ const pathOfUrl = (u) => {
105
+ try {
106
+ const x = new URL(u)
107
+ x.searchParams.delete("__wb")
108
+ return x.pathname + x.search + x.hash
109
+ } catch {
110
+ return String(u || "")
111
+ }
112
+ }
113
+
114
+ /** The user's actions, routes, page loads and requests, as [{ t, end?, kind, text, selector? }]. */
115
+ export function happenings(rec) {
116
+ const out = []
117
+ const start = rec.start || 0
118
+ /** @type {any} */
119
+ let burst = null
120
+ /** @type {any} */
121
+ let keys = null
122
+ /** @type {any} */
123
+ let scroll = null
124
+ for (const ev of rec.events) {
125
+ if (ev.t == null || ev.t < start) continue
126
+ if (ev.type === "click") {
127
+ const prev = out[out.length - 1]
128
+ if (prev && prev.kind === "focus" && prev.selector === ev.css && ev.t - prev.t < 1000) out.pop()
129
+ out.push({ t: ev.t, kind: "click", text: `click ${ev.label ? quote(ev.label) + " " : ""}(${ev.css || "?"})`, selector: ev.css })
130
+ } else if (ev.type === "submit") out.push({ t: ev.t, kind: "submit", text: `submit ${ev.css || "a form"}`, selector: ev.css })
131
+ else if (ev.type === "focusin" && ev.editable) {
132
+ const prev = out[out.length - 1]
133
+ if (!(prev && prev.kind === "click" && prev.selector === ev.css && ev.t - prev.t < 50)) out.push({ t: ev.t, kind: "focus", text: `focus ${ev.label ? quote(ev.label) + " " : ""}(${ev.css || "a field"})`, selector: ev.css })
134
+ } else if (ev.type === "input") {
135
+ const typed = /^insert/.test(ev.inputType || "insertText") ? (ev.data ? ev.data.length : 1) : 0
136
+ const hide = SECRET.test(`${ev.css || ""} ${ev.label || ""}`)
137
+ if (burst && burst.selector === ev.css && ev.t - burst.end < 800) {
138
+ burst.end = ev.t
139
+ burst.chars += typed
140
+ } else {
141
+ burst = { t: ev.t, end: ev.t, kind: "type", chars: typed, selector: ev.css, hide }
142
+ out.push(burst)
143
+ }
144
+ burst.value = ev.value
145
+ } else if (ev.type === "change" && (ev.checked != null || /select/i.test(ev.css || ""))) {
146
+ const text = ev.checked != null ? `${ev.checked ? "checked" : "unchecked"} ${ev.css || "a box"}` : `chose ${quote(ev.value)} in ${ev.css}`
147
+ out.push({ t: ev.t, kind: "change", text, selector: ev.css })
148
+ } else if (ev.type === "keydown" && !ev.inField && ev.key && (NAMED_KEYS.test(ev.key) || MODS.some((m) => ev[m]))) {
149
+ if (/^(Control|Meta|Alt|Shift)$/.test(ev.key)) continue
150
+ const name = [...MODS.filter((m) => ev[m]).map((m) => ({ ctrlKey: "Ctrl", metaKey: "⌘", altKey: "Alt", shiftKey: "Shift" })[m]), ev.key === " " ? "Space" : ev.key].join("+")
151
+ if (keys && keys.name === name && ev.t - keys.end < 1000) {
152
+ keys.end = ev.t
153
+ keys.n++
154
+ } else out.push((keys = { t: ev.t, end: ev.t, kind: "key", name, n: 1 }))
155
+ } else if (ev.type === "nav") out.push({ t: ev.t, kind: "nav", text: `back/forward to ${pathOfUrl(ev.url)}` })
156
+ else if (ev.type === "scroll") {
157
+ const where = ev.path === "d" ? "the page" : "an element"
158
+ if (scroll && scroll.where === where && ev.t - scroll.end < 600) {
159
+ scroll.end = ev.t
160
+ scroll.top = ev.top
161
+ } else out.push((scroll = { t: ev.t, end: ev.t, kind: "scroll", where, top: ev.top }))
162
+ }
163
+ }
164
+ for (const h of out) {
165
+ if (h.kind === "type") {
166
+ h.text = `typed ${h.chars} char${h.chars === 1 ? "" : "s"} in ${h.selector || "a field"}${h.hide || h.value == null || /</.test(h.value) ? "" : ` (now ${quote(h.value)})`}`
167
+ delete h.value
168
+ } else if (h.kind === "key") h.text = `key ${h.name}${h.n > 1 ? ` ×${h.n}` : ""}`
169
+ else if (h.kind === "scroll") h.text = `scrolled ${h.where} to ${Math.round(h.top || 0)}px`
170
+ if (h.end === h.t) delete h.end
171
+ }
172
+ for (const r of rec.routes || []) if (r.t >= start && r.t > 0) out.push({ t: r.t, kind: "route", text: `route → ${r.path}` })
173
+ for (const s of rec.segments || []) out.push({ t: s.t, kind: "load", text: `page loaded: ${pathOfUrl(s.url)}` })
174
+ out.push(...requests(rec))
175
+ // At the same moment, what the user did comes before what it set off.
176
+ const rank = (h) => (h.kind === "request" ? 2 : h.kind === "route" || h.kind === "load" ? 1 : 0)
177
+ return out.sort((a, b) => a.t - b.t || rank(a) - rank(b))
178
+ }
179
+
180
+ // Recorded requests: when they went out, when they finished, how they ended.
181
+ function requests(rec) {
182
+ const ends = new Map()
183
+ for (const ev of rec.events) if (ev.type === "net" && (ev.kind === "end" || ev.kind === "done" || ev.kind === "close")) ends.set(`${ev.list}:${ev.i}`, ev.t)
184
+ const out = []
185
+ const kinds = { fetches: "", xhrs: "XHR ", sses: "EventSource ", sockets: "WebSocket " }
186
+ for (const list of Object.keys(kinds)) {
187
+ ;(rec[list] || []).forEach((e, i) => {
188
+ if (!e || e.t0 == null) return
189
+ const key = String(e.key || "").replace(/ (rsc|next-router-[\w-]+|next-action)=\S+/g, (m) => (/next-action/.test(m) ? " (server action)" : /rsc/.test(m) ? " (RSC)" : ""))
190
+ const end = ends.get(`${list}:${i}`)
191
+ const how = e.fail ? `failed (${e.fail})` : e.error ? "failed" : e.status ? `${e.status}` : list === "fetches" || list === "xhrs" ? "no answer recorded" : "opened"
192
+ out.push({ t: e.t0, end, kind: "request", text: `${kinds[list]}${key} → ${how}${end != null && end > e.t0 ? ` (${dur(end - e.t0)})` : ""}` })
193
+ })
194
+ }
195
+ return out
196
+ }
197
+
198
+ // "css-animation `fade-in` (opacity, transform)", "opacity transition"
199
+ function clipName(c) {
200
+ const what = c.kind === "transition" ? `${c.property || "?"} transition` : c.kind === "css-animation" ? `animation \`${c.label}\`${c.property ? ` (${c.property})` : ""}` : `animation (${c.property || c.label || "waapi"})`
201
+ return what
202
+ }
203
+ function values(c) {
204
+ if (!c.from || !c.to) return ""
205
+ const keys = [...new Set([...Object.keys(c.from), ...Object.keys(c.to)])]
206
+ return keys.map((k) => `${k} ${c.from[k] ?? "?"} → ${c.to[k] ?? "?"}`).join(", ")
207
+ }
208
+ const clipEnd = (c, rec) => (c.end == null ? Math.max(rec.end || 0, c.start) : c.end)
209
+
210
+ function clipLine(c, rec, at) {
211
+ const s = rec.start || 0
212
+ const loops = c.iterations === "infinite"
213
+ const span = c.end == null ? `${fmt(c.start - s)} → ${loops ? `looping${c.dur ? ` (${dur(c.dur)} each)` : ""}` : "still running"}` : `${fmt(c.start - s)} → ${fmt(c.end - s)} (${dur(c.end - c.start)}${c.iterations > 1 ? `, ${c.iterations}×` : ""})`
214
+ const bits = [`${span} ${clipName(c)} on ${c.selector || "?"}${c.component ? ` [${c.component}]` : ""}`]
215
+ const v = values(c)
216
+ if (v) bits.push(v)
217
+ if (c.delay) bits.push(`after a ${dur(c.delay)} delay`)
218
+ if (at != null) {
219
+ const end = clipEnd(c, rec)
220
+ if (at < c.start) bits.push(`starts ${dur(c.start - at)} after the moment`)
221
+ else if (at > end) bits.push(`ended ${dur(at - end)} before the moment`)
222
+ else if (c.end != null && c.end > c.start) bits.push(`running at the moment, ${Math.round(((at - c.start) / (c.end - c.start)) * 100)}% through`)
223
+ else bits.push("running at the moment")
224
+ }
225
+ return `- ${bits.join("; ")} (clip ${c.id})`
226
+ }
227
+
228
+ /**
229
+ * The report. Either a moment (`at`, with a window around it) or a window.
230
+ * @param {any} rec a readRecording() result
231
+ * @param {{ at?: number | null, from?: number | null, to?: number | null, selector?: string | null, component?: string | null,
232
+ * title?: string, limit?: number, known?: { rel: Map<string, string>, running: string[] } | null }} opts times in the recording's own clock (ms);
233
+ * `known`: a note's own word on which clips are its element's (by recorded path) and what was running, used instead of selectors
234
+ */
235
+ export function report(rec, { at = null, from = null, to = null, selector = null, component = null, title = "", limit = 40, known = null } = {}) {
236
+ const s = rec.start || 0
237
+ const end = Math.max(rec.end || 0, s)
238
+ const lo = Math.max(s, from ?? s)
239
+ const hi = Math.min(end, to ?? end)
240
+ const lines = []
241
+ if (title) lines.push(title)
242
+ lines.push(`Window: ${fmt(lo - s)} → ${fmt(hi - s)} of a ${fmt(end - s)} recording${at != null ? `; the moment is ${fmt(at - s)}` : ""}.`)
243
+
244
+ // Animations on or near the element.
245
+ const overlaps = (c) => c.start <= hi && clipEnd(c, rec) >= lo
246
+ if (selector) {
247
+ const relOf = (c) => (known ? known.rel.get(String(c.id)) || null : relation(c.selector, selector) || (component && c.component === component ? "same component" : null))
248
+ const near = rec.clips.map((c) => ({ c, rel: relOf(c) })).filter((x) => x.rel)
249
+ const inWin = near.filter((x) => overlaps(x.c))
250
+ lines.push("", `Animations on or near ${selector}${component ? ` [${component}]` : ""}:`)
251
+ if (!inWin.length) lines.push("- none in this window")
252
+ const order = { on: 0, inside: 1, around: 2, "same component": 3 }
253
+ inWin.sort((a, b) => (order[a.rel] ?? 0.5) - (order[b.rel] ?? 0.5) || a.c.start - b.c.start)
254
+ for (const { c, rel } of inWin.slice(0, 12)) lines.push(clipLine(c, rec, at).replace(/^- /, `- (${rel}) `))
255
+ if (inWin.length > 12) lines.push(`- …and ${inWin.length - 12} more`)
256
+ const outside = near.filter((x) => !overlaps(x.c) && x.rel === "on")
257
+ if (outside.length) lines.push(`Outside the window this element also animates at: ${outside.slice(0, 6).map(({ c }) => `${fmt(c.start - s)} (${c.kind === "transition" ? `${c.property} transition` : c.label})`).join(", ")}${outside.length > 6 ? `, +${outside.length - 6} more` : ""}.`)
258
+ if (known) lines.push(known.running.length ? `At the note's moment it is mid-animation (the note says): ${known.running.join(", ")}.` : "At the note's moment nothing on it is animating (the note says so; see above for what came before and after).")
259
+ else if (at != null) {
260
+ const running = inWin.filter(({ c, rel }) => (rel === "on" || rel === "inside") && c.start <= at && at <= clipEnd(c, rec))
261
+ lines.push(running.length ? `At the moment it is mid-animation: ${running.map(({ c }) => clipName(c)).join(", ")}.` : "At the moment nothing on it is animating (see above for what came before and after).")
262
+ }
263
+ }
264
+
265
+ // What happened, in order.
266
+ const all = happenings(rec).filter((h) => h.t <= hi && (h.end ?? h.t) >= lo)
267
+ let shown = all
268
+ let dropped = 0
269
+ if (all.length > limit) {
270
+ const ref = at ?? (lo + hi) / 2
271
+ shown = [...all].sort((a, b) => Math.abs(a.t - ref) - Math.abs(b.t - ref)).slice(0, limit).sort((a, b) => a.t - b.t)
272
+ dropped = all.length - shown.length
273
+ }
274
+ lines.push("", "What happened (user actions, routes, page loads, requests):")
275
+ /** @type {Array<{ t: number, line: string, moment?: boolean }>} */
276
+ const marks = shown.map((h) => ({ t: h.t, line: `- ${fmt(h.t - s)}${h.end != null && h.kind !== "request" ? `–${fmt(h.end - s)}` : ""} ${h.text}` }))
277
+ if (at != null) marks.push({ t: at, line: `- ${fmt(at - s)} ◆ the moment`, moment: true })
278
+ marks.sort((a, b) => a.t - b.t || (a.moment ? 1 : b.moment ? -1 : 0))
279
+ if (!shown.length) lines.push("- nothing recorded in this window")
280
+ lines.push(...marks.map((m) => m.line))
281
+ if (dropped) lines.push(`(${dropped} more not shown, the furthest from ${at != null ? "the moment" : "the middle"}; ask for a narrower window)`)
282
+ if (at != null) {
283
+ const acts = happenings(rec).filter((h) => ["click", "submit", "type", "key", "change", "focus", "nav"].includes(h.kind))
284
+ const before = acts.filter((h) => h.t <= at).pop()
285
+ const after = acts.find((h) => h.t > at)
286
+ lines.push(`Last action before the moment: ${before ? `${before.text} at ${fmt(before.t - s)} (${dur(at - before.t)} earlier)` : "none"}. Next after: ${after ? `${after.text} at ${fmt(after.t - s)}` : "none"}.`)
287
+ }
288
+
289
+ // Animations elsewhere on the page.
290
+ const others = rec.clips.filter((c) => overlaps(c) && !(selector && (known ? known.rel.has(String(c.id)) : relation(c.selector, selector))))
291
+ if (others.length) {
292
+ const groups = new Map()
293
+ for (const c of others) {
294
+ const key = `${clipName(c)} on ${c.selector}`
295
+ const g = groups.get(key) || { c, n: 0 }
296
+ g.n++
297
+ groups.set(key, g)
298
+ }
299
+ const list = [...groups.values()].sort((a, b) => (at != null ? Math.abs(a.c.start - at) - Math.abs(b.c.start - at) : a.c.start - b.c.start))
300
+ lines.push("", `${selector ? "Other animations" : "Animations"} in this window (${others.length}):`)
301
+ for (const g of list.slice(0, selector ? 6 : 15)) lines.push(clipLine(g.c, rec, at) + (g.n > 1 ? ` ×${g.n}` : ""))
302
+ if (list.length > (selector ? 6 : 15)) lines.push(`- …and ${list.length - (selector ? 6 : 15)} more kinds`)
303
+ }
304
+
305
+ // How much of the screen changed: the biggest moments.
306
+ const act = rec.activity
307
+ if (act && Array.isArray(act.v)) {
308
+ const peaks = act.v
309
+ .map((v, i) => ({ t: act.start + i * act.step, v }))
310
+ .filter((p) => p.t >= lo && p.t <= hi && p.v >= 10)
311
+ .sort((a, b) => b.v - a.v)
312
+ .slice(0, 3)
313
+ .sort((a, b) => a.t - b.t)
314
+ if (peaks.length) lines.push("", `Screen changed most at: ${peaks.map((p) => `${fmt(p.t - s)} (${p.v}% of the view)`).join(", ")}.`)
315
+ }
316
+ return lines.join("\n")
317
+ }
@@ -0,0 +1,192 @@
1
+ // A compiled bundle's line → the original file:line, through the bundle's
2
+ // source map. For notes whose source is a served chunk (Next/Turbopack and
3
+ // webpack chunks, Vite's optimized deps, a production-style bundle) rather
4
+ // than the app's own file. No dependencies: a VLQ decoder, and index maps
5
+ // (`sections`, as Turbopack writes them) read section by section.
6
+ // The dock has the same logic in src/shell/30-notes.js (it can't import).
7
+
8
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
9
+
10
+ /** "mappings" → per generated line, segments [genCol, sourceIndex, origLine, origCol] (0-based). */
11
+ export function decodeMappings(mappings) {
12
+ const lines = []
13
+ let src = 0, oLine = 0, oCol = 0
14
+ for (const text of String(mappings || "").split(";")) {
15
+ const segs = []
16
+ let gCol = 0
17
+ for (const seg of text.split(",")) {
18
+ if (!seg) continue
19
+ const v = []
20
+ for (let i = 0, shift = 0, acc = 0; i < seg.length; i++) {
21
+ const d = B64.indexOf(seg[i])
22
+ if (d < 0) break
23
+ acc += (d & 31) << shift
24
+ if (d & 32) shift += 5
25
+ else {
26
+ v.push(acc & 1 ? -(acc >> 1) : acc >> 1)
27
+ acc = shift = 0
28
+ }
29
+ }
30
+ gCol += v[0] || 0
31
+ if (v.length >= 4) {
32
+ src += v[1]
33
+ oLine += v[2]
34
+ oCol += v[3]
35
+ segs.push([gCol, src, oLine, oCol])
36
+ }
37
+ }
38
+ lines.push(segs)
39
+ }
40
+ return lines
41
+ }
42
+
43
+ const decoded = new WeakMap()
44
+ /**
45
+ * Where generated line/column (1-based; col may be null) came from.
46
+ * @returns {{ source: string, line: number, sources: string[] } | null}
47
+ */
48
+ export function originalPosition(map, line, col) {
49
+ if (!map || !(line > 0)) return null
50
+ if (Array.isArray(map.sections)) {
51
+ const L = line - 1
52
+ const C = col > 0 ? col - 1 : null
53
+ /** @type {any} */
54
+ let hit = null
55
+ for (const s of map.sections) {
56
+ const o = s.offset || { line: 0, column: 0 }
57
+ if (o.line < L || (o.line === L && (C == null || o.column <= C))) hit = s
58
+ else break
59
+ }
60
+ if (!hit || !hit.map) return null
61
+ const o = hit.offset || { line: 0, column: 0 }
62
+ const c = C == null ? null : (L === o.line ? C - o.column : C) + 1
63
+ const pos = originalPosition(hit.map, L - o.line + 1, c)
64
+ return pos && { ...pos, sources: map.sections.flatMap((x) => (x.map && x.map.sources) || []) }
65
+ }
66
+ let lines = decoded.get(map)
67
+ if (!lines) decoded.set(map, (lines = decodeMappings(map.mappings)))
68
+ const segs = lines[line - 1]
69
+ if (!segs || !segs.length) return null
70
+ let best = segs[0]
71
+ if (col > 0) for (const s of segs) if (s[0] <= col - 1) best = s
72
+ const sources = (map.sources || []).map((s) => (map.sourceRoot && s && !/^[a-z]+:/i.test(s) && !s.startsWith("/") ? map.sourceRoot.replace(/\/?$/, "/") + s : s))
73
+ const source = sources[best[1]]
74
+ return source ? { source, line: best[2] + 1, sources } : null
75
+ }
76
+
77
+ /** Library code (not where the app's own element was written). */
78
+ export const isLibrary = (file) => /(^|\/)node_modules\/|\/\.vite\/deps\/|react-dom|jsx-dev-runtime|jsx-runtime|\[turbopack\]|turbopack\/|webpack\/(bootstrap|runtime)/.test(String(file || ""))
79
+
80
+ /**
81
+ * A served bundle rather than a source file: Next's /_next/static chunks,
82
+ * webpack/Turbopack chunk names, Vite's deps, hashed build assets.
83
+ */
84
+ export function isCompiled(file) {
85
+ const f = String(file || "").replace(/[?#].*$/, "")
86
+ return (
87
+ /\/_next\/static\/|\/\.vite\/deps\/|\/node_modules\/\.vite\//.test(f) ||
88
+ /\._\.js$|[-_.](?=[0-9a-z]*\d)[0-9a-z]{6,}\.(m?js|cjs)$/i.test(f.split("/").pop() || "") ||
89
+ /\/assets\/[^/]+-[\w-]{8}\.js$/.test(f)
90
+ )
91
+ }
92
+
93
+ /**
94
+ * A map's source name as a path: `webpack://app/./src/a.tsx` → `src/a.tsx`,
95
+ * `turbopack:///[project]/app/page.tsx` → `app/page.tsx`, `file:///x/y` → `/x/y`,
96
+ * then relative to `root` when it's inside it. Without a root, an absolute path
97
+ * is made relative to the project the map's own node_modules sources sit in.
98
+ * @param {string} name
99
+ * @param {{ root?: string | null, sources?: string[] }} [options]
100
+ */
101
+ export function cleanSource(name, { root = null, sources = [] } = {}) {
102
+ let p = String(name || "").replace(/[?#].*$/, "")
103
+ p = p.replace(/^webpack:\/\/[^/]*\//, "").replace(/^turbopack:\/\/\/?(\[project\]\/)?/, "").replace(/^\[project\]\//, "")
104
+ if (p.startsWith("file://")) {
105
+ try {
106
+ p = decodeURIComponent(new URL(p).pathname)
107
+ } catch {
108
+ p = p.slice(7)
109
+ }
110
+ }
111
+ p = p.replace(/^\.\//, "")
112
+ if (p.startsWith("/")) {
113
+ const roots = []
114
+ if (root) roots.push(String(root).replace(/\/$/, ""), "/private" + String(root).replace(/\/$/, ""))
115
+ for (const s of sources) {
116
+ const m = /^(?:file:\/\/)?(\/.*?)\/node_modules\//.exec(String(s || ""))
117
+ if (m) roots.push(m[1])
118
+ }
119
+ for (const r of roots) if (r && p.startsWith(r + "/")) return p.slice(r.length + 1)
120
+ }
121
+ return p
122
+ }
123
+
124
+ /** The map's URL for a bundle: a SourceMap header, its last sourceMappingURL comment, else `<url>.map`. */
125
+ export function mapUrlOf(code, url, headers) {
126
+ const h = headers && (headers.get("sourcemap") || headers.get("x-sourcemap"))
127
+ if (h) return new URL(h, url).href
128
+ const all = [...String(code).matchAll(/\/\/[#@] sourceMappingURL=(\S+)/g)]
129
+ if (all.length) {
130
+ const ref = all[all.length - 1][1]
131
+ return ref.startsWith("data:") ? ref : new URL(ref, url).href
132
+ }
133
+ return null
134
+ }
135
+
136
+ function parseDataUrl(ref) {
137
+ const comma = ref.indexOf(",")
138
+ const body = ref.slice(comma + 1)
139
+ return /;base64/.test(ref.slice(0, comma)) ? Buffer.from(body, "base64").toString("utf8") : decodeURIComponent(body)
140
+ }
141
+
142
+ /**
143
+ * Loads `url`'s source map. Resolves to the parsed map, or null.
144
+ * @param {string} url
145
+ * @param {{ fetch?: typeof fetch, timeoutMs?: number }} [options]
146
+ */
147
+ export async function loadSourceMap(url, { fetch: get = fetch, timeoutMs = 5000 } = {}) {
148
+ const ac = new AbortController()
149
+ const timer = setTimeout(() => ac.abort(), timeoutMs)
150
+ try {
151
+ const res = await get(url, { signal: ac.signal })
152
+ if (!res.ok) return null
153
+ const code = await res.text()
154
+ let ref = mapUrlOf(code, url, res.headers)
155
+ const tries = ref ? [ref] : [url.replace(/[?#].*$/, "") + ".map"]
156
+ for (const r of tries) {
157
+ try {
158
+ if (r.startsWith("data:")) return JSON.parse(parseDataUrl(r))
159
+ const m = await get(r, { signal: ac.signal })
160
+ if (m.ok) return JSON.parse(await m.text())
161
+ } catch {}
162
+ }
163
+ return null
164
+ } catch {
165
+ return null
166
+ } finally {
167
+ clearTimeout(timer)
168
+ }
169
+ }
170
+
171
+ /**
172
+ * A compiled source { file, line, col? } → { file, line, from } in the app's
173
+ * own code, or { library } when the line is library code, or null.
174
+ * @param {{ file: string, line?: number | null, col?: number | null, url?: string }} src
175
+ * @param {{ base: string, root?: string | null, fetch?: typeof fetch }} options
176
+ */
177
+ export async function resolveSource(src, { base, root = null, fetch: get = fetch }) {
178
+ if (!src || !src.file || !((src.line ?? 0) > 0)) return null
179
+ let url
180
+ try {
181
+ url = src.url && /^https?:/.test(src.url) ? new URL(new URL(src.url).pathname + new URL(src.url).search, base.replace(/\/?$/, "/")).href : new URL(src.file, base.replace(/\/?$/, "/")).href
182
+ } catch {
183
+ return null
184
+ }
185
+ const map = await loadSourceMap(url, { fetch: get })
186
+ if (!map) return null
187
+ const pos = originalPosition(map, src.line, src.col || null)
188
+ if (!pos) return null
189
+ const file = cleanSource(pos.source, { root, sources: pos.sources })
190
+ if (isLibrary(file)) return { library: file, line: pos.line }
191
+ return { file, line: pos.line, from: `${src.file}:${src.line}` }
192
+ }
@@ -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