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.
- package/CHANGELOG.md +665 -0
- package/LICENSE +191 -0
- package/README.md +853 -0
- package/hooks/claude-code/post-plan.mjs +391 -0
- package/hooks/claude-code/post-remember.mjs +239 -0
- package/hooks/claude-code/pre-compact.mjs +241 -0
- package/hooks/claude-code/pre-websearch.mjs +90 -0
- package/hooks/claude-code/prompt-submit.mjs +861 -0
- package/hooks/claude-code/session-start.mjs +494 -0
- package/hooks/claude-code/shared.mjs +368 -0
- package/hooks/claude-code/stop.mjs +646 -0
- package/hooks/claude-code/test-user-learning.mjs +531 -0
- package/mcp-server/package.json +38 -0
- package/mcp-server/run.mjs +10 -0
- package/mcp-server/src/config.ts +70 -0
- package/mcp-server/src/http.ts +93 -0
- package/mcp-server/src/index.ts +203 -0
- package/mcp-server/src/preload-http.ts +25 -0
- package/mcp-server/src/preload.ts +29 -0
- package/mcp-server/src/scripts/migrate-qdrant-to-sqlite.ts +506 -0
- package/mcp-server/src/services/categories.ts +429 -0
- package/mcp-server/src/services/discord.ts +702 -0
- package/mcp-server/src/services/file-checksums.ts +33 -0
- package/mcp-server/src/services/local-embeddings.ts +77 -0
- package/mcp-server/src/services/neural-interface.ts +344 -0
- package/mcp-server/src/services/sqlite.ts +823 -0
- package/mcp-server/src/tools/browser-advanced.ts +123 -0
- package/mcp-server/src/tools/browser-interact.ts +255 -0
- package/mcp-server/src/tools/browser-navigate.ts +77 -0
- package/mcp-server/src/tools/browser-observe.ts +1330 -0
- package/mcp-server/src/tools/browser.ts +107 -0
- package/mcp-server/src/tools/card-tools.ts +193 -0
- package/mcp-server/src/tools/card.ts +22 -0
- package/mcp-server/src/tools/category.ts +379 -0
- package/mcp-server/src/tools/discord-channel.ts +274 -0
- package/mcp-server/src/tools/discord-guild.ts +188 -0
- package/mcp-server/src/tools/discord-member.ts +158 -0
- package/mcp-server/src/tools/discord-message.ts +231 -0
- package/mcp-server/src/tools/discord-onboarding.ts +228 -0
- package/mcp-server/src/tools/discord-role.ts +209 -0
- package/mcp-server/src/tools/discord-thread.ts +110 -0
- package/mcp-server/src/tools/discord-webhook.ts +176 -0
- package/mcp-server/src/tools/discord.ts +40 -0
- package/mcp-server/src/tools/forget.ts +38 -0
- package/mcp-server/src/tools/git-tools.ts +141 -0
- package/mcp-server/src/tools/git.ts +13 -0
- package/mcp-server/src/tools/image-tools.ts +98 -0
- package/mcp-server/src/tools/image.ts +17 -0
- package/mcp-server/src/tools/leonardo-browser-tools.ts +281 -0
- package/mcp-server/src/tools/leonardo.ts +28 -0
- package/mcp-server/src/tools/loop.ts +348 -0
- package/mcp-server/src/tools/memories.ts +118 -0
- package/mcp-server/src/tools/recall.ts +323 -0
- package/mcp-server/src/tools/reflect.ts +145 -0
- package/mcp-server/src/tools/remember.ts +115 -0
- package/mcp-server/src/tools/response.ts +26 -0
- package/mcp-server/src/tools/restore.ts +34 -0
- package/mcp-server/src/tools/sync.ts +114 -0
- package/mcp-server/src/tools/tictactoe-tools.ts +98 -0
- package/mcp-server/src/tools/tictactoe.ts +13 -0
- package/mcp-server/src/tools/utils.ts +25 -0
- package/mcp-server/src/tools/whiteboard-tools.ts +260 -0
- package/mcp-server/src/tools/whiteboard.ts +22 -0
- package/mcp-server/src/tui.ts +940 -0
- package/mcp-server/src/types.ts +85 -0
- package/mcp-server/tsconfig.json +19 -0
- package/neural-interface/package.json +27 -0
- package/neural-interface/public/apple-touch-icon.png +0 -0
- package/neural-interface/public/category-logos/claude.svg +1 -0
- package/neural-interface/public/category-logos/gemini-color.svg +1 -0
- package/neural-interface/public/category-logos/openai.svg +1 -0
- package/neural-interface/public/category-logos/synabun.png +0 -0
- package/neural-interface/public/claude-chat.css +878 -0
- package/neural-interface/public/claude-chat.html +92 -0
- package/neural-interface/public/claude-chat.js +1168 -0
- package/neural-interface/public/favicon-16x16.png +0 -0
- package/neural-interface/public/favicon-32x32.png +0 -0
- package/neural-interface/public/gallery.svg +6 -0
- package/neural-interface/public/icon-192.png +0 -0
- package/neural-interface/public/icon-512.png +0 -0
- package/neural-interface/public/index-legacy.html +14677 -0
- package/neural-interface/public/index.html +212 -0
- package/neural-interface/public/index2d-legacy.html +8193 -0
- package/neural-interface/public/index2d.html +127 -0
- package/neural-interface/public/invite.html +351 -0
- package/neural-interface/public/leonardoai.svg +1 -0
- package/neural-interface/public/logo-synabun.png +0 -0
- package/neural-interface/public/logoHD.png +0 -0
- package/neural-interface/public/manifest.json +14 -0
- package/neural-interface/public/offline.html +299 -0
- package/neural-interface/public/onboarding.html +2335 -0
- package/neural-interface/public/openclaw-logo-text.png +0 -0
- package/neural-interface/public/pin-svgrepo-com.svg +25 -0
- package/neural-interface/public/sessionicon.svg +8 -0
- package/neural-interface/public/shared/ansi-parser.js +305 -0
- package/neural-interface/public/shared/api.js +1043 -0
- package/neural-interface/public/shared/colors.js +153 -0
- package/neural-interface/public/shared/constants.js +141 -0
- package/neural-interface/public/shared/html-shell.js +777 -0
- package/neural-interface/public/shared/html-term-renderer.js +1022 -0
- package/neural-interface/public/shared/i18n.js +147 -0
- package/neural-interface/public/shared/registry.js +82 -0
- package/neural-interface/public/shared/state.js +86 -0
- package/neural-interface/public/shared/storage.js +150 -0
- package/neural-interface/public/shared/styles.css +21212 -0
- package/neural-interface/public/shared/ui-automation-studio.js +3573 -0
- package/neural-interface/public/shared/ui-bookmarks.js +231 -0
- package/neural-interface/public/shared/ui-claude-panel.js +6333 -0
- package/neural-interface/public/shared/ui-command-runner.js +534 -0
- package/neural-interface/public/shared/ui-cost-widget.js +602 -0
- package/neural-interface/public/shared/ui-detail.js +1454 -0
- package/neural-interface/public/shared/ui-explorer.js +1356 -0
- package/neural-interface/public/shared/ui-file-explorer.js +2750 -0
- package/neural-interface/public/shared/ui-games.js +411 -0
- package/neural-interface/public/shared/ui-help.js +107 -0
- package/neural-interface/public/shared/ui-image-gallery.js +461 -0
- package/neural-interface/public/shared/ui-invite.js +519 -0
- package/neural-interface/public/shared/ui-keybinds.js +452 -0
- package/neural-interface/public/shared/ui-layouts.js +297 -0
- package/neural-interface/public/shared/ui-link.js +731 -0
- package/neural-interface/public/shared/ui-loading.js +255 -0
- package/neural-interface/public/shared/ui-menubar.js +499 -0
- package/neural-interface/public/shared/ui-multiselect.js +93 -0
- package/neural-interface/public/shared/ui-navbar.js +222 -0
- package/neural-interface/public/shared/ui-notifications.js +287 -0
- package/neural-interface/public/shared/ui-panels.js +309 -0
- package/neural-interface/public/shared/ui-resume.js +653 -0
- package/neural-interface/public/shared/ui-search.js +145 -0
- package/neural-interface/public/shared/ui-sessions.js +531 -0
- package/neural-interface/public/shared/ui-settings.js +5987 -0
- package/neural-interface/public/shared/ui-sidebar.js +1905 -0
- package/neural-interface/public/shared/ui-skills.js +2805 -0
- package/neural-interface/public/shared/ui-stats.js +38 -0
- package/neural-interface/public/shared/ui-sync.js +201 -0
- package/neural-interface/public/shared/ui-terminal.js +6385 -0
- package/neural-interface/public/shared/ui-tooltip.js +123 -0
- package/neural-interface/public/shared/ui-trash.js +439 -0
- package/neural-interface/public/shared/ui-tutorial-draw.js +875 -0
- package/neural-interface/public/shared/ui-tutorial-steps.js +2276 -0
- package/neural-interface/public/shared/ui-tutorial.js +1117 -0
- package/neural-interface/public/shared/ui-update.js +244 -0
- package/neural-interface/public/shared/ui-whiteboard.js +3262 -0
- package/neural-interface/public/shared/ui-workspaces.js +722 -0
- package/neural-interface/public/shared/utils.js +360 -0
- package/neural-interface/public/shared/vterm-buffer.js +1102 -0
- package/neural-interface/public/sw.js +52 -0
- package/neural-interface/public/synabun.png +0 -0
- package/neural-interface/public/toolbar-album.svg +8 -0
- package/neural-interface/public/toolbar-question.svg +6 -0
- package/neural-interface/public/toolbar-sessions.svg +6 -0
- package/neural-interface/public/variant/2d/background.js +113 -0
- package/neural-interface/public/variant/2d/context-menu.js +133 -0
- package/neural-interface/public/variant/2d/gfx.js +45 -0
- package/neural-interface/public/variant/2d/graph.js +1635 -0
- package/neural-interface/public/variant/2d/hulls.js +207 -0
- package/neural-interface/public/variant/2d/lasso.js +135 -0
- package/neural-interface/public/variant/2d/layout.js +594 -0
- package/neural-interface/public/variant/2d/main.js +521 -0
- package/neural-interface/public/variant/2d/minimap.js +312 -0
- package/neural-interface/public/variant/2d/settings-gfx.js +98 -0
- package/neural-interface/public/variant/3d/background.js +884 -0
- package/neural-interface/public/variant/3d/camera.js +661 -0
- package/neural-interface/public/variant/3d/gfx.js +116 -0
- package/neural-interface/public/variant/3d/graph.js +2399 -0
- package/neural-interface/public/variant/3d/main.js +500 -0
- package/neural-interface/public/variant/3d/settings-gfx.js +228 -0
- package/neural-interface/scripts/rebuild-pty.js +100 -0
- package/neural-interface/server.js +15000 -0
- package/neural-interface/templates/CLAUDE-template.md +211 -0
- package/package.json +53 -0
- package/postinstall.js +135 -0
- package/setup.js +260 -0
- package/skills/leonardo/SKILL.md +159 -0
- package/skills/leonardo/icon.svg +1 -0
- package/skills/leonardo/modules/browser-guide.md +378 -0
- package/skills/leonardo/modules/image-prompter.md +277 -0
- package/skills/leonardo/modules/model-advisor.md +100 -0
- package/skills/leonardo/modules/prompt-library.md +110 -0
- package/skills/leonardo/modules/style-guide.md +186 -0
- package/skills/leonardo/modules/video-prompter.md +311 -0
- package/skills/synabun/SKILL.md +185 -0
- package/skills/synabun/icon.png +0 -0
- package/skills/synabun/modules/audit.md +374 -0
- package/skills/synabun/modules/changelog.md +194 -0
- package/skills/synabun/modules/idea.md +144 -0
- package/skills/synabun/modules/memorize.md +188 -0
- 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
|
+
}
|