@grainulation/grainulation 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,1139 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" dir="auto" data-tool="grainulation">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Grainulation</title>
7
+ <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='%239ca3af' font-family='-apple-system,system-ui,sans-serif' font-size='34' font-weight='800'>G</text></svg>">
8
+ <style>
9
+ /* ── Tokens (inline, zero deps) ── */
10
+ :root {
11
+ --bg: #0a0e1a;
12
+ --bg2: #111827;
13
+ --bg3: #1e293b;
14
+ --bg4: #334155;
15
+ --fg: #e2e8f0;
16
+ --fg2: #94a3b8;
17
+ --fg3: #64748b;
18
+ --border: #1e293b;
19
+ --border-subtle: rgba(255,255,255,0.08);
20
+ --green: #34d399;
21
+ --red: #f87171;
22
+ --blue: #60a5fa;
23
+ --orange: #fb923c;
24
+ --purple: #a78bfa;
25
+ --cyan: #22d3ee;
26
+ --accent: #9ca3af;
27
+ --accent-light: #d1d5db;
28
+ --accent-dim: rgba(156,163,175,0.10);
29
+ --accent-border: rgba(156,163,175,0.25);
30
+ --radius: 8px;
31
+ --radius-sm: 4px;
32
+ --radius-lg: 12px;
33
+ --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', sans-serif;
34
+ --font-mono: 'SF Mono', 'Cascadia Code', 'JetBrains Mono', monospace;
35
+ --transition-fast: 0.1s ease;
36
+ --transition-base: 0.15s ease;
37
+ }
38
+
39
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
40
+ html, body { height: 100%; }
41
+
42
+ body {
43
+ font-family: var(--font-sans);
44
+ background: var(--bg);
45
+ color: var(--fg);
46
+ font-size: 13px;
47
+ line-height: 1.5;
48
+ -webkit-font-smoothing: antialiased;
49
+ overflow-x: hidden;
50
+ }
51
+
52
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
53
+ ::-webkit-scrollbar-track { background: transparent; }
54
+ ::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 3px; }
55
+ ::-webkit-scrollbar-thumb:hover { background: var(--fg3); }
56
+
57
+ /* ── Layout ── */
58
+ .app { max-width: 1080px; margin: 0 auto; padding: 24px 24px 0; }
59
+
60
+ /* ── Header ── */
61
+ .header {
62
+ display: flex; align-items: center; padding: 4px 24px; gap: 10px;
63
+ background: rgba(255,255,255,0.08); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
64
+ border-bottom: 1px solid var(--border);
65
+ }
66
+ .header canvas { flex-shrink: 0; }
67
+ .header-spacer { flex: 1; }
68
+ .header-right { display: flex; align-items: center; gap: 8px; }
69
+ .conn-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); display: inline-block; }
70
+ .conn-dot.disconnected { background: var(--red); }
71
+ .reconnect-banner { position:fixed;top:0;left:0;right:0;z-index:9999;padding:8px 16px;background:#92400e;color:#fbbf24;font-size:12px;text-align:center;transform:translateY(-100%);transition:transform .3s;font-family:system-ui,sans-serif }
72
+ .reconnect-banner.visible { transform:translateY(0) }
73
+ .reconnect-banner button { background:none;border:1px solid #fbbf24;color:#fbbf24;padding:2px 10px;border-radius:4px;cursor:pointer;font-size:11px;margin-inline-start:8px }
74
+ .meta-tag {
75
+ font-size: 10px; color: var(--fg3);
76
+ background: var(--bg2); padding: 2px 8px; border-radius: 10px;
77
+ border: 1px solid var(--border);
78
+ }
79
+
80
+ /* ── Global controls ── */
81
+ .global-controls {
82
+ display: flex; align-items: center; justify-content: space-between;
83
+ margin-bottom: 20px; gap: 12px;
84
+ }
85
+ .global-controls-left {
86
+ display: flex; align-items: center; gap: 8px;
87
+ }
88
+ .global-controls-right {
89
+ display: flex; align-items: center; gap: 8px;
90
+ }
91
+ .gc-btn {
92
+ font-size: 11px; font-weight: 600; padding: 6px 16px;
93
+ border-radius: var(--radius-sm); border: 1px solid var(--border);
94
+ background: var(--bg2); color: var(--fg2); cursor: pointer;
95
+ font-family: var(--font-sans); transition: all var(--transition-fast);
96
+ text-transform: uppercase; letter-spacing: 0.5px;
97
+ }
98
+ .gc-btn:hover { background: var(--bg3); color: var(--fg); border-color: var(--accent-border); }
99
+ .gc-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
100
+ .gc-btn:disabled { opacity: 0.4; cursor: not-allowed; }
101
+ .gc-btn-start { border-color: rgba(52,211,153,0.3); color: var(--green); }
102
+ .gc-btn-start:hover { background: rgba(52,211,153,0.08); border-color: rgba(52,211,153,0.5); }
103
+ .gc-btn-stop { border-color: rgba(248,113,113,0.3); color: var(--red); }
104
+ .gc-btn-stop:hover { background: rgba(248,113,113,0.08); border-color: rgba(248,113,113,0.5); }
105
+
106
+ .status-summary {
107
+ font-size: 12px; color: var(--fg2); font-family: var(--font-mono);
108
+ }
109
+ .status-summary .count-running { color: var(--green); font-weight: 600; }
110
+ .status-summary .count-stopped { color: var(--fg3); }
111
+
112
+ /* ── Active sprint ── */
113
+ .sprint-panel {
114
+ background: var(--bg2); border: 1px solid var(--border);
115
+ border-radius: var(--radius-lg); padding: 20px 24px;
116
+ margin-bottom: 24px;
117
+ }
118
+ .sprint-panel.sprint-empty {
119
+ border-left: 3px solid var(--bg4);
120
+ }
121
+ .sprint-header {
122
+ display: flex; align-items: center; justify-content: space-between;
123
+ margin-bottom: 12px;
124
+ }
125
+ .sprint-label {
126
+ font-size: 10px; font-weight: 600; text-transform: uppercase;
127
+ letter-spacing: 0.8px; color: #fbbf24;
128
+ }
129
+ .sprint-count {
130
+ font-size: 10px; color: var(--fg3); font-weight: 500;
131
+ }
132
+ .sprint-panel.sprint-empty .sprint-label { color: var(--fg3); }
133
+ .sprint-card {
134
+ padding: 14px 16px; border-radius: var(--radius);
135
+ background: var(--bg); border: 1px solid var(--border);
136
+ margin-bottom: 8px; transition: border-color 0.15s;
137
+ }
138
+ .sprint-card:last-child { margin-bottom: 0; }
139
+ .sprint-card.sprint-active {
140
+ border-left: 3px solid #fbbf24;
141
+ }
142
+ .sprint-card-header {
143
+ display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
144
+ }
145
+ .sprint-active-badge {
146
+ font-size: 9px; padding: 1px 6px; border-radius: 8px;
147
+ font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px;
148
+ background: rgba(251,191,36,0.12); color: #fbbf24;
149
+ }
150
+ .sprint-phase {
151
+ font-size: 9px; padding: 2px 8px; border-radius: 10px;
152
+ font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px;
153
+ }
154
+ .sprint-question {
155
+ font-size: 13px; font-weight: 600; color: var(--fg);
156
+ margin-bottom: 8px; line-height: 1.4;
157
+ }
158
+ .sprint-stats {
159
+ display: flex; gap: 16px; font-size: 11px; color: var(--fg2);
160
+ }
161
+ .sprint-stat { display: flex; align-items: center; gap: 4px; }
162
+ .sprint-stat-value {
163
+ font-weight: 600; font-family: var(--font-mono); color: var(--fg);
164
+ }
165
+ .sprint-empty-msg {
166
+ font-size: 12px; color: var(--fg3);
167
+ }
168
+ .sprint-sub-header {
169
+ margin-top: 16px; margin-bottom: 8px; padding-top: 12px;
170
+ border-top: 1px solid var(--border);
171
+ }
172
+ .sprint-sub-label {
173
+ font-size: 10px; font-weight: 600; text-transform: uppercase;
174
+ letter-spacing: 0.6px; color: var(--fg3);
175
+ }
176
+ .sprint-example-badge {
177
+ font-size: 9px; padding: 1px 6px; border-radius: 8px;
178
+ font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px;
179
+ background: rgba(100,116,139,0.15); color: #64748b;
180
+ }
181
+
182
+ /* ── Section title ── */
183
+ .section-title {
184
+ font-size: 11px; font-weight: 600; text-transform: uppercase;
185
+ letter-spacing: 0.8px; color: var(--fg3); margin-bottom: 16px;
186
+ }
187
+
188
+ /* ── Tool grid (PM view) ── */
189
+ .tool-grid {
190
+ display: grid; grid-template-columns: repeat(2, 1fr);
191
+ gap: 12px; margin-bottom: 32px;
192
+ }
193
+
194
+ .tool-card {
195
+ background: var(--bg2); border: 1px solid var(--border);
196
+ border-radius: var(--radius); padding: 16px 16px 14px 20px;
197
+ transition: all var(--transition-base);
198
+ position: relative; overflow: hidden;
199
+ }
200
+ .tool-card:hover { border-color: var(--accent-border); transform: translateY(-1px); }
201
+ .tool-card:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
202
+ .tool-card.tool-running { border-color: rgba(52,211,153,0.2); }
203
+
204
+ .tool-card-header {
205
+ display: flex; align-items: center; gap: 10px;
206
+ margin-bottom: 6px;
207
+ }
208
+ .tool-icon {
209
+ width: 28px; height: 28px; border-radius: 6px;
210
+ display: flex; align-items: center; justify-content: center;
211
+ font-size: 13px; font-weight: 800; font-family: var(--font-sans);
212
+ flex-shrink: 0; color: #fff;
213
+ }
214
+ .tool-name { font-weight: 600; font-size: 14px; flex: 1; }
215
+
216
+ /* ── Status indicator (dot + label) ── */
217
+ .tool-status-indicator {
218
+ display: flex; align-items: center; gap: 5px;
219
+ }
220
+ .tool-status-dot {
221
+ width: 8px; height: 8px; border-radius: 50%;
222
+ flex-shrink: 0; transition: background 0.3s;
223
+ }
224
+ .tool-status-dot.running { background: var(--green); box-shadow: 0 0 6px rgba(52,211,153,0.4); }
225
+ .tool-status-dot.stopped { background: var(--red); opacity: 0.6; }
226
+ .tool-status-dot.probing { background: var(--orange); animation: pulse-dot 1s infinite; }
227
+ .tool-status-text {
228
+ font-size: 9px; font-weight: 600; text-transform: uppercase;
229
+ letter-spacing: 0.3px;
230
+ }
231
+ .tool-status-text.running { color: var(--green); }
232
+ .tool-status-text.stopped { color: var(--fg3); }
233
+ .tool-status-text.probing { color: var(--orange); }
234
+
235
+ @keyframes pulse-dot {
236
+ 0%, 100% { opacity: 1; }
237
+ 50% { opacity: 0.3; }
238
+ }
239
+
240
+ .tool-role { font-size: 11px; color: var(--fg2); margin-bottom: 8px; }
241
+
242
+ /* ── Tool metrics row ── */
243
+ .tool-metrics {
244
+ display: flex; align-items: center; gap: 12px;
245
+ font-size: 10px; font-family: var(--font-mono); color: var(--fg3);
246
+ margin-bottom: 10px;
247
+ }
248
+ .tool-metric {
249
+ display: flex; align-items: center; gap: 4px;
250
+ }
251
+ .tool-metric-label { color: var(--fg3); }
252
+ .tool-metric-value { color: var(--fg2); }
253
+ .tool-metric-value.latency-good { color: var(--green); }
254
+ .tool-metric-value.latency-slow { color: var(--orange); }
255
+ .tool-metric-value.latency-bad { color: var(--red); }
256
+
257
+ /* ── Tool footer with actions ── */
258
+ .tool-footer {
259
+ display: flex; align-items: center; justify-content: space-between;
260
+ }
261
+ .tool-version {
262
+ font-size: 10px; font-family: var(--font-mono); color: var(--fg3);
263
+ }
264
+ .tool-method {
265
+ font-size: 9px; color: var(--fg3); background: var(--bg3);
266
+ padding: 1px 6px; border-radius: 8px;
267
+ }
268
+ .tool-actions { display: flex; gap: 6px; margin-inline-start: auto; }
269
+ .tool-btn {
270
+ font-size: 10px; padding: 3px 10px; border-radius: var(--radius-sm);
271
+ border: 1px solid var(--border); background: var(--bg3);
272
+ color: var(--fg2); cursor: pointer; text-decoration: none;
273
+ transition: all var(--transition-fast); font-family: var(--font-sans);
274
+ display: inline-flex; align-items: center; gap: 4px;
275
+ }
276
+ .tool-btn:hover { background: var(--bg4); color: var(--fg); }
277
+ .tool-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
278
+ .tool-btn:disabled { opacity: 0.4; cursor: not-allowed; }
279
+ .tool-btn-start {
280
+ border-color: rgba(52,211,153,0.3); color: var(--green);
281
+ }
282
+ .tool-btn-start:hover { background: rgba(52,211,153,0.1); }
283
+ .tool-btn-stop {
284
+ border-color: rgba(248,113,113,0.3); color: var(--red);
285
+ }
286
+ .tool-btn-stop:hover { background: rgba(248,113,113,0.1); }
287
+ .tool-btn-open {
288
+ background: var(--accent-dim); border-color: var(--accent-border);
289
+ color: var(--fg); text-decoration: none;
290
+ }
291
+ .tool-btn-open:hover { background: rgba(156,163,175,0.2); }
292
+
293
+ /* ── Health checks ── */
294
+ .check-list { display: flex; flex-direction: column; gap: 4px; margin-bottom: 32px; }
295
+ .check-item {
296
+ display: flex; align-items: center; gap: 10px;
297
+ padding: 8px 12px; background: var(--bg2);
298
+ border: 1px solid var(--border); border-radius: var(--radius-sm);
299
+ font-size: 12px;
300
+ }
301
+ .check-icon {
302
+ width: 18px; height: 18px; border-radius: 50%;
303
+ display: flex; align-items: center; justify-content: center;
304
+ font-size: 10px; font-weight: 700; flex-shrink: 0;
305
+ }
306
+ .check-pass .check-icon { background: rgba(52,211,153,0.15); color: var(--green); }
307
+ .check-warning .check-icon { background: rgba(251,146,60,0.15); color: var(--orange); }
308
+ .check-fail .check-icon { background: rgba(248,113,113,0.15); color: var(--red); }
309
+ .check-info .check-icon { background: rgba(156,163,175,0.10); color: var(--fg3); }
310
+ .check-name { font-weight: 500; min-width: 100px; }
311
+ .check-detail { color: var(--fg3); font-family: var(--font-mono); font-size: 11px; }
312
+ .check-cat {
313
+ font-size: 9px; color: var(--fg3); background: var(--bg3);
314
+ padding: 1px 6px; border-radius: 8px; margin-inline-start: auto;
315
+ }
316
+
317
+ /* ── Cross-tool nav ── */
318
+ .nav-grid {
319
+ display: grid; grid-template-columns: repeat(4, 1fr);
320
+ gap: 8px; margin-bottom: 32px;
321
+ }
322
+ .nav-link {
323
+ display: flex; align-items: center; gap: 8px;
324
+ padding: 10px 12px; background: var(--bg2);
325
+ border: 1px solid var(--border); border-radius: var(--radius-sm);
326
+ text-decoration: none; color: var(--fg2);
327
+ font-size: 11px; font-weight: 500;
328
+ transition: all var(--transition-fast);
329
+ }
330
+ .nav-link:hover { background: var(--bg3); color: var(--fg); border-color: var(--accent-border); }
331
+ .nav-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
332
+ .nav-link.nav-offline { opacity: 0.4; pointer-events: none; }
333
+ .nav-dot {
334
+ width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
335
+ }
336
+ .nav-port { font-size: 9px; color: var(--fg3); font-family: var(--font-mono); margin-inline-start: auto; }
337
+
338
+ /* ── Footer ── */
339
+ .footer {
340
+ display: flex; align-items: center; justify-content: space-between;
341
+ padding: 8px 24px; border-top: 1px solid var(--border);
342
+ background: var(--bg2); font-size: 10px; color: var(--fg3);
343
+ margin-top: 32px;
344
+ }
345
+ .footer-links { display: flex; gap: 16px; }
346
+ .footer a { color: var(--fg3); text-decoration: none; transition: color 0.15s; }
347
+ .footer a:hover { color: var(--fg); }
348
+
349
+ /* ── A11y ── */
350
+ .skip-link{position:absolute;top:-40px;inset-inline-start:0;background:var(--accent);color:#000;padding:8px 16px;z-index:10000;font-size:14px;font-weight:600;transition:top .2s}
351
+ .skip-link:focus{top:0}
352
+ .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}
353
+ @media (prefers-reduced-motion: reduce) {
354
+ *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; }
355
+ }
356
+
357
+ /* ── Responsive ── */
358
+ @media (max-width: 768px) {
359
+ .app { padding: 16px 16px 0; }
360
+ .header { padding: 4px 16px; gap: 8px; }
361
+ .tool-grid { grid-template-columns: 1fr; gap: 10px; }
362
+ .tool-card { padding: 14px 14px 12px 16px; }
363
+ .tool-name { font-size: 13px; }
364
+ .tool-metrics { flex-wrap: wrap; gap: 8px; }
365
+ .nav-grid { grid-template-columns: repeat(2, 1fr); gap: 6px; }
366
+ .nav-link { padding: 8px 10px; font-size: 10px; }
367
+ .sprint-panel { padding: 16px; }
368
+ .sprint-stats { flex-wrap: wrap; gap: 8px; }
369
+ .sprint-question { font-size: 12px; }
370
+ .global-controls { flex-wrap: wrap; gap: 8px; }
371
+ .global-controls-right { flex-wrap: wrap; }
372
+ .gc-btn { padding: 5px 12px; font-size: 10px; }
373
+ .check-item { padding: 6px 10px; font-size: 11px; }
374
+ .check-name { min-width: 80px; font-size: 11px; }
375
+ .check-detail { font-size: 10px; }
376
+ .footer { padding: 8px 16px; flex-wrap: wrap; gap: 8px; }
377
+ .footer-links { gap: 10px; }
378
+ .section-title { font-size: 10px; margin-bottom: 12px; }
379
+ }
380
+ @media (max-width: 480px) {
381
+ .app { padding: 12px 12px 0; }
382
+ .header { padding: 4px 12px; }
383
+ .nav-grid { grid-template-columns: 1fr; }
384
+ .sprint-card { padding: 10px 12px; }
385
+ .tool-card-header { gap: 8px; }
386
+ .tool-icon { width: 24px; height: 24px; font-size: 11px; }
387
+ .tool-footer { flex-wrap: wrap; gap: 6px; }
388
+ .footer { padding: 8px 12px; }
389
+ }
390
+ </style>
391
+ </head>
392
+ <body>
393
+ <a href="#main-content" class="skip-link">Skip to main content</a>
394
+ <div id="live-status" aria-live="polite" aria-atomic="true" class="sr-only"></div>
395
+ <div class="reconnect-banner" id="reconnectBanner" role="status" aria-live="polite"></div>
396
+ <header class="header" role="banner">
397
+ <canvas id="grainLogo" width="256" height="256"></canvas>
398
+ <div class="header-spacer"></div>
399
+ <div class="header-right">
400
+ <span class="conn-dot" id="connDot" title="Server connection status"></span>
401
+ </div>
402
+ </header>
403
+ <div class="app">
404
+ <main id="main-content" role="main" aria-label="Grainulation workspace">
405
+ <!-- Global controls -->
406
+ <div class="global-controls">
407
+ <div class="global-controls-left">
408
+ <div class="status-summary" id="statusSummary" aria-live="polite">
409
+ <span class="count-running" id="runningCount">0</span> running
410
+ <span class="count-stopped"> / <span id="stoppedCount">0</span> stopped</span>
411
+ </div>
412
+ </div>
413
+ <div class="global-controls-right">
414
+ <button class="gc-btn gc-btn-start" id="btnStartAll" type="button" title="Start all tools">Start All</button>
415
+ <button class="gc-btn gc-btn-stop" id="btnStopAll" type="button" title="Stop all tools">Stop All</button>
416
+ <button class="gc-btn" id="btnRefresh" type="button" title="Refresh health probes">Refresh</button>
417
+ </div>
418
+ </div>
419
+
420
+ <!-- Active sprint -->
421
+ <section class="sprint-panel sprint-empty" id="sprintPanel" aria-label="Sprints">
422
+ <div class="sprint-header">
423
+ <span class="sprint-label">Active Sprint</span>
424
+ </div>
425
+ <div class="sprint-empty-msg">No active wheat sprint detected.</div>
426
+ </section>
427
+
428
+ <!-- Tool catalog (PM view) -->
429
+ <div class="section-title" id="toolCatalogLabel">Processes</div>
430
+ <div class="tool-grid" id="toolGrid" role="list" aria-labelledby="toolCatalogLabel"></div>
431
+
432
+ <!-- Health checks -->
433
+ <div class="section-title" id="healthLabel">Health Checks</div>
434
+ <div class="check-list" id="checkList" role="list" aria-labelledby="healthLabel"></div>
435
+
436
+ <!-- Cross-tool navigation -->
437
+ <div class="section-title" id="navLabel">Open Tools</div>
438
+ <nav class="nav-grid" id="navGrid" aria-labelledby="navLabel"></nav>
439
+
440
+ </main>
441
+ </div>
442
+ <footer class="footer">
443
+ <span>grainulation v1.0.0 -- @grainulation/grainulation</span>
444
+ <div class="footer-links">
445
+ <a href="http://localhost:9091">wheat</a>
446
+ <a href="http://localhost:9090">farmer</a>
447
+ <a href="http://localhost:9093">barn</a>
448
+ <a href="http://localhost:9094">mill</a>
449
+ <a href="http://localhost:9095">silo</a>
450
+ <a href="http://localhost:9096">harvest</a>
451
+ <a href="http://localhost:9097">orchard</a>
452
+ </div>
453
+ </footer>
454
+
455
+ <script>
456
+ (function() {
457
+ 'use strict';
458
+
459
+ // ── Farmer token (fetched from running farmer server) ──
460
+ var FARMER_TOKEN = '';
461
+ function toolUrl(name, port) {
462
+ var base = 'http://localhost:' + port;
463
+ return (name === 'farmer' && FARMER_TOKEN) ? base + '?token=' + FARMER_TOKEN : base;
464
+ }
465
+ // Fetch token from farmer's localhost-only endpoint
466
+ fetch('http://localhost:9090/api/local/token').then(function(r) {
467
+ return r.ok ? r.json() : null;
468
+ }).then(function(d) {
469
+ if (d && d.token) FARMER_TOKEN = d.token;
470
+ }).catch(function() {});
471
+
472
+ // ── Tool metadata ──
473
+ var TOOLS = [
474
+ { name: 'wheat', port: 9091, accent: '#fbbf24', icon: 'W', role: 'Research sprint engine', category: 'core' },
475
+ { name: 'farmer', port: 9090, accent: '#3b82f6', icon: 'F', role: 'Permission dashboard', category: 'core' },
476
+ { name: 'barn', port: 9093, accent: '#f43f5e', icon: 'B', role: 'Design system & templates', category: 'foundation' },
477
+ { name: 'mill', port: 9094, accent: '#a78bfa', icon: 'M', role: 'Export & publish engine', category: 'output' },
478
+ { name: 'silo', port: 9095, accent: '#6ee7b7', icon: 'S', role: 'Reusable claim libraries', category: 'storage' },
479
+ { name: 'harvest', port: 9096, accent: '#fb923c', icon: 'H', role: 'Analytics & retrospectives', category: 'analytics' },
480
+ { name: 'orchard', port: 9097, accent: '#14b8a6', icon: 'O', role: 'Multi-sprint orchestrator', category: 'orchestration' },
481
+ { name: 'grainulation', port: 9098, accent: '#9ca3af', icon: 'G', role: 'The machine', category: 'meta' }
482
+ ];
483
+
484
+ var SVG_CHECK = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>';
485
+ var SVG_CROSS = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6L6 18M6 6l12 12"/></svg>';
486
+
487
+ // ── State ──
488
+ var serverState = null; // from SSE (ecosystem + doctor)
489
+ var probeState = {}; // client-side health probes: { toolName: { alive, latencyMs, lastProbe } }
490
+ var sprintData = null;
491
+ var sse = null;
492
+ var retryCount = 0;
493
+ var probeInterval = null;
494
+ var actionInFlight = {}; // track pending start/stop actions
495
+
496
+ // ── Helpers ──
497
+ function esc(str) {
498
+ return String(str || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
499
+ }
500
+
501
+ function announce(msg) {
502
+ var ls = document.getElementById('live-status');
503
+ if (ls) ls.textContent = msg;
504
+ }
505
+
506
+ // ── Reconnect banner ──
507
+ function showBanner(count) {
508
+ var b = document.getElementById('reconnectBanner');
509
+ if (count > 5) {
510
+ b.innerHTML = 'Connection lost. <button onclick="location.reload()">Retry now</button>';
511
+ } else if (count > 1) {
512
+ b.textContent = 'Reconnecting (attempt ' + count + ')...';
513
+ } else {
514
+ b.textContent = 'Reconnecting...';
515
+ }
516
+ b.classList.add('visible');
517
+ }
518
+
519
+ function hideBanner() {
520
+ document.getElementById('reconnectBanner').classList.remove('visible');
521
+ }
522
+
523
+ // ── Client-side health probing ──
524
+ // Directly fetch /health on each tool's port from the browser
525
+ function probeTool(tool) {
526
+ var start = performance.now();
527
+ var controller = new AbortController();
528
+ var timeoutId = setTimeout(function() { controller.abort(); }, 2000);
529
+
530
+ return fetch('http://localhost:' + tool.port + '/health', {
531
+ signal: controller.signal,
532
+ mode: 'no-cors' // fallback — opaque response still means port is open
533
+ })
534
+ .then(function(res) {
535
+ clearTimeout(timeoutId);
536
+ var latency = Math.round(performance.now() - start);
537
+ // no-cors gives opaque response (type "opaque", status 0)
538
+ // but if we get here without error, the port is alive
539
+ return { alive: true, latencyMs: latency, status: res.status || 200 };
540
+ })
541
+ .catch(function() {
542
+ clearTimeout(timeoutId);
543
+ return { alive: false, latencyMs: null, status: null };
544
+ });
545
+ }
546
+
547
+ function probeAll() {
548
+ var promises = TOOLS.map(function(tool) {
549
+ return probeTool(tool).then(function(result) {
550
+ probeState[tool.name] = {
551
+ alive: result.alive,
552
+ latencyMs: result.latencyMs,
553
+ status: result.status,
554
+ lastProbe: Date.now()
555
+ };
556
+ });
557
+ });
558
+
559
+ return Promise.all(promises).then(function() {
560
+ render();
561
+ });
562
+ }
563
+
564
+ function startProbing() {
565
+ probeAll();
566
+ if (probeInterval) clearInterval(probeInterval);
567
+ probeInterval = setInterval(probeAll, 5000);
568
+ }
569
+
570
+ // ── SSE connection ──
571
+ function connect() {
572
+ sse = new EventSource('/events');
573
+ document.getElementById('connDot').className = 'conn-dot';
574
+
575
+ sse.onmessage = function(e) {
576
+ try {
577
+ var msg = JSON.parse(e.data);
578
+ if (msg.type === 'state' && msg.data) {
579
+ serverState = msg.data;
580
+ render();
581
+ announce('Ecosystem state refreshed');
582
+ }
583
+ } catch (err) { /* ignore parse errors */ }
584
+ };
585
+
586
+ sse.onopen = function() {
587
+ retryCount = 0;
588
+ document.getElementById('connDot').className = 'conn-dot';
589
+ if (window._grainSetState) window._grainSetState('idle');
590
+ fetchSprint();
591
+ probeAll();
592
+ };
593
+
594
+ sse.onerror = function() {
595
+ document.getElementById('connDot').className = 'conn-dot disconnected';
596
+ if (window._grainSetState) window._grainSetState('orbit');
597
+ sse.close();
598
+ var delay = Math.min(30000, 1000 * Math.pow(2, retryCount)) + Math.random() * 1000;
599
+ retryCount++;
600
+ setTimeout(connect, delay);
601
+ };
602
+ }
603
+
604
+ // ── PM actions (POST to server) ──
605
+ function pmAction(action, toolName) {
606
+ var url = '/api/pm/' + action;
607
+ var body = toolName ? JSON.stringify({ tool: toolName }) : JSON.stringify({ tool: 'all' });
608
+ actionInFlight[toolName || '_all'] = true;
609
+ render();
610
+
611
+ return fetch(url, {
612
+ method: 'POST',
613
+ headers: { 'Content-Type': 'application/json' },
614
+ body: body
615
+ })
616
+ .then(function(res) { return res.json(); })
617
+ .then(function(data) {
618
+ delete actionInFlight[toolName || '_all'];
619
+ // Re-probe after action to pick up new status
620
+ setTimeout(probeAll, 800);
621
+ return data;
622
+ })
623
+ .catch(function(err) {
624
+ delete actionInFlight[toolName || '_all'];
625
+ render();
626
+ return { error: err.message };
627
+ });
628
+ }
629
+
630
+ // ── Sprint detection ──
631
+ var allSprints = [];
632
+ var activeSprint = null;
633
+
634
+ function fetchSprint() {
635
+ var controller = new AbortController();
636
+ var timeoutId = setTimeout(function() { controller.abort(); }, 2000);
637
+
638
+ fetch('http://localhost:9091/api/state', { signal: controller.signal })
639
+ .then(function(r) { return r.ok ? r.json() : null; })
640
+ .then(function(data) {
641
+ clearTimeout(timeoutId);
642
+ if (data && data.sprints && data.sprints.length) {
643
+ allSprints = data.sprints;
644
+ activeSprint = data.activeSprint || allSprints[0];
645
+ renderSprint();
646
+ } else if (data && data.meta && data.meta.question) {
647
+ activeSprint = {
648
+ name: 'current',
649
+ question: data.meta.question,
650
+ phase: data.meta.phase,
651
+ claims_count: (data.claims || []).length,
652
+ active_claims: (data.claims || []).filter(function(c) { return !c.resolved_by; }).length
653
+ };
654
+ allSprints = [activeSprint];
655
+ renderSprint();
656
+ }
657
+ })
658
+ .catch(function() {
659
+ clearTimeout(timeoutId);
660
+ allSprints = [];
661
+ activeSprint = null;
662
+ renderSprint();
663
+ });
664
+ }
665
+
666
+ function phaseColor(phase) {
667
+ var map = { define: '#60a5fa', research: '#fbbf24', prototype: '#a78bfa', evaluate: '#34d399', archived: '#64748b' };
668
+ return map[phase] || '#fbbf24';
669
+ }
670
+
671
+ function sprintCard(s, isActive) {
672
+ var pc = phaseColor(s.phase);
673
+ return '<div class="sprint-card' + (isActive ? ' sprint-active' : '') + '">' +
674
+ '<div class="sprint-card-header">' +
675
+ '<span class="sprint-phase" style="background:' + pc + '1f;color:' + pc + '">' + esc(s.phase) + '</span>' +
676
+ (isActive ? '<span class="sprint-active-badge">active</span>' : '') +
677
+ (s.status === 'example' ? '<span class="sprint-example-badge">example</span>' : '') +
678
+ '</div>' +
679
+ '<div class="sprint-question">' + esc(s.question) + '</div>' +
680
+ '<div class="sprint-stats">' +
681
+ '<span class="sprint-stat"><span class="sprint-stat-value">' + (s.claims_count || 0) + '</span> claims</span>' +
682
+ '<span class="sprint-stat"><span class="sprint-stat-value">' + (s.active_claims || 0) + '</span> active</span>' +
683
+ (s.initiated ? '<span class="sprint-stat">' + esc(s.initiated) + '</span>' : '') +
684
+ '</div>' +
685
+ '</div>';
686
+ }
687
+
688
+ // ── Render sprint panel ──
689
+ function renderSprint() {
690
+ var panel = document.getElementById('sprintPanel');
691
+ if (!allSprints.length) {
692
+ panel.className = 'sprint-panel sprint-empty';
693
+ panel.innerHTML =
694
+ '<div class="sprint-header"><span class="sprint-label">Sprints</span></div>' +
695
+ '<div class="sprint-empty-msg">No wheat sprints detected. Start wheat to begin.</div>';
696
+ return;
697
+ }
698
+
699
+ // Only show active/candidate sprints — drop examples and archived
700
+ var active = [];
701
+ for (var i = 0; i < allSprints.length; i++) {
702
+ var s = allSprints[i];
703
+ if (s.status !== 'example' && s.status !== 'archived') {
704
+ active.push(s);
705
+ }
706
+ }
707
+
708
+ var html = '<div class="sprint-header"><span class="sprint-label">Sprints</span>' +
709
+ (active.length > 1 ? '<span style="font-size:10px;color:var(--fg3);margin-left:auto">' + active.length + ' active</span>' : '') +
710
+ '</div>';
711
+
712
+ if (active.length) {
713
+ for (var j = 0; j < active.length; j++) {
714
+ html += sprintCard(active[j], activeSprint && active[j].name === activeSprint.name);
715
+ }
716
+ } else {
717
+ html += '<div class="sprint-empty-msg">No active sprint.</div>';
718
+ }
719
+
720
+ panel.className = 'sprint-panel';
721
+ panel.innerHTML = html;
722
+ }
723
+
724
+ // ── Latency class ──
725
+ function latencyClass(ms) {
726
+ if (ms == null) return '';
727
+ if (ms < 50) return 'latency-good';
728
+ if (ms < 200) return 'latency-slow';
729
+ return 'latency-bad';
730
+ }
731
+
732
+ // ── Main render ──
733
+ function render() {
734
+ var tools = (serverState && serverState.ecosystem) ? serverState.ecosystem : [];
735
+ var doctor = (serverState && serverState.doctorResults) ? serverState.doctorResults : { checks: [], summary: {} };
736
+
737
+ // Build merged tool data: server state + client probes
738
+ var mergedTools = TOOLS.map(function(t) {
739
+ var serverTool = null;
740
+ for (var i = 0; i < tools.length; i++) {
741
+ if (tools[i].name === t.name) { serverTool = tools[i]; break; }
742
+ }
743
+ var probe = probeState[t.name] || {};
744
+ return {
745
+ name: t.name,
746
+ port: t.port,
747
+ accent: t.accent,
748
+ icon: t.icon,
749
+ role: t.role,
750
+ category: t.category,
751
+ installed: serverTool ? serverTool.installed : false,
752
+ version: serverTool ? serverTool.version : null,
753
+ method: serverTool ? serverTool.method : null,
754
+ alive: probe.alive || false,
755
+ latencyMs: probe.latencyMs || null,
756
+ lastProbe: probe.lastProbe || null,
757
+ inFlight: !!actionInFlight[t.name] || !!actionInFlight['_all']
758
+ };
759
+ });
760
+
761
+ // Counts
762
+ var runningCount = 0;
763
+ var stoppedCount = 0;
764
+ var installedCount = 0;
765
+ for (var i = 0; i < mergedTools.length; i++) {
766
+ if (mergedTools[i].alive) runningCount++;
767
+ else stoppedCount++;
768
+ if (mergedTools[i].installed) installedCount++;
769
+ }
770
+
771
+ document.getElementById('runningCount').textContent = runningCount;
772
+ document.getElementById('stoppedCount').textContent = stoppedCount;
773
+
774
+
775
+ // Tool grid
776
+ document.getElementById('toolGrid').innerHTML = mergedTools.map(function(tl) {
777
+ var isRunning = tl.alive;
778
+ var isSelf = tl.name === 'grainulation';
779
+ var statusDotCls = tl.inFlight ? 'probing' : (isRunning ? 'running' : 'stopped');
780
+ var statusText = tl.inFlight ? 'pending' : (isRunning ? 'running' : 'stopped');
781
+ var cardCls = 'tool-card' + (isRunning ? ' tool-running' : '');
782
+
783
+ // Metrics row (port, latency)
784
+ var metrics = '<div class="tool-metrics">';
785
+ metrics += '<span class="tool-metric"><span class="tool-metric-label">port</span> <span class="tool-metric-value">:' + tl.port + '</span></span>';
786
+ if (isRunning && tl.latencyMs != null) {
787
+ metrics += '<span class="tool-metric"><span class="tool-metric-label">latency</span> <span class="tool-metric-value ' + latencyClass(tl.latencyMs) + '">' + tl.latencyMs + 'ms</span></span>';
788
+ }
789
+ if (tl.lastProbe) {
790
+ var ago = Math.round((Date.now() - tl.lastProbe) / 1000);
791
+ metrics += '<span class="tool-metric"><span class="tool-metric-label">probed</span> <span class="tool-metric-value">' + ago + 's ago</span></span>';
792
+ }
793
+ metrics += '</div>';
794
+
795
+ // Actions
796
+ var actions = '';
797
+ if (isSelf) {
798
+ // Grainulation itself — always running (it's serving this page)
799
+ actions = '<span class="tool-method">self</span>';
800
+ } else if (isRunning) {
801
+ actions =
802
+ '<a class="tool-btn tool-btn-open" href="' + toolUrl(tl.name, tl.port) + '" target="_blank" rel="noopener">Open</a>' +
803
+ '<button class="tool-btn tool-btn-stop" type="button" onclick="pmStop(\'' + tl.name + '\')"' + (tl.inFlight ? ' disabled' : '') + '>Stop</button>';
804
+ } else {
805
+ actions =
806
+ '<button class="tool-btn tool-btn-start" type="button" onclick="pmStart(\'' + tl.name + '\')"' + (tl.inFlight ? ' disabled' : '') + '>Start</button>';
807
+ }
808
+
809
+ return '<div class="' + cardCls + '" role="listitem">' +
810
+ '<div style="position:absolute;top:0;left:0;width:3px;height:100%;background:' + tl.accent + ';border-radius:2px 0 0 2px;" aria-hidden="true"></div>' +
811
+ '<div class="tool-card-header">' +
812
+ '<span class="tool-icon" style="background:' + tl.accent + '" aria-hidden="true">' + tl.icon + '</span>' +
813
+ '<span class="tool-name">' + esc(tl.name) + '</span>' +
814
+ '<span class="tool-status-indicator">' +
815
+ '<span class="tool-status-dot ' + statusDotCls + '" aria-hidden="true"></span>' +
816
+ '<span class="tool-status-text ' + statusDotCls + '">' + statusText + '</span>' +
817
+ '</span>' +
818
+ '</div>' +
819
+ '<div class="tool-role">' + esc(tl.role) + '</div>' +
820
+ metrics +
821
+ '<div class="tool-footer">' +
822
+ '<span class="tool-version">' + (tl.installed ? 'v' + esc(tl.version) : (isSelf ? 'self' : '--')) + '</span>' +
823
+ (tl.method && !isSelf ? '<span class="tool-method">' + esc(tl.method) + '</span>' : '') +
824
+ '<div class="tool-actions">' + actions + '</div>' +
825
+ '</div>' +
826
+ '</div>';
827
+ }).join('');
828
+
829
+ // Doctor checklist
830
+ var checks = doctor.checks || [];
831
+ document.getElementById('checkList').innerHTML = checks.map(function(c) {
832
+ var cls = 'check-' + c.status;
833
+ var icon =
834
+ c.status === 'pass' ? SVG_CHECK :
835
+ c.status === 'warning' ? '!' :
836
+ c.status === 'fail' ? SVG_CROSS : '-';
837
+
838
+ return '<div class="check-item ' + cls + '" role="listitem">' +
839
+ '<span class="check-icon">' + icon + '</span>' +
840
+ '<span class="check-name">' + esc(c.name) + '</span>' +
841
+ '<span class="check-detail">' + esc(c.detail) + '</span>' +
842
+ '<span class="check-cat">' + esc(c.category) + '</span>' +
843
+ '</div>';
844
+ }).join('');
845
+
846
+ // Cross-tool nav — only show tools that are alive (excluding self)
847
+ var navTools = mergedTools.filter(function(tl) { return tl.name !== 'grainulation'; });
848
+ if (navTools.length > 0) {
849
+ document.getElementById('navGrid').innerHTML = navTools.map(function(tl) {
850
+ var isAlive = tl.alive;
851
+ var cls = 'nav-link' + (isAlive ? '' : ' nav-offline');
852
+ var href = isAlive ? toolUrl(tl.name, tl.port) : '#';
853
+ var target = isAlive ? ' target="_blank" rel="noopener"' : '';
854
+ return '<a class="' + cls + '" href="' + href + '"' + target + '>' +
855
+ '<span class="nav-dot" style="background:' + (isAlive ? tl.accent : 'var(--bg4)') + '" aria-hidden="true"></span>' +
856
+ esc(tl.name) +
857
+ '<span class="nav-port">:' + tl.port + '</span>' +
858
+ '</a>';
859
+ }).join('');
860
+ } else {
861
+ document.getElementById('navGrid').innerHTML =
862
+ '<div style="grid-column:1/-1;text-align:center;color:var(--fg3);padding:16px;font-size:12px;">No tools available.</div>';
863
+ }
864
+ }
865
+
866
+ // ── Global action handlers ──
867
+ window.pmStart = function(name) {
868
+ pmAction('start', name);
869
+ };
870
+
871
+ window.pmStop = function(name) {
872
+ pmAction('stop', name);
873
+ };
874
+
875
+ document.getElementById('btnStartAll').addEventListener('click', function() {
876
+ pmAction('up');
877
+ });
878
+
879
+ document.getElementById('btnStopAll').addEventListener('click', function() {
880
+ pmAction('down');
881
+ });
882
+
883
+ document.getElementById('btnRefresh').addEventListener('click', function() {
884
+ probeAll();
885
+ });
886
+
887
+ // ── Keyboard navigation ──
888
+ document.addEventListener('keydown', function(e) {
889
+ if (e.key === 'Escape') {
890
+ window.scrollTo({ top: 0, behavior: 'smooth' });
891
+ }
892
+ // j/k to navigate tool cards
893
+ if (e.target === document.body && (e.key === 'j' || e.key === 'k')) {
894
+ var cards = Array.from(document.querySelectorAll('.tool-card'));
895
+ if (cards.length === 0) return;
896
+ var focused = document.activeElement;
897
+ var idx = cards.indexOf(focused);
898
+ if (e.key === 'j') idx = Math.min(idx + 1, cards.length - 1);
899
+ else idx = Math.max(idx - 1, 0);
900
+ cards[idx].focus();
901
+ }
902
+ // r to refresh probes
903
+ if (e.target === document.body && e.key === 'r') {
904
+ probeAll();
905
+ }
906
+ });
907
+
908
+ // Make tool cards focusable
909
+ var observer = new MutationObserver(function() {
910
+ document.querySelectorAll('.tool-card:not([tabindex])').forEach(function(el) {
911
+ el.setAttribute('tabindex', '0');
912
+ });
913
+ });
914
+ observer.observe(document.getElementById('toolGrid'), { childList: true });
915
+
916
+ // ── Patch footer farmer link with token ──
917
+ if (FARMER_TOKEN) {
918
+ var fLinks = document.querySelectorAll('.footer-links a');
919
+ for (var i = 0; i < fLinks.length; i++) {
920
+ if (fLinks[i].textContent.trim() === 'farmer') {
921
+ fLinks[i].href = toolUrl('farmer', 9090);
922
+ }
923
+ }
924
+ }
925
+
926
+ // ── Init ──
927
+ connect();
928
+ startProbing();
929
+ })();
930
+ </script>
931
+ <script>
932
+ (function() {
933
+ var LW = 0.025;
934
+ var TOOL = { name: 'Grainulation', letter: 'G', color: '#9ca3af' };
935
+ var _c, _ctx, _s, _cx, _textStart, _restText, _font;
936
+ var _state = 'drawon', _start = null, _raf, _pendingState = null;
937
+ var _openPts = null, _closedPts = null;
938
+
939
+ function _lerp(a,b,t){ return {x:a.x+(b.x-a.x)*t, y:a.y+(b.y-a.y)*t}; }
940
+ function _easeInOut(t){ return t<0.5 ? 2*t*t : 1-Math.pow(-2*t+2,2)/2; }
941
+
942
+ function _bracket(ctx, s, color, alpha) {
943
+ var lw=s*LW, cx=_cx, cy=s/2, gw=s*0.72, gh=s*0.68;
944
+ var topY=cy-gh/2, botY=cy+gh/2, fe=gw*0.30;
945
+ if(alpha!==undefined) ctx.globalAlpha=alpha;
946
+ ctx.strokeStyle=color; ctx.lineWidth=lw; ctx.lineCap='round'; ctx.lineJoin='round';
947
+ ctx.beginPath(); ctx.moveTo(cx,topY); ctx.lineTo(cx-fe,topY);
948
+ ctx.bezierCurveTo(cx-gw*0.52,cy-gh*0.32, cx-gw*0.52,cy+gh*0.24, cx-fe,botY);
949
+ ctx.lineTo(cx,botY); ctx.stroke();
950
+ if(alpha!==undefined) ctx.globalAlpha=1;
951
+ }
952
+
953
+ function _drawBracket(ctx, s, color, progress) {
954
+ var lw=s*LW, cx=_cx, cy=s/2, gw=s*0.72, gh=s*0.68;
955
+ var topY=cy-gh/2, botY=cy+gh/2, fe=gw*0.30;
956
+ ctx.strokeStyle=color; ctx.lineWidth=lw; ctx.lineCap='round'; ctx.lineJoin='round';
957
+ var seg1=0.12, seg2=0.72;
958
+ ctx.beginPath();
959
+ if(progress<=seg1){ctx.moveTo(cx,topY);ctx.lineTo(cx-fe*(progress/seg1),topY);}
960
+ else if(progress<=seg1+seg2){ctx.moveTo(cx,topY);ctx.lineTo(cx-fe,topY);ctx.stroke();ctx.beginPath();
961
+ var bt=(progress-seg1)/seg2;
962
+ 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};
963
+ var q1=_lerp(p0,p1,bt),q2=_lerp(p1,p2,bt),q3=_lerp(p2,p3,bt);
964
+ var r1=_lerp(q1,q2,bt),r2=_lerp(q2,q3,bt),s1=_lerp(r1,r2,bt);
965
+ ctx.moveTo(p0.x,p0.y);ctx.bezierCurveTo(q1.x,q1.y,r1.x,r1.y,s1.x,s1.y);}
966
+ else{ctx.moveTo(cx,topY);ctx.lineTo(cx-fe,topY);
967
+ ctx.bezierCurveTo(cx-gw*0.52,cy-gh*0.32, cx-gw*0.52,cy+gh*0.24, cx-fe,botY);
968
+ ctx.lineTo((cx-fe)+fe*((progress-seg1-seg2)/(1-seg1-seg2)),botY);}
969
+ ctx.stroke();
970
+ }
971
+
972
+ function _drawName(ctx, s, spellP, alpha) {
973
+ var a = alpha !== undefined ? alpha : 1;
974
+ ctx.font = _font; ctx.textBaseline = 'middle';
975
+ var cy = s/2 + s*0.02;
976
+ ctx.globalAlpha = a; ctx.fillStyle = TOOL.color; ctx.textAlign = 'center';
977
+ ctx.fillText(TOOL.letter, _cx, cy);
978
+ if(_restText.length > 0 && spellP > 0) {
979
+ var n = _restText.length, num = Math.min(n, Math.ceil(spellP * n));
980
+ var rawP = spellP * n, charP = num >= n ? 1 : rawP - Math.floor(rawP);
981
+ var full = charP >= 1 ? num : num - 1;
982
+ ctx.fillStyle = '#e2e8f0'; ctx.textAlign = 'left';
983
+ if(full > 0) { ctx.globalAlpha = a; ctx.fillText(_restText.slice(0, full), _textStart, cy); }
984
+ if(full < num) {
985
+ var prevW = full > 0 ? ctx.measureText(_restText.slice(0, full)).width : 0;
986
+ ctx.globalAlpha = a * (0.3 + 0.7 * charP);
987
+ ctx.fillText(_restText[full], _textStart + prevW, cy);
988
+ }
989
+ }
990
+ ctx.globalAlpha = 1;
991
+ }
992
+
993
+ function _getOpenPts(s) {
994
+ if(_openPts && _openPts._s === s) return _openPts;
995
+ 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;
996
+ var pts=[];
997
+ for(var t=0;t<=1;t+=0.05) pts.push({x:cx-fe*t,y:topY});
998
+ 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};
999
+ 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});}
1000
+ for(var t=0;t<=1;t+=0.05) pts.push({x:(cx-fe)+fe*t,y:botY});
1001
+ pts._s=s; _openPts=pts; return pts;
1002
+ }
1003
+
1004
+ function _getClosedPts(s) {
1005
+ if(_closedPts && _closedPts._s === s) return _closedPts;
1006
+ 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;
1007
+ var pts=[];
1008
+ for(var t=0;t<=1;t+=0.03) pts.push({x:cx-fe*t,y:topY});
1009
+ 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};
1010
+ 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});}
1011
+ for(var t=0;t<=1;t+=0.03) pts.push({x:(cx-fe)+2*fe*t,y:botY});
1012
+ 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};
1013
+ 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});}
1014
+ for(var t=0;t<=1;t+=0.03) pts.push({x:(cx+fe)-fe*t,y:topY});
1015
+ pts._s=s; _closedPts=pts; return pts;
1016
+ }
1017
+
1018
+ function _frame(ts) {
1019
+ if(!_c) return;
1020
+ if(!_start) _start = ts;
1021
+ var e = ts - _start, ctx = _ctx, s = _s;
1022
+ ctx.clearRect(0, 0, _c.width, s);
1023
+ switch(_state) {
1024
+ case 'drawon':
1025
+ var bp = _easeInOut(Math.min(1, e / 1400));
1026
+ _drawBracket(ctx, s, TOOL.color, bp);
1027
+ var la = Math.max(0, Math.min(1, (e - 900) / 400));
1028
+ if(la > 0) {
1029
+ ctx.font = _font; ctx.textBaseline = 'middle';
1030
+ ctx.globalAlpha = la; ctx.fillStyle = TOOL.color; ctx.textAlign = 'center';
1031
+ ctx.fillText(TOOL.letter, _cx, s/2 + s*0.02); ctx.globalAlpha = 1;
1032
+ }
1033
+ if(e > 1100 && _restText.length > 0) {
1034
+ var sp = Math.min(1, (e - 1100) / (120 * _restText.length));
1035
+ var n = _restText.length, num = Math.min(n, Math.ceil(sp * n));
1036
+ if(num > 0) {
1037
+ ctx.font = _font; ctx.textBaseline = 'middle';
1038
+ var cy = s/2 + s*0.02, rawP = sp * n;
1039
+ var charP = num >= n ? 1 : rawP - Math.floor(rawP);
1040
+ var full = charP >= 1 ? num : num - 1;
1041
+ ctx.fillStyle = '#e2e8f0'; ctx.textAlign = 'left';
1042
+ if(full > 0) ctx.fillText(_restText.slice(0, full), _textStart, cy);
1043
+ if(full < num) {
1044
+ var prevW = full > 0 ? ctx.measureText(_restText.slice(0, full)).width : 0;
1045
+ ctx.globalAlpha = 0.3 + 0.7 * charP;
1046
+ ctx.fillText(_restText[full], _textStart + prevW, cy); ctx.globalAlpha = 1;
1047
+ }
1048
+ }
1049
+ }
1050
+ if(e > 1100 + 120 * _restText.length + 300) { _state = _pendingState || 'idle'; _pendingState = null; _start = ts; }
1051
+ break;
1052
+ case 'idle':
1053
+ var breathe = 0.5 + 0.5 * (0.5 + 0.5 * Math.sin(e / 1200));
1054
+ _bracket(ctx, s, TOOL.color, breathe);
1055
+ var textBreath = 0.88 + 0.12 * (0.5 + 0.5 * Math.sin(e / 1800));
1056
+ _drawName(ctx, s, 1, textBreath);
1057
+ break;
1058
+ case 'shimmer':
1059
+ _bracket(ctx, s, TOOL.color, 0.2);
1060
+ var spts = _getOpenPts(s), sspeed = 1800;
1061
+ var spos = (e % sspeed) / sspeed;
1062
+ var sidx = Math.floor(spos * (spts.length - 1));
1063
+ var spt = spts[sidx];
1064
+ var sgrad = ctx.createRadialGradient(spt.x, spt.y, 0, spt.x, spt.y, s * 0.10);
1065
+ sgrad.addColorStop(0, TOOL.color + 'aa'); sgrad.addColorStop(1, 'transparent');
1066
+ ctx.fillStyle = sgrad; ctx.fillRect(0, 0, _c.width, s);
1067
+ var strailFrac = 0.18;
1068
+ var si0 = Math.max(0, Math.floor((spos - strailFrac) * (spts.length - 1)));
1069
+ ctx.strokeStyle = TOOL.color; ctx.lineWidth = s * LW; ctx.lineCap = 'round';
1070
+ ctx.globalAlpha = 0.85; ctx.beginPath(); ctx.moveTo(spts[si0].x, spts[si0].y);
1071
+ for(var si = si0 + 1; si <= sidx; si++) ctx.lineTo(spts[si].x, spts[si].y);
1072
+ ctx.stroke(); ctx.globalAlpha = 1;
1073
+ _drawName(ctx, s, 1, undefined);
1074
+ break;
1075
+ case 'orbit':
1076
+ _bracket(ctx, s, TOOL.color, 0.15);
1077
+ _drawName(ctx, s, 1, 0.4);
1078
+ var pts = _getOpenPts(s), speed = 1200, trailFrac = 0.28;
1079
+ var halfCycle = (e % speed) / speed;
1080
+ var cycle = (e % (speed * 2)) / (speed * 2);
1081
+ var pos = cycle < 0.5 ? halfCycle : 1 - halfCycle;
1082
+ var headIdx = Math.floor(pos * (pts.length - 1));
1083
+ var trailLen = Math.floor(trailFrac * pts.length);
1084
+ var dir = cycle < 0.5 ? 1 : -1;
1085
+ ctx.lineWidth = s * LW; ctx.lineCap = 'round';
1086
+ for(var i = 0; i < trailLen; i++) {
1087
+ var idx = headIdx - dir * (trailLen - i);
1088
+ if(idx < 0 || idx >= pts.length) continue;
1089
+ var nxt = idx + dir;
1090
+ if(nxt < 0 || nxt >= pts.length) continue;
1091
+ ctx.globalAlpha = (i / trailLen) * 0.7; ctx.strokeStyle = TOOL.color;
1092
+ ctx.beginPath(); ctx.moveTo(pts[idx].x, pts[idx].y); ctx.lineTo(pts[nxt].x, pts[nxt].y); ctx.stroke();
1093
+ }
1094
+ ctx.globalAlpha = 1;
1095
+ break;
1096
+ case 'dim':
1097
+ var dim = 0.1 + 0.08 * Math.sin(e / 2000);
1098
+ _bracket(ctx, s, TOOL.color, dim);
1099
+ _drawName(ctx, s, 1, 0.2);
1100
+ break;
1101
+ }
1102
+ _raf = requestAnimationFrame(_frame);
1103
+ }
1104
+
1105
+ _c = document.getElementById('grainLogo');
1106
+ if(_c) {
1107
+ _c.style.width = '0px';
1108
+ _s = 256;
1109
+ var targetFontPx = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
1110
+ var fontRatio = 0.38;
1111
+ var dh = 64;
1112
+ _c.height = _s; _c.width = 1024;
1113
+ _ctx = _c.getContext('2d');
1114
+ _cx = _s / 2;
1115
+ _restText = TOOL.name.slice(1);
1116
+ _font = '800 ' + (_s * fontRatio) + 'px -apple-system,"SF Pro Display","Helvetica Neue",Arial,sans-serif';
1117
+ _ctx.font = _font;
1118
+ var letterW = _ctx.measureText(TOOL.letter).width;
1119
+ var restW = _restText.length > 0 ? _ctx.measureText(_restText).width : 0;
1120
+ _textStart = _cx + letterW / 2 + _s * 0.02;
1121
+ var totalW = Math.ceil(_textStart + restW + _s * 0.12);
1122
+ _c.width = totalW;
1123
+ _ctx = _c.getContext('2d');
1124
+ _c.style.height = dh + 'px';
1125
+ _c.style.width = Math.round(totalW / _s * dh) + 'px';
1126
+ _state = 'drawon'; _start = null;
1127
+ _raf = requestAnimationFrame(_frame);
1128
+ }
1129
+
1130
+ window._grainSetState = function(state) {
1131
+ if(_state === state) return;
1132
+ if(_state === 'drawon') { _pendingState = state; return; }
1133
+ _state = state; _start = null;
1134
+ if(!_raf) _raf = requestAnimationFrame(_frame);
1135
+ };
1136
+ })();
1137
+ </script>
1138
+ </body>
1139
+ </html>