@grainulation/orchard 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +118 -0
- package/bin/orchard.js +209 -0
- package/lib/assignments.js +98 -0
- package/lib/conflicts.js +150 -0
- package/lib/dashboard.js +291 -0
- package/lib/doctor.js +137 -0
- package/lib/export.js +98 -0
- package/lib/farmer.js +107 -0
- package/lib/planner.js +198 -0
- package/lib/server.js +707 -0
- package/lib/sync.js +100 -0
- package/lib/tracker.js +124 -0
- package/package.json +50 -0
- package/public/index.html +922 -0
- package/templates/dashboard.html +981 -0
- package/templates/orchard-dashboard.html +171 -0
|
@@ -0,0 +1,922 @@
|
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
|
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>
|