@rune-kit/rune 2.7.0 → 2.10.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 (123) hide show
  1. package/README.md +78 -41
  2. package/agents/adversary.md +27 -0
  3. package/agents/architect.md +19 -29
  4. package/agents/asset-creator.md +18 -4
  5. package/agents/audit.md +25 -4
  6. package/agents/autopsy.md +19 -4
  7. package/agents/ba.md +35 -0
  8. package/agents/brainstorm.md +31 -4
  9. package/agents/browser-pilot.md +21 -4
  10. package/agents/coder.md +21 -29
  11. package/agents/completion-gate.md +20 -4
  12. package/agents/constraint-check.md +18 -4
  13. package/agents/context-engine.md +22 -4
  14. package/agents/context-pack.md +32 -0
  15. package/agents/cook.md +41 -4
  16. package/agents/db.md +19 -4
  17. package/agents/debug.md +33 -4
  18. package/agents/dependency-doctor.md +20 -4
  19. package/agents/deploy.md +27 -4
  20. package/agents/design.md +22 -4
  21. package/agents/doc-processor.md +27 -0
  22. package/agents/docs-seeker.md +19 -4
  23. package/agents/docs.md +31 -0
  24. package/agents/fix.md +37 -4
  25. package/agents/git.md +29 -0
  26. package/agents/hallucination-guard.md +20 -4
  27. package/agents/incident.md +21 -4
  28. package/agents/integrity-check.md +18 -4
  29. package/agents/journal.md +19 -4
  30. package/agents/launch.md +32 -4
  31. package/agents/logic-guardian.md +26 -11
  32. package/agents/marketing.md +23 -4
  33. package/agents/mcp-builder.md +26 -0
  34. package/agents/neural-memory.md +30 -0
  35. package/agents/onboard.md +22 -4
  36. package/agents/perf.md +21 -4
  37. package/agents/plan.md +29 -4
  38. package/agents/preflight.md +22 -4
  39. package/agents/problem-solver.md +20 -4
  40. package/agents/rescue.md +23 -4
  41. package/agents/research.md +19 -4
  42. package/agents/researcher.md +19 -29
  43. package/agents/retro.md +32 -0
  44. package/agents/review-intake.md +20 -4
  45. package/agents/review.md +32 -4
  46. package/agents/reviewer.md +20 -28
  47. package/agents/safeguard.md +19 -4
  48. package/agents/sast.md +18 -4
  49. package/agents/scaffold.md +41 -0
  50. package/agents/scanner.md +19 -28
  51. package/agents/scope-guard.md +18 -4
  52. package/agents/scout.md +23 -4
  53. package/agents/sentinel-env.md +26 -0
  54. package/agents/sentinel.md +33 -4
  55. package/agents/sequential-thinking.md +20 -4
  56. package/agents/session-bridge.md +24 -4
  57. package/agents/skill-forge.md +22 -4
  58. package/agents/skill-router.md +26 -4
  59. package/agents/slides.md +24 -0
  60. package/agents/surgeon.md +19 -4
  61. package/agents/team.md +30 -4
  62. package/agents/test.md +36 -4
  63. package/agents/trend-scout.md +17 -4
  64. package/agents/verification.md +20 -4
  65. package/agents/video-creator.md +20 -4
  66. package/agents/watchdog.md +19 -4
  67. package/agents/worktree.md +17 -4
  68. package/compiler/__tests__/analytics.test.js +370 -0
  69. package/compiler/adapters/openclaw.js +2 -2
  70. package/compiler/analytics.js +385 -0
  71. package/compiler/bin/rune.js +68 -2
  72. package/compiler/dashboard.js +883 -0
  73. package/compiler/transforms/branding.js +1 -1
  74. package/extensions/ui/skills/animation-patterns.md +21 -0
  75. package/extensions/ui/skills/component-patterns.md +25 -0
  76. package/extensions/ui/skills/landing-patterns.md +1 -1
  77. package/hooks/context-watch/index.cjs +95 -68
  78. package/hooks/metrics-collector/index.cjs +86 -42
  79. package/hooks/post-session-reflect/index.cjs +41 -5
  80. package/hooks/session-start/index.cjs +8 -2
  81. package/package.json +2 -2
  82. package/skills/audit/SKILL.md +1 -0
  83. package/skills/autopsy/SKILL.md +78 -2
  84. package/skills/autopsy/references/repo-analysis-patterns.md +113 -0
  85. package/skills/ba/SKILL.md +72 -2
  86. package/skills/brainstorm/SKILL.md +1 -0
  87. package/skills/completion-gate/SKILL.md +26 -3
  88. package/skills/context-engine/SKILL.md +49 -1
  89. package/skills/cook/SKILL.md +72 -3
  90. package/skills/cook/references/output-format.md +33 -0
  91. package/skills/db/SKILL.md +1 -0
  92. package/skills/debug/SKILL.md +57 -1
  93. package/skills/deploy/SKILL.md +1 -1
  94. package/skills/design/SKILL.md +110 -3
  95. package/skills/docs/SKILL.md +2 -1
  96. package/skills/fix/SKILL.md +47 -1
  97. package/skills/graft/SKILL.md +352 -0
  98. package/skills/graft/references/challenge-framework.md +98 -0
  99. package/skills/graft/references/mode-decision.md +44 -0
  100. package/skills/incident/SKILL.md +2 -0
  101. package/skills/journal/SKILL.md +2 -0
  102. package/skills/launch/SKILL.md +2 -0
  103. package/skills/marketing/SKILL.md +46 -2
  104. package/skills/mcp-builder/SKILL.md +3 -1
  105. package/skills/mcp-builder/references/auto-discovery-pattern.md +169 -0
  106. package/skills/plan/SKILL.md +60 -4
  107. package/skills/plan/references/feature-map.md +84 -0
  108. package/skills/preflight/SKILL.md +20 -1
  109. package/skills/rescue/SKILL.md +25 -1
  110. package/skills/research/SKILL.md +24 -1
  111. package/skills/retro/SKILL.md +2 -0
  112. package/skills/review/SKILL.md +55 -2
  113. package/skills/scaffold/SKILL.md +1 -0
  114. package/skills/scope-guard/SKILL.md +35 -17
  115. package/skills/scout/SKILL.md +22 -1
  116. package/skills/sentinel/SKILL.md +29 -1
  117. package/skills/sentinel-env/SKILL.md +0 -2
  118. package/skills/session-bridge/SKILL.md +155 -9
  119. package/skills/skill-forge/SKILL.md +43 -1
  120. package/skills/skill-router/{skill.md → SKILL.md} +27 -2
  121. package/skills/team/SKILL.md +24 -1
  122. package/skills/test/SKILL.md +29 -2
  123. package/skills/verification/SKILL.md +1 -2
