dsh-browser-playwright-codex 0.8.0 → 0.8.2

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,3864 @@
1
+ /*
2
+ * Swarm Rebuild
3
+ *
4
+ * Snapshots the current page into a blueprint (boxes, text blocks, media,
5
+ * borders, repeated components), blanks the page, then lets a swarm of
6
+ * multiplayer cursors rebuild it on a canvas: crosshair-drawn rects, typed
7
+ * text, pen-tool lines, ruler guides with snapping, alt-drag duplicates.
8
+ *
9
+ * Injected by background.js; injecting again toggles it off.
10
+ */
11
+ (() => {
12
+ "use strict";
13
+
14
+ const KEY = "__swarmRebuild";
15
+ if (window[KEY]) {
16
+ window[KEY].toggle();
17
+ return;
18
+ }
19
+
20
+ // ---------------------------------------------------------------- constants
21
+
22
+ const NAMES = [
23
+ "Ada", "Linus", "Grace", "Alan", "Margaret", "Ken", "Barbara", "Dennis",
24
+ "Radia", "Guido", "Frances", "Bjarne", "Hedy", "Tim", "Katherine", "Edsger",
25
+ "Joan", "Donald", "Sophie", "Niklaus", "Anita", "Brian", "Mary", "Rob",
26
+ "Ida", "Yukihiro", "Shafi", "Leslie", "Jean", "Vint", "Lynn", "Bram",
27
+ ];
28
+ const COLORS = [
29
+ "#F24E1E", "#A259FF", "#1ABCFE", "#0ACF83", "#FF7262", "#FFC700",
30
+ "#9747FF", "#E8467C", "#14AE5C", "#0D99FF", "#FF8A00", "#00B5CE",
31
+ ];
32
+ const BLUE = "#0D99FF";
33
+ const RED = "#F24822";
34
+ const RULER = 20;
35
+ const MIN_AGENTS = 8;
36
+ const MAX_AGENTS = 48;
37
+ const FLOCK_AGENTS = 36;
38
+ const INTRO_AGENTS = 18;
39
+ const MAX_ITEMS = 4000;
40
+ const MAX_WORLD_H = 40000;
41
+ const SVGNS = "http://www.w3.org/2000/svg";
42
+
43
+ const rand = (a, b) => a + Math.random() * (b - a);
44
+ const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
45
+ const easeInOut = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
46
+ const easeOut = (t) => 1 - Math.pow(1 - t, 3);
47
+ const bump = (it) => {
48
+ it.v++;
49
+ };
50
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
51
+ const nextFrame = () => new Promise((r) => requestAnimationFrame(() => r()));
52
+ const f1 = (n) => Math.round(n * 10) / 10;
53
+
54
+ function alphaOf(c) {
55
+ if (!c || c === "transparent") return 0;
56
+ const slash = c.match(/\/\s*([\d.]+%?)\s*\)/);
57
+ if (slash) return slash[1].endsWith("%") ? parseFloat(slash[1]) / 100 : parseFloat(slash[1]);
58
+ const m = c.match(/^rgba\(([^)]+)\)/);
59
+ if (m) {
60
+ const p = m[1].split(",");
61
+ return p.length >= 4 ? parseFloat(p[3]) : 1;
62
+ }
63
+ return 1;
64
+ }
65
+
66
+ function intersect(a, b) {
67
+ if (!a || !b) return null;
68
+ const l = Math.max(a.l, b.l);
69
+ const t = Math.max(a.t, b.t);
70
+ const r = Math.min(a.r, b.r);
71
+ const bt = Math.min(a.b, b.b);
72
+ return r > l && bt > t ? { l, t, r, b: bt } : null;
73
+ }
74
+
75
+ const ltrb = (r) => ({ l: r.left, t: r.top, r: r.right, b: r.bottom });
76
+
77
+ // ----------------------------------------------------------- cursor shapes
78
+
79
+ const ARROW_D =
80
+ "M4.6 3.9 C4.2 3.1 4.9 2.4 5.7 2.8 L20.6 9.4 C21.5 9.8 21.4 11.1 20.4 11.3 L13.9 12.9 C13.4 13 13 13.4 12.9 13.9 L11.3 20.4 C11.1 21.4 9.8 21.5 9.4 20.6 Z";
81
+ const HOT = {
82
+ arrow: [5, 3.5],
83
+ dup: [5, 3.5],
84
+ pen: [3, 3],
85
+ "pen-add": [3, 3],
86
+ "pen-close": [3, 3],
87
+ cross: [12, 12],
88
+ ibeam: [12, 12],
89
+ ew: [12, 12],
90
+ ns: [12, 12],
91
+ };
92
+
93
+ function cursorSVG(shape, c) {
94
+ const st = 'stroke="#fff" stroke-width="1.5" stroke-linejoin="round"';
95
+ switch (shape) {
96
+ case "arrow":
97
+ return `<path d="${ARROW_D}" fill="${c}" ${st}/>`;
98
+ case "dup":
99
+ return (
100
+ `<path d="${ARROW_D}" transform="translate(4 3.5)" fill="#fff" stroke="${c}" stroke-width="1.3" stroke-linejoin="round"/>` +
101
+ `<path d="${ARROW_D}" fill="${c}" ${st}/>`
102
+ );
103
+ case "pen":
104
+ case "pen-add":
105
+ case "pen-close": {
106
+ let s =
107
+ `<path d="M17.4 10.6 L20.6 13.8 L13.8 20.6 L10.6 17.4 Z" fill="${c}" ${st}/>` +
108
+ `<path d="M3 3 L13.1 6.3 L17.4 10.6 L10.6 17.4 L6.3 13.1 Z" fill="${c}" ${st}/>` +
109
+ `<path d="M3.6 3.6 L8.9 8.9" stroke="#fff" stroke-width="1.2" stroke-linecap="round"/>` +
110
+ `<circle cx="10.2" cy="10.2" r="1.75" fill="none" stroke="#fff" stroke-width="1.2"/>`;
111
+ if (shape === "pen-add")
112
+ s +=
113
+ `<g stroke-linecap="round"><path d="M23 19.5 V26.5 M19.5 23 H26.5" stroke="#fff" stroke-width="3.4"/>` +
114
+ `<path d="M23 19.5 V26.5 M19.5 23 H26.5" stroke="#1E1E1E" stroke-width="1.3"/></g>`;
115
+ if (shape === "pen-close")
116
+ s +=
117
+ `<circle cx="23" cy="23" r="2.8" fill="none" stroke="#fff" stroke-width="3.2"/>` +
118
+ `<circle cx="23" cy="23" r="2.8" fill="none" stroke="#1E1E1E" stroke-width="1.2"/>`;
119
+ return s;
120
+ }
121
+ case "cross":
122
+ return (
123
+ `<g stroke-linecap="round"><path d="M12 3.5 V20.5 M3.5 12 H20.5" stroke="#fff" stroke-width="3.6"/>` +
124
+ `<path d="M12 3.5 V20.5 M3.5 12 H20.5" stroke="${c}" stroke-width="1.5"/></g>`
125
+ );
126
+ case "ibeam": {
127
+ const d =
128
+ "M8.5 4.5 H10.5 Q12 4.5 12 6 Q12 4.5 13.5 4.5 H15.5 M12 6 V18 M8.5 19.5 H10.5 Q12 19.5 12 18 Q12 19.5 13.5 19.5 H15.5";
129
+ return (
130
+ `<g fill="none" stroke-linecap="round"><path d="${d}" stroke="#fff" stroke-width="3.6"/>` +
131
+ `<path d="${d}" stroke="${c}" stroke-width="1.5"/></g>`
132
+ );
133
+ }
134
+ case "ew":
135
+ case "ns":
136
+ return `<path d="M2.5 12 L7 7.5 V10.4 H17 V7.5 L21.5 12 L17 16.5 V13.6 H7 V16.5 Z"${
137
+ shape === "ns" ? ' transform="rotate(90 12 12)"' : ""
138
+ } fill="${c}" ${st}/>`;
139
+ }
140
+ return "";
141
+ }
142
+
143
+ const IMG_ICON =
144
+ '<svg class="ph" viewBox="0 0 24 24" fill="none" stroke="#8A8A8A" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="1.8"/><path d="M4 18 L10 13 L14 16 L17 13.5 L21 17"/></svg>';
145
+
146
+ // ------------------------------------------------------------------ capture
147
+
148
+ const SKIP = new Set([
149
+ "script", "style", "noscript", "template", "head", "meta", "link", "title",
150
+ "base", "datalist", "area", "map", "source", "track", "param", "swarm-rebuild",
151
+ ]);
152
+ const DROP_IN_CLONE = new Set(["script", "style", "noscript", "template", "iframe", "object", "embed", "link", "meta"]);
153
+ const REPLACED = new Set(["img", "svg", "video", "canvas", "iframe", "embed", "object", "picture"]);
154
+ const FORMS = new Set(["input", "textarea", "select"]);
155
+ const ATOMIC = new Set(["img", "svg", "video", "canvas", "input", "select", "textarea", "iframe", "picture", "button"]);
156
+
157
+ const TEXT_PROPS = [
158
+ "color", "font-family", "font-size", "font-weight", "font-style", "font-variant",
159
+ "font-stretch", "font-feature-settings", "font-variation-settings", "font-kerning",
160
+ "font-optical-sizing", "line-height", "letter-spacing", "word-spacing",
161
+ "text-transform", "text-align", "text-indent", "text-decoration-line",
162
+ "text-decoration-color", "text-decoration-style", "text-decoration-thickness",
163
+ "text-underline-offset", "text-shadow", "white-space", "white-space-collapse",
164
+ "text-wrap-mode", "text-wrap-style", "direction", "overflow-wrap", "word-break",
165
+ "hyphens", "tab-size", "-webkit-text-fill-color", "-webkit-text-stroke-width",
166
+ "-webkit-text-stroke-color", "text-rendering", "-webkit-font-smoothing",
167
+ "list-style-type", "list-style-position", "list-style-image", "writing-mode",
168
+ ];
169
+ const BOX_PROPS = [
170
+ "display", "box-sizing", "width", "height", "min-width", "min-height", "max-width",
171
+ "max-height", "padding-top", "padding-right", "padding-bottom", "padding-left",
172
+ "margin-top", "margin-right", "margin-bottom", "margin-left", "border-top-width",
173
+ "border-right-width", "border-bottom-width", "border-left-width", "border-top-style",
174
+ "border-right-style", "border-bottom-style", "border-left-style", "border-top-color",
175
+ "border-right-color", "border-bottom-color", "border-left-color",
176
+ "border-top-left-radius", "border-top-right-radius", "border-bottom-right-radius",
177
+ "border-bottom-left-radius", "background-color", "background-image",
178
+ "background-size", "background-position", "background-repeat", "background-clip",
179
+ "-webkit-background-clip", "box-shadow", "opacity", "vertical-align", "position",
180
+ "top", "right", "bottom", "left", "flex-direction", "flex-wrap", "align-items",
181
+ "justify-content", "gap", "flex-grow", "flex-shrink", "flex-basis", "align-self",
182
+ "object-fit", "object-position", "overflow-x", "overflow-y", "text-overflow",
183
+ "-webkit-line-clamp", "-webkit-box-orient", "visibility", "transform", "filter",
184
+ "float", "fill", "stroke", "clip-path", "mix-blend-mode",
185
+ "-webkit-mask-image", "-webkit-mask-size", "-webkit-mask-position", "-webkit-mask-repeat",
186
+ ];
187
+ const ELEM_PROPS = [...TEXT_PROPS, ...BOX_PROPS];
188
+ const SVG_PROPS = [
189
+ "fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin",
190
+ "stroke-dasharray", "stroke-dashoffset", "stroke-miterlimit", "fill-opacity",
191
+ "stroke-opacity", "fill-rule", "clip-rule", "opacity", "color", "stop-color",
192
+ "stop-opacity", "display", "visibility", "font-family", "font-size", "font-weight",
193
+ "text-anchor", "dominant-baseline", "letter-spacing", "mix-blend-mode", "filter",
194
+ ];
195
+ const SVG_OUTER_PROPS = [
196
+ "margin-top", "margin-right", "margin-bottom", "margin-left", "vertical-align", "display",
197
+ "position", "top", "right", "bottom", "left", "transform", "transform-origin",
198
+ "flex-shrink", "flex-grow", "align-self", "order", "overflow",
199
+ ];
200
+ const RECT_PROPS = [
201
+ "background-color", "background-image", "background-size", "background-position",
202
+ "background-repeat", "background-origin", "background-clip", "border-top-width",
203
+ "border-right-width", "border-bottom-width", "border-left-width", "border-top-style",
204
+ "border-right-style", "border-bottom-style", "border-left-style", "border-top-color",
205
+ "border-right-color", "border-bottom-color", "border-left-color",
206
+ "border-top-left-radius", "border-top-right-radius", "border-bottom-right-radius",
207
+ "border-bottom-left-radius", "box-shadow", "backdrop-filter", "filter", "clip-path",
208
+ "-webkit-mask-image", "-webkit-mask-size", "-webkit-mask-position", "-webkit-mask-repeat",
209
+ ];
210
+
211
+ function styleString(cs, props) {
212
+ let s = "";
213
+ for (const p of props) {
214
+ const v = cs.getPropertyValue(p);
215
+ if (v !== "") s += `${p}:${v};`;
216
+ }
217
+ return s;
218
+ }
219
+
220
+ const lhPx = (cs) => {
221
+ const v = parseFloat(cs.lineHeight);
222
+ return isNaN(v) ? (parseFloat(cs.fontSize) || 16) * 1.2 : v;
223
+ };
224
+
225
+ function kidsOf(el) {
226
+ if (el.shadowRoot) return [...el.shadowRoot.childNodes];
227
+ if (el.localName === "slot") {
228
+ const a = el.assignedNodes({ flatten: true });
229
+ return a.length ? a : [...el.childNodes];
230
+ }
231
+ return [...el.childNodes];
232
+ }
233
+
234
+ // Copy one element's computed style onto its clone, fix up media sources.
235
+ // ::before / ::after become real spans so icons, chevrons and separators survive.
236
+ function pseudoClone(src, which) {
237
+ const ps = getComputedStyle(src, which);
238
+ const c = ps.content;
239
+ if (!c || c === "none" || c === "normal" || ps.display === "none") return null;
240
+ const sp = document.createElement("span");
241
+ const str = c.match(/^"((?:[^"\\]|\\.)*)"/);
242
+ if (str) sp.textContent = str[1].replace(/\\(.)/g, "$1");
243
+ else if (/^url\(/.test(c)) {
244
+ const u = c.match(/^url\(\s*["']?([^"')]+)["']?\s*\)/);
245
+ if (u) {
246
+ const im = document.createElement("img");
247
+ im.src = u[1];
248
+ im.setAttribute("style", "display:inline-block;vertical-align:middle;");
249
+ sp.appendChild(im);
250
+ }
251
+ }
252
+ sp.setAttribute("style", styleString(ps, ELEM_PROPS));
253
+ return sp;
254
+ }
255
+
256
+ function addPseudos(src, dst) {
257
+ const b = pseudoClone(src, "::before");
258
+ if (b) dst.insertBefore(b, dst.firstChild);
259
+ const a = pseudoClone(src, "::after");
260
+ if (a) dst.appendChild(a);
261
+ }
262
+
263
+ function prepClone(src, dst) {
264
+ const tag = src.localName;
265
+ if (DROP_IN_CLONE.has(tag)) {
266
+ dst.remove();
267
+ return null;
268
+ }
269
+ const cs = getComputedStyle(src);
270
+ if (cs.display === "none") {
271
+ dst.remove();
272
+ return null;
273
+ }
274
+ const isSvg = src instanceof SVGElement;
275
+ let st = styleString(cs, isSvg ? SVG_PROPS : ELEM_PROPS);
276
+ if (isSvg && tag === "svg" && !src.ownerSVGElement) {
277
+ dst.setAttribute("width", src.clientWidth || src.getBoundingClientRect().width);
278
+ dst.setAttribute("height", src.clientHeight || src.getBoundingClientRect().height);
279
+ st += styleString(cs, SVG_OUTER_PROPS);
280
+ }
281
+ dst.setAttribute("style", st);
282
+ for (const a of [...dst.attributes]) {
283
+ const n = a.name;
284
+ if (n.startsWith("on") || n === "contenteditable" || n === "tabindex" || n === "autofocus" || n === "srcset" || n === "sizes" || n === "loading")
285
+ dst.removeAttribute(n);
286
+ }
287
+ if (tag === "img") {
288
+ dst.setAttribute("src", imgSrc(src));
289
+ dst.setAttribute("decoding", "async");
290
+ }
291
+ if (tag === "svg") fixSvgRefs(src, dst);
292
+ // Clones live in the page's document: custom elements would be upgraded by the
293
+ // site's own JS (and often wipe their content), media would play. Swap in inert stand-ins.
294
+ if (tag === "audio") {
295
+ dst.remove();
296
+ return null;
297
+ }
298
+ if (tag === "video" || tag === "canvas" || (!isSvg && tag.includes("-"))) {
299
+ let rep;
300
+ const vurl = tag === "video" ? videoUrl(src) : null;
301
+ if (vurl) {
302
+ rep = document.createElement("video");
303
+ for (const [k, v] of [["src", vurl], ["muted", ""], ["playsinline", ""], ["preload", "auto"]]) rep.setAttribute(k, v);
304
+ if (!src.paused || src.autoplay) rep.setAttribute("autoplay", "");
305
+ if (src.loop || src.autoplay || !src.paused) rep.setAttribute("loop", "");
306
+ if (src.poster) rep.setAttribute("poster", src.poster);
307
+ } else if (tag === "video" || tag === "canvas") {
308
+ let url = tag === "video" ? src.poster : canvasPixels(src);
309
+ rep = document.createElement(url ? "img" : "span");
310
+ if (url) rep.setAttribute("src", url);
311
+ else st += "background:#1b1b1f;";
312
+ } else {
313
+ rep = document.createElement("span");
314
+ while (dst.firstChild) rep.appendChild(dst.firstChild);
315
+ }
316
+ rep.setAttribute("style", st);
317
+ if (dst.parentNode) dst.replaceWith(rep);
318
+ dst = rep;
319
+ }
320
+ if (!isSvg && !REPLACED.has(tag) && !FORMS.has(tag) && tag !== "img") addPseudos(src, dst);
321
+ return dst;
322
+ }
323
+
324
+ // Clones are made in a document without a custom-element registry so the
325
+ // site's component code never runs on them.
326
+ let inertDoc = null;
327
+ const inert = () => inertDoc || (inertDoc = document.implementation.createHTMLDocument(""));
328
+
329
+ function cloneStyled(src) {
330
+ const dst = inert().importNode(src, true);
331
+ const a = [...src.querySelectorAll("*")];
332
+ const b = [...dst.querySelectorAll("*")];
333
+ const root = prepClone(src, dst) || document.createElement("span");
334
+ for (let i = 0; i < a.length && i < b.length; i++) prepClone(a[i], b[i]);
335
+ return root;
336
+ }
337
+
338
+ function cloneDescendantsStyled(src, dst) {
339
+ const a = src.querySelectorAll("*");
340
+ const b = dst.querySelectorAll("*");
341
+ for (let i = 0; i < a.length && i < b.length; i++) prepClone(a[i], b[i]);
342
+ }
343
+
344
+ function imgSrc(img) {
345
+ let s = img.currentSrc || img.src || "";
346
+ const lazy = img.getAttribute("data-src") || img.getAttribute("data-lazy-src") || img.getAttribute("data-original");
347
+ if (lazy && (!s || s.startsWith("data:") || !img.complete || !img.naturalWidth)) {
348
+ try {
349
+ s = new URL(lazy, location.href).href;
350
+ } catch (_) {}
351
+ }
352
+ return s;
353
+ }
354
+
355
+ // Shadow trees can't see document-level <symbol>/<linearGradient> ids, so
356
+ // inline anything the clone references.
357
+ function fixSvgRefs(src, dst) {
358
+ for (const u of dst.querySelectorAll("use")) {
359
+ const href = u.getAttribute("href") || u.getAttribute("xlink:href");
360
+ if (!href || href[0] !== "#") continue;
361
+ const ref = document.getElementById(href.slice(1));
362
+ if (!ref) continue;
363
+ const isSym = ref.localName === "symbol";
364
+ const g = document.createElementNS(SVGNS, isSym ? "svg" : "g");
365
+ if (isSym) {
366
+ for (const at of ["viewBox", "preserveAspectRatio"]) if (ref.getAttribute(at)) g.setAttribute(at, ref.getAttribute(at));
367
+ g.setAttribute("width", u.getAttribute("width") || "100%");
368
+ g.setAttribute("height", u.getAttribute("height") || "100%");
369
+ g.setAttribute("x", u.getAttribute("x") || 0);
370
+ g.setAttribute("y", u.getAttribute("y") || 0);
371
+ g.setAttribute("overflow", "visible");
372
+ } else if (u.getAttribute("x") || u.getAttribute("y")) {
373
+ g.setAttribute("transform", `translate(${u.getAttribute("x") || 0} ${u.getAttribute("y") || 0})`);
374
+ }
375
+ const us = u.getAttribute("style");
376
+ if (us) g.setAttribute("style", us);
377
+ for (const ch of ref.childNodes) {
378
+ if (ch.nodeType !== 1) {
379
+ g.appendChild(ch.cloneNode(true));
380
+ continue;
381
+ }
382
+ g.appendChild(cloneStyled(ch));
383
+ }
384
+ u.replaceWith(g);
385
+ }
386
+ const html = dst.outerHTML;
387
+ const ids = new Set();
388
+ html.replace(/url\(\s*["']?#([^"')\s]+)["']?\s*\)/g, (_, id) => ids.add(id));
389
+ if (!ids.size) return;
390
+ let defs = null;
391
+ for (const id of ids) {
392
+ if (dst.querySelector(`[id="${CSS.escape(id)}"]`)) continue;
393
+ const ref = document.getElementById(id);
394
+ if (!ref) continue;
395
+ if (!defs) {
396
+ defs = document.createElementNS(SVGNS, "defs");
397
+ dst.insertBefore(defs, dst.firstChild);
398
+ }
399
+ defs.appendChild(ref.cloneNode(true));
400
+ }
401
+ }
402
+
403
+ // Pixels for things the DOM can't give back (WebGL canvases, playing video,
404
+ // cross-origin iframes) come from a screenshot taken when the user clicked.
405
+ function cropShot(shot, r) {
406
+ if (!shot || !shot.valid) return null;
407
+ if (r.height > innerHeight * 3 || r.width > innerWidth * 2) return null;
408
+ const vis = intersect(ltrb(r), { l: 0, t: 0, r: innerWidth, b: innerHeight });
409
+ if (!vis) return null;
410
+ const k = shot.scale;
411
+ const c = document.createElement("canvas");
412
+ c.width = Math.max(1, Math.round(r.width * k));
413
+ c.height = Math.max(1, Math.round(r.height * k));
414
+ const w = (vis.r - vis.l) * k;
415
+ const h = (vis.b - vis.t) * k;
416
+ c.getContext("2d").drawImage(shot.img, vis.l * k, vis.t * k, w, h, (vis.l - r.left) * k, (vis.t - r.top) * k, w, h);
417
+ const full = vis.l <= r.left + 1 && vis.t <= r.top + 1 && vis.r >= r.right - 1 && vis.b >= r.bottom - 1;
418
+ try {
419
+ return c.toDataURL(full ? "image/jpeg" : "image/webp", 0.9);
420
+ } catch (_) {
421
+ return null;
422
+ }
423
+ }
424
+
425
+ // A URL another <video> can play: the chosen source, a <source>, or a lazy-load data-src.
426
+ // (blob:/MediaSource streams are tied to the original element and can't be reused.)
427
+ function videoUrl(el) {
428
+ const cands = [el.currentSrc, el.getAttribute("src"), el.getAttribute("data-src")];
429
+ for (const s of el.querySelectorAll("source")) cands.push(s.getAttribute("src"), s.getAttribute("data-src"));
430
+ for (const c of cands) {
431
+ if (!c || /^(blob|mediasource):/i.test(c)) continue;
432
+ try {
433
+ return new URL(c, location.href).href;
434
+ } catch (_) {}
435
+ }
436
+ return null;
437
+ }
438
+
439
+ function canvasPixels(el) {
440
+ try {
441
+ const probe = document.createElement("canvas");
442
+ probe.width = probe.height = 12;
443
+ const g = probe.getContext("2d");
444
+ g.drawImage(el, 0, 0, 12, 12);
445
+ const d = g.getImageData(0, 0, 12, 12).data;
446
+ let a = 0;
447
+ for (let i = 3; i < d.length; i += 4) a += d[i];
448
+ return a > 0 ? el.toDataURL() : null;
449
+ } catch (_) {
450
+ return null;
451
+ }
452
+ }
453
+
454
+ function boxyStyle(st) {
455
+ if (alphaOf(st.backgroundColor) > 0.02) return true;
456
+ if (st.backgroundImage && st.backgroundImage !== "none") return true;
457
+ if (st.boxShadow && st.boxShadow !== "none") return true;
458
+ for (const s of ["Top", "Right", "Bottom", "Left"]) {
459
+ if (parseFloat(st["border" + s + "Width"]) > 0 && st["border" + s + "Style"] !== "none" && alphaOf(st["border" + s + "Color"]) > 0.02) return true;
460
+ }
461
+ return false;
462
+ }
463
+
464
+ function sameColor(a, b) {
465
+ return (a || "").replace(/\s/g, "") === (b || "").replace(/\s/g, "");
466
+ }
467
+
468
+ function pageBackground() {
469
+ const h = document.documentElement;
470
+ const b = document.body;
471
+ const hs = getComputedStyle(h);
472
+ const bs = b ? getComputedStyle(b) : null;
473
+ const pick = (cs) => ({
474
+ color: cs.backgroundColor,
475
+ image: cs.backgroundImage,
476
+ size: cs.backgroundSize,
477
+ position: cs.backgroundPosition,
478
+ repeat: cs.backgroundRepeat,
479
+ });
480
+ if (alphaOf(hs.backgroundColor) > 0.01 || hs.backgroundImage !== "none") return { ...pick(hs), from: h };
481
+ if (bs && (alphaOf(bs.backgroundColor) > 0.01 || bs.backgroundImage !== "none")) return { ...pick(bs), from: b };
482
+ const dark = /dark/.test(hs.colorScheme) && matchMedia("(prefers-color-scheme: dark)").matches;
483
+ return { color: dark ? "rgb(18, 18, 18)" : "rgb(255, 255, 255)", image: "none", from: null };
484
+ }
485
+
486
+ /**
487
+ * Walk the live DOM and produce a blueprint the swarm can rebuild.
488
+ * Items live in "world" coords (scroller content space) or, for fixed UI,
489
+ * in screen coords on an "over"/"under" layer.
490
+ */
491
+ function captureModel(env, shot) {
492
+ const { ox, oy, sx, sy, vw, vh } = env.geom();
493
+ const docW = Math.max(env.scrollWidth(), vw);
494
+ const docH = Math.min(Math.max(env.scrollHeight(), vh), MAX_WORLD_H);
495
+ const items = [];
496
+ const roots = [];
497
+ let order = 0;
498
+ const csCache = new Map();
499
+ const css = (el) => {
500
+ let c = csCache.get(el);
501
+ if (!c) {
502
+ c = getComputedStyle(el);
503
+ csCache.set(el, c);
504
+ }
505
+ return c;
506
+ };
507
+ const inlineCache = new Map();
508
+ const fullClip = { l: -1e9, t: -1e9, r: 1e9, b: 1e9 };
509
+ const viewClip = { l: 0, t: 0, r: innerWidth, b: innerHeight };
510
+ const pageBg = env.pageBg;
511
+ const floats = [];
512
+ const bars = [];
513
+ let portKey = null;
514
+ const html = document.documentElement;
515
+ const body = document.body;
516
+
517
+ const conv = (r, layer) =>
518
+ layer === "world"
519
+ ? { x: r.left - ox + sx, y: r.top - oy + sy, w: r.width, h: r.height }
520
+ : { x: r.left, y: r.top, w: r.width, h: r.height };
521
+ const convClip = (c, layer) =>
522
+ layer === "world" ? { l: c.l - ox + sx, t: c.t - oy + sy, r: c.r - ox + sx, b: c.b - oy + sy } : { ...c };
523
+ const clipFor = (r, clip, layer) => {
524
+ if (r.left >= clip.l - 0.5 && r.top >= clip.t - 0.5 && r.right <= clip.r + 0.5 && r.bottom <= clip.b + 0.5) return null;
525
+ return convClip(clip, layer);
526
+ };
527
+ const inDoc = (b, layer) =>
528
+ layer === "world"
529
+ ? b.x + b.w > 0 && b.x < docW && b.y + b.h > 0 && b.y < docH
530
+ : b.x + b.w > 0 && b.x < innerWidth && b.y + b.h > 0 && b.y < innerHeight;
531
+
532
+ function add(it) {
533
+ it.id = items.length;
534
+ it.v = 1;
535
+ it.rv = 0;
536
+ it.vis = false;
537
+ it.dx = 0;
538
+ it.dy = 0;
539
+ it.scale = 1;
540
+ it.mode = "own";
541
+ items.push(it);
542
+ return it;
543
+ }
544
+
545
+ function allInline(el) {
546
+ let v = inlineCache.get(el);
547
+ if (v !== undefined) return v;
548
+ v = !el.shadowRoot;
549
+ if (v)
550
+ for (const c of el.children) {
551
+ const tag = c.localName;
552
+ if (SKIP.has(tag) || tag === "br" || tag === "wbr") continue;
553
+ if (c instanceof SVGElement) continue;
554
+ const d = css(c).display;
555
+ if (d === "none") continue;
556
+ if (d === "inline") {
557
+ if (!allInline(c)) {
558
+ v = false;
559
+ break;
560
+ }
561
+ } else if (!d.startsWith("inline")) {
562
+ v = false;
563
+ break;
564
+ }
565
+ }
566
+ inlineCache.set(el, v);
567
+ return v;
568
+ }
569
+
570
+ function rootStyle(cs, isRun) {
571
+ let s = styleString(cs, TEXT_PROPS);
572
+ if (isRun) return s + "display:block;padding:0;margin:0;box-sizing:border-box;width:100%;height:100%;position:relative;";
573
+ const pad = (side) =>
574
+ (parseFloat(cs.getPropertyValue(`padding-${side}`)) || 0) + (parseFloat(cs.getPropertyValue(`border-${side}-width`)) || 0);
575
+ let d = cs.display;
576
+ if (/flex/.test(d)) {
577
+ d = "flex";
578
+ s += styleString(cs, ["flex-direction", "flex-wrap", "align-items", "justify-content", "align-content", "row-gap", "column-gap"]);
579
+ } else if (/grid/.test(d)) {
580
+ d = "grid";
581
+ s += styleString(cs, [
582
+ "grid-template-columns", "grid-template-rows", "grid-auto-flow", "grid-auto-columns",
583
+ "grid-auto-rows", "align-items", "justify-items", "justify-content", "align-content",
584
+ "row-gap", "column-gap",
585
+ ]);
586
+ } else if (d === "list-item" || d === "-webkit-box") {
587
+ // keep
588
+ } else d = "block";
589
+ s += `display:${d};padding:${pad("top")}px ${pad("right")}px ${pad("bottom")}px ${pad("left")}px;`;
590
+ if (cs.overflowX !== "visible" || cs.overflowY !== "visible" || cs.textOverflow === "ellipsis" || (cs.webkitLineClamp && cs.webkitLineClamp !== "none")) {
591
+ s += styleString(cs, ["overflow-x", "overflow-y", "text-overflow", "-webkit-line-clamp", "-webkit-box-orient"]);
592
+ }
593
+ return s + "margin:0;box-sizing:border-box;width:100%;height:100%;position:relative;";
594
+ }
595
+
596
+ function textItem(base, root, tb, cs, layer, extra) {
597
+ const fs = parseFloat(cs.fontSize) || 16;
598
+ const text = root.textContent.replace(/\s+/g, " ").trim();
599
+ return add({
600
+ kind: "text",
601
+ ...base,
602
+ tb,
603
+ full: root,
604
+ fs,
605
+ lh: lhPx(cs),
606
+ align: cs.textAlign,
607
+ text,
608
+ sig: text,
609
+ ...extra,
610
+ });
611
+ }
612
+
613
+ function emitLeaf(el, cs, r, ctx, key) {
614
+ const root = document.createElement("div");
615
+ root.setAttribute("style", rootStyle(cs, false));
616
+ const clone = inert().importNode(el, true);
617
+ cloneDescendantsStyled(el, clone);
618
+ while (clone.firstChild) root.appendChild(clone.firstChild);
619
+ addPseudos(el, root);
620
+ const rg = document.createRange();
621
+ rg.selectNodeContents(el);
622
+ const tr = rg.getBoundingClientRect();
623
+ // table cells center their content vertically; reproduce the offset
624
+ if (cs.display === "table-cell" && tr.height) root.style.paddingTop = Math.max(0, tr.top - r.top) + "px";
625
+ // floats outside the leaf (figures, infoboxes) shape its line boxes: add invisible shims
626
+ if (/^(block|list-item)$/.test(root.style.display)) {
627
+ const n = (p) => parseFloat(cs.getPropertyValue(p)) || 0;
628
+ const cl = r.left + n("border-left-width") + n("padding-left");
629
+ const ct = r.top + n("border-top-width") + n("padding-top");
630
+ const cr = r.right - n("border-right-width") - n("padding-right");
631
+ for (const f of floats) {
632
+ if (f.b <= r.top + 1 || f.t >= r.bottom - 1 || f.r <= r.left + 1 || f.l >= r.right - 1) continue;
633
+ if (f.el.contains(el)) continue;
634
+ const shim = document.createElement("div");
635
+ const top = Math.max(0, f.t - ct);
636
+ const w = f.side === "left" ? f.r - cl : cr - f.l;
637
+ if (w <= 0) continue;
638
+ shim.setAttribute("style", `float:${f.side};clear:${f.side};width:${w}px;height:${Math.max(0, f.b - Math.max(f.t, ct))}px;margin-top:${top}px;`);
639
+ root.insertBefore(shim, root.firstChild);
640
+ }
641
+ }
642
+ const box = conv(r, ctx.layer);
643
+ const tb = tr.width > 0 && tr.height > 0 ? conv(tr, ctx.layer) : { ...box };
644
+ tb.x = clamp(tb.x, box.x - 4, box.x + box.w);
645
+ tb.y = clamp(tb.y, box.y - 4, box.y + box.h);
646
+ textItem(
647
+ { ...box, layer: ctx.layer, key, op: ctx.op, clip: clipFor(r, ctx.clip, ctx.layer), parentBox: ctx.boxItem, blend: cs.mixBlendMode !== "normal" ? cs.mixBlendMode : ctx.blend || "" },
648
+ root,
649
+ tb,
650
+ cs,
651
+ ctx.layer,
652
+ );
653
+ }
654
+
655
+ function emitRun(el, cs, run, ctx) {
656
+ const rg = document.createRange();
657
+ rg.setStartBefore(run[0]);
658
+ rg.setEndAfter(run[run.length - 1]);
659
+ const r = rg.getBoundingClientRect();
660
+ if (r.width < 1 || r.height < 1) return;
661
+ if (!intersect(ltrb(r), ctx.clip)) return;
662
+ const root = document.createElement("div");
663
+ root.setAttribute("style", rootStyle(cs, true));
664
+ const single = r.height < lhPx(cs) * 1.6;
665
+ if (single) root.style.whiteSpace = "nowrap";
666
+ for (const n of run) {
667
+ if (n.nodeType === 3) root.appendChild(document.createTextNode(n.data));
668
+ else root.appendChild(cloneStyled(n));
669
+ }
670
+ const box = conv(r, ctx.layer);
671
+ if (!inDoc(box, ctx.layer)) return;
672
+ box.w += 1;
673
+ textItem(
674
+ { ...box, layer: ctx.layer, key: ctx.key(), op: ctx.op, clip: clipFor(r, ctx.clip, ctx.layer), parentBox: ctx.boxItem, blend: ctx.blend || "" },
675
+ root,
676
+ { ...box },
677
+ cs,
678
+ ctx.layer,
679
+ );
680
+ }
681
+
682
+ function lineItem(pts, stroke, sw, dash, ctx, key) {
683
+ const xs = pts.map((p) => p.x);
684
+ const ys = pts.map((p) => p.y);
685
+ return add({
686
+ kind: "line",
687
+ x: Math.min(...xs),
688
+ y: Math.min(...ys),
689
+ w: Math.max(...xs) - Math.min(...xs),
690
+ h: Math.max(...ys) - Math.min(...ys),
691
+ pts,
692
+ stroke,
693
+ sw,
694
+ dash,
695
+ placed: 0,
696
+ shut: false,
697
+ edit: false,
698
+ owner: null,
699
+ layer: ctx.layer,
700
+ key,
701
+ op: ctx.op,
702
+ clip: null,
703
+ parentBox: ctx.boxItem,
704
+ sig: pts.length + stroke,
705
+ });
706
+ }
707
+
708
+ function emitBox(el, cs, r, ctx, key, forceRect) {
709
+ if (el === html) return null;
710
+ const tag = el.localName;
711
+ const bgc = cs.backgroundColor;
712
+ const bgi = cs.backgroundImage;
713
+ const bgVisible = (alphaOf(bgc) > 0.01 && !sameColor(bgc, pageBg.color)) || bgi !== "none";
714
+ if (el === body && pageBg.from === body) return null;
715
+ const sides = ["top", "right", "bottom", "left"].map((s) => {
716
+ const w = parseFloat(cs.getPropertyValue(`border-${s}-width`)) || 0;
717
+ const st = cs.getPropertyValue(`border-${s}-style`);
718
+ const c = cs.getPropertyValue(`border-${s}-color`);
719
+ return w > 0 && st !== "none" && st !== "hidden" && alphaOf(c) > 0.02 ? { s, w, st, c } : null;
720
+ });
721
+ const nb = sides.filter(Boolean).length;
722
+ const shadow = cs.boxShadow && cs.boxShadow !== "none";
723
+ const backdrop = cs.backdropFilter && cs.backdropFilter !== "none";
724
+ const b = conv(r, ctx.layer);
725
+ if (tag === "hr") {
726
+ const c = sides[0] ? sides[0].c : alphaOf(bgc) > 0.01 ? bgc : "rgb(200,200,200)";
727
+ const y = b.y + b.h / 2;
728
+ lineItem([{ x: b.x, y }, { x: b.x + b.w, y }], c, Math.max(1, Math.min(b.h, 2)), null, ctx, key);
729
+ return null;
730
+ }
731
+ const sameAsPage = alphaOf(bgc) > 0.01 && sameColor(bgc, pageBg.color) && bgi === "none";
732
+ if (!forceRect && !bgVisible && !shadow && !backdrop) {
733
+ if (nb === 0) return null;
734
+ if (nb < 4) {
735
+ for (const sd of sides) {
736
+ if (!sd) continue;
737
+ const hw = sd.w / 2;
738
+ let pts;
739
+ if (sd.s === "top") pts = [{ x: b.x, y: b.y + hw }, { x: b.x + b.w, y: b.y + hw }];
740
+ else if (sd.s === "bottom") pts = [{ x: b.x, y: b.y + b.h - hw }, { x: b.x + b.w, y: b.y + b.h - hw }];
741
+ else if (sd.s === "left") pts = [{ x: b.x + hw, y: b.y }, { x: b.x + hw, y: b.y + b.h }];
742
+ else pts = [{ x: b.x + b.w - hw, y: b.y }, { x: b.x + b.w - hw, y: b.y + b.h }];
743
+ const dash = sd.st === "dashed" ? `${sd.w * 3} ${sd.w * 2}` : sd.st === "dotted" ? `${sd.w} ${sd.w * 1.5}` : null;
744
+ lineItem(pts, sd.c, sd.w, dash, ctx, key);
745
+ }
746
+ return null;
747
+ }
748
+ }
749
+ if (sameAsPage && nb === 0 && !shadow && !backdrop && !forceRect) return null;
750
+ const cssText = styleString(cs, RECT_PROPS);
751
+ return add({
752
+ kind: "rect",
753
+ blend: cs.mixBlendMode !== "normal" ? cs.mixBlendMode : ctx.blend || "",
754
+ big: b.w * b.h > vw * vh * 0.4,
755
+ ...b,
756
+ css: cssText,
757
+ radius: cs.borderRadius,
758
+ draw: null,
759
+ styled: false,
760
+ layer: ctx.layer,
761
+ key,
762
+ op: ctx.op,
763
+ clip: clipFor(r, ctx.clip, ctx.layer),
764
+ parentBox: ctx.boxItem,
765
+ sig: cssText,
766
+ });
767
+ }
768
+
769
+ // Decorative ::before/::after on containers (overlays, glows, standalone icons).
770
+ // Their boxes aren't exposed, so derive them from the host box and the pseudo's insets.
771
+ const PSEUDO_EL = { localName: "pseudo" };
772
+ function emitPseudoBox(el, cs, r, ctx, which, empty) {
773
+ const ps = getComputedStyle(el, which);
774
+ const c = ps.content;
775
+ if (!c || c === "none" || c === "normal" || ps.display === "none" || ps.visibility !== "visible") return;
776
+ const pop = parseFloat(ps.opacity);
777
+ if (!(pop > 0.03)) return;
778
+ const n = (v) => parseFloat(v) || 0;
779
+ const w = parseFloat(ps.width);
780
+ const h = parseFloat(ps.height);
781
+ if (!(w > 0 && h > 0)) return;
782
+ const pad = {
783
+ l: r.left + n(cs.borderLeftWidth), t: r.top + n(cs.borderTopWidth),
784
+ r: r.right - n(cs.borderRightWidth), b: r.bottom - n(cs.borderBottomWidth),
785
+ };
786
+ let L;
787
+ let T;
788
+ if (ps.position === "absolute") {
789
+ if (cs.position === "static") return; // containing block is some other ancestor
790
+ const bw = w + n(ps.paddingLeft) + n(ps.paddingRight) + n(ps.borderLeftWidth) + n(ps.borderRightWidth);
791
+ const bh = h + n(ps.paddingTop) + n(ps.paddingBottom) + n(ps.borderTopWidth) + n(ps.borderBottomWidth);
792
+ L = ps.left !== "auto" ? pad.l + n(ps.left) : ps.right !== "auto" ? pad.r - n(ps.right) - bw : pad.l;
793
+ T = ps.top !== "auto" ? pad.t + n(ps.top) : ps.bottom !== "auto" ? pad.b - n(ps.bottom) - bh : pad.t;
794
+ const m = ps.transform.match(/^matrix\(([^)]+)\)$/);
795
+ if (m) {
796
+ const k = m[1].split(",").map(Number);
797
+ L += k[4];
798
+ T += k[5];
799
+ }
800
+ const fake = { left: L, top: T, right: L + bw, bottom: T + bh, width: bw, height: bh };
801
+ if (!intersect(ltrb(fake), ctx.clip)) return;
802
+ emitBox(PSEUDO_EL, ps, fake, { ...ctx, op: ctx.op * pop }, ctx.key(), false);
803
+ } else {
804
+ const cl = pad.l + n(cs.paddingLeft);
805
+ const ct = pad.t + n(cs.paddingTop);
806
+ const cw = pad.r - n(cs.paddingRight) - cl;
807
+ const ch = pad.b - n(cs.paddingBottom) - ct;
808
+ const align = (ps.alignSelf !== "auto" ? ps.alignSelf : cs.alignItems) || "";
809
+ const vAlign = () => (/center/.test(align) ? ct + (ch - h) / 2 : /end/.test(align) ? ct + ch - h : ct);
810
+ if (empty) {
811
+ // the only content of an icon-ish element: centre it in the content box
812
+ L = cl + Math.max(0, (cw - w) / 2);
813
+ T = ct + Math.max(0, (ch - h) / 2);
814
+ } else if (/flex/.test(cs.display) && /^row/.test(cs.flexDirection)) {
815
+ // icon before the first / after the last item of a flex row (chevrons, arrows)
816
+ const kidsR = [...el.children]
817
+ .filter((k) => css(k).display !== "none" && css(k).position !== "absolute" && css(k).position !== "fixed")
818
+ .map((k) => k.getBoundingClientRect())
819
+ .filter((k) => k.width || k.height);
820
+ if (!kidsR.length) return;
821
+ const gap = n(cs.columnGap);
822
+ if (which === "::after") L = kidsR[kidsR.length - 1].right + gap + n(ps.marginLeft);
823
+ else L = kidsR[0].left - gap - n(ps.marginRight) - w;
824
+ T = vAlign();
825
+ } else return;
826
+ const fake = { left: L, top: T, right: L + w, bottom: T + h, width: w, height: h };
827
+ if (!intersect(ltrb(fake), ctx.clip)) return;
828
+ emitBox(PSEUDO_EL, ps, fake, { ...ctx, op: ctx.op * pop }, ctx.key(), false);
829
+ }
830
+ }
831
+
832
+ function mediaFor(el, cs, r) {
833
+ const tag = el.localName;
834
+ const fit = `object-fit:${cs.objectFit};object-position:${cs.objectPosition};`;
835
+ if (tag === "img") {
836
+ const src = imgSrc(el);
837
+ if (!src) return null;
838
+ const m = document.createElement("img");
839
+ m.src = src;
840
+ m.decoding = "async";
841
+ m.setAttribute("style", fit + "display:block;");
842
+ m.draggable = false;
843
+ return { el: m, sig: src };
844
+ }
845
+ if (tag === "svg") {
846
+ const m = cloneStyled(el);
847
+ m.setAttribute("width", "100%");
848
+ m.setAttribute("height", "100%");
849
+ m.style.display = "block";
850
+ m.style.overflow = "visible";
851
+ m.style.margin = "0";
852
+ m.style.position = "static";
853
+ // the box we draw is the transformed bounding box; keep only a pure rotation/flip
854
+ const mt = cs.transform.match(/^matrix\(([^)]+)\)$/);
855
+ const k = mt ? mt[1].split(",").map(Number) : null;
856
+ m.style.transform = k && Math.abs(Math.hypot(k[0], k[1]) - 1) < 0.02 && Math.abs(Math.hypot(k[2], k[3]) - 1) < 0.02 ? `matrix(${k[0]},${k[1]},${k[2]},${k[3]},0,0)` : "none";
857
+ m.style.transformOrigin = "50% 50%";
858
+ const h = m.outerHTML;
859
+ return { el: m, sig: h.length + ":" + h.slice(0, 160) };
860
+ }
861
+ const imgOf = (src, extra) => {
862
+ const m = document.createElement("img");
863
+ m.src = src;
864
+ m.setAttribute("style", (extra || "") + "display:block;width:100%;height:100%;");
865
+ m.draggable = false;
866
+ return { el: m, sig: src.slice(0, 120) + src.length };
867
+ };
868
+ if (tag === "canvas" || tag === "iframe" || tag === "embed" || tag === "object") {
869
+ const crop = cropShot(shot, r);
870
+ if (crop) return imgOf(crop);
871
+ }
872
+ if (tag === "video") {
873
+ // Cross-origin video can't be read back as pixels once it's off-screen, so
874
+ // rebuild it as a real (muted) video in the state the original was in.
875
+ const crop = cropShot(shot, r);
876
+ const url = videoUrl(el);
877
+ if (url) {
878
+ const v = document.createElement("video");
879
+ v.setAttribute("src", url);
880
+ v.setAttribute("muted", "");
881
+ v.setAttribute("playsinline", "");
882
+ v.setAttribute("preload", "auto");
883
+ if (el.loop || el.autoplay || !el.paused) v.setAttribute("loop", "");
884
+ if (!el.paused || el.autoplay) v.setAttribute("data-play", "");
885
+ if (el.crossOrigin) v.setAttribute("crossorigin", el.crossOrigin);
886
+ if (el.currentTime > 0) v.setAttribute("data-t", String(el.currentTime));
887
+ const poster = crop || el.poster;
888
+ if (poster) v.setAttribute("poster", poster);
889
+ v.setAttribute("style", fit + "display:block;width:100%;height:100%;");
890
+ return { el: v, sig: url };
891
+ }
892
+ if (crop) return imgOf(crop);
893
+ let src = el.poster || "";
894
+ if (!src && el.videoWidth) {
895
+ try {
896
+ const c = document.createElement("canvas");
897
+ c.width = el.videoWidth;
898
+ c.height = el.videoHeight;
899
+ c.getContext("2d").drawImage(el, 0, 0);
900
+ src = c.toDataURL("image/jpeg", 0.82);
901
+ } catch (_) {}
902
+ }
903
+ if (src) {
904
+ const m = document.createElement("img");
905
+ m.src = src;
906
+ m.setAttribute("style", fit + "display:block;");
907
+ return { el: m, sig: src.slice(0, 200) };
908
+ }
909
+ const d = document.createElement("div");
910
+ d.setAttribute("style", "background:#111;");
911
+ return { el: d, sig: "video" };
912
+ }
913
+ if (tag === "canvas") {
914
+ const src = canvasPixels(el);
915
+ if (src) return imgOf(src);
916
+ return null;
917
+ }
918
+ // iframe / embed / object
919
+ const d = document.createElement("div");
920
+ let host = "";
921
+ try {
922
+ host = new URL(el.src || el.data || "", location.href).hostname.replace(/^www\./, "");
923
+ } catch (_) {}
924
+ d.setAttribute(
925
+ "style",
926
+ "background:#F1F2F4;display:flex;align-items:center;justify-content:center;font:500 12px/1.2 system-ui,sans-serif;color:#8A8F98;letter-spacing:.01em;",
927
+ );
928
+ d.textContent = host || "embed";
929
+ return { el: d, sig: "frame:" + host };
930
+ }
931
+
932
+ function emitMedia(el, cs, r, ctx, key) {
933
+ let src = el;
934
+ let scs = cs;
935
+ if (el.localName === "picture") {
936
+ src = el.querySelector("img");
937
+ if (!src) return;
938
+ scs = css(src);
939
+ }
940
+ const m = mediaFor(src, scs, r);
941
+ if (!m) return;
942
+ const b = conv(r, ctx.layer);
943
+ add({
944
+ kind: "image",
945
+ ...b,
946
+ blend: cs.mixBlendMode !== "normal" ? cs.mixBlendMode : ctx.blend || "",
947
+ media: m.el,
948
+ frame: styleString(cs, ["border-top-width", "border-right-width", "border-bottom-width", "border-left-width", "border-top-style", "border-right-style", "border-bottom-style", "border-left-style", "border-top-color", "border-right-color", "border-bottom-color", "border-left-color"]),
949
+ radius: cs.borderRadius,
950
+ shadow: cs.boxShadow !== "none" ? cs.boxShadow : "",
951
+ bg: alphaOf(cs.backgroundColor) > 0.01 ? cs.backgroundColor : "",
952
+ draw: null,
953
+ styled: false,
954
+ layer: ctx.layer,
955
+ key,
956
+ op: ctx.op,
957
+ clip: clipFor(r, ctx.clip, ctx.layer),
958
+ parentBox: ctx.boxItem,
959
+ sig: m.sig,
960
+ });
961
+ }
962
+
963
+ function emitForm(el, cs, r, ctx, key) {
964
+ const tag = el.localName;
965
+ const type = (el.getAttribute("type") || "").toLowerCase();
966
+ if (type === "hidden") return;
967
+ const b = conv(r, ctx.layer);
968
+ if (tag === "input" && (type === "checkbox" || type === "radio")) {
969
+ const accent = cs.accentColor && cs.accentColor !== "auto" ? cs.accentColor : "#0B6CFF";
970
+ const s = document.createElementNS(SVGNS, "svg");
971
+ s.setAttribute("viewBox", "0 0 16 16");
972
+ s.setAttribute("width", "100%");
973
+ s.setAttribute("height", "100%");
974
+ const on = el.checked;
975
+ s.innerHTML =
976
+ type === "radio"
977
+ ? `<circle cx="8" cy="8" r="7" fill="#fff" stroke="${on ? accent : "#767676"}" stroke-width="1.2"/>${on ? `<circle cx="8" cy="8" r="4" fill="${accent}"/>` : ""}`
978
+ : `<rect x="1" y="1" width="14" height="14" rx="2.5" fill="${on ? accent : "#fff"}" stroke="${on ? accent : "#767676"}" stroke-width="1.2"/>${on ? '<path d="M4.2 8.3 L7 11 L12 5.4" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>' : ""}`;
979
+ add({
980
+ kind: "image", ...b, media: s, frame: "", radius: "0px", shadow: "", bg: "",
981
+ draw: null, styled: false, layer: ctx.layer, key, op: ctx.op, clip: null,
982
+ parentBox: ctx.boxItem, sig: type + on,
983
+ });
984
+ return;
985
+ }
986
+ const boxIt = emitBox(el, cs, r, ctx, key, false);
987
+ let text = "";
988
+ let ph = false;
989
+ if (tag === "select") text = (el.selectedOptions && el.selectedOptions[0] && el.selectedOptions[0].text) || "";
990
+ else if (["submit", "button", "reset"].includes(type)) text = el.value || (type === "submit" ? "Submit" : type === "reset" ? "Reset" : "");
991
+ else if (type === "password") text = "•".repeat((el.value || "").length);
992
+ else if (["range", "color", "file", "image"].includes(type)) text = "";
993
+ else text = el.value || "";
994
+ if (!text && el.placeholder) {
995
+ text = el.placeholder;
996
+ ph = true;
997
+ }
998
+ if (!text.trim()) return;
999
+ const root = document.createElement("div");
1000
+ const pad = (side) =>
1001
+ (parseFloat(cs.getPropertyValue(`padding-${side}`)) || 0) + (parseFloat(cs.getPropertyValue(`border-${side}-width`)) || 0);
1002
+ const isButton = ["submit", "button", "reset"].includes(type);
1003
+ let st = styleString(cs, TEXT_PROPS);
1004
+ st += `padding:${pad("top")}px ${pad("right")}px ${pad("bottom")}px ${pad("left")}px;margin:0;box-sizing:border-box;width:100%;height:100%;overflow:hidden;`;
1005
+ st += tag === "textarea" ? "display:block;white-space:pre-wrap;" : `display:flex;align-items:center;white-space:pre;${isButton ? "justify-content:center;" : ""}`;
1006
+ if (ph) st += `color:${getComputedStyle(el, "::placeholder").color};-webkit-text-fill-color:currentColor;`;
1007
+ root.setAttribute("style", st);
1008
+ root.textContent = text;
1009
+ textItem(
1010
+ { ...b, layer: ctx.layer, key: key + 0.5, op: ctx.op, clip: clipFor(r, ctx.clip, ctx.layer), parentBox: boxIt || ctx.boxItem },
1011
+ root,
1012
+ { ...b },
1013
+ cs,
1014
+ ctx.layer,
1015
+ );
1016
+ }
1017
+
1018
+ function walk(el, ctx, parent) {
1019
+ if (items.length >= MAX_ITEMS) return;
1020
+ const tag = el.localName;
1021
+ if (SKIP.has(tag) || el === env.host) return;
1022
+ const cs = css(el);
1023
+ const disp = cs.display;
1024
+ if (disp === "none") return;
1025
+ if (disp === "contents") {
1026
+ // transparent wrapper (incl. <slot>): its loose text still renders, styled by it
1027
+ let run = [];
1028
+ const flushRun = () => {
1029
+ if (ctx.key && run.some((n) => /\S/.test(n.data))) emitRun(el, cs, run, ctx);
1030
+ run = [];
1031
+ };
1032
+ for (const c of kidsOf(el)) {
1033
+ if (c.nodeType === 3) run.push(c);
1034
+ else if (c.nodeType === 1) {
1035
+ flushRun();
1036
+ walk(c, ctx, parent);
1037
+ }
1038
+ }
1039
+ flushRun();
1040
+ return;
1041
+ }
1042
+ const op = ctx.op * parseFloat(cs.opacity);
1043
+ if (!(op > 0.03)) return;
1044
+ const r = el.getBoundingClientRect();
1045
+ // "visually hidden" accessibility text: 1px boxes, clip:rect(0 0 0 0), clip-path:inset(50%)
1046
+ // (html/body overflow belongs to the viewport, so a 0px-tall body clips nothing)
1047
+ const clipsSelf = el !== html && el !== body && el !== env.scroller && cs.overflowX !== "visible" && cs.overflowY !== "visible";
1048
+ if (clipsSelf && (r.width <= 2 || r.height <= 2)) return;
1049
+ if (cs.position === "absolute" && /rect\(0px,? 0px,? 0px,? 0px\)/.test(cs.clip)) return;
1050
+ if (/inset\(50%\)|circle\(0px/.test(cs.clipPath)) return;
1051
+ if (cs.float !== "none" && r.width > 1 && r.height > 1) {
1052
+ const n = (p) => parseFloat(cs.getPropertyValue(p)) || 0;
1053
+ floats.push({ el, side: cs.float, l: r.left - n("margin-left"), t: r.top - n("margin-top"), r: r.right + n("margin-right"), b: r.bottom + n("margin-bottom") });
1054
+ }
1055
+ let layer = ctx.layer;
1056
+ let clip = ctx.clip;
1057
+ let zTop = ctx.zTop;
1058
+ let posd = ctx.posd;
1059
+ const pos = cs.position;
1060
+ if (pos === "fixed" && layer !== "over") {
1061
+ layer = "over";
1062
+ clip = viewClip;
1063
+ } else if (pos === "sticky" && layer === "world") {
1064
+ // stuck (or sitting exactly where it would stick) at the top of its scroller
1065
+ const top = parseFloat(cs.top);
1066
+ if (!isNaN(top) && Math.abs(r.top - oy - top) < 2 && r.height < vh * 0.4) {
1067
+ layer = "over";
1068
+ clip = viewClip;
1069
+ }
1070
+ }
1071
+ // z-index counts for positioned boxes and for flex/grid items (even when static)
1072
+ const pe = el.parentElement;
1073
+ const flexItem = pos === "static" && pe && /flex|grid/.test(css(pe).display);
1074
+ if (pos !== "static" || flexItem) {
1075
+ if (cs.zIndex !== "auto") {
1076
+ if (zTop == null) zTop = parseInt(cs.zIndex, 10) || 0;
1077
+ } else if (pos !== "static") posd = true;
1078
+ }
1079
+ const keyFn = () => (zTop || 0) * 1e7 + (posd ? 5e6 : 0) + order++;
1080
+ if (layer === "over" && ctx.layer !== "over" && r.top <= 4 && r.width >= vw * 0.8 && r.height > 20 && r.height <= 200) {
1081
+ bars.push({ node: null, r, start: items.length, key: (zTop || 0) * 1e7 + (posd ? 5e6 : 0) + order - 0.5 });
1082
+ }
1083
+ const isScroller = !env.isRoot && el === env.scroller;
1084
+ const kidLayer = isScroller ? "world" : layer;
1085
+ const box = conv(r, layer);
1086
+ const visible = cs.visibility === "visible";
1087
+ const sized = r.width >= 1 && r.height >= 1;
1088
+ const emit = visible && sized && !!intersect(ltrb(r), clip) && inDoc(box, layer);
1089
+
1090
+ const node = {
1091
+ el, tag, cls: (el.getAttribute && el.getAttribute("class")) || "", layer,
1092
+ start: items.length, end: items.length, kids: [], box,
1093
+ };
1094
+ (parent ? parent.kids : roots).push(node);
1095
+
1096
+ if (REPLACED.has(tag) || FORMS.has(tag)) {
1097
+ if (emit) {
1098
+ if (FORMS.has(tag)) emitForm(el, cs, r, { ...ctx, layer, clip, op }, keyFn());
1099
+ else emitMedia(el, cs, r, { ...ctx, layer, clip, op }, keyFn());
1100
+ }
1101
+ node.end = items.length;
1102
+ return;
1103
+ }
1104
+
1105
+ let boxItem = ctx.boxItem;
1106
+ if (emit) {
1107
+ const b = emitBox(el, cs, r, { ...ctx, layer, clip, op }, keyFn(), false);
1108
+ if (b) boxItem = b;
1109
+ }
1110
+
1111
+ let kidClip = clip;
1112
+ if (isScroller) {
1113
+ kidClip = fullClip;
1114
+ portKey = keyFn();
1115
+ }
1116
+ else if (el !== html && el !== body && (cs.overflowX !== "visible" || cs.overflowY !== "visible")) {
1117
+ const bl = parseFloat(cs.borderLeftWidth) || 0;
1118
+ const bt = parseFloat(cs.borderTopWidth) || 0;
1119
+ const br = parseFloat(cs.borderRightWidth) || 0;
1120
+ const bb = parseFloat(cs.borderBottomWidth) || 0;
1121
+ kidClip = intersect(clip, { l: r.left + bl, t: r.top + bt, r: r.right - br, b: r.bottom - bb });
1122
+ if (!kidClip) {
1123
+ node.end = items.length;
1124
+ return;
1125
+ }
1126
+ }
1127
+ const blend = cs.mixBlendMode !== "normal" ? cs.mixBlendMode : ctx.blend;
1128
+ const kctx = { op, layer: kidLayer, clip: kidClip, zTop, posd, boxItem, key: keyFn, blend };
1129
+
1130
+ // Decide: one text leaf, or a mix of text runs and nested blocks.
1131
+ // Closed <details> and content-visibility:hidden still report real boxes for
1132
+ // content that isn't painted, so only the summary (or nothing) gets built.
1133
+ if (cs.contentVisibility === "hidden") {
1134
+ node.end = items.length;
1135
+ return;
1136
+ }
1137
+ let kids = kidsOf(el);
1138
+ if (tag === "details" && !el.open) {
1139
+ const sum = kids.find((n) => n.nodeType === 1 && n.localName === "summary");
1140
+ kids = sum ? [sum] : [];
1141
+ }
1142
+ const emptyEl = !kids.some((k) => (k.nodeType === 3 && /\S/.test(k.data)) || k.nodeType === 1);
1143
+ const pctx = { ...kctx, layer, clip };
1144
+ const directText = kids.some((n) => n.nodeType === 3 && /\S/.test(n.data));
1145
+ const kinds = [];
1146
+ let allRun = !el.shadowRoot;
1147
+ let anyText = directText;
1148
+ for (const n of kids) {
1149
+ if (n.nodeType === 3) {
1150
+ kinds.push(1);
1151
+ continue;
1152
+ }
1153
+ if (n.nodeType !== 1 || SKIP.has(n.localName)) {
1154
+ kinds.push(0);
1155
+ continue;
1156
+ }
1157
+ const d = css(n).display;
1158
+ if (d === "none") {
1159
+ kinds.push(0);
1160
+ continue;
1161
+ }
1162
+ let inl = false;
1163
+ const nt = n.localName;
1164
+ if (n.shadowRoot || nt === "slot") inl = false;
1165
+ else if (nt === "br" || nt === "wbr") inl = true;
1166
+ else if (d === "inline") inl = allInline(n);
1167
+ else if (directText && d.startsWith("inline")) inl = true;
1168
+ if (inl && !anyText && /\S/.test(n.textContent)) anyText = true;
1169
+ kinds.push(inl ? 1 : 2);
1170
+ if (!inl) allRun = false;
1171
+ }
1172
+
1173
+ if (allRun && anyText && !isScroller) {
1174
+ if (visible && sized && emit) emitLeaf(el, cs, r, { ...kctx, layer }, keyFn());
1175
+ node.end = items.length;
1176
+ return;
1177
+ }
1178
+ // (text leaves carry their pseudos inside the clone instead)
1179
+ if (emit && visible) emitPseudoBox(el, cs, r, pctx, "::before", emptyEl);
1180
+ const afterPseudo = () => {
1181
+ if (emit && visible) emitPseudoBox(el, cs, r, pctx, "::after", emptyEl);
1182
+ };
1183
+
1184
+ let run = [];
1185
+ const flush = () => {
1186
+ if (!run.length) return;
1187
+ const hasText = run.some((n) => (n.nodeType === 3 ? /\S/.test(n.data) : /\S/.test(n.textContent)));
1188
+ if (hasText) {
1189
+ if (visible) emitRun(el, cs, run, kctx);
1190
+ } else {
1191
+ for (const n of run) if (n.nodeType === 1) walk(n, kctx, node);
1192
+ }
1193
+ run = [];
1194
+ };
1195
+ kids.forEach((n, i) => {
1196
+ if (kinds[i] === 1) run.push(n);
1197
+ else if (kinds[i] === 2) {
1198
+ flush();
1199
+ walk(n, kctx, node);
1200
+ }
1201
+ });
1202
+ flush();
1203
+ afterPseudo();
1204
+ node.end = items.length;
1205
+ }
1206
+
1207
+ const rootLayer = env.isRoot ? "world" : "under";
1208
+ const rootClip = env.isRoot ? fullClip : viewClip;
1209
+ walk(html, { op: 1, layer: rootLayer, clip: rootClip, zTop: null, posd: false, boxItem: null }, null);
1210
+
1211
+ // --------------------------------------------- repeated components (dup)
1212
+ const groups = [];
1213
+ const kindsSig = (n) => {
1214
+ let s = "";
1215
+ for (let i = n.start; i < n.end; i++) s += items[i].kind[0];
1216
+ return s;
1217
+ };
1218
+ const similar = (a, b, loose) =>
1219
+ a.w > 0 && b.w > 0 && a.h > 0 && b.h > 0 && a.h / b.h < 1.8 && b.h / a.h < 1.8 && (loose || (a.w / b.w < 1.6 && b.w / a.w < 1.6));
1220
+ function findGroups(node, inCopy) {
1221
+ if (inCopy) return;
1222
+ const buckets = new Map();
1223
+ for (const k of node.kids) {
1224
+ const n = k.end - k.start;
1225
+ if (n < 1 || n > 48) continue;
1226
+ // long single paragraphs read better typed than duplicated
1227
+ if (n === 1 && items[k.start].kind === "text" && items[k.start].text.length > 40) continue;
1228
+ const sig = `${k.tag}|${k.cls}|${k.layer}|${kindsSig(k)}`;
1229
+ if (!buckets.has(sig)) buckets.set(sig, []);
1230
+ buckets.get(sig).push(k);
1231
+ }
1232
+ const copies = new Set();
1233
+ for (const list of buckets.values()) {
1234
+ if (list.length < 2) continue;
1235
+ const m = list[0];
1236
+ const loose = m.end - m.start === 1 && items[m.start].kind === "text";
1237
+ const members = list.filter((k) => similar(k.box, m.box, loose));
1238
+ if (members.length < 2) continue;
1239
+ const g = { id: groups.length, master: members[0], copies: members.slice(1) };
1240
+ groups.push(g);
1241
+ for (const c of g.copies) copies.add(c);
1242
+ }
1243
+ for (const k of node.kids) findGroups(k, copies.has(k));
1244
+ }
1245
+ for (const r of roots) findGroups(r, false);
1246
+
1247
+ for (const g of groups) {
1248
+ for (const c of g.copies) {
1249
+ for (let i = c.start; i < c.end; i++) {
1250
+ const it = items[i];
1251
+ if (it.copyOf) continue;
1252
+ it.copyOf = items[g.master.start + (i - c.start)];
1253
+ it.group = g;
1254
+ }
1255
+ }
1256
+ }
1257
+
1258
+ const twinKey = new Map();
1259
+ for (const it of items) {
1260
+ if (it.kind !== "text" || it.copyOf || !it.text) continue;
1261
+ const k = `${it.layer}|${Math.round(it.tb.x / 4)}|${Math.round(it.tb.y / 4)}|${it.text}`;
1262
+ const m = twinKey.get(k);
1263
+ if (m) it.twinOf = m;
1264
+ else twinKey.set(k, it);
1265
+ }
1266
+
1267
+ // ------------------------------------------------------------- tasks
1268
+ const tasks = [];
1269
+ const mk = (y, spec, deps, layer) => {
1270
+ const t = { id: tasks.length, y, spec, deps, state: "wait", doneAt: 0, layer };
1271
+ tasks.push(t);
1272
+ return t;
1273
+ };
1274
+ const KIND_TASK = { rect: "rect", image: "image", text: "text", line: "line" };
1275
+ for (const it of items) {
1276
+ if (it.copyOf) continue;
1277
+ if (it.twinOf) {
1278
+ it.final = it.twinOf.final;
1279
+ continue;
1280
+ }
1281
+ const deps = it.parentBox && it.parentBox.final ? it.parentBox.final : [];
1282
+ const y = it.kind === "text" ? it.tb.y : it.y;
1283
+ it.task = mk(y, { type: KIND_TASK[it.kind], it }, deps, it.layer);
1284
+ it.final = [it.task];
1285
+ }
1286
+ // inner groups first so outer copies can wait for them
1287
+ for (let gi = groups.length - 1; gi >= 0; gi--) {
1288
+ const g = groups[gi];
1289
+ const mItems = items.slice(g.master.start, g.master.end);
1290
+ const masterFinals = [];
1291
+ for (const it of mItems) if (it.final) masterFinals.push(...it.final);
1292
+ if (g.master.start < g.master.end && items[g.master.start].parentBox && items[g.master.start].parentBox.final)
1293
+ masterFinals.push(...items[g.master.start].parentBox.final);
1294
+ g.src = { items: mItems, box: g.master.box };
1295
+ g.dst = g.copies.map((c, k) => {
1296
+ const its = items.slice(c.start, c.end).filter((it) => it.group === g);
1297
+ const minY = Math.min(...its.map((it) => (it.kind === "text" ? it.tb.y : it.y)));
1298
+ const task = mk(minY, { type: "dup", g, k }, masterFinals, c.layer);
1299
+ for (const it of its) {
1300
+ it.dupTask = task;
1301
+ it.final = [task];
1302
+ const m = it.copyOf;
1303
+ let type = null;
1304
+ if (it.kind === "text" && it.sig !== m.sig) type = "edit";
1305
+ else if (it.kind === "image" && it.sig !== m.sig) type = "swap";
1306
+ else if (it.kind === "rect" && it.sig !== m.sig) type = "restyle";
1307
+ if (type) {
1308
+ it.editTask = mk(it.kind === "text" ? it.tb.y : it.y, { type, it }, [task], it.layer);
1309
+ it.final = [task, it.editTask];
1310
+ }
1311
+ }
1312
+ return { items: its, box: c.box, task };
1313
+ });
1314
+ }
1315
+
1316
+ // -------------------------------------------------- guides & snapping
1317
+ const guides = [];
1318
+ const texts = items.filter((it) => it.kind === "text" && it.task && it.layer === "world" && it.fs >= 13 && it.tb.w > 30);
1319
+ const BAND = 900;
1320
+ const bands = new Map();
1321
+ for (const it of texts) {
1322
+ const k = Math.floor(it.tb.y / BAND);
1323
+ if (!bands.has(k)) bands.set(k, []);
1324
+ bands.get(k).push(it);
1325
+ }
1326
+ for (const [, list] of [...bands.entries()].sort((a, b) => a[0] - b[0])) {
1327
+ if (guides.length >= 16) break;
1328
+ const byX = new Map();
1329
+ for (const it of list) {
1330
+ const x = Math.round(it.tb.x);
1331
+ if (!byX.has(x)) byX.set(x, []);
1332
+ byX.get(x).push(it);
1333
+ }
1334
+ let best = null;
1335
+ for (const [x, l] of byX) if (l.length >= 3 && (!best || l.length > best[1].length)) best = [x, l];
1336
+ if (best) {
1337
+ const top = Math.min(...best[1].map((m) => m.tb.y));
1338
+ const members = best[1].filter((m) => m.tb.y < top + 450).sort((a, b) => b.fs - a.fs).slice(0, 2);
1339
+ const g = { axis: "x", pos: best[0], cur: best[0], op: 1, flash: 0, dragging: false, vis: false };
1340
+ guides.push(g);
1341
+ const gy = Math.min(...members.map((m) => m.tb.y)) - 30;
1342
+ g.task = mk(gy, { type: "guide", g }, [], "world");
1343
+ g.deps = [];
1344
+ for (const m of members) {
1345
+ m.task.spec.snap = g;
1346
+ m.task.spec.snapDx = m.tb.x - best[0];
1347
+ g.deps.push(m.task);
1348
+ }
1349
+ }
1350
+ const byY = new Map();
1351
+ for (const it of list) {
1352
+ const y = Math.round(it.tb.y);
1353
+ if (!byY.has(y)) byY.set(y, []);
1354
+ byY.get(y).push(it);
1355
+ }
1356
+ let row = null;
1357
+ for (const [y, l] of byY) if (l.length >= 3 && (!row || l.length > row[1].length)) row = [y, l];
1358
+ if (row) {
1359
+ const m = row[1].find((it) => !it.task.spec.snap);
1360
+ if (m) {
1361
+ const g = { axis: "y", pos: row[0], cur: row[0], op: 1, flash: 0, dragging: false, vis: false };
1362
+ guides.push(g);
1363
+ g.task = mk(row[0] - 30, { type: "guide", g }, [], "world");
1364
+ g.deps = [m.task];
1365
+ m.task.spec.snap = g;
1366
+ }
1367
+ }
1368
+ }
1369
+ for (const it of items) if (it.kind === "text" && it.task && it.fs >= 28 && it.tb.h < 400) it.task.spec.grow = true;
1370
+ // deps always have lower ids, so one reverse pass propagates the earliest need
1371
+ for (const t of tasks) t.gate = t.y;
1372
+ for (let i = tasks.length - 1; i >= 0; i--) for (const d of tasks[i].deps) if (tasks[i].gate < d.gate) d.gate = tasks[i].gate;
1373
+
1374
+ // A sticky header that is transparent at the top of the page still needs
1375
+ // something behind it once content scrolls underneath (sites add it on scroll).
1376
+ const backdrops = [];
1377
+ if (pageBg.image === "none")
1378
+ for (const b of bars) {
1379
+ const area = b.r.width * b.r.height;
1380
+ const covered = items.some(
1381
+ (it) => it.id >= b.start && it.layer === "over" && it.kind === "rect" && it.w * it.h >= area * 0.85 && /rgba?\(/.test(it.css) && !/background-color:\s*rgba\([^)]*,\s*0(\.\d+)?\)/.test(it.css),
1382
+ );
1383
+ if (!covered) backdrops.push({ x: b.r.left, y: b.r.top, w: b.r.width, h: b.r.height, key: b.key, color: pageBg.color });
1384
+ }
1385
+
1386
+ return { items, tasks, groups, guides, docW, docH, backdrops, portKey };
1387
+ }
1388
+
1389
+ // -------------------------------------------------------------- text reveal
1390
+
1391
+ function wsOf(n, root) {
1392
+ for (let e = n; e && e !== root.parentNode; e = e.parentNode) {
1393
+ const w = e.style && e.style.whiteSpace;
1394
+ if (w) return w;
1395
+ }
1396
+ return "normal";
1397
+ }
1398
+
1399
+ function prepText(it) {
1400
+ const live = it.full.cloneNode(true);
1401
+ const units = [];
1402
+ let seq = "";
1403
+ const boxes = [];
1404
+ (function rec(n, box) {
1405
+ for (const c of [...n.childNodes]) {
1406
+ if (c.nodeType === 3) {
1407
+ let s = c.data;
1408
+ const ws = wsOf(n, live);
1409
+ if (!/^pre|break-spaces/.test(ws)) s = s.replace(/[\t\n\r ]+/g, " ");
1410
+ if (!s.length) {
1411
+ c.remove();
1412
+ continue;
1413
+ }
1414
+ units.push({ t: c, s, len: s.length, box });
1415
+ seq += s;
1416
+ } else if (c.nodeType === 1) {
1417
+ // media, and empty styled boxes (mask icons, dots, swatches) appear in one keystroke
1418
+ if (ATOMIC.has(c.localName) || c instanceof SVGElement || (!/\S/.test(c.textContent) && c.style && boxyStyle(c.style))) {
1419
+ units.push({ e: c, len: 1, box, on: true });
1420
+ seq += "□";
1421
+ } else {
1422
+ const b = c.style && boxyStyle(c.style) ? c : box;
1423
+ if (b === c) boxes.push(c);
1424
+ rec(c, b);
1425
+ }
1426
+ }
1427
+ }
1428
+ })(live, null);
1429
+ for (const u of units) {
1430
+ if (u.t) u.t.data = "";
1431
+ else {
1432
+ u.e.style.visibility = "hidden";
1433
+ u.on = false;
1434
+ }
1435
+ }
1436
+ for (const b of boxes) {
1437
+ b.style.visibility = "hidden";
1438
+ b.__on = false;
1439
+ }
1440
+ it.live = live;
1441
+ it.units = units;
1442
+ it.seq = seq;
1443
+ it.total = seq.length;
1444
+ it.shown = 0;
1445
+ it.rshown = 0;
1446
+ }
1447
+
1448
+ function reveal(it, n) {
1449
+ let left = n;
1450
+ let last = null;
1451
+ for (const u of it.units) {
1452
+ const k = left <= 0 ? 0 : left >= u.len ? u.len : left;
1453
+ left -= k;
1454
+ if (u.t) {
1455
+ const want = k === u.len ? u.s : u.s.slice(0, k);
1456
+ if (u.t.data !== want) u.t.data = want;
1457
+ if (k) last = u.t;
1458
+ } else {
1459
+ const on = k > 0;
1460
+ if (u.on !== on) {
1461
+ u.e.style.visibility = on ? "" : "hidden";
1462
+ u.on = on;
1463
+ }
1464
+ if (on) last = u.e;
1465
+ }
1466
+ if (k && u.box && u.box.__on === false) {
1467
+ for (let b = u.box; b && b !== it.live; b = b.parentNode)
1468
+ if (b.__on === false) {
1469
+ b.style.visibility = "";
1470
+ b.__on = true;
1471
+ }
1472
+ }
1473
+ }
1474
+ return last;
1475
+ }
1476
+
1477
+ // ------------------------------------------------------------------ styles
1478
+
1479
+ const STYLE = `
1480
+ :host{all:initial}
1481
+ *{box-sizing:border-box}
1482
+ .bg{position:absolute;inset:0;opacity:0;transition:opacity .5s ease}
1483
+ .bg.on{opacity:1}
1484
+ .layer,.port{position:absolute;overflow:hidden;pointer-events:none}
1485
+ .layer{inset:0}
1486
+ .world{position:absolute;left:0;top:0;will-change:transform}
1487
+ .it{position:absolute;left:0;top:0;transform-origin:0 0}
1488
+ .it.settle{transition:transform .3s cubic-bezier(.2,.8,.2,1)}
1489
+ .r{background-color:var(--ph,#D9D9D9);transition:background-color .22s ease,border-radius .22s ease}
1490
+ .r.st{background-color:transparent}
1491
+ .r>.f{position:absolute;inset:0;opacity:0;transition:opacity .22s ease,background-color .35s ease,border-radius .35s ease,box-shadow .35s ease,border-color .35s ease}
1492
+ .r.st>.f{opacity:1}
1493
+ .m{background-color:var(--ph,#D9D9D9);overflow:hidden}
1494
+ .m>.ph{position:absolute;left:50%;top:50%;width:22px;height:22px;transform:translate(-50%,-50%);opacity:.7;transition:opacity .2s}
1495
+ .m>.md{position:absolute;inset:0;width:100%;height:100%;opacity:0;clip-path:inset(0 0 100% 0);transition:opacity .35s ease,clip-path .6s cubic-bezier(.2,.75,.2,1)}
1496
+ .m.st{background-color:transparent;transition:background-color .3s ease .3s}
1497
+ .m.st>.ph{opacity:0}
1498
+ .m.st>.md{opacity:1;clip-path:inset(0 0 0 0)}
1499
+ .m>.md.swap{animation:swap .45s ease}
1500
+ @keyframes swap{from{opacity:0;filter:blur(6px)}}
1501
+ .m>.fr{position:absolute;inset:0;pointer-events:none}
1502
+ .l svg{position:absolute;left:0;top:0;overflow:visible}
1503
+ .bd{position:absolute;opacity:0;-webkit-backdrop-filter:saturate(180%) blur(14px);backdrop-filter:saturate(180%) blur(14px);box-shadow:0 1px 0 rgba(0,0,0,.06);pointer-events:none}
1504
+ .caret{display:inline-block;width:1.5px;height:1.1em;margin-left:1px;margin-right:-2.5px;vertical-align:text-bottom;background:currentColor;animation:blink 1s steps(1) infinite}
1505
+ @keyframes blink{50%{opacity:0}}
1506
+ .sa{background-color:rgba(13,153,255,.28)!important}
1507
+ .ov{position:absolute;left:0;top:0;width:100%;height:100%;overflow:visible;pointer-events:none}
1508
+ .badges,.cursors{position:absolute;inset:0;pointer-events:none;overflow:hidden}
1509
+ .badge{position:absolute;transform:translate(-50%);padding:1px 4px;border-radius:2px;color:#fff;font:600 11px/16px Inter,system-ui,-apple-system,"Segoe UI",sans-serif;white-space:nowrap;font-variant-numeric:tabular-nums}
1510
+ .cursor{position:absolute;left:0;top:0;will-change:transform}
1511
+ .cursor svg{position:absolute;overflow:visible;filter:drop-shadow(0 1px 1.5px rgba(0,0,0,.28))}
1512
+ .rulers{position:absolute;inset:0;pointer-events:none}
1513
+ .ruler{position:absolute;background:#2c2c2c;pointer-events:none}
1514
+ .ruler svg{display:block}
1515
+ .ruler-top{top:0;left:0;right:0;height:20px;border-bottom:1px solid #3a3a3a}
1516
+ .ruler-left{top:0;left:0;bottom:0;width:20px;border-right:1px solid #3a3a3a}
1517
+ .ruler-corner{position:absolute;top:0;left:0;width:20px;height:20px;background:#2c2c2c}
1518
+ .rtext{font:500 9px Inter,system-ui,-apple-system,sans-serif;fill:#8c8c8c}
1519
+ .rsel{fill:#0d99ff}
1520
+ .intro{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) scale(.92);opacity:0;transition:opacity .35s ease,transform .45s cubic-bezier(.2,.8,.2,1);pointer-events:none}
1521
+ .intro.on{opacity:1;transform:translate(-50%,-50%)}
1522
+ .intro.gone{opacity:0;transform:translate(-50%,-50%) scale(.9)}
1523
+ .intro img{display:block;width:60px;height:60px;object-fit:contain;filter:grayscale(1);opacity:.42;border-radius:14px}
1524
+ .intro .mono{width:60px;height:60px;border-radius:16px;background:#D7DADE;color:#fff;font:700 30px/60px Inter,system-ui,sans-serif;text-align:center}
1525
+ .hud{position:absolute;left:50%;bottom:20px;transform:translate(-50%,18px);opacity:0;display:flex;align-items:center;gap:10px;padding:6px 6px 6px 10px;background:rgba(30,30,30,.94);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);color:#fff;border-radius:12px;box-shadow:0 10px 30px rgba(0,0,0,.28),inset 0 0 0 1px rgba(255,255,255,.07);font:500 12px/1 Inter,system-ui,-apple-system,"Segoe UI",sans-serif;pointer-events:auto;transition:opacity .45s ease,transform .45s cubic-bezier(.2,.8,.2,1);white-space:nowrap;user-select:none;z-index:5}
1526
+ .hud.on{opacity:1;transform:translate(-50%,0)}
1527
+ .hud .dots{display:flex;padding-left:5px}
1528
+ .hud .dots i{display:block;width:18px;height:18px;margin-left:-5px;border-radius:50%;border:2px solid #1e1e1e;font:700 9px/14px Inter,system-ui,sans-serif;font-style:normal;text-align:center;color:#fff;transition:background .3s}
1529
+ .hud .txt,.hud .status{display:flex;align-items:center;gap:7px;color:#cfcfcf;font-variant-numeric:tabular-nums}
1530
+ .hud .txt b{color:#fff;font-weight:600}
1531
+ .hud .sep{width:1px;height:12px;background:#444}
1532
+ .hud .bar{width:64px;height:3px;border-radius:2px;background:#3a3a3a;overflow:hidden}
1533
+ .hud .bar i{display:block;height:100%;width:0;background:#0D99FF;transition:width .35s ease}
1534
+ .hud button{all:unset;cursor:pointer;padding:7px 10px;border-radius:8px;background:#2c2c2c;color:#fff;font:500 12px/1 Inter,system-ui,-apple-system,sans-serif;transition:background .15s}
1535
+ .hud button:hover{background:#3a3a3a}
1536
+ .hud button.pri{background:#0D99FF}
1537
+ .hud button.pri:hover{background:#2aa6ff}
1538
+ .hud button.on{background:rgba(13,153,255,.22);color:#8fd0ff}
1539
+ .hud button[hidden]{display:none}
1540
+ .hud .x{padding:7px 9px;color:#aaa}
1541
+ .hud button.snd{display:flex;align-items:center;gap:6px;padding:6px 8px}
1542
+ .hud button.snd.off{color:#8a8a8a}
1543
+ .hud button.snd.tap{background:#0D99FF;animation:hudpulse 1.6s ease-in-out infinite}
1544
+ @keyframes hudpulse{50%{box-shadow:0 0 0 4px rgba(13,153,255,.28)}}
1545
+ `;
1546
+
1547
+ // -------------------------------------------------------------------- env
1548
+
1549
+ function detectEnv() {
1550
+ const se = document.scrollingElement || document.documentElement;
1551
+ let scroller = se;
1552
+ let isRoot = true;
1553
+ if (se.scrollHeight <= innerHeight + 4) {
1554
+ let best = null;
1555
+ let bestArea = 0;
1556
+ const all = document.body ? document.body.getElementsByTagName("*") : [];
1557
+ for (let i = 0; i < all.length && i < 20000; i++) {
1558
+ const el = all[i];
1559
+ if (el.scrollHeight <= el.clientHeight + 40 || el.clientHeight < innerHeight * 0.4) continue;
1560
+ const oy = getComputedStyle(el).overflowY;
1561
+ if (!/(auto|scroll|overlay)/.test(oy)) continue;
1562
+ const r = el.getBoundingClientRect();
1563
+ const area = r.width * r.height;
1564
+ if (area > bestArea && r.width > innerWidth * 0.35) {
1565
+ best = el;
1566
+ bestArea = area;
1567
+ }
1568
+ }
1569
+ if (best) {
1570
+ scroller = best;
1571
+ isRoot = false;
1572
+ }
1573
+ }
1574
+ const env = {
1575
+ scroller,
1576
+ isRoot,
1577
+ host: null,
1578
+ pageBg: pageBackground(),
1579
+ geom() {
1580
+ if (isRoot) {
1581
+ return {
1582
+ ox: 0,
1583
+ oy: 0,
1584
+ sx: window.scrollX,
1585
+ sy: window.scrollY,
1586
+ vw: document.documentElement.clientWidth,
1587
+ vh: window.innerHeight,
1588
+ };
1589
+ }
1590
+ const r = scroller.getBoundingClientRect();
1591
+ return {
1592
+ ox: r.left + scroller.clientLeft,
1593
+ oy: r.top + scroller.clientTop,
1594
+ sx: scroller.scrollLeft,
1595
+ sy: scroller.scrollTop,
1596
+ vw: scroller.clientWidth,
1597
+ vh: scroller.clientHeight,
1598
+ };
1599
+ },
1600
+ scrollHeight: () => (isRoot ? Math.max(se.scrollHeight, document.body ? document.body.scrollHeight : 0) : scroller.scrollHeight),
1601
+ scrollWidth: () => (isRoot ? se.scrollWidth : scroller.scrollWidth),
1602
+ scrollTo(y, x) {
1603
+ const opts = { top: y, behavior: "instant" };
1604
+ if (x != null) opts.left = x;
1605
+ if (isRoot) window.scrollTo(opts);
1606
+ else scroller.scrollTo(opts);
1607
+ },
1608
+ scrollBy(dy) {
1609
+ if (isRoot) window.scrollBy({ top: dy, behavior: "instant" });
1610
+ else scroller.scrollBy({ top: dy, behavior: "instant" });
1611
+ },
1612
+ };
1613
+ return env;
1614
+ }
1615
+
1616
+ // ------------------------------------------------------------------- view
1617
+
1618
+ class View {
1619
+ constructor(env) {
1620
+ this.env = env;
1621
+ this.frozen = null;
1622
+ for (const old of document.querySelectorAll("swarm-rebuild")) old.remove();
1623
+ const host = document.createElement("swarm-rebuild");
1624
+ host.style.cssText =
1625
+ "all:initial !important;position:fixed !important;inset:0 !important;z-index:2147483647 !important;display:block !important;pointer-events:auto !important;contain:strict !important;opacity:1;transition:opacity .6s ease;";
1626
+ env.host = host;
1627
+ this.host = host;
1628
+ const root = host.attachShadow({ mode: "open" });
1629
+ this.root = root;
1630
+ root.innerHTML = `<style>${STYLE}</style>
1631
+ <div class="bg"></div>
1632
+ <div class="layer under"></div>
1633
+ <div class="port"><div class="world"></div></div>
1634
+ <div class="layer over"></div>
1635
+ <div class="intro"></div>
1636
+ <svg class="ov"></svg>
1637
+ <div class="badges"></div>
1638
+ <div class="cursors"></div>
1639
+ <div class="rulers"></div>
1640
+ `;
1641
+ const $ = (s) => root.querySelector(s);
1642
+ this.bg = $(".bg");
1643
+ this.under = $(".under");
1644
+ this.over = $(".over");
1645
+ this.port = $(".port");
1646
+ this.world = $(".world");
1647
+ this.intro = $(".intro");
1648
+ this.ov = $(".ov");
1649
+ this.badges = $(".badges");
1650
+ this.cursorsEl = $(".cursors");
1651
+ this.rulers = $(".rulers");
1652
+ // 控制条已移除(见 scripts/swarm-patch.mjs)。下面四个字段在各处被写,
1653
+ // 换成不挂在文档里的哑元素:写入照常成功,但屏幕上什么都不会出现。
1654
+ this.hud = $(".hud") || document.createElement("div");
1655
+ this.status = $(".status") || document.createElement("span");
1656
+ this.barFill = $(".bar i") || document.createElement("i");
1657
+ this.dots = $(".dots") || document.createElement("div");
1658
+ this.cursorEls = new Map();
1659
+ this._ovS = "";
1660
+ this._ovB = "";
1661
+ this._rulersOn = false;
1662
+
1663
+ const pb = env.pageBg;
1664
+ this.bg.style.backgroundColor = pb.color;
1665
+ const rgb = (pb.color.match(/[\d.]+/g) || [255, 255, 255]).map(Number);
1666
+ const lum = (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) / 255;
1667
+ if (lum < 0.4) this.host.style.setProperty("--ph", "#3B3D42");
1668
+ if (pb.image && pb.image !== "none") {
1669
+ this.bg.style.backgroundImage = pb.image;
1670
+ this.bg.style.backgroundSize = pb.size;
1671
+ this.bg.style.backgroundPosition = pb.position;
1672
+ this.bg.style.backgroundRepeat = pb.repeat;
1673
+ }
1674
+ this.makeIntroLogo();
1675
+ document.documentElement.appendChild(host);
1676
+ this.measure();
1677
+ }
1678
+
1679
+ makeIntroLogo() {
1680
+ const links = [...document.querySelectorAll('link[rel~="icon"], link[rel="apple-touch-icon"], link[rel="shortcut icon"]')];
1681
+ const sized = links
1682
+ .map((l) => ({ href: l.href, s: parseInt((l.getAttribute("sizes") || "0").split("x")[0], 10) || (l.rel.includes("apple") ? 180 : 32) }))
1683
+ .filter((l) => l.href)
1684
+ .sort((a, b) => b.s - a.s);
1685
+ const label = (document.title || location.hostname || "?").replace(/^www\./, "").trim();
1686
+ const mono = () => {
1687
+ this.intro.innerHTML = "";
1688
+ const d = document.createElement("div");
1689
+ d.className = "mono";
1690
+ d.textContent = (label[0] || "?").toUpperCase();
1691
+ this.intro.appendChild(d);
1692
+ };
1693
+ const href = sized.length ? sized[0].href : location.origin + "/favicon.ico";
1694
+ const img = document.createElement("img");
1695
+ img.onerror = mono;
1696
+ img.src = href;
1697
+ this.intro.appendChild(img);
1698
+ }
1699
+
1700
+ measure() {
1701
+ this.W = innerWidth;
1702
+ this.H = innerHeight;
1703
+ this.g = this.env.geom();
1704
+ const g = this.g;
1705
+ this.port.style.left = g.ox + "px";
1706
+ this.port.style.top = g.oy + "px";
1707
+ this.port.style.width = g.vw + "px";
1708
+ this.port.style.height = g.vh + "px";
1709
+ }
1710
+
1711
+ get vp() {
1712
+ if (this.frozen) return this.frozen;
1713
+ const g = this.env.geom();
1714
+ return { ...g, ox: this.g.ox, oy: this.g.oy };
1715
+ }
1716
+
1717
+ syncWorld() {
1718
+ const vp = this.vp;
1719
+ this.world.style.transform = `translate(${-vp.sx}px,${-vp.sy}px)`;
1720
+ }
1721
+
1722
+ mount(model) {
1723
+ this.world.style.width = model.docW + "px";
1724
+ this.world.style.height = model.docH + "px";
1725
+ const by = { world: [], under: [], over: [] };
1726
+ for (const it of model.items) by[it.layer].push(it);
1727
+ if (model.portKey != null) {
1728
+ // inner scroller: its scrolling content sits at the scroller's own place in
1729
+ // the stacking order (above its dialog/panel background, below later UI)
1730
+ const screen = [...by.under, ...by.over].sort((a, b) => a.key - b.key);
1731
+ const frag = document.createDocumentFragment();
1732
+ let placed = false;
1733
+ for (const it of screen) {
1734
+ if (!placed && it.key > model.portKey) {
1735
+ frag.appendChild(this.port);
1736
+ placed = true;
1737
+ }
1738
+ frag.appendChild(this.makeNode(it));
1739
+ }
1740
+ if (!placed) frag.appendChild(this.port);
1741
+ this.over.appendChild(frag);
1742
+ const wl = by.world.sort((a, b) => a.key - b.key);
1743
+ const wf = document.createDocumentFragment();
1744
+ for (const it of wl) wf.appendChild(this.makeNode(it));
1745
+ this.world.appendChild(wf);
1746
+ } else
1747
+ for (const layer of ["under", "world", "over"]) {
1748
+ const list = by[layer].sort((a, b) => a.key - b.key);
1749
+ const frag = document.createDocumentFragment();
1750
+ for (const it of list) frag.appendChild(this.makeNode(it));
1751
+ (layer === "world" ? this.world : this[layer]).appendChild(frag);
1752
+ }
1753
+ this.backdrops = (model.backdrops || []).map((b) => {
1754
+ const d = document.createElement("div");
1755
+ d.className = "bd";
1756
+ const rgb = (b.color.match(/[\d.]+/g) || [255, 255, 255]).slice(0, 3).join(",");
1757
+ d.setAttribute("style", `left:${b.x}px;top:${b.y}px;width:${b.w}px;height:${b.h}px;background:rgba(${rgb},.82);`);
1758
+ this.over.insertBefore(d, this.over.firstChild);
1759
+ return d;
1760
+ });
1761
+ this.bdStart = this.env.geom().sy;
1762
+ }
1763
+
1764
+ makeNode(it) {
1765
+ const n = document.createElement("div");
1766
+ n.style.display = "none";
1767
+ it.node = n;
1768
+ if (it.kind === "rect") {
1769
+ n.className = "it r";
1770
+ const f = document.createElement("div");
1771
+ f.className = "f";
1772
+ n.appendChild(f);
1773
+ } else if (it.kind === "image") {
1774
+ n.className = "it m";
1775
+ n.innerHTML = IMG_ICON;
1776
+ } else if (it.kind === "text") {
1777
+ n.className = "it t";
1778
+ prepText(it);
1779
+ } else if (it.kind === "line") {
1780
+ n.className = "it l";
1781
+ n.innerHTML = '<svg width="1" height="1"><path fill="none"/></svg>';
1782
+ }
1783
+ if (it.op < 0.999) n.style.opacity = it.op;
1784
+ if (it.blend) n.style.mixBlendMode = it.blend;
1785
+ return n;
1786
+ }
1787
+
1788
+ renderItem(it) {
1789
+ const tw = it.twinOf;
1790
+ if (tw && it._tv !== tw.v) {
1791
+ it._tv = tw.v;
1792
+ it.vis = tw.vis;
1793
+ it.shown = tw.total ? Math.round((tw.shown / tw.total) * it.total) : it.total;
1794
+ it.dx = tw.dx;
1795
+ it.dy = tw.dy;
1796
+ it.scale = tw.scale;
1797
+ it.done = tw.done;
1798
+ it.v++;
1799
+ }
1800
+ if (it.rv === it.v) return;
1801
+ it.rv = it.v;
1802
+ const n = it.node;
1803
+ if (!it.vis) {
1804
+ if (n.style.display !== "none") n.style.display = "none";
1805
+ return;
1806
+ }
1807
+ if (n.style.display === "none") n.style.display = "";
1808
+ if (it.settle) {
1809
+ it.settle = false;
1810
+ n.classList.add("settle");
1811
+ clearTimeout(n._st);
1812
+ n._st = setTimeout(() => n.classList.remove("settle"), 340);
1813
+ }
1814
+ if (it.kind === "line") {
1815
+ n.style.transform = `translate(${it.dx}px,${it.dy}px)`;
1816
+ const pts = it.pts.slice(0, it.placed);
1817
+ const d = pts.map((p, i) => `${i ? "L" : "M"}${p.x} ${p.y}`).join(" ") + (it.shut ? " Z" : "");
1818
+ const path = n.firstChild.firstChild;
1819
+ path.setAttribute("d", d);
1820
+ path.setAttribute("stroke", it.stroke);
1821
+ path.setAttribute("stroke-width", it.sw);
1822
+ path.setAttribute("stroke-linecap", "square");
1823
+ if (it.dash) path.setAttribute("stroke-dasharray", it.dash);
1824
+ return;
1825
+ }
1826
+ const w = it.draw ? it.draw.w : it.w;
1827
+ const h = it.draw ? it.draw.h : it.h;
1828
+ let tf = `translate(${it.x + it.dx}px,${it.y + it.dy}px)`;
1829
+ if (it.scale !== 1) tf += ` scale(${it.scale})`;
1830
+ n.style.transform = tf;
1831
+ n.style.width = w + "px";
1832
+ n.style.height = h + "px";
1833
+ const c = it.clip;
1834
+ n.style.clipPath =
1835
+ it.done && c ? `inset(${Math.max(0, c.t - it.y)}px ${Math.max(0, it.x + it.w - c.r)}px ${Math.max(0, it.y + it.h - c.b)}px ${Math.max(0, c.l - it.x)}px)` : "";
1836
+
1837
+ if (it.kind === "rect") {
1838
+ const css = it.mode === "master" ? it.copyOf.css : it.css;
1839
+ if (n._css !== css) {
1840
+ n.firstChild.setAttribute("style", css);
1841
+ n._css = css;
1842
+ }
1843
+ n.classList.toggle("st", !!it.styled);
1844
+ n.style.borderRadius = it.styled ? (it.mode === "master" ? it.copyOf.radius : it.radius) : "0px";
1845
+ } else if (it.kind === "image") {
1846
+ const src = it.mode === "master" ? it.copyOf : it;
1847
+ if (n._src !== src) {
1848
+ const old = n.querySelector(".md");
1849
+ const m = src.media.cloneNode(true);
1850
+ m.classList.add("md");
1851
+ if (old) {
1852
+ m.classList.add("swap");
1853
+ old.replaceWith(m);
1854
+ } else n.appendChild(m);
1855
+ if (m.localName === "video") {
1856
+ m.muted = true;
1857
+ const t = parseFloat(m.getAttribute("data-t"));
1858
+ if (t > 0) m.addEventListener("loadedmetadata", () => (m.currentTime = t), { once: true });
1859
+ if (m.hasAttribute("data-play")) m.play().catch(() => {});
1860
+ }
1861
+ n._src = src;
1862
+ if (src.frame) {
1863
+ let fr = n.querySelector(".fr");
1864
+ if (!fr) {
1865
+ fr = document.createElement("div");
1866
+ fr.className = "fr";
1867
+ n.appendChild(fr);
1868
+ }
1869
+ fr.setAttribute("style", src.frame + `border-radius:${src.radius};`);
1870
+ }
1871
+ }
1872
+ n.classList.toggle("st", !!it.styled);
1873
+ n.style.borderRadius = it.styled ? src.radius : "0px";
1874
+ n.style.boxShadow = it.styled ? src.shadow : "";
1875
+ n.style.backgroundColor = it.styled && src.bg ? src.bg : "";
1876
+ } else if (it.kind === "text") {
1877
+ let content;
1878
+ if (it.mode === "master") {
1879
+ if (!it._mv) it._mv = it.copyOf.full.cloneNode(true);
1880
+ content = it._mv;
1881
+ } else content = it.live;
1882
+ if (n.firstChild !== content) {
1883
+ n.textContent = "";
1884
+ n.appendChild(content);
1885
+ }
1886
+ if (it.mode !== "master" && it.rshown !== it.shown) {
1887
+ it._last = reveal(it, it.shown);
1888
+ it.rshown = it.shown;
1889
+ }
1890
+ content.classList.toggle("sa", !!it.selAll);
1891
+ const wantCaret = it.editing && !it.selAll && it.mode !== "master";
1892
+ if (wantCaret) {
1893
+ if (!it._caret) {
1894
+ it._caret = document.createElement("span");
1895
+ it._caret.className = "caret";
1896
+ }
1897
+ const last = it._last;
1898
+ if (last && last.parentNode) last.parentNode.insertBefore(it._caret, last.nextSibling);
1899
+ else content.insertBefore(it._caret, content.firstChild);
1900
+ } else if (it._caret && it._caret.parentNode) it._caret.remove();
1901
+ if (it.scale !== 1) {
1902
+ const ox = it.tb.x - it.x + (it.align === "center" ? it.tb.w / 2 : it.align === "right" || it.align === "end" ? it.tb.w : 0);
1903
+ n.style.transformOrigin = `${ox}px ${it.tb.y - it.y}px`;
1904
+ }
1905
+ }
1906
+ }
1907
+
1908
+ renderCursors(E) {
1909
+ const vp = E.vp;
1910
+ const seen = new Set();
1911
+ for (const c of E.cursors) {
1912
+ let el = this.cursorEls.get(c.id);
1913
+ if (!el) {
1914
+ el = document.createElement("div");
1915
+ el.className = "cursor";
1916
+ el.innerHTML = '<svg width="28" height="28" viewBox="0 0 28 28"></svg>';
1917
+ this.cursorsEl.appendChild(el);
1918
+ this.cursorEls.set(c.id, el);
1919
+ el._shape = "";
1920
+ }
1921
+ seen.add(c.id);
1922
+ const X = c.sticky ? c.x : c.x - vp.sx + vp.ox;
1923
+ const Y = c.sticky ? c.y : c.y - vp.sy + vp.oy;
1924
+ el.style.transform = `translate(${f1(X)}px,${f1(Y)}px)`;
1925
+ const svg = el.firstChild;
1926
+ if (el._shape !== c.shape) {
1927
+ svg.innerHTML = cursorSVG(c.shape, c.color);
1928
+ el._shape = c.shape;
1929
+ const [hx, hy] = HOT[c.shape];
1930
+ el._hx = hx;
1931
+ el._hy = hy;
1932
+ svg.style.transformOrigin = `${hx}px ${hy}px`;
1933
+ }
1934
+ const s = 1 - c.press * 0.12;
1935
+ svg.style.transform = `translate(${-el._hx}px,${-el._hy}px) rotate(${f1(c.angle)}deg) scale(${s})`;
1936
+ }
1937
+ for (const [id, el] of this.cursorEls)
1938
+ if (!seen.has(id)) {
1939
+ el.remove();
1940
+ this.cursorEls.delete(id);
1941
+ }
1942
+ }
1943
+
1944
+ renderOverlay(E) {
1945
+ const vp = E.vp;
1946
+ const W = this.W;
1947
+ const H = this.H;
1948
+ const X = (x, st) => f1(st ? x : x - vp.sx + vp.ox);
1949
+ const Y = (y, st) => f1(st ? y : y - vp.sy + vp.oy);
1950
+ let s = "";
1951
+ let b = "";
1952
+ for (const g of E.guides) {
1953
+ if (!g.vis || g.op <= 0) continue;
1954
+ const sw = 1 + g.flash;
1955
+ const flt = g.flash > 0 ? ` style="filter:drop-shadow(0 0 ${f1(2 + g.flash * 3)}px ${RED})"` : "";
1956
+ if (g.axis === "x") {
1957
+ const x = X(g.cur);
1958
+ s += `<line x1="${x}" x2="${x}" y1="0" y2="${H}" stroke="${RED}" stroke-width="${sw}" opacity="${f1(g.op)}"${flt}/>`;
1959
+ } else {
1960
+ const y = Y(g.cur);
1961
+ s += `<line x1="0" x2="${W}" y1="${y}" y2="${y}" stroke="${RED}" stroke-width="${sw}" opacity="${f1(g.op)}"${flt}/>`;
1962
+ }
1963
+ }
1964
+ for (const it of E.editLines) {
1965
+ const st = it.layer !== "world";
1966
+ const pts = it.pts.slice(0, it.placed).map((p) => [X(p.x + it.dx, st), Y(p.y + it.dy, st)]);
1967
+ const o = it.owner;
1968
+ const all = o ? [...pts, [X(o.x, o.sticky), Y(o.y, o.sticky)]] : pts;
1969
+ s += `<polyline points="${all.map((p) => p.join(",")).join(" ")}${it.shut && pts[0] ? " " + pts[0].join(",") : ""}" fill="none" stroke="${BLUE}" stroke-width="1.5"/>`;
1970
+ for (const p of pts) s += `<circle cx="${p[0]}" cy="${p[1]}" r="3.6" fill="#fff" stroke="${BLUE}" stroke-width="1.5"/>`;
1971
+ }
1972
+ const rt = E.rulers.t;
1973
+ for (const c of E.cursors) {
1974
+ if (!c.ov) continue;
1975
+ const st = c.sticky;
1976
+ for (const v of c.ov()) {
1977
+ if (v.t === "box") {
1978
+ const x = X(v.b.x, st);
1979
+ const y = Y(v.b.y, st);
1980
+ const w = f1(v.b.w);
1981
+ const h = f1(v.b.h);
1982
+ const col = v.color === "red" ? RED : BLUE;
1983
+ s += `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="none" stroke="${col}" stroke-width="1"/>`;
1984
+ if (v.underline && v.underline.w > 0) {
1985
+ const u = v.underline;
1986
+ s += `<line x1="${X(u.x, st)}" x2="${X(u.x + u.w, st)}" y1="${Y(u.y, st)}" y2="${Y(u.y, st)}" stroke="${BLUE}" stroke-width="1.5"/>`;
1987
+ }
1988
+ if (v.rulerLines && rt > 0.05)
1989
+ s += `<g stroke="${BLUE}" stroke-width="1" stroke-dasharray="2 2" opacity="${f1(rt)}"><line x1="${RULER}" x2="${x}" y1="${f1(y + h / 2)}" y2="${f1(y + h / 2)}"/><line x1="${f1(x + w / 2)}" x2="${f1(x + w / 2)}" y1="${RULER}" y2="${y}"/></g>`;
1990
+ if (v.handles)
1991
+ for (const [hx, hy] of [[x, y], [x + w, y], [x, y + h], [x + w, y + h]])
1992
+ s += `<rect x="${f1(hx - 3.5)}" y="${f1(hy - 3.5)}" width="7" height="7" fill="#fff" stroke="${col}" stroke-width="1"/>`;
1993
+ if (v.badge && w > 0) b += `<div class="badge" style="left:${f1(x + w / 2)}px;top:${f1(y + h + 8)}px;background:${col}">${v.badge}</div>`;
1994
+ } else if (v.t === "measure") {
1995
+ const a = { x: X(v.a.x, st), y: Y(v.a.y, st) };
1996
+ const z = { x: X(v.b.x, st), y: Y(v.b.y, st) };
1997
+ s += `<line x1="${a.x}" y1="${a.y}" x2="${z.x}" y2="${z.y}" stroke="${RED}" stroke-width="1"/>`;
1998
+ const horiz = a.y === z.y;
1999
+ b += `<div class="badge" style="left:${f1(horiz ? (a.x + z.x) / 2 : a.x + 18)}px;top:${f1(horiz ? a.y + 8 : (a.y + z.y) / 2 - 9)}px;background:${RED}">${v.label}</div>`;
2000
+ }
2001
+ }
2002
+ }
2003
+ if (s !== this._ovS) {
2004
+ this.ov.innerHTML = s;
2005
+ this._ovS = s;
2006
+ }
2007
+ if (b !== this._ovB) {
2008
+ this.badges.innerHTML = b;
2009
+ this._ovB = b;
2010
+ }
2011
+ }
2012
+
2013
+ renderRulers(E) {
2014
+ const t = E.rulers.t;
2015
+ if (t <= 0.001) {
2016
+ if (this._rulersOn) {
2017
+ this.rulers.innerHTML = "";
2018
+ this._rulersOn = false;
2019
+ }
2020
+ return;
2021
+ }
2022
+ this._rulersOn = true;
2023
+ const vp = E.vp;
2024
+ const W = this.W;
2025
+ const H = this.H;
2026
+ const step = 100;
2027
+ let u = "";
2028
+ let a = "";
2029
+ const x0 = Math.floor((vp.sx - vp.ox) / step) * step;
2030
+ for (let c = x0; c - vp.sx + vp.ox < W; c += step / 2) {
2031
+ const x = f1(c - vp.sx + vp.ox);
2032
+ const major = c % step === 0;
2033
+ u += `<line x1="${x}" x2="${x}" y1="${major ? 13 : 16}" y2="${RULER}" stroke="#5E5E5E"/>`;
2034
+ if (major) u += `<text x="${f1(x + 3)}" y="11" class="rtext">${c}</text>`;
2035
+ }
2036
+ const y0 = Math.floor((vp.sy - vp.oy) / step) * step;
2037
+ for (let c = y0; c - vp.sy + vp.oy < H; c += step / 2) {
2038
+ const y = f1(c - vp.sy + vp.oy);
2039
+ const major = c % step === 0;
2040
+ a += `<line y1="${y}" y2="${y}" x1="${major ? 13 : 16}" x2="${RULER}" stroke="#5E5E5E"/>`;
2041
+ if (major) a += `<text x="11" y="${f1(y - 3)}" class="rtext" transform="rotate(-90 11 ${f1(y - 3)})">${c}</text>`;
2042
+ }
2043
+ for (const c of E.cursors) {
2044
+ if (!c.ov || c.sticky) continue;
2045
+ for (const v of c.ov()) {
2046
+ if (v.t !== "box" || !v.handles) continue;
2047
+ const l = f1(v.b.x - vp.sx + vp.ox);
2048
+ const r = f1(v.b.x + v.b.w - vp.sx + vp.ox);
2049
+ const tp = f1(v.b.y - vp.sy + vp.oy);
2050
+ const bt = f1(v.b.y + v.b.h - vp.sy + vp.oy);
2051
+ u = `<rect x="${l}" y="0" width="${f1(r - l)}" height="${RULER}" fill="rgba(13,153,255,0.22)"/>` + u + `<text x="${f1(l + 3)}" y="11" class="rtext rsel">${Math.round(v.b.x)}</text>`;
2052
+ a = `<rect y="${tp}" x="0" height="${f1(bt - tp)}" width="${RULER}" fill="rgba(13,153,255,0.22)"/>` + a;
2053
+ }
2054
+ }
2055
+ for (const g of E.guides) {
2056
+ if (!g.dragging) continue;
2057
+ if (g.axis === "x") {
2058
+ const x = f1(g.cur - vp.sx + vp.ox);
2059
+ u += `<line x1="${x}" x2="${x}" y1="0" y2="${RULER}" stroke="${RED}"/><text x="${f1(x + 4)}" y="11" class="rtext" fill="${RED}" style="fill:${RED}">${Math.round(g.cur)}</text>`;
2060
+ } else {
2061
+ const y = f1(g.cur - vp.sy + vp.oy);
2062
+ a += `<line y1="${y}" y2="${y}" x1="0" x2="${RULER}" stroke="${RED}"/>`;
2063
+ }
2064
+ }
2065
+ this.rulers.innerHTML =
2066
+ `<div class="ruler ruler-top" style="transform:translateY(${f1((t - 1) * 100)}%)"><svg width="${W}" height="${RULER}">${u}</svg></div>` +
2067
+ `<div class="ruler ruler-left" style="transform:translateX(${f1((t - 1) * 100)}%)"><svg width="${RULER}" height="${H}">${a}</svg></div>` +
2068
+ `<div class="ruler-corner" style="opacity:${f1(t)}"></div>`;
2069
+ }
2070
+
2071
+ render(E) {
2072
+ this.syncWorld();
2073
+ if (this.backdrops && this.backdrops.length) {
2074
+ const o = f1(clamp((Math.abs(E.vp.sy - this.bdStart) - 8) / 80, 0, 1));
2075
+ if (o !== this._bdO) {
2076
+ this._bdO = o;
2077
+ for (const d of this.backdrops) d.style.opacity = o;
2078
+ }
2079
+ }
2080
+ if (E.items) for (const it of E.items) this.renderItem(it);
2081
+ this.renderOverlay(E);
2082
+ this.renderCursors(E);
2083
+ this.renderRulers(E);
2084
+ }
2085
+ }
2086
+
2087
+ // ----------------------------------------------------------------- engine
2088
+
2089
+ class Engine {
2090
+ constructor(view) {
2091
+ this.view = view;
2092
+ this.vp = view.vp;
2093
+ this.items = null;
2094
+ this.tasks = [];
2095
+ this.groups = [];
2096
+ this.guides = [];
2097
+ this.editLines = new Set();
2098
+ this.cursors = [];
2099
+ this.started = false;
2100
+ this.ready = false;
2101
+ this.frontier = 0;
2102
+ this.vel = 0;
2103
+ this.tempo = 1;
2104
+ this.speed = 1;
2105
+ this.time = 0;
2106
+ this.rulers = { want: 0, t: 0, lastNeed: -10 };
2107
+ this.lastScroll = null;
2108
+ this.nextId = 0;
2109
+ this.spawnCooldown = 0;
2110
+ this.flocking = false;
2111
+ this.lastT = null;
2112
+ this.tv = { x: 0, y: 0 };
2113
+ this.mouse = null;
2114
+ this.retired = 0;
2115
+ this.doneCount = 0;
2116
+ this.waitCount = 0;
2117
+ const vp = this.vp;
2118
+ for (let n = 0; n < INTRO_AGENTS; n++) {
2119
+ const side = n % 2 === 0;
2120
+ const p = this.fromViewport(side ? vp.ox - 40 - rand(0, 200) : vp.ox + vp.vw + 40 + rand(0, 200), vp.oy + rand(0.05, 0.95) * vp.vh);
2121
+ const c = this.makeCursor(p.x, p.y, n < MIN_AGENTS);
2122
+ c.follow = true;
2123
+ this.cursors.push(c);
2124
+ }
2125
+ }
2126
+
2127
+ setModel(m) {
2128
+ this.items = m.items;
2129
+ this.tasks = m.tasks;
2130
+ this.groups = m.groups;
2131
+ this.guides = m.guides;
2132
+ this.guideDeps = new Map(m.guides.map((g) => [g, g.deps || []]));
2133
+ this.waitCount = m.tasks.length;
2134
+ this.ready = true;
2135
+ }
2136
+
2137
+ makeCursor(x, y, home = false) {
2138
+ const id = this.nextId++;
2139
+ const fast = Math.random() < 0.25;
2140
+ return {
2141
+ id, x, y, home,
2142
+ name: NAMES[id % NAMES.length],
2143
+ color: COLORS[id % COLORS.length],
2144
+ shape: "arrow", press: 0, task: null, gen: null, ov: null,
2145
+ idleFor: 0, leaving: false, worked: false, sticky: false,
2146
+ vx: 0, vy: 0, angle: 0,
2147
+ speed: fast ? rand(1000, 1500) : rand(300, 750),
2148
+ force: fast ? rand(3500, 6000) : rand(700, 1800),
2149
+ spin: fast ? rand(2.2, 3.4) : rand(0.8, 1.8),
2150
+ curl: rand(-1, 1), orbit: rand(70, 170), follow: false,
2151
+ };
2152
+ }
2153
+
2154
+ get viewTop() {
2155
+ return this.vp.sy;
2156
+ }
2157
+ get viewBot() {
2158
+ return this.vp.sy + this.vp.vh;
2159
+ }
2160
+ fromViewport(x, y) {
2161
+ const vp = this.vp;
2162
+ return { x: x - vp.ox + vp.sx, y: y - vp.oy + vp.sy };
2163
+ }
2164
+ taskY(t) {
2165
+ return t.layer === "world" ? t.y : this.viewTop + t.y;
2166
+ }
2167
+
2168
+ // screen position of a cursor / an item (for panning and loudness)
2169
+ scr(c) {
2170
+ const vp = this.vp;
2171
+ return c.sticky ? { x: c.x, y: c.y } : { x: c.x - vp.sx + vp.ox, y: c.y - vp.sy + vp.oy };
2172
+ }
2173
+
2174
+ sfxPlace(kind, it, b) {
2175
+ if (!this.sfx) return;
2176
+ const vp = this.vp;
2177
+ b = b || this.bounds(it);
2178
+ let x = b.x + b.w / 2;
2179
+ let y = b.y + b.h / 2;
2180
+ if (!it || it.layer === "world") {
2181
+ x += vp.ox - vp.sx;
2182
+ y += vp.oy - vp.sy;
2183
+ }
2184
+ this.sfx.place(kind, x, y, b.w, b.h, vp.vw, vp.vh, (it && it.fs) || 0);
2185
+ }
2186
+
2187
+ start() {
2188
+ if (this.started || !this.ready) return;
2189
+ this.started = true;
2190
+ this.startTime = this.time;
2191
+ for (const c of this.cursors) {
2192
+ c.follow = false;
2193
+ c.gen = null;
2194
+ c.angle = ((((c.angle + 180) % 360) + 360) % 360) - 180;
2195
+ }
2196
+ if (this.sfx) this.sfx.start();
2197
+ if (this.onStart) this.onStart();
2198
+ }
2199
+
2200
+ bounds(it) {
2201
+ if (it.kind === "text") {
2202
+ let h = it.tb.h;
2203
+ if (it.editing && !it.selAll && it.total) {
2204
+ const lines = Math.max(1, Math.round(it.tb.h / it.lh));
2205
+ h = Math.min(it.tb.h, Math.max(it.lh, Math.ceil((it.shown / it.total) * lines) * it.lh));
2206
+ }
2207
+ const s = it.scale;
2208
+ const ax = it.align === "center" ? it.tb.w * (1 - s) / 2 : it.align === "right" || it.align === "end" ? it.tb.w * (1 - s) : 0;
2209
+ return { x: it.tb.x + it.dx + ax, y: it.tb.y + it.dy, w: it.tb.w * s, h: h * s };
2210
+ }
2211
+ if (it.kind === "line") {
2212
+ return { x: it.x + it.dx, y: it.y + it.dy, w: it.w, h: it.h };
2213
+ }
2214
+ return {
2215
+ x: it.x + it.dx,
2216
+ y: it.y + it.dy,
2217
+ w: it.draw ? it.draw.w : it.w,
2218
+ h: it.draw ? it.draw.h : it.h,
2219
+ };
2220
+ }
2221
+
2222
+ unionBounds(list) {
2223
+ const bs = list.map((it) => this.bounds(it));
2224
+ const x = Math.min(...bs.map((b) => b.x));
2225
+ const y = Math.min(...bs.map((b) => b.y));
2226
+ return { x, y, w: Math.max(...bs.map((b) => b.x + b.w)) - x, h: Math.max(...bs.map((b) => b.y + b.h)) - y };
2227
+ }
2228
+
2229
+ underline(it, b) {
2230
+ return { x: b.x, y: b.y + (it.lh * 0.5 + it.fs * 0.42) * it.scale, w: Math.min(b.w, it.tb.w * it.scale), h: 0 };
2231
+ }
2232
+
2233
+ selection(it, extra) {
2234
+ const b = this.bounds(it);
2235
+ return Object.assign(
2236
+ {
2237
+ t: "box",
2238
+ b,
2239
+ handles: true,
2240
+ badge: `${Math.round(b.w)} × ${Math.round(b.h)}`,
2241
+ rulerLines: true,
2242
+ underline: it.kind === "text" ? this.underline(it, b) : undefined,
2243
+ },
2244
+ extra,
2245
+ );
2246
+ }
2247
+
2248
+ available() {
2249
+ const out = [];
2250
+ for (const t of this.tasks) {
2251
+ if (t.state !== "wait") continue;
2252
+ if (t.layer === "world" && t.gate > this.frontier) continue;
2253
+ let ok = true;
2254
+ for (const d of t.deps)
2255
+ if (d.state !== "done") {
2256
+ ok = false;
2257
+ break;
2258
+ }
2259
+ if (ok) out.push(t);
2260
+ }
2261
+ return out;
2262
+ }
2263
+
2264
+ pick(c, list) {
2265
+ let best = null;
2266
+ let bestS = Infinity;
2267
+ const top = this.viewTop - 40;
2268
+ const bot = this.viewBot;
2269
+ const cy = c.sticky ? c.y + this.viewTop : c.y;
2270
+ for (const t of list) {
2271
+ const y = this.taskY(t);
2272
+ const u = y >= top && y <= bot ? 0 : y < top ? 1 : 2;
2273
+ const s = u * 1e6 + (u === 1 ? top - y : y) * 0.6 + Math.abs(y - cy) * 0.4 - (t.spec.type === "guide" ? 5e5 : 0);
2274
+ if (s < bestS) {
2275
+ bestS = s;
2276
+ best = t;
2277
+ }
2278
+ }
2279
+ return best;
2280
+ }
2281
+
2282
+ // work that's unlocked but unfinished (things past the frontier need scrolling, not waiting)
2283
+ backlog() {
2284
+ let n = 0;
2285
+ for (const t of this.tasks) if (t.state !== "done" && (t.layer !== "world" || t.gate <= this.frontier)) n++;
2286
+ return n;
2287
+ }
2288
+
2289
+ step(dt) {
2290
+ dt = Math.min(dt, 0.05);
2291
+ this.time += dt;
2292
+ const vp = (this.vp = this.view.vp);
2293
+ const sv = Math.abs(vp.sy - (this.lastScroll == null ? vp.sy : this.lastScroll)) / Math.max(dt, 0.001);
2294
+ this.lastScroll = vp.sy;
2295
+ this.vel = Math.max(this.vel * Math.exp(-dt * 1.2), this.vel * 0.7 + sv * 0.3);
2296
+ if (this.started) this.frontier = Math.max(this.frontier, this.viewBot);
2297
+ const avail = this.started ? this.available() : [];
2298
+ this.tempo = clamp(2.1 + this.vel / 900 + (avail.length / Math.max(1, this.cursors.length)) * 0.5, 2.1, 4.5) * this.speed;
2299
+
2300
+ if (this.rulers.want > 0) this.rulers.lastNeed = this.time;
2301
+ const rt = this.time - this.rulers.lastNeed < 0.7 ? 1 : 0;
2302
+ this.rulers.t += (rt - this.rulers.t) * Math.min(1, dt * 10);
2303
+ if (Math.abs(rt - this.rulers.t) < 0.002) this.rulers.t = rt;
2304
+
2305
+ for (const g of this.guides) {
2306
+ if (!g.vis) continue;
2307
+ if (g.flash > 0) g.flash = Math.max(0, g.flash - dt * 1.5);
2308
+ const deps = this.guideDeps.get(g) || [];
2309
+ if (g.shownAt == null) g.shownAt = this.time;
2310
+ if (!g.dragging && g.op > 0 && this.time - g.shownAt > 7 && !deps.some((t) => t.state === "active")) g.op = Math.max(0, g.op - dt * 1.2);
2311
+ else if (!g.dragging && deps.every((t) => t.state === "done")) {
2312
+ const last = Math.max(0, ...deps.map((t) => t.doneAt));
2313
+ if (this.time - last > 1.2 && g.op > 0) g.op = Math.max(0, g.op - dt * 1.2);
2314
+ }
2315
+ }
2316
+
2317
+ if (!this.flocking && this.started && this.waitCount === 0) {
2318
+ for (const c of this.cursors)
2319
+ if (!c.task && !c.follow) {
2320
+ c.follow = true;
2321
+ c.gen = null;
2322
+ c.leaving = false;
2323
+ c.ov = null;
2324
+ c.shape = "arrow";
2325
+ }
2326
+ if (this.cursors.every((c) => c.follow)) {
2327
+ this.flocking = true;
2328
+ this.endTime = this.time;
2329
+ if (this.onComplete) this.onComplete();
2330
+ }
2331
+ }
2332
+ if (this.flocking) {
2333
+ this.flock(dt);
2334
+ return;
2335
+ }
2336
+ if (!this.started) {
2337
+ const center = this.fromViewport(vp.ox + vp.vw / 2, vp.oy + vp.vh / 2);
2338
+ if (this.follow(dt, this.cursors, center, 0.7) && this.ready) this.start();
2339
+ return;
2340
+ }
2341
+ if (this.cursors.some((c) => c.follow))
2342
+ this.follow(
2343
+ dt,
2344
+ this.cursors.filter((c) => c.follow),
2345
+ );
2346
+
2347
+ this.spawnCooldown -= dt;
2348
+ const want = clamp(MIN_AGENTS + Math.floor(this.vel / 40) + Math.floor(avail.length / 2), MIN_AGENTS, MAX_AGENTS);
2349
+ const active = this.cursors.filter((c) => !c.leaving).length;
2350
+ const busy = this.cursors.filter((c) => c.task).length;
2351
+ if (active < want && avail.length > active - busy && this.spawnCooldown <= 0) {
2352
+ const left = Math.random() < 0.5;
2353
+ const p = this.fromViewport(left ? vp.ox - 40 : vp.ox + vp.vw + 40, vp.oy + rand(0.2, 0.8) * vp.vh);
2354
+ this.cursors.push(this.makeCursor(p.x, p.y));
2355
+ this.spawnCooldown = 0.035;
2356
+ }
2357
+
2358
+ for (const c of this.cursors) {
2359
+ if (c.task || c.leaving || c.follow) continue;
2360
+ const t = this.pick(c, avail);
2361
+ if (!t) continue;
2362
+ avail.splice(avail.indexOf(t), 1);
2363
+ t.state = "active";
2364
+ this.waitCount--;
2365
+ c.task = t;
2366
+ c.worked = true;
2367
+ c.idleFor = 0;
2368
+ if (t.layer !== "world") {
2369
+ c.sticky = true;
2370
+ c.x += vp.ox - vp.sx;
2371
+ c.y += vp.oy - vp.sy;
2372
+ }
2373
+ c.gen = this.run(c, t);
2374
+ }
2375
+
2376
+ for (const c of [...this.cursors]) {
2377
+ if (c.follow) continue;
2378
+ c.press = Math.max(0, c.press - dt * 6);
2379
+ if (c.angle !== 0) c.angle = Math.abs(c.angle) < 0.5 ? 0 : c.angle * (1 - Math.min(1, dt * 10));
2380
+ if (!c.task) {
2381
+ c.idleFor += dt;
2382
+ if (!c.leaving && c.idleFor > 2.5 && this.cursors.filter((m) => !m.leaving).length > MIN_AGENTS && !c.home) {
2383
+ c.leaving = true;
2384
+ c.gen = this.leave(c);
2385
+ }
2386
+ if (!c.gen) c.gen = this.idle(c);
2387
+ }
2388
+ if (c.gen.next(dt).done) {
2389
+ if (c.task) {
2390
+ c.task.state = "done";
2391
+ c.task.doneAt = this.time;
2392
+ this.doneCount++;
2393
+ c.task = null;
2394
+ c.ov = null;
2395
+ if (c.sticky) {
2396
+ c.sticky = false;
2397
+ c.x -= vp.ox - vp.sx;
2398
+ c.y -= vp.oy - vp.sy;
2399
+ }
2400
+ c.shape = "arrow";
2401
+ }
2402
+ c.gen = null;
2403
+ if (c.leaving) {
2404
+ if (c.worked) this.retired++;
2405
+ this.cursors.splice(this.cursors.indexOf(c), 1);
2406
+ }
2407
+ }
2408
+ }
2409
+ }
2410
+
2411
+ flockTarget() {
2412
+ if (this.mouse) return this.fromViewport(this.mouse.x, this.mouse.y);
2413
+ const t = this.time;
2414
+ const vp = this.vp;
2415
+ return this.fromViewport(vp.ox + vp.vw * (0.5 + 0.28 * Math.sin(t * 0.37)), vp.oy + vp.vh * (0.5 + 0.25 * Math.sin(t * 0.53 + 1)));
2416
+ }
2417
+
2418
+ flock(dt) {
2419
+ this.spawnCooldown -= dt;
2420
+ if (this.cursors.length < FLOCK_AGENTS && this.spawnCooldown <= 0 && !this.releasing) {
2421
+ const vp = this.vp;
2422
+ const left = Math.random() < 0.5;
2423
+ const p = this.fromViewport(left ? vp.ox - 30 : vp.ox + vp.vw + 30, vp.oy + rand(0.1, 0.9) * vp.vh);
2424
+ const c = this.makeCursor(p.x, p.y);
2425
+ c.follow = true;
2426
+ this.cursors.push(c);
2427
+ this.spawnCooldown = 0.04;
2428
+ }
2429
+ if (this.releasing) {
2430
+ for (const c of [...this.cursors]) {
2431
+ if (!c.gen) c.gen = this.leave(c);
2432
+ if (c.gen.next(dt).done) this.cursors.splice(this.cursors.indexOf(c), 1);
2433
+ }
2434
+ return;
2435
+ }
2436
+ this.follow(dt, this.cursors);
2437
+ }
2438
+
2439
+ // Boids-ish orbit around a target, ported from the original.
2440
+ follow(dt, list, target, scale = 1) {
2441
+ const m = this.mouse ? this.fromViewport(this.mouse.x, this.mouse.y) : null;
2442
+ const o = target || this.flockTarget();
2443
+ const time = this.time;
2444
+ if (m && this.lastT && dt > 0) {
2445
+ const k = Math.min(1, dt * 18);
2446
+ this.tv.x += ((m.x - this.lastT.x) / dt - this.tv.x) * k;
2447
+ this.tv.y += ((m.y - this.lastT.y) / dt - this.tv.y) * k;
2448
+ }
2449
+ this.lastT = m;
2450
+ const R = 135 * scale * (1 + 0.18 * Math.sin(time * 0.55));
2451
+ const swirl = Math.tanh(Math.cos(time * 0.11) * 3);
2452
+ const want = [];
2453
+ for (const c of list) {
2454
+ const vx = c.x - o.x;
2455
+ const vy = c.y - o.y;
2456
+ const d = Math.hypot(vx, vy) || 0.01;
2457
+ const nx = vx / d;
2458
+ const ny = vy / d;
2459
+ const ang = Math.atan2(vy, vx);
2460
+ const off = (c.orbit - 120) * 0.55 * scale;
2461
+ const rr = R * (1 + 0.3 * Math.sin(3 * ang + time * 0.7)) + off;
2462
+ const tang = 300 * (0.7 + c.spin * 0.2) * swirl;
2463
+ const rad = clamp((rr - d) * 4, -2600, 1400);
2464
+ const far = Math.min(1, Math.max(0, (d - rr) / 600));
2465
+ want.push([-ny * tang + nx * rad - ny * c.curl * 400 * far, nx * tang + ny * rad + nx * c.curl * 400 * far]);
2466
+ }
2467
+ for (let i = 0; i < list.length; i++) {
2468
+ const c = list[i];
2469
+ let ax = 0;
2470
+ let ay = 0;
2471
+ let wsum = 0;
2472
+ for (let j = 0; j < list.length; j++) {
2473
+ if (j === i) continue;
2474
+ const q = list[j];
2475
+ const d = Math.hypot(q.x - c.x, q.y - c.y);
2476
+ if (d > 90) continue;
2477
+ const w = 1 - d / 90;
2478
+ ax += q.vx * w;
2479
+ ay += q.vy * w;
2480
+ wsum += w;
2481
+ }
2482
+ let [fx, fy] = want[i];
2483
+ if (wsum > 0) {
2484
+ fx = fx * 0.7 + (ax / wsum) * 0.3;
2485
+ fy = fy * 0.7 + (ay / wsum) * 0.3;
2486
+ }
2487
+ const dist = Math.max(0, Math.hypot(c.x - o.x, c.y - o.y) - 180 * scale);
2488
+ const maxV = c.speed * (1.3 + Math.min(3.2, dist / 90));
2489
+ const L = Math.hypot(fx, fy);
2490
+ if (L > maxV) {
2491
+ fx *= maxV / L;
2492
+ fy *= maxV / L;
2493
+ }
2494
+ let gx = (fx - c.vx) * 6;
2495
+ let gy = (fy - c.vy) * 6;
2496
+ const maxF = c.force * (1.4 + Math.min(5, dist / 50));
2497
+ const G = Math.hypot(gx, gy);
2498
+ if (G > maxF) {
2499
+ gx *= maxF / G;
2500
+ gy *= maxF / G;
2501
+ }
2502
+ c.vx += gx * dt;
2503
+ c.vy += gy * dt;
2504
+ c.x += c.vx * dt;
2505
+ c.y += c.vy * dt;
2506
+ }
2507
+ const R0 = 48;
2508
+ let touched = false;
2509
+ if (m)
2510
+ for (const c of list) {
2511
+ const vx = c.x - m.x;
2512
+ const vy = c.y - m.y;
2513
+ const d = Math.hypot(vx, vy) || 0.01;
2514
+ if (d >= R0) continue;
2515
+ touched = true;
2516
+ const nx = vx / d;
2517
+ const ny = vy / d;
2518
+ c.x = m.x + nx * R0;
2519
+ c.y = m.y + ny * R0;
2520
+ const rel = (c.vx - this.tv.x) * nx + (c.vy - this.tv.y) * ny;
2521
+ if (rel < 0) {
2522
+ c.vx -= nx * rel * 1.6;
2523
+ c.vy -= ny * rel * 1.6;
2524
+ }
2525
+ c.vx += nx * 260;
2526
+ c.vy += ny * 260;
2527
+ }
2528
+ for (const c of list) {
2529
+ if (Math.hypot(c.vx, c.vy) < 30) continue;
2530
+ const d = ((((Math.atan2(c.vy, c.vx) * 180) / Math.PI + 128 - c.angle + 540) % 360) + 360) % 360 - 180;
2531
+ c.angle += d * Math.min(1, dt * 8);
2532
+ c.press = Math.max(0, c.press - dt * 6);
2533
+ }
2534
+ return touched;
2535
+ }
2536
+
2537
+ // ------------------------------------------------------- primitives
2538
+
2539
+ *wait(t) {
2540
+ let n = 0;
2541
+ while (n < t) n += (yield) * this.tempo;
2542
+ }
2543
+
2544
+ *moveTo(c, target, o = {}) {
2545
+ const get = typeof target === "function" ? target : () => target;
2546
+ const x0 = c.x;
2547
+ const y0 = c.y;
2548
+ const p0 = get();
2549
+ const dist = Math.hypot(p0.x - x0, p0.y - y0);
2550
+ if (dist < 0.5) {
2551
+ c.x = p0.x;
2552
+ c.y = p0.y;
2553
+ if (o.onStep) o.onStep();
2554
+ return;
2555
+ }
2556
+ const dur = clamp(0.12 + Math.sqrt(dist) * 0.021, 0.14, 0.9) / (o.speed || 1);
2557
+ const bend = (o.bend != null ? o.bend : (Math.random() < 0.5 ? -1 : 1) * rand(0.04, 0.14)) * Math.min(dist, 600);
2558
+ let t = 0;
2559
+ while (t < dur) {
2560
+ t += (yield) * this.tempo;
2561
+ const k = Math.min(1, t / dur);
2562
+ const e = o.drag || k < 0.15 ? easeInOut(k) : easeOut(k);
2563
+ const p = get();
2564
+ const dx = p.x - x0;
2565
+ const dy = p.y - y0;
2566
+ const L = Math.hypot(dx, dy) || 1;
2567
+ const off = Math.sin(Math.PI * e) * bend;
2568
+ c.x = x0 + dx * e + (-dy / L) * off;
2569
+ c.y = y0 + dy * e + (dx / L) * off;
2570
+ if (o.onStep) o.onStep();
2571
+ }
2572
+ const p = get();
2573
+ c.x = p.x;
2574
+ c.y = p.y;
2575
+ if (o.onStep) o.onStep();
2576
+ }
2577
+
2578
+ *click(c) {
2579
+ c.press = 1;
2580
+ if (this.sfx) this.sfx.press(this.scr(c).x, this.vp.vw);
2581
+ yield* this.wait(0.07);
2582
+ }
2583
+
2584
+ *type(it, c) {
2585
+ const N = it.total;
2586
+ const k = clamp(70 / Math.max(1, N), 0.06, 1);
2587
+ let budget = 0;
2588
+ let i = it.shown;
2589
+ while (i < N) {
2590
+ budget += (yield) * this.tempo;
2591
+ while (i < N && budget > 0) {
2592
+ const ch = it.seq[i];
2593
+ let d = rand(0.012, 0.04);
2594
+ if (ch === " ") d += rand(0, 0.03);
2595
+ if (ch === "\n") d += 0.1;
2596
+ if (Math.random() < 0.025) d += rand(0.08, 0.2);
2597
+ budget -= d * k;
2598
+ i++;
2599
+ }
2600
+ if (c && this.sfx && i > it.shown) this.sfx.key(this.scr(c).x, this.vp.vw, c.id, i - it.shown, /\s/.test(it.seq.slice(it.shown, i)) ? 1 : 0);
2601
+ it.shown = i;
2602
+ bump(it);
2603
+ }
2604
+ }
2605
+
2606
+ *idle(c) {
2607
+ for (;;) {
2608
+ const vp = this.vp;
2609
+ const top = this.viewTop;
2610
+ const bot = this.viewBot;
2611
+ const p = { x: rand(vp.sx + vp.vw * 0.17, vp.sx + vp.vw * 0.83), y: rand(top + (bot - top) * 0.2, top + (bot - top) * 0.8) };
2612
+ yield* this.moveTo(c, p, { speed: 0.35 });
2613
+ let w = rand(0.4, 1.4);
2614
+ while (w > 0) {
2615
+ w -= yield;
2616
+ if (c.y < this.viewTop || c.y > this.viewBot) break;
2617
+ }
2618
+ }
2619
+ }
2620
+
2621
+ *leave(c) {
2622
+ const right = c.x > this.vp.sx + this.vp.vw / 2;
2623
+ yield* this.moveTo(
2624
+ c,
2625
+ () => {
2626
+ const vp = this.vp;
2627
+ return this.fromViewport(right ? vp.ox + vp.vw + 60 : vp.ox - 60, c.y - this.viewTop + vp.oy);
2628
+ },
2629
+ { speed: 0.8 },
2630
+ );
2631
+ }
2632
+
2633
+ run(c, t) {
2634
+ const s = t.spec;
2635
+ switch (s.type) {
2636
+ case "rect":
2637
+ return this.drawBox(c, s.it, false);
2638
+ case "image":
2639
+ return this.drawBox(c, s.it, true);
2640
+ case "line":
2641
+ return this.drawLine(c, s.it);
2642
+ case "text":
2643
+ return this.drawText(c, s.it, s.snap || null, !!s.grow);
2644
+ case "guide":
2645
+ return this.dragGuide(c, s.g);
2646
+ case "dup":
2647
+ return this.duplicate(c, s.g, s.k);
2648
+ case "edit":
2649
+ return this.editText(c, s.it);
2650
+ case "restyle":
2651
+ case "swap":
2652
+ return this.restyle(c, s.it);
2653
+ }
2654
+ return this.wait(0.1);
2655
+ }
2656
+
2657
+ // Where a drag should end so a huge box doesn't drag the cursor off-screen.
2658
+ dragEnd(c, it) {
2659
+ const vp = this.vp;
2660
+ const screen = c.sticky;
2661
+ const right = screen ? vp.ox + vp.vw : vp.sx + vp.vw;
2662
+ const bot = screen ? vp.oy + vp.vh : this.viewBot;
2663
+ const x = it.x + it.w > right - 16 ? Math.max(it.x + Math.min(it.w, 40), right - 30) : it.x + it.w;
2664
+ const y = it.y + it.h > bot - 16 ? Math.max(it.y + Math.min(it.h, 40), bot - 40) : it.y + it.h;
2665
+ return { x: Math.min(x, it.x + it.w), y: Math.min(y, it.y + it.h) };
2666
+ }
2667
+
2668
+ *drawBox(c, it, isImg) {
2669
+ c.shape = "cross";
2670
+ yield* this.moveTo(c, { x: it.x, y: it.y });
2671
+ c.press = 1;
2672
+ it.vis = true;
2673
+ if (it.big) it.styled = true;
2674
+ it.draw = { w: 0, h: 0 };
2675
+ bump(it);
2676
+ c.ov = () => [this.selection(it)];
2677
+ const end = this.dragEnd(c, it);
2678
+ yield* this.moveTo(c, end, {
2679
+ speed: 0.75,
2680
+ drag: true,
2681
+ bend: 0,
2682
+ onStep: () => {
2683
+ it.draw = { w: Math.max(0, c.x - it.x), h: Math.max(0, c.y - it.y) };
2684
+ bump(it);
2685
+ },
2686
+ });
2687
+ if (end.x < it.x + it.w - 0.5 || end.y < it.y + it.h - 0.5) {
2688
+ const w0 = it.draw.w;
2689
+ const h0 = it.draw.h;
2690
+ let k = 0;
2691
+ while (k < 1) {
2692
+ k = Math.min(1, k + ((yield) * this.tempo) / 0.5);
2693
+ const e = easeOut(k);
2694
+ it.draw = { w: w0 + (it.w - w0) * e, h: h0 + (it.h - h0) * e };
2695
+ bump(it);
2696
+ }
2697
+ }
2698
+ it.draw = null;
2699
+ bump(it);
2700
+ c.shape = "arrow";
2701
+ yield* this.wait(rand(0.18, 0.3));
2702
+ it.styled = true;
2703
+ bump(it);
2704
+ this.sfxPlace(isImg ? "image" : "rect", it);
2705
+ yield* this.wait(isImg ? 0.5 : 0.35);
2706
+ c.ov = null;
2707
+ it.done = true;
2708
+ bump(it);
2709
+ }
2710
+
2711
+ *drawLine(c, it) {
2712
+ c.shape = "pen-add";
2713
+ yield* this.moveTo(c, it.pts[0]);
2714
+ yield* this.click(c);
2715
+ it.vis = true;
2716
+ it.edit = true;
2717
+ it.placed = 1;
2718
+ it.owner = c;
2719
+ bump(it);
2720
+ this.editLines.add(it);
2721
+ c.shape = "pen";
2722
+ for (let i = 1; i < it.pts.length; i++) {
2723
+ yield* this.moveTo(c, it.pts[i], { bend: rand(-0.03, 0.03) });
2724
+ yield* this.click(c);
2725
+ it.placed = i + 1;
2726
+ bump(it);
2727
+ yield* this.wait(rand(0.04, 0.12));
2728
+ }
2729
+ it.owner = null;
2730
+ bump(it);
2731
+ yield* this.moveTo(c, { x: c.x + rand(14, 30), y: c.y + rand(10, 24) }, { speed: 1.4 });
2732
+ yield* this.wait(0.3);
2733
+ it.edit = false;
2734
+ this.editLines.delete(it);
2735
+ it.done = true;
2736
+ bump(it);
2737
+ this.sfxPlace("line", it);
2738
+ }
2739
+
2740
+ *drawText(c, it, guide, grow) {
2741
+ if (guide) {
2742
+ if (guide.axis === "x") {
2743
+ it.dx = rand(28, 64);
2744
+ it.dy = rand(-8, 8);
2745
+ } else {
2746
+ it.dx = rand(-8, 8);
2747
+ it.dy = rand(18, 34) * (Math.random() < 0.5 ? -1 : 1);
2748
+ }
2749
+ }
2750
+ if (grow) it.scale = Math.min(1, 18 / it.fs);
2751
+ const b0 = this.bounds(it);
2752
+ c.shape = "cross";
2753
+ yield* this.moveTo(c, { x: b0.x, y: b0.y });
2754
+ const lineH = Math.min(b0.h, it.lh * it.scale);
2755
+ if (b0.w < 140 && it.tb.h < it.lh * 1.6) {
2756
+ yield* this.click(c);
2757
+ } else {
2758
+ c.press = 1;
2759
+ const ghost = { x: b0.x, y: b0.y, w: 0, h: 0 };
2760
+ c.ov = () => [{ t: "box", b: ghost, handles: true, badge: `${Math.round(ghost.w)} × ${Math.round(ghost.h)}`, rulerLines: true }];
2761
+ yield* this.moveTo(
2762
+ c,
2763
+ { x: b0.x + b0.w, y: b0.y + lineH },
2764
+ {
2765
+ speed: 0.9,
2766
+ drag: true,
2767
+ bend: 0,
2768
+ onStep: () => {
2769
+ ghost.w = Math.max(0, c.x - b0.x);
2770
+ ghost.h = Math.max(0, c.y - b0.y);
2771
+ },
2772
+ },
2773
+ );
2774
+ }
2775
+ it.vis = true;
2776
+ it.editing = true;
2777
+ it.shown = 0;
2778
+ bump(it);
2779
+ c.shape = "ibeam";
2780
+ c.ov = () => [{ t: "box", b: this.bounds(it) }];
2781
+ yield* this.moveTo(c, { x: c.x + rand(-30, 10), y: c.y + rand(10, 30) }, { speed: 1.5 });
2782
+ yield* this.type(it, c);
2783
+ yield* this.wait(0.12);
2784
+ it.editing = false;
2785
+ bump(it);
2786
+ this.sfxPlace("text", it);
2787
+ c.shape = "arrow";
2788
+ c.ov = () => [this.selection(it)];
2789
+ if (grow) {
2790
+ const steps = [24, 32, 40, 48, 56, 64, 72, 88, 104].filter((s) => s < it.fs && s > 18);
2791
+ let k = 0;
2792
+ for (const s of [...steps, it.fs]) {
2793
+ yield* this.wait(0.06);
2794
+ it.scale = s / it.fs;
2795
+ bump(it);
2796
+ if (this.sfx) this.sfx.grow(k++, this.scr(c).x, this.vp.vw);
2797
+ }
2798
+ it.scale = 1;
2799
+ bump(it);
2800
+ yield* this.wait(0.15);
2801
+ }
2802
+ if (guide) {
2803
+ // give the guide a moment to land; never block a cursor on it
2804
+ let waited = 0;
2805
+ while (guide.task.state !== "done" && waited < 1.6) waited += (yield) * this.tempo;
2806
+ if (guide.task.state !== "done") guide = null;
2807
+ }
2808
+ if (guide) {
2809
+ const u = this.bounds(it);
2810
+ const a = { x: u.x + Math.min(u.w * 0.4, 60) + rand(-6, 6), y: u.y + Math.min(u.h * 0.5, 22) };
2811
+ yield* this.moveTo(c, a);
2812
+ c.press = 1;
2813
+ const p = { x: a.x - it.dx, y: a.y - it.dy };
2814
+ const m = {
2815
+ x: p.x + (guide.axis === "x" ? -rand(2, 5) : rand(-6, 6)),
2816
+ y: p.y + (guide.axis === "y" ? -rand(2, 5) : rand(-3, 3)),
2817
+ };
2818
+ const drag = () => {
2819
+ let h = c.x - p.x;
2820
+ let v = c.y - p.y;
2821
+ if (guide.axis === "x" && Math.abs(h) < 9) {
2822
+ if (h !== 0 && it.dx !== 0) {
2823
+ if (guide.flash < 0.5 && this.sfx) this.sfx.snap(this.scr(c).x, this.vp.vw);
2824
+ guide.flash = 1;
2825
+ }
2826
+ h = 0;
2827
+ }
2828
+ if (guide.axis === "y" && Math.abs(v) < 9) {
2829
+ if (v !== 0 && it.dy !== 0) {
2830
+ if (guide.flash < 0.5 && this.sfx) this.sfx.snap(this.scr(c).x, this.vp.vw);
2831
+ guide.flash = 1;
2832
+ }
2833
+ v = 0;
2834
+ }
2835
+ it.dx = h;
2836
+ it.dy = v;
2837
+ bump(it);
2838
+ };
2839
+ yield* this.moveTo(c, m, { speed: 0.6, drag: true, onStep: drag });
2840
+ yield* this.wait(0.1);
2841
+ yield* this.moveTo(c, p, { speed: 2, onStep: drag });
2842
+ it.dx = 0;
2843
+ it.dy = 0;
2844
+ bump(it);
2845
+ yield* this.wait(0.25);
2846
+ } else if (it.dx || it.dy) {
2847
+ const dx0 = it.dx;
2848
+ const dy0 = it.dy;
2849
+ let k = 0;
2850
+ while (k < 1) {
2851
+ k = Math.min(1, k + ((yield) * this.tempo) / 0.3);
2852
+ const e = easeInOut(k);
2853
+ it.dx = dx0 * (1 - e);
2854
+ it.dy = dy0 * (1 - e);
2855
+ bump(it);
2856
+ }
2857
+ yield* this.wait(0.15);
2858
+ } else yield* this.wait(0.2);
2859
+ c.ov = null;
2860
+ it.done = true;
2861
+ bump(it);
2862
+ }
2863
+
2864
+ *dragGuide(c, g) {
2865
+ this.rulers.want++;
2866
+ try {
2867
+ const r = rand(-0.15, 0.15);
2868
+ const from = () => {
2869
+ const vp = this.vp;
2870
+ return g.axis === "x"
2871
+ ? this.fromViewport(vp.ox + 10, vp.oy + vp.vh * (0.45 + r))
2872
+ : this.fromViewport(vp.ox + vp.vw * 0.3 + r * 200, vp.oy + 10);
2873
+ };
2874
+ c.shape = "arrow";
2875
+ yield* this.moveTo(c, from);
2876
+ while (this.rulers.t < 0.95) yield;
2877
+ yield* this.moveTo(c, from, { speed: 3 });
2878
+ c.press = 1;
2879
+ c.shape = g.axis === "x" ? "ew" : "ns";
2880
+ g.vis = true;
2881
+ g.dragging = true;
2882
+ g.op = 1;
2883
+ const upd = () => {
2884
+ g.cur = g.axis === "x" ? c.x : c.y;
2885
+ };
2886
+ upd();
2887
+ const to = () => {
2888
+ const s = from();
2889
+ return g.axis === "x" ? { x: g.pos, y: s.y + 40 } : { x: s.x + 30, y: g.pos };
2890
+ };
2891
+ yield* this.moveTo(c, to, { speed: 0.55, drag: true, onStep: upd });
2892
+ g.cur = g.pos;
2893
+ g.dragging = false;
2894
+ if (this.sfx) {
2895
+ const p = this.scr(c);
2896
+ this.sfx.place("guide", p.x, p.y, 1, 1, this.vp.vw, this.vp.vh, 0);
2897
+ }
2898
+ c.shape = "arrow";
2899
+ yield* this.wait(0.25);
2900
+ } finally {
2901
+ this.rulers.want--;
2902
+ }
2903
+ }
2904
+
2905
+ *duplicate(c, g, k) {
2906
+ const dst = g.dst[k];
2907
+ let src = g.src;
2908
+ const cand = [g.src, ...g.dst.filter((d) => d.task.state === "done")];
2909
+ const dd = (a) => Math.hypot(a.box.x - dst.box.x, a.box.y - dst.box.y);
2910
+ for (const s of cand) if (dd(s) < dd(src)) src = s;
2911
+ const sb = this.unionBounds(src.items);
2912
+ c.shape = "arrow";
2913
+ const u = { x: sb.x + Math.min(sb.w * 0.45, 70) + rand(-8, 8), y: sb.y + Math.min(sb.h * 0.45, 30) + rand(-4, 4) };
2914
+ yield* this.moveTo(c, u);
2915
+ yield* this.click(c);
2916
+ c.ov = () => [{ t: "box", b: sb, handles: true, badge: `${Math.round(sb.w)} × ${Math.round(sb.h)}` }];
2917
+ yield* this.wait(rand(0.15, 0.3));
2918
+ c.shape = "dup";
2919
+ yield* this.wait(0.12);
2920
+ c.press = 1;
2921
+ const ox = src.box.x - dst.box.x;
2922
+ const oy = src.box.y - dst.box.y;
2923
+ for (const it of dst.items) {
2924
+ it.vis = true;
2925
+ it.mode = "master";
2926
+ it.dx = ox;
2927
+ it.dy = oy;
2928
+ if (it.kind === "rect" || it.kind === "image") {
2929
+ it.styled = true;
2930
+ it.draw = null;
2931
+ }
2932
+ if (it.kind === "line") {
2933
+ it.placed = it.pts.length;
2934
+ }
2935
+ bump(it);
2936
+ }
2937
+ const m = { x: u.x - ox, y: u.y - oy };
2938
+ c.ov = () => {
2939
+ const cb = this.unionBounds(dst.items);
2940
+ const h = [
2941
+ { t: "box", b: sb, color: "red" },
2942
+ { t: "box", b: cb, color: "red" },
2943
+ ];
2944
+ const sp = this.spacing(sb, cb);
2945
+ if (sp) h.push(sp);
2946
+ return h;
2947
+ };
2948
+ yield* this.moveTo(c, m, {
2949
+ speed: 0.6,
2950
+ drag: true,
2951
+ onStep: () => {
2952
+ for (const it of dst.items) {
2953
+ it.dx = ox + (c.x - u.x);
2954
+ it.dy = oy + (c.y - u.y);
2955
+ bump(it);
2956
+ }
2957
+ },
2958
+ });
2959
+ this.sfxPlace("dup", dst.items[0], this.unionBounds(dst.items));
2960
+ for (const it of dst.items) {
2961
+ it.dx = 0;
2962
+ it.dy = 0;
2963
+ it.settle = true;
2964
+ if (!it.editTask) it.mode = "own";
2965
+ if (it.kind === "text" && it.mode === "own") it.shown = it.total;
2966
+ bump(it);
2967
+ }
2968
+ yield* this.wait(0.1);
2969
+ c.shape = "arrow";
2970
+ c.ov = () => {
2971
+ const cb = this.unionBounds(dst.items);
2972
+ return [{ t: "box", b: cb, handles: true, badge: `${Math.round(cb.w)} × ${Math.round(cb.h)}` }];
2973
+ };
2974
+ yield* this.wait(0.35);
2975
+ for (const it of dst.items)
2976
+ if (!it.editTask) {
2977
+ it.done = true;
2978
+ bump(it);
2979
+ }
2980
+ }
2981
+
2982
+ spacing(a, b) {
2983
+ const oy = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
2984
+ const ox = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
2985
+ if (oy > 0) {
2986
+ const y = Math.max(a.y, b.y) + oy / 2;
2987
+ const [l, r] = a.x < b.x ? [a, b] : [b, a];
2988
+ const gap = r.x - (l.x + l.w);
2989
+ if (gap > 0) return { t: "measure", a: { x: l.x + l.w, y }, b: { x: r.x, y }, label: String(Math.round(gap)) };
2990
+ } else if (ox > 0) {
2991
+ const x = Math.max(a.x, b.x) + ox / 2;
2992
+ const [t, btm] = a.y < b.y ? [a, b] : [b, a];
2993
+ const gap = btm.y - (t.y + t.h);
2994
+ if (gap > 0) return { t: "measure", a: { x, y: t.y + t.h }, b: { x, y: btm.y }, label: String(Math.round(gap)) };
2995
+ }
2996
+ return null;
2997
+ }
2998
+
2999
+ *editText(c, it) {
3000
+ c.shape = "arrow";
3001
+ const b = this.bounds(it);
3002
+ yield* this.moveTo(c, { x: b.x + Math.min(b.w * 0.5, 50) + rand(-5, 5), y: b.y + Math.min(it.lh, b.h) * 0.55 });
3003
+ yield* this.click(c);
3004
+ yield* this.wait(0.08);
3005
+ yield* this.click(c);
3006
+ it.selAll = true;
3007
+ it.editing = true;
3008
+ bump(it);
3009
+ c.shape = "ibeam";
3010
+ c.ov = () => [{ t: "box", b: this.bounds(it) }];
3011
+ yield* this.wait(rand(0.2, 0.35));
3012
+ it.mode = "own";
3013
+ it.selAll = false;
3014
+ it.shown = 0;
3015
+ bump(it);
3016
+ yield* this.type(it, c);
3017
+ yield* this.wait(0.12);
3018
+ it.editing = false;
3019
+ bump(it);
3020
+ c.shape = "arrow";
3021
+ c.ov = () => [this.selection(it)];
3022
+ this.sfxPlace("edit", it);
3023
+ yield* this.wait(0.3);
3024
+ c.ov = null;
3025
+ it.done = true;
3026
+ bump(it);
3027
+ }
3028
+
3029
+ *restyle(c, it) {
3030
+ c.shape = "arrow";
3031
+ const b = this.bounds(it);
3032
+ yield* this.moveTo(c, { x: b.x + Math.min(b.w * 0.5, 60) + rand(-6, 6), y: b.y + Math.min(b.h * 0.5, 30) + rand(-4, 4) });
3033
+ yield* this.click(c);
3034
+ c.ov = () => [this.selection(it)];
3035
+ yield* this.wait(rand(0.15, 0.3));
3036
+ it.mode = "own";
3037
+ bump(it);
3038
+ this.sfxPlace(it.kind === "image" ? "image" : "restyle", it);
3039
+ yield* this.wait(0.4);
3040
+ c.ov = null;
3041
+ it.done = true;
3042
+ bump(it);
3043
+ }
3044
+ }
3045
+
3046
+ // ------------------------------------------------------------------ sound
3047
+
3048
+ // A glass piano in E. Every finished piece of the page plays one note of the
3049
+ // E major pentatonic, quantized to a soft 16th-note grid, so a busy swarm comes
3050
+ // out as a generative melody: big sections low, small things high, pitch rising
3051
+ // left to right, panned to where the cursor is. Typing and clicks are quiet
3052
+ // paper ticks underneath. All synthesized; no audio files.
3053
+ const PENTA = [0, 2, 4, 7, 9]; // E F# G# B C#
3054
+ const E2 = 40;
3055
+ const mtof = (m) => 440 * Math.pow(2, (m - 69) / 12);
3056
+ let soundPref = true;
3057
+ const SPK_ICON = '<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 6v4h2.5l3.5 3V3L5 6z" fill="currentColor"/>';
3058
+
3059
+ class Sound {
3060
+ constructor() {
3061
+ this.on = soundPref;
3062
+ this.ctx = null;
3063
+ this.bpm = 104;
3064
+ this.slots = new Map();
3065
+ this.lastKey = new Map();
3066
+ this.keyTimes = [];
3067
+ this.pressTimes = [];
3068
+ this.ends = []; // when each sounding piano voice dies out
3069
+ this.fakeNow = null; // offline renders drive the clock explicitly
3070
+ }
3071
+
3072
+ init(offline) {
3073
+ if (this.ctx) return true;
3074
+ try {
3075
+ const Ctx = window.AudioContext || window.webkitAudioContext;
3076
+ this.ctx = offline || new Ctx({ latencyHint: "interactive" });
3077
+ } catch (_) {
3078
+ return false;
3079
+ }
3080
+ const ctx = this.ctx;
3081
+ const out = ctx.createGain();
3082
+ out.gain.value = this.on ? 1 : 0;
3083
+ const comp = ctx.createDynamicsCompressor();
3084
+ comp.threshold.value = -18;
3085
+ comp.knee.value = 14;
3086
+ comp.ratio.value = 5;
3087
+ comp.attack.value = 0.003;
3088
+ comp.release.value = 0.3;
3089
+ const master = ctx.createGain();
3090
+ master.gain.value = 0.9;
3091
+ master.connect(comp);
3092
+ comp.connect(out);
3093
+ out.connect(ctx.destination);
3094
+ const verb = ctx.createConvolver();
3095
+ verb.buffer = this.impulse(3.2, 2.4);
3096
+ const wet = ctx.createGain();
3097
+ wet.gain.value = 0.5;
3098
+ verb.connect(wet);
3099
+ wet.connect(master);
3100
+ this.out = out;
3101
+ this.master = master;
3102
+ this.verb = verb;
3103
+ this.noiseBuf = this.noise(1.5);
3104
+ this.t0 = this.now();
3105
+ return true;
3106
+ }
3107
+
3108
+ now() {
3109
+ return this.fakeNow != null ? this.fakeNow : this.ctx.currentTime;
3110
+ }
3111
+
3112
+ // Generated room: decaying stereo noise, darkened as it rings out.
3113
+ impulse(secs, decay) {
3114
+ const sr = this.ctx.sampleRate;
3115
+ const n = Math.floor(sr * secs);
3116
+ const b = this.ctx.createBuffer(2, n, sr);
3117
+ for (let ch = 0; ch < 2; ch++) {
3118
+ const d = b.getChannelData(ch);
3119
+ let lp = 0;
3120
+ for (let i = 0; i < n; i++) {
3121
+ const k = i / n;
3122
+ const a = 0.55 - 0.4 * k; // lowpass closes over time: warm tail
3123
+ lp += a * (Math.random() * 2 - 1 - lp);
3124
+ d[i] = lp * Math.pow(1 - k, decay) * (i < sr * 0.012 ? i / (sr * 0.012) : 1);
3125
+ }
3126
+ }
3127
+ return b;
3128
+ }
3129
+
3130
+ noise(secs) {
3131
+ const sr = this.ctx.sampleRate;
3132
+ const b = this.ctx.createBuffer(1, Math.floor(sr * secs), sr);
3133
+ const d = b.getChannelData(0);
3134
+ for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
3135
+ return b;
3136
+ }
3137
+
3138
+ setOn(on) {
3139
+ this.on = soundPref = on;
3140
+ if (!this.ctx) return;
3141
+ const t = this.ctx.currentTime;
3142
+ this.out.gain.cancelScheduledValues(t);
3143
+ this.out.gain.setTargetAtTime(on ? 1 : 0, t, 0.05);
3144
+ if (on) this.resume();
3145
+ }
3146
+
3147
+ resume() {
3148
+ if (this.ctx && this.ctx.state === "suspended" && this.ctx.resume) this.ctx.resume().catch(() => {});
3149
+ }
3150
+
3151
+ get blocked() {
3152
+ return !!this.ctx && this.ctx.state !== "running" && this.fakeNow == null;
3153
+ }
3154
+
3155
+ log(fn, args) {
3156
+ if (Array.isArray(window.__swarmSfxLog)) window.__swarmSfxLog.push({ t: performance.now() / 1000, fn, args });
3157
+ }
3158
+
3159
+ ready() {
3160
+ return this.ctx && this.on && (this.fakeNow != null || this.ctx.state === "running");
3161
+ }
3162
+
3163
+ // next 16th-note on the grid (or 8th for slow, low events)
3164
+ grid(div = 4) {
3165
+ const step = 60 / this.bpm / div;
3166
+ const now = this.now() + 0.015;
3167
+ const k = Math.ceil((now - this.t0) / step);
3168
+ return { when: this.t0 + k * step, slot: `${div}:${k}` };
3169
+ }
3170
+
3171
+ pan(x, vw) {
3172
+ return clamp((x / Math.max(1, vw)) * 1.6 - 0.8, -0.8, 0.8);
3173
+ }
3174
+
3175
+ bus(when, pan, send) {
3176
+ const g = this.ctx.createGain();
3177
+ const p = this.ctx.createStereoPanner();
3178
+ p.pan.setValueAtTime(pan, when);
3179
+ g.connect(p);
3180
+ p.connect(this.master);
3181
+ if (send) {
3182
+ const s = this.ctx.createGain();
3183
+ s.gain.value = send;
3184
+ p.connect(s);
3185
+ s.connect(this.verb);
3186
+ }
3187
+ return g;
3188
+ }
3189
+
3190
+ // Glass piano: a few sine partials (one inharmonic, for the glass) with a
3191
+ // soft felt-hammer tick, long exponential ring.
3192
+ piano(midi, when, vel, pan, ring = 2.4) {
3193
+ const now = this.now();
3194
+ this.ends = this.ends.filter((e) => e > now);
3195
+ if (this.ends.length >= 28) return;
3196
+ const ctx = this.ctx;
3197
+ const f = mtof(midi);
3198
+ const v = this.bus(when, pan, 0.85);
3199
+ v.gain.value = 0.3 * vel;
3200
+ const hi = f > 900;
3201
+ const parts = [
3202
+ [1, 1, 1],
3203
+ [2, 0.38, 0.55],
3204
+ [3, 0.12, 0.32],
3205
+ [2.756, 0.16, 0.42],
3206
+ [5.4, 0.05, 0.16],
3207
+ ];
3208
+ let last = null;
3209
+ for (const [r, a, d] of parts) {
3210
+ if (f * r > 15000) continue;
3211
+ const o = ctx.createOscillator();
3212
+ o.type = "sine";
3213
+ o.frequency.value = f * r * (1 + (Math.random() - 0.5) * 0.002);
3214
+ const g = ctx.createGain();
3215
+ const dec = ring * d * (hi ? 0.65 : 1);
3216
+ g.gain.setValueAtTime(0, when);
3217
+ g.gain.linearRampToValueAtTime(a, when + 0.005);
3218
+ g.gain.exponentialRampToValueAtTime(0.0006, when + dec);
3219
+ o.connect(g);
3220
+ g.connect(v);
3221
+ o.start(when);
3222
+ o.stop(when + dec + 0.05);
3223
+ last = o;
3224
+ }
3225
+ this.hammer(when, v, f, 0.5);
3226
+ this.ends.push(when + ring * (hi ? 0.65 : 1));
3227
+ if (last) last.onended = () => v.disconnect();
3228
+ }
3229
+
3230
+ hammer(when, dest, f, amt) {
3231
+ const ctx = this.ctx;
3232
+ const src = ctx.createBufferSource();
3233
+ src.buffer = this.noiseBuf;
3234
+ const bp = ctx.createBiquadFilter();
3235
+ bp.type = "bandpass";
3236
+ bp.frequency.value = Math.min(9000, f * 4 + 1200);
3237
+ bp.Q.value = 1.2;
3238
+ const g = ctx.createGain();
3239
+ g.gain.setValueAtTime(0, when);
3240
+ g.gain.linearRampToValueAtTime(0.25 * amt, when + 0.001);
3241
+ g.gain.exponentialRampToValueAtTime(0.0005, when + 0.03);
3242
+ src.connect(bp);
3243
+ bp.connect(g);
3244
+ g.connect(dest);
3245
+ src.start(when, Math.random());
3246
+ src.stop(when + 0.05);
3247
+ }
3248
+
3249
+ // short filtered-noise ticks: typing and clicks
3250
+ tick(when, pan, freq, q, vel, len) {
3251
+ const ctx = this.ctx;
3252
+ const src = ctx.createBufferSource();
3253
+ src.buffer = this.noiseBuf;
3254
+ const bp = ctx.createBiquadFilter();
3255
+ bp.type = "bandpass";
3256
+ bp.frequency.value = freq;
3257
+ bp.Q.value = q;
3258
+ const g = this.bus(when, pan, 0.06);
3259
+ g.gain.setValueAtTime(0, when);
3260
+ g.gain.linearRampToValueAtTime(vel, when + 0.0008);
3261
+ g.gain.exponentialRampToValueAtTime(0.0004, when + len);
3262
+ src.connect(bp);
3263
+ bp.connect(g);
3264
+ src.start(when, Math.random() * 1.2);
3265
+ src.stop(when + len + 0.02);
3266
+ src.onended = () => g.disconnect();
3267
+ }
3268
+
3269
+ // ---- events (screen coords in; all plain numbers so a log can be replayed)
3270
+
3271
+ // one mechanical key: a sharp click on top of a short "thock" (deeper for space)
3272
+ clack(when, pan, space, vel = 1) {
3273
+ this.tick(when, pan, rand(3600, 5600), 2.2, rand(0.2, 0.3) * vel, rand(0.006, 0.011));
3274
+ this.tick(when + rand(0.001, 0.005), pan, space ? rand(380, 560) : rand(850, 1500), 1.6, (space ? 0.26 : rand(0.13, 0.2)) * vel, space ? 0.065 : rand(0.025, 0.042));
3275
+ }
3276
+
3277
+ key(x, vw, id, n = 1, space = 0) {
3278
+ this.log("key", [x, vw, id, n, space]);
3279
+ if (!this.ready()) return;
3280
+ const t = this.now();
3281
+ if (t - (this.lastKey.get(id) || -1) < 0.024) return;
3282
+ this.keyTimes = this.keyTimes.filter((k) => t - k < 1);
3283
+ if (this.keyTimes.length > 80) return;
3284
+ this.lastKey.set(id, t);
3285
+ const pan = this.pan(x, vw);
3286
+ // a burst of characters in one frame becomes a quick roll of keys
3287
+ let at = t + 0.004;
3288
+ for (let k = 0; k < Math.min(n, 3) && this.keyTimes.length <= 80; k++) {
3289
+ this.keyTimes.push(t);
3290
+ this.clack(at, pan, space && k === 0, rand(0.75, 1));
3291
+ at += rand(0.018, 0.032);
3292
+ }
3293
+ }
3294
+
3295
+ // a mouse button: press click, then a lighter release
3296
+ press(x, vw) {
3297
+ this.log("press", [x, vw]);
3298
+ if (!this.ready()) return;
3299
+ const t = this.now();
3300
+ this.pressTimes = this.pressTimes.filter((k) => t - k < 1);
3301
+ if (this.pressTimes.length > 40) return;
3302
+ this.pressTimes.push(t);
3303
+ const pan = this.pan(x, vw);
3304
+ this.tick(t + 0.004, pan, rand(2100, 2700), 4, 0.32, 0.012);
3305
+ this.tick(t + 0.005, pan, rand(650, 850), 2, 0.16, 0.022);
3306
+ this.tick(t + rand(0.055, 0.075), pan, rand(2900, 3400), 4, 0.15, 0.008);
3307
+ }
3308
+
3309
+ // a finished element: rect, image, text, line, dup, guide, edit, restyle
3310
+ place(kind, x, y, w, h, vw, vh, fs) {
3311
+ this.log("place", [kind, x, y, w, h, vw, vh, fs]);
3312
+ if (!this.ready()) return;
3313
+ // only boxes, media, duplicates and guides sing; text and lines stay percussive
3314
+ if (kind === "text" || kind === "edit" || kind === "line") return;
3315
+ const area = (w * h) / Math.max(1, vw * vh);
3316
+ let oct;
3317
+ if (kind === "text") oct = fs >= 40 ? 2 : fs >= 22 ? 3 : 4;
3318
+ else if (kind === "guide") oct = 5;
3319
+ else oct = area > 0.08 ? 1 : area > 0.015 ? 2 : area > 0.002 ? 3 : 4;
3320
+ const deg = Math.floor(clamp(x / Math.max(1, vw), 0, 0.999) * 5);
3321
+ const midi = E2 + oct * 12 + PENTA[deg];
3322
+ const { when, slot } = this.grid(2);
3323
+ const seen = this.slots.get(slot) || [];
3324
+ if (seen.length >= 1) return;
3325
+ seen.push(midi);
3326
+ this.slots.set(slot, seen);
3327
+ if (this.slots.size > 64) this.slots.delete(this.slots.keys().next().value);
3328
+ const off = y < -40 || y > vh + 40 ? 0.45 : 1;
3329
+ const pan = this.pan(x, vw);
3330
+ const vel = ({ rect: 0.55, image: 0.5, dup: 0.5, guide: 0.3, restyle: 0.34 }[kind] || 0.4) * 0.42;
3331
+ const ring = oct <= 1 ? 3.6 : oct >= 4 ? 1.6 : 2.4;
3332
+ this.piano(midi, when, vel * off, pan, ring);
3333
+ if (kind === "image") this.piano(midi + (PENTA[(deg + 2) % 5] - PENTA[deg] + 12) % 12, when + 0.04, vel * 0.6 * off, pan, ring);
3334
+ if (kind === "dup") this.piano(E2 + 12 + (deg % 2 ? 7 : 0), when, 0.15 * off, pan * 0.5, 2.8);
3335
+ }
3336
+
3337
+ // headings stepping up in size: a little rising run
3338
+ grow(step, x, vw) {
3339
+ this.log("grow", [step, x, vw]);
3340
+ if (!this.ready()) return;
3341
+ const t = this.now() + 0.004;
3342
+ this.tick(t, this.pan(x, vw), 2400 + step * 260, 5, 0.12, 0.01);
3343
+ }
3344
+
3345
+ snap(x, vw) {
3346
+ this.log("snap", [x, vw]);
3347
+ if (!this.ready()) return;
3348
+ const t = this.now() + 0.004;
3349
+ const pan = this.pan(x, vw);
3350
+ this.tick(t, pan, 4800, 3, 0.36, 0.012);
3351
+ this.tick(t + 0.002, pan, 1200, 2, 0.2, 0.03);
3352
+ }
3353
+
3354
+ // the page dissolving: an airy swell over a low open fifth
3355
+ intro() {
3356
+ this.log("intro", []);
3357
+ if (!this.ready()) return;
3358
+ const ctx = this.ctx;
3359
+ const t = this.now() + 0.02;
3360
+ const src = ctx.createBufferSource();
3361
+ src.buffer = this.noiseBuf;
3362
+ src.loop = true;
3363
+ const bp = ctx.createBiquadFilter();
3364
+ bp.type = "bandpass";
3365
+ bp.Q.value = 0.8;
3366
+ bp.frequency.setValueAtTime(260, t);
3367
+ bp.frequency.exponentialRampToValueAtTime(2400, t + 0.9);
3368
+ bp.frequency.exponentialRampToValueAtTime(900, t + 1.8);
3369
+ const g = this.bus(t, 0, 0.6);
3370
+ g.gain.setValueAtTime(0, t);
3371
+ g.gain.linearRampToValueAtTime(0.16, t + 0.7);
3372
+ g.gain.exponentialRampToValueAtTime(0.0005, t + 2.2);
3373
+ src.connect(bp);
3374
+ bp.connect(g);
3375
+ src.start(t);
3376
+ src.stop(t + 2.3);
3377
+ src.onended = () => g.disconnect();
3378
+ for (const [m, d] of [[E2, 0], [E2 + 7, 0.08], [E2 + 12, 0.16]]) this.pad(m, t + 0.3 + d, 0.07, 2.6);
3379
+ }
3380
+
3381
+ pad(midi, when, vel, len) {
3382
+ const ctx = this.ctx;
3383
+ const g = this.bus(when, 0, 0.9);
3384
+ g.gain.setValueAtTime(0, when);
3385
+ g.gain.linearRampToValueAtTime(vel, when + len * 0.4);
3386
+ g.gain.exponentialRampToValueAtTime(0.0005, when + len);
3387
+ for (const det of [-4, 4]) {
3388
+ const o = ctx.createOscillator();
3389
+ o.type = "triangle";
3390
+ o.frequency.value = mtof(midi);
3391
+ o.detune.value = det;
3392
+ o.connect(g);
3393
+ o.start(when);
3394
+ o.stop(when + len + 0.05);
3395
+ }
3396
+ }
3397
+
3398
+ // the swarm fans out: E G# B E arpeggio on the grid
3399
+ start() {
3400
+ this.log("start", []);
3401
+ if (!this.ready()) return;
3402
+ const { when } = this.grid(4);
3403
+ const step = 60 / this.bpm / 4;
3404
+ [E2 + 24, E2 + 28, E2 + 31, E2 + 36, E2 + 40].forEach((m, i) => this.piano(m, when + i * step, 0.2, -0.5 + i * 0.25, 2));
3405
+ }
3406
+
3407
+ // the last chord rings: E major add9, strummed
3408
+ finale() {
3409
+ this.log("finale", []);
3410
+ if (!this.ready()) return;
3411
+ const { when } = this.grid(2);
3412
+ const chord = [E2, E2 + 7, E2 + 12, E2 + 16, E2 + 19, E2 + 26, E2 + 28, E2 + 31, E2 + 36, E2 + 43];
3413
+ chord.forEach((m, i) => this.piano(m, when + i * 0.035, 0.34 - i * 0.015, -0.6 + (i / chord.length) * 1.2, 5.5));
3414
+ this.pad(E2 + 12, when, 0.05, 5);
3415
+ }
3416
+
3417
+ // back to the real page: a falling sweep
3418
+ release() {
3419
+ this.log("release", []);
3420
+ if (!this.ready()) return;
3421
+ const ctx = this.ctx;
3422
+ const t = this.now() + 0.01;
3423
+ const src = ctx.createBufferSource();
3424
+ src.buffer = this.noiseBuf;
3425
+ src.loop = true;
3426
+ const bp = ctx.createBiquadFilter();
3427
+ bp.type = "bandpass";
3428
+ bp.Q.value = 1;
3429
+ bp.frequency.setValueAtTime(3000, t);
3430
+ bp.frequency.exponentialRampToValueAtTime(220, t + 1);
3431
+ const g = this.bus(t, 0, 0.5);
3432
+ g.gain.setValueAtTime(0.12, t);
3433
+ g.gain.exponentialRampToValueAtTime(0.0005, t + 1.1);
3434
+ src.connect(bp);
3435
+ bp.connect(g);
3436
+ src.start(t);
3437
+ src.stop(t + 1.2);
3438
+ this.piano(E2 + 12, t + 0.05, 0.3, 0, 2.5);
3439
+ }
3440
+
3441
+ close(after = 0) {
3442
+ if (!this.ctx || this.fakeNow != null) return;
3443
+ const ctx = this.ctx;
3444
+ const t = ctx.currentTime;
3445
+ this.out.gain.cancelScheduledValues(t);
3446
+ this.out.gain.setValueAtTime(this.out.gain.value, t);
3447
+ this.out.gain.linearRampToValueAtTime(0, t + Math.max(0.05, after));
3448
+ setTimeout(() => ctx.close().catch(() => {}), (after + 0.2) * 1000);
3449
+ }
3450
+ }
3451
+
3452
+ // -------------------------------------------------------------------- app
3453
+
3454
+ class App {
3455
+ constructor() {
3456
+ this.dead = false;
3457
+ this.t0 = performance.now();
3458
+ this.tour = false;
3459
+ this.tourV = 0;
3460
+ this.tourAcc = 0;
3461
+ this.speeds = [1, 2, 3];
3462
+ this.speedIx = 0;
3463
+ this.complete = false;
3464
+ this.timing = {};
3465
+ }
3466
+
3467
+ async loadShot() {
3468
+ let src = window.__swarmShot;
3469
+ window.__swarmShot = null;
3470
+ if (src) lastShot = { src, at: this.env.geom() };
3471
+ else if (lastShot) src = lastShot.src; // Replay reuses the click-time screenshot
3472
+ if (!src) return null;
3473
+ const img = new Image();
3474
+ img.src = src;
3475
+ try {
3476
+ await img.decode();
3477
+ } catch (_) {
3478
+ return null;
3479
+ }
3480
+ const g = lastShot && lastShot.src === src ? lastShot.at : this.env.geom();
3481
+ return { img, sx: g.sx, sy: g.sy, scale: img.naturalWidth / innerWidth, valid: false };
3482
+ }
3483
+
3484
+ async run() {
3485
+ this.env = detectEnv();
3486
+ const shotP = this.loadShot();
3487
+ this.view = new View(this.env);
3488
+ this.engine = new Engine(this.view);
3489
+ this.sfx = new Sound();
3490
+ this.sfx.init();
3491
+ this.engine.sfx = this.sfx;
3492
+ this.engine.onStart = () => this.view.intro.classList.add("gone");
3493
+ this.engine.onComplete = () => this.onComplete();
3494
+ this.bind();
3495
+ this.last = performance.now();
3496
+ this.raf = requestAnimationFrame((t) => this.frame(t));
3497
+ await nextFrame();
3498
+ this.renderSoundBtn();
3499
+ this.sfx.intro();
3500
+ // 白底不做 .5s 淡入(见 scripts/swarm-patch.mjs):第一帧就盖死,别让原页面透出来。
3501
+ this.view.bg.style.setProperty("transition", "none", "important");
3502
+ this.view.bg.classList.add("on");
3503
+ this.view.intro.classList.add("on");
3504
+ this.view.hud.classList.add("on");
3505
+ const mark = (k) => (this.timing[k] = Math.round(performance.now() - this.t0));
3506
+ await sleep(520);
3507
+ await unhidePage();
3508
+ mark("blank");
3509
+ if (this.dead) return;
3510
+ // 等插件放行(它要先拍完快照),并且给开场留够时间:白幕→特效这段是
3511
+ // "空白 + 光标飞进来打转",太短会一闪而过(用户实测豆瓣那种快站看不到)。
3512
+ // 至少 2.8 秒、最多 5 秒,之后无论有没有信号都开始画,不能把页面卡在空白上。
3513
+ {
3514
+ const waitFrom = performance.now();
3515
+ while (
3516
+ (window.__dshSwarmGo !== true || performance.now() - waitFrom < 2800) &&
3517
+ !this.dead &&
3518
+ performance.now() - waitFrom < 5000
3519
+ ) {
3520
+ await new Promise((r) => setTimeout(r, 40));
3521
+ }
3522
+ }
3523
+ // 提前注入时 document_start 的 DOM 还没建全,滚动容器可能认错(异步渲染站)。
3524
+ // 放行之后页面已 load,重认一次滚动环境;**不动白底颜色**,避免中途变色闪一下。
3525
+ if (window.__dshSwarmEarly === true) {
3526
+ const oldTarget = this.env.isRoot ? window : this.env.scroller;
3527
+ const fresh = detectEnv();
3528
+ fresh.host = this.view.host;
3529
+ this.env = fresh;
3530
+ this.view.env = fresh;
3531
+ this.view.measure();
3532
+ // bind() 在开场就把 scroll 监听绑死在"当时的" scroller 上。重认后若换了容器,
3533
+ // 补一条到新容器,否则容器滚动时世界图层不跟随(2026-11-09 复核 find A)。
3534
+ // 旧监听留在原目标上无害:syncWorld() 每次读的都是新 env,幂等。
3535
+ const newTarget = fresh.isRoot ? window : fresh.scroller;
3536
+ if (newTarget !== oldTarget) {
3537
+ const sync = () => { if (!this.view.frozen) this.view.syncWorld(); };
3538
+ newTarget.addEventListener("scroll", sync, { passive: true });
3539
+ (this.unbinds || (this.unbinds = [])).push(() => newTarget.removeEventListener("scroll", sync, { passive: true }));
3540
+ }
3541
+ }
3542
+ await this.scan();
3543
+ mark("scan");
3544
+ if (this.dead) return;
3545
+ try {
3546
+ await Promise.race([document.fonts.ready, sleep(1200)]);
3547
+ } catch (_) {}
3548
+ mark("fonts");
3549
+ this.view.measure();
3550
+ const shot = await shotP;
3551
+ if (shot) {
3552
+ const g = this.env.geom();
3553
+ shot.valid = Math.abs(g.sx - shot.sx) < 1 && Math.abs(g.sy - shot.sy) < 1;
3554
+ }
3555
+ let model;
3556
+ try {
3557
+ model = captureModel(this.env, shot);
3558
+ } catch (err) {
3559
+ console.error("[swarm-rebuild] capture failed", err);
3560
+ model = { items: [], tasks: [], groups: [], guides: [], docW: 0, docH: 0 };
3561
+ }
3562
+ mark("capture");
3563
+ if (this.dead) return;
3564
+ this.view.mount(model);
3565
+ this.engine.setModel(model);
3566
+ this.model = model;
3567
+ mark("mount");
3568
+ const wait = 1500 - (performance.now() - this.t0);
3569
+ if (wait > 0) await sleep(wait);
3570
+ if (this.dead) return;
3571
+ this.engine.start();
3572
+ }
3573
+
3574
+ async scan() {
3575
+ const env = this.env;
3576
+ const g = env.geom();
3577
+ const H = env.scrollHeight();
3578
+ if (H <= g.vh * 1.25) return;
3579
+ this.view.frozen = { ...this.view.vp };
3580
+ const steps = Math.min(28, Math.ceil(H / (g.vh * 0.85)));
3581
+ for (let i = 1; i <= steps && !this.dead; i++) {
3582
+ env.scrollTo(Math.min(H, (H / steps) * i));
3583
+ await nextFrame();
3584
+ await sleep(30);
3585
+ }
3586
+ env.scrollTo(g.sy, g.sx);
3587
+ await nextFrame();
3588
+ await sleep(750);
3589
+ env.scrollTo(g.sy, g.sx);
3590
+ this.view.frozen = null;
3591
+ }
3592
+
3593
+ bind() {
3594
+ const on = (t, ev, fn, o) => {
3595
+ t.addEventListener(ev, fn, o);
3596
+ (this.unbinds || (this.unbinds = [])).push(() => t.removeEventListener(ev, fn, o));
3597
+ };
3598
+ const scrollTarget = this.env.isRoot ? window : this.env.scroller;
3599
+ on(scrollTarget, "scroll", () => {
3600
+ if (!this.view.frozen) this.view.syncWorld();
3601
+ }, { passive: true });
3602
+ on(window, "resize", () => this.view.measure());
3603
+ on(window, "wheel", () => {
3604
+ if (this.engine.ready) this.engine.start();
3605
+ }, { passive: true });
3606
+ on(window, "pointermove", (e) => {
3607
+ this.engine.mouse = { x: e.clientX, y: e.clientY };
3608
+ }, { passive: true });
3609
+ on(document.documentElement, "pointerleave", () => {
3610
+ this.engine.mouse = null;
3611
+ });
3612
+ on(window, "keydown", (e) => {
3613
+ if (e.key === "Escape") {
3614
+ e.stopPropagation();
3615
+ stop(false);
3616
+ } else if ((e.key === "m" || e.key === "M") && !e.metaKey && !e.ctrlKey && !e.altKey) {
3617
+ this.sfx.setOn(!this.sfx.on);
3618
+ this.renderSoundBtn();
3619
+ }
3620
+ this.unlockSound();
3621
+ }, true);
3622
+ // browsers only let audio start after a click/keypress on the page
3623
+ on(window, "pointerdown", () => this.unlockSound(), true);
3624
+ if (!this.env.isRoot) {
3625
+ on(this.view.host, "wheel", (e) => {
3626
+ e.preventDefault();
3627
+ this.env.scroller.scrollBy({ top: e.deltaY, left: e.deltaX, behavior: "instant" });
3628
+ }, { passive: false });
3629
+ }
3630
+ on(this.view.hud, "click", (e) => {
3631
+ const b = e.target.closest("button");
3632
+ if (!b) return;
3633
+ const a = b.dataset.a;
3634
+ if (a === "close") stop(false);
3635
+ else if (a === "live") stop(true);
3636
+ else if (a === "replay") restart();
3637
+ else if (a === "tour") {
3638
+ this.tour = !this.tour;
3639
+ b.classList.toggle("on", this.tour);
3640
+ b.textContent = this.tour ? "Touring" : "Tour";
3641
+ if (this.tour) this.engine.start();
3642
+ } else if (a === "sound") {
3643
+ if (this.sfx.on && this.sfx.blocked) this.sfx.resume();
3644
+ else this.sfx.setOn(!this.sfx.on);
3645
+ setTimeout(() => this.renderSoundBtn(), 60);
3646
+ } else if (a === "speed") {
3647
+ this.speedIx = (this.speedIx + 1) % this.speeds.length;
3648
+ this.engine.speed = this.speeds[this.speedIx];
3649
+ b.textContent = this.speeds[this.speedIx] + "×";
3650
+ }
3651
+ });
3652
+ }
3653
+
3654
+ unlockSound() {
3655
+ if (this.dead || !this.sfx || !this.sfx.blocked) return;
3656
+ this.sfx.resume();
3657
+ setTimeout(() => this.renderSoundBtn(), 80);
3658
+ }
3659
+
3660
+ renderSoundBtn() {
3661
+ const b = this.view.root.querySelector('[data-a="sound"]');
3662
+ if (!b || !this.sfx) return;
3663
+ const tap = this.sfx.on && this.sfx.blocked;
3664
+ const key = this.sfx.on ? (tap ? "tap" : "on") : "off";
3665
+ if (b._k === key) return;
3666
+ b._k = key;
3667
+ b.classList.toggle("off", key === "off");
3668
+ b.classList.toggle("tap", tap);
3669
+ const waves = '<path d="M11 5.5a3.5 3.5 0 0 1 0 5M12.8 3.6a6 6 0 0 1 0 8.8"/>';
3670
+ const mute = '<path d="M11 6l4 4M15 6l-4 4"/>';
3671
+ b.innerHTML = `${SPK_ICON}${key === "off" ? mute : waves}</svg>${tap ? "<span>Tap for sound</span>" : ""}`;
3672
+ b.title = key === "off" ? "Sound off (M)" : "Sound on (M)";
3673
+ }
3674
+
3675
+ frame(now) {
3676
+ if (this.dead) return;
3677
+ const dt = Math.min(0.05, (now - this.last) / 1000);
3678
+ this.last = now;
3679
+ try {
3680
+ this.engine.step(dt);
3681
+ if (this.tour) this.tourStep(dt);
3682
+ this.view.render(this.engine);
3683
+ if (!this._hudT || now - this._hudT > 200) {
3684
+ this._hudT = now;
3685
+ this.updateHud();
3686
+ this.renderSoundBtn();
3687
+ }
3688
+ } catch (err) {
3689
+ console.error("[swarm-rebuild]", err);
3690
+ }
3691
+ this.raf = requestAnimationFrame((t) => this.frame(t));
3692
+ }
3693
+
3694
+ tourStep(dt) {
3695
+ const E = this.engine;
3696
+ if (!E.started) return;
3697
+ const g = this.env.geom();
3698
+ const maxY = this.env.scrollHeight() - g.vh;
3699
+ if (g.sy >= maxY - 1) {
3700
+ this.tourV = 0;
3701
+ return;
3702
+ }
3703
+ const crew = Math.max(MIN_AGENTS, E.cursors.filter((c) => !c.leaving).length);
3704
+ const backlog = E.backlog();
3705
+ const target = backlog <= Math.max(4, crew * 0.6) ? 230 : backlog <= crew * 1.5 ? 80 : 0;
3706
+ this.tourV += (target - this.tourV) * Math.min(1, dt * 2.5);
3707
+ this.tourAcc += this.tourV * dt;
3708
+ if (this.tourAcc >= 1) {
3709
+ const d = Math.floor(this.tourAcc);
3710
+ this.tourAcc -= d;
3711
+ this.env.scrollBy(d);
3712
+ }
3713
+ }
3714
+
3715
+ updateHud() {
3716
+ const E = this.engine;
3717
+ const v = this.view;
3718
+ if (!v.hud.isConnected) return;
3719
+ if (!E.ready) return;
3720
+ const workers = E.cursors.filter((c) => c.worked).length + E.retired;
3721
+ const total = E.tasks.length;
3722
+ const done = E.doneCount;
3723
+ if (this.complete) return;
3724
+ if (!E.started) v.status.innerHTML = `<b>${E.cursors.length}</b> agents ready<span class="sep"></span>${total.toLocaleString()} layers`;
3725
+ else {
3726
+ const pend = E.tasks.length - done;
3727
+ const below = E.tasks.some((t) => t.state === "wait" && t.layer === "world" && t.gate > E.frontier);
3728
+ v.status.innerHTML =
3729
+ `<b>${workers}</b> agents<span class="sep"></span><b>${done.toLocaleString()}</b> / ${total.toLocaleString()} layers` +
3730
+ (below && pend && !this.tour && E.backlog() === 0 ? `<span class="sep"></span>scroll to keep building` : "");
3731
+ }
3732
+ v.barFill.style.width = total ? `${(done / total) * 100}%` : "0%";
3733
+ const busy = E.cursors.filter((c) => c.task).slice(-5);
3734
+ const key = busy.map((c) => c.id).join(",");
3735
+ if (key !== this._dots) {
3736
+ this._dots = key;
3737
+ v.dots.innerHTML = busy.map((c) => `<i style="background:${c.color}" title="${c.name}">${c.name[0]}</i>`).join("");
3738
+ }
3739
+ }
3740
+
3741
+ onComplete() {
3742
+ this.complete = true;
3743
+ if (this.sfx) this.sfx.finale();
3744
+ const E = this.engine;
3745
+ const v = this.view;
3746
+ const workers = E.cursors.filter((c) => c.worked).length + E.retired;
3747
+ const secs = (E.endTime - E.startTime).toFixed(1);
3748
+ v.status.innerHTML = `Rebuilt by <b>${workers}</b> agents in <b>${secs}s</b>`;
3749
+ v.barFill.style.width = "100%";
3750
+ v.dots.innerHTML = "";
3751
+ // 控制条已移除:逐个找、找到才设,找不到就跳过(原来这里是硬取 .hidden)。
3752
+ for (const [name, hidden] of [["live", false], ["replay", false], ["tour", true], ["speed", true]]) {
3753
+ const button = v.root.querySelector('[data-a="' + name + '"]');
3754
+ if (button) button.hidden = hidden;
3755
+ }
3756
+ this.tour = false;
3757
+ }
3758
+
3759
+ destroy(reveal) {
3760
+ unhidePage();
3761
+ if (this.sfx) {
3762
+ if (reveal) this.sfx.release();
3763
+ this.sfx.close(reveal ? 1.4 : 0.25);
3764
+ }
3765
+ const finish = () => {
3766
+ this.dead = true;
3767
+ cancelAnimationFrame(this.raf);
3768
+ for (const u of this.unbinds || []) u();
3769
+ this.view.host.remove();
3770
+ };
3771
+ if (reveal) {
3772
+ this.engine.releasing = true;
3773
+ this.engine.flocking = true;
3774
+ for (const c of this.engine.cursors) {
3775
+ c.gen = null;
3776
+ c.follow = false;
3777
+ }
3778
+ this.view.hud.classList.remove("on");
3779
+ setTimeout(() => {
3780
+ this.view.host.style.setProperty("opacity", "0", "important");
3781
+ }, 450);
3782
+ setTimeout(finish, 1150);
3783
+ } else {
3784
+ this.view.host.style.setProperty("transition", "opacity .25s ease", "important");
3785
+ this.view.host.style.setProperty("opacity", "0", "important");
3786
+ this.dead = true;
3787
+ cancelAnimationFrame(this.raf);
3788
+ for (const u of this.unbinds || []) u();
3789
+ setTimeout(() => this.view.host.remove(), 260);
3790
+ }
3791
+ }
3792
+ }
3793
+
3794
+ // background.js may have hidden everything but media for its screenshot;
3795
+ // put the page back once our canvas covers it (or if we bail out early).
3796
+ async function unhidePage() {
3797
+ if (!window.__swarmHidden) return;
3798
+ window.__swarmHidden = false;
3799
+ try {
3800
+ if (typeof chrome !== "undefined" && chrome.runtime && chrome.runtime.sendMessage) await chrome.runtime.sendMessage({ type: "swarm:unhide" });
3801
+ } catch (_) {}
3802
+ if (typeof window.__swarmUnhide === "function") {
3803
+ try {
3804
+ window.__swarmUnhide();
3805
+ } catch (_) {}
3806
+ }
3807
+ await nextFrame();
3808
+ }
3809
+
3810
+ let app = null;
3811
+ let lastShot = null;
3812
+
3813
+ function start() {
3814
+ if (app) return;
3815
+ app = new App();
3816
+ app.run().catch((err) => console.error("[swarm-rebuild]", err));
3817
+ }
3818
+
3819
+ function stop(reveal) {
3820
+ if (!app) return;
3821
+ const a = app;
3822
+ app = null;
3823
+ a.destroy(reveal);
3824
+ }
3825
+
3826
+ function restart() {
3827
+ if (app) {
3828
+ const a = app;
3829
+ app = null;
3830
+ a.dead = true;
3831
+ cancelAnimationFrame(a.raf);
3832
+ for (const u of a.unbinds || []) u();
3833
+ a.view.host.remove();
3834
+ if (a.sfx) a.sfx.close(0.1);
3835
+ // rebuild from where the first run started so screenshot crops line up again
3836
+ if (lastShot) a.env.scrollTo(lastShot.at.sy, lastShot.at.sx);
3837
+ }
3838
+ start();
3839
+ }
3840
+
3841
+ window[KEY] = {
3842
+ toggle() {
3843
+ if (app) stop(false);
3844
+ else start();
3845
+ },
3846
+ start,
3847
+ stop,
3848
+ get app() {
3849
+ return app;
3850
+ },
3851
+ debug: { captureModel, detectEnv, Sound },
3852
+ };
3853
+ // document_start 注入时 <html> 还没建好(实测 scrollingElement 为 null)。等解析器
3854
+ // 把 <html> 挂上来再启动,就能从第一帧用**原版自己的引擎**盖住页面:白底 + 18 个
3855
+ // 光标飞进来打转,全程只有这一套,不再有"自造白幕 → 切换引擎"的断层。
3856
+ const _dshBoot = () => {
3857
+ if (document.documentElement) { start(); return; }
3858
+ const mo = new MutationObserver(() => {
3859
+ if (document.documentElement) { mo.disconnect(); start(); }
3860
+ });
3861
+ mo.observe(document, { childList: true });
3862
+ };
3863
+ _dshBoot();
3864
+ })();