waygraph 0.15.21 → 0.15.23

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,1301 @@
1
+ // Moved verbatim from the former CHAIN_RUNNER_SCRIPT template string in cli.ts (see src/ARCHITECTURE.md).
2
+ // Runs inside the target project's own waygraph copy - keep it dependency-light and self-contained.
3
+ import { RING_CSS, WAYGRAPH_FAVICON } from "./overlay-css.js";
4
+ import { resolveTodoDockUi } from "../highlights.js";
5
+ import { join } from "node:path";
6
+ import { applyVideoDeviceStage } from "./device-stage.js";
7
+
8
+ export async function installOverlay(page, title) {
9
+ await page.addStyleTag({ content: RING_CSS }).catch(() => {});
10
+ // Default top-left; override with WAYGRAPH_TITLE_POS=left|center|right.
11
+ // Click cycles left -> center -> right (persisted in localStorage so a
12
+ // navigation that rebuilds the banner keeps the human's last pick).
13
+ const envPos = (process.env.WAYGRAPH_TITLE_POS || "left").toLowerCase();
14
+ const bannerPos = envPos === "center" || envPos === "right" ? envPos : "left";
15
+ const envTodoPos = (process.env.WAYGRAPH_TODO_POS || "left").toLowerCase();
16
+ const todoPos = envTodoPos === "right" ? "right" : "left";
17
+ const envAutoplay = process.env.WAYGRAPH_AUTOPLAY === "1";
18
+ const todoDockUi = resolveTodoDockUi();
19
+ await page
20
+ .evaluate(
21
+ ({ title, favicon, bannerPos, todoPos, envAutoplay, todoDockUi }) => {
22
+ window.__wgTodoDockUi = todoDockUi || {
23
+ compact: true,
24
+ cap: 5,
25
+ expandCap: 14,
26
+ collision: true,
27
+ behindRing: true,
28
+ };
29
+ // Seed the live autoplay toggle from the env default on first ever
30
+ // load only - a real navigation re-runs this, and re-stamping here
31
+ // would silently undo a human's mid-run checkbox click.
32
+ try {
33
+ if (localStorage.getItem("wg-autoplay") === null) {
34
+ localStorage.setItem("wg-autoplay", envAutoplay ? "1" : "0");
35
+ }
36
+ } catch {
37
+ /* private mode / blocked storage - falls back to manual gating */
38
+ }
39
+ if (!document.getElementById("wg-ring")) {
40
+ const ring = document.createElement("div");
41
+ ring.id = "wg-ring";
42
+ document.documentElement.appendChild(ring);
43
+ }
44
+ if (!document.getElementById("wg-ring-label")) {
45
+ const ringLabel = document.createElement("div");
46
+ ringLabel.id = "wg-ring-label";
47
+ document.documentElement.appendChild(ringLabel);
48
+ }
49
+ if (!document.getElementById("wg-cursor")) {
50
+ const cursor = document.createElement("div");
51
+ cursor.id = "wg-cursor";
52
+ // Dark fill + white stroke, same as help-center-clip-engine's own
53
+ // #clip-cursor - visible against any page background, light or
54
+ // dark, unlike a plain solid-white shape.
55
+ cursor.innerHTML =
56
+ "<svg viewBox='0 0 32 32' width='24' height='24'>" +
57
+ "<path fill='#0C0C1A' stroke='#fff' stroke-width='1.4' stroke-linejoin='round' " +
58
+ "d='M6 3.5l1.4 22.5 5.8-5.4 4.2 9.4 3.6-1.6-4.2-9.2H26z'/></svg>";
59
+ document.documentElement.appendChild(cursor);
60
+ }
61
+ if (!document.getElementById("wg-click-pulse")) {
62
+ const pulse = document.createElement("div");
63
+ pulse.id = "wg-click-pulse";
64
+ document.documentElement.appendChild(pulse);
65
+ }
66
+ window.__wgMoveCursorTo = (x, y, ms, instant) => {
67
+ const cursor = document.getElementById("wg-cursor");
68
+ if (!cursor) return;
69
+ cursor.style.setProperty("--wg-cursor-ms", (ms || 600) + "ms");
70
+ if (instant) {
71
+ const prev = cursor.style.transition;
72
+ cursor.style.transition = "none";
73
+ cursor.style.transform = "translate(" + x + "px," + y + "px)";
74
+ void cursor.offsetWidth;
75
+ cursor.style.transition = prev || "";
76
+ } else {
77
+ cursor.style.transform = "translate(" + x + "px," + y + "px)";
78
+ }
79
+ cursor.style.opacity = "1";
80
+ };
81
+ window.__wgHideCursor = () => {
82
+ const cursor = document.getElementById("wg-cursor");
83
+ if (cursor) cursor.style.opacity = "0";
84
+ };
85
+ window.__wgPositionRing = (box, label, tone, style) => {
86
+ const ring = document.getElementById("wg-ring");
87
+ const ringLabel = document.getElementById("wg-ring-label");
88
+ if (!ring || !ringLabel || !box) return;
89
+ const raw = (tone || "planned") + "";
90
+ const t =
91
+ raw === "auto" || raw === "info" || raw === "warning" || raw === "danger" || raw === "success"
92
+ ? raw
93
+ : "planned";
94
+ const st = style && typeof style === "object" ? style : {};
95
+ const sizeRaw = (st.size || "md") + "";
96
+ const size =
97
+ sizeRaw === "sm" || sizeRaw === "lg" ? sizeRaw : "md";
98
+ const weightRaw = (st.weight || "normal") + "";
99
+ const weight = weightRaw === "bold" ? "bold" : "normal";
100
+ ring.dataset.tone = t;
101
+ ringLabel.dataset.tone = t;
102
+ ring.dataset.size = size;
103
+ ringLabel.dataset.size = size;
104
+ ringLabel.dataset.weight = weight;
105
+ const pad = size === "sm" ? 3 : size === "lg" ? 10 : 6;
106
+ const margin = 6;
107
+ const vw = window.innerWidth;
108
+ const vh = window.innerHeight;
109
+ // Pin ring EXACTLY to the element - never clamp the ring away from
110
+ // the target (that looked "strapped on" the viewport).
111
+ const left = box.x - pad;
112
+ const top = box.y - pad;
113
+ const width = Math.max(4, box.width + pad * 2);
114
+ const height = Math.max(4, box.height + pad * 2);
115
+ ring.style.left = left + "px";
116
+ ring.style.top = top + "px";
117
+ ring.style.width = width + "px";
118
+ ring.style.height = height + "px";
119
+ ring.style.opacity = "1";
120
+ ringLabel.textContent = label || "";
121
+ ringLabel.style.opacity = "1";
122
+ const lw = ringLabel.offsetWidth;
123
+ const lh = ringLabel.offsetHeight;
124
+ let labelLeft = left;
125
+ let labelTop = top + height + 8;
126
+ if (labelTop + lh > vh - margin) labelTop = top - lh - 8;
127
+ if (labelTop < margin) labelTop = margin;
128
+ if (labelLeft + lw > vw - margin) labelLeft = Math.max(margin, vw - margin - lw);
129
+ if (labelLeft < margin) labelLeft = margin;
130
+ ringLabel.style.left = labelLeft + "px";
131
+ ringLabel.style.top = labelTop + "px";
132
+ if (window.__wgTodosSetBehind) window.__wgTodosSetBehind(true);
133
+ if (window.__wgTodosAvoidRingCollision) window.__wgTodosAvoidRingCollision(box);
134
+ };
135
+ /** Dim/lower floating todo docks while a ring caption is the focus. */
136
+ window.__wgTodosSetBehind = (on) => {
137
+ const ui = window.__wgTodoDockUi || {};
138
+ if (ui.behindRing === false) {
139
+ document.querySelectorAll(".wg-todo-dock, #wg-todo-dock").forEach((el) => {
140
+ el.classList.remove("wg-todo-behind");
141
+ });
142
+ return;
143
+ }
144
+ document.querySelectorAll(".wg-todo-dock, #wg-todo-dock").forEach((el) => {
145
+ el.classList.toggle("wg-todo-behind", !!on);
146
+ });
147
+ };
148
+ /**
149
+ * If the active ring box intersects a todo dock, flip L/R once.
150
+ * Locked until hideRing so follow-rAF does not thrash.
151
+ */
152
+ window.__wgTodosAvoidRingCollision = (box) => {
153
+ const ui = window.__wgTodoDockUi || {};
154
+ if (ui.collision === false) return;
155
+ if (!box || window.__wgTodoCollisionLocked) return;
156
+ const pad = 10;
157
+ const rx1 = box.x - pad;
158
+ const ry1 = box.y - pad;
159
+ const rx2 = box.x + box.width + pad;
160
+ const ry2 = box.y + box.height + pad;
161
+ const overlaps = (el) => {
162
+ const r = el.getBoundingClientRect();
163
+ return !(r.right < rx1 || r.left > rx2 || r.bottom < ry1 || r.top > ry2);
164
+ };
165
+ const docks = [...document.querySelectorAll(".wg-todo-dock, #wg-todo-dock")].filter(
166
+ (el, i, arr) => arr.indexOf(el) === i,
167
+ );
168
+ for (const dock of docks) {
169
+ if (!overlaps(dock)) continue;
170
+ const cur = dock.dataset.pos === "right" ? "right" : "left";
171
+ dock.dataset.pos = cur === "right" ? "left" : "right";
172
+ try {
173
+ localStorage.setItem("wg-todo-pos", dock.dataset.pos);
174
+ } catch {
175
+ /* ignore */
176
+ }
177
+ // Still colliding after flip - tuck (shorter max-height).
178
+ if (overlaps(dock)) dock.dataset.tucked = "1";
179
+ else delete dock.dataset.tucked;
180
+ window.__wgTodoCollisionLocked = true;
181
+ break;
182
+ }
183
+ };
184
+ /** Live-follow a selector with rAF (accurate under device shell scale). */
185
+ window.__wgStopRingFollow = () => {
186
+ if (window.__wgRingFollowRaf) {
187
+ cancelAnimationFrame(window.__wgRingFollowRaf);
188
+ window.__wgRingFollowRaf = 0;
189
+ }
190
+ window.__wgRingFollowSel = "";
191
+ };
192
+ window.__wgFollowRing = (sel, label, tone, style, focus) => {
193
+ window.__wgStopRingFollow();
194
+ if (!sel) return;
195
+ window.__wgRingFollowSel = sel;
196
+ const tick = () => {
197
+ if (window.__wgRingFollowSel !== sel) return;
198
+ const el = document.querySelector(sel);
199
+ if (!el) {
200
+ window.__wgRingFollowRaf = requestAnimationFrame(tick);
201
+ return;
202
+ }
203
+ const r = el.getBoundingClientRect();
204
+ const box = { x: r.x, y: r.y, width: r.width, height: r.height };
205
+ if (window.__wgPositionRing) {
206
+ window.__wgPositionRing(box, label, tone || "planned", style || {});
207
+ }
208
+ if (focus && window.__wgApplyFocus) window.__wgApplyFocus(box);
209
+ window.__wgRingFollowRaf = requestAnimationFrame(tick);
210
+ };
211
+ tick();
212
+ };
213
+ window.__wgClickPulse = (x, y, tone) => {
214
+ const pulse = document.getElementById("wg-click-pulse");
215
+ if (!pulse) return;
216
+ const raw = (tone || "planned") + "";
217
+ pulse.dataset.tone =
218
+ raw === "auto" || raw === "info" || raw === "warning" || raw === "danger" || raw === "success"
219
+ ? raw
220
+ : "planned";
221
+ pulse.style.left = x + "px";
222
+ pulse.style.top = y + "px";
223
+ pulse.classList.remove("wg-pulse");
224
+ void pulse.offsetWidth;
225
+ pulse.classList.add("wg-pulse");
226
+ };
227
+ window.__wgHideRing = (opts) => {
228
+ // While a narrate() call owns the ring (mid multi-step action),
229
+ // the auto-highlight click/fill patches' own end-of-step hide is
230
+ // a no-op - narrate() itself clears it once the WHOLE wrapped
231
+ // action finishes, not just its first sub-step.
232
+ if (window.__wgNarrateOwnsRing) return;
233
+ if (window.__wgStopRingFollow) window.__wgStopRingFollow();
234
+ const ring = document.getElementById("wg-ring");
235
+ const ringLabel = document.getElementById("wg-ring-label");
236
+ if (ring) ring.style.opacity = "0";
237
+ if (ringLabel) ringLabel.style.opacity = "0";
238
+ if (window.__wgClearFocus) window.__wgClearFocus();
239
+ if (window.__wgTodosSetBehind) window.__wgTodosSetBehind(false);
240
+ window.__wgTodoCollisionLocked = false;
241
+ document.querySelectorAll(".wg-todo-dock[data-tucked], #wg-todo-dock[data-tucked]").forEach((el) => {
242
+ delete el.dataset.tucked;
243
+ });
244
+ // Honor zoomOut:false - keep camera until next zoom / desktop clear.
245
+ const forceClear = opts && opts.clearZoom === true;
246
+ const skipClear = opts && opts.clearZoom === false;
247
+ if (
248
+ !skipClear &&
249
+ (forceClear || window.__wgZoomOutOnHide !== false) &&
250
+ window.__wgClearZoom
251
+ ) {
252
+ window.__wgClearZoom();
253
+ }
254
+ };
255
+ window.__wgClearFocus = () => {
256
+ const veil = document.getElementById("wg-focus-veil");
257
+ if (!veil) return;
258
+ veil.classList.remove("wg-in");
259
+ setTimeout(() => {
260
+ const v = document.getElementById("wg-focus-veil");
261
+ if (v && !v.classList.contains("wg-in")) v.remove();
262
+ }, 320);
263
+ };
264
+ window.__wgApplyFocus = (box) => {
265
+ if (!box) {
266
+ if (window.__wgClearFocus) window.__wgClearFocus();
267
+ return;
268
+ }
269
+ let veil = document.getElementById("wg-focus-veil");
270
+ if (!veil) {
271
+ veil = document.createElement("div");
272
+ veil.id = "wg-focus-veil";
273
+ veil.setAttribute("data-wg-ui", "1");
274
+ document.documentElement.appendChild(veil);
275
+ }
276
+ const pad = 10;
277
+ veil.style.left = Math.max(0, box.x - pad) + "px";
278
+ veil.style.top = Math.max(0, box.y - pad) + "px";
279
+ veil.style.width = Math.max(8, box.width + pad * 2) + "px";
280
+ veil.style.height = Math.max(8, box.height + pad * 2) + "px";
281
+ void veil.offsetWidth;
282
+ veil.classList.add("wg-in");
283
+ };
284
+ /**
285
+ * Finger swipe trail for touch theater (orientation rotate / device change).
286
+ * opts: { dir: 'left'|'right'|'up'|'down', label?, ms? }
287
+ * Returns duration waited (ms) via Promise when called from page.evaluate async.
288
+ */
289
+ window.__wgSwipeTrail = (opts) => {
290
+ const o = opts || {};
291
+ const dir = o.dir === "right" || o.dir === "up" || o.dir === "down" ? o.dir : "left";
292
+ const ms = Math.max(280, Math.min(1400, Number(o.ms) || 720));
293
+ const label =
294
+ o.label ||
295
+ (dir === "left" || dir === "right" ? "swipe" : "swipe") +
296
+ (o.hint ? " \u00b7 " + o.hint : "");
297
+ let layer = document.getElementById("wg-swipe-layer");
298
+ if (layer && layer._wgTimer) {
299
+ clearTimeout(layer._wgTimer);
300
+ layer._wgTimer = null;
301
+ }
302
+ if (layer) layer.remove();
303
+ layer = document.createElement("div");
304
+ layer.id = "wg-swipe-layer";
305
+ layer.setAttribute("data-wg-ui", "1");
306
+ const finger = document.createElement("div");
307
+ finger.className = "wg-swipe-finger";
308
+ const lab = document.createElement("div");
309
+ lab.className = "wg-swipe-label";
310
+ lab.textContent = label;
311
+ layer.appendChild(lab);
312
+ layer.appendChild(finger);
313
+ document.documentElement.appendChild(layer);
314
+ const vw = window.innerWidth || 390;
315
+ const vh = window.innerHeight || 844;
316
+ const pad = Math.min(vw, vh) * 0.18;
317
+ let x0;
318
+ let y0;
319
+ let x1;
320
+ let y1;
321
+ if (dir === "left") {
322
+ x0 = vw - pad;
323
+ x1 = pad;
324
+ y0 = y1 = vh * 0.52;
325
+ } else if (dir === "right") {
326
+ x0 = pad;
327
+ x1 = vw - pad;
328
+ y0 = y1 = vh * 0.52;
329
+ } else if (dir === "up") {
330
+ x0 = x1 = vw * 0.5;
331
+ y0 = vh - pad;
332
+ y1 = pad;
333
+ } else {
334
+ x0 = x1 = vw * 0.5;
335
+ y0 = pad;
336
+ y1 = vh - pad;
337
+ }
338
+ finger.style.left = x0 + "px";
339
+ finger.style.top = y0 + "px";
340
+ finger.style.opacity = "1";
341
+ void layer.offsetWidth;
342
+ layer.classList.add("wg-in");
343
+ const steps = 12;
344
+ const stepMs = Math.floor(ms / steps);
345
+ let i = 0;
346
+ const tick = () => {
347
+ i += 1;
348
+ const t = Math.min(1, i / steps);
349
+ const e = t * t * (3 - 2 * t);
350
+ const x = x0 + (x1 - x0) * e;
351
+ const y = y0 + (y1 - y0) * e;
352
+ finger.style.left = x + "px";
353
+ finger.style.top = y + "px";
354
+ const dot = document.createElement("div");
355
+ dot.className = "wg-swipe-dot";
356
+ dot.style.left = x + "px";
357
+ dot.style.top = y + "px";
358
+ dot.style.opacity = String(0.85 - t * 0.55);
359
+ layer.appendChild(dot);
360
+ requestAnimationFrame(() => {
361
+ dot.style.transition = "opacity .45s ease, transform .45s ease";
362
+ dot.style.opacity = "0";
363
+ dot.style.transform = "scale(1.8)";
364
+ });
365
+ if (i < steps) {
366
+ layer._wgTimer = setTimeout(tick, stepMs);
367
+ } else {
368
+ finger.style.opacity = "0";
369
+ layer.classList.remove("wg-in");
370
+ layer._wgTimer = setTimeout(() => {
371
+ const el = document.getElementById("wg-swipe-layer");
372
+ if (el) el.remove();
373
+ }, 380);
374
+ }
375
+ };
376
+ layer._wgTimer = setTimeout(tick, 40);
377
+ return ms + 120;
378
+ };
379
+ window.__wgClearZoom = () => {
380
+ // Strip leftover element transforms from older zoom path.
381
+ document.querySelectorAll("[data-wg-zoomed=\"1\"]").forEach((el) => {
382
+ el.style.removeProperty("transform");
383
+ el.style.removeProperty("transform-origin");
384
+ el.style.removeProperty("transition");
385
+ el.style.removeProperty("z-index");
386
+ el.style.removeProperty("position");
387
+ delete el.dataset.wgZoomed;
388
+ });
389
+ // Undo any prior shell-camera experiment - restore device fit scale only.
390
+ const shell = document.getElementById("wg-device-shell");
391
+ if (shell && shell.dataset.wgCamBase != null) {
392
+ shell.style.transform = shell.dataset.wgCamBase;
393
+ if (shell.dataset.wgCamOrigin) {
394
+ shell.style.transformOrigin = shell.dataset.wgCamOrigin;
395
+ } else {
396
+ shell.style.transformOrigin = "center center";
397
+ }
398
+ delete shell.dataset.wgCamBase;
399
+ delete shell.dataset.wgCamOrigin;
400
+ delete shell.dataset.wgCamZoom;
401
+ }
402
+ window.__wgZoomLevel = 1;
403
+ window.__wgZoomSel = "";
404
+ if (window.__wgSetZoomBadge) window.__wgSetZoomBadge(1, "");
405
+ };
406
+ const ZOOM_ICO =
407
+ '<svg viewBox="0 0 24 24" fill="none" stroke="#fde68a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
408
+ '<circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>';
409
+ window.__wgSetZoomBadge = (scale, sel) => {
410
+ const n = Number(scale);
411
+ const level = Number.isFinite(n) && n > 0 ? n : 1;
412
+ let badge = document.getElementById("wg-zoom-badge");
413
+ if (!badge) {
414
+ badge = document.createElement("div");
415
+ badge.id = "wg-zoom-badge";
416
+ badge.setAttribute("data-wg-ui", "1");
417
+ document.documentElement.appendChild(badge);
418
+ }
419
+ const zoomed = level > 1.001 || level < 0.999;
420
+ badge.dataset.zoomed = zoomed ? "1" : "0";
421
+ const txt = level.toFixed(2) + "\u00d7";
422
+ badge.innerHTML =
423
+ '<span class="wg-zoom-ico">' +
424
+ ZOOM_ICO +
425
+ '</span><span class="wg-zoom-val">' +
426
+ txt +
427
+ "</span>";
428
+ badge.classList.add("wg-in");
429
+ window.__wgZoomLevel = level;
430
+ window.__wgZoomSel = sel || "";
431
+ };
432
+ window.__wgEnsureZoomBadge = () => {
433
+ if (window.__wgSetZoomBadge) {
434
+ window.__wgSetZoomBadge(window.__wgZoomLevel || 1, window.__wgZoomSel || "");
435
+ }
436
+ };
437
+ window.__wgSetTypingBadge = (on, label) => {
438
+ let badge = document.getElementById("wg-typing-badge");
439
+ if (!on) {
440
+ if (badge) {
441
+ badge.classList.remove("wg-in");
442
+ setTimeout(() => {
443
+ const b = document.getElementById("wg-typing-badge");
444
+ if (b && !b.classList.contains("wg-in")) b.remove();
445
+ }, 220);
446
+ }
447
+ return;
448
+ }
449
+ if (!badge) {
450
+ badge = document.createElement("div");
451
+ badge.id = "wg-typing-badge";
452
+ badge.setAttribute("data-wg-ui", "1");
453
+ document.documentElement.appendChild(badge);
454
+ }
455
+ const text = label ? String(label).slice(0, 40) : "typing";
456
+ badge.innerHTML =
457
+ '<span class="wg-ty-dots">\u25cf\u25cf\u25cf</span><span>typing \u00b7 ' +
458
+ text.replace(/</g, "&lt;") +
459
+ "</span>";
460
+ void badge.offsetWidth;
461
+ badge.classList.add("wg-in");
462
+ };
463
+ /**
464
+ * Zoom HUD only - never CSS-scale the target or the device shell.
465
+ * Shell camera zoom fought device-theater fit-scale (broke ring follow
466
+ * + uneven border-radius). Element scale mangled cart rows. Emphasis
467
+ * is ring + focus veil; badge still shows authored zoom level.
468
+ */
469
+ window.__wgApplyZoom = (sel, scale) => {
470
+ document.querySelectorAll("[data-wg-zoomed=\"1\"]").forEach((el) => {
471
+ el.style.removeProperty("transform");
472
+ el.style.removeProperty("transform-origin");
473
+ el.style.removeProperty("transition");
474
+ el.style.removeProperty("z-index");
475
+ el.style.removeProperty("position");
476
+ delete el.dataset.wgZoomed;
477
+ });
478
+ const shell = document.getElementById("wg-device-shell");
479
+ if (shell && shell.dataset.wgCamBase != null) {
480
+ shell.style.transform = shell.dataset.wgCamBase;
481
+ shell.style.transformOrigin = shell.dataset.wgCamOrigin || "center center";
482
+ delete shell.dataset.wgCamBase;
483
+ delete shell.dataset.wgCamOrigin;
484
+ delete shell.dataset.wgCamZoom;
485
+ }
486
+ const n = Number(scale);
487
+ if (!Number.isFinite(n) || n <= 1.001) {
488
+ window.__wgZoomLevel = 1;
489
+ window.__wgZoomSel = "";
490
+ if (window.__wgSetZoomBadge) window.__wgSetZoomBadge(1, "");
491
+ return;
492
+ }
493
+ window.__wgZoomLevel = n;
494
+ window.__wgZoomSel = sel || "";
495
+ if (window.__wgSetZoomBadge) window.__wgSetZoomBadge(n, sel || "");
496
+ };
497
+ // Mount zoom HUD immediately so demos always show 1.00x.
498
+ if (window.__wgEnsureZoomBadge) window.__wgEnsureZoomBadge();
499
+ const POSITIONS = ["left", "center", "right"];
500
+ const applyPos = (el, pos) => {
501
+ el.dataset.pos = pos;
502
+ try {
503
+ localStorage.setItem("wg-banner-pos", pos);
504
+ } catch {
505
+ /* private mode / blocked storage - position still applies this page */
506
+ }
507
+ };
508
+ if (title) {
509
+ let banner = document.getElementById("wg-banner");
510
+ if (!banner) {
511
+ banner = document.createElement("div");
512
+ banner.id = "wg-banner";
513
+ let saved = null;
514
+ try {
515
+ saved = localStorage.getItem("wg-banner-pos");
516
+ } catch {
517
+ /* ignore */
518
+ }
519
+ const startPos =
520
+ saved && POSITIONS.includes(saved) ? saved : bannerPos;
521
+ applyPos(banner, startPos);
522
+ banner.title = "Click to move: top left / center / right";
523
+ banner.addEventListener("click", (e) => {
524
+ e.stopPropagation();
525
+ const i = POSITIONS.indexOf(banner.dataset.pos || "left");
526
+ applyPos(banner, POSITIONS[(i + 1) % POSITIONS.length]);
527
+ });
528
+ const tag = document.createElement("span");
529
+ tag.className = "wg-banner-tag";
530
+ tag.textContent = "waygraph demo";
531
+ const text = document.createElement("span");
532
+ text.className = "wg-banner-text";
533
+ text.textContent = title;
534
+ banner.appendChild(tag);
535
+ banner.appendChild(text);
536
+ document.documentElement.appendChild(banner);
537
+ } else {
538
+ // Fixture / episode title changes every step - update in place
539
+ // (banner is created once; do not leave the first step's text stuck).
540
+ let text = banner.querySelector(".wg-banner-text");
541
+ if (!text) {
542
+ text = document.createElement("span");
543
+ text.className = "wg-banner-text";
544
+ banner.appendChild(text);
545
+ }
546
+ text.textContent = title;
547
+ }
548
+ }
549
+ // Tab title/favicon: a real navigation resets document.title and any
550
+ // <link rel="icon"> the new document brings, so re-check (not
551
+ // re-append) on every call instead of a one-time flag.
552
+ if (!document.title.startsWith("[waygraph] ")) {
553
+ document.title = "[waygraph] " + document.title;
554
+ }
555
+ let iconLink = document.querySelector("link[rel~='icon']");
556
+ if (!iconLink) {
557
+ iconLink = document.createElement("link");
558
+ iconLink.rel = "icon";
559
+ document.head.appendChild(iconLink);
560
+ }
561
+ if (iconLink.href !== favicon) iconLink.href = favicon;
562
+ // Blind-agent overlay beacons: every modal root gets data-wg-ui /
563
+ // data-wg-modal / data-wg-ready so tests can fail on a blank panel.
564
+ window.__wgStampModal = (el, kind, meta) => {
565
+ if (!el) return;
566
+ meta = meta || {};
567
+ el.setAttribute("data-wg-ui", "1");
568
+ el.setAttribute("data-wg-modal", kind || "panel");
569
+ if (meta.phase != null) el.setAttribute("data-wg-phase", String(meta.phase));
570
+ if (meta.step != null) el.setAttribute("data-wg-step", String(meta.step));
571
+ if (meta.total != null) el.setAttribute("data-wg-total", String(meta.total));
572
+ if (meta.block != null) el.setAttribute("data-wg-block", String(meta.block));
573
+ el.setAttribute(
574
+ "data-wg-collapsed",
575
+ el.classList.contains("wg-collapsed") ? "1" : "0",
576
+ );
577
+ el.setAttribute("data-wg-ready", meta.ready === false ? "0" : "1");
578
+ };
579
+ window.__wgOverlayBeacon = () => {
580
+ return Array.from(document.querySelectorAll("[data-wg-ui=\"1\"]")).map((el) => {
581
+ const r = el.getBoundingClientRect();
582
+ const st = getComputedStyle(el);
583
+ return {
584
+ id: el.id || null,
585
+ modal: el.getAttribute("data-wg-modal"),
586
+ ready: el.getAttribute("data-wg-ready") === "1",
587
+ phase: el.getAttribute("data-wg-phase"),
588
+ step: el.getAttribute("data-wg-step"),
589
+ block: el.getAttribute("data-wg-block"),
590
+ collapsed:
591
+ el.getAttribute("data-wg-collapsed") === "1" ||
592
+ el.classList.contains("wg-collapsed"),
593
+ opacity: st.opacity,
594
+ textLen: (el.innerText || "").trim().length,
595
+ w: Math.round(r.width),
596
+ h: Math.round(r.height),
597
+ visible: r.width > 0 && r.height > 0 && Number(st.opacity) > 0.05,
598
+ };
599
+ });
600
+ };
601
+ const existingBanner = document.getElementById("wg-banner");
602
+ if (existingBanner) window.__wgStampModal(existingBanner, "banner", { ready: true });
603
+ // Hide/Show for #wg-panel - call after every panel.innerHTML refresh.
604
+ // Collapsed = "N / M · block" + Next (when not auto). Never pass
605
+ // forceCollapsed:false - omit so Hide/localStorage wins.
606
+ window.__wgWirePanelChrome = (panel, storageKey, chromeTitle, opts) => {
607
+ if (!panel) return;
608
+ opts = opts || {};
609
+ const ensureChrome = () => {
610
+ let chrome = panel.querySelector(":scope > .wg-chrome");
611
+ if (!chrome) {
612
+ const body = document.createElement("div");
613
+ body.className = "wg-body";
614
+ while (panel.firstChild) body.appendChild(panel.firstChild);
615
+ chrome = document.createElement("div");
616
+ chrome.className = "wg-chrome";
617
+ const titleEl = document.createElement("span");
618
+ titleEl.className = "wg-chrome-title";
619
+ titleEl.textContent = chromeTitle || "waygraph demo";
620
+ const actions = document.createElement("div");
621
+ actions.className = "wg-chrome-actions";
622
+ const nextBtn = document.createElement("button");
623
+ nextBtn.type = "button";
624
+ nextBtn.className = "wg-mini-next";
625
+ nextBtn.setAttribute("data-wg-mini-next", "1");
626
+ nextBtn.textContent = "Next ▶";
627
+ const btn = document.createElement("button");
628
+ btn.type = "button";
629
+ btn.className = "wg-hide-btn";
630
+ btn.setAttribute("data-wg-toggle", "1");
631
+ btn.textContent = "Hide";
632
+ actions.appendChild(nextBtn);
633
+ actions.appendChild(btn);
634
+ chrome.appendChild(titleEl);
635
+ chrome.appendChild(actions);
636
+ panel.appendChild(chrome);
637
+ panel.appendChild(body);
638
+ return;
639
+ }
640
+ // Yap slides ship a bare chrome - ensure actions + mini Next exist.
641
+ let actions = chrome.querySelector(".wg-chrome-actions");
642
+ if (!actions) {
643
+ actions = document.createElement("div");
644
+ actions.className = "wg-chrome-actions";
645
+ const hide = chrome.querySelector("[data-wg-toggle]");
646
+ if (hide) actions.appendChild(hide);
647
+ chrome.appendChild(actions);
648
+ }
649
+ if (!actions.querySelector("[data-wg-mini-next]")) {
650
+ const nextBtn = document.createElement("button");
651
+ nextBtn.type = "button";
652
+ nextBtn.className = "wg-mini-next";
653
+ nextBtn.setAttribute("data-wg-mini-next", "1");
654
+ nextBtn.textContent = "Next ▶";
655
+ actions.insertBefore(nextBtn, actions.firstChild);
656
+ }
657
+ };
658
+ ensureChrome();
659
+ const stepLabel = opts.stepLabel || panel.dataset.wgStepLabel || "";
660
+ if (stepLabel) panel.dataset.wgStepLabel = stepLabel;
661
+ const syncMiniNext = (hidden) => {
662
+ const miniNext = panel.querySelector("[data-wg-mini-next]");
663
+ if (!miniNext) return;
664
+ let auto = false;
665
+ try {
666
+ auto = localStorage.getItem("wg-autoplay") === "1";
667
+ } catch {
668
+ /* ignore */
669
+ }
670
+ const runBtn = document.getElementById("wg-run");
671
+ const show = !!hidden && !auto && !!runBtn;
672
+ miniNext.classList.toggle("wg-mini-next-show", show);
673
+ if (runBtn) {
674
+ miniNext.textContent = runBtn.textContent || "Next ▶";
675
+ miniNext.disabled = !!runBtn.disabled;
676
+ } else {
677
+ miniNext.disabled = true;
678
+ }
679
+ };
680
+ const apply = (hidden, persist) => {
681
+ panel.classList.toggle("wg-collapsed", hidden);
682
+ panel.setAttribute("data-wg-collapsed", hidden ? "1" : "0");
683
+ const t = panel.querySelector("[data-wg-toggle]");
684
+ const titleEl = panel.querySelector(".wg-chrome-title");
685
+ const label = panel.dataset.wgStepLabel || stepLabel;
686
+ if (titleEl) {
687
+ titleEl.textContent = hidden
688
+ ? (label || chromeTitle || "waygraph demo")
689
+ : (chromeTitle || "waygraph demo");
690
+ }
691
+ if (t) t.textContent = hidden ? "Show" : "Hide";
692
+ syncMiniNext(hidden);
693
+ if (persist !== false) {
694
+ try {
695
+ localStorage.setItem(storageKey, hidden ? "1" : "0");
696
+ } catch {
697
+ /* private mode */
698
+ }
699
+ }
700
+ };
701
+ let hidden = false;
702
+ if (opts.forceCollapsed === true) {
703
+ hidden = true;
704
+ } else {
705
+ try {
706
+ hidden = localStorage.getItem(storageKey) === "1";
707
+ } catch {
708
+ /* ignore */
709
+ }
710
+ }
711
+ // force mini does not overwrite Hide preference in storage.
712
+ apply(hidden, opts.forceCollapsed === true ? false : true);
713
+ const toggle = panel.querySelector("[data-wg-toggle]");
714
+ if (toggle && !toggle.dataset.wgWired) {
715
+ toggle.dataset.wgWired = "1";
716
+ toggle.addEventListener("click", (e) => {
717
+ e.stopPropagation();
718
+ apply(!panel.classList.contains("wg-collapsed"), true);
719
+ });
720
+ }
721
+ const miniNext = panel.querySelector("[data-wg-mini-next]");
722
+ if (miniNext && !miniNext.dataset.wgWired) {
723
+ miniNext.dataset.wgWired = "1";
724
+ miniNext.addEventListener("click", (e) => {
725
+ e.stopPropagation();
726
+ const runBtn = document.getElementById("wg-run");
727
+ if (runBtn && !runBtn.disabled) {
728
+ runBtn.click();
729
+ return;
730
+ }
731
+ if (typeof window.__wgNext === "function") window.__wgNext({});
732
+ });
733
+ }
734
+ const autoCb = document.getElementById("wg-autoplay-cb");
735
+ if (autoCb && !autoCb.dataset.wgMiniWired) {
736
+ autoCb.dataset.wgMiniWired = "1";
737
+ autoCb.addEventListener("change", () => {
738
+ syncMiniNext(panel.classList.contains("wg-collapsed"));
739
+ });
740
+ }
741
+ const cur = panel.querySelector("#wg-modules .wg-mod-current");
742
+ if (cur && typeof cur.scrollIntoView === "function") {
743
+ try {
744
+ cur.scrollIntoView({ inline: "center", block: "nearest", behavior: "smooth" });
745
+ } catch {
746
+ cur.scrollIntoView(false);
747
+ }
748
+ }
749
+ };
750
+ // Floating todo dock - lives outside #wg-panel so --mini / Hide never
751
+ // hide the checklist. Click cycles left <-> right with CSS transform.
752
+ // Authors set side via ctx.todoPos("left"|"right"); env / --todo-* fallback.
753
+ // Payload:
754
+ // { sync: "clear" } -> remove dock
755
+ // { sync: "keep" } -> leave dock alone (no wipe)
756
+ // { sync: "set", dock } / { sync:"set", list } -> render / recreate
757
+ // Compat: bare array = set list (empty array = clear - legacy only).
758
+ window.__wgSyncTodos = (payload, pos) => {
759
+ const syncPayload = payload;
760
+ let sync = "set";
761
+ let list = [];
762
+ let dockState = null;
763
+ let wantPos =
764
+ pos === "left" || pos === "right" ? pos : null;
765
+ if (Array.isArray(payload)) {
766
+ list = payload;
767
+ sync = list.length ? "set" : "clear";
768
+ } else if (payload && typeof payload === "object") {
769
+ sync = payload.sync === "clear" || payload.sync === "keep" ? payload.sync : "set";
770
+ dockState = payload.dock || null;
771
+ if (payload.pos === "left" || payload.pos === "right") wantPos = payload.pos;
772
+ if (Array.isArray(payload.list)) list = payload.list;
773
+ else if (dockState && Array.isArray(dockState.groups)) {
774
+ // Flatten for legacy callers; full dock rendered below when present.
775
+ list = [];
776
+ for (const g of dockState.groups) {
777
+ if (g && Array.isArray(g.items)) list = list.concat(g.items);
778
+ }
779
+ }
780
+ } else {
781
+ list = [];
782
+ sync = "clear";
783
+ }
784
+
785
+ let dockKey =
786
+ (dockState && dockState.id && String(dockState.id).trim()) ||
787
+ (payload && !Array.isArray(payload) && payload.todoId && String(payload.todoId).trim()) ||
788
+ "_default";
789
+
790
+ // Drop any in-panel leftover (old builds put #wg-todos in .wg-body).
791
+ document.querySelectorAll("#wg-panel #wg-todos").forEach((el) => el.remove());
792
+
793
+ const allDocks = () =>
794
+ [...document.querySelectorAll(".wg-todo-dock, #wg-todo-dock")].filter(
795
+ (el, i, arr) => arr.indexOf(el) === i,
796
+ );
797
+ const relayoutTodoDocks = () => {
798
+ const docks = allDocks();
799
+ let top = 72;
800
+ for (const el of docks) {
801
+ el.style.top = top + "px";
802
+ top += Math.max(48, el.getBoundingClientRect().height) + 10;
803
+ }
804
+ };
805
+ const findDock = (key) => {
806
+ if (key === "_default") {
807
+ return (
808
+ document.querySelector('.wg-todo-dock[data-wg-todo-key="_default"]') ||
809
+ document.getElementById("wg-todo-dock")
810
+ );
811
+ }
812
+ const all = document.querySelectorAll(".wg-todo-dock");
813
+ for (let i = 0; i < all.length; i++) {
814
+ if (all[i].getAttribute("data-wg-todo-key") === String(key)) return all[i];
815
+ }
816
+ return null;
817
+ };
818
+
819
+ // keep + no new payload: do not remove existing docks (PIA #10).
820
+ if (sync === "keep") {
821
+ if (!dockState && !list.length) return;
822
+ // After navigation the DOM may be gone - recreate from carried dock.
823
+ sync = "set";
824
+ }
825
+ // Default replace=true: one checklist. parallel docks only when replace:false.
826
+ const replace =
827
+ !(payload && !Array.isArray(payload) && payload.replace === false) &&
828
+ !(payload && !Array.isArray(payload) && payload.parallel === true);
829
+ const hasDock =
830
+ !!(dockState && Array.isArray(dockState.groups) && dockState.groups.length);
831
+ // Explicit clear only - never treat "set + dock + empty list" as clear
832
+ // (that thrash was marks -> blank in a few ms).
833
+ if (sync === "clear" || (sync === "set" && !hasDock && !list.length)) {
834
+ const clearId =
835
+ (payload && !Array.isArray(payload) && payload.todoId && String(payload.todoId).trim()) ||
836
+ (dockState && dockState.id && String(dockState.id).trim()) ||
837
+ "";
838
+ if (clearId && !replace) {
839
+ const el = findDock(clearId);
840
+ if (el) el.remove();
841
+ } else {
842
+ allDocks().forEach((el) => el.remove());
843
+ }
844
+ relayoutTodoDocks();
845
+ return;
846
+ }
847
+
848
+ if (sync === "set" && replace) {
849
+ // Drop sibling docks so we never stack duplicates.
850
+ for (const el of allDocks()) {
851
+ const key = el.getAttribute("data-wg-todo-key") || "_default";
852
+ if (key !== dockKey) el.remove();
853
+ }
854
+ }
855
+
856
+ const POS = ["left", "right"];
857
+ let dock = findDock(dockKey);
858
+ if (!dock) {
859
+ dock = document.createElement("div");
860
+ dock.className = "wg-todo-dock";
861
+ dock.setAttribute("data-wg-todo-key", dockKey);
862
+ if (dockKey === "_default") dock.id = "wg-todo-dock";
863
+ else dock.id = "wg-todo-dock--" + dockKey.replace(/[^a-zA-Z0-9_-]/g, "-");
864
+ dock.setAttribute("data-wg-ui", "1");
865
+ dock.setAttribute("data-wg-modal", "todos");
866
+ dock.setAttribute("data-wg-ready", "1");
867
+ let saved = null;
868
+ try {
869
+ saved = localStorage.getItem("wg-todo-pos");
870
+ } catch {
871
+ /* ignore */
872
+ }
873
+ const start =
874
+ wantPos ||
875
+ (saved && POS.includes(saved) ? saved : todoPos === "right" ? "right" : "left");
876
+ dock.dataset.pos = start;
877
+ dock.title = "Click to move checklist: left / right";
878
+ dock.addEventListener("click", (e) => {
879
+ e.stopPropagation();
880
+ const i = POS.indexOf(dock.dataset.pos || "left");
881
+ const next = POS[(i + 1) % POS.length];
882
+ dock.dataset.pos = next;
883
+ try {
884
+ localStorage.setItem("wg-todo-pos", next);
885
+ } catch {
886
+ /* ignore */
887
+ }
888
+ });
889
+ document.documentElement.appendChild(dock);
890
+ } else if (wantPos) {
891
+ dock.dataset.pos = wantPos;
892
+ try {
893
+ localStorage.setItem("wg-todo-pos", wantPos);
894
+ } catch {
895
+ /* ignore */
896
+ }
897
+ }
898
+
899
+ const esc = (s) =>
900
+ String(s)
901
+ .replace(/&/g, "&amp;")
902
+ .replace(/</g, "&lt;")
903
+ .replace(/>/g, "&gt;")
904
+ .replace(/"/g, "&quot;");
905
+ const attrId = (raw) => {
906
+ const id = raw != null && String(raw).trim() ? String(raw).trim() : "";
907
+ return id ? ' data-wg-todo-item="' + esc(id) + '"' : "";
908
+ };
909
+ // Compact + cap: >cap rows folds overflow; hover shows up to expandCap; "+N more" pins expand.
910
+ const ui = (payload && !Array.isArray(payload) && payload.ui) || window.__wgTodoDockUi || {};
911
+ const COMPACT_AT = Number(ui.cap) > 0 ? Number(ui.cap) : 5;
912
+ const WINDOW = COMPACT_AT;
913
+ const EXPAND_MAX = Number(ui.expandCap) > 0 ? Number(ui.expandCap) : 14;
914
+ const compactEnabled = ui.compact !== false;
915
+ const flattenItems = () => {
916
+ if (dockState && Array.isArray(dockState.groups) && dockState.groups.length) {
917
+ const out = [];
918
+ for (const g of dockState.groups) {
919
+ for (const t of g.items || []) out.push({ ...t, __style: g.style || dockState.style });
920
+ }
921
+ return out;
922
+ }
923
+ return (list || []).map((t) => ({ ...t, __style: "sequential" }));
924
+ };
925
+ const windowBounds = (items, cap) => {
926
+ const n = items.length;
927
+ if (n <= cap) return { start: 0, end: n };
928
+ let cur = items.findIndex((t) => t.current);
929
+ if (cur < 0) cur = items.findIndex((t) => !t.done);
930
+ if (cur < 0) cur = 0;
931
+ let start = Math.max(0, cur - Math.floor((cap - 1) / 2));
932
+ let end = Math.min(n, start + cap);
933
+ start = Math.max(0, end - cap);
934
+ return { start, end };
935
+ };
936
+ const renderItems = (items, style, foldMeta, indexOffset) => {
937
+ const offset = indexOffset || 0;
938
+ const rawStyle = String(style || "sequential").toLowerCase();
939
+ const st =
940
+ rawStyle === "checklist"
941
+ ? "checklist"
942
+ : rawStyle === "bullets" ||
943
+ rawStyle === "bullet" ||
944
+ rawStyle === "list" ||
945
+ rawStyle === "ul" ||
946
+ rawStyle === "points" ||
947
+ rawStyle === "plain"
948
+ ? "bullets"
949
+ : "sequential";
950
+ const foldStart = foldMeta ? foldMeta.foldStart : -1;
951
+ const foldEnd = foldMeta ? foldMeta.foldEnd : -1;
952
+ const deepStart = foldMeta ? foldMeta.deepStart : -1;
953
+ const deepEnd = foldMeta ? foldMeta.deepEnd : -1;
954
+ return (
955
+ '<ul class="wg-todos-list" data-wg-todo-style="' +
956
+ st +
957
+ '">' +
958
+ (items || [])
959
+ .map((t, idx) => {
960
+ const gi = offset + idx;
961
+ const clsBase =
962
+ st === "bullets"
963
+ ? t.done
964
+ ? "wg-todo-done"
965
+ : "wg-todo-pending"
966
+ : t.current
967
+ ? "wg-todo-current"
968
+ : t.done
969
+ ? "wg-todo-done"
970
+ : "wg-todo-pending";
971
+ let foldCls = "";
972
+ if (foldStart >= 0 && (gi < foldStart || gi >= foldEnd)) foldCls += " wg-todo-fold";
973
+ if (deepStart >= 0 && (gi < deepStart || gi >= deepEnd)) foldCls += " wg-todo-fold-deep";
974
+ let mark;
975
+ if (st === "checklist") {
976
+ mark = t.done ? "☑" : "☐";
977
+ } else if (st === "bullets") {
978
+ mark = "•";
979
+ } else {
980
+ mark = t.done ? "✓" : t.current ? "→" : "○";
981
+ }
982
+ const rowId = t.id || t.name || "";
983
+ return (
984
+ '<li class="' +
985
+ clsBase +
986
+ foldCls +
987
+ '"' +
988
+ attrId(rowId) +
989
+ '><span class="wg-todo-mark">' +
990
+ mark +
991
+ "</span><span>" +
992
+ esc(t.text || "") +
993
+ "</span></li>"
994
+ );
995
+ })
996
+ .join("") +
997
+ "</ul>"
998
+ );
999
+ };
1000
+
1001
+ if (dockKey !== "_default") dock.setAttribute("data-wg-todo-id", dockKey);
1002
+ else dock.removeAttribute("data-wg-todo-id");
1003
+
1004
+ const flat = flattenItems();
1005
+ const compact = compactEnabled && flat.length > COMPACT_AT;
1006
+ const win = windowBounds(flat, WINDOW);
1007
+ const expand = windowBounds(flat, EXPAND_MAX);
1008
+ const foldMeta = compact
1009
+ ? {
1010
+ foldStart: win.start,
1011
+ foldEnd: win.end,
1012
+ deepStart: expand.start,
1013
+ deepEnd: expand.end,
1014
+ }
1015
+ : null;
1016
+ const hiddenCompact = compact ? flat.length - (win.end - win.start) : 0;
1017
+ const hiddenExpand = compact ? flat.length - (expand.end - expand.start) : 0;
1018
+
1019
+ let html = "";
1020
+ if (dockState && Array.isArray(dockState.groups) && dockState.groups.length) {
1021
+ const dockTitle = dockState.title && String(dockState.title).trim();
1022
+ if (dockTitle) {
1023
+ html += '<div class="wg-todo-dock-title">' + esc(dockTitle) + "</div>";
1024
+ }
1025
+ let globalIdx = 0;
1026
+ for (const g of dockState.groups) {
1027
+ const gTitle = g.title && String(g.title).trim();
1028
+ const gid = g.id || g.name || "";
1029
+ html +=
1030
+ '<div class="wg-todo-group"' +
1031
+ (gid ? ' data-wg-todo-group="' + esc(gid) + '"' : "") +
1032
+ ">";
1033
+ if (gTitle) {
1034
+ html += '<div class="wg-todo-group-title">' + esc(gTitle) + "</div>";
1035
+ }
1036
+ const gItems = g.items || [];
1037
+ html += renderItems(gItems, g.style || dockState.style, foldMeta, globalIdx);
1038
+ html += "</div>";
1039
+ globalIdx += gItems.length;
1040
+ }
1041
+ } else {
1042
+ html = renderItems(list, "sequential", foldMeta, 0);
1043
+ }
1044
+ if (compact && hiddenCompact > 0) {
1045
+ const n = dock.dataset.expanded === "1" ? hiddenExpand : hiddenCompact;
1046
+ if (n > 0) {
1047
+ html +=
1048
+ '<div class="wg-todo-more" data-wg-todo-more="1" title="Click to expand / collapse">' +
1049
+ "+" +
1050
+ n +
1051
+ " more</div>";
1052
+ }
1053
+ }
1054
+ if (compact) dock.dataset.compact = "1";
1055
+ else {
1056
+ delete dock.dataset.compact;
1057
+ delete dock.dataset.expanded;
1058
+ }
1059
+ dock.innerHTML = html;
1060
+ const moreBtn = dock.querySelector("[data-wg-todo-more]");
1061
+ if (moreBtn) {
1062
+ moreBtn.addEventListener("click", (e) => {
1063
+ e.stopPropagation();
1064
+ if (dock.dataset.expanded === "1") delete dock.dataset.expanded;
1065
+ else dock.dataset.expanded = "1";
1066
+ if (window.__wgSyncTodos && dock._wgLastPayload) {
1067
+ window.__wgSyncTodos(dock._wgLastPayload);
1068
+ }
1069
+ });
1070
+ }
1071
+ dock._wgLastPayload = syncPayload;
1072
+ relayoutTodoDocks();
1073
+ if (window.__wgStampModal) {
1074
+ window.__wgStampModal(dock, "todos", { ready: true });
1075
+ }
1076
+ };
1077
+ // Device toast + chip (0.13.2+) - announce on set/clear; quiet chip on keep.
1078
+ // Payload: { sync, device?, remain?, announce? }
1079
+ window.__wgSyncDevice = (payload) => {
1080
+ const sync =
1081
+ payload && (payload.sync === "clear" || payload.sync === "keep" || payload.sync === "set")
1082
+ ? payload.sync
1083
+ : "keep";
1084
+ const device = payload && payload.device ? payload.device : null;
1085
+ const cursor = document.getElementById("wg-cursor");
1086
+ const MOUSE_SVG =
1087
+ "<svg viewBox='0 0 32 32' width='24' height='24'>" +
1088
+ "<path fill='#0C0C1A' stroke='#fff' stroke-width='1.4' stroke-linejoin='round' " +
1089
+ "d='M6 3.5l1.4 22.5 5.8-5.4 4.2 9.4 3.6-1.6-4.2-9.2H26z'/></svg>";
1090
+ const FINGER_SVG =
1091
+ "<svg viewBox='0 0 32 32' width='28' height='28'>" +
1092
+ "<ellipse cx='16' cy='22' rx='7' ry='8' fill='#0C0C1A' stroke='#fff' stroke-width='1.4'/>" +
1093
+ "<rect x='12' y='6' width='8' height='16' rx='4' fill='#0C0C1A' stroke='#fff' stroke-width='1.4'/>" +
1094
+ "</svg>";
1095
+ const ICONS = {
1096
+ mobile:
1097
+ '<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
1098
+ '<rect x="7" y="2.5" width="10" height="19" rx="2.2"/>' +
1099
+ '<circle cx="12" cy="18.2" r="1.1" fill="#fff" stroke="none"/></svg>',
1100
+ tablet:
1101
+ '<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
1102
+ '<rect x="3.5" y="4" width="17" height="16" rx="2"/>' +
1103
+ '<circle cx="12" cy="17.2" r="1" fill="#fff" stroke="none"/></svg>',
1104
+ desktop:
1105
+ '<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
1106
+ '<rect x="2.5" y="3.5" width="19" height="12.5" rx="1.5"/>' +
1107
+ '<path d="M8 20h8M12 16v4"/></svg>',
1108
+ };
1109
+ const setCursor = (touch) => {
1110
+ if (!cursor) return;
1111
+ if (touch) {
1112
+ cursor.setAttribute("data-touch", "1");
1113
+ cursor.innerHTML = FINGER_SVG;
1114
+ } else {
1115
+ cursor.removeAttribute("data-touch");
1116
+ cursor.innerHTML = MOUSE_SVG;
1117
+ }
1118
+ };
1119
+ const ensureChip = (preset, touch, remain, orientation) => {
1120
+ let badge = document.getElementById("wg-device-badge");
1121
+ if (preset === "desktop" && !touch) {
1122
+ if (badge) {
1123
+ badge.classList.remove("wg-in");
1124
+ setTimeout(() => {
1125
+ const b = document.getElementById("wg-device-badge");
1126
+ if (b) b.remove();
1127
+ }, 320);
1128
+ }
1129
+ return;
1130
+ }
1131
+ if (!badge) {
1132
+ badge = document.createElement("div");
1133
+ badge.id = "wg-device-badge";
1134
+ badge.setAttribute("data-wg-ui", "1");
1135
+ document.documentElement.appendChild(badge);
1136
+ }
1137
+ badge.dataset.preset = preset;
1138
+ badge.dataset.touch = touch ? "1" : "0";
1139
+ const orient =
1140
+ orientation === "landscape" || orientation === "portrait"
1141
+ ? orientation
1142
+ : "";
1143
+ if (orient) badge.dataset.orientation = orient;
1144
+ const chipLabel =
1145
+ preset === "desktop" && !touch
1146
+ ? "desktop"
1147
+ : preset +
1148
+ (orient ? " · " + (orient === "landscape" ? "land" : "port") : "") +
1149
+ (remain ? " · remain" : "");
1150
+ badge.innerHTML =
1151
+ '<span class="wg-dev-icon">' +
1152
+ (ICONS[preset] || ICONS.desktop) +
1153
+ '</span><span class="wg-dev-label">' +
1154
+ chipLabel +
1155
+ "</span>";
1156
+ void badge.offsetWidth;
1157
+ badge.classList.add("wg-in");
1158
+ };
1159
+ const showToast = (preset, touch, title, sub, orientation) => {
1160
+ let toast = document.getElementById("wg-device-toast");
1161
+ if (toast && toast._wgTimer) {
1162
+ clearTimeout(toast._wgTimer);
1163
+ toast._wgTimer = null;
1164
+ }
1165
+ if (!toast) {
1166
+ toast = document.createElement("div");
1167
+ toast.id = "wg-device-toast";
1168
+ toast.setAttribute("data-wg-ui", "1");
1169
+ document.documentElement.appendChild(toast);
1170
+ }
1171
+ // Hide chip while toast is up (same corner).
1172
+ const badge = document.getElementById("wg-device-badge");
1173
+ if (badge) badge.classList.remove("wg-in");
1174
+ toast.dataset.preset = preset;
1175
+ toast.innerHTML =
1176
+ '<span class="wg-dev-icon">' +
1177
+ (ICONS[preset] || ICONS.desktop) +
1178
+ '</span><span class="wg-dev-copy"><div class="wg-dev-title">' +
1179
+ title +
1180
+ '</div><div class="wg-dev-sub">' +
1181
+ sub +
1182
+ "</div></span>";
1183
+ toast.classList.remove("wg-out");
1184
+ void toast.offsetWidth;
1185
+ toast.classList.add("wg-in");
1186
+ toast._wgTimer = setTimeout(() => {
1187
+ toast.classList.add("wg-out");
1188
+ toast.classList.remove("wg-in");
1189
+ setTimeout(() => {
1190
+ const t = document.getElementById("wg-device-toast");
1191
+ if (t) t.remove();
1192
+ if (preset !== "desktop" || touch) {
1193
+ ensureChip(preset, touch, true, orientation);
1194
+ }
1195
+ }, 380);
1196
+ }, 2200);
1197
+ };
1198
+
1199
+ if (sync === "keep" && !device) return;
1200
+
1201
+ const isDesktopClear =
1202
+ sync === "clear" ||
1203
+ (device && device.preset === "desktop" && !device.touchMode);
1204
+ if (isDesktopClear) {
1205
+ setCursor(false);
1206
+ const announce = payload.announce !== false && sync !== "keep";
1207
+ if (announce) {
1208
+ const vp = device && device.viewport ? device.viewport : { width: 1280, height: 720 };
1209
+ showToast(
1210
+ "desktop",
1211
+ false,
1212
+ "Back to desktop",
1213
+ vp.width + "×" + vp.height + " · mouse",
1214
+ "landscape",
1215
+ );
1216
+ setTimeout(() => {
1217
+ const badge = document.getElementById("wg-device-badge");
1218
+ if (badge) badge.remove();
1219
+ }, 2600);
1220
+ } else {
1221
+ const badge = document.getElementById("wg-device-badge");
1222
+ if (badge) badge.remove();
1223
+ const toast = document.getElementById("wg-device-toast");
1224
+ if (toast) toast.remove();
1225
+ }
1226
+ window.__wgDevicePreset = "desktop";
1227
+ window.__wgDeviceOrientation = "landscape";
1228
+ window.__wgDeviceTouch = false;
1229
+ document.documentElement.dataset.wgTouch = "0";
1230
+ if (window.__wgEnsureZoomBadge) window.__wgEnsureZoomBadge();
1231
+ return;
1232
+ }
1233
+
1234
+ if (!device) return;
1235
+ const preset = device.preset || "desktop";
1236
+ const touch = !!device.touchMode;
1237
+ const remain = payload.remain !== false && sync !== "clear";
1238
+ const vp = device.viewport || {};
1239
+ const orient =
1240
+ device.orientation === "landscape" || device.orientation === "portrait"
1241
+ ? device.orientation
1242
+ : vp.height > vp.width
1243
+ ? "portrait"
1244
+ : "landscape";
1245
+ const prev = window.__wgDevicePreset || "";
1246
+ const prevOrient = window.__wgDeviceOrientation || "";
1247
+ const presetChanged = prev !== preset || (touch && prev === "desktop");
1248
+ const orientChanged = prevOrient !== "" && prevOrient !== orient;
1249
+ const changed = presetChanged || orientChanged || prev === "";
1250
+ setCursor(touch);
1251
+ window.__wgDevicePreset = preset;
1252
+ window.__wgDeviceOrientation = orient;
1253
+ window.__wgDeviceTouch = touch;
1254
+ document.documentElement.dataset.wgTouch = touch ? "1" : "0";
1255
+ if (window.__wgEnsureZoomBadge) window.__wgEnsureZoomBadge();
1256
+
1257
+ const announce =
1258
+ payload.announce === true ||
1259
+ (payload.announce !== false && (sync === "set" || sync === "clear") && changed);
1260
+
1261
+ const size =
1262
+ (vp.width || "?") +
1263
+ "×" +
1264
+ (vp.height || "?") +
1265
+ " · " +
1266
+ orient +
1267
+ (touch ? " · touch" : "");
1268
+ let titleText = {
1269
+ mobile: "Now in mobile mode",
1270
+ tablet: "Now in tablet mode",
1271
+ desktop: "Now in desktop mode",
1272
+ }[preset] || "Device updated";
1273
+ if (orientChanged && !presetChanged) {
1274
+ titleText =
1275
+ orient === "landscape" ? "Rotated to landscape" : "Rotated to portrait";
1276
+ }
1277
+ if (announce) {
1278
+ showToast(preset, touch, titleText, size, orient);
1279
+ } else {
1280
+ ensureChip(preset, touch, remain, orient);
1281
+ }
1282
+ };
1283
+ },
1284
+ { title, favicon: WAYGRAPH_FAVICON, bannerPos, todoPos, envAutoplay, todoDockUi },
1285
+ )
1286
+ .catch(() => {});
1287
+ // Navigations wipe #wg-device-shell - rebuild when video stage + device are live.
1288
+ // If we already shuttered to desktop-flat, re-apply FLAT (never re-add bezel).
1289
+ if (
1290
+ page.__wgVideoViewport &&
1291
+ page.__wgDeviceShell &&
1292
+ page.__wgDeviceShell.width &&
1293
+ page.__wgDeviceShell.height
1294
+ ) {
1295
+ await applyVideoDeviceStage(page, page.__wgDeviceShell, page.__wgVideoViewport, {
1296
+ clear: false,
1297
+ shutterOut: !!page.__wgDesktopFlat,
1298
+ desktopFlat: !!page.__wgDesktopFlat,
1299
+ });
1300
+ }
1301
+ }