synabun 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/CHANGELOG.md +665 -0
  2. package/LICENSE +191 -0
  3. package/README.md +853 -0
  4. package/hooks/claude-code/post-plan.mjs +391 -0
  5. package/hooks/claude-code/post-remember.mjs +239 -0
  6. package/hooks/claude-code/pre-compact.mjs +241 -0
  7. package/hooks/claude-code/pre-websearch.mjs +90 -0
  8. package/hooks/claude-code/prompt-submit.mjs +861 -0
  9. package/hooks/claude-code/session-start.mjs +494 -0
  10. package/hooks/claude-code/shared.mjs +368 -0
  11. package/hooks/claude-code/stop.mjs +646 -0
  12. package/hooks/claude-code/test-user-learning.mjs +531 -0
  13. package/mcp-server/package.json +38 -0
  14. package/mcp-server/run.mjs +10 -0
  15. package/mcp-server/src/config.ts +70 -0
  16. package/mcp-server/src/http.ts +93 -0
  17. package/mcp-server/src/index.ts +203 -0
  18. package/mcp-server/src/preload-http.ts +25 -0
  19. package/mcp-server/src/preload.ts +29 -0
  20. package/mcp-server/src/scripts/migrate-qdrant-to-sqlite.ts +506 -0
  21. package/mcp-server/src/services/categories.ts +429 -0
  22. package/mcp-server/src/services/discord.ts +702 -0
  23. package/mcp-server/src/services/file-checksums.ts +33 -0
  24. package/mcp-server/src/services/local-embeddings.ts +77 -0
  25. package/mcp-server/src/services/neural-interface.ts +344 -0
  26. package/mcp-server/src/services/sqlite.ts +823 -0
  27. package/mcp-server/src/tools/browser-advanced.ts +123 -0
  28. package/mcp-server/src/tools/browser-interact.ts +255 -0
  29. package/mcp-server/src/tools/browser-navigate.ts +77 -0
  30. package/mcp-server/src/tools/browser-observe.ts +1330 -0
  31. package/mcp-server/src/tools/browser.ts +107 -0
  32. package/mcp-server/src/tools/card-tools.ts +193 -0
  33. package/mcp-server/src/tools/card.ts +22 -0
  34. package/mcp-server/src/tools/category.ts +379 -0
  35. package/mcp-server/src/tools/discord-channel.ts +274 -0
  36. package/mcp-server/src/tools/discord-guild.ts +188 -0
  37. package/mcp-server/src/tools/discord-member.ts +158 -0
  38. package/mcp-server/src/tools/discord-message.ts +231 -0
  39. package/mcp-server/src/tools/discord-onboarding.ts +228 -0
  40. package/mcp-server/src/tools/discord-role.ts +209 -0
  41. package/mcp-server/src/tools/discord-thread.ts +110 -0
  42. package/mcp-server/src/tools/discord-webhook.ts +176 -0
  43. package/mcp-server/src/tools/discord.ts +40 -0
  44. package/mcp-server/src/tools/forget.ts +38 -0
  45. package/mcp-server/src/tools/git-tools.ts +141 -0
  46. package/mcp-server/src/tools/git.ts +13 -0
  47. package/mcp-server/src/tools/image-tools.ts +98 -0
  48. package/mcp-server/src/tools/image.ts +17 -0
  49. package/mcp-server/src/tools/leonardo-browser-tools.ts +281 -0
  50. package/mcp-server/src/tools/leonardo.ts +28 -0
  51. package/mcp-server/src/tools/loop.ts +348 -0
  52. package/mcp-server/src/tools/memories.ts +118 -0
  53. package/mcp-server/src/tools/recall.ts +323 -0
  54. package/mcp-server/src/tools/reflect.ts +145 -0
  55. package/mcp-server/src/tools/remember.ts +115 -0
  56. package/mcp-server/src/tools/response.ts +26 -0
  57. package/mcp-server/src/tools/restore.ts +34 -0
  58. package/mcp-server/src/tools/sync.ts +114 -0
  59. package/mcp-server/src/tools/tictactoe-tools.ts +98 -0
  60. package/mcp-server/src/tools/tictactoe.ts +13 -0
  61. package/mcp-server/src/tools/utils.ts +25 -0
  62. package/mcp-server/src/tools/whiteboard-tools.ts +260 -0
  63. package/mcp-server/src/tools/whiteboard.ts +22 -0
  64. package/mcp-server/src/tui.ts +940 -0
  65. package/mcp-server/src/types.ts +85 -0
  66. package/mcp-server/tsconfig.json +19 -0
  67. package/neural-interface/package.json +27 -0
  68. package/neural-interface/public/apple-touch-icon.png +0 -0
  69. package/neural-interface/public/category-logos/claude.svg +1 -0
  70. package/neural-interface/public/category-logos/gemini-color.svg +1 -0
  71. package/neural-interface/public/category-logos/openai.svg +1 -0
  72. package/neural-interface/public/category-logos/synabun.png +0 -0
  73. package/neural-interface/public/claude-chat.css +878 -0
  74. package/neural-interface/public/claude-chat.html +92 -0
  75. package/neural-interface/public/claude-chat.js +1168 -0
  76. package/neural-interface/public/favicon-16x16.png +0 -0
  77. package/neural-interface/public/favicon-32x32.png +0 -0
  78. package/neural-interface/public/gallery.svg +6 -0
  79. package/neural-interface/public/icon-192.png +0 -0
  80. package/neural-interface/public/icon-512.png +0 -0
  81. package/neural-interface/public/index-legacy.html +14677 -0
  82. package/neural-interface/public/index.html +212 -0
  83. package/neural-interface/public/index2d-legacy.html +8193 -0
  84. package/neural-interface/public/index2d.html +127 -0
  85. package/neural-interface/public/invite.html +351 -0
  86. package/neural-interface/public/leonardoai.svg +1 -0
  87. package/neural-interface/public/logo-synabun.png +0 -0
  88. package/neural-interface/public/logoHD.png +0 -0
  89. package/neural-interface/public/manifest.json +14 -0
  90. package/neural-interface/public/offline.html +299 -0
  91. package/neural-interface/public/onboarding.html +2335 -0
  92. package/neural-interface/public/openclaw-logo-text.png +0 -0
  93. package/neural-interface/public/pin-svgrepo-com.svg +25 -0
  94. package/neural-interface/public/sessionicon.svg +8 -0
  95. package/neural-interface/public/shared/ansi-parser.js +305 -0
  96. package/neural-interface/public/shared/api.js +1043 -0
  97. package/neural-interface/public/shared/colors.js +153 -0
  98. package/neural-interface/public/shared/constants.js +141 -0
  99. package/neural-interface/public/shared/html-shell.js +777 -0
  100. package/neural-interface/public/shared/html-term-renderer.js +1022 -0
  101. package/neural-interface/public/shared/i18n.js +147 -0
  102. package/neural-interface/public/shared/registry.js +82 -0
  103. package/neural-interface/public/shared/state.js +86 -0
  104. package/neural-interface/public/shared/storage.js +150 -0
  105. package/neural-interface/public/shared/styles.css +21212 -0
  106. package/neural-interface/public/shared/ui-automation-studio.js +3573 -0
  107. package/neural-interface/public/shared/ui-bookmarks.js +231 -0
  108. package/neural-interface/public/shared/ui-claude-panel.js +6333 -0
  109. package/neural-interface/public/shared/ui-command-runner.js +534 -0
  110. package/neural-interface/public/shared/ui-cost-widget.js +602 -0
  111. package/neural-interface/public/shared/ui-detail.js +1454 -0
  112. package/neural-interface/public/shared/ui-explorer.js +1356 -0
  113. package/neural-interface/public/shared/ui-file-explorer.js +2750 -0
  114. package/neural-interface/public/shared/ui-games.js +411 -0
  115. package/neural-interface/public/shared/ui-help.js +107 -0
  116. package/neural-interface/public/shared/ui-image-gallery.js +461 -0
  117. package/neural-interface/public/shared/ui-invite.js +519 -0
  118. package/neural-interface/public/shared/ui-keybinds.js +452 -0
  119. package/neural-interface/public/shared/ui-layouts.js +297 -0
  120. package/neural-interface/public/shared/ui-link.js +731 -0
  121. package/neural-interface/public/shared/ui-loading.js +255 -0
  122. package/neural-interface/public/shared/ui-menubar.js +499 -0
  123. package/neural-interface/public/shared/ui-multiselect.js +93 -0
  124. package/neural-interface/public/shared/ui-navbar.js +222 -0
  125. package/neural-interface/public/shared/ui-notifications.js +287 -0
  126. package/neural-interface/public/shared/ui-panels.js +309 -0
  127. package/neural-interface/public/shared/ui-resume.js +653 -0
  128. package/neural-interface/public/shared/ui-search.js +145 -0
  129. package/neural-interface/public/shared/ui-sessions.js +531 -0
  130. package/neural-interface/public/shared/ui-settings.js +5987 -0
  131. package/neural-interface/public/shared/ui-sidebar.js +1905 -0
  132. package/neural-interface/public/shared/ui-skills.js +2805 -0
  133. package/neural-interface/public/shared/ui-stats.js +38 -0
  134. package/neural-interface/public/shared/ui-sync.js +201 -0
  135. package/neural-interface/public/shared/ui-terminal.js +6385 -0
  136. package/neural-interface/public/shared/ui-tooltip.js +123 -0
  137. package/neural-interface/public/shared/ui-trash.js +439 -0
  138. package/neural-interface/public/shared/ui-tutorial-draw.js +875 -0
  139. package/neural-interface/public/shared/ui-tutorial-steps.js +2276 -0
  140. package/neural-interface/public/shared/ui-tutorial.js +1117 -0
  141. package/neural-interface/public/shared/ui-update.js +244 -0
  142. package/neural-interface/public/shared/ui-whiteboard.js +3262 -0
  143. package/neural-interface/public/shared/ui-workspaces.js +722 -0
  144. package/neural-interface/public/shared/utils.js +360 -0
  145. package/neural-interface/public/shared/vterm-buffer.js +1102 -0
  146. package/neural-interface/public/sw.js +52 -0
  147. package/neural-interface/public/synabun.png +0 -0
  148. package/neural-interface/public/toolbar-album.svg +8 -0
  149. package/neural-interface/public/toolbar-question.svg +6 -0
  150. package/neural-interface/public/toolbar-sessions.svg +6 -0
  151. package/neural-interface/public/variant/2d/background.js +113 -0
  152. package/neural-interface/public/variant/2d/context-menu.js +133 -0
  153. package/neural-interface/public/variant/2d/gfx.js +45 -0
  154. package/neural-interface/public/variant/2d/graph.js +1635 -0
  155. package/neural-interface/public/variant/2d/hulls.js +207 -0
  156. package/neural-interface/public/variant/2d/lasso.js +135 -0
  157. package/neural-interface/public/variant/2d/layout.js +594 -0
  158. package/neural-interface/public/variant/2d/main.js +521 -0
  159. package/neural-interface/public/variant/2d/minimap.js +312 -0
  160. package/neural-interface/public/variant/2d/settings-gfx.js +98 -0
  161. package/neural-interface/public/variant/3d/background.js +884 -0
  162. package/neural-interface/public/variant/3d/camera.js +661 -0
  163. package/neural-interface/public/variant/3d/gfx.js +116 -0
  164. package/neural-interface/public/variant/3d/graph.js +2399 -0
  165. package/neural-interface/public/variant/3d/main.js +500 -0
  166. package/neural-interface/public/variant/3d/settings-gfx.js +228 -0
  167. package/neural-interface/scripts/rebuild-pty.js +100 -0
  168. package/neural-interface/server.js +15000 -0
  169. package/neural-interface/templates/CLAUDE-template.md +211 -0
  170. package/package.json +53 -0
  171. package/postinstall.js +135 -0
  172. package/setup.js +260 -0
  173. package/skills/leonardo/SKILL.md +159 -0
  174. package/skills/leonardo/icon.svg +1 -0
  175. package/skills/leonardo/modules/browser-guide.md +378 -0
  176. package/skills/leonardo/modules/image-prompter.md +277 -0
  177. package/skills/leonardo/modules/model-advisor.md +100 -0
  178. package/skills/leonardo/modules/prompt-library.md +110 -0
  179. package/skills/leonardo/modules/style-guide.md +186 -0
  180. package/skills/leonardo/modules/video-prompter.md +311 -0
  181. package/skills/synabun/SKILL.md +185 -0
  182. package/skills/synabun/icon.png +0 -0
  183. package/skills/synabun/modules/audit.md +374 -0
  184. package/skills/synabun/modules/changelog.md +194 -0
  185. package/skills/synabun/modules/idea.md +144 -0
  186. package/skills/synabun/modules/memorize.md +188 -0
  187. package/skills/synabun/synabunicon.png +0 -0
