@grainulation/orchard 1.0.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.
@@ -0,0 +1,981 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
6
+ <meta name="apple-mobile-web-app-capable" content="yes">
7
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
8
+ <meta name="theme-color" content="#0a0e1a">
9
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><rect width='64' height='64' rx='14' fill='%230a0e1a'/><text x='32' y='34' text-anchor='middle' dominant-baseline='central' fill='%23d4a574' font-family='-apple-system,system-ui,sans-serif' font-weight='800' font-size='32'>O</text></svg>">
10
+ <title>Orchard</title>
11
+ <style>
12
+ :root {
13
+ --bg: #0a0e1a; --bg-s: #111827; --bg-e: #1a2332; --bg-h: #1e293b;
14
+ --bdr: #1e293b; --bdr-f: #334155;
15
+ --txt: #e2e8f0; --txt-m: #94a3b8; --txt-d: #8494a7;
16
+ --acc: #d4a574; --acc-d: #6a5239;
17
+ --grn: #22c55e; --grn-d: rgba(34,197,94,0.12);
18
+ --ylw: #eab308; --ylw-d: rgba(234,179,8,0.08);
19
+ --org: #f97316; --org-d: rgba(249,115,22,0.1);
20
+ --red: #ef4444; --red-d: rgba(239,68,68,0.1);
21
+ --pur: #a855f7; --pur-d: rgba(168,85,247,0.1);
22
+ --cyn: #06b6d4; --cyn-d: rgba(6,182,212,0.1);
23
+ }
24
+ *{margin:0;padding:0;box-sizing:border-box}
25
+ body{font-family:'SF Mono','Fira Code','JetBrains Mono',monospace;background:var(--bg);background-image:radial-gradient(ellipse at 20% 50%,rgba(59,130,246,0.08) 0%,transparent 60%),radial-gradient(ellipse at 80% 20%,rgba(167,139,250,0.06) 0%,transparent 50%);color:var(--txt);line-height:1.6;overflow-y:auto}
26
+
27
+ .skip-link{position:absolute;top:-100px;left:0;background:var(--acc);color:#fff;padding:8px 16px;z-index:1000;font-size:12px;text-decoration:none;border-radius:0 0 4px 0}
28
+ .skip-link:focus{top:0}
29
+ .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
30
+ :focus-visible{outline:2px solid var(--acc);outline-offset:2px}
31
+ @media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
32
+
33
+ .app{max-width:1200px;margin:0 auto;padding:12px 24px;padding-left:calc(24px + env(safe-area-inset-left));padding-right:calc(24px + env(safe-area-inset-right));padding-bottom:calc(12px + env(safe-area-inset-bottom));overflow-x:hidden}
34
+ .main-grid{display:grid;grid-template-columns:1fr 340px;gap:16px;height:calc(100vh - 48px - 60px - 48px - 16px);height:calc(100dvh - 48px - 60px - 48px - 16px);min-height:400px}
35
+ .main-grid>*{overflow-y:auto;overflow-x:hidden}
36
+
37
+ /* Toolbar */
38
+ .toolbar{position:sticky;top:0;z-index:100;background:rgba(255,255,255,0.08);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border-bottom:1px solid var(--bdr);padding:4px 24px;padding-top:calc(4px + env(safe-area-inset-top));padding-left:calc(24px + env(safe-area-inset-left));padding-right:calc(24px + env(safe-area-inset-right));display:flex;align-items:center;gap:10px}
39
+ .toolbar canvas{flex-shrink:0}
40
+ .tool-status{font-size:10px;color:var(--txt-d);transition:color 0.3s}
41
+ .toolbar-spacer{flex:1}
42
+ .toolbar-right{display:flex;align-items:center;gap:8px}
43
+ .sprint-select{padding:6px 28px 6px 12px;border-radius:6px;background:#111827;border:1px solid #1e293b;color:#e2e8f0;font-size:12px;font-family:inherit;outline:none;cursor:pointer;max-width:320px;text-overflow:ellipsis;-webkit-appearance:none;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='none'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%2394a3b8' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center}
44
+ .status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;background:var(--txt-d);transition:background 0.3s,box-shadow 0.3s}
45
+ .status-dot.ok{background:#34c759;box-shadow:0 0 6px rgba(52,199,89,0.5)}
46
+ header .meta{color:var(--txt-d);font-size:11px;display:flex;gap:16px;flex-wrap:wrap;flex:1;display:none}
47
+
48
+ /* Hero -- compact bar */
49
+ .hero{background:var(--bg-s);border:1px solid var(--bdr);border-radius:4px;padding:10px 16px;margin-bottom:12px;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
50
+ .hero-score{font-size:28px;font-weight:700;line-height:1;letter-spacing:-1px;flex-shrink:0}
51
+ .hero-detail{flex:1;min-width:0}
52
+ .hero-label{font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:0.8px;color:var(--txt-d);display:none}
53
+ .hero-prose{font-size:11px;color:var(--txt-m);line-height:1.5}
54
+ .hero-prose strong{color:var(--txt)}
55
+ .hero-kpis{display:flex;gap:16px;font-size:10px;color:var(--txt-d)}
56
+ .hero-kpis strong{color:var(--txt-m)}
57
+ .status-label{font-size:8px;font-weight:600;text-transform:uppercase;letter-spacing:0.4px;margin-left:4px}
58
+ .status-label-good{color:var(--grn)}.status-label-warn{color:var(--ylw)}.status-label-critical{color:var(--red)}
59
+
60
+ /* KPI -- hidden, merged into hero */
61
+ .kpi-row{display:none}
62
+
63
+ /* Stat tiles */
64
+ .stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin-bottom:12px}
65
+ .stat-tile{background:var(--bg-s);border:1px solid var(--bdr);border-radius:4px;padding:10px 14px}
66
+ .stat-tile-value{font-size:24px;font-weight:700;line-height:1.1;letter-spacing:-0.5px}
67
+ .stat-tile-label{font-size:9px;color:var(--txt-d);text-transform:uppercase;letter-spacing:0.5px;margin-top:2px}
68
+ .stat-tile-accent .stat-tile-value{color:var(--acc)}
69
+ .stat-tile-green .stat-tile-value{color:var(--grn)}
70
+ .stat-tile-red .stat-tile-value{color:var(--red)}
71
+ .stat-tile-cyan .stat-tile-value{color:var(--cyn)}
72
+ @media(max-width:600px){.stats-grid{grid-template-columns:repeat(3,1fr);gap:6px}.stat-tile{padding:8px 10px}.stat-tile-value{font-size:18px}}
73
+
74
+ /* Section */
75
+ .section{margin-bottom:8px}
76
+ .section-title{font-size:11px;font-weight:600;margin-bottom:8px;padding-bottom:4px;border-bottom:1px solid var(--bdr);display:flex;align-items:center;gap:8px}
77
+ .section-title .count{color:var(--txt-d);font-size:10px;font-weight:400}
78
+
79
+ /* Graph */
80
+ .graph-wrap{position:relative;background:var(--bg-s);border:1px solid var(--bdr);border-radius:4px;overflow:hidden;cursor:grab;flex:1}
81
+ .graph-wrap:active{cursor:grabbing}
82
+ .graph-canvas{position:relative}
83
+ .graph-svg{position:absolute;top:0;left:0;pointer-events:none}
84
+
85
+ /* Graph nodes */
86
+ .g-node{position:absolute;background:var(--bg-e);border:1.5px solid var(--bdr-f);border-radius:4px;padding:6px 10px;width:140px;cursor:pointer;z-index:2;transition:border-color 0.15s,box-shadow 0.15s}
87
+ .g-node:hover{border-color:var(--acc);box-shadow:0 0 12px rgba(59,130,246,0.15)}
88
+ .g-node.selected{border-color:var(--acc);box-shadow:0 0 16px rgba(59,130,246,0.25)}
89
+ .g-node.in-cycle{border-color:var(--red);box-shadow:0 0 10px rgba(239,68,68,0.2)}
90
+ .g-node .n-name{font-size:11px;font-weight:700;color:var(--txt);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-bottom:4px}
91
+ .g-node .n-meta{display:flex;gap:6px;align-items:center;font-size:9px;color:var(--txt-m)}
92
+ .g-node .n-bar{height:3px;background:var(--bg-h);border-radius:2px;margin-top:6px;overflow:hidden}
93
+ .g-node .n-bar-fill{height:100%;border-radius:2px}
94
+
95
+ /* Phase badges */
96
+ .ph{font-size:8px;padding:1px 5px;border-radius:2px;font-weight:600;text-transform:uppercase;letter-spacing:0.4px}
97
+ .ph-define{background:var(--pur-d);color:var(--pur)}
98
+ .ph-research{background:var(--acc-d);color:var(--acc)}
99
+ .ph-prototype{background:var(--grn-d);color:var(--grn)}
100
+ .ph-evaluate{background:var(--ylw-d);color:var(--ylw)}
101
+ .ph-feedback{background:var(--org-d);color:var(--org)}
102
+ .ph-archived{background:rgba(100,116,139,0.12);color:var(--txt-d)}
103
+ .ph-unknown{background:rgba(100,116,139,0.12);color:var(--txt-d)}
104
+
105
+ /* Detail panel */
106
+ .detail-panel{background:var(--bg-s);border:1px solid var(--bdr);border-radius:4px;padding:20px;margin-bottom:24px;display:none}
107
+ .detail-panel.open{display:block}
108
+ .detail-header{display:flex;align-items:center;gap:12px;margin-bottom:16px}
109
+ .detail-header h2{font-size:14px;font-weight:700;flex:1}
110
+ .detail-close{background:none;border:1px solid var(--bdr);color:var(--txt-d);width:44px;height:44px;border-radius:4px;cursor:pointer;font-size:16px;display:flex;align-items:center;justify-content:center}
111
+ .detail-close:hover{border-color:var(--bdr-f);color:var(--txt)}
112
+ .detail-close:active{background:var(--bg-h);transform:scale(0.95)}
113
+ .f-btn:active{transform:scale(0.96);opacity:0.8}
114
+ .s-card:active{border-color:var(--acc);transform:scale(0.99)}
115
+ .g-node:active{border-color:var(--acc);transform:scale(0.97)}
116
+ .detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
117
+ .detail-stat{font-size:11px}
118
+ .detail-stat .dl{color:var(--txt-m);font-size:10px;text-transform:uppercase;letter-spacing:0.4px;margin-bottom:2px}
119
+ .detail-stat .dv{font-weight:600;color:var(--txt)}
120
+ .detail-question{font-size:12px;color:var(--txt-m);margin-top:12px;padding:12px;background:var(--bg-e);border-radius:4px;line-height:1.5}
121
+ .detail-edges{margin-top:16px}
122
+ .detail-edge{display:flex;align-items:center;gap:8px;padding:4px 0;font-size:11px;border-bottom:1px solid var(--bdr)}
123
+ .detail-edge:last-child{border-bottom:none}
124
+
125
+ /* Filter */
126
+ .filter-row{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap;align-items:center}
127
+ .filter-row .label{font-size:10px;color:var(--txt-d);text-transform:uppercase;letter-spacing:0.4px}
128
+ .f-btn{background:var(--bg-s);border:1px solid var(--bdr);color:var(--txt-m);padding:8px 12px;border-radius:3px;font-family:inherit;font-size:10px;cursor:pointer;min-height:36px}
129
+ .f-btn:hover{border-color:var(--bdr-f);color:var(--txt)}
130
+ .f-btn.active{border-color:var(--acc);color:var(--acc);background:var(--acc-d)}
131
+
132
+ /* Sprint cards -- right panel, single column */
133
+ .sprint-cards{display:flex;flex-direction:column;gap:8px}
134
+ .s-card{background:var(--bg-s);border:1px solid var(--bdr);border-radius:4px;padding:10px 12px;cursor:pointer;transition:border-color 0.15s}
135
+ .s-card:focus-visible{outline:2px solid var(--acc);outline-offset:2px}
136
+ .s-card:hover{border-color:var(--bdr-f)}
137
+ .s-card.in-cycle{border-left:3px solid var(--red)}
138
+ .s-card-name{font-size:12px;font-weight:700;color:var(--txt);margin-bottom:6px;display:flex;align-items:center;gap:8px;overflow:hidden;text-overflow:ellipsis;min-width:0}
139
+ .s-card-question{font-size:10px;color:var(--txt-d);line-height:1.4;margin-bottom:8px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
140
+ .s-card-stats{display:flex;gap:12px;font-size:10px;color:var(--txt-d);flex-wrap:wrap}
141
+ .s-card-stats strong{color:var(--txt-m)}
142
+ .s-card-bar{height:3px;background:var(--bg-h);border-radius:2px;margin-top:8px;overflow:hidden;display:flex}
143
+ .s-card-bar span{height:100%}
144
+
145
+ /* Edge type badges */
146
+ .e-badge{font-size:8px;padding:1px 5px;border-radius:2px;font-weight:600;text-transform:uppercase}
147
+ .e-resolved_by{background:var(--grn-d);color:var(--grn)}
148
+ .e-conflict{background:var(--red-d);color:var(--red)}
149
+ .e-reference{background:var(--acc-d);color:var(--acc)}
150
+
151
+ /* Collapsible */
152
+ .collapsible{margin-bottom:24px;border:1px solid var(--bdr);border-radius:4px;overflow:hidden}
153
+ .collapsible-header{padding:12px 16px;background:var(--bg-s);cursor:pointer;display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;user-select:none}
154
+ .collapsible-header:hover{background:var(--bg-e)}
155
+ .collapsible-header .arrow{color:var(--txt-d);font-size:10px;transition:transform 0.2s}
156
+ .collapsible.open .arrow{transform:rotate(90deg)}
157
+ .collapsible-body{display:none;padding:16px;border-top:1px solid var(--bdr)}
158
+ .collapsible.open .collapsible-body{display:block}
159
+
160
+ footer{margin-top:32px;padding-top:16px;border-top:1px solid var(--bdr);color:var(--txt-d);font-size:10px;display:flex;justify-content:space-between}
161
+ .grain-idle { display: block; margin: 0 auto 10px; }
162
+
163
+ /* Mobile: stack layout, scrollable graph */
164
+ @media(max-width:768px){
165
+ .toolbar{padding:4px 12px}
166
+ .app{padding:8px 12px}
167
+ .main-grid{grid-template-columns:1fr;height:auto;min-height:0}
168
+ .main-grid>*{overflow-y:visible;overflow-x:hidden}
169
+ #graph-section{order:2}
170
+ #cards-section{order:1}
171
+ .graph-wrap{overflow:auto;-webkit-overflow-scrolling:touch;min-height:300px;max-height:50vh}
172
+ .hero{padding:8px 12px;gap:10px}
173
+ .hero-score{font-size:22px}
174
+ .hero-kpis{flex-wrap:wrap;gap:8px}
175
+ .detail-panel{padding:12px}
176
+ .detail-grid{grid-template-columns:1fr}
177
+ .g-node{width:110px;padding:4px 8px}
178
+ .g-node .n-name{font-size:10px}
179
+ .g-node .n-meta{font-size:8px}
180
+ .s-card{padding:8px 10px}
181
+ .filter-row{gap:4px}
182
+ .f-btn{padding:2px 6px;font-size:9px}
183
+ .section-title{font-size:10px}
184
+ .sprint-select{max-width:180px}
185
+ }
186
+ /* Small phones */
187
+ @media(max-width:480px){
188
+ .toolbar{padding:4px 8px;gap:6px}
189
+ .app{padding:6px 8px}
190
+ .hero-score{font-size:18px}
191
+ .hero-kpis{font-size:9px}
192
+ .g-node{width:90px;padding:3px 6px}
193
+ .g-node .n-name{font-size:9px}
194
+ .sprint-select{max-width:140px}
195
+ }
196
+ </style>
197
+ </head>
198
+ <body>
199
+ <a href="#main-content" class="skip-link">Skip to main content</a>
200
+
201
+ <header class="toolbar" role="banner">
202
+ <canvas id="grainLogo" width="256" height="256"></canvas>
203
+ <div class="toolbar-spacer"></div>
204
+ <div class="toolbar-right">
205
+ <select id="sprintSelector" class="sprint-select" aria-label="Select sprint to inspect"></select>
206
+ <div class="status-dot" id="statusDot"></div>
207
+ </div>
208
+ </header>
209
+
210
+ <div class="app">
211
+ <main id="main-content">
212
+
213
+ <!-- Hero -- compact summary bar -->
214
+ <div class="hero" id="hero" role="region" aria-label="Ecosystem health overview"></div>
215
+
216
+ <!-- Stats tiles -->
217
+ <div class="stats-grid" id="stats-grid" role="region" aria-label="Ecosystem statistics"></div>
218
+
219
+ <!-- Detail panel (overlay when sprint selected) -->
220
+ <div class="detail-panel" id="detail" role="region" aria-label="Sprint detail"></div>
221
+
222
+ <!-- Two-column: graph left, sprint list right -->
223
+ <div class="main-grid">
224
+ <div id="graph-section" role="region" aria-label="Dependency graph" style="display:flex;flex-direction:column">
225
+ <div class="section-title" style="flex-shrink:0">Dependency Graph <span class="count" id="graph-count"></span></div>
226
+ <div class="graph-wrap" id="graph-wrap">
227
+ <div class="graph-canvas" id="graph-canvas">
228
+ <svg class="graph-svg" id="graph-svg"></svg>
229
+ </div>
230
+ </div>
231
+ </div>
232
+ <div id="cards-section" style="display:flex;flex-direction:column">
233
+ <div class="section-title" style="flex-shrink:0">Sprints <span class="count" id="cards-count"></span></div>
234
+ <div class="filter-row" id="filter-row" style="flex-shrink:0"></div>
235
+ <div class="sprint-cards" id="sprint-cards" style="flex:1;overflow-y:auto"></div>
236
+ </div>
237
+ </div>
238
+
239
+ <!-- Hidden KPIs (data still computed) -->
240
+ <div class="kpi-row" id="kpis" role="region" aria-label="Key metrics"></div>
241
+
242
+ </main>
243
+ </div>
244
+
245
+ <script>
246
+ var D = __SPRINT_DATA__;
247
+ var sprints = D.sprints || [];
248
+ var edges = D.edges || [];
249
+ var cycles = D.cycles || [];
250
+ var statuses = D.status || [];
251
+
252
+ // Index helpers
253
+ var statusByPath = {};
254
+ statuses.forEach(function(s) { statusByPath[s.path] = s; });
255
+
256
+ var cycleSet = {};
257
+ cycles.forEach(function(c) { c.forEach(function(n) { cycleSet[n] = true; }); });
258
+
259
+ function shortName(p) {
260
+ var parts = (p || '').replace(/\\/g, '/').split('/');
261
+ var name = parts[parts.length - 1] || parts[parts.length - 2] || p;
262
+ return name === '.' ? '(root)' : name;
263
+ }
264
+
265
+ function phaseClass(p) { return 'ph ph-' + (p || 'unknown').toLowerCase(); }
266
+ function isInCycle(path) {
267
+ var name = shortName(path);
268
+ return cycleSet[name] || cycleSet[path] || false;
269
+ }
270
+
271
+ // Compute ecosystem health
272
+ var totalClaims = 0, totalActive = 0, totalResolved = 0, totalSuperseded = 0;
273
+ var activeSprints = 0, phases = {};
274
+ statuses.forEach(function(s) {
275
+ totalClaims += s.total;
276
+ totalActive += s.active;
277
+ totalResolved += s.resolved;
278
+ totalSuperseded += s.superseded;
279
+ var ph = (s.phase || 'unknown').toLowerCase();
280
+ if (ph !== 'archived') activeSprints++;
281
+ phases[ph] = (phases[ph] || 0) + 1;
282
+ });
283
+
284
+ var resolutionRate = totalClaims > 0 ? totalResolved / totalClaims : 0;
285
+ var activeRate = totalClaims > 0 ? totalActive / totalClaims : 0;
286
+ var connectivity = edges.length > 0 ? Math.min(edges.length / sprints.length, 3) / 3 : 0;
287
+ var phaseSpread = Math.min(Object.keys(phases).length / 4, 1);
288
+ var cyclesPenalty = cycles.length > 0 ? Math.min(cycles.length * 0.1, 0.3) : 0;
289
+
290
+ var health = Math.round(
291
+ resolutionRate * 25 +
292
+ activeRate * 20 +
293
+ connectivity * 25 +
294
+ phaseSpread * 20 +
295
+ (1 - cyclesPenalty) * 10
296
+ );
297
+ health = Math.max(0, Math.min(100, health));
298
+
299
+ // Size confidence: penalize scores from tiny datasets
300
+ var sizeConfidence = totalClaims < 20 ? 0.6 : totalClaims < 50 ? 0.8 : totalClaims < 100 ? 0.9 : 1.0;
301
+ health = Math.round(health * sizeConfidence);
302
+ var lowConfidence = sizeConfidence < 1.0;
303
+
304
+ function healthColor(h) { return h >= 70 ? 'var(--grn)' : h >= 40 ? 'var(--ylw)' : 'var(--red)'; }
305
+ function healthLabel(h) { return h >= 70 ? 'good' : h >= 40 ? 'warn' : 'critical'; }
306
+
307
+ // Sprint selector dropdown
308
+ var sprintSel = document.getElementById('sprintSelector');
309
+ var selOpt0 = document.createElement('option');
310
+ selOpt0.value = '-1';
311
+ selOpt0.textContent = 'All sprints (' + sprints.length + ')';
312
+ sprintSel.appendChild(selOpt0);
313
+ sprints.forEach(function(s, i) {
314
+ var opt = document.createElement('option');
315
+ opt.value = i;
316
+ var name = shortName(s.path);
317
+ var ph = (s.phase || '').toLowerCase();
318
+ var prefix = ph === 'archived' ? '[archived] ' : '';
319
+ opt.textContent = prefix + name + ' (' + s.claimCount + ')';
320
+ sprintSel.appendChild(opt);
321
+ });
322
+ sprintSel.addEventListener('change', function() {
323
+ var idx = parseInt(this.value);
324
+ if (idx >= 0) selectSprint(idx);
325
+ else closeSprint();
326
+ });
327
+ document.getElementById('statusDot').classList.add('ok');
328
+
329
+ // Hero
330
+ var heroLines = [];
331
+ heroLines.push('Tracking <strong>' + sprints.length + '</strong> sprints with <strong>' + totalClaims + '</strong> total claims across the ecosystem.');
332
+ if (cycles.length > 0) heroLines.push('<span style="color:var(--red)">' + cycles.length + ' dependency cycle(s) detected</span> -- circular references between sprints.');
333
+ heroLines.push('Resolution rate: <strong>' + (resolutionRate * 100).toFixed(1) + '%</strong>. ' +
334
+ 'Cross-sprint connectivity: <strong>' + edges.length + ' edges</strong> linking ' + activeSprints + ' active sprints.');
335
+
336
+ document.getElementById('hero').innerHTML =
337
+ '<div style="display:flex;align-items:baseline;gap:8px;flex-wrap:wrap">' +
338
+ '<span class="hero-score" style="color:' + healthColor(health) + '">' + health + '</span>' +
339
+ '<span class="status-label status-label-' + healthLabel(health) + '">' + healthLabel(health) + '</span>' +
340
+ (lowConfidence ? '<span style="font-size:10px;color:var(--txt-d)">(low confidence)</span>' : '') +
341
+ '<span style="color:var(--txt-d);font-size:10px;margin-left:auto">' +
342
+ activeSprints + ' sprints &middot; ' + totalClaims + ' claims &middot; ' + edges.length + ' edges' +
343
+ (cycles.length > 0 ? ' &middot; <span style="color:var(--red)">' + cycles.length + ' cycle' + (cycles.length > 1 ? 's' : '') + '</span>' : '') +
344
+ ' &middot; ' + (resolutionRate * 100).toFixed(0) + '% resolved' +
345
+ '</span>' +
346
+ '</div>';
347
+
348
+ // Stats tiles
349
+ document.getElementById('stats-grid').innerHTML = [
350
+ { v: sprints.length, l: 'Sprints', cls: 'stat-tile-accent' },
351
+ { v: activeSprints, l: 'Active', cls: 'stat-tile-green' },
352
+ { v: totalClaims, l: 'Claims', cls: '' },
353
+ { v: edges.length, l: 'Edges', cls: 'stat-tile-cyan' },
354
+ { v: cycles.length, l: 'Cycles', cls: cycles.length > 0 ? 'stat-tile-red' : '' },
355
+ { v: (resolutionRate * 100).toFixed(0) + '%', l: 'Resolved', cls: resolutionRate > 0.5 ? 'stat-tile-green' : 'stat-tile-red' },
356
+ ].map(function(s) {
357
+ return '<div class="stat-tile ' + s.cls + '"><div class="stat-tile-value">' + s.v + '</div><div class="stat-tile-label">' + s.l + '</div></div>';
358
+ }).join('');
359
+
360
+ // KPIs
361
+ var phaseDistHtml = Object.keys(phases).sort().map(function(p) {
362
+ return '<span class="' + phaseClass(p) + '">' + p + '</span> ' + phases[p];
363
+ }).join(' &middot; ');
364
+
365
+ document.getElementById('kpis').innerHTML = [
366
+ { l: 'Active Sprints', v: activeSprints, s: '/ ' + sprints.length + ' total' },
367
+ { l: 'Total Claims', v: totalClaims, s: totalActive + ' active, ' + totalResolved + ' resolved' },
368
+ { l: 'Dependencies', v: edges.length, s: cycles.length > 0 ? cycles.length + ' cycle(s)' : 'no cycles' },
369
+ { l: 'Phase Distribution', v: '', s: phaseDistHtml },
370
+ ].map(function(k) {
371
+ return '<div class="kpi"><div class="kpi-label">' + k.l + '</div>' +
372
+ (k.v !== '' ? '<div class="kpi-value">' + k.v + '</div>' : '') +
373
+ '<div class="kpi-sub">' + k.s + '</div></div>';
374
+ }).join('');
375
+
376
+ // --- GRAPH: container-first layout ---
377
+ var NW = 130, NH = 44;
378
+
379
+ var pathIdx = {};
380
+ sprints.forEach(function(s, i) { pathIdx[s.path] = i; });
381
+
382
+ // Measure container FIRST
383
+ var graphWrap = document.getElementById('graph-wrap');
384
+ var canvas = document.getElementById('graph-canvas');
385
+ var svg = document.getElementById('graph-svg');
386
+
387
+ document.getElementById('graph-count').textContent = sprints.length + ' nodes, ' + edges.length + ' edges';
388
+
389
+ var wrapRect = graphWrap.getBoundingClientRect();
390
+ var GW = Math.max(400, Math.floor(wrapRect.width) - 4);
391
+ var GH = Math.max(300, Math.floor(wrapRect.height) - 4);
392
+ var PAD = 8;
393
+
394
+ // Available space for node centers
395
+ var areaW = GW - NW - PAD * 2;
396
+ var areaH = GH - NH - PAD * 2;
397
+
398
+ // Compute grid: fill available space evenly
399
+ var n = sprints.length;
400
+ var cols = Math.ceil(Math.sqrt(n * (areaW / areaH)));
401
+ var rows = Math.ceil(n / cols);
402
+ // Recalc to avoid wasted rows
403
+ while ((rows - 1) * cols >= n) rows--;
404
+ var cellW = areaW / cols;
405
+ var cellH = areaH / Math.max(rows, 1);
406
+
407
+ // Sort by edge count so hubs land center
408
+ var edgeCount = {};
409
+ edges.forEach(function(e) {
410
+ edgeCount[e.from] = (edgeCount[e.from] || 0) + 1;
411
+ edgeCount[e.to] = (edgeCount[e.to] || 0) + 1;
412
+ });
413
+ var order = sprints.map(function(s, i) { return { i: i, deg: edgeCount[s.path] || 0 }; });
414
+ order.sort(function(a, b) { return b.deg - a.deg; });
415
+
416
+ // Spiral-out placement: hubs get center cells
417
+ var placed = [];
418
+ var cx = Math.floor(cols / 2), cy = Math.floor(rows / 2);
419
+ var grid = {};
420
+ var dirs = [[0,0],[1,0],[0,1],[-1,0],[0,-1]]; // seed + cardinal
421
+ var spiral = [];
422
+ // Generate spiral coordinates from center
423
+ (function() {
424
+ var x = cx, y = cy, dx = 1, dy = 0, steps = 1, stepCount = 0, turnCount = 0;
425
+ for (var k = 0; k < cols * rows + 10; k++) {
426
+ if (x >= 0 && x < cols && y >= 0 && y < rows) spiral.push([x, y]);
427
+ x += dx; y += dy; stepCount++;
428
+ if (stepCount >= steps) {
429
+ stepCount = 0; turnCount++;
430
+ var tmp = dx; dx = -dy; dy = tmp;
431
+ if (turnCount % 2 === 0) steps++;
432
+ }
433
+ }
434
+ })();
435
+
436
+ // Place nodes along spiral
437
+ var nodes = [];
438
+ var si = 0;
439
+ for (var k = 0; k < spiral.length && si < order.length; k++) {
440
+ var gx = spiral[k][0], gy = spiral[k][1];
441
+ var key = gx + ',' + gy;
442
+ if (grid[key]) continue;
443
+ grid[key] = true;
444
+ var oi = order[si].i;
445
+ nodes[oi] = {
446
+ x: PAD + gx * cellW + cellW / 2 - NW / 2,
447
+ y: PAD + gy * cellH + cellH / 2 - NH / 2,
448
+ vx: 0, vy: 0,
449
+ sprint: sprints[oi], idx: oi
450
+ };
451
+ si++;
452
+ }
453
+
454
+ // No spring nudge — grid stays fixed, edges just connect where nodes are.
455
+ // User can drag to rearrange if needed.
456
+
457
+ canvas.style.width = GW + 'px';
458
+ canvas.style.height = GH + 'px';
459
+
460
+ svg.setAttribute('width', GW);
461
+ svg.setAttribute('height', GH);
462
+ svg.style.width = GW + 'px';
463
+ svg.style.height = GH + 'px';
464
+
465
+ // SVG edges
466
+ var defs = '<defs>' +
467
+ '<marker id="a-ref" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6" fill="var(--acc)" opacity="0.6"/></marker>' +
468
+ '<marker id="a-res" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6" fill="var(--grn)" opacity="0.6"/></marker>' +
469
+ '<marker id="a-con" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6" fill="var(--red)" opacity="0.6"/></marker>' +
470
+ '</defs>';
471
+
472
+ // Edge rendering function (called on drag too)
473
+ function renderEdges() {
474
+ var edgeSvg = '';
475
+ edges.forEach(function(e) {
476
+ var fi = pathIdx[e.from], ti = pathIdx[e.to];
477
+ if (fi === undefined || ti === undefined) return;
478
+ var a = nodes[fi], b = nodes[ti];
479
+ var ax = a.x + NW / 2, ay = a.y + NH / 2;
480
+ var bx = b.x + NW / 2, by = b.y + NH / 2;
481
+ var color = e.type === 'conflict' ? 'var(--red)' : e.type === 'resolved_by' ? 'var(--grn)' : 'var(--acc)';
482
+ var marker = e.type === 'conflict' ? 'a-con' : e.type === 'resolved_by' ? 'a-res' : 'a-ref';
483
+ var opacity = Math.min(0.25 + Math.log2(e.count + 1) * 0.15, 0.85);
484
+ var sw = Math.min(1 + Math.log2(e.count + 1) * 0.5, 3);
485
+ var mx = (ax + bx) / 2 + (ay - by) * 0.15;
486
+ var my = (ay + by) / 2 + (bx - ax) * 0.15;
487
+ edgeSvg += '<path d="M' + ax + ',' + ay + ' Q' + mx + ',' + my + ' ' + bx + ',' + by + '" ' +
488
+ 'fill="none" stroke="' + color + '" stroke-width="' + sw + '" stroke-opacity="' + opacity + '" ' +
489
+ 'marker-end="url(#' + marker + ')" data-from="' + fi + '" data-to="' + ti + '"/>';
490
+ });
491
+ svg.innerHTML = defs + edgeSvg;
492
+ }
493
+ renderEdges();
494
+
495
+ // DOM nodes
496
+ var maxClaims = Math.max.apply(null, sprints.map(function(s) { return s.claimCount; }).concat([1]));
497
+ var nodeEls = [];
498
+ nodes.forEach(function(n, i) {
499
+ var s = n.sprint;
500
+ var st = statusByPath[s.path] || {};
501
+ var name = shortName(s.path);
502
+ var phase = (s.phase || 'unknown').toLowerCase();
503
+ var cycle = isInCycle(s.path) ? ' in-cycle' : '';
504
+ var pct = (s.claimCount / maxClaims) * 100;
505
+ var barColor = phase === 'archived' ? 'var(--txt-d)' : phase === 'research' ? 'var(--acc)' :
506
+ phase === 'prototype' ? 'var(--grn)' : phase === 'evaluate' ? 'var(--ylw)' : 'var(--pur)';
507
+ var div = document.createElement('div');
508
+ div.className = 'g-node' + cycle;
509
+ div.setAttribute('data-idx', i);
510
+ div.style.left = Math.round(n.x) + 'px';
511
+ div.style.top = Math.round(n.y) + 'px';
512
+ div.title = (s.question || '').replace(/"/g, '&quot;');
513
+ div.innerHTML = '<div class="n-name">' + name + '</div>' +
514
+ '<div class="n-meta"><span class="' + phaseClass(phase) + '">' + s.phase + '</span><span>' + s.claimCount + '</span></div>' +
515
+ '<div class="n-bar"><div class="n-bar-fill" style="width:' + pct + '%;background:' + barColor + '"></div></div>';
516
+ canvas.appendChild(div);
517
+ nodeEls.push(div);
518
+ });
519
+
520
+ // --- DRAG TO MOVE NODES ---
521
+ var _dragIdx = -1, _dragOX = 0, _dragOY = 0, _didDrag = false;
522
+
523
+ canvas.addEventListener('mousedown', function(ev) {
524
+ var el = ev.target.closest('.g-node');
525
+ if (!el) return;
526
+ _dragIdx = parseInt(el.getAttribute('data-idx'));
527
+ var r = graphWrap.getBoundingClientRect();
528
+ _dragOX = ev.clientX - nodes[_dragIdx].x - r.left;
529
+ _dragOY = ev.clientY - nodes[_dragIdx].y - r.top;
530
+ _didDrag = false;
531
+ el.style.zIndex = 10;
532
+ el.style.cursor = 'grabbing';
533
+ ev.preventDefault();
534
+ });
535
+
536
+ document.addEventListener('mousemove', function(ev) {
537
+ if (_dragIdx < 0) return;
538
+ _didDrag = true;
539
+ var r = graphWrap.getBoundingClientRect();
540
+ var nx = ev.clientX - _dragOX - r.left;
541
+ var ny = ev.clientY - _dragOY - r.top;
542
+ nx = Math.max(0, Math.min(GW - NW, nx));
543
+ ny = Math.max(0, Math.min(GH - NH, ny));
544
+ nodes[_dragIdx].x = nx;
545
+ nodes[_dragIdx].y = ny;
546
+ nodeEls[_dragIdx].style.left = Math.round(nx) + 'px';
547
+ nodeEls[_dragIdx].style.top = Math.round(ny) + 'px';
548
+ renderEdges();
549
+ });
550
+
551
+ document.addEventListener('mouseup', function(ev) {
552
+ if (_dragIdx < 0) return;
553
+ nodeEls[_dragIdx].style.zIndex = 2;
554
+ nodeEls[_dragIdx].style.cursor = 'pointer';
555
+ if (!_didDrag) selectSprint(_dragIdx);
556
+ _dragIdx = -1;
557
+ });
558
+
559
+ // Touch events for mobile drag
560
+ canvas.addEventListener('touchstart', function(ev) {
561
+ var touch = ev.touches[0];
562
+ var el = document.elementFromPoint(touch.clientX, touch.clientY);
563
+ if (el) el = el.closest('.g-node');
564
+ if (!el) return;
565
+ _dragIdx = parseInt(el.getAttribute('data-idx'));
566
+ var r = graphWrap.getBoundingClientRect();
567
+ _dragOX = touch.clientX - nodes[_dragIdx].x - r.left;
568
+ _dragOY = touch.clientY - nodes[_dragIdx].y - r.top;
569
+ _didDrag = false;
570
+ el.style.zIndex = 10;
571
+ }, { passive: true });
572
+
573
+ canvas.addEventListener('touchmove', function(ev) {
574
+ if (_dragIdx < 0) return;
575
+ ev.preventDefault();
576
+ _didDrag = true;
577
+ var touch = ev.touches[0];
578
+ var r = graphWrap.getBoundingClientRect();
579
+ var nx = touch.clientX - _dragOX - r.left;
580
+ var ny = touch.clientY - _dragOY - r.top;
581
+ nx = Math.max(0, Math.min(GW - NW, nx));
582
+ ny = Math.max(0, Math.min(GH - NH, ny));
583
+ nodes[_dragIdx].x = nx;
584
+ nodes[_dragIdx].y = ny;
585
+ nodeEls[_dragIdx].style.left = Math.round(nx) + 'px';
586
+ nodeEls[_dragIdx].style.top = Math.round(ny) + 'px';
587
+ renderEdges();
588
+ }, { passive: false });
589
+
590
+ canvas.addEventListener('touchend', function(ev) {
591
+ if (_dragIdx < 0) return;
592
+ nodeEls[_dragIdx].style.zIndex = 2;
593
+ if (!_didDrag) selectSprint(_dragIdx);
594
+ _dragIdx = -1;
595
+ });
596
+
597
+ // --- DETAIL PANEL ---
598
+ var selectedIdx = -1;
599
+
600
+ function selectSprint(idx) {
601
+ var panel = document.getElementById('detail');
602
+ if (selectedIdx === idx) { panel.classList.remove('open'); selectedIdx = -1; clearSelection(); sprintSel.value = '-1'; return; }
603
+ selectedIdx = idx;
604
+ sprintSel.value = idx;
605
+
606
+ // Highlight node
607
+ clearSelection();
608
+ var nodeEl = document.querySelector('.g-node[data-idx="' + idx + '"]');
609
+ if (nodeEl) nodeEl.classList.add('selected');
610
+
611
+ var s = sprints[idx];
612
+ var st = statusByPath[s.path] || {};
613
+ var name = shortName(s.path);
614
+ var inEdges = edges.filter(function(e) { return e.to === s.path; });
615
+ var outEdges = edges.filter(function(e) { return e.from === s.path; });
616
+
617
+ var html = '<div class="detail-header">' +
618
+ '<h2>' + name + '</h2>' +
619
+ '<span class="' + phaseClass(s.phase) + '">' + s.phase + '</span>' +
620
+ '<button class="detail-close" onclick="closeSprint()" aria-label="Close detail">&times;</button>' +
621
+ '</div>';
622
+
623
+ html += '<div class="detail-grid">' +
624
+ '<div class="detail-stat"><div class="dl">Claims</div><div class="dv">' + s.claimCount + '</div></div>' +
625
+ '<div class="detail-stat"><div class="dl">Active / Resolved / Superseded</div><div class="dv" style="color:var(--grn)">' +
626
+ (st.active || 0) + '<span style="color:var(--txt-d)"> / </span><span style="color:var(--txt-m)">' +
627
+ (st.resolved || 0) + '</span><span style="color:var(--txt-d)"> / </span><span style="color:var(--txt-d)">' +
628
+ (st.superseded || 0) + '</span></div></div>' +
629
+ '<div class="detail-stat"><div class="dl">Initiated</div><div class="dv">' + (s.initiated || '--') + '</div></div>' +
630
+ '<div class="detail-stat"><div class="dl">Days Since Update</div><div class="dv">' +
631
+ (st.daysSinceUpdate != null ? st.daysSinceUpdate + 'd' : '--') + '</div></div>' +
632
+ '<div class="detail-stat"><div class="dl">Topics</div><div class="dv">' + (s.topics || []).length + '</div></div>' +
633
+ '<div class="detail-stat"><div class="dl">Inbound / Outbound Edges</div><div class="dv">' + inEdges.length + ' in / ' + outEdges.length + ' out</div></div>' +
634
+ '</div>';
635
+
636
+ if (s.question) {
637
+ html += '<div class="detail-question">' + s.question + '</div>';
638
+ }
639
+
640
+ if (inEdges.length + outEdges.length > 0) {
641
+ html += '<div class="detail-edges"><div style="font-size:10px;font-weight:600;text-transform:uppercase;color:var(--txt-d);margin-bottom:6px">Connections</div>';
642
+ inEdges.forEach(function(e) {
643
+ html += '<div class="detail-edge"><span class="e-badge e-' + e.type + '">' + e.type + '</span>' +
644
+ '<span style="color:var(--txt-m)">' + shortName(e.from) + '</span>' +
645
+ '<span style="color:var(--txt-d)">--> ' + name + '</span>' +
646
+ '<span style="color:var(--txt-m);font-size:9px">x' + e.count + '</span></div>';
647
+ });
648
+ outEdges.forEach(function(e) {
649
+ html += '<div class="detail-edge"><span class="e-badge e-' + e.type + '">' + e.type + '</span>' +
650
+ '<span style="color:var(--txt)">' + name + '</span>' +
651
+ '<span style="color:var(--txt-m)">--> </span>' +
652
+ '<span style="color:var(--txt-m)">' + shortName(e.to) + '</span>' +
653
+ '<span style="color:var(--txt-m);font-size:9px">x' + e.count + '</span></div>';
654
+ });
655
+ html += '</div>';
656
+ }
657
+
658
+ panel.innerHTML = html;
659
+ panel.classList.add('open');
660
+ panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
661
+ }
662
+
663
+ function closeSprint() {
664
+ document.getElementById('detail').classList.remove('open');
665
+ clearSelection();
666
+ selectedIdx = -1;
667
+ sprintSel.value = '-1';
668
+ }
669
+
670
+ function clearSelection() {
671
+ document.querySelectorAll('.g-node.selected').forEach(function(el) { el.classList.remove('selected'); });
672
+ }
673
+
674
+ // --- SPRINT CARDS ---
675
+ var phaseFilter = '__active__';
676
+ var allPhases = [];
677
+ var seenPhases = {};
678
+ statuses.forEach(function(s) {
679
+ var p = (s.phase || 'unknown').toLowerCase();
680
+ if (!seenPhases[p]) { allPhases.push(p); seenPhases[p] = true; }
681
+ });
682
+
683
+ document.getElementById('filter-row').innerHTML =
684
+ '<span class="label">Filter</span>' +
685
+ '<button class="f-btn active" data-filter="__active__" onclick="filterPhase(\'__active__\',this)">Active</button>' +
686
+ '<button class="f-btn" data-filter="__all__" onclick="filterPhase(null,this)">All</button>' +
687
+ allPhases.map(function(p) {
688
+ return '<button class="f-btn" data-filter="' + p + '" onclick="filterPhase(\'' + p + '\',this)">' +
689
+ '<span class="' + phaseClass(p) + '" style="margin-right:4px">' + p + '</span>' +
690
+ phases[p] + '</button>';
691
+ }).join('');
692
+
693
+ function filterPhase(p, btn) {
694
+ phaseFilter = p;
695
+ document.querySelectorAll('.filter-row .f-btn').forEach(function(b) { b.classList.remove('active'); });
696
+ if (btn) btn.classList.add('active');
697
+ renderCards();
698
+ }
699
+
700
+ function renderCards() {
701
+ var sorted = statuses.slice().sort(function(a, b) {
702
+ var da = a.daysSinceUpdate != null ? a.daysSinceUpdate : -1;
703
+ var db = b.daysSinceUpdate != null ? b.daysSinceUpdate : -1;
704
+ return db - da;
705
+ });
706
+
707
+ if (phaseFilter === '__active__') {
708
+ sorted = sorted.filter(function(s) { return (s.phase || 'unknown').toLowerCase() !== 'archived'; });
709
+ } else if (phaseFilter) {
710
+ sorted = sorted.filter(function(s) { return (s.phase || 'unknown').toLowerCase() === phaseFilter; });
711
+ }
712
+
713
+ document.getElementById('cards-count').textContent = sorted.length + ' sprints';
714
+
715
+ document.getElementById('sprint-cards').innerHTML = sorted.map(function(s) {
716
+ var name = shortName(s.path);
717
+ var phase = (s.phase || 'unknown').toLowerCase();
718
+ var cycle = isInCycle(s.path) ? ' in-cycle' : '';
719
+ var si = pathIdx[s.path];
720
+ var activeW = s.total > 0 ? (s.active / s.total * 100) : 0;
721
+ var resolvedW = s.total > 0 ? (s.resolved / s.total * 100) : 0;
722
+ var supersededW = s.total > 0 ? (s.superseded / s.total * 100) : 0;
723
+ var daysStr = s.daysSinceUpdate != null ? s.daysSinceUpdate + 'd ago' : '';
724
+ var topics = (s.topTopics || []).slice(0, 3).map(function(t) { return t.topic.replace(/-/g, ' '); }).join(', ');
725
+
726
+ return '<div class="s-card' + cycle + '" tabindex="0" role="button" onclick="selectSprint(' + si + ')" onkeydown="if(event.key===\'Enter\')selectSprint(' + si + ')">' +
727
+ '<div class="s-card-name"><span class="' + phaseClass(phase) + '">' + s.phase + '</span>' + name + '</div>' +
728
+ '<div class="s-card-question">' + (s.question || '') + '</div>' +
729
+ '<div class="s-card-stats">' +
730
+ '<span><strong>' + s.total + '</strong> claims</span>' +
731
+ '<span style="color:var(--grn)">' + s.active + ' active</span>' +
732
+ (daysStr ? '<span>' + daysStr + '</span>' : '') +
733
+ '</div>' +
734
+ '<div class="s-card-bar">' +
735
+ '<span style="width:' + activeW + '%;background:var(--grn)"></span>' +
736
+ '<span style="width:' + resolvedW + '%;background:var(--acc)"></span>' +
737
+ '<span style="width:' + supersededW + '%;background:var(--txt-d)"></span>' +
738
+ '</div>' +
739
+ (topics ? '<div style="font-size:9px;color:var(--txt-d);margin-top:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + topics + '</div>' : '') +
740
+ '</div>';
741
+ }).join('');
742
+ }
743
+
744
+ renderCards();
745
+
746
+ // (edges and footer removed -- data visible in graph and hero)
747
+
748
+ // Collapsible
749
+ function toggle(id) {
750
+ var el = document.getElementById(id);
751
+ el.classList.toggle('open');
752
+ el.querySelector('.collapsible-header').setAttribute('aria-expanded', el.classList.contains('open'));
753
+ }
754
+ function toggleKey(e, id) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(id); } }
755
+
756
+ // ── Grain Logo Animation ──
757
+ (function() {
758
+ var LW = 0.025;
759
+ var TOOL = { name: 'Orchard', letter: 'O', color: '#d4a574' };
760
+ var _c, _ctx, _s, _cx, _textStart, _restText, _font;
761
+ var _state = 'drawon', _start = null, _raf;
762
+ var _openPts = null, _closedPts = null;
763
+
764
+ function _lerp(a,b,t){ return {x:a.x+(b.x-a.x)*t, y:a.y+(b.y-a.y)*t}; }
765
+ function _easeInOut(t){ return t<0.5 ? 2*t*t : 1-Math.pow(-2*t+2,2)/2; }
766
+
767
+ function _bracket(ctx, s, color, alpha) {
768
+ var lw=s*LW, cx=_cx, cy=s/2, gw=s*0.72, gh=s*0.68;
769
+ var topY=cy-gh/2, botY=cy+gh/2, fe=gw*0.30;
770
+ if(alpha!==undefined) ctx.globalAlpha=alpha;
771
+ ctx.strokeStyle=color; ctx.lineWidth=lw; ctx.lineCap='round'; ctx.lineJoin='round';
772
+ ctx.beginPath(); ctx.moveTo(cx,topY); ctx.lineTo(cx-fe,topY);
773
+ ctx.bezierCurveTo(cx-gw*0.52,cy-gh*0.32, cx-gw*0.52,cy+gh*0.24, cx-fe,botY);
774
+ ctx.lineTo(cx,botY); ctx.stroke();
775
+ if(alpha!==undefined) ctx.globalAlpha=1;
776
+ }
777
+
778
+ function _drawBracket(ctx, s, color, progress) {
779
+ var lw=s*LW, cx=_cx, cy=s/2, gw=s*0.72, gh=s*0.68;
780
+ var topY=cy-gh/2, botY=cy+gh/2, fe=gw*0.30;
781
+ ctx.strokeStyle=color; ctx.lineWidth=lw; ctx.lineCap='round'; ctx.lineJoin='round';
782
+ var seg1=0.12, seg2=0.72;
783
+ ctx.beginPath();
784
+ if(progress<=seg1){ctx.moveTo(cx,topY);ctx.lineTo(cx-fe*(progress/seg1),topY);}
785
+ else if(progress<=seg1+seg2){ctx.moveTo(cx,topY);ctx.lineTo(cx-fe,topY);ctx.stroke();ctx.beginPath();
786
+ var bt=(progress-seg1)/seg2;
787
+ var p0={x:cx-fe,y:topY},p1={x:cx-gw*0.52,y:cy-gh*0.32},p2={x:cx-gw*0.52,y:cy+gh*0.24},p3={x:cx-fe,y:botY};
788
+ var q1=_lerp(p0,p1,bt),q2=_lerp(p1,p2,bt),q3=_lerp(p2,p3,bt);
789
+ var r1=_lerp(q1,q2,bt),r2=_lerp(q2,q3,bt),s1=_lerp(r1,r2,bt);
790
+ ctx.moveTo(p0.x,p0.y);ctx.bezierCurveTo(q1.x,q1.y,r1.x,r1.y,s1.x,s1.y);}
791
+ else{ctx.moveTo(cx,topY);ctx.lineTo(cx-fe,topY);
792
+ ctx.bezierCurveTo(cx-gw*0.52,cy-gh*0.32, cx-gw*0.52,cy+gh*0.24, cx-fe,botY);
793
+ ctx.lineTo((cx-fe)+fe*((progress-seg1-seg2)/(1-seg1-seg2)),botY);}
794
+ ctx.stroke();
795
+ }
796
+
797
+ function _drawName(ctx, s, spellP, alpha) {
798
+ var a = alpha !== undefined ? alpha : 1;
799
+ ctx.font = _font; ctx.textBaseline = 'middle';
800
+ var cy = s/2 + s*0.02;
801
+ ctx.globalAlpha = a; ctx.fillStyle = TOOL.color; ctx.textAlign = 'center';
802
+ ctx.fillText(TOOL.letter, _cx, cy);
803
+ if(_restText.length > 0 && spellP > 0) {
804
+ var n = _restText.length, num = Math.min(n, Math.ceil(spellP * n));
805
+ var rawP = spellP * n, charP = num >= n ? 1 : rawP - Math.floor(rawP);
806
+ var full = charP >= 1 ? num : num - 1;
807
+ ctx.fillStyle = '#e2e8f0'; ctx.textAlign = 'left';
808
+ if(full > 0) { ctx.globalAlpha = a; ctx.fillText(_restText.slice(0, full), _textStart, cy); }
809
+ if(full < num) {
810
+ var prevW = full > 0 ? ctx.measureText(_restText.slice(0, full)).width : 0;
811
+ ctx.globalAlpha = a * (0.3 + 0.7 * charP);
812
+ ctx.fillText(_restText[full], _textStart + prevW, cy);
813
+ }
814
+ }
815
+ ctx.globalAlpha = 1;
816
+ }
817
+
818
+ function _getOpenPts(s) {
819
+ if(_openPts && _openPts._s === s) return _openPts;
820
+ var cx=_cx, cy=s/2, gw=s*0.72, gh=s*0.68, topY=cy-gh/2, botY=cy+gh/2, fe=gw*0.30;
821
+ var pts=[];
822
+ for(var t=0;t<=1;t+=0.05) pts.push({x:cx-fe*t,y:topY});
823
+ var p0={x:cx-fe,y:topY},p1={x:cx-gw*0.52,y:cy-gh*0.32},p2={x:cx-gw*0.52,y:cy+gh*0.24},p3={x:cx-fe,y:botY};
824
+ for(var t=0;t<=1;t+=0.02){var u=1-t;pts.push({x:u*u*u*p0.x+3*u*u*t*p1.x+3*u*t*t*p2.x+t*t*t*p3.x,y:u*u*u*p0.y+3*u*u*t*p1.y+3*u*t*t*p2.y+t*t*t*p3.y});}
825
+ for(var t=0;t<=1;t+=0.05) pts.push({x:(cx-fe)+fe*t,y:botY});
826
+ pts._s=s; _openPts=pts; return pts;
827
+ }
828
+
829
+ function _getClosedPts(s) {
830
+ if(_closedPts && _closedPts._s === s) return _closedPts;
831
+ var cx=_cx, cy=s/2, gw=s*0.72, gh=s*0.68, topY=cy-gh/2, botY=cy+gh/2, fe=gw*0.30;
832
+ var pts=[];
833
+ for(var t=0;t<=1;t+=0.03) pts.push({x:cx-fe*t,y:topY});
834
+ var lp0={x:cx-fe,y:topY},lp1={x:cx-gw*0.52,y:cy-gh*0.32},lp2={x:cx-gw*0.52,y:cy+gh*0.24},lp3={x:cx-fe,y:botY};
835
+ for(var t=0;t<=1;t+=0.02){var u=1-t;pts.push({x:u*u*u*lp0.x+3*u*u*t*lp1.x+3*u*t*t*lp2.x+t*t*t*lp3.x,y:u*u*u*lp0.y+3*u*u*t*lp1.y+3*u*t*t*lp2.y+t*t*t*lp3.y});}
836
+ for(var t=0;t<=1;t+=0.03) pts.push({x:(cx-fe)+2*fe*t,y:botY});
837
+ var rp0={x:cx+fe,y:botY},rp1={x:cx+gw*0.52,y:cy+gh*0.24},rp2={x:cx+gw*0.52,y:cy-gh*0.32},rp3={x:cx+fe,y:topY};
838
+ for(var t=0;t<=1;t+=0.02){var u=1-t;pts.push({x:u*u*u*rp0.x+3*u*u*t*rp1.x+3*u*t*t*rp2.x+t*t*t*rp3.x,y:u*u*u*rp0.y+3*u*u*t*rp1.y+3*u*t*t*rp2.y+t*t*t*rp3.y});}
839
+ for(var t=0;t<=1;t+=0.03) pts.push({x:(cx+fe)-fe*t,y:topY});
840
+ pts._s=s; _closedPts=pts; return pts;
841
+ }
842
+
843
+ function _frame(ts) {
844
+ if(!_c) return;
845
+ if(!_start) _start = ts;
846
+ var e = ts - _start, ctx = _ctx, s = _s;
847
+ ctx.clearRect(0, 0, _c.width, s);
848
+ switch(_state) {
849
+ case 'drawon':
850
+ var bp = _easeInOut(Math.min(1, e / 1400));
851
+ _drawBracket(ctx, s, TOOL.color, bp);
852
+ var la = Math.max(0, Math.min(1, (e - 900) / 400));
853
+ if(la > 0) {
854
+ ctx.font = _font; ctx.textBaseline = 'middle';
855
+ ctx.globalAlpha = la; ctx.fillStyle = TOOL.color; ctx.textAlign = 'center';
856
+ ctx.fillText(TOOL.letter, _cx, s/2 + s*0.02); ctx.globalAlpha = 1;
857
+ }
858
+ if(e > 1300 && _restText.length > 0) {
859
+ var sp = Math.min(1, (e - 1300) / (200 * _restText.length));
860
+ var n = _restText.length, num = Math.min(n, Math.ceil(sp * n));
861
+ if(num > 0) {
862
+ ctx.font = _font; ctx.textBaseline = 'middle';
863
+ var cy = s/2 + s*0.02, rawP = sp * n;
864
+ var charP = num >= n ? 1 : rawP - Math.floor(rawP);
865
+ var full = charP >= 1 ? num : num - 1;
866
+ ctx.fillStyle = '#e2e8f0'; ctx.textAlign = 'left';
867
+ if(full > 0) ctx.fillText(_restText.slice(0, full), _textStart, cy);
868
+ if(full < num) {
869
+ var prevW = full > 0 ? ctx.measureText(_restText.slice(0, full)).width : 0;
870
+ ctx.globalAlpha = 0.3 + 0.7 * charP;
871
+ ctx.fillText(_restText[full], _textStart + prevW, cy); ctx.globalAlpha = 1;
872
+ }
873
+ }
874
+ }
875
+ if(e > 1300 + 200 * _restText.length + 400) { _state = 'idle'; _start = ts; }
876
+ break;
877
+ case 'idle':
878
+ var breathe = 0.5 + 0.5 * (0.5 + 0.5 * Math.sin(e / 1200));
879
+ _bracket(ctx, s, TOOL.color, breathe);
880
+ var textBreath = 0.88 + 0.12 * (0.5 + 0.5 * Math.sin(e / 1800));
881
+ _drawName(ctx, s, 1, textBreath);
882
+ break;
883
+ case 'shimmer':
884
+ _bracket(ctx, s, TOOL.color, 0.2);
885
+ var spts = _getOpenPts(s), sspeed = 1800;
886
+ var spos = (e % sspeed) / sspeed;
887
+ var sidx = Math.floor(spos * (spts.length - 1));
888
+ var spt = spts[sidx];
889
+ var sgrad = ctx.createRadialGradient(spt.x, spt.y, 0, spt.x, spt.y, s * 0.10);
890
+ sgrad.addColorStop(0, TOOL.color + 'aa'); sgrad.addColorStop(1, 'transparent');
891
+ ctx.fillStyle = sgrad; ctx.fillRect(0, 0, _c.width, s);
892
+ var strailFrac = 0.18;
893
+ var si0 = Math.max(0, Math.floor((spos - strailFrac) * (spts.length - 1)));
894
+ ctx.strokeStyle = TOOL.color; ctx.lineWidth = s * LW; ctx.lineCap = 'round';
895
+ ctx.globalAlpha = 0.85; ctx.beginPath(); ctx.moveTo(spts[si0].x, spts[si0].y);
896
+ for(var si = si0 + 1; si <= sidx; si++) ctx.lineTo(spts[si].x, spts[si].y);
897
+ ctx.stroke(); ctx.globalAlpha = 1;
898
+ _drawName(ctx, s, 1, undefined);
899
+ break;
900
+ case 'orbit':
901
+ _bracket(ctx, s, TOOL.color, 0.15);
902
+ _drawName(ctx, s, 1, 0.4);
903
+ var pts = _getOpenPts(s), speed = 1200, trailFrac = 0.28;
904
+ var halfCycle = (e % speed) / speed;
905
+ var cycle = (e % (speed * 2)) / (speed * 2);
906
+ var pos = cycle < 0.5 ? halfCycle : 1 - halfCycle;
907
+ var headIdx = Math.floor(pos * (pts.length - 1));
908
+ var trailLen = Math.floor(trailFrac * pts.length);
909
+ var dir = cycle < 0.5 ? 1 : -1;
910
+ ctx.lineWidth = s * LW; ctx.lineCap = 'round';
911
+ for(var i = 0; i < trailLen; i++) {
912
+ var idx = headIdx - dir * (trailLen - i);
913
+ if(idx < 0 || idx >= pts.length) continue;
914
+ var nxt = idx + dir;
915
+ if(nxt < 0 || nxt >= pts.length) continue;
916
+ ctx.globalAlpha = (i / trailLen) * 0.7; ctx.strokeStyle = TOOL.color;
917
+ ctx.beginPath(); ctx.moveTo(pts[idx].x, pts[idx].y); ctx.lineTo(pts[nxt].x, pts[nxt].y); ctx.stroke();
918
+ }
919
+ ctx.globalAlpha = 1;
920
+ break;
921
+ case 'dim':
922
+ var dim = 0.1 + 0.08 * Math.sin(e / 2000);
923
+ _bracket(ctx, s, TOOL.color, dim);
924
+ _drawName(ctx, s, 1, 0.2);
925
+ break;
926
+ }
927
+ // Animate idle canvases
928
+ var idles = document.querySelectorAll('.grain-idle');
929
+ for(var i = 0; i < idles.length; i++) {
930
+ var ic = idles[i], ictx = ic.getContext('2d'), is = ic.width;
931
+ ictx.clearRect(0, 0, is, is);
932
+ var off = i * 400, cx = is/2;
933
+ var b = 0.25 + 0.35 * (0.5 + 0.5 * Math.sin((ts + off) / 1500));
934
+ var lw=is*LW, icy=is/2, gw=is*0.72, gh=is*0.68, topY=icy-gh/2, botY=icy+gh/2, fe=gw*0.30;
935
+ ictx.globalAlpha=b; ictx.strokeStyle=TOOL.color; ictx.lineWidth=lw; ictx.lineCap='round'; ictx.lineJoin='round';
936
+ ictx.beginPath(); ictx.moveTo(cx,topY); ictx.lineTo(cx-fe,topY);
937
+ ictx.bezierCurveTo(cx-gw*0.52,icy-gh*0.32, cx-gw*0.52,icy+gh*0.24, cx-fe,botY);
938
+ ictx.lineTo(cx,botY); ictx.stroke();
939
+ ictx.globalAlpha = 0.3 + 0.2 * (0.5 + 0.5 * Math.sin((ts + off) / 1500));
940
+ ictx.fillStyle=TOOL.color; ictx.textAlign='center'; ictx.textBaseline='middle';
941
+ ictx.font='800 '+(is*0.38)+'px -apple-system,"SF Pro Display","Helvetica Neue",Arial,sans-serif';
942
+ ictx.fillText(TOOL.letter, cx, icy+is*0.02); ictx.globalAlpha=1;
943
+ }
944
+ _raf = requestAnimationFrame(_frame);
945
+ }
946
+
947
+ // Init: measure text, size canvas, start animation
948
+ _c = document.getElementById('grainLogo');
949
+ if(_c) {
950
+ _c.style.width = '0px';
951
+ _s = 256;
952
+ var fontRatio = 0.38;
953
+ var dh = 64;
954
+ _c.height = _s; _c.width = 1024;
955
+ _ctx = _c.getContext('2d');
956
+ _cx = _s / 2;
957
+ _restText = TOOL.name.slice(1);
958
+ _font = '800 ' + (_s * fontRatio) + 'px -apple-system,"SF Pro Display","Helvetica Neue",Arial,sans-serif';
959
+ _ctx.font = _font;
960
+ var letterW = _ctx.measureText(TOOL.letter).width;
961
+ var restW = _restText.length > 0 ? _ctx.measureText(_restText).width : 0;
962
+ _textStart = _cx + letterW / 2 + _s * 0.02;
963
+ var totalW = Math.ceil(_textStart + restW + _s * 0.12);
964
+ _c.width = totalW;
965
+ _ctx = _c.getContext('2d');
966
+ _c.style.height = dh + 'px';
967
+ _c.style.width = Math.round(totalW / _s * dh) + 'px';
968
+ _state = 'drawon'; _start = null;
969
+ _raf = requestAnimationFrame(_frame);
970
+ }
971
+
972
+ window._grainSetState = function(state) {
973
+ if(_state === state) return;
974
+ if(_state === 'drawon') { _pendingState = state; return; }
975
+ _state = state; _start = null;
976
+ if(!_raf) _raf = requestAnimationFrame(_frame);
977
+ };
978
+ })();
979
+ </script>
980
+ </body>
981
+ </html>