@@ -0,0 +1,883 @@
1
+ /**
2
+ * Dashboard — Analytics HTML Generator (Hybrid Mesh+Rhythm)
3
+ *
4
+ * Generates a self-contained HTML dashboard from analytics data.
5
+ * Layout: Interactive skill mesh + heatmap + session timeline.
6
+ * Same pattern as visualizer.js — single file, no CDN, all inline.
7
+ * Business tier exclusive.
8
+ */
9
+
10
+ export function generateDashboardHTML(data) {
11
+ const dataJson = JSON.stringify(data).replace(/<\/script>/gi, '<\\/script>');
12
+
13
+ return `<!DOCTYPE html>
14
+ <html lang="en">
15
+ <head>
16
+ <meta charset="UTF-8">
17
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
18
+ <title>Rune Analytics Dashboard</title>
19
+ <style>
20
+ @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@500;700&display=swap');
21
+
22
+ * { margin: 0; padding: 0; box-sizing: border-box; }
23
+
24
+ :root {
25
+ --bg-base: #0c1222;
26
+ --bg-card: #151d2e;
27
+ --bg-elevated: #1a2540;
28
+ --bg-hover: #1f2d4a;
29
+ --text-primary: #f1f5f9;
30
+ --text-secondary: #8892a8;
31
+ --text-muted: #5a6478;
32
+ --border: #243049;
33
+ --accent: #3b82f6;
34
+ --accent-dim: rgba(59,130,246,0.15);
35
+ --green: #10b981;
36
+ --green-dim: rgba(16,185,129,0.15);
37
+ --amber: #f59e0b;
38
+ --amber-dim: rgba(245,158,11,0.15);
39
+ --red: #ef4444;
40
+ --purple: #a855f7;
41
+ --purple-dim: rgba(168,85,247,0.15);
42
+ --font-display: 'Space Grotesk', sans-serif;
43
+ --font-body: 'Inter', sans-serif;
44
+ --font-mono: 'JetBrains Mono', monospace;
45
+ --radius: 12px;
46
+ --shadow: 0 4px 24px rgba(0,0,0,0.25);
47
+ }
48
+
49
+ .light {
50
+ --bg-base: #f8fafc;
51
+ --bg-card: #ffffff;
52
+ --bg-elevated: #f1f5f9;
53
+ --bg-hover: #e2e8f0;
54
+ --text-primary: #0f172a;
55
+ --text-secondary: #475569;
56
+ --text-muted: #94a3b8;
57
+ --border: #e2e8f0;
58
+ --shadow: 0 4px 24px rgba(0,0,0,0.08);
59
+ }
60
+
61
+ body {
62
+ background: var(--bg-base);
63
+ color: var(--text-primary);
64
+ font-family: var(--font-body);
65
+ line-height: 1.5;
66
+ min-height: 100vh;
67
+ overflow-x: hidden;
68
+ }
69
+
70
+ #voronoi-bg, #mesh-overlay {
71
+ position: fixed; top: 0; left: 0; width: 100%; height: 100%;
72
+ z-index: 0; pointer-events: none;
73
+ }
74
+ #mesh-overlay { z-index: 0; }
75
+ .light #voronoi-bg { opacity: 0.35; }
76
+ .light #mesh-overlay { opacity: 0.15; }
77
+
78
+ .container { max-width: 1280px; margin: 0 auto; padding: 24px 20px; position: relative; z-index: 1; }
79
+
80
+ /* ─── Header ─── */
81
+ .header {
82
+ display: flex; align-items: center; justify-content: space-between;
83
+ margin-bottom: 20px; flex-wrap: wrap; gap: 12px;
84
+ }
85
+ .header h1 {
86
+ font-family: var(--font-display);
87
+ font-size: 24px; font-weight: 700;
88
+ background: linear-gradient(135deg, var(--accent), var(--purple));
89
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
90
+ }
91
+ .header-controls { display: flex; gap: 6px; align-items: center; }
92
+ .btn {
93
+ padding: 5px 12px; border-radius: 8px; border: 1px solid var(--border);
94
+ background: var(--bg-card); color: var(--text-secondary);
95
+ font-family: var(--font-body); font-size: 12px; cursor: pointer;
96
+ transition: all 150ms ease;
97
+ }
98
+ .btn:hover { background: var(--bg-hover); color: var(--text-primary); }
99
+ .btn.active { background: var(--accent-dim); color: var(--accent); border-color: var(--accent); }
100
+
101
+ /* ─── Top Row: KPIs + Model Donut ─── */
102
+ .top-row {
103
+ display: grid; grid-template-columns: 1fr auto; gap: 16px;
104
+ margin-bottom: 20px; align-items: stretch;
105
+ }
106
+ .kpi-strip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
107
+ .kpi-card {
108
+ background: var(--bg-card); border: 1px solid var(--border);
109
+ border-radius: var(--radius); padding: 16px;
110
+ box-shadow: var(--shadow);
111
+ }
112
+ .kpi-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; }
113
+ .kpi-value { font-family: var(--font-mono); font-size: 28px; font-weight: 700; }
114
+ .kpi-sub { font-size: 11px; color: var(--text-secondary); margin-top: 2px; }
115
+
116
+ .model-card {
117
+ background: var(--bg-card); border: 1px solid var(--border);
118
+ border-radius: var(--radius); padding: 16px; min-width: 200px;
119
+ box-shadow: var(--shadow); display: flex; flex-direction: column; align-items: center; justify-content: center;
120
+ }
121
+ .model-title { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px; }
122
+ .model-legend { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
123
+ .legend-item { display: flex; align-items: center; gap: 6px; font-size: 12px; }
124
+ .legend-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
125
+ .legend-label { color: var(--text-secondary); }
126
+ .legend-value { font-family: var(--font-mono); font-weight: 600; color: var(--text-primary); margin-left: auto; }
127
+
128
+ /* ─── Skill Mesh (Interactive Canvas) ─── */
129
+ .mesh-panel {
130
+ background: var(--bg-card); border: 1px solid var(--border);
131
+ border-radius: var(--radius); padding: 0; margin-bottom: 20px;
132
+ box-shadow: var(--shadow); position: relative; overflow: hidden;
133
+ }
134
+ .mesh-panel-header {
135
+ padding: 14px 18px; display: flex; align-items: center; justify-content: space-between;
136
+ border-bottom: 1px solid var(--border);
137
+ }
138
+ .mesh-panel-title {
139
+ font-family: var(--font-display); font-size: 14px; font-weight: 600;
140
+ color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.04em;
141
+ }
142
+ .mesh-hint { font-size: 11px; color: var(--text-muted); }
143
+ #skill-mesh-canvas { width: 100%; height: 340px; display: block; cursor: crosshair; }
144
+ .mesh-tooltip {
145
+ position: absolute; pointer-events: none; z-index: 10;
146
+ background: var(--bg-elevated); border: 1px solid var(--border);
147
+ border-radius: 8px; padding: 8px 12px; font-size: 12px;
148
+ box-shadow: 0 8px 24px rgba(0,0,0,0.4); display: none;
149
+ }
150
+ .mesh-tooltip-name { font-family: var(--font-mono); font-weight: 700; color: var(--accent); }
151
+ .mesh-tooltip-stat { color: var(--text-secondary); }
152
+
153
+ /* ─── Heatmap ─── */
154
+ .heatmap-panel {
155
+ background: var(--bg-card); border: 1px solid var(--border);
156
+ border-radius: var(--radius); padding: 18px; margin-bottom: 20px;
157
+ box-shadow: var(--shadow); overflow-x: auto;
158
+ }
159
+ .heatmap-title {
160
+ font-family: var(--font-display); font-size: 14px; font-weight: 600;
161
+ color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.04em;
162
+ margin-bottom: 14px;
163
+ }
164
+ .heatmap-grid { display: flex; flex-direction: column; gap: 2px; }
165
+ .heatmap-row { display: flex; align-items: center; gap: 2px; }
166
+ .heatmap-label {
167
+ width: 90px; flex-shrink: 0; font-family: var(--font-mono);
168
+ font-size: 11px; color: var(--text-secondary); text-align: right; padding-right: 8px;
169
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
170
+ }
171
+ .heatmap-cell {
172
+ width: 14px; height: 14px; border-radius: 2px; flex-shrink: 0;
173
+ background: var(--bg-elevated); cursor: pointer;
174
+ transition: transform 100ms ease;
175
+ }
176
+ .heatmap-cell:hover { transform: scale(1.5); z-index: 2; position: relative; }
177
+ .heatmap-cell[data-active="true"] { outline: 2px solid var(--accent); outline-offset: 1px; }
178
+ .heatmap-dates {
179
+ display: flex; gap: 2px; margin-left: 92px; margin-top: 4px;
180
+ }
181
+ .heatmap-date {
182
+ width: 14px; flex-shrink: 0; font-size: 8px; color: var(--text-muted);
183
+ text-align: center; transform: rotate(-45deg); transform-origin: center;
184
+ }
185
+ .heatmap-legend {
186
+ display: flex; align-items: center; gap: 6px; margin-top: 10px; margin-left: 92px;
187
+ font-size: 10px; color: var(--text-muted);
188
+ }
189
+ .heatmap-legend-cell {
190
+ width: 12px; height: 12px; border-radius: 2px;
191
+ }
192
+
193
+ /* ─── Bottom Row: Timeline + Chains ─── */
194
+ .bottom-row {
195
+ display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
196
+ }
197
+ .panel {
198
+ background: var(--bg-card); border: 1px solid var(--border);
199
+ border-radius: var(--radius); padding: 18px;
200
+ box-shadow: var(--shadow);
201
+ }
202
+ .panel-title {
203
+ font-family: var(--font-display); font-size: 14px; font-weight: 600;
204
+ color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.04em;
205
+ margin-bottom: 14px;
206
+ }
207
+
208
+ /* Session Timeline */
209
+ .timeline { display: flex; flex-direction: column; gap: 12px; }
210
+ .tl-session {
211
+ padding: 12px; background: var(--bg-elevated); border-radius: 8px;
212
+ border: 1px solid transparent; cursor: pointer;
213
+ transition: border-color 150ms ease;
214
+ }
215
+ .tl-session:hover { border-color: var(--accent); }
216
+ .tl-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
217
+ .tl-date { font-family: var(--font-mono); font-size: 12px; font-weight: 600; }
218
+ .tl-meta { font-size: 11px; color: var(--text-muted); }
219
+ .tl-skills {
220
+ display: flex; gap: 4px; flex-wrap: wrap;
221
+ }
222
+ .tl-skill {
223
+ font-family: var(--font-mono); font-size: 10px; padding: 2px 6px;
224
+ border-radius: 4px; background: var(--accent-dim); color: var(--accent);
225
+ }
226
+ .tl-skill.primary { background: var(--accent); color: #fff; }
227
+ .tl-chain {
228
+ font-family: var(--font-mono); font-size: 10px; color: var(--text-muted);
229
+ margin-top: 4px;
230
+ }
231
+
232
+ /* Chain table */
233
+ .chain-table { width: 100%; border-collapse: collapse; }
234
+ .chain-table th {
235
+ text-align: left; font-size: 10px; color: var(--text-muted);
236
+ text-transform: uppercase; letter-spacing: 0.05em;
237
+ padding: 6px 10px; border-bottom: 1px solid var(--border);
238
+ }
239
+ .chain-table td { padding: 8px 10px; font-size: 12px; border-bottom: 1px solid var(--border); }
240
+ .chain-table tr:last-child td { border-bottom: none; }
241
+ .chain-table tr:hover { background: var(--bg-hover); }
242
+ .chain-name { font-family: var(--font-mono); color: var(--accent); font-size: 11px; }
243
+ .chain-count { font-family: var(--font-mono); font-weight: 700; text-align: right; }
244
+ .chain-bar {
245
+ height: 4px; border-radius: 2px; background: var(--accent);
246
+ transition: width 600ms cubic-bezier(0.34,1.56,0.64,1);
247
+ }
248
+
249
+ /* ─── Empty State ─── */
250
+ .empty-state { text-align: center; padding: 80px 20px; color: var(--text-muted); }
251
+ .empty-state h2 { font-family: var(--font-display); font-size: 22px; margin-bottom: 12px; color: var(--text-secondary); }
252
+ .empty-state p { max-width: 480px; margin: 0 auto; line-height: 1.7; }
253
+
254
+ /* ─── Footer ─── */
255
+ .footer { text-align: center; margin-top: 24px; padding: 12px; font-size: 11px; color: var(--text-muted); }
256
+
257
+ /* ─── Responsive ─── */
258
+ @media (max-width: 900px) {
259
+ .top-row { grid-template-columns: 1fr; }
260
+ .kpi-strip { grid-template-columns: repeat(2, 1fr); }
261
+ .bottom-row { grid-template-columns: 1fr; }
262
+ #skill-mesh-canvas { height: 260px; }
263
+ }
264
+ @media (max-width: 480px) {
265
+ .kpi-strip { grid-template-columns: 1fr; }
266
+ }
267
+
268
+ @media (prefers-reduced-motion: reduce) {
269
+ *, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }
270
+ }
271
+ </style>
272
+ </head>
273
+ <body>
274
+ <div class="container" id="app"></div>
275
+
276
+ <script>
277
+ const DATA = ${dataJson};
278
+
279
+ const COLORS = {
280
+ bars: ['#3b82f6','#6366f1','#8b5cf6','#a855f7','#d946ef','#ec4899','#f43f5e','#f97316','#eab308','#22c55e','#14b8a6','#06b6d4','#0ea5e9','#2563eb','#4f46e5'],
281
+ donut: ['#3b82f6','#10b981','#f59e0b','#a855f7','#ef4444','#06b6d4','#ec4899','#f97316'],
282
+ models: { opus: '#a855f7', sonnet: '#3b82f6', haiku: '#10b981' },
283
+ heatmap: ['var(--bg-elevated)', '#1a3a5c', '#1d5a8a', '#2176b8', '#3b9fe6']
284
+ };
285
+
286
+ function el(tag, attrs, ...children) {
287
+ const node = document.createElement(tag);
288
+ if (attrs) Object.entries(attrs).forEach(([k, v]) => {
289
+ if (k === 'className') node.className = v;
290
+ else if (k === 'innerHTML') node.innerHTML = v;
291
+ else if (k.startsWith('on')) node.addEventListener(k.slice(2).toLowerCase(), v);
292
+ else node.setAttribute(k, v);
293
+ });
294
+ children.flat().forEach(c => {
295
+ if (typeof c === 'string') node.appendChild(document.createTextNode(c));
296
+ else if (c) node.appendChild(c);
297
+ });
298
+ return node;
299
+ }
300
+
301
+ function fmtNum(n) {
302
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
303
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
304
+ return String(n);
305
+ }
306
+
307
+ // ─── Donut SVG ───
308
+ function donutSVG(items, size = 120) {
309
+ const total = items.reduce((s, i) => s + i.value, 0);
310
+ if (total === 0) return '<svg></svg>';
311
+ const r = size / 2 - 8;
312
+ const cx = size / 2, cy = size / 2;
313
+ let cumulative = 0;
314
+ const paths = items.map((item) => {
315
+ const pct = item.value / total;
316
+ const start = cumulative * 2 * Math.PI - Math.PI / 2;
317
+ cumulative += pct;
318
+ const end = cumulative * 2 * Math.PI - Math.PI / 2;
319
+ const large = pct > 0.5 ? 1 : 0;
320
+ const x1 = cx + r * Math.cos(start), y1 = cy + r * Math.sin(start);
321
+ const x2 = cx + r * Math.cos(end), y2 = cy + r * Math.sin(end);
322
+ if (pct >= 0.999) return '<circle cx="'+cx+'" cy="'+cy+'" r="'+r+'" fill="none" stroke="'+item.color+'" stroke-width="18"/>';
323
+ return '<path d="M '+cx+' '+cy+' L '+x1+' '+y1+' A '+r+' '+r+' 0 '+large+' 1 '+x2+' '+y2+' Z" fill="'+item.color+'" opacity="0.85"/>';
324
+ });
325
+ return '<svg width="'+size+'" height="'+size+'" viewBox="0 0 '+size+' '+size+'">'+
326
+ '<circle cx="'+cx+'" cy="'+cy+'" r="'+(r-12)+'" fill="var(--bg-card)"/>'+
327
+ paths.join('')+
328
+ '<circle cx="'+cx+'" cy="'+cy+'" r="'+(r-12)+'" fill="var(--bg-card)"/>'+
329
+ '</svg>';
330
+ }
331
+
332
+ // ─── Force-Directed Skill Mesh ───
333
+ function renderSkillMesh(canvasEl, meshData, tooltip) {
334
+ if (!meshData || !meshData.nodes || meshData.nodes.length === 0) return;
335
+
336
+ const nodes = meshData.nodes.map(n => ({
337
+ ...n,
338
+ x: Math.random() * 600 + 100,
339
+ y: Math.random() * 280 + 30,
340
+ vx: 0, vy: 0,
341
+ radius: 6 + (n.count / meshData.maxCount) * 18
342
+ }));
343
+ const nodeMap = new Map(nodes.map((n, i) => [n.id, i]));
344
+ const edges = meshData.edges
345
+ .filter(e => nodeMap.has(e.source) && nodeMap.has(e.target))
346
+ .map(e => ({ ...e, si: nodeMap.get(e.source), ti: nodeMap.get(e.target) }));
347
+ const maxEdgeWeight = Math.max(1, ...edges.map(e => e.weight));
348
+
349
+ let W, H, dpr, hoveredNode = null;
350
+
351
+ function resize() {
352
+ dpr = window.devicePixelRatio || 1;
353
+ W = canvasEl.offsetWidth;
354
+ H = canvasEl.offsetHeight;
355
+ canvasEl.width = W * dpr;
356
+ canvasEl.height = H * dpr;
357
+ }
358
+ resize();
359
+
360
+ // Simple force simulation
361
+ const center = { x: W / 2, y: H / 2 };
362
+ for (let i = 0; i < nodes.length; i++) {
363
+ const angle = (i / nodes.length) * Math.PI * 2;
364
+ const spread = Math.min(W, H) * 0.35;
365
+ nodes[i].x = center.x + Math.cos(angle) * spread * (0.5 + Math.random() * 0.5);
366
+ nodes[i].y = center.y + Math.sin(angle) * spread * (0.5 + Math.random() * 0.5);
367
+ }
368
+
369
+ function simulate() {
370
+ for (let iter = 0; iter < 80; iter++) {
371
+ // Repulsion
372
+ for (let i = 0; i < nodes.length; i++) {
373
+ for (let j = i + 1; j < nodes.length; j++) {
374
+ let dx = nodes[j].x - nodes[i].x;
375
+ let dy = nodes[j].y - nodes[i].y;
376
+ let d = Math.sqrt(dx * dx + dy * dy) || 1;
377
+ let force = 800 / (d * d);
378
+ let fx = (dx / d) * force;
379
+ let fy = (dy / d) * force;
380
+ nodes[i].vx -= fx; nodes[i].vy -= fy;
381
+ nodes[j].vx += fx; nodes[j].vy += fy;
382
+ }
383
+ }
384
+ // Attraction (edges)
385
+ for (const e of edges) {
386
+ let dx = nodes[e.ti].x - nodes[e.si].x;
387
+ let dy = nodes[e.ti].y - nodes[e.si].y;
388
+ let d = Math.sqrt(dx * dx + dy * dy) || 1;
389
+ let force = (d - 100) * 0.005;
390
+ let fx = (dx / d) * force;
391
+ let fy = (dy / d) * force;
392
+ nodes[e.si].vx += fx; nodes[e.si].vy += fy;
393
+ nodes[e.ti].vx -= fx; nodes[e.ti].vy -= fy;
394
+ }
395
+ // Center gravity
396
+ for (const n of nodes) {
397
+ n.vx += (center.x - n.x) * 0.002;
398
+ n.vy += (center.y - n.y) * 0.002;
399
+ n.vx *= 0.85; n.vy *= 0.85;
400
+ n.x += n.vx; n.y += n.vy;
401
+ n.x = Math.max(n.radius + 10, Math.min(W - n.radius - 10, n.x));
402
+ n.y = Math.max(n.radius + 10, Math.min(H - n.radius - 10, n.y));
403
+ }
404
+ }
405
+ }
406
+ simulate();
407
+
408
+ const ctx = canvasEl.getContext('2d');
409
+
410
+ function draw(time) {
411
+ ctx.save();
412
+ ctx.scale(dpr, dpr);
413
+ ctx.clearRect(0, 0, W, H);
414
+ const isLight = document.body.classList.contains('light');
415
+ const t = (time || 0) * 0.001;
416
+
417
+ // Draw edges
418
+ for (const e of edges) {
419
+ const a = nodes[e.si], b = nodes[e.ti];
420
+ const pulse = 0.5 + 0.5 * Math.sin(t * 0.5 + e.si + e.ti);
421
+ const alpha = 0.08 + (e.weight / maxEdgeWeight) * 0.15 * pulse;
422
+ const isHighlight = hoveredNode !== null && (e.si === hoveredNode || e.ti === hoveredNode);
423
+ ctx.beginPath();
424
+ ctx.moveTo(a.x, a.y);
425
+ ctx.lineTo(b.x, b.y);
426
+ if (isHighlight) {
427
+ ctx.strokeStyle = isLight ? 'rgba(59,130,246,0.5)' : 'rgba(59,130,246,0.6)';
428
+ ctx.lineWidth = 2;
429
+ } else {
430
+ ctx.strokeStyle = isLight ? 'rgba(0,0,0,'+alpha+')' : 'rgba(100,160,255,'+alpha+')';
431
+ ctx.lineWidth = 0.5 + (e.weight / maxEdgeWeight) * 1.5;
432
+ }
433
+ ctx.stroke();
434
+ }
435
+
436
+ // Draw nodes
437
+ for (let i = 0; i < nodes.length; i++) {
438
+ const n = nodes[i];
439
+ const isHovered = hoveredNode === i;
440
+ const isConnected = hoveredNode !== null && edges.some(e =>
441
+ (e.si === hoveredNode && e.ti === i) || (e.ti === hoveredNode && e.si === i));
442
+ const dimmed = hoveredNode !== null && !isHovered && !isConnected;
443
+
444
+ const pulse = 0.8 + 0.2 * Math.sin(t * 0.8 + i * 1.3);
445
+ const r = n.radius * (isHovered ? 1.2 : 1) * pulse;
446
+
447
+ // Glow
448
+ if (!dimmed) {
449
+ const grad = ctx.createRadialGradient(n.x, n.y, 0, n.x, n.y, r * 2.5);
450
+ const glowColor = COLORS.bars[i % COLORS.bars.length];
451
+ grad.addColorStop(0, glowColor + (isHovered ? '40' : '18'));
452
+ grad.addColorStop(1, glowColor + '00');
453
+ ctx.fillStyle = grad;
454
+ ctx.beginPath();
455
+ ctx.arc(n.x, n.y, r * 2.5, 0, Math.PI * 2);
456
+ ctx.fill();
457
+ }
458
+
459
+ // Node circle
460
+ ctx.beginPath();
461
+ ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
462
+ const nodeColor = COLORS.bars[i % COLORS.bars.length];
463
+ ctx.fillStyle = dimmed ? (isLight ? '#ddd' : '#2a2a2a') : nodeColor;
464
+ ctx.globalAlpha = dimmed ? 0.3 : 0.85;
465
+ ctx.fill();
466
+ ctx.globalAlpha = 1;
467
+
468
+ // Label
469
+ if (r > 8 || isHovered) {
470
+ ctx.font = (isHovered ? '600 12px' : '500 10px') + ' var(--font-mono)';
471
+ ctx.fillStyle = dimmed ? 'transparent' : (isLight ? '#1e293b' : '#e2e8f0');
472
+ ctx.textAlign = 'center';
473
+ ctx.fillText(n.id, n.x, n.y + r + 14);
474
+ }
475
+ }
476
+ ctx.restore();
477
+ requestAnimationFrame(draw);
478
+ }
479
+ requestAnimationFrame(draw);
480
+
481
+ // Hover interaction
482
+ canvasEl.addEventListener('mousemove', (evt) => {
483
+ const rect = canvasEl.getBoundingClientRect();
484
+ const mx = evt.clientX - rect.left;
485
+ const my = evt.clientY - rect.top;
486
+ hoveredNode = null;
487
+ for (let i = 0; i < nodes.length; i++) {
488
+ const dx = mx - nodes[i].x, dy = my - nodes[i].y;
489
+ if (dx * dx + dy * dy < (nodes[i].radius + 5) ** 2) {
490
+ hoveredNode = i;
491
+ break;
492
+ }
493
+ }
494
+ if (hoveredNode !== null) {
495
+ const n = nodes[hoveredNode];
496
+ tooltip.style.display = 'block';
497
+ tooltip.style.left = (n.x + n.radius + 12) + 'px';
498
+ tooltip.style.top = (n.y - 10 + canvasEl.offsetTop) + 'px';
499
+ const connected = edges.filter(e => e.si === hoveredNode || e.ti === hoveredNode)
500
+ .map(e => e.si === hoveredNode ? nodes[e.ti].id : nodes[e.si].id);
501
+ tooltip.innerHTML = '<div class="mesh-tooltip-name">' + n.id + '</div>' +
502
+ '<div class="mesh-tooltip-stat">' + n.count + ' sessions</div>' +
503
+ (connected.length > 0 ? '<div class="mesh-tooltip-stat" style="margin-top:4px;font-size:10px;color:var(--text-muted)">\\u2194 ' + connected.join(', ') + '</div>' : '');
504
+ } else {
505
+ tooltip.style.display = 'none';
506
+ }
507
+ });
508
+ canvasEl.addEventListener('mouseleave', () => {
509
+ hoveredNode = null;
510
+ tooltip.style.display = 'none';
511
+ });
512
+
513
+ window.addEventListener('resize', () => { resize(); simulate(); });
514
+ }
515
+
516
+ // ─── Render ───
517
+ function render() {
518
+ const app = document.getElementById('app');
519
+ const d = Object.assign({
520
+ overview: { total_sessions: 0, avg_duration_min: 0, total_tool_calls: 0, total_skill_invocations: 0, active_days: 0 },
521
+ skillFrequency: [], modelDistribution: [], sessionTrend: [], skillChains: [], toolDistribution: [],
522
+ skillHeatmap: { heatmap: [], dates: [], maxCount: 1 },
523
+ sessionTimeline: [],
524
+ skillMesh: { nodes: [], edges: [], maxCount: 1 },
525
+ generated: new Date().toISOString(), days: 30
526
+ }, DATA);
527
+
528
+ if (!d.overview || d.overview.total_sessions === 0) {
529
+ app.innerHTML = '';
530
+ app.appendChild(el('div', { className: 'empty-state' },
531
+ el('h2', null, 'No analytics data yet'),
532
+ el('p', null, 'Use Rune skills in your projects to start collecting metrics. Data is captured automatically via hooks and stored locally in .rune/metrics/.')
533
+ ));
534
+ return;
535
+ }
536
+
537
+ app.innerHTML = '';
538
+ const daysLabel = d.days > 0 ? d.days + 'd' : 'All';
539
+
540
+ // Header
541
+ app.appendChild(el('div', { className: 'header' },
542
+ el('h1', null, 'Rune Analytics'),
543
+ el('div', { className: 'header-controls' },
544
+ ...[7, 30, 90, 0].map(days =>
545
+ el('button', {
546
+ className: 'btn' + (d.days === days ? ' active' : ''),
547
+ innerHTML: days === 0 ? 'All' : days + 'd'
548
+ })
549
+ ),
550
+ el('button', { className: 'btn', innerHTML: '\\u263E', onClick: toggleTheme, title: 'Toggle theme' })
551
+ )
552
+ ));
553
+
554
+ // ── Top Row: KPIs + Model Donut ──
555
+ const o = d.overview;
556
+ const topRow = el('div', { className: 'top-row' });
557
+ const kpiStrip = el('div', { className: 'kpi-strip' });
558
+
559
+ const kpis = [
560
+ { label: 'Sessions', value: o.total_sessions, sub: daysLabel, color: 'var(--accent)' },
561
+ { label: 'Avg Duration', value: o.avg_duration_min + 'm', sub: 'per session', color: 'var(--green)' },
562
+ { label: 'Skill Calls', value: fmtNum(o.total_skill_invocations), sub: fmtNum(o.total_tool_calls) + ' tools', color: 'var(--amber)' },
563
+ { label: 'Active Days', value: o.active_days, sub: 'with usage', color: 'var(--purple)' },
564
+ ];
565
+ for (const k of kpis) {
566
+ kpiStrip.appendChild(el('div', { className: 'kpi-card' },
567
+ el('div', { className: 'kpi-label' }, k.label),
568
+ el('div', { className: 'kpi-value', style: 'color:' + k.color }, String(k.value)),
569
+ el('div', { className: 'kpi-sub' }, k.sub)
570
+ ));
571
+ }
572
+ topRow.appendChild(kpiStrip);
573
+
574
+ // Model donut (compact)
575
+ if (d.modelDistribution.length > 0) {
576
+ const modelCard = el('div', { className: 'model-card' });
577
+ modelCard.appendChild(el('div', { className: 'model-title' }, 'Models'));
578
+ const items = d.modelDistribution.map(m => ({
579
+ label: m.model, value: m.skill_count,
580
+ color: COLORS.models[m.model] || COLORS.donut[0]
581
+ }));
582
+ modelCard.appendChild(el('div', { innerHTML: donutSVG(items, 100) }));
583
+ const legend = el('div', { className: 'model-legend' });
584
+ const total = items.reduce((s, i) => s + i.value, 0);
585
+ items.forEach(item => {
586
+ const pct = total > 0 ? Math.round(item.value / total * 100) : 0;
587
+ legend.appendChild(el('div', { className: 'legend-item' },
588
+ el('div', { className: 'legend-dot', style: 'background:' + item.color }),
589
+ el('span', { className: 'legend-label' }, item.label),
590
+ el('span', { className: 'legend-value' }, pct + '%')
591
+ ));
592
+ });
593
+ modelCard.appendChild(legend);
594
+ topRow.appendChild(modelCard);
595
+ }
596
+ app.appendChild(topRow);
597
+
598
+ // ── Skill Mesh ──
599
+ const meshPanel = el('div', { className: 'mesh-panel' });
600
+ meshPanel.appendChild(el('div', { className: 'mesh-panel-header' },
601
+ el('div', { className: 'mesh-panel-title' }, 'Skill Mesh'),
602
+ el('div', { className: 'mesh-hint' }, 'Hover to explore connections')
603
+ ));
604
+ const meshCanvas = el('canvas', { id: 'skill-mesh-canvas' });
605
+ const meshTooltip = el('div', { className: 'mesh-tooltip' });
606
+ meshPanel.appendChild(meshCanvas);
607
+ meshPanel.appendChild(meshTooltip);
608
+ app.appendChild(meshPanel);
609
+
610
+ requestAnimationFrame(() => renderSkillMesh(meshCanvas, d.skillMesh, meshTooltip));
611
+
612
+ // ── Heatmap ──
613
+ const hm = d.skillHeatmap;
614
+ if (hm && hm.heatmap && hm.heatmap.length > 0) {
615
+ const hmPanel = el('div', { className: 'heatmap-panel' });
616
+ hmPanel.appendChild(el('div', { className: 'heatmap-title' }, 'Skill Activity'));
617
+
618
+ const grid = el('div', { className: 'heatmap-grid' });
619
+ const isLight = document.body.classList.contains('light');
620
+
621
+ for (const row of hm.heatmap) {
622
+ const rowEl = el('div', { className: 'heatmap-row' });
623
+ rowEl.appendChild(el('div', { className: 'heatmap-label', title: row.skill }, row.skill));
624
+ for (const day of row.days) {
625
+ const intensity = day.count / hm.maxCount;
626
+ let bg;
627
+ if (day.count === 0) {
628
+ bg = isLight ? '#f1f5f9' : 'var(--bg-elevated)';
629
+ } else if (intensity < 0.25) {
630
+ bg = isLight ? '#bfdbfe' : '#1a3a5c';
631
+ } else if (intensity < 0.5) {
632
+ bg = isLight ? '#60a5fa' : '#1d5a8a';
633
+ } else if (intensity < 0.75) {
634
+ bg = isLight ? '#3b82f6' : '#2176b8';
635
+ } else {
636
+ bg = isLight ? '#1d4ed8' : '#3b9fe6';
637
+ }
638
+ rowEl.appendChild(el('div', {
639
+ className: 'heatmap-cell',
640
+ style: 'background:' + bg,
641
+ title: day.date + ': ' + day.count + 'x ' + row.skill
642
+ }));
643
+ }
644
+ grid.appendChild(rowEl);
645
+ }
646
+
647
+ // Date labels (show every 7th)
648
+ if (hm.dates.length > 0) {
649
+ const dateRow = el('div', { className: 'heatmap-dates' });
650
+ hm.dates.forEach((date, i) => {
651
+ const label = el('div', { className: 'heatmap-date' });
652
+ if (i % 7 === 0) label.textContent = date.slice(5);
653
+ dateRow.appendChild(label);
654
+ });
655
+ hmPanel.appendChild(grid);
656
+ hmPanel.appendChild(dateRow);
657
+ } else {
658
+ hmPanel.appendChild(grid);
659
+ }
660
+
661
+ // Legend
662
+ const legend = el('div', { className: 'heatmap-legend' });
663
+ legend.appendChild(document.createTextNode('Less'));
664
+ const legendColors = isLight
665
+ ? ['#f1f5f9', '#bfdbfe', '#60a5fa', '#3b82f6', '#1d4ed8']
666
+ : ['var(--bg-elevated)', '#1a3a5c', '#1d5a8a', '#2176b8', '#3b9fe6'];
667
+ legendColors.forEach(c => {
668
+ legend.appendChild(el('div', { className: 'heatmap-legend-cell', style: 'background:' + c }));
669
+ });
670
+ legend.appendChild(document.createTextNode('More'));
671
+ hmPanel.appendChild(legend);
672
+
673
+ app.appendChild(hmPanel);
674
+ }
675
+
676
+ // ── Bottom Row: Timeline + Chains ──
677
+ const bottomRow = el('div', { className: 'bottom-row' });
678
+
679
+ // Session Timeline
680
+ const tlPanel = el('div', { className: 'panel' });
681
+ tlPanel.appendChild(el('div', { className: 'panel-title' }, 'Recent Sessions'));
682
+ const timeline = el('div', { className: 'timeline' });
683
+ if (d.sessionTimeline && d.sessionTimeline.length > 0) {
684
+ for (const session of d.sessionTimeline) {
685
+ const tlEl = el('div', { className: 'tl-session' });
686
+ tlEl.appendChild(el('div', { className: 'tl-header' },
687
+ el('div', { className: 'tl-date' }, session.date),
688
+ el('div', { className: 'tl-meta' }, session.duration_min + 'min \\u2022 ' + session.tool_calls + ' tools')
689
+ ));
690
+ const skills = el('div', { className: 'tl-skills' });
691
+ for (const skill of session.skills_used.slice(0, 8)) {
692
+ skills.appendChild(el('span', {
693
+ className: 'tl-skill' + (skill === session.primary_skill ? ' primary' : '')
694
+ }, skill));
695
+ }
696
+ if (session.skills_used.length > 8) {
697
+ skills.appendChild(el('span', { className: 'tl-skill', style: 'opacity:0.5' }, '+' + (session.skills_used.length - 8)));
698
+ }
699
+ tlEl.appendChild(skills);
700
+ if (session.chains.length > 0) {
701
+ tlEl.appendChild(el('div', { className: 'tl-chain' }, session.chains[0].join(' \\u2192 ')));
702
+ }
703
+ timeline.appendChild(tlEl);
704
+ }
705
+ } else {
706
+ timeline.appendChild(el('div', { style: 'color:var(--text-muted);text-align:center;padding:20px;font-size:13px' }, 'No sessions yet'));
707
+ }
708
+ tlPanel.appendChild(timeline);
709
+ bottomRow.appendChild(tlPanel);
710
+
711
+ // Workflow Chains
712
+ const chainPanel = el('div', { className: 'panel' });
713
+ chainPanel.appendChild(el('div', { className: 'panel-title' }, 'Workflow Chains'));
714
+ if (d.skillChains && d.skillChains.length > 0) {
715
+ const maxChain = d.skillChains[0].count || 1;
716
+ const table = el('table', { className: 'chain-table' });
717
+ table.appendChild(el('thead', null,
718
+ el('tr', null,
719
+ el('th', null, 'Chain'),
720
+ el('th', null, ''),
721
+ el('th', { style: 'text-align:right;width:50px' }, '#')
722
+ )
723
+ ));
724
+ const tbody = el('tbody');
725
+ d.skillChains.slice(0, 12).forEach(c => {
726
+ const pct = Math.max(5, (c.count / maxChain) * 100);
727
+ tbody.appendChild(el('tr', null,
728
+ el('td', { className: 'chain-name' }, c.chain),
729
+ el('td', { style: 'width:80px' }, el('div', { className: 'chain-bar', style: 'width:' + pct + '%' })),
730
+ el('td', { className: 'chain-count' }, String(c.count))
731
+ ));
732
+ });
733
+ table.appendChild(tbody);
734
+ chainPanel.appendChild(table);
735
+ } else {
736
+ chainPanel.appendChild(el('div', { style: 'color:var(--text-muted);text-align:center;padding:20px;font-size:13px' }, 'No chains yet'));
737
+ }
738
+ bottomRow.appendChild(chainPanel);
739
+
740
+ app.appendChild(bottomRow);
741
+
742
+ // Footer
743
+ app.appendChild(el('div', { className: 'footer' },
744
+ 'Generated ' + new Date(d.generated).toLocaleString() + ' \\u2022 Rune Business \\u2022 100% local data'
745
+ ));
746
+ }
747
+
748
+ function toggleTheme() {
749
+ document.body.classList.toggle('light');
750
+ drawVoronoiMesh();
751
+ }
752
+
753
+ // ─── Voronoi Mesh Background ───
754
+ function mulberry32(seed) {
755
+ return function() {
756
+ seed |= 0; seed = seed + 0x6D2B79F5 | 0;
757
+ let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
758
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
759
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
760
+ };
761
+ }
762
+
763
+ let meshSeeds = [], meshW = 0, meshH = 0;
764
+
765
+ function generateSeeds() {
766
+ meshW = window.innerWidth;
767
+ meshH = window.innerHeight;
768
+ meshSeeds = [];
769
+ const N = 50;
770
+ const rng = mulberry32(42);
771
+ for (let i = 0; i < N; i++) {
772
+ const x = rng() * meshW;
773
+ const y = rng() * meshH;
774
+ const g = (x / meshW) * 0.45 + (1 - y / meshH) * 0.35;
775
+ const hueShift = rng() * 0.3 - 0.15;
776
+ meshSeeds.push({ x, y, g, hue: hueShift, phase: rng() * Math.PI * 2 });
777
+ }
778
+ }
779
+
780
+ function drawVoronoiCells() {
781
+ let canvas = document.getElementById('voronoi-bg');
782
+ if (!canvas) { canvas = document.createElement('canvas'); canvas.id = 'voronoi-bg'; document.body.prepend(canvas); }
783
+ const w = meshW, h = meshH, scale = 2;
784
+ const sw = Math.ceil(w / scale), sh = Math.ceil(h / scale);
785
+ canvas.width = sw; canvas.height = sh;
786
+ canvas.style.width = w + 'px'; canvas.style.height = h + 'px';
787
+ const ctx = canvas.getContext('2d');
788
+ const isLight = document.body.classList.contains('light');
789
+ const imageData = ctx.createImageData(sw, sh);
790
+ const data = imageData.data;
791
+ const seeds = meshSeeds.map(s => ({ ...s, sx: s.x / scale, sy: s.y / scale }));
792
+ for (let py = 0; py < sh; py++) {
793
+ for (let px = 0; px < sw; px++) {
794
+ let d1 = Infinity, d2 = Infinity, ni = 0;
795
+ for (let i = 0; i < seeds.length; i++) {
796
+ const dx = px - seeds[i].sx, dy = py - seeds[i].sy;
797
+ const d = dx * dx + dy * dy;
798
+ if (d < d1) { d2 = d1; d1 = d; ni = i; } else if (d < d2) { d2 = d; }
799
+ }
800
+ const seed = seeds[ni];
801
+ const e1 = Math.sqrt(d1), e2 = Math.sqrt(d2);
802
+ const edgeGlow = Math.max(0, 1 - (e2 - e1) / 2.5);
803
+ const depthShade = 0.85 + 0.15 * Math.min(1, (e1 / (e2 * 0.5 + 1)) * 0.8);
804
+ let r, g, b;
805
+ if (isLight) {
806
+ const base = 0.88 - seed.g * 0.18;
807
+ r = Math.round((base - 0.03 + seed.hue * 0.04) * 255 * depthShade);
808
+ g = Math.round((base - 0.01 + seed.hue * 0.02) * 255 * depthShade);
809
+ b = Math.round((base + 0.02) * 255 * depthShade);
810
+ r = Math.min(255, r + Math.round(edgeGlow * 40));
811
+ g = Math.min(255, g + Math.round(edgeGlow * 38));
812
+ b = Math.min(255, b + Math.round(edgeGlow * 50));
813
+ } else {
814
+ const base = 0.04 + seed.g * 0.09;
815
+ r = Math.round((base * 0.45 + seed.hue * 0.03) * 255 * depthShade);
816
+ g = Math.round((base * 0.65 + seed.hue * 0.05) * 255 * depthShade);
817
+ b = Math.round((base * 1.4 + seed.hue * 0.08) * 255 * depthShade);
818
+ r = Math.min(255, r + Math.round(edgeGlow * 25));
819
+ g = Math.min(255, g + Math.round(edgeGlow * 35));
820
+ b = Math.min(255, b + Math.round(edgeGlow * 65));
821
+ }
822
+ const idx = (py * sw + px) * 4;
823
+ data[idx] = r; data[idx+1] = g; data[idx+2] = b; data[idx+3] = 255;
824
+ }
825
+ }
826
+ ctx.putImageData(imageData, 0, 0);
827
+ const grd = ctx.createRadialGradient(sw * 0.7, sh * 0.1, 0, sw * 0.5, sh * 0.5, sw * 0.9);
828
+ if (isLight) { grd.addColorStop(0, 'rgba(255,255,255,0.1)'); grd.addColorStop(1, 'rgba(0,0,0,0.04)'); }
829
+ else { grd.addColorStop(0, 'rgba(80,140,255,0.06)'); grd.addColorStop(0.5, 'rgba(0,0,0,0)'); grd.addColorStop(1, 'rgba(0,0,0,0.2)'); }
830
+ ctx.fillStyle = grd; ctx.fillRect(0, 0, sw, sh);
831
+ }
832
+
833
+ let meshAnimFrame = null;
834
+ function initMeshOverlay() {
835
+ let canvas = document.getElementById('mesh-overlay');
836
+ if (!canvas) { canvas = document.createElement('canvas'); canvas.id = 'mesh-overlay'; const bg = document.getElementById('voronoi-bg'); if (bg) bg.after(canvas); else document.body.prepend(canvas); }
837
+ canvas.width = meshW; canvas.height = meshH;
838
+ canvas.style.width = meshW + 'px'; canvas.style.height = meshH + 'px';
839
+ const edges = [];
840
+ const seeds = meshSeeds;
841
+ for (let i = 0; i < seeds.length; i++) {
842
+ const dists = [];
843
+ for (let j = 0; j < seeds.length; j++) { if (i === j) continue; const dx = seeds[i].x - seeds[j].x, dy = seeds[i].y - seeds[j].y; dists.push({ j, d: dx*dx+dy*dy }); }
844
+ dists.sort((a,b) => a.d - b.d);
845
+ for (let k = 0; k < Math.min(3, dists.length); k++) { const key = Math.min(i, dists[k].j)+'-'+Math.max(i, dists[k].j); if (!edges.find(e => e.key === key)) edges.push({ key, a: Math.min(i, dists[k].j), b: Math.max(i, dists[k].j) }); }
846
+ }
847
+ if (meshAnimFrame) cancelAnimationFrame(meshAnimFrame);
848
+ const ctx = canvas.getContext('2d');
849
+ function frame(t) {
850
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
851
+ const time = t * 0.001;
852
+ const isLight = document.body.classList.contains('light');
853
+ for (const edge of edges) {
854
+ const sa = seeds[edge.a], sb = seeds[edge.b];
855
+ const pulse = 0.3 + 0.7 * (0.5 + 0.5 * Math.sin(time * 0.4 + (sa.phase + sb.phase) / 2));
856
+ ctx.beginPath(); ctx.moveTo(sa.x, sa.y); ctx.lineTo(sb.x, sb.y);
857
+ ctx.strokeStyle = isLight ? 'rgba(100,100,140,' + (0.04 * pulse).toFixed(3) + ')' : 'rgba(100,160,255,' + (0.06 * pulse).toFixed(3) + ')';
858
+ ctx.lineWidth = 0.8; ctx.stroke();
859
+ }
860
+ for (const s of seeds) {
861
+ const pulse = 0.4 + 0.6 * (0.5 + 0.5 * Math.sin(time * 0.6 + s.phase));
862
+ const radius = 1.5 + pulse;
863
+ const grad = ctx.createRadialGradient(s.x, s.y, 0, s.x, s.y, radius * 4);
864
+ grad.addColorStop(0, isLight ? 'rgba(80,80,160,' + (0.12 * pulse).toFixed(3) + ')' : 'rgba(100,180,255,' + (0.2 * pulse).toFixed(3) + ')');
865
+ grad.addColorStop(1, 'rgba(0,0,0,0)');
866
+ ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(s.x, s.y, radius * 4, 0, Math.PI * 2); ctx.fill();
867
+ ctx.fillStyle = isLight ? 'rgba(100,100,180,' + (0.25 * pulse).toFixed(3) + ')' : 'rgba(140,200,255,' + (0.4 * pulse).toFixed(3) + ')';
868
+ ctx.beginPath(); ctx.arc(s.x, s.y, radius, 0, Math.PI * 2); ctx.fill();
869
+ }
870
+ meshAnimFrame = requestAnimationFrame(frame);
871
+ }
872
+ meshAnimFrame = requestAnimationFrame(frame);
873
+ }
874
+
875
+ function drawVoronoiMesh() { generateSeeds(); drawVoronoiCells(); initMeshOverlay(); }
876
+
877
+ drawVoronoiMesh();
878
+ window.addEventListener('resize', drawVoronoiMesh);
879
+ render();
880
+ </script>
881
+ </body>
882
+ </html>`;
883
+ }