@@ -0,0 +1,875 @@
1
+ // ═══════════════════════════════════════════
2
+ // TUTORIAL DRAWING ENGINE
3
+ // ═══════════════════════════════════════════
4
+ //
5
+ // Hand-drawn SVG animation primitives for the onboarding tutorial.
6
+ // Replicates the fluid drawing style from the SynaBun website whiteboard section.
7
+ // Zero knowledge of tutorial steps — pure drawing utilities.
8
+ //
9
+ // Core techniques:
10
+ // - Catmull-Rom spline interpolation for smooth, organic curves
11
+ // - Seeded PRNG for deterministic wobble (same seed = same wobble every time)
12
+ // - SVG stroke-dashoffset animation for "draw-in" effect
13
+ // - CSS transitions for timing/easing
14
+
15
+ const SVG_NS = 'http://www.w3.org/2000/svg';
16
+
17
+
18
+ // ═══════════════════════════════════════════
19
+ // PORTED FUNCTIONS (from ui-whiteboard.js:964-1143)
20
+ // ═══════════════════════════════════════════
21
+
22
+ /** Seeded random for consistent wobble per element ID. */
23
+ export function seededRand(seed) {
24
+ let h = 0;
25
+ for (let i = 0; i < seed.length; i++) {
26
+ h = ((h << 5) - h + seed.charCodeAt(i)) | 0;
27
+ }
28
+ return () => {
29
+ h = (h * 16807 + 0) % 2147483647;
30
+ return (h & 0x7fffffff) / 2147483647;
31
+ };
32
+ }
33
+
34
+ /** Convert points to a smooth SVG path using Catmull-Rom → cubic bezier (open path). */
35
+ export function pointsToSmoothPath(pts) {
36
+ if (pts.length === 0) return '';
37
+ if (pts.length === 1) return `M ${pts[0][0]} ${pts[0][1]}`;
38
+ if (pts.length === 2) return `M ${pts[0][0]} ${pts[0][1]} L ${pts[1][0]} ${pts[1][1]}`;
39
+
40
+ let d = `M ${pts[0][0]} ${pts[0][1]}`;
41
+
42
+ for (let i = 0; i < pts.length - 1; i++) {
43
+ const p0 = pts[Math.max(0, i - 1)];
44
+ const p1 = pts[i];
45
+ const p2 = pts[Math.min(pts.length - 1, i + 1)];
46
+ const p3 = pts[Math.min(pts.length - 1, i + 2)];
47
+
48
+ const t = 0.4;
49
+ const cp1x = p1[0] + (p2[0] - p0[0]) * t;
50
+ const cp1y = p1[1] + (p2[1] - p0[1]) * t;
51
+ const cp2x = p2[0] - (p3[0] - p1[0]) * t;
52
+ const cp2y = p2[1] - (p3[1] - p1[1]) * t;
53
+
54
+ d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2[0]} ${p2[1]}`;
55
+ }
56
+ return d;
57
+ }
58
+
59
+ /** Convert points to a smooth CLOSED SVG path — wraps neighbors around for proper closure. */
60
+ export function closedSmoothPath(pts) {
61
+ if (pts.length < 3) return pointsToSmoothPath(pts) + ' Z';
62
+ const n = pts.length;
63
+ let d = `M ${pts[0][0]} ${pts[0][1]}`;
64
+
65
+ for (let i = 0; i < n; i++) {
66
+ const p0 = pts[(i - 1 + n) % n];
67
+ const p1 = pts[i];
68
+ const p2 = pts[(i + 1) % n];
69
+ const p3 = pts[(i + 2) % n];
70
+
71
+ const t = 0.3;
72
+ const cp1x = p1[0] + (p2[0] - p0[0]) * t;
73
+ const cp1y = p1[1] + (p2[1] - p0[1]) * t;
74
+ const cp2x = p2[0] - (p3[0] - p1[0]) * t;
75
+ const cp2y = p2[1] - (p3[1] - p1[1]) * t;
76
+
77
+ d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2[0]} ${p2[1]}`;
78
+ }
79
+ return d + ' Z';
80
+ }
81
+
82
+ /** Smooth points using Laplacian (moving average) — preserves stroke shape, removes jitter. */
83
+ export function smoothPoints(pts, passes = 5) {
84
+ if (pts.length <= 2) return pts;
85
+ let result = pts;
86
+ for (let p = 0; p < passes; p++) {
87
+ const next = [result[0]];
88
+ for (let i = 1; i < result.length - 1; i++) {
89
+ const prev = result[i - 1], cur = result[i], nxt = result[i + 1];
90
+ next.push([
91
+ cur[0] * 0.4 + (prev[0] + nxt[0]) * 0.3,
92
+ cur[1] * 0.4 + (prev[1] + nxt[1]) * 0.3,
93
+ ]);
94
+ }
95
+ next.push(result[result.length - 1]);
96
+ result = next;
97
+ }
98
+ if (result.length > 200) {
99
+ const step = Math.ceil(result.length / 200);
100
+ const thinned = [result[0]];
101
+ for (let i = step; i < result.length - 1; i += step) thinned.push(result[i]);
102
+ thinned.push(result[result.length - 1]);
103
+ return thinned;
104
+ }
105
+ return result;
106
+ }
107
+
108
+ /** Generate a hand-drawn SVG path for a shape type. */
109
+ export function generateHandDrawnShape(shape, w, h, id) {
110
+ const rand = seededRand(id || 'default');
111
+ const j = (amt = 4) => (rand() - 0.5) * amt;
112
+ const m = 5;
113
+
114
+ if (shape === 'circle') {
115
+ const cx = w / 2, cy = h / 2;
116
+ const rx = (w / 2) - m, ry = (h / 2) - m;
117
+ const pts = [];
118
+ const n = 12;
119
+ for (let i = 0; i < n; i++) {
120
+ const a = (i / n) * Math.PI * 2;
121
+ pts.push([
122
+ cx + Math.cos(a) * (rx + j(3)),
123
+ cy + Math.sin(a) * (ry + j(3)),
124
+ ]);
125
+ }
126
+ return closedSmoothPath(pts);
127
+ }
128
+
129
+ if (shape === 'pill') {
130
+ const r = Math.min(w, h) / 2 - m;
131
+ const pts = [];
132
+ pts.push([m + r + j(), m + j(2)]);
133
+ pts.push([w / 2 + j(), m + j(2)]);
134
+ pts.push([w - m - r + j(), m + j(2)]);
135
+ for (let i = 0; i <= 5; i++) {
136
+ const a = -Math.PI / 2 + (i / 5) * Math.PI;
137
+ pts.push([w - m - r + Math.cos(a) * (r + j(2)), h / 2 + Math.sin(a) * (r + j(2))]);
138
+ }
139
+ pts.push([w - m - r + j(), h - m + j(2)]);
140
+ pts.push([w / 2 + j(), h - m + j(2)]);
141
+ pts.push([m + r + j(), h - m + j(2)]);
142
+ for (let i = 0; i <= 5; i++) {
143
+ const a = Math.PI / 2 + (i / 5) * Math.PI;
144
+ pts.push([m + r + Math.cos(a) * (r + j(2)), h / 2 + Math.sin(a) * (r + j(2))]);
145
+ }
146
+ return closedSmoothPath(pts);
147
+ }
148
+
149
+ // Default: rect
150
+ const r = Math.min(12, Math.min(w, h) / 4);
151
+ const x1 = m, y1 = m, x2 = w - m, y2 = h - m;
152
+ return `M ${x1 + r} ${y1}`
153
+ + ` L ${x2 - r} ${y1} A ${r} ${r} 0 0 1 ${x2} ${y1 + r}`
154
+ + ` L ${x2} ${y2 - r} A ${r} ${r} 0 0 1 ${x2 - r} ${y2}`
155
+ + ` L ${x1 + r} ${y2} A ${r} ${r} 0 0 1 ${x1} ${y2 - r}`
156
+ + ` L ${x1} ${y1 + r} A ${r} ${r} 0 0 1 ${x1 + r} ${y1}`
157
+ + ` Z`;
158
+ }
159
+
160
+ /** Generate a drawn-circle path with overshoot tail. */
161
+ export function generateDrawnCirclePath(w, h, id) {
162
+ const rand = seededRand(id || 'drawn');
163
+ const j = (amt = 3) => (rand() - 0.5) * amt;
164
+ const cx = w / 2, cy = h / 2;
165
+ const rx = (w / 2) - 6, ry = (h / 2) - 6;
166
+
167
+ const pts = [];
168
+ const n = 16;
169
+ const overshoot = 0.6;
170
+ const totalAngle = Math.PI * 2 + overshoot;
171
+
172
+ for (let i = 0; i <= n; i++) {
173
+ const t = i / n;
174
+ const a = -0.2 + t * totalAngle;
175
+ const wobble = i < n ? j(4) : j(1);
176
+ pts.push([
177
+ cx + Math.cos(a) * (rx + wobble),
178
+ cy + Math.sin(a) * (ry + wobble),
179
+ ]);
180
+ }
181
+ return pointsToSmoothPath(pts);
182
+ }
183
+
184
+
185
+ // ═══════════════════════════════════════════
186
+ // SVG HELPERS
187
+ // ═══════════════════════════════════════════
188
+
189
+ function createSvgPath(container, d, color, strokeWidth = 2) {
190
+ const path = document.createElementNS(SVG_NS, 'path');
191
+ path.setAttribute('d', d);
192
+ path.setAttribute('fill', 'none');
193
+ path.setAttribute('stroke', color);
194
+ path.setAttribute('stroke-width', String(strokeWidth));
195
+ path.setAttribute('stroke-linecap', 'round');
196
+ path.setAttribute('stroke-linejoin', 'round');
197
+ container.appendChild(path);
198
+ return path;
199
+ }
200
+
201
+ function animateStrokeDash(path, duration, delay) {
202
+ const length = path.getTotalLength();
203
+ path.style.strokeDasharray = String(length);
204
+ path.style.strokeDashoffset = String(length);
205
+ path.style.transition = `stroke-dashoffset ${duration}ms ease ${delay}ms`;
206
+ requestAnimationFrame(() => {
207
+ path.style.strokeDashoffset = '0';
208
+ });
209
+ return length;
210
+ }
211
+
212
+
213
+ // ═══════════════════════════════════════════
214
+ // ANIMATED ARROW
215
+ // ═══════════════════════════════════════════
216
+
217
+ /**
218
+ * Draw a hand-drawn animated arrow from point A to point B.
219
+ * @param {SVGElement} svg - Container SVG element
220
+ * @param {Object} config - { from:{x,y}, to:{x,y}, color, wobbleSeed, duration, delay, arrowheadSize }
221
+ * @returns {{ destroy: Function }}
222
+ */
223
+ export function createAnimatedArrow(svg, config) {
224
+ const {
225
+ from, to,
226
+ color = 'rgba(255,255,255,0.35)',
227
+ wobbleSeed = 'arrow',
228
+ duration = 800,
229
+ delay = 0,
230
+ arrowheadSize = 18,
231
+ strokeWidth = 2,
232
+ } = config;
233
+
234
+ const rand = seededRand(wobbleSeed);
235
+ const dx = to.x - from.x;
236
+ const dy = to.y - from.y;
237
+ const dist = Math.hypot(dx, dy);
238
+ const angle = Math.atan2(dy, dx);
239
+ const perpAngle = angle + Math.PI / 2;
240
+
241
+ // Control point: gentle perpendicular offset, capped so long arrows stay tight
242
+ const arcAmount = Math.min(dist * (0.15 + rand() * 0.1), 50);
243
+ const cpX = (from.x + to.x) / 2 + Math.cos(perpAngle) * arcAmount;
244
+ const cpY = (from.y + to.y) / 2 + Math.sin(perpAngle) * arcAmount;
245
+
246
+ // Quadratic bezier — clean, predictable arc
247
+ const pathD = `M ${from.x} ${from.y} Q ${cpX} ${cpY} ${to.x} ${to.y}`;
248
+
249
+ const bodyPath = createSvgPath(svg, pathD, color, strokeWidth);
250
+ bodyPath.setAttribute('stroke-linecap', 'round');
251
+ animateStrokeDash(bodyPath, duration, delay);
252
+
253
+ // Arrowhead: filled triangle at the tip
254
+ // endAngle = direction the arrow is traveling at its endpoint
255
+ const endAngle = Math.atan2(to.y - cpY, to.x - cpX);
256
+ const s = arrowheadSize;
257
+ const spread = 0.6;
258
+ // Two back points of the triangle
259
+ const lx = to.x - Math.cos(endAngle - spread) * s;
260
+ const ly = to.y - Math.sin(endAngle - spread) * s;
261
+ const rx = to.x - Math.cos(endAngle + spread) * s;
262
+ const ry = to.y - Math.sin(endAngle + spread) * s;
263
+
264
+ const head = document.createElementNS(SVG_NS, 'polyline');
265
+ head.setAttribute('points', `${lx},${ly} ${to.x},${to.y} ${rx},${ry}`);
266
+ head.setAttribute('fill', 'none');
267
+ head.setAttribute('stroke', color);
268
+ head.setAttribute('stroke-width', String(strokeWidth));
269
+ head.setAttribute('stroke-linecap', 'round');
270
+ head.setAttribute('stroke-linejoin', 'round');
271
+ head.style.opacity = '0';
272
+ head.style.transition = `opacity 0.25s ease ${delay + duration - 100}ms`;
273
+ svg.appendChild(head);
274
+ requestAnimationFrame(() => { head.style.opacity = '1'; });
275
+
276
+ return {
277
+ elements: [bodyPath, head],
278
+ destroy() { bodyPath.remove(); head.remove(); },
279
+ };
280
+ }
281
+
282
+
283
+ // ═══════════════════════════════════════════
284
+ // HIGHLIGHT CIRCLE (clean animated ellipse)
285
+ // ═══════════════════════════════════════════
286
+
287
+ /**
288
+ * Draw a clean animated ellipse around a DOMRect.
289
+ * Elegant stroke-dashoffset draw-in — no wobble, no overshoot.
290
+ * Best for small UI elements (toolbar buttons, icons).
291
+ * @param {SVGElement} svg
292
+ * @param {DOMRect} rect - Bounding rect of the target
293
+ * @param {Object} config - { color, padding, duration, delay, strokeWidth }
294
+ * @returns {{ destroy: Function }}
295
+ */
296
+ export function createHighlightCircle(svg, rect, config) {
297
+ const {
298
+ color = 'rgba(255, 255, 255, 0.25)',
299
+ padding = 12,
300
+ duration = 600,
301
+ delay = 0,
302
+ strokeWidth = 1.2,
303
+ } = config;
304
+
305
+ const cx = rect.left + rect.width / 2;
306
+ const cy = rect.top + rect.height / 2;
307
+ const rx = rect.width / 2 + padding;
308
+ const ry = rect.height / 2 + padding;
309
+
310
+ const ellipse = document.createElementNS(SVG_NS, 'ellipse');
311
+ ellipse.setAttribute('cx', String(cx));
312
+ ellipse.setAttribute('cy', String(cy));
313
+ ellipse.setAttribute('rx', String(rx));
314
+ ellipse.setAttribute('ry', String(ry));
315
+ ellipse.setAttribute('fill', 'none');
316
+ ellipse.setAttribute('stroke', color);
317
+ ellipse.setAttribute('stroke-width', String(strokeWidth));
318
+ ellipse.setAttribute('stroke-linecap', 'round');
319
+ svg.appendChild(ellipse);
320
+
321
+ // Ramanujan approximation for ellipse circumference
322
+ const circumference = Math.PI * (3 * (rx + ry) - Math.sqrt((3 * rx + ry) * (rx + 3 * ry)));
323
+ ellipse.style.strokeDasharray = String(circumference);
324
+ ellipse.style.strokeDashoffset = String(circumference);
325
+ ellipse.style.transition = `stroke-dashoffset ${duration}ms cubic-bezier(0.4, 0, 0.2, 1) ${delay}ms`;
326
+ requestAnimationFrame(() => {
327
+ ellipse.style.strokeDashoffset = '0';
328
+ });
329
+
330
+ return {
331
+ elements: [ellipse],
332
+ destroy() { ellipse.remove(); },
333
+ };
334
+ }
335
+
336
+
337
+ // ═══════════════════════════════════════════
338
+ // WOBBLY CIRCLE (around a target element)
339
+ // ═══════════════════════════════════════════
340
+
341
+ /**
342
+ * Draw a hand-drawn wobbly circle around a DOMRect.
343
+ * @param {SVGElement} svg
344
+ * @param {DOMRect} rect - Bounding rect of the target
345
+ * @param {Object} config - { color, padding, wobbleSeed, duration, delay }
346
+ * @returns {{ destroy: Function }}
347
+ */
348
+ export function createWobblyCircle(svg, rect, config) {
349
+ const {
350
+ color = 'rgba(255,255,255,0.3)',
351
+ padding = 12,
352
+ wobbleSeed = 'circle',
353
+ duration = 700,
354
+ delay = 0,
355
+ } = config;
356
+
357
+ const w = rect.width + padding * 2;
358
+ const h = rect.height + padding * 2;
359
+ const cx = rect.left - padding + w / 2;
360
+ const cy = rect.top - padding + h / 2;
361
+
362
+ const rand = seededRand(wobbleSeed);
363
+ const j = (amt = 4) => (rand() - 0.5) * amt;
364
+
365
+ const rx = w / 2;
366
+ const ry = h / 2;
367
+ const pts = [];
368
+ const n = 16;
369
+ const overshoot = 0.6;
370
+ const totalAngle = Math.PI * 2 + overshoot;
371
+
372
+ for (let i = 0; i <= n; i++) {
373
+ const t = i / n;
374
+ const a = -0.2 + t * totalAngle;
375
+ const wobble = i < n ? j(4) : j(1);
376
+ pts.push([
377
+ cx + Math.cos(a) * (rx + wobble),
378
+ cy + Math.sin(a) * (ry + wobble),
379
+ ]);
380
+ }
381
+
382
+ const pathD = pointsToSmoothPath(pts);
383
+ const path = createSvgPath(svg, pathD, color, 2);
384
+ animateStrokeDash(path, duration, delay);
385
+
386
+ return {
387
+ elements: [path],
388
+ destroy() { path.remove(); },
389
+ };
390
+ }
391
+
392
+
393
+ // ═══════════════════════════════════════════
394
+ // HAND-DRAWN UNDERLINE
395
+ // ═══════════════════════════════════════════
396
+
397
+ /**
398
+ * Draw a wavy hand-drawn underline beneath a DOMRect.
399
+ * @param {SVGElement} svg
400
+ * @param {DOMRect} rect
401
+ * @param {Object} config - { color, wobbleSeed, duration, delay, offset }
402
+ * @returns {{ destroy: Function }}
403
+ */
404
+ export function createHandDrawnUnderline(svg, rect, config) {
405
+ const {
406
+ color = 'rgba(255,255,255,0.3)',
407
+ wobbleSeed = 'underline',
408
+ duration = 500,
409
+ delay = 0,
410
+ offset = 4,
411
+ } = config;
412
+
413
+ const rand = seededRand(wobbleSeed);
414
+ const j = () => (rand() - 0.5) * 3;
415
+
416
+ const y = rect.bottom + offset;
417
+ const pts = [];
418
+ const segments = 5;
419
+ for (let i = 0; i <= segments; i++) {
420
+ const t = i / segments;
421
+ pts.push([
422
+ rect.left + rect.width * t + j(),
423
+ y + j(),
424
+ ]);
425
+ }
426
+
427
+ const pathD = pointsToSmoothPath(pts);
428
+ const path = createSvgPath(svg, pathD, color, 1.5);
429
+ animateStrokeDash(path, duration, delay);
430
+
431
+ return {
432
+ elements: [path],
433
+ destroy() { path.remove(); },
434
+ };
435
+ }
436
+
437
+
438
+ // ═══════════════════════════════════════════
439
+ // TYPEWRITER TEXT (DOM-based)
440
+ // ═══════════════════════════════════════════
441
+
442
+ /**
443
+ * Character-by-character text reveal with blinking cursor.
444
+ * @param {HTMLElement} container
445
+ * @param {string} text
446
+ * @param {Object} config - { delay, charDelay, font ('mono'|'sans'), color, onComplete }
447
+ * @returns {{ el: HTMLElement, destroy: Function }}
448
+ */
449
+ export function typewriterText(container, text, config) {
450
+ const {
451
+ delay: startDelay = 0,
452
+ charDelay = 35,
453
+ font = 'sans',
454
+ color = 'rgba(255,255,255,0.6)',
455
+ onComplete,
456
+ } = config;
457
+
458
+ const el = document.createElement('span');
459
+ el.className = 'tutorial-typed';
460
+ el.style.fontFamily = font === 'mono'
461
+ ? "'JetBrains Mono', monospace"
462
+ : "'Inter', -apple-system, sans-serif";
463
+ el.style.color = color;
464
+ el.style.fontSize = '13px';
465
+ container.appendChild(el);
466
+
467
+ let i = 0;
468
+ let interval = null;
469
+ let timer = null;
470
+
471
+ timer = setTimeout(() => {
472
+ el.classList.add('typing');
473
+ interval = setInterval(() => {
474
+ if (i < text.length) {
475
+ el.textContent += text[i++];
476
+ } else {
477
+ clearInterval(interval);
478
+ interval = null;
479
+ el.classList.remove('typing');
480
+ onComplete?.();
481
+ }
482
+ }, charDelay);
483
+ }, startDelay);
484
+
485
+ return {
486
+ el,
487
+ destroy() {
488
+ if (timer) clearTimeout(timer);
489
+ if (interval) clearInterval(interval);
490
+ el.remove();
491
+ },
492
+ };
493
+ }
494
+
495
+
496
+ // ═══════════════════════════════════════════
497
+ // HANDWRITTEN LABEL (SVG text with Caveat)
498
+ // ═══════════════════════════════════════════
499
+
500
+ /**
501
+ * An SVG <text> element using Caveat font that fades in with slight scale.
502
+ * @param {SVGElement} svg
503
+ * @param {string} text
504
+ * @param {Object} config - { x, y, color, fontSize, delay, duration }
505
+ * @returns {{ destroy: Function }}
506
+ */
507
+ export function handwrittenLabel(svg, text, config) {
508
+ const {
509
+ x = 0, y = 0,
510
+ color = 'rgba(255,255,255,0.4)',
511
+ fontSize = 18,
512
+ delay: startDelay = 0,
513
+ duration = 400,
514
+ maxWidth = 0,
515
+ lineHeight = 1.3,
516
+ } = config;
517
+
518
+ const textEl = document.createElementNS(SVG_NS, 'text');
519
+ textEl.setAttribute('x', String(x));
520
+ textEl.setAttribute('y', String(y));
521
+ textEl.setAttribute('fill', color);
522
+ textEl.setAttribute('font-family', "'Caveat', cursive");
523
+ textEl.setAttribute('font-size', String(fontSize));
524
+ if (config.textAnchor) textEl.setAttribute('text-anchor', config.textAnchor);
525
+
526
+ // Multiline: wrap text into <tspan> rows when maxWidth is set
527
+ if (maxWidth > 0) {
528
+ const words = text.split(' ');
529
+ const lines = [];
530
+ let cur = '';
531
+ // Approximate char width for Caveat at this size (roughly 0.55em)
532
+ const charW = fontSize * 0.55;
533
+ const maxChars = Math.floor(maxWidth / charW);
534
+ for (const word of words) {
535
+ const test = cur ? cur + ' ' + word : word;
536
+ if (test.length > maxChars && cur) {
537
+ lines.push(cur);
538
+ cur = word;
539
+ } else {
540
+ cur = test;
541
+ }
542
+ }
543
+ if (cur) lines.push(cur);
544
+
545
+ const dy = fontSize * lineHeight;
546
+ lines.forEach((line, i) => {
547
+ const tspan = document.createElementNS(SVG_NS, 'tspan');
548
+ tspan.setAttribute('x', String(x));
549
+ tspan.setAttribute('dy', i === 0 ? '0' : String(dy));
550
+ tspan.textContent = line;
551
+ textEl.appendChild(tspan);
552
+ });
553
+ } else {
554
+ textEl.textContent = text;
555
+ }
556
+
557
+ // Start invisible + slightly scaled (use transform-box so origin is the text's own bbox)
558
+ textEl.style.opacity = '0';
559
+ textEl.style.transformBox = 'fill-box';
560
+ textEl.style.transformOrigin = 'center center';
561
+ textEl.style.transform = 'scale(0.85)';
562
+ textEl.style.transition = `opacity ${duration}ms ease ${startDelay}ms, transform ${duration}ms cubic-bezier(0.22, 1, 0.36, 1) ${startDelay}ms`;
563
+
564
+ svg.appendChild(textEl);
565
+
566
+ requestAnimationFrame(() => {
567
+ textEl.style.opacity = '1';
568
+ textEl.style.transform = 'scale(1)';
569
+ });
570
+
571
+ return {
572
+ elements: [textEl],
573
+ destroy() { textEl.remove(); },
574
+ };
575
+ }
576
+
577
+
578
+ // ═══════════════════════════════════════════
579
+ // DOODLE LIBRARY
580
+ // ═══════════════════════════════════════════
581
+
582
+ const DOODLE_LIBRARY = {
583
+ sparkle: {
584
+ viewBox: [0, 0, 40, 40],
585
+ paths: [
586
+ // 4-pointed star
587
+ 'M 20 4 Q 21 18 36 20 Q 21 22 20 36 Q 19 22 4 20 Q 19 18 20 4',
588
+ // Small dots around
589
+ 'M 10 8 L 10.5 8.5',
590
+ 'M 30 8 L 30.5 8.5',
591
+ 'M 10 32 L 10.5 32.5',
592
+ 'M 30 32 L 30.5 32.5',
593
+ ],
594
+ },
595
+ checkmark: {
596
+ viewBox: [0, 0, 36, 36],
597
+ paths: [
598
+ 'M 6 18 Q 10 22 14 26 Q 20 14 30 8',
599
+ ],
600
+ },
601
+ star: {
602
+ viewBox: [0, 0, 40, 40],
603
+ paths: [
604
+ 'M 20 4 L 24 15 L 36 16 L 27 24 L 30 36 L 20 29 L 10 36 L 13 24 L 4 16 L 16 15 Z',
605
+ ],
606
+ },
607
+ 'arrow-squiggle': {
608
+ viewBox: [0, 0, 60, 30],
609
+ paths: [
610
+ 'M 4 20 Q 15 6 25 18 Q 35 30 45 16 L 56 12',
611
+ 'M 50 6 L 56 12 L 50 18',
612
+ ],
613
+ },
614
+ heart: {
615
+ viewBox: [0, 0, 36, 34],
616
+ paths: [
617
+ 'M 18 30 Q 4 20 4 12 Q 4 4 11 4 Q 16 4 18 10 Q 20 4 25 4 Q 32 4 32 12 Q 32 20 18 30',
618
+ ],
619
+ },
620
+ wave: {
621
+ viewBox: [0, 0, 60, 20],
622
+ paths: [
623
+ 'M 4 10 Q 12 2 20 10 Q 28 18 36 10 Q 44 2 52 10',
624
+ ],
625
+ },
626
+ alert: {
627
+ viewBox: [0, 0, 40, 40],
628
+ paths: [
629
+ // Triangle outline
630
+ 'M 20 4 L 36 34 L 4 34 Z',
631
+ // Exclamation line
632
+ 'M 20 14 L 20 24',
633
+ // Exclamation dot
634
+ 'M 20 28 L 20.3 28.3',
635
+ ],
636
+ },
637
+ };
638
+
639
+ /**
640
+ * Render a pre-defined SVG doodle with stroke-dashoffset draw-in animation.
641
+ * @param {SVGElement} svg
642
+ * @param {string} doodleId - Key into DOODLE_LIBRARY
643
+ * @param {Object} config - { x, y, scale, color, duration, delay }
644
+ * @returns {{ destroy: Function }}
645
+ */
646
+ export function createDoodle(svg, doodleId, config) {
647
+ const doodle = DOODLE_LIBRARY[doodleId];
648
+ if (!doodle) return { elements: [], destroy() {} };
649
+
650
+ const {
651
+ x = 0, y = 0,
652
+ scale = 1,
653
+ color = 'rgba(255,255,255,0.3)',
654
+ duration = 600,
655
+ delay: startDelay = 0,
656
+ } = config;
657
+
658
+ const g = document.createElementNS(SVG_NS, 'g');
659
+ g.setAttribute('transform', `translate(${x}, ${y}) scale(${scale})`);
660
+ svg.appendChild(g);
661
+
662
+ const elements = [g];
663
+ doodle.paths.forEach((pathD, i) => {
664
+ const path = createSvgPath(g, pathD, color, 1.5);
665
+ const perPathDelay = startDelay + i * 120;
666
+ animateStrokeDash(path, duration, perPathDelay);
667
+ elements.push(path);
668
+ });
669
+
670
+ return {
671
+ elements,
672
+ destroy() { g.remove(); },
673
+ };
674
+ }
675
+
676
+ /** Get all available doodle IDs. */
677
+ export function getDoodleIds() {
678
+ return Object.keys(DOODLE_LIBRARY);
679
+ }
680
+
681
+
682
+ // ═══════════════════════════════════════════
683
+ // SPOTLIGHT MASK
684
+ // ═══════════════════════════════════════════
685
+
686
+ /**
687
+ * Create an SVG mask with a hand-drawn cutout around a target rect.
688
+ * Returns the mask group + backdrop rect for controlling visibility.
689
+ * @param {SVGElement} svg
690
+ * @param {DOMRect|null} targetRect - null for fullscreen (no cutout)
691
+ * @param {Object} config - { padding, wobbleSeed }
692
+ * @returns {{ update: Function, destroy: Function }}
693
+ */
694
+ export function createSpotlight(svg, targetRect, config) {
695
+ const {
696
+ padding = 16,
697
+ wobbleSeed = 'spotlight',
698
+ } = config;
699
+
700
+ const vw = window.innerWidth;
701
+ const vh = window.innerHeight;
702
+
703
+ // Defs + mask
704
+ let defs = svg.querySelector('defs');
705
+ if (!defs) {
706
+ defs = document.createElementNS(SVG_NS, 'defs');
707
+ svg.prepend(defs);
708
+ }
709
+
710
+ const maskId = 'tutorial-spotlight-mask';
711
+ const mask = document.createElementNS(SVG_NS, 'mask');
712
+ mask.id = maskId;
713
+
714
+ // White full rect (shows everything as dimmed)
715
+ const maskBg = document.createElementNS(SVG_NS, 'rect');
716
+ maskBg.setAttribute('x', '0');
717
+ maskBg.setAttribute('y', '0');
718
+ maskBg.setAttribute('width', String(vw));
719
+ maskBg.setAttribute('height', String(vh));
720
+ maskBg.setAttribute('fill', 'white');
721
+ mask.appendChild(maskBg);
722
+
723
+ // Black cutout (reveals the target area)
724
+ let cutout = null;
725
+ if (targetRect) {
726
+ cutout = document.createElementNS(SVG_NS, 'rect');
727
+ cutout.setAttribute('x', String(targetRect.left - padding));
728
+ cutout.setAttribute('y', String(targetRect.top - padding));
729
+ cutout.setAttribute('width', String(targetRect.width + padding * 2));
730
+ cutout.setAttribute('height', String(targetRect.height + padding * 2));
731
+ cutout.setAttribute('rx', '12');
732
+ cutout.setAttribute('ry', '12');
733
+ cutout.setAttribute('fill', 'black');
734
+ mask.appendChild(cutout);
735
+ }
736
+
737
+ defs.appendChild(mask);
738
+
739
+ // Dimmed backdrop rect
740
+ const backdrop = document.createElementNS(SVG_NS, 'rect');
741
+ backdrop.setAttribute('x', '0');
742
+ backdrop.setAttribute('y', '0');
743
+ backdrop.setAttribute('width', String(vw));
744
+ backdrop.setAttribute('height', String(vh));
745
+ backdrop.setAttribute('fill', 'rgba(0,0,0,0.6)');
746
+ backdrop.setAttribute('mask', `url(#${maskId})`);
747
+ backdrop.style.opacity = '0';
748
+ backdrop.style.transition = 'opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1)';
749
+
750
+ // Insert backdrop as first child so drawings render on top
751
+ if (svg.firstChild && svg.firstChild !== defs) {
752
+ svg.insertBefore(backdrop, svg.firstChild.nextSibling);
753
+ } else {
754
+ svg.appendChild(backdrop);
755
+ }
756
+
757
+ // Fade in
758
+ requestAnimationFrame(() => {
759
+ backdrop.style.opacity = '1';
760
+ });
761
+
762
+ return {
763
+ update(newRect) {
764
+ if (cutout && newRect) {
765
+ cutout.setAttribute('x', String(newRect.left - padding));
766
+ cutout.setAttribute('y', String(newRect.top - padding));
767
+ cutout.setAttribute('width', String(newRect.width + padding * 2));
768
+ cutout.setAttribute('height', String(newRect.height + padding * 2));
769
+ }
770
+ // Update viewport size
771
+ maskBg.setAttribute('width', String(window.innerWidth));
772
+ maskBg.setAttribute('height', String(window.innerHeight));
773
+ backdrop.setAttribute('width', String(window.innerWidth));
774
+ backdrop.setAttribute('height', String(window.innerHeight));
775
+ },
776
+ fadeOut(cb) {
777
+ backdrop.style.opacity = '0';
778
+ setTimeout(() => cb?.(), 400);
779
+ },
780
+ destroy() {
781
+ backdrop.remove();
782
+ mask.remove();
783
+ },
784
+ };
785
+ }
786
+
787
+
788
+ // ═══════════════════════════════════════════
789
+ // TARGET RESOLUTION
790
+ // ═══════════════════════════════════════════
791
+
792
+ /** Resolve a CSS selector to a DOMRect. Returns null if not found or invisible. */
793
+ export function resolveTargetRect(selector) {
794
+ if (!selector) return null;
795
+ const el = document.querySelector(selector);
796
+ if (!el) return null;
797
+ const rect = el.getBoundingClientRect();
798
+ if (rect.width === 0 && rect.height === 0) return null;
799
+ return rect;
800
+ }
801
+
802
+ /** Resolve a named anchor position on a DOMRect. */
803
+ export function resolveAnchorPoint(rect, anchor) {
804
+ const map = {
805
+ 'top-left': { x: rect.left, y: rect.top },
806
+ 'top-center': { x: rect.left + rect.width / 2, y: rect.top },
807
+ 'top-right': { x: rect.right, y: rect.top },
808
+ 'center-left': { x: rect.left, y: rect.top + rect.height / 2 },
809
+ 'center': { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 },
810
+ 'center-right': { x: rect.right, y: rect.top + rect.height / 2 },
811
+ 'bottom-left': { x: rect.left, y: rect.bottom },
812
+ 'bottom-center': { x: rect.left + rect.width / 2, y: rect.bottom },
813
+ 'bottom-right': { x: rect.right, y: rect.bottom },
814
+ };
815
+ return map[anchor] || map['center'];
816
+ }
817
+
818
+ /** Convert viewport-relative coordinates (0-1) to absolute pixels. */
819
+ export function resolveRelativePoint(point) {
820
+ return {
821
+ x: point.x * window.innerWidth,
822
+ y: point.y * window.innerHeight,
823
+ };
824
+ }
825
+
826
+
827
+ // ═══════════════════════════════════════════
828
+ // ANIMATION SEQUENCER
829
+ // ═══════════════════════════════════════════
830
+
831
+ /**
832
+ * Lightweight promise-based animation sequencer.
833
+ * Queues animation functions with delays and plays them in order.
834
+ * Each function should return a { destroy } handle (or void).
835
+ */
836
+ export class AnimationSequencer {
837
+ constructor() {
838
+ this._queue = [];
839
+ this._cleanup = [];
840
+ this._cancelled = false;
841
+ }
842
+
843
+ /** Add an animation function with a delay before it runs (ms). */
844
+ add(fn, delay = 0) {
845
+ this._queue.push({ fn, delay });
846
+ return this;
847
+ }
848
+
849
+ /** Play all queued animations in order. */
850
+ async play() {
851
+ for (const { fn, delay } of this._queue) {
852
+ if (this._cancelled) return;
853
+ if (delay > 0) await this._wait(delay);
854
+ if (this._cancelled) return;
855
+ const result = await fn();
856
+ if (result && typeof result.destroy === 'function') {
857
+ this._cleanup.push(result.destroy);
858
+ }
859
+ }
860
+ }
861
+
862
+ /** Cancel all pending and clean up completed animations. */
863
+ cancel() {
864
+ this._cancelled = true;
865
+ this._cleanup.forEach(fn => fn());
866
+ this._cleanup = [];
867
+ }
868
+
869
+ _wait(ms) {
870
+ return new Promise(resolve => {
871
+ const timer = setTimeout(resolve, ms);
872
+ this._cleanup.push(() => clearTimeout(timer));
873
+ });
874
+ }
875
+ }