@grainulation/orchard 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/farmer.js CHANGED
@@ -11,7 +11,7 @@ let _warnedNoToken = false;
11
11
  /**
12
12
  * POST an activity event to farmer.
13
13
  * Graceful failure -- catch and warn, never crash.
14
- * @param {string} farmerUrl - Base URL of farmer (e.g. http://localhost:9090)
14
+ * @param {string} farmerUrl - Base URL of farmer
15
15
  * @param {object} event - Event object (e.g. { type: "scan", data: {...} })
16
16
  * @param {object} [opts] - Options
17
17
  * @param {string} [opts.token] - Bearer token for Authorization header
@@ -89,7 +89,7 @@ function notify(farmerUrl, event, opts) {
89
89
  * CLI handler for `orchard connect farmer`.
90
90
  * Reads/writes .farmer.json in targetDir.
91
91
  * @param {string} targetDir - Working directory
92
- * @param {string[]} args - CLI arguments (e.g. ["farmer", "--url", "http://..."])
92
+ * @param {string[]} args - CLI arguments (e.g. ["farmer", "--url", "<farmer-url>"])
93
93
  */
94
94
  async function connect(targetDir, args) {
95
95
  const subcommand = args[0];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grainulation/orchard",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Multi-sprint research orchestrator — coordinate parallel research across teams",
5
5
  "main": "lib/planner.js",
6
6
  "exports": {
@@ -21,7 +21,6 @@
21
21
  "files": [
22
22
  "bin/",
23
23
  "lib/",
24
- "public/",
25
24
  "templates/",
26
25
  "README.md",
27
26
  "LICENSE",
package/lib/export.js DELETED
@@ -1,126 +0,0 @@
1
- "use strict";
2
-
3
- /**
4
- * orchard -> mill edge: export trigger for completed sprints.
5
- *
6
- * When orchard detects a sprint is "done", it can trigger mill's
7
- * export API to produce formatted output. Probes mill via localhost
8
- * or filesystem. Graceful fallback if mill is not available.
9
- */
10
-
11
- const fs = require("node:fs");
12
- const path = require("node:path");
13
- const http = require("node:http");
14
-
15
- const MILL_PORT = 9094;
16
- const MILL_SIBLINGS = [
17
- path.join(__dirname, "..", "..", "mill"),
18
- path.join(__dirname, "..", "..", "..", "mill"),
19
- ];
20
-
21
- /**
22
- * Check if mill is reachable (HTTP or filesystem).
23
- * Returns { available: true, method, formats? } or { available: false }.
24
- */
25
- function detectMill() {
26
- for (const dir of MILL_SIBLINGS) {
27
- const pkg = path.join(dir, "package.json");
28
- if (fs.existsSync(pkg)) {
29
- try {
30
- const meta = JSON.parse(fs.readFileSync(pkg, "utf8"));
31
- if (meta.name === "@grainulation/mill") {
32
- return { available: true, method: "filesystem", path: dir };
33
- }
34
- } catch {
35
- continue;
36
- }
37
- }
38
- }
39
- return { available: false };
40
- }
41
-
42
- /**
43
- * Trigger an export via mill's HTTP API.
44
- * @param {string} sprintPath — absolute path to the sprint directory
45
- * @param {string} format — export format name (e.g. "markdown", "csv", "json-ld")
46
- * @returns {Promise<{ok: boolean, job?, error?}>}
47
- */
48
- function exportSprint(sprintPath, format) {
49
- return new Promise((resolve) => {
50
- const body = JSON.stringify({ format, options: { source: sprintPath } });
51
- const req = http.request(
52
- {
53
- hostname: "127.0.0.1",
54
- port: MILL_PORT,
55
- path: "/api/export",
56
- method: "POST",
57
- headers: {
58
- "Content-Type": "application/json",
59
- "Content-Length": Buffer.byteLength(body),
60
- },
61
- timeout: 5000,
62
- },
63
- (res) => {
64
- let data = "";
65
- res.on("data", (chunk) => {
66
- data += chunk;
67
- });
68
- res.on("end", () => {
69
- try {
70
- const result = JSON.parse(data);
71
- resolve(
72
- result.error
73
- ? { ok: false, error: result.error }
74
- : { ok: true, job: result.job },
75
- );
76
- } catch {
77
- resolve({ ok: false, error: "Invalid response from mill" });
78
- }
79
- });
80
- },
81
- );
82
- req.on("error", () =>
83
- resolve({ ok: false, error: "mill not reachable on port " + MILL_PORT }),
84
- );
85
- req.on("timeout", () => {
86
- req.destroy();
87
- resolve({ ok: false, error: "mill request timed out" });
88
- });
89
- req.write(body);
90
- req.end();
91
- });
92
- }
93
-
94
- /**
95
- * List available export formats from mill's API.
96
- * @returns {Promise<{available: boolean, formats?: Array}>}
97
- */
98
- function listFormats() {
99
- return new Promise((resolve) => {
100
- const req = http.get(
101
- `http://127.0.0.1:${MILL_PORT}/api/formats`,
102
- { timeout: 2000 },
103
- (res) => {
104
- let body = "";
105
- res.on("data", (chunk) => {
106
- body += chunk;
107
- });
108
- res.on("end", () => {
109
- try {
110
- const data = JSON.parse(body);
111
- resolve({ available: true, formats: data.formats || [] });
112
- } catch {
113
- resolve({ available: false });
114
- }
115
- });
116
- },
117
- );
118
- req.on("error", () => resolve({ available: false }));
119
- req.on("timeout", () => {
120
- req.destroy();
121
- resolve({ available: false });
122
- });
123
- });
124
- }
125
-
126
- module.exports = { detectMill, exportSprint, listFormats, MILL_PORT };
package/public/index.html DELETED
@@ -1,922 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en" dir="auto" data-tool="orchard">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>Orchard</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='%2314b8a6' font-family='-apple-system,system-ui,sans-serif' font-size='34' font-weight='800'>O</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: #14b8a6;
27
- --accent-light: #2dd4bf;
28
- --accent-dim: rgba(20,184,166,0.10);
29
- --accent-border: rgba(20,184,166,0.25);
30
- --radius: 8px;
31
- --radius-sm: 4px;
32
- --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', sans-serif;
33
- --font-mono: 'SF Mono', 'Cascadia Code', 'JetBrains Mono', monospace;
34
- --transition-fast: 0.1s ease;
35
- --transition-base: 0.15s ease;
36
- }
37
-
38
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
39
-
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: 1280px; margin: 0 auto; padding: 24px; }
59
-
60
- /* ── Header ── */
61
- .header {
62
- display: flex; align-items: center; justify-content: space-between;
63
- padding-bottom: 20px; border-bottom: 1px solid var(--border);
64
- margin-bottom: 24px;
65
- }
66
- .header h1 { font-size: 18px; font-weight: 600; }
67
- .header h1 span { color: var(--accent); }
68
- .header-meta { display: flex; align-items: center; gap: 12px; }
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
- .header-meta .scan-btn {
75
- background: var(--bg3); border: 1px solid var(--border); color: var(--fg2);
76
- padding: 4px 12px; border-radius: var(--radius-sm); cursor: pointer;
77
- font-size: 11px; transition: all var(--transition-fast);
78
- }
79
- .header-meta .scan-btn:hover { background: var(--bg4); color: var(--fg); }
80
- .last-scan { font-size: 10px; color: var(--fg3); }
81
-
82
- /* ── Stats bar ── */
83
- .stats {
84
- display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
85
- gap: 12px; margin-bottom: 24px;
86
- }
87
- .stat {
88
- background: var(--bg2); border: 1px solid var(--border);
89
- border-radius: var(--radius); padding: 14px 16px;
90
- }
91
- .stat-value { font-size: 28px; font-weight: 700; font-family: var(--font-mono); }
92
- .stat-label { font-size: 10px; color: var(--fg3); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 2px; }
93
- .stat-accent .stat-value { color: var(--accent); }
94
- .stat-green .stat-value { color: var(--green); }
95
- .stat-red .stat-value { color: var(--red); }
96
- .stat-blue .stat-value { color: var(--blue); }
97
-
98
- /* ── Section titles ── */
99
- .section-title {
100
- font-size: 11px; font-weight: 600; text-transform: uppercase;
101
- letter-spacing: 0.8px; color: var(--fg3); margin-bottom: 12px;
102
- display: flex; align-items: center; gap: 8px;
103
- }
104
- .section-title .count {
105
- background: var(--accent-dim); color: var(--accent);
106
- padding: 1px 6px; border-radius: 10px; font-size: 10px;
107
- }
108
-
109
- /* ── Sprint grid ── */
110
- .sprint-grid {
111
- display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
112
- gap: 12px; margin-bottom: 32px;
113
- }
114
- .sprint-card {
115
- background: var(--bg2); border: 1px solid var(--border);
116
- border-radius: var(--radius); padding: 16px;
117
- transition: border-color var(--transition-base);
118
- }
119
- .sprint-card:hover, .sprint-card:focus-visible { border-color: var(--accent-border); outline: none; }
120
- .sprint-card:focus-visible { box-shadow: 0 0 0 2px var(--accent); }
121
- .sprint-card.conflict { border-color: rgba(248,113,113,0.4); }
122
- .sprint-card-header {
123
- display: flex; align-items: center; justify-content: space-between;
124
- margin-bottom: 10px;
125
- }
126
- .sprint-name { font-weight: 600; font-size: 14px; }
127
- .sprint-badge {
128
- font-size: 9px; padding: 2px 8px; border-radius: 10px;
129
- text-transform: uppercase; font-weight: 600; letter-spacing: 0.3px;
130
- }
131
- .badge-active { background: rgba(52,211,153,0.12); color: var(--green); }
132
- .badge-compiled { background: rgba(96,165,250,0.12); color: var(--blue); }
133
- .badge-not-started { background: rgba(148,163,184,0.12); color: var(--fg3); }
134
- .badge-blocked { background: rgba(248,113,113,0.12); color: var(--red); }
135
- .badge-done { background: var(--accent-dim); color: var(--accent); }
136
- .sprint-question {
137
- font-size: 11px; color: var(--fg2); margin-bottom: 10px;
138
- display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
139
- overflow: hidden;
140
- }
141
- .sprint-meta {
142
- display: flex; gap: 16px; font-size: 10px; color: var(--fg3);
143
- }
144
- .sprint-meta strong { color: var(--fg2); }
145
- .claim-bar {
146
- display: flex; gap: 2px; margin-top: 8px; height: 4px; border-radius: 2px;
147
- overflow: hidden;
148
- }
149
- .claim-bar-seg { height: 100%; border-radius: 1px; min-width: 2px; }
150
-
151
- /* ── Dependencies grid ── */
152
- .dep-section { margin-bottom: 32px; }
153
- .dep-grid {
154
- display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
155
- }
156
- .dep-node {
157
- background: var(--bg2); border: 1px solid var(--border);
158
- border-radius: var(--radius-sm); padding: 6px 12px;
159
- font-size: 11px; font-weight: 500;
160
- }
161
- .dep-edge {
162
- color: var(--fg3); font-size: 18px; font-family: var(--font-mono);
163
- }
164
- .dep-edge.ref { color: var(--accent); opacity: 0.5; }
165
- .dep-table {
166
- width: 100%; border-collapse: collapse;
167
- }
168
- .dep-table th {
169
- text-align: start; font-size: 10px; color: var(--fg3);
170
- text-transform: uppercase; padding: 6px 12px;
171
- border-bottom: 1px solid var(--border);
172
- }
173
- .dep-table td {
174
- padding: 8px 12px; border-bottom: 1px solid var(--border-subtle);
175
- font-size: 12px;
176
- }
177
- .dep-type {
178
- font-size: 9px; padding: 1px 6px; border-radius: 8px;
179
- }
180
- .dep-type-explicit { background: var(--accent-dim); color: var(--accent); }
181
- .dep-type-reference { background: rgba(148,163,184,0.12); color: var(--fg3); }
182
-
183
- /* ── Dep graph SVG ── */
184
- .dep-graph {
185
- margin-bottom: 16px; overflow-x: auto;
186
- background: var(--bg2); border: 1px solid var(--border);
187
- border-radius: var(--radius); min-height: 0;
188
- }
189
- .dep-graph:empty { display: none; }
190
- .dep-graph svg { display: block; width: 100%; }
191
- .dep-graph .node rect { fill: var(--bg3); stroke: var(--border); stroke-width: 1; rx: 4; }
192
- .dep-graph .node text { fill: var(--fg); font-size: 11px; font-family: var(--font-sans); }
193
- .dep-graph .edge { stroke: var(--fg3); stroke-width: 1.5; fill: none; marker-end: url(#arrowhead); }
194
- .dep-graph .edge.explicit { stroke: var(--accent); }
195
- .dep-graph .edge.reference { stroke: var(--fg3); stroke-dasharray: 4 3; }
196
-
197
- /* ── Conflict cards ── */
198
- .conflict-list { display: flex; flex-direction: column; gap: 10px; margin-bottom: 32px; }
199
- .conflict-card {
200
- background: var(--bg2); border: 1px solid rgba(248,113,113,0.3);
201
- border-radius: var(--radius); padding: 14px;
202
- border-inline-start: 3px solid var(--red);
203
- }
204
- .conflict-card.medium { border-inline-start-color: var(--orange); border-color: rgba(251,146,60,0.3); }
205
- .conflict-header {
206
- display: flex; justify-content: space-between; align-items: center;
207
- margin-bottom: 8px;
208
- }
209
- .conflict-type { font-size: 11px; font-weight: 600; color: var(--red); }
210
- .conflict-card.medium .conflict-type { color: var(--orange); }
211
- .conflict-tag {
212
- font-size: 10px; background: var(--bg3); padding: 2px 8px;
213
- border-radius: 8px; color: var(--fg2);
214
- }
215
- .conflict-claims { font-size: 11px; color: var(--fg2); }
216
- .conflict-claims .claim-ref {
217
- display: flex; gap: 8px; padding: 4px 0;
218
- }
219
- .conflict-claims .claim-id { color: var(--accent); font-family: var(--font-mono); font-size: 10px; min-width: 50px; }
220
-
221
- /* ── Timeline ── */
222
- .timeline-section { margin-bottom: 32px; }
223
- .timeline-row {
224
- display: flex; align-items: center; gap: 12px;
225
- padding: 8px 0; border-bottom: 1px solid var(--border-subtle);
226
- }
227
- .timeline-name {
228
- min-width: 120px; font-size: 12px; font-weight: 500;
229
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
230
- }
231
- .timeline-bar {
232
- flex: 1; display: flex; gap: 2px; height: 24px;
233
- background: var(--bg3); border-radius: var(--radius-sm);
234
- overflow: hidden; position: relative;
235
- }
236
- .timeline-phase {
237
- height: 100%; display: flex; align-items: center; justify-content: center;
238
- font-size: 9px; font-weight: 500; color: var(--bg);
239
- min-width: 20px; padding: 0 4px;
240
- transition: flex var(--transition-base);
241
- }
242
- .phase-define { background: var(--fg3); }
243
- .phase-research { background: var(--blue); }
244
- .phase-prototype { background: var(--purple); }
245
- .phase-evaluate { background: var(--cyan); }
246
- .phase-challenge { background: var(--orange); }
247
- .phase-witness { background: var(--accent); }
248
- .phase-feedback { background: #f472b6; }
249
- .phase-calibrate { background: var(--green); }
250
- .phase-other { background: var(--bg4); }
251
- .timeline-claims {
252
- min-width: 50px; text-align: end; font-size: 11px;
253
- font-family: var(--font-mono); color: var(--fg3);
254
- }
255
-
256
- /* ── Empty state ── */
257
- .empty {
258
- text-align: center; padding: 48px 24px; color: var(--fg3);
259
- }
260
- .empty h3 { color: var(--fg2); margin-bottom: 8px; font-size: 14px; }
261
- .empty p { font-size: 12px; }
262
- .empty code {
263
- background: var(--bg3); padding: 2px 6px; border-radius: 3px;
264
- font-family: var(--font-mono); font-size: 11px;
265
- }
266
-
267
- /* ── A11y ── */
268
- .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}
269
- .skip-link:focus{top:0}
270
- .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}
271
- @media (prefers-reduced-motion: reduce) {
272
- *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; }
273
- }
274
-
275
- /* ── Responsive ── */
276
- @media (max-width: 768px) {
277
- .app { padding: 16px; }
278
- .header { flex-wrap: wrap; gap: 8px; padding-bottom: 14px; margin-bottom: 16px; }
279
- .header h1 { font-size: 16px; }
280
- .header-meta { gap: 8px; }
281
- .stats { grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; margin-bottom: 16px; }
282
- .stat { padding: 10px 12px; }
283
- .stat-value { font-size: 22px; }
284
- .sprint-grid { grid-template-columns: 1fr; gap: 10px; margin-bottom: 24px; }
285
- .sprint-card { padding: 12px; }
286
- .sprint-name { font-size: 13px; }
287
- .sprint-meta { flex-wrap: wrap; gap: 8px; }
288
- .conflict-card { padding: 10px; }
289
- .timeline-name { min-width: 80px; font-size: 11px; }
290
- .timeline-bar { height: 20px; }
291
- .dep-table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }
292
- .dep-table th, .dep-table td { white-space: nowrap; }
293
- }
294
- @media (max-width: 480px) {
295
- .app { padding: 12px; }
296
- .stats { grid-template-columns: repeat(2, 1fr); }
297
- .stat-value { font-size: 20px; }
298
- .sprint-meta { font-size: 9px; }
299
- .timeline-row { gap: 8px; }
300
- .timeline-name { min-width: 60px; font-size: 10px; }
301
- .timeline-claims { font-size: 10px; min-width: 36px; }
302
- }
303
- </style>
304
- </head>
305
- <body>
306
- <a href="#main-content" class="skip-link">Skip to main content</a>
307
- <div id="live-status" aria-live="polite" aria-atomic="true" class="sr-only"></div>
308
- <div class="reconnect-banner" id="reconnectBanner" role="status" aria-live="polite"></div>
309
- <div class="app">
310
- <!-- Header -->
311
- <header class="header" role="banner">
312
- <canvas id="grainLogo" width="256" height="256" style="display:block"></canvas>
313
- <div class="header-meta">
314
- <span class="conn-dot" id="connDot"></span>
315
- <span class="last-scan" id="lastScan">--</span>
316
- <button class="scan-btn" id="scanBtn">Rescan</button>
317
- </div>
318
- </header>
319
-
320
- <main id="main-content" role="main" aria-label="Orchard workspace">
321
- <!-- Stats -->
322
- <div class="stats" id="stats"></div>
323
-
324
- <!-- Sprint cards -->
325
- <div class="section-title">Sprints <span class="count" id="sprintCount">0</span></div>
326
- <div class="sprint-grid" id="sprintGrid"></div>
327
-
328
- <!-- Conflicts -->
329
- <div class="section-title">Conflicts <span class="count" id="conflictCount">0</span></div>
330
- <div class="conflict-list" id="conflictList"></div>
331
-
332
- <!-- Dependencies -->
333
- <div class="section-title dep-section">Dependencies <span class="count" id="depCount">0</span></div>
334
- <div id="depGraph" class="dep-graph" role="img" aria-label="Sprint dependency graph"></div>
335
- <div style="margin-bottom:32px; overflow-x:auto;">
336
- <table class="dep-table" id="depTable" role="table">
337
- <thead><tr><th scope="col">From</th><th scope="col">To</th><th scope="col">Type</th></tr></thead>
338
- <tbody id="depBody"></tbody>
339
- </table>
340
- </div>
341
-
342
- <!-- Timeline -->
343
- <div class="section-title timeline-section">Timeline</div>
344
- <div id="timeline"></div>
345
- </main>
346
- </div>
347
-
348
- <script>
349
- (function() {
350
- 'use strict';
351
-
352
- // ── i18n passthrough ──
353
- const t = (s, v) => v ? s.replace(/\{(\w+)\}/g, (_, k) => v[k] ?? k) : s;
354
- const _dtf = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' });
355
-
356
- const PHASE_COLORS = {
357
- define: 'var(--fg3)', research: 'var(--blue)', prototype: 'var(--purple)',
358
- evaluate: 'var(--cyan)', challenge: 'var(--orange)', witness: '#14b8a6',
359
- feedback: '#f472b6', calibrate: 'var(--green)',
360
- };
361
-
362
- const CLAIM_TYPE_COLORS = {
363
- constraint: '#f87171', factual: '#60a5fa', estimate: '#a78bfa',
364
- risk: '#fb923c', recommendation: '#34d399', feedback: '#f472b6',
365
- };
366
-
367
- let state = null;
368
- let sse = null;
369
-
370
- // ── SSE connection with exponential backoff ──
371
- let retryCount = 0;
372
- function showBanner(count) {
373
- const b = document.getElementById('reconnectBanner');
374
- if (count > 5) {
375
- b.innerHTML = t('Connection lost.') + ' <button onclick="retryCount=0;connect()">' + t('Retry now') + '</button>';
376
- } else if (count > 1) {
377
- b.textContent = t('Reconnecting (attempt {count})...', { count });
378
- } else {
379
- b.textContent = t('Reconnecting...');
380
- }
381
- b.classList.add('visible');
382
- }
383
- function hideBanner() { document.getElementById('reconnectBanner').classList.remove('visible'); }
384
-
385
- function connect() {
386
- sse = new EventSource('/events');
387
- document.getElementById('connDot').className = 'conn-dot';
388
-
389
- sse.onmessage = (e) => {
390
- try {
391
- const msg = JSON.parse(e.data);
392
- if (msg.type === 'state' && msg.data) {
393
- state = msg.data;
394
- render();
395
- const ls = document.getElementById('live-status');
396
- if (ls) ls.textContent = 'Updated: ' + (state.portfolio || []).length + ' sprints loaded';
397
- }
398
- } catch { /* ignore */ }
399
- };
400
-
401
- sse.onopen = () => {
402
- retryCount = 0;
403
- document.getElementById('connDot').className = 'conn-dot';
404
- if (window._grainSetState) window._grainSetState('idle');
405
- };
406
-
407
- sse.onerror = () => {
408
- document.getElementById('connDot').className = 'conn-dot disconnected';
409
- if (window._grainSetState) window._grainSetState('orbit');
410
- sse.close();
411
- const delay = Math.min(30000, 1000 * Math.pow(2, retryCount)) + Math.random() * 1000;
412
- retryCount++;
413
- setTimeout(connect, delay);
414
- };
415
- }
416
-
417
- // ── Rescan ──
418
- document.getElementById('scanBtn').addEventListener('click', async () => {
419
- const btn = document.getElementById('scanBtn');
420
- btn.textContent = t('Scanning...');
421
- btn.disabled = true;
422
- try {
423
- await fetch('/api/scan', { method: 'POST' });
424
- } catch { /* ignore */ }
425
- btn.textContent = t('Rescan');
426
- btn.disabled = false;
427
- });
428
-
429
- // ── Render ──
430
- function render() {
431
- if (!state) return;
432
-
433
- // Last scan
434
- if (state.lastScan) {
435
- const d = new Date(state.lastScan);
436
- document.getElementById('lastScan').textContent = _dtf.format(d);
437
- }
438
-
439
- const portfolio = state.portfolio || [];
440
- const conflicts = state.conflicts || [];
441
- const deps = state.dependencies || { nodes: [], edges: [] };
442
- const timeline = state.timeline || [];
443
-
444
- // Stats
445
- const active = portfolio.filter(s => s.status === 'active').length;
446
- const compiled = portfolio.filter(s => s.status === 'compiled' || s.status === 'done').length;
447
- const totalClaims = portfolio.reduce((sum, s) => sum + (s.claimCount || 0), 0);
448
-
449
- document.getElementById('stats').innerHTML = [
450
- { value: portfolio.length, label: t('Sprints'), cls: 'stat-accent' },
451
- { value: active, label: t('Active'), cls: 'stat-green' },
452
- { value: compiled, label: t('Compiled'), cls: 'stat-blue' },
453
- { value: totalClaims, label: t('Total Claims'), cls: '' },
454
- { value: conflicts.length, label: t('Conflicts'), cls: conflicts.length > 0 ? 'stat-red' : '' },
455
- { value: deps.edges.length, label: t('Dep Links'), cls: '' },
456
- ].map(s => `
457
- <div class="stat ${s.cls}">
458
- <div class="stat-value">${s.value}</div>
459
- <div class="stat-label">${s.label}</div>
460
- </div>
461
- `).join('');
462
-
463
- // Sprint count
464
- document.getElementById('sprintCount').textContent = portfolio.length;
465
-
466
- // Sprint cards
467
- if (portfolio.length === 0) {
468
- document.getElementById('sprintGrid').innerHTML = `
469
- <div class="empty">
470
- <h3>${t('No sprints found')}</h3>
471
- <p>${t('Create a sprint directory with claims.json or add sprints to orchard.json')}</p>
472
- </div>`;
473
- } else {
474
- document.getElementById('sprintGrid').innerHTML = portfolio.map(s => {
475
- const hasConflict = conflicts.some(c =>
476
- c.claimA.sprint === s.name || c.claimB.sprint === s.name
477
- );
478
- const badgeClass =
479
- s.status === 'active' ? 'badge-active' :
480
- s.status === 'compiled' || s.status === 'done' ? 'badge-compiled' :
481
- s.status === 'blocked' ? 'badge-blocked' :
482
- 'badge-not-started';
483
-
484
- // Claim type bar
485
- const types = Object.entries(s.claimTypes || {});
486
- const total = types.reduce((sum, [,v]) => sum + v, 0) || 1;
487
- const barSegs = types.map(([tp, v]) => {
488
- const pct = (v / total * 100).toFixed(1);
489
- const color = CLAIM_TYPE_COLORS[tp] || 'var(--bg4)';
490
- return `<div class="claim-bar-seg" style="flex:${v}; background:${color}" title="${tp}: ${v}"></div>`;
491
- }).join('');
492
-
493
- return `
494
- <div class="sprint-card${hasConflict ? ' conflict' : ''}" tabindex="0" role="article" aria-label="${esc(s.name)} sprint">
495
- <div class="sprint-card-header">
496
- <span class="sprint-name">${esc(s.name)}</span>
497
- <span class="sprint-badge ${badgeClass}">${esc(s.status || 'unknown')}</span>
498
- </div>
499
- ${s.question ? `<div class="sprint-question">${esc(s.question.substring(0, 160))}</div>` : ''}
500
- <div class="sprint-meta">
501
- <span><strong>${s.claimCount}</strong> claims</span>
502
- <span>phase: <strong>${esc(s.phase || 'unknown')}</strong></span>
503
- ${s.assignedTo ? `<span>${esc(s.assignedTo)}</span>` : ''}
504
- ${s.deadline ? `<span>${esc(s.deadline)}</span>` : ''}
505
- </div>
506
- ${types.length > 0 ? `<div class="claim-bar">${barSegs}</div>` : ''}
507
- </div>`;
508
- }).join('');
509
- }
510
-
511
- // Conflicts
512
- document.getElementById('conflictCount').textContent = conflicts.length;
513
- if (conflicts.length === 0) {
514
- document.getElementById('conflictList').innerHTML =
515
- '<div class="empty"><p>' + t('No cross-sprint conflicts detected') + '</p></div>';
516
- } else {
517
- document.getElementById('conflictList').innerHTML = conflicts.map(c => `
518
- <div class="conflict-card${c.severity === 'medium' ? ' medium' : ''}">
519
- <div class="conflict-header">
520
- <span class="conflict-type">${esc(c.type)}</span>
521
- <span class="conflict-tag">${esc(c.tag)}</span>
522
- </div>
523
- <div class="conflict-claims">
524
- <div class="claim-ref">
525
- <span class="claim-id">${esc(c.claimA.id)}</span>
526
- <span>${esc(c.claimA.sprint)}: ${esc(c.claimA.text)}</span>
527
- </div>
528
- <div class="claim-ref">
529
- <span class="claim-id">${esc(c.claimB.id)}</span>
530
- <span>${esc(c.claimB.sprint)}: ${esc(c.claimB.text)}</span>
531
- </div>
532
- </div>
533
- </div>
534
- `).join('');
535
- }
536
-
537
- // Dependencies
538
- document.getElementById('depCount').textContent = deps.edges.length;
539
- renderDepGraph(deps);
540
-
541
- if (deps.edges.length === 0) {
542
- document.getElementById('depBody').innerHTML =
543
- '<tr><td colspan="3" style="color:var(--fg3); text-align:center; padding:16px;">' + t('No dependencies detected') + '</td></tr>';
544
- } else {
545
- document.getElementById('depBody').innerHTML = deps.edges.map(e => {
546
- const fromName = e.from.split('/').pop();
547
- const toName = e.to.split('/').pop();
548
- const typeClass = e.type === 'explicit' ? 'dep-type-explicit' : 'dep-type-reference';
549
- return `
550
- <tr>
551
- <td>${esc(fromName)}</td>
552
- <td>${esc(toName)}</td>
553
- <td><span class="dep-type ${typeClass}">${esc(e.type)}</span></td>
554
- </tr>`;
555
- }).join('');
556
- }
557
-
558
- // Timeline
559
- if (timeline.length === 0) {
560
- document.getElementById('timeline').innerHTML =
561
- '<div class="empty"><p>' + t('No timeline data available') + '</p></div>';
562
- } else {
563
- document.getElementById('timeline').innerHTML = timeline.map(tl => {
564
- const phases = (tl.phases || []);
565
- const total = phases.reduce((sum, p) => sum + p.claimCount, 0);
566
-
567
- const bars = phases.map(p => {
568
- const phaseClass = 'phase-' + p.name.replace(/[^a-z]/g, '');
569
- return `<div class="timeline-phase ${phaseClass}" style="flex:${p.claimCount}" title="${p.name}: ${p.claimCount} claims">${p.claimCount > 2 ? p.name.substring(0, 3) : ''}</div>`;
570
- }).join('');
571
-
572
- return `
573
- <div class="timeline-row">
574
- <span class="timeline-name" title="${esc(tl.name)}">${esc(tl.name)}</span>
575
- <div class="timeline-bar">${bars || '<div style="flex:1"></div>'}</div>
576
- <span class="timeline-claims">${total}</span>
577
- </div>`;
578
- }).join('');
579
- }
580
- }
581
-
582
- function esc(str) {
583
- return String(str || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
584
- }
585
-
586
- // ── SVG dependency graph ──
587
- function renderDepGraph(deps) {
588
- const container = document.getElementById('depGraph');
589
- if (!deps.nodes.length || !deps.edges.length) { container.innerHTML = ''; return; }
590
-
591
- const nameMap = {};
592
- for (const n of deps.nodes) nameMap[n.id] = n.name;
593
-
594
- // Collect unique node names involved in edges
595
- const involved = new Set();
596
- for (const e of deps.edges) {
597
- involved.add(e.from);
598
- involved.add(e.to);
599
- }
600
- const nodeList = deps.nodes.filter(n => involved.has(n.id));
601
- if (nodeList.length === 0) { container.innerHTML = ''; return; }
602
-
603
- const nodeW = 120, nodeH = 32, padX = 24, padY = 20, gapX = 40, gapY = 16;
604
-
605
- // Simple layered layout: nodes with no incoming edges go first
606
- const incoming = new Map();
607
- for (const n of nodeList) incoming.set(n.id, 0);
608
- for (const e of deps.edges) {
609
- if (incoming.has(e.to)) incoming.set(e.to, incoming.get(e.to) + 1);
610
- }
611
-
612
- // Topological layers (BFS)
613
- const layers = [];
614
- const placed = new Set();
615
- let frontier = nodeList.filter(n => incoming.get(n.id) === 0).map(n => n.id);
616
- if (frontier.length === 0) frontier = [nodeList[0].id]; // cycle fallback
617
-
618
- while (frontier.length > 0) {
619
- layers.push(frontier);
620
- for (const id of frontier) placed.add(id);
621
- const next = [];
622
- for (const e of deps.edges) {
623
- if (placed.has(e.from) && !placed.has(e.to) && !next.includes(e.to)) {
624
- next.push(e.to);
625
- }
626
- }
627
- if (next.length === 0) {
628
- // Place any remaining unplaced nodes
629
- const remaining = nodeList.filter(n => !placed.has(n.id)).map(n => n.id);
630
- if (remaining.length > 0) { layers.push(remaining); for (const id of remaining) placed.add(id); }
631
- break;
632
- }
633
- frontier = next;
634
- }
635
-
636
- // Compute positions
637
- const pos = {};
638
- const maxPerLayer = Math.max(...layers.map(l => l.length));
639
- const svgW = layers.length * (nodeW + gapX) + padX * 2;
640
- const svgH = maxPerLayer * (nodeH + gapY) + padY * 2;
641
-
642
- for (let li = 0; li < layers.length; li++) {
643
- const layer = layers[li];
644
- const layerH = layer.length * (nodeH + gapY) - gapY;
645
- const startY = (svgH - layerH) / 2;
646
- for (let ni = 0; ni < layer.length; ni++) {
647
- pos[layer[ni]] = {
648
- x: padX + li * (nodeW + gapX),
649
- y: startY + ni * (nodeH + gapY),
650
- };
651
- }
652
- }
653
-
654
- // Build SVG
655
- let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgW} ${svgH}" style="min-height:${Math.min(svgH, 200)}px; max-height:240px">`;
656
- svg += '<defs><marker id="arrowhead" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6" fill="var(--fg3)"/></marker>';
657
- svg += '<marker id="arrowhead-accent" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6" fill="var(--accent)"/></marker></defs>';
658
-
659
- // Edges
660
- for (const e of deps.edges) {
661
- const from = pos[e.from];
662
- const to = pos[e.to];
663
- if (!from || !to) continue;
664
- const x1 = from.x + nodeW;
665
- const y1 = from.y + nodeH / 2;
666
- const x2 = to.x;
667
- const y2 = to.y + nodeH / 2;
668
- const cls = e.type === 'explicit' ? 'edge explicit' : 'edge reference';
669
- const marker = e.type === 'explicit' ? 'url(#arrowhead-accent)' : 'url(#arrowhead)';
670
- const mx = (x1 + x2) / 2;
671
- svg += `<path class="${cls}" d="M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}" marker-end="${marker}"/>`;
672
- }
673
-
674
- // Nodes
675
- for (const n of nodeList) {
676
- const p = pos[n.id];
677
- if (!p) continue;
678
- const statusColor =
679
- n.status === 'active' ? 'var(--green)' :
680
- n.status === 'compiled' || n.status === 'done' ? 'var(--blue)' :
681
- n.status === 'blocked' ? 'var(--red)' : 'var(--fg3)';
682
- svg += `<g class="node"><rect x="${p.x}" y="${p.y}" width="${nodeW}" height="${nodeH}" style="stroke:${statusColor}"/>`;
683
- svg += `<text x="${p.x + nodeW / 2}" y="${p.y + nodeH / 2 + 1}" text-anchor="middle" dominant-baseline="central">${esc(n.name.length > 14 ? n.name.substring(0, 13) + '..' : n.name)}</text></g>`;
684
- }
685
-
686
- svg += '</svg>';
687
- container.innerHTML = svg;
688
- }
689
-
690
- // ── Keyboard navigation ──
691
- document.addEventListener('keydown', (e) => {
692
- if (e.key === 'Escape') {
693
- document.activeElement?.blur();
694
- window.scrollTo({ top: 0, behavior: 'smooth' });
695
- }
696
- // J/K to navigate between sprint cards
697
- if ((e.key === 'j' || e.key === 'k') && !e.ctrlKey && !e.metaKey && !e.altKey) {
698
- const target = e.target;
699
- if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
700
- const cards = [...document.querySelectorAll('.sprint-card')];
701
- if (cards.length === 0) return;
702
- const idx = cards.indexOf(document.activeElement);
703
- let next;
704
- if (e.key === 'j') next = idx < 0 ? 0 : Math.min(idx + 1, cards.length - 1);
705
- else next = idx < 0 ? cards.length - 1 : Math.max(idx - 1, 0);
706
- cards[next].focus();
707
- e.preventDefault();
708
- }
709
- });
710
-
711
- connect();
712
- })();
713
- </script>
714
- <script>
715
- (function() {
716
- var LW = 0.025;
717
- var TOOL = { name: 'Orchard', letter: 'O', color: '#14b8a6' };
718
- var _c, _ctx, _s, _cx, _textStart, _restText, _font;
719
- var _state = 'drawon', _start = null, _raf, _pendingState = null;
720
- var _openPts = null, _closedPts = null;
721
-
722
- function _lerp(a,b,t){ return {x:a.x+(b.x-a.x)*t, y:a.y+(b.y-a.y)*t}; }
723
- function _easeInOut(t){ return t<0.5 ? 2*t*t : 1-Math.pow(-2*t+2,2)/2; }
724
-
725
- function _bracket(ctx, s, color, alpha) {
726
- var lw=s*LW, cx=_cx, cy=s/2, gw=s*0.72, gh=s*0.68;
727
- var topY=cy-gh/2, botY=cy+gh/2, fe=gw*0.30;
728
- if(alpha!==undefined) ctx.globalAlpha=alpha;
729
- ctx.strokeStyle=color; ctx.lineWidth=lw; ctx.lineCap='round'; ctx.lineJoin='round';
730
- ctx.beginPath(); ctx.moveTo(cx,topY); ctx.lineTo(cx-fe,topY);
731
- ctx.bezierCurveTo(cx-gw*0.52,cy-gh*0.32, cx-gw*0.52,cy+gh*0.24, cx-fe,botY);
732
- ctx.lineTo(cx,botY); ctx.stroke();
733
- if(alpha!==undefined) ctx.globalAlpha=1;
734
- }
735
-
736
- function _drawBracket(ctx, s, color, progress) {
737
- var lw=s*LW, cx=_cx, cy=s/2, gw=s*0.72, gh=s*0.68;
738
- var topY=cy-gh/2, botY=cy+gh/2, fe=gw*0.30;
739
- ctx.strokeStyle=color; ctx.lineWidth=lw; ctx.lineCap='round'; ctx.lineJoin='round';
740
- var seg1=0.12, seg2=0.72;
741
- ctx.beginPath();
742
- if(progress<=seg1){ctx.moveTo(cx,topY);ctx.lineTo(cx-fe*(progress/seg1),topY);}
743
- else if(progress<=seg1+seg2){ctx.moveTo(cx,topY);ctx.lineTo(cx-fe,topY);ctx.stroke();ctx.beginPath();
744
- var bt=(progress-seg1)/seg2;
745
- 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};
746
- var q1=_lerp(p0,p1,bt),q2=_lerp(p1,p2,bt),q3=_lerp(p2,p3,bt);
747
- var r1=_lerp(q1,q2,bt),r2=_lerp(q2,q3,bt),s1=_lerp(r1,r2,bt);
748
- ctx.moveTo(p0.x,p0.y);ctx.bezierCurveTo(q1.x,q1.y,r1.x,r1.y,s1.x,s1.y);}
749
- else{ctx.moveTo(cx,topY);ctx.lineTo(cx-fe,topY);
750
- ctx.bezierCurveTo(cx-gw*0.52,cy-gh*0.32, cx-gw*0.52,cy+gh*0.24, cx-fe,botY);
751
- ctx.lineTo((cx-fe)+fe*((progress-seg1-seg2)/(1-seg1-seg2)),botY);}
752
- ctx.stroke();
753
- }
754
-
755
- function _drawName(ctx, s, spellP, alpha) {
756
- var a = alpha !== undefined ? alpha : 1;
757
- ctx.font = _font; ctx.textBaseline = 'middle';
758
- var cy = s/2 + s*0.02;
759
- ctx.globalAlpha = a; ctx.fillStyle = TOOL.color; ctx.textAlign = 'center';
760
- ctx.fillText(TOOL.letter, _cx, cy);
761
- if(_restText.length > 0 && spellP > 0) {
762
- var n = _restText.length, num = Math.min(n, Math.ceil(spellP * n));
763
- var rawP = spellP * n, charP = num >= n ? 1 : rawP - Math.floor(rawP);
764
- var full = charP >= 1 ? num : num - 1;
765
- ctx.fillStyle = '#e2e8f0'; ctx.textAlign = 'left';
766
- if(full > 0) { ctx.globalAlpha = a; ctx.fillText(_restText.slice(0, full), _textStart, cy); }
767
- if(full < num) {
768
- var prevW = full > 0 ? ctx.measureText(_restText.slice(0, full)).width : 0;
769
- ctx.globalAlpha = a * (0.3 + 0.7 * charP);
770
- ctx.fillText(_restText[full], _textStart + prevW, cy);
771
- }
772
- }
773
- ctx.globalAlpha = 1;
774
- }
775
-
776
- function _getOpenPts(s) {
777
- if(_openPts && _openPts._s === s) return _openPts;
778
- 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;
779
- var pts=[];
780
- for(var t=0;t<=1;t+=0.05) pts.push({x:cx-fe*t,y:topY});
781
- 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};
782
- 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});}
783
- for(var t=0;t<=1;t+=0.05) pts.push({x:(cx-fe)+fe*t,y:botY});
784
- pts._s=s; _openPts=pts; return pts;
785
- }
786
-
787
- function _getClosedPts(s) {
788
- if(_closedPts && _closedPts._s === s) return _closedPts;
789
- 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;
790
- var pts=[];
791
- for(var t=0;t<=1;t+=0.03) pts.push({x:cx-fe*t,y:topY});
792
- 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};
793
- 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});}
794
- for(var t=0;t<=1;t+=0.03) pts.push({x:(cx-fe)+2*fe*t,y:botY});
795
- 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};
796
- 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});}
797
- for(var t=0;t<=1;t+=0.03) pts.push({x:(cx+fe)-fe*t,y:topY});
798
- pts._s=s; _closedPts=pts; return pts;
799
- }
800
-
801
- function _frame(ts) {
802
- if(!_c) return;
803
- if(!_start) _start = ts;
804
- var e = ts - _start, ctx = _ctx, s = _s;
805
- ctx.clearRect(0, 0, _c.width, s);
806
- switch(_state) {
807
- case 'drawon':
808
- var bp = _easeInOut(Math.min(1, e / 1400));
809
- _drawBracket(ctx, s, TOOL.color, bp);
810
- var la = Math.max(0, Math.min(1, (e - 900) / 400));
811
- if(la > 0) {
812
- ctx.font = _font; ctx.textBaseline = 'middle';
813
- ctx.globalAlpha = la; ctx.fillStyle = TOOL.color; ctx.textAlign = 'center';
814
- ctx.fillText(TOOL.letter, _cx, s/2 + s*0.02); ctx.globalAlpha = 1;
815
- }
816
- if(e > 1100 && _restText.length > 0) {
817
- var sp = Math.min(1, (e - 1100) / (120 * _restText.length));
818
- var n = _restText.length, num = Math.min(n, Math.ceil(sp * n));
819
- if(num > 0) {
820
- ctx.font = _font; ctx.textBaseline = 'middle';
821
- var cy = s/2 + s*0.02, rawP = sp * n;
822
- var charP = num >= n ? 1 : rawP - Math.floor(rawP);
823
- var full = charP >= 1 ? num : num - 1;
824
- ctx.fillStyle = '#e2e8f0'; ctx.textAlign = 'left';
825
- if(full > 0) ctx.fillText(_restText.slice(0, full), _textStart, cy);
826
- if(full < num) {
827
- var prevW = full > 0 ? ctx.measureText(_restText.slice(0, full)).width : 0;
828
- ctx.globalAlpha = 0.3 + 0.7 * charP;
829
- ctx.fillText(_restText[full], _textStart + prevW, cy); ctx.globalAlpha = 1;
830
- }
831
- }
832
- }
833
- if(e > 1100 + 120 * _restText.length + 300) { _state = _pendingState || 'idle'; _pendingState = null; _start = ts; }
834
- break;
835
- case 'idle':
836
- var breathe = 0.5 + 0.5 * (0.5 + 0.5 * Math.sin(e / 1200));
837
- _bracket(ctx, s, TOOL.color, breathe);
838
- var textBreath = 0.88 + 0.12 * (0.5 + 0.5 * Math.sin(e / 1800));
839
- _drawName(ctx, s, 1, textBreath);
840
- break;
841
- case 'shimmer':
842
- _bracket(ctx, s, TOOL.color, 0.2);
843
- var spts = _getOpenPts(s), sspeed = 1800;
844
- var spos = (e % sspeed) / sspeed;
845
- var sidx = Math.floor(spos * (spts.length - 1));
846
- var spt = spts[sidx];
847
- var sgrad = ctx.createRadialGradient(spt.x, spt.y, 0, spt.x, spt.y, s * 0.10);
848
- sgrad.addColorStop(0, TOOL.color + 'aa'); sgrad.addColorStop(1, 'transparent');
849
- ctx.fillStyle = sgrad; ctx.fillRect(0, 0, _c.width, s);
850
- var strailFrac = 0.18;
851
- var si0 = Math.max(0, Math.floor((spos - strailFrac) * (spts.length - 1)));
852
- ctx.strokeStyle = TOOL.color; ctx.lineWidth = s * LW; ctx.lineCap = 'round';
853
- ctx.globalAlpha = 0.85; ctx.beginPath(); ctx.moveTo(spts[si0].x, spts[si0].y);
854
- for(var si = si0 + 1; si <= sidx; si++) ctx.lineTo(spts[si].x, spts[si].y);
855
- ctx.stroke(); ctx.globalAlpha = 1;
856
- _drawName(ctx, s, 1, undefined);
857
- break;
858
- case 'orbit':
859
- _bracket(ctx, s, TOOL.color, 0.15);
860
- _drawName(ctx, s, 1, 0.4);
861
- var pts = _getOpenPts(s), speed = 1200, trailFrac = 0.28;
862
- var halfCycle = (e % speed) / speed;
863
- var cycle = (e % (speed * 2)) / (speed * 2);
864
- var pos = cycle < 0.5 ? halfCycle : 1 - halfCycle;
865
- var headIdx = Math.floor(pos * (pts.length - 1));
866
- var trailLen = Math.floor(trailFrac * pts.length);
867
- var dir = cycle < 0.5 ? 1 : -1;
868
- ctx.lineWidth = s * LW; ctx.lineCap = 'round';
869
- for(var i = 0; i < trailLen; i++) {
870
- var idx = headIdx - dir * (trailLen - i);
871
- if(idx < 0 || idx >= pts.length) continue;
872
- var nxt = idx + dir;
873
- if(nxt < 0 || nxt >= pts.length) continue;
874
- ctx.globalAlpha = (i / trailLen) * 0.7; ctx.strokeStyle = TOOL.color;
875
- ctx.beginPath(); ctx.moveTo(pts[idx].x, pts[idx].y); ctx.lineTo(pts[nxt].x, pts[nxt].y); ctx.stroke();
876
- }
877
- ctx.globalAlpha = 1;
878
- break;
879
- case 'dim':
880
- var dim = 0.1 + 0.08 * Math.sin(e / 2000);
881
- _bracket(ctx, s, TOOL.color, dim);
882
- _drawName(ctx, s, 1, 0.2);
883
- break;
884
- }
885
- _raf = requestAnimationFrame(_frame);
886
- }
887
-
888
- _c = document.getElementById('grainLogo');
889
- if(_c) {
890
- _c.style.width = '0px';
891
- _s = 256;
892
- var targetFontPx = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
893
- var fontRatio = 0.38;
894
- var dh = 64;
895
- _c.height = _s; _c.width = 1024;
896
- _ctx = _c.getContext('2d');
897
- _cx = _s / 2;
898
- _restText = TOOL.name.slice(1);
899
- _font = '800 ' + (_s * fontRatio) + 'px -apple-system,"SF Pro Display","Helvetica Neue",Arial,sans-serif';
900
- _ctx.font = _font;
901
- var letterW = _ctx.measureText(TOOL.letter).width;
902
- var restW = _restText.length > 0 ? _ctx.measureText(_restText).width : 0;
903
- _textStart = _cx + letterW / 2 + _s * 0.02;
904
- var totalW = Math.ceil(_textStart + restW + _s * 0.12);
905
- _c.width = totalW;
906
- _ctx = _c.getContext('2d');
907
- _c.style.height = dh + 'px';
908
- _c.style.width = Math.round(totalW / _s * dh) + 'px';
909
- _state = 'drawon'; _start = null;
910
- _raf = requestAnimationFrame(_frame);
911
- }
912
-
913
- window._grainSetState = function(state) {
914
- if(_state === state) return;
915
- if(_state === 'drawon') { _pendingState = state; return; }
916
- _state = state; _start = null;
917
- if(!_raf) _raf = requestAnimationFrame(_frame);
918
- };
919
- })();
920
- </script>
921
- </body>
922
- </html>