@grainulation/harvest 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,1230 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
6
+ <meta name="apple-mobile-web-app-capable" content="yes">
7
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
8
+ <meta name="theme-color" content="#0a0e1a">
9
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><rect width='64' height='64' rx='14' fill='%230a0e1a'/><text x='32' y='34' text-anchor='middle' dominant-baseline='central' fill='%23fb923c' font-family='-apple-system,system-ui,sans-serif' font-weight='800' font-size='32'>H</text></svg>">
10
+ <title>Harvest</title>
11
+ <style>
12
+ :root {
13
+ --bg: #0a0e1a; --bg-s: #111827; --bg-e: #1a2332; --bg-h: #1e293b;
14
+ --bdr: #1e293b; --bdr-f: #334155;
15
+ --txt: #e2e8f0; --txt-m: #94a3b8; --txt-d: #8494a7;
16
+ --acc: #fb923c; --acc-d: #7c4a1e;
17
+ --grn: #22c55e; --grn-d: rgba(34,197,94,0.12);
18
+ --ylw: #eab308; --ylw-d: rgba(234,179,8,0.08);
19
+ --org: #f97316; --org-d: rgba(249,115,22,0.1);
20
+ --red: #ef4444; --red-d: rgba(239,68,68,0.1);
21
+ --pur: #a855f7; --pur-d: rgba(168,85,247,0.1);
22
+ --cyn: #06b6d4; --cyn-d: rgba(6,182,212,0.1);
23
+ }
24
+ *{margin:0;padding:0;box-sizing:border-box}
25
+ body{font-family:'SF Mono','Fira Code','JetBrains Mono',monospace;background:var(--bg);background-image:radial-gradient(ellipse at 20% 50%,rgba(59,130,246,0.08) 0%,transparent 60%),radial-gradient(ellipse at 80% 20%,rgba(167,139,250,0.06) 0%,transparent 50%);color:var(--txt);line-height:1.6;overflow-y:auto}
26
+
27
+ /* Accessibility: skip link */
28
+ .skip-link{position:absolute;top:-100px;left:0;background:var(--acc);color:#fff;padding:8px 16px;z-index:1000;font-size:12px;text-decoration:none;border-radius:0 0 4px 0}
29
+ .skip-link:focus{top:0}
30
+
31
+ /* Accessibility: screen-reader-only text */
32
+ .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
33
+
34
+ /* Accessibility: visible focus outlines */
35
+ :focus-visible{outline:2px solid var(--acc);outline-offset:2px}
36
+
37
+ /* Accessibility: status labels for color-coded elements */
38
+ .status-label{font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;margin-left:4px}
39
+ .status-label-good{color:var(--grn)}.status-label-warn{color:var(--ylw)}.status-label-critical{color:var(--red)}
40
+
41
+ /* Accessibility: reduced motion */
42
+ @media (prefers-reduced-motion: reduce) {
43
+ .hero-spark .bar, .collapsible-header .arrow, .collapsible-body { transition: none !important; animation: none !important; }
44
+ }
45
+
46
+ /* 8px grid spacing */
47
+ .app{max-width:1120px;margin:0 auto;padding:32px 24px;padding-left:calc(24px + env(safe-area-inset-left));padding-right:calc(24px + env(safe-area-inset-right));padding-bottom:calc(32px + env(safe-area-inset-bottom));overflow-x:hidden}
48
+
49
+ /* Toolbar */
50
+ .toolbar{position:sticky;top:0;z-index:100;background:rgba(255,255,255,0.08);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border-bottom:1px solid var(--bdr);padding:4px 24px;padding-top:calc(4px + env(safe-area-inset-top));padding-left:calc(24px + env(safe-area-inset-left));padding-right:calc(24px + env(safe-area-inset-right));display:flex;align-items:center;gap:10px}
51
+ .toolbar canvas{flex-shrink:0}
52
+ .tool-status{font-size:10px;color:var(--txt-d);transition:color 0.3s}
53
+ .toolbar-spacer{flex:1}
54
+ .toolbar-right{display:flex;align-items:center;gap:8px}
55
+ .sprint-select{padding:6px 28px 6px 12px;border-radius:6px;background:#111827;border:1px solid #1e293b;color:#e2e8f0;font-size:12px;font-family:inherit;outline:none;cursor:pointer;max-width:320px;text-overflow:ellipsis;-webkit-appearance:none;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='none'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%2394a3b8' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center}
56
+ .status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;background:var(--txt-d);transition:background 0.3s,box-shadow 0.3s}
57
+ .status-dot.ok{background:#34c759;box-shadow:0 0 6px rgba(52,199,89,0.5)}
58
+ header .meta{color:var(--txt-d);font-size:11px;display:flex;gap:16px;flex-wrap:wrap;flex:1;display:none}
59
+
60
+ /* Hero metric */
61
+ .hero{background:var(--bg-s);border:1px solid var(--bdr);border-radius:4px;padding:24px;margin-bottom:24px;display:flex;flex-direction:column;gap:12px;overflow:hidden}
62
+ .hero-top{display:flex;align-items:flex-start;gap:24px}
63
+ .hero-score{font-size:48px;font-weight:700;line-height:1;letter-spacing:-1px;flex-shrink:0}
64
+ .hero-detail{flex:1;min-width:0}
65
+ .hero-label{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.8px;color:var(--txt-d);margin-bottom:4px}
66
+ .hero-prose{font-size:13px;color:var(--txt-m);line-height:1.6;margin-top:8px}
67
+ .hero-prose strong{color:var(--txt)}
68
+ .hero-spark{display:flex;align-items:flex-end;gap:2px;height:48px;overflow:hidden;min-width:0;padding-right:8px}
69
+ .hero-spark .bar{flex:1 1 0;max-width:24px;min-width:2px;border-radius:2px 2px 0 0;background:var(--acc);opacity:0.5;transition:height 0.3s}
70
+ .hero-spark .bar.current{opacity:1}
71
+ .hero-spark-labels{display:flex;gap:2px;margin-top:4px;font-size:9px;color:var(--txt-d);overflow:hidden;padding-right:8px}
72
+ .hero-spark-labels span{flex:1 1 0;max-width:24px;min-width:0;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
73
+ .hero-spark-labels .current{color:var(--acc);font-weight:700}
74
+
75
+ /* KPI sections */
76
+ .kpi-group{margin-bottom:24px}
77
+ .kpi-group-title{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.8px;color:var(--txt-d);margin-bottom:8px;padding-left:4px}
78
+ .kpi-row{display:grid;gap:16px;grid-template-columns:repeat(auto-fit, minmax(200px, 1fr))}
79
+ .kpi{background:var(--bg-s);border:1px solid var(--bdr);border-radius:4px;padding:16px}
80
+ .kpi-label{font-size:10px;text-transform:uppercase;letter-spacing:0.6px;color:var(--txt-d);margin-bottom:4px}
81
+ .kpi-value{font-size:24px;font-weight:700;line-height:1.2}
82
+ .kpi-delta{font-size:11px;margin-top:4px;display:flex;align-items:center;gap:4px}
83
+ .kpi-delta.up{color:var(--grn)}.kpi-delta.down{color:var(--red)}.kpi-delta.neutral{color:var(--txt-d)}
84
+ .kpi-spark{display:flex;align-items:flex-end;gap:1px;height:24px;margin-top:8px}
85
+ .kpi-spark .bar{flex:1;min-width:2px;border-radius:1px 1px 0 0;background:var(--acc);opacity:0.4}
86
+ .kpi-spark .bar.current{opacity:1}
87
+
88
+ /* Section headers */
89
+ .section{margin-bottom:32px}
90
+ .section-title{font-size:13px;font-weight:600;margin-bottom:16px;padding-bottom:8px;border-bottom:1px solid var(--bdr);display:flex;align-items:center;gap:8px}
91
+ .section-title .count{color:var(--txt-d);font-size:11px;font-weight:400}
92
+
93
+ /* Heatmap */
94
+ .heatmap-wrap{overflow-x:auto;margin:0 -4px;padding:0 4px}
95
+ .heatmap{border-collapse:collapse;width:100%;font-size:11px}
96
+ .heatmap th{padding:6px 8px;text-align:left;font-weight:600;font-size:10px;text-transform:uppercase;letter-spacing:0.5px;color:var(--txt-d);border-bottom:1px solid var(--bdr);position:sticky;top:0;background:var(--bg)}
97
+ .heatmap th.rotate{writing-mode:vertical-lr;text-align:center;padding:8px 4px;min-width:32px}
98
+ .heatmap td{padding:4px 8px;border-bottom:1px solid var(--bdr)}
99
+ .heatmap tr:hover{background:var(--bg-s)}
100
+ .heatmap .topic-name{color:var(--txt-m);font-size:11px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
101
+ .heatmap .cell{text-align:center;min-width:32px;border-radius:2px;font-size:10px;color:var(--txt)}
102
+ .heatmap .cell.has-conflict{outline:1px solid var(--ylw);outline-offset:-1px}
103
+ .heat-0{background:transparent;color:var(--txt-d)}.heat-1{background:rgba(59,130,246,0.15)}.heat-2{background:rgba(59,130,246,0.25)}
104
+ .heat-3{background:rgba(59,130,246,0.4)}.heat-4{background:rgba(59,130,246,0.55)}.heat-5{background:rgba(59,130,246,0.7)}
105
+
106
+ /* Collapsible sections */
107
+ .collapsible{margin-bottom:24px;border:1px solid var(--bdr);border-radius:4px;overflow:hidden}
108
+ .collapsible-header{padding:12px 16px;background:var(--bg-s);cursor:pointer;display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;user-select:none}
109
+ .collapsible-header:hover{background:var(--bg-e)}
110
+ .collapsible-header:active{background:var(--bg-h);transform:scale(0.995)}
111
+ .collapsible-header .arrow{color:var(--txt-d);font-size:10px;transition:transform 0.2s}
112
+ .collapsible.open .arrow{transform:rotate(90deg)}
113
+ .collapsible-body{display:none;padding:16px;border-top:1px solid var(--bdr)}
114
+ .collapsible.open .collapsible-body{display:block}
115
+
116
+ /* Decay list */
117
+ .decay-item{display:flex;align-items:center;gap:12px;padding:6px 0;border-bottom:1px solid var(--bdr);font-size:11px}
118
+ .decay-item:last-child{border-bottom:none}
119
+ .decay-score{width:40px;text-align:right;font-weight:600;flex-shrink:0}
120
+ .decay-bar{width:60px;height:6px;background:var(--bg-h);border-radius:3px;overflow:hidden;flex-shrink:0}
121
+ .decay-fill{height:100%;border-radius:3px}
122
+ .decay-content{flex:1;color:var(--txt-m);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
123
+ .decay-id{color:var(--txt-d);width:36px;flex-shrink:0;font-size:10px}
124
+ .decay-tier{font-size:9px;padding:1px 4px;border-radius:2px;flex-shrink:0}
125
+
126
+ /* Claims table */
127
+ .claims-table{width:100%;border-collapse:collapse;font-size:11px}
128
+ .claims-table th{text-align:left;padding:6px 8px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--txt-d);border-bottom:1px solid var(--bdr);position:sticky;top:0;background:var(--bg)}
129
+ .claims-table td{padding:5px 8px;border-bottom:1px solid var(--bdr);vertical-align:top}
130
+ .claims-table tr:hover{background:var(--bg-s)}
131
+ .claims-table .content-cell{max-width:400px;color:var(--txt-m);cursor:pointer}
132
+ .claims-table .content-cell.collapsed{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
133
+ .claims-table .content-cell.expanded{white-space:normal;word-break:break-word}
134
+ .badge{font-size:9px;padding:1px 5px;border-radius:2px;font-weight:600;display:inline-block}
135
+ .badge-factual{background:var(--acc-d);color:var(--acc)}
136
+ .badge-risk{background:var(--red-d);color:var(--red)}
137
+ .badge-recommendation{background:var(--grn-d);color:var(--grn)}
138
+ .badge-constraint{background:var(--pur-d);color:var(--pur)}
139
+ .badge-estimate{background:var(--ylw-d);color:var(--ylw)}
140
+ .badge-feedback{background:var(--org-d);color:var(--org)}
141
+
142
+ /* Phase bars */
143
+ .phase-bars{display:flex;flex-direction:column;gap:4px}
144
+ .phase-bar{display:flex;align-items:center;gap:8px;font-size:11px}
145
+ .phase-bar .label{width:80px;text-align:right;color:var(--txt-d);font-size:10px;flex-shrink:0}
146
+ .phase-bar .track{flex:1;height:16px;background:var(--bg-h);border-radius:2px;overflow:hidden;position:relative}
147
+ .phase-bar .fill{height:100%;border-radius:2px;display:flex;align-items:center;justify-content:flex-end;padding-right:6px;font-size:9px;color:var(--txt);min-width:20px}
148
+ .phase-bar .count{width:32px;text-align:right;color:var(--txt-m);font-size:11px;flex-shrink:0}
149
+
150
+ /* Small multiples (cross-sprint comparison) */
151
+ .small-multiples{display:grid;grid-template-columns:repeat(auto-fit, minmax(280px, 1fr));gap:16px}
152
+ .mini-chart{background:var(--bg-s);border:1px solid var(--bdr);border-radius:4px;padding:16px}
153
+ .mini-chart-title{font-size:11px;font-weight:600;margin-bottom:12px;color:var(--txt-m)}
154
+ .mini-bars{display:flex;flex-direction:column;gap:3px}
155
+ .mini-bar{display:flex;align-items:center;gap:6px;font-size:10px}
156
+ .mini-bar .label{width:70px;text-align:right;color:var(--txt-d);flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
157
+ .mini-bar .track{flex:1;height:10px;background:var(--bg-h);border-radius:2px;overflow:hidden}
158
+ .mini-bar .fill{height:100%;border-radius:2px;display:block}
159
+ .mini-bar .val{width:24px;text-align:right;color:var(--txt-d);flex-shrink:0}
160
+
161
+ /* Search/filter */
162
+ .filter-bar{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap}
163
+ .filter-bar input{background:var(--bg-s);border:1px solid var(--bdr);color:var(--txt);padding:6px 12px;border-radius:3px;font-family:inherit;font-size:11px;flex:1;min-width:200px}
164
+ .filter-bar input:focus{outline:none;border-color:var(--acc)}
165
+ .filter-btn{background:var(--bg-s);border:1px solid var(--bdr);color:var(--txt-m);padding:8px 12px;border-radius:3px;font-family:inherit;font-size:10px;cursor:pointer;min-height:36px}
166
+ .filter-btn:hover{border-color:var(--bdr-f);color:var(--txt)}
167
+ .filter-btn.active{border-color:var(--acc);color:var(--acc);background:var(--acc-d)}
168
+ .filter-btn:active{transform:scale(0.96);opacity:0.8}
169
+
170
+ /* Actions */
171
+ .actions{display:flex;flex-direction:column;gap:8px}
172
+ .action{display:flex;gap:12px;padding:12px 16px;background:var(--bg-s);border:1px solid var(--bdr);border-radius:4px;align-items:flex-start;font-size:12px;border-left:3px solid var(--acc)}
173
+ .action.a-crit{border-left-color:var(--red)}
174
+ .action.a-warn{border-left-color:var(--ylw)}
175
+ .action.a-info{border-left-color:var(--acc)}
176
+ .action.a-good{border-left-color:var(--grn)}
177
+ .action-priority{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:0.5px;padding:2px 6px;border-radius:2px;flex-shrink:0;min-width:28px;text-align:center}
178
+ .action-priority.p1{background:var(--red-d);color:var(--red)}
179
+ .action-priority.p2{background:var(--ylw-d);color:var(--ylw)}
180
+ .action-priority.p3{background:var(--acc-d);color:var(--acc)}
181
+ .action-body{flex:1;min-width:0}
182
+ .action-title{font-weight:600;color:var(--txt);margin-bottom:2px}
183
+ .action-detail{font-size:11px;color:var(--txt-m);line-height:1.5}
184
+ .action-cmd{font-size:10px;color:var(--acc);margin-top:4px;font-family:inherit}
185
+ .action-targets{display:flex;gap:4px;flex-wrap:wrap;margin-top:4px}
186
+ .action-target{font-size:9px;padding:1px 5px;background:var(--bg-h);border-radius:2px;color:var(--txt-d)}
187
+
188
+ /* Footer */
189
+ footer{margin-top:32px;padding-top:16px;border-top:1px solid var(--bdr);color:var(--txt-d);font-size:10px;display:flex;justify-content:space-between}
190
+ .grain-idle { display: block; margin: 0 auto 10px; }
191
+
192
+ /* Mobile */
193
+ @media(max-width:768px){
194
+ .toolbar{padding:4px 12px}
195
+ .app{padding:16px 12px}
196
+ .hero{padding:14px;gap:10px}
197
+ .hero-top{gap:12px;flex-direction:column}
198
+ .hero-score{font-size:22px}
199
+ .hero-spark{height:32px}
200
+ .kpi-row{grid-template-columns:1fr 1fr}
201
+ .kpi{padding:10px}
202
+ .kpi-value{font-size:18px}
203
+ .kpi-spark{height:18px}
204
+ .section{margin-bottom:16px}
205
+ .heatmap-grid{font-size:9px}
206
+ .small-multiples{grid-template-columns:1fr}
207
+ .claims-list .claim{padding:8px 10px}
208
+ .collapsible-header{padding:10px 12px;font-size:11px}
209
+ .collapsible-body{padding:10px}
210
+ .action-card{padding:8px 10px}
211
+ .sprint-select{max-width:180px}
212
+ }
213
+ /* Small phones */
214
+ @media(max-width:480px){
215
+ .toolbar{padding:4px 8px;gap:6px}
216
+ .app{padding:12px 8px}
217
+ .hero{padding:10px}
218
+ .hero-top{gap:8px}
219
+ .hero-score{font-size:18px}
220
+ .kpi-row{grid-template-columns:1fr}
221
+ .kpi-value{font-size:16px}
222
+ .sprint-select{max-width:140px}
223
+ }
224
+ </style>
225
+ </head>
226
+ <body>
227
+ <a href="#main-content" class="skip-link">Skip to main content</a>
228
+
229
+ <!-- Toolbar -->
230
+ <header class="toolbar" role="banner">
231
+ <canvas id="grainLogo" width="256" height="256"></canvas>
232
+ <div class="toolbar-spacer"></div>
233
+ <div class="toolbar-right">
234
+ <select id="sprintSelector" class="sprint-select" aria-label="Select sprint to analyze"></select>
235
+ <div class="status-dot" id="statusDot"></div>
236
+ </div>
237
+ </header>
238
+
239
+ <div class="app">
240
+ <!-- Layer 2: Overview — Hero + KPIs -->
241
+ <main id="main-content">
242
+ <div class="hero" id="hero" role="region" aria-label="Sprint health overview"></div>
243
+
244
+ <!-- Actions: what to do right now -->
245
+ <div id="actions-section" style="margin-bottom:24px"></div>
246
+
247
+ <div id="kpi-groups"></div>
248
+
249
+ <!-- Layer 3: Diagnosis — Topic Coverage Heatmap -->
250
+ <div class="section" id="heatmap-section" role="region" aria-label="Topic coverage heatmap">
251
+ <div class="section-title">Topic Coverage <span class="count" id="heatmap-count"></span></div>
252
+ <div class="heatmap-wrap">
253
+ <table class="heatmap" id="heatmap" role="table" aria-label="Claim counts by topic and type"></table>
254
+ </div>
255
+ </div>
256
+
257
+ <!-- Layer 3b: Small Multiples (cross-sprint comparison) -->
258
+ <div class="section" id="multiples-section" style="display:none">
259
+ <div class="section-title">Cross-Sprint Comparison</div>
260
+ <div class="small-multiples" id="small-multiples"></div>
261
+ </div>
262
+
263
+ <!-- Layer 4: Details — Collapsible -->
264
+ <div class="collapsible" id="decay-section">
265
+ <div class="collapsible-header" role="button" tabindex="0" aria-expanded="false" aria-controls="decay-body" onclick="toggle('decay-section')" onkeydown="toggleKey(event,'decay-section')">
266
+ <span class="arrow" aria-hidden="true">&#9654;</span>
267
+ Decay Analysis
268
+ <span class="count" id="decay-count"></span>
269
+ </div>
270
+ <div class="collapsible-body" id="decay-body" role="region" aria-label="Decay analysis details"></div>
271
+ </div>
272
+
273
+ <div class="collapsible" id="phase-section">
274
+ <div class="collapsible-header" role="button" tabindex="0" aria-expanded="false" aria-controls="phase-body" onclick="toggle('phase-section')" onkeydown="toggleKey(event,'phase-section')">
275
+ <span class="arrow" aria-hidden="true">&#9654;</span>
276
+ Phase Breakdown
277
+ <span class="count" id="phase-count"></span>
278
+ </div>
279
+ <div class="collapsible-body" id="phase-body" role="region" aria-label="Phase breakdown details"></div>
280
+ </div>
281
+
282
+ <div class="collapsible" id="insights-section">
283
+ <div class="collapsible-header" role="button" tabindex="0" aria-expanded="false" aria-controls="insights-body" onclick="toggle('insights-section')" onkeydown="toggleKey(event,'insights-section')">
284
+ <span class="arrow" aria-hidden="true">&#9654;</span>
285
+ Insights
286
+ <span class="count" id="insights-count"></span>
287
+ </div>
288
+ <div class="collapsible-body" id="insights-body" role="region" aria-label="Insights details"></div>
289
+ </div>
290
+
291
+ <!-- Layer 5: Data — Full Claims Table -->
292
+ <div class="collapsible" id="claims-section">
293
+ <div class="collapsible-header" role="button" tabindex="0" aria-expanded="false" aria-controls="claims-body" onclick="toggle('claims-section')" onkeydown="toggleKey(event,'claims-section')">
294
+ <span class="arrow" aria-hidden="true">&#9654;</span>
295
+ All Claims
296
+ <span class="count" id="claims-count"></span>
297
+ </div>
298
+ <div class="collapsible-body" id="claims-body" role="region" aria-label="All claims details">
299
+ <div class="filter-bar" id="filter-bar" role="search" aria-label="Filter claims"></div>
300
+ <div style="max-height:500px;overflow-y:auto">
301
+ <table class="claims-table" id="claims-table" aria-live="polite"></table>
302
+ </div>
303
+ </div>
304
+ </div>
305
+
306
+ </main>
307
+
308
+ <footer role="contentinfo">
309
+ <span id="footer-left"></span>
310
+ <span id="footer-right"></span>
311
+ </footer>
312
+
313
+ </div>
314
+
315
+ <script>
316
+ const SPRINTS = __SPRINT_DATA__;
317
+
318
+ // --- Computed metrics ---
319
+ function computeMetrics(sprint) {
320
+ const c = sprint.c;
321
+ const total = c.length;
322
+ const active = c.filter(x => x.s === 'active').length;
323
+ const types = {};
324
+ const topics = {};
325
+ const evidence = {};
326
+ const phases = {};
327
+ const conflicts = c.filter(x => x.cf && x.cf.length > 0);
328
+
329
+ c.forEach(cl => {
330
+ types[cl.t] = (types[cl.t] || 0) + 1;
331
+ topics[cl.tp] = topics[cl.tp] || { types: {}, count: 0, hasConflict: false };
332
+ topics[cl.tp].types[cl.t] = (topics[cl.tp].types[cl.t] || 0) + 1;
333
+ topics[cl.tp].count++;
334
+ if (cl.cf && cl.cf.length) topics[cl.tp].hasConflict = true;
335
+ evidence[cl.e] = (evidence[cl.e] || 0) + 1;
336
+ phases[cl.p] = (phases[cl.p] || 0) + 1;
337
+ });
338
+
339
+ // Evidence strength: weighted average (stated=1, web=2, documented=3, tested=4, production=5)
340
+ const tierWeight = { stated: 1, web: 2, documented: 3, tested: 4, production: 5 };
341
+ const evidenceStrength = total > 0 ? c.reduce((s, cl) => s + (tierWeight[cl.e] || 1), 0) / total : 0;
342
+
343
+ // Verification coverage: % of claims with tested or production evidence
344
+ const verified = c.filter(cl => cl.e === 'tested' || cl.e === 'production').length;
345
+ const verificationCoverage = total > 0 ? verified / total : 0;
346
+
347
+ // Challenge rate: % of claims that have been challenged (have conflicts)
348
+ const challenged = c.filter(cl => cl.cf && cl.cf.length > 0).length;
349
+ const challengeRate = total > 0 ? challenged / total : 0;
350
+
351
+ // Eval/research ratio
352
+ const evalClaims = c.filter(cl => cl.p === 'evaluate').length;
353
+ const researchClaims = c.filter(cl => cl.p === 'research').length;
354
+ const evalResearchRatio = researchClaims > 0 ? evalClaims / researchClaims : 0;
355
+
356
+ // Conflict resolution rate
357
+ const superseded = c.filter(cl => cl.s === 'superseded').length;
358
+ const conflictResRate = conflicts.length + superseded > 0 ? superseded / (conflicts.length + superseded) : 1;
359
+
360
+ // Decay: sprint-relative time-based scoring (p014 fix)
361
+ // Evidence tier is a credibility score, NOT a decay signal (p012 fix)
362
+ const now = Date.now();
363
+ const timestamps = c.map(cl => cl.ts ? new Date(cl.ts).getTime() : 0).filter(t => t > 0);
364
+ const sprintSpanDays = timestamps.length >= 2
365
+ ? (Math.max(...timestamps) - Math.min(...timestamps)) / (1000 * 60 * 60 * 24)
366
+ : 30;
367
+ const baseSprintDays = 30;
368
+ const decayScale = Math.max(sprintSpanDays / baseSprintDays, 1 / 720); // floor ~2min
369
+ const baseHalfLives = { stated: 7, web: 14, documented: 30, tested: 60, production: 90 };
370
+ const credibilityScores = { stated: 0.2, web: 0.4, documented: 0.6, tested: 0.8, production: 1.0 };
371
+
372
+ const decayScores = c.filter(cl => cl.s === 'active').map(cl => {
373
+ const age = cl.ts ? (now - new Date(cl.ts).getTime()) / (1000 * 60 * 60 * 24) : 30;
374
+ const halfLife = (baseHalfLives[cl.e] || 14) * decayScale;
375
+ const score = Math.pow(0.5, age / halfLife); // freshness (1=fresh, 0=decayed)
376
+ const credibility = credibilityScores[cl.e] || 0.3;
377
+ return { ...cl, decay: score, credibility };
378
+ }).sort((a, b) => a.decay - b.decay);
379
+
380
+ // Avg decay
381
+ const avgDecay = decayScores.length > 0 ? decayScores.reduce((s, x) => s + x.decay, 0) / decayScores.length : 1;
382
+
383
+ // Unchallenged topic %
384
+ const topicKeys = Object.keys(topics);
385
+ const unchallengedTopics = topicKeys.filter(t => !topics[t].hasConflict).length;
386
+ const unchallengedPct = topicKeys.length > 0 ? unchallengedTopics / topicKeys.length : 0;
387
+
388
+ // Evidence depth: weighted by verification effort
389
+ // web=0.3 (research is real work), documented=0.5, tested=0.8, production=1.0
390
+ const evidenceDepth = total > 0 ? c.reduce((s, cl) => {
391
+ const w = { stated: 0, web: 0.3, documented: 0.5, tested: 0.8, production: 1.0 };
392
+ return s + (w[cl.e] || 0);
393
+ }, 0) / total : 0;
394
+
395
+ // Type diversity: how many of 6 types are represented?
396
+ const typeDiversity = Object.keys(types).length / 6;
397
+
398
+ // Phase-aware coverage: score relative to what the current phase expects
399
+ // research phase only needs research+define, prototype needs 3, evaluate needs 4+
400
+ const phaseCount = Object.keys(phases).length;
401
+ const sprintPhase = c.length > 0 ? (c[c.length - 1].p || 'research') : 'research';
402
+ const phaseExpected = { define: 1, research: 2, prototype: 3, evaluate: 4, feedback: 4 }[sprintPhase] || 3;
403
+ const phaseCoverage = Math.min(phaseCount / phaseExpected, 1);
404
+
405
+ // Process health: conflict resolution + challenge coverage (was display-only, now scored)
406
+ const processHealth = (conflictResRate * 0.6) + (Math.min(challengeRate / 0.1, 1) * 0.4);
407
+
408
+ // Composite health score (0-100)
409
+ // Evidence quality 25, evidence depth 15, freshness 15, type diversity 10, phase coverage 10, process 25
410
+ const rawHealth = Math.round(
411
+ (evidenceStrength / 5) * 25 +
412
+ evidenceDepth * 15 +
413
+ avgDecay * 15 +
414
+ typeDiversity * 10 +
415
+ phaseCoverage * 10 +
416
+ processHealth * 25
417
+ );
418
+
419
+ // Size confidence: penalize small sprints
420
+ const sizeConfidence = total < 10 ? 0.5 : total < 25 ? 0.75 : total < 50 ? 0.9 : 1.0;
421
+ const health = Math.round(rawHealth * sizeConfidence);
422
+ const lowSample = sizeConfidence < 1.0;
423
+
424
+ return {
425
+ total, active, types, topics, evidence, phases, conflicts,
426
+ evidenceStrength, verificationCoverage, challengeRate, evalResearchRatio,
427
+ conflictResRate, decayScores, avgDecay, unchallengedPct, health, rawHealth, sizeConfidence, lowSample
428
+ };
429
+ }
430
+
431
+ // --- Cross-sprint normalization ---
432
+ // Compute percentile ranks so no single sprint dominates by having unusual distributions
433
+ const allRaw = SPRINTS.map(s => computeMetrics(s));
434
+
435
+ // Percentile rank helper: where does value sit among peers? Returns 0-1
436
+ function pctRank(val, arr) {
437
+ if (arr.length <= 1) return 0.5;
438
+ const sorted = arr.slice().sort((a, b) => a - b);
439
+ const idx = sorted.findIndex(v => v >= val);
440
+ return idx < 0 ? 1 : idx / (sorted.length - 1);
441
+ }
442
+
443
+ // For each metric, compute the percentile rank across ALL sprints
444
+ const evStrengths = allRaw.map(m => m.evidenceStrength);
445
+ const evDepths = allRaw.map(m => {
446
+ const total = m.total;
447
+ if (total === 0) return 0;
448
+ const w = { stated: 0, web: 0.3, documented: 0.5, tested: 0.8, production: 1.0 };
449
+ let sum = 0;
450
+ Object.entries(m.evidence).forEach(([tier, cnt]) => { sum += (w[tier] || 0) * cnt; });
451
+ return sum / total;
452
+ });
453
+ const typeDivs = allRaw.map(m => Object.keys(m.types).length / 6);
454
+ const freshnesses = allRaw.map(m => m.avgDecay);
455
+ const processScores = allRaw.map(m => (m.conflictResRate * 0.6) + (Math.min(m.challengeRate / 0.1, 1) * 0.4));
456
+
457
+ // Attach normalized health to each metric set
458
+ const allMetrics = allRaw.map((m, i) => {
459
+ const sprint = SPRINTS[i];
460
+ const cat = sprint.cat || 'active';
461
+
462
+ // Percentile-based health: each dimension scored relative to peers
463
+ const evPct = pctRank(evStrengths[i], evStrengths);
464
+ const depthPct = pctRank(evDepths[i], evDepths);
465
+ const freshPct = pctRank(freshnesses[i], freshnesses);
466
+ const typePct = pctRank(typeDivs[i], typeDivs);
467
+ const procPct = pctRank(processScores[i], processScores);
468
+ const phaseCount = Object.keys(m.phases).length;
469
+ const phasePct = Math.min(phaseCount / 3, 1);
470
+
471
+ // Same weights as raw: evidence 25, depth 15, freshness 15, types 10, phase 10, process 25
472
+ const normalizedRaw = Math.round(
473
+ evPct * 25 +
474
+ depthPct * 15 +
475
+ freshPct * 15 +
476
+ typePct * 10 +
477
+ phasePct * 10 +
478
+ procPct * 25
479
+ );
480
+
481
+ // Size confidence penalty
482
+ const total = m.total;
483
+ const sizeConf = total < 10 ? 0.5 : total < 25 ? 0.75 : total < 50 ? 0.9 : 1.0;
484
+ // Category penalty: only archive gets a mild penalty (root is the active sprint, not stale)
485
+ const catConf = cat === 'archive' ? 0.9 : 1.0;
486
+ const normHealth = Math.round(normalizedRaw * sizeConf * catConf);
487
+ const lowSample = sizeConf < 1.0;
488
+ const lowLabel = cat === 'archive' ? 'archived' : sizeConf < 1.0 ? 'low sample' : '';
489
+
490
+ return { ...m, health: normHealth, rawHealth: m.health, sizeConfidence: sizeConf, lowSample, lowLabel, cat };
491
+ });
492
+
493
+ // Build merged "All sprints" synthetic entry
494
+ const allClaims = [];
495
+ SPRINTS.forEach(s => { s.c.forEach(cl => allClaims.push(cl)); });
496
+ const ALL_SPRINT = { n: 'All sprints', c: allClaims, cat: 'all', p: 'all', q: '' };
497
+ const ALL_RAW = computeMetrics(ALL_SPRINT);
498
+ const ALL_METRIC = { ...ALL_RAW, health: ALL_RAW.health, rawHealth: ALL_RAW.rawHealth, sizeConfidence: ALL_RAW.sizeConfidence, lowSample: false, lowLabel: '', cat: 'all' };
499
+
500
+ // Default to "all sprints" (idx = -1)
501
+ let selectedIdx = -1;
502
+
503
+ // Populate sprint selector
504
+ const sprintSelector = document.getElementById('sprintSelector');
505
+ const allOpt = document.createElement('option');
506
+ allOpt.value = -1;
507
+ allOpt.textContent = 'All sprints (' + allClaims.length + ' claims)';
508
+ sprintSelector.appendChild(allOpt);
509
+ const catLabels = { active: '', archive: '[archived] ', root: '[root] ' };
510
+ SPRINTS.forEach((s, i) => {
511
+ const opt = document.createElement('option');
512
+ opt.value = i;
513
+ const cat = s.cat || 'active';
514
+ opt.textContent = (catLabels[cat] || '') + s.n + ' (' + s.c.length + ' claims)';
515
+ sprintSelector.appendChild(opt);
516
+ });
517
+ sprintSelector.value = selectedIdx;
518
+ sprintSelector.addEventListener('change', function() {
519
+ selectedIdx = parseInt(this.value);
520
+ renderAll(selectedIdx);
521
+ });
522
+
523
+ // Global state for claims filtering (used by inline event handlers)
524
+ var _currentClaims = [];
525
+ var filterType = null;
526
+ var filterText = '';
527
+
528
+ function renderClaims() {
529
+ let filtered = _currentClaims;
530
+ if (filterType) filtered = filtered.filter(c => c.t === filterType);
531
+ if (filterText) {
532
+ const q = filterText.toLowerCase();
533
+ filtered = filtered.filter(c => (c.c || '').toLowerCase().includes(q) || (c.tp || '').toLowerCase().includes(q) || (c.i || '').toLowerCase().includes(q));
534
+ }
535
+
536
+ const humanTopic = t => (t || '').replace(/-/g, ' ');
537
+ const humanEvidence = e => ({ stated: 'stated', web: 'web', documented: 'docs', tested: 'tested', production: 'prod' }[e] || e);
538
+
539
+ document.getElementById('claims-table').innerHTML = `
540
+ <thead><tr><th>ID</th><th>Type</th><th>Topic</th><th>Content</th><th>Evidence</th><th>Status</th></tr></thead>
541
+ <tbody>
542
+ ${filtered.map(c => `
543
+ <tr>
544
+ <td style="color:var(--txt-d);font-size:10px;font-weight:600">${c.i}</td>
545
+ <td><span class="badge badge-${c.t}">${c.t}</span></td>
546
+ <td style="color:var(--txt-m);font-size:10px;max-width:120px" title="${c.tp}">${humanTopic(c.tp)}</td>
547
+ <td class="content-cell collapsed" onclick="this.classList.toggle('collapsed');this.classList.toggle('expanded')">${c.c || ''}</td>
548
+ <td style="font-size:10px"><span class="badge badge-${c.t === 'risk' ? 'risk' : c.e === 'tested' ? 'recommendation' : c.e === 'documented' ? 'factual' : 'estimate'}">${humanEvidence(c.e)}</span></td>
549
+ <td style="font-size:10px;color:${c.s === 'active' ? 'var(--grn)' : c.s === 'superseded' ? 'var(--txt-d)' : 'var(--ylw)'}" aria-label="Status: ${c.s}">${c.s}</td>
550
+ </tr>
551
+ `).join('')}
552
+ </tbody>
553
+ `;
554
+ }
555
+
556
+ function renderAll(idx) {
557
+ const current = idx >= 0 ? SPRINTS[idx] : ALL_SPRINT;
558
+ const m = idx >= 0 ? allMetrics[idx] : ALL_METRIC;
559
+ const prev = idx > 0 ? allMetrics[idx - 1] : null;
560
+
561
+ // Toolbar status
562
+ // (toolbar status removed — sprint info shown in dropdown + hero)
563
+ const dot = document.getElementById('statusDot');
564
+ dot.classList.remove('ok'); dot.classList.add('ok');
565
+
566
+ // Hero
567
+ function healthColor(h) {
568
+ if (h >= 70) return 'var(--grn)';
569
+ if (h >= 40) return 'var(--ylw)';
570
+ return 'var(--red)';
571
+ }
572
+
573
+ function healthLabel(h) {
574
+ if (h >= 70) return 'good';
575
+ if (h >= 40) return 'warn';
576
+ return 'critical';
577
+ }
578
+
579
+ function statusLabelHtml(h) {
580
+ const label = healthLabel(h);
581
+ return `<span class="status-label status-label-${label}">${label}</span>`;
582
+ }
583
+
584
+ function sparkBars(values, currentIdx) {
585
+ return values.map((v, i) =>
586
+ `<div class="bar${i === currentIdx ? ' current' : ''}" style="height:${Math.max(4, v)}%;background:${healthColor(v)}"></div>`
587
+ ).join('');
588
+ }
589
+
590
+ const healthHistory = allMetrics.map(m => m.health);
591
+ const lowSampleHtml = m.lowLabel ? ` <span style="color:var(--ylw);font-size:11px;font-weight:400">(${m.lowLabel})</span>` : '';
592
+ const heroProseLines = [];
593
+ heroProseLines.push(`Analyzing <strong>${current.n}</strong> (${m.total} claims) -- ${SPRINTS.length} sprint${SPRINTS.length > 1 ? 's' : ''} loaded`);
594
+ if (m.evidenceStrength >= 2) heroProseLines.push(`Evidence strength <strong>${m.evidenceStrength.toFixed(2)}</strong> (${m.evidenceStrength >= 3 ? 'strong' : 'moderate'}) -- ${(m.verificationCoverage * 100).toFixed(1)}% verified`);
595
+ else heroProseLines.push(`Evidence strength <strong>${m.evidenceStrength.toFixed(2)}</strong> (weak) -- most claims lack verification`);
596
+ if (m.unchallengedPct > 0.8) heroProseLines.push(`<strong>${(m.unchallengedPct * 100).toFixed(0)}%</strong> of topics have no conflicting claims -- adversarial testing needed`);
597
+ if (m.evalResearchRatio < 0.1) heroProseLines.push(`Eval/research ratio <strong>${m.evalResearchRatio.toFixed(3)}</strong> -- accumulating research without closing evaluation loops`);
598
+
599
+ document.getElementById('hero').innerHTML = `
600
+ <div class="hero-top">
601
+ <div>
602
+ <div class="hero-label">Sprint Health</div>
603
+ <div class="hero-score" style="color:${healthColor(m.health)}" aria-label="Health score ${m.health}, ${healthLabel(m.health)}">${m.health}${lowSampleHtml}${statusLabelHtml(m.health)}</div>
604
+ </div>
605
+ <div class="hero-detail">
606
+ <div class="hero-prose">${heroProseLines.join('. ')}.</div>
607
+ </div>
608
+ </div>
609
+ <div class="hero-spark">${sparkBars(healthHistory, idx)}</div>
610
+ <div class="hero-spark-labels">
611
+ ${SPRINTS.map((s, i) => `<span${i === idx ? ' class="current"' : ''}>${s.n.replace(/^Sprint\s*/i, '').substring(0, 4)}</span>`).join('')}
612
+ </div>
613
+ `;
614
+
615
+ // KPI Groups
616
+ function delta(cur, prev, fmt, inverse) {
617
+ if (prev == null) return '<span class="kpi-delta neutral">--</span>';
618
+ const diff = cur - prev;
619
+ const dir = inverse ? (diff < 0 ? 'up' : diff > 0 ? 'down' : 'neutral') : (diff > 0 ? 'up' : diff < 0 ? 'down' : 'neutral');
620
+ const sign = diff > 0 ? '+' : '';
621
+ return `<span class="kpi-delta ${dir}">${sign}${fmt(diff)} from prev</span>`;
622
+ }
623
+
624
+ function miniSpark(values, idx) {
625
+ const max = Math.max(...values, 0.001);
626
+ return `<div class="kpi-spark">${values.map((v, i) =>
627
+ `<div class="bar${i === idx ? ' current' : ''}" style="height:${Math.max(8, (v / max) * 100)}%"></div>`
628
+ ).join('')}</div>`;
629
+ }
630
+
631
+ const fmt2 = v => v.toFixed(2);
632
+ const fmtPct = v => (v * 100).toFixed(1) + '%';
633
+ const fmt3 = v => v.toFixed(3);
634
+ const fmtInt = v => Math.round(v).toString();
635
+
636
+ const kpiGroups = [
637
+ {
638
+ title: 'Evidence',
639
+ kpis: [
640
+ { label: 'Evidence Strength', value: m.evidenceStrength, fmt: fmt2, history: allMetrics.map(am => am.evidenceStrength), prev: prev?.evidenceStrength },
641
+ { label: 'Verification Coverage', value: m.verificationCoverage, fmt: fmtPct, history: allMetrics.map(am => am.verificationCoverage), prev: prev?.verificationCoverage },
642
+ { label: 'Challenge Rate', value: m.challengeRate, fmt: fmtPct, history: allMetrics.map(am => am.challengeRate), prev: prev?.challengeRate },
643
+ ]
644
+ },
645
+ {
646
+ title: 'Process',
647
+ kpis: [
648
+ { label: 'Eval/Research Ratio', value: m.evalResearchRatio, fmt: fmt3, history: allMetrics.map(am => am.evalResearchRatio), prev: prev?.evalResearchRatio },
649
+ { label: 'Conflict Resolution', value: m.conflictResRate, fmt: fmtPct, history: allMetrics.map(am => am.conflictResRate), prev: prev?.conflictResRate },
650
+ { label: 'Avg Freshness', value: m.avgDecay, fmt: fmtPct, history: allMetrics.map(am => am.avgDecay), prev: prev?.avgDecay },
651
+ ]
652
+ }
653
+ ];
654
+
655
+ document.getElementById('kpi-groups').innerHTML = kpiGroups.map(g => `
656
+ <div class="kpi-group" role="group" aria-label="${g.title} metrics">
657
+ <div class="kpi-group-title">${g.title}</div>
658
+ <div class="kpi-row">
659
+ ${g.kpis.map((k, ki) => `
660
+ <div class="kpi" role="group" aria-label="${k.label}: ${k.fmt(k.value)}">
661
+ <div class="kpi-label">${k.label}</div>
662
+ <div class="kpi-value">${k.fmt(k.value)}</div>
663
+ ${delta(k.value, k.prev, k.fmt, false)}
664
+ ${miniSpark(k.history, idx)}
665
+ </div>
666
+ `).join('')}
667
+ </div>
668
+ </div>
669
+ `).join('');
670
+
671
+ // Topic Heatmap
672
+ const claimTypes = ['factual', 'risk', 'recommendation', 'constraint', 'estimate', 'feedback'];
673
+ const topicEntries = Object.entries(m.topics).sort((a, b) => b[1].count - a[1].count);
674
+ document.getElementById('heatmap-count').textContent = `${topicEntries.length} topics`;
675
+
676
+ function heatClass(n) {
677
+ if (n === 0) return 'heat-0';
678
+ if (n === 1) return 'heat-1';
679
+ if (n <= 3) return 'heat-2';
680
+ if (n <= 6) return 'heat-3';
681
+ if (n <= 12) return 'heat-4';
682
+ return 'heat-5';
683
+ }
684
+
685
+ document.getElementById('heatmap').innerHTML = `
686
+ <thead><tr>
687
+ <th>Topic</th>
688
+ ${claimTypes.map(t => `<th class="rotate">${t}</th>`).join('')}
689
+ <th>Total</th>
690
+ </tr></thead>
691
+ <tbody>
692
+ ${topicEntries.slice(0, 30).map(([topic, data]) => `
693
+ <tr>
694
+ <td class="topic-name" title="${topic}">${topic.replace(/-/g, ' ')}</td>
695
+ ${claimTypes.map(t => {
696
+ const n = data.types[t] || 0;
697
+ const conflict = data.hasConflict && n > 0 ? ' has-conflict' : '';
698
+ return `<td class="cell ${heatClass(n)}${conflict}">${n || ''}</td>`;
699
+ }).join('')}
700
+ <td style="color:var(--txt-m);text-align:center">${data.count}</td>
701
+ </tr>
702
+ `).join('')}
703
+ </tbody>
704
+ `;
705
+
706
+ // Small Multiples (cross-sprint comparison)
707
+ if (SPRINTS.length > 1) {
708
+ document.getElementById('multiples-section').style.display = '';
709
+ const compareMetrics = [
710
+ { label: 'Claims by Type', fn: m => m.types, keys: claimTypes },
711
+ { label: 'Claims by Evidence', fn: m => m.evidence, keys: ['stated', 'web', 'documented', 'tested', 'production'] },
712
+ ];
713
+
714
+ document.getElementById('small-multiples').innerHTML = SPRINTS.map((s, si) => {
715
+ const sm = allMetrics[si];
716
+ return compareMetrics.map(cm => {
717
+ const data = cm.fn(sm);
718
+ const max = Math.max(...cm.keys.map(k => data[k] || 0), 1);
719
+ return `
720
+ <div class="mini-chart">
721
+ <div class="mini-chart-title">${s.n} -- ${cm.label}</div>
722
+ <div class="mini-bars">
723
+ ${cm.keys.map(k => {
724
+ const v = data[k] || 0;
725
+ const pct = (v / max) * 100;
726
+ const color = k === 'factual' || k === 'documented' ? 'var(--acc)' :
727
+ k === 'risk' || k === 'stated' ? 'var(--red)' :
728
+ k === 'recommendation' || k === 'tested' ? 'var(--grn)' :
729
+ k === 'constraint' || k === 'production' ? 'var(--pur)' :
730
+ k === 'estimate' ? 'var(--ylw)' : 'var(--cyn)';
731
+ return `<div class="mini-bar">
732
+ <span class="label" title="${k}">${k}</span>
733
+ <span class="track"><span class="fill" style="width:${pct}%;background:${color}"></span></span>
734
+ <span class="val">${v}</span>
735
+ </div>`;
736
+ }).join('')}
737
+ </div>
738
+ </div>
739
+ `;
740
+ }).join('');
741
+ }).join('');
742
+ }
743
+
744
+ // Decay
745
+ const decaying = m.decayScores.filter(d => d.decay < 0.5);
746
+ document.getElementById('decay-count').textContent = `${decaying.length} stale`;
747
+
748
+ function decayColor(d) {
749
+ if (d >= 0.7) return 'var(--grn)';
750
+ if (d >= 0.4) return 'var(--ylw)';
751
+ return 'var(--red)';
752
+ }
753
+
754
+ function decayLabel(d) {
755
+ if (d >= 0.7) return 'fresh';
756
+ if (d >= 0.4) return 'aging';
757
+ return 'stale';
758
+ }
759
+
760
+ function credColor(c) {
761
+ if (c >= 0.7) return 'var(--grn)';
762
+ if (c >= 0.4) return 'var(--ylw)';
763
+ return 'var(--red)';
764
+ }
765
+
766
+ document.getElementById('decay-body').innerHTML = decaying.length === 0
767
+ ? '<div style="color:var(--txt-d);font-size:11px;padding:8px 0">No claims below 50% freshness.</div>'
768
+ : `<div style="display:flex;gap:16px;font-size:10px;color:var(--txt-d);margin-bottom:8px;padding:4px 0;border-bottom:1px solid var(--bdr)">
769
+ <span style="width:36px;text-align:right">ID</span>
770
+ <span style="width:48px;text-align:right">Fresh</span>
771
+ <span style="width:60px">Decay</span>
772
+ <span style="width:48px;text-align:center">Cred</span>
773
+ <span>Claim</span>
774
+ </div>
775
+ <div>${decaying.slice(0, 30).map(cl => `
776
+ <div class="decay-item">
777
+ <span class="decay-id">${cl.i}</span>
778
+ <span class="decay-score" style="color:${decayColor(cl.decay)}" aria-label="${(cl.decay * 100).toFixed(0)}% freshness, ${decayLabel(cl.decay)}">${(cl.decay * 100).toFixed(0)}%</span>
779
+ <span class="decay-bar"><span class="decay-fill" style="width:${Math.max(cl.decay * 100, 2)}%;background:${decayColor(cl.decay)}"></span></span>
780
+ <span style="font-size:9px;color:${credColor(cl.credibility || 0)};width:48px;text-align:center;flex-shrink:0;background:${cl.credibility >= 0.6 ? 'rgba(34,197,94,0.08)' : cl.credibility >= 0.4 ? 'rgba(234,179,8,0.06)' : 'rgba(239,68,68,0.06)'};border-radius:2px;padding:1px 4px" title="${cl.e} evidence">${cl.e}</span>
781
+ <span class="badge badge-${cl.t}">${cl.t}</span>
782
+ <span class="decay-content" title="${(cl.c || '').replace(/"/g, '&quot;')}">${cl.c || ''}</span>
783
+ </div>
784
+ `).join('')}</div>`;
785
+
786
+ // --- NEXT ACTIONS (specific, prioritized, with claim IDs) ---
787
+ const actions = [];
788
+
789
+ // P1: Unresolved conflicts (blocking)
790
+ if (m.conflicts.length > 0) {
791
+ const conflictTopics = {};
792
+ m.conflicts.forEach(cl => {
793
+ const tp = cl.tp || 'unknown';
794
+ if (!conflictTopics[tp]) conflictTopics[tp] = [];
795
+ conflictTopics[tp].push(cl.i);
796
+ });
797
+ Object.entries(conflictTopics).forEach(([topic, ids]) => {
798
+ actions.push({
799
+ priority: 1, level: 'crit',
800
+ title: `Resolve ${ids.length} conflict${ids.length > 1 ? 's' : ''} in "${topic.replace(/-/g, ' ')}"`,
801
+ detail: `Active claims contradict each other. Compilation clarity is blocked until these are adjudicated.`,
802
+ cmd: `/resolve ${ids[0]}`,
803
+ targets: ids
804
+ });
805
+ });
806
+ }
807
+
808
+ // P1: Stale claims in active topics
809
+ const staleBuckets = {};
810
+ decaying.forEach(d => {
811
+ const tp = d.tp || 'unknown';
812
+ if (!staleBuckets[tp]) staleBuckets[tp] = [];
813
+ staleBuckets[tp].push(d);
814
+ });
815
+ const topStaleTopics = Object.entries(staleBuckets)
816
+ .sort((a, b) => b[1].length - a[1].length)
817
+ .slice(0, 3);
818
+
819
+ topStaleTopics.forEach(([topic, claims]) => {
820
+ if (claims.length >= 3) {
821
+ actions.push({
822
+ priority: 2, level: 'warn',
823
+ title: `Re-validate ${claims.length} stale claims in "${topic.replace(/-/g, ' ')}"`,
824
+ detail: `These claims have decayed below 50% freshness. Re-research or archive them.`,
825
+ cmd: `/research "${topic}"`,
826
+ targets: claims.slice(0, 5).map(c => c.i)
827
+ });
828
+ }
829
+ });
830
+
831
+ // P2: Monoculture topics (only one claim type)
832
+ const topMonocultures = Object.entries(m.topics)
833
+ .filter(([, data]) => Object.keys(data.types).length === 1 && data.count >= 3)
834
+ .sort((a, b) => b[1].count - a[1].count)
835
+ .slice(0, 3);
836
+
837
+ topMonocultures.forEach(([topic, data]) => {
838
+ const onlyType = Object.keys(data.types)[0];
839
+ actions.push({
840
+ priority: 2, level: 'warn',
841
+ title: `Diversify "${topic.replace(/-/g, ' ')}" (${data.count} claims, all ${onlyType})`,
842
+ detail: `This topic has only ${onlyType} claims. Add risks, constraints, or challenges to stress-test assumptions.`,
843
+ cmd: `/challenge "${topic}"`,
844
+ targets: []
845
+ });
846
+ });
847
+
848
+ // P2: Low evidence topics (high claim count, low evidence tier)
849
+ const weakTopics = Object.entries(m.topics)
850
+ .map(([topic, data]) => {
851
+ const claimsInTopic = current.c.filter(c => c.tp === topic);
852
+ const avgTier = claimsInTopic.reduce((s, c) => {
853
+ const w = { stated: 1, web: 2, documented: 3, tested: 4, production: 5 };
854
+ return s + (w[c.e] || 1);
855
+ }, 0) / (claimsInTopic.length || 1);
856
+ return { topic, count: data.count, avgTier };
857
+ })
858
+ .filter(t => t.count >= 3 && t.avgTier < 2)
859
+ .sort((a, b) => a.avgTier - b.avgTier)
860
+ .slice(0, 3);
861
+
862
+ weakTopics.forEach(t => {
863
+ actions.push({
864
+ priority: 2, level: 'warn',
865
+ title: `Strengthen evidence in "${t.topic.replace(/-/g, ' ')}" (avg tier ${t.avgTier.toFixed(1)})`,
866
+ detail: `${t.count} claims rely mostly on stakeholder assertions. Prototype or document to upgrade evidence.`,
867
+ cmd: `/prototype "${t.topic}"`,
868
+ targets: []
869
+ });
870
+ });
871
+
872
+ // P3: Unchallenged high-count topics
873
+ if (m.unchallengedPct > 0.5) {
874
+ const bigUnchallenged = Object.entries(m.topics)
875
+ .filter(([, data]) => !data.hasConflict && data.count >= 5)
876
+ .sort((a, b) => b[1].count - a[1].count)
877
+ .slice(0, 2);
878
+ bigUnchallenged.forEach(([topic, data]) => {
879
+ actions.push({
880
+ priority: 3, level: 'info',
881
+ title: `Challenge "${topic.replace(/-/g, ' ')}" (${data.count} claims, never contested)`,
882
+ detail: `Large unchallenged topic. Run adversarial testing to validate assumptions.`,
883
+ cmd: `/challenge "${topic}"`,
884
+ targets: []
885
+ });
886
+ });
887
+ }
888
+
889
+ // P3: Eval gap
890
+ if (m.evalResearchRatio < 0.1 && Object.keys(m.topics).length > 5) {
891
+ actions.push({
892
+ priority: 3, level: 'info',
893
+ title: `Close evaluation gap (ratio: ${m.evalResearchRatio.toFixed(3)})`,
894
+ detail: `Research is accumulating without evaluation. Pick the highest-risk topic and evaluate it.`,
895
+ cmd: `/evaluate`,
896
+ targets: []
897
+ });
898
+ }
899
+
900
+ // Sort by priority, cap at 7
901
+ actions.sort((a, b) => a.priority - b.priority);
902
+ const topActions = actions.slice(0, 7);
903
+
904
+ if (topActions.length > 0) {
905
+ document.getElementById('actions-section').innerHTML = `
906
+ <div class="section-title">Next Actions <span class="count">${topActions.length} items, sorted by priority</span></div>
907
+ <div class="actions">
908
+ ${topActions.map(a => `
909
+ <div class="action a-${a.level}">
910
+ <span class="action-priority p${a.priority}">P${a.priority}</span>
911
+ <div class="action-body">
912
+ <div class="action-title">${a.title}</div>
913
+ <div class="action-detail">${a.detail}</div>
914
+ ${a.cmd ? `<div class="action-cmd">${a.cmd}</div>` : ''}
915
+ ${a.targets.length > 0 ? `<div class="action-targets">${a.targets.map(t => `<span class="action-target">${t}</span>`).join('')}</div>` : ''}
916
+ </div>
917
+ </div>
918
+ `).join('')}
919
+ </div>
920
+ `;
921
+ }
922
+
923
+ // Phase Breakdown
924
+ const phaseOrder = ['define', 'research', 'prototype', 'evaluate', 'feedback'];
925
+ const maxPhase = Math.max(...Object.values(m.phases), 1);
926
+ document.getElementById('phase-count').textContent = `${Object.keys(m.phases).length} phases`;
927
+ const phaseColors = { define: 'var(--pur)', research: 'var(--acc)', prototype: 'var(--grn)', evaluate: 'var(--ylw)', feedback: 'var(--org)' };
928
+
929
+ document.getElementById('phase-body').innerHTML = `<div class="phase-bars">
930
+ ${phaseOrder.filter(p => m.phases[p]).map(p => {
931
+ const n = m.phases[p];
932
+ const pct = (n / maxPhase) * 100;
933
+ return `<div class="phase-bar">
934
+ <span class="label">${p}</span>
935
+ <span class="track"><span class="fill" style="width:${pct}%;background:${phaseColors[p] || 'var(--acc)'}">${n}</span></span>
936
+ <span class="count">${n}</span>
937
+ </div>`;
938
+ }).join('')}
939
+ </div>`;
940
+
941
+ // Insights
942
+ const insights = [];
943
+ if (m.unchallengedPct > 0.8) insights.push({ level: 'warn', text: `${(m.unchallengedPct * 100).toFixed(0)}% of topics (${Math.round(m.unchallengedPct * Object.keys(m.topics).length)}/${Object.keys(m.topics).length}) have no conflicting claims -- consider adversarial testing on key topics`, action: '/challenge' });
944
+ if (m.verificationCoverage < 0.1) insights.push({ level: 'warn', text: `Only ${(m.verificationCoverage * 100).toFixed(1)}% claims verified -- most findings are theoretical`, action: '/prototype' });
945
+ if (m.evalResearchRatio < 0.05) insights.push({ level: 'warn', text: `Eval/research ratio ${m.evalResearchRatio.toFixed(3)} -- research accumulating without evaluation`, action: '/evaluate' });
946
+ if (decaying.length > 5) insights.push({ level: 'warn', text: `${decaying.length} claims below 50% freshness -- consider re-validating or archiving`, action: '/research' });
947
+ if (m.conflicts.length > 0) insights.push({ level: 'crit', text: `${m.conflicts.length} unresolved conflict${m.conflicts.length > 1 ? 's' : ''} blocking compilation clarity`, action: '/resolve' });
948
+ if (m.conflictResRate >= 0.8 && m.evidenceStrength >= 2) insights.push({ level: 'good', text: `Strong evidence base with ${(m.conflictResRate * 100).toFixed(0)}% conflicts resolved`, action: '' });
949
+ // Type diversity
950
+ const typeCount = Object.keys(m.types).length;
951
+ if (typeCount <= 2) insights.push({ level: 'warn', text: `Only ${typeCount} claim types used -- consider adding risks, estimates, or constraints`, action: '/challenge' });
952
+
953
+ document.getElementById('insights-count').textContent = `${insights.length} items`;
954
+ document.getElementById('insights-body').innerHTML = insights.length === 0
955
+ ? '<div style="color:var(--txt-d);font-size:11px">No insights to surface.</div>'
956
+ : insights.map(ins => `
957
+ <div style="display:flex;gap:10px;padding:7px 12px;background:var(--bg-s);border-radius:3px;border-left:3px solid ${
958
+ ins.level === 'crit' ? 'var(--red)' : ins.level === 'warn' ? 'var(--ylw)' : ins.level === 'good' ? 'var(--grn)' : 'var(--acc)'
959
+ };font-size:11px;align-items:center;margin-bottom:4px" role="alert" aria-label="${ins.level === 'crit' ? 'Critical' : ins.level === 'warn' ? 'Warning' : 'Good'}: ${ins.text}">
960
+ <span class="status-label status-label-${ins.level === 'crit' ? 'critical' : ins.level === 'warn' ? 'warn' : 'good'}">${ins.level === 'crit' ? 'critical' : ins.level}</span>
961
+ <span style="flex:1">${ins.text}</span>
962
+ ${ins.action ? `<span style="color:var(--acc);font-size:10px;white-space:nowrap">${ins.action}</span>` : ''}
963
+ </div>
964
+ `).join('');
965
+
966
+ // Claims Table
967
+ _currentClaims = current.c;
968
+ document.getElementById('claims-count').textContent = `${_currentClaims.length} claims`;
969
+ filterType = null;
970
+ filterText = '';
971
+
972
+ document.getElementById('filter-bar').innerHTML = `
973
+ <input type="text" placeholder="Search claims..." aria-label="Search claims by text, topic, or ID" oninput="filterText=this.value;renderClaims()">
974
+ ${claimTypes.map(t => `<button class="filter-btn" onclick="filterType=filterType==='${t}'?null:'${t}';document.querySelectorAll('.filter-btn').forEach(b=>b.classList.remove('active'));if(filterType)this.classList.add('active');renderClaims()">${t}</button>`).join('')}
975
+ `;
976
+
977
+ renderClaims();
978
+
979
+ // Footer
980
+ document.getElementById('footer-left').textContent = `Harvest -- ${SPRINTS.length} sprint${SPRINTS.length > 1 ? 's' : ''}, ${_currentClaims.length} claims`;
981
+ document.getElementById('footer-right').textContent = `Built ${new Date().toISOString().split('T')[0]}`;
982
+
983
+ } // end renderAll
984
+
985
+ // Initial render
986
+ renderAll(selectedIdx);
987
+
988
+ // Toggle collapsible with ARIA state
989
+ function toggle(id) {
990
+ const el = document.getElementById(id);
991
+ el.classList.toggle('open');
992
+ const header = el.querySelector('.collapsible-header');
993
+ const isOpen = el.classList.contains('open');
994
+ header.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
995
+ }
996
+
997
+ // Keyboard handler for collapsible sections (Enter/Space)
998
+ function toggleKey(event, id) {
999
+ if (event.key === 'Enter' || event.key === ' ') {
1000
+ event.preventDefault();
1001
+ toggle(id);
1002
+ }
1003
+ }
1004
+
1005
+ // ── Grain Logo Animation ──
1006
+ (function() {
1007
+ var LW = 0.025;
1008
+ var TOOL = { name: 'Harvest', letter: 'H', color: '#fb923c' };
1009
+ var _c, _ctx, _s, _cx, _textStart, _restText, _font;
1010
+ var _state = 'drawon', _start = null, _raf;
1011
+ var _openPts = null, _closedPts = null;
1012
+
1013
+ function _lerp(a,b,t){ return {x:a.x+(b.x-a.x)*t, y:a.y+(b.y-a.y)*t}; }
1014
+ function _easeInOut(t){ return t<0.5 ? 2*t*t : 1-Math.pow(-2*t+2,2)/2; }
1015
+
1016
+ function _bracket(ctx, s, color, alpha) {
1017
+ var lw=s*LW, cx=_cx, cy=s/2, gw=s*0.72, gh=s*0.68;
1018
+ var topY=cy-gh/2, botY=cy+gh/2, fe=gw*0.30;
1019
+ if(alpha!==undefined) ctx.globalAlpha=alpha;
1020
+ ctx.strokeStyle=color; ctx.lineWidth=lw; ctx.lineCap='round'; ctx.lineJoin='round';
1021
+ ctx.beginPath(); ctx.moveTo(cx,topY); ctx.lineTo(cx-fe,topY);
1022
+ ctx.bezierCurveTo(cx-gw*0.52,cy-gh*0.32, cx-gw*0.52,cy+gh*0.24, cx-fe,botY);
1023
+ ctx.lineTo(cx,botY); ctx.stroke();
1024
+ if(alpha!==undefined) ctx.globalAlpha=1;
1025
+ }
1026
+
1027
+ function _drawBracket(ctx, s, color, progress) {
1028
+ var lw=s*LW, cx=_cx, cy=s/2, gw=s*0.72, gh=s*0.68;
1029
+ var topY=cy-gh/2, botY=cy+gh/2, fe=gw*0.30;
1030
+ ctx.strokeStyle=color; ctx.lineWidth=lw; ctx.lineCap='round'; ctx.lineJoin='round';
1031
+ var seg1=0.12, seg2=0.72;
1032
+ ctx.beginPath();
1033
+ if(progress<=seg1){ctx.moveTo(cx,topY);ctx.lineTo(cx-fe*(progress/seg1),topY);}
1034
+ else if(progress<=seg1+seg2){ctx.moveTo(cx,topY);ctx.lineTo(cx-fe,topY);ctx.stroke();ctx.beginPath();
1035
+ var bt=(progress-seg1)/seg2;
1036
+ 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};
1037
+ var q1=_lerp(p0,p1,bt),q2=_lerp(p1,p2,bt),q3=_lerp(p2,p3,bt);
1038
+ var r1=_lerp(q1,q2,bt),r2=_lerp(q2,q3,bt),s1=_lerp(r1,r2,bt);
1039
+ ctx.moveTo(p0.x,p0.y);ctx.bezierCurveTo(q1.x,q1.y,r1.x,r1.y,s1.x,s1.y);}
1040
+ else{ctx.moveTo(cx,topY);ctx.lineTo(cx-fe,topY);
1041
+ ctx.bezierCurveTo(cx-gw*0.52,cy-gh*0.32, cx-gw*0.52,cy+gh*0.24, cx-fe,botY);
1042
+ ctx.lineTo((cx-fe)+fe*((progress-seg1-seg2)/(1-seg1-seg2)),botY);}
1043
+ ctx.stroke();
1044
+ }
1045
+
1046
+ function _drawName(ctx, s, spellP, alpha) {
1047
+ var a = alpha !== undefined ? alpha : 1;
1048
+ ctx.font = _font; ctx.textBaseline = 'middle';
1049
+ var cy = s/2 + s*0.02;
1050
+ ctx.globalAlpha = a; ctx.fillStyle = TOOL.color; ctx.textAlign = 'center';
1051
+ ctx.fillText(TOOL.letter, _cx, cy);
1052
+ if(_restText.length > 0 && spellP > 0) {
1053
+ var n = _restText.length, num = Math.min(n, Math.ceil(spellP * n));
1054
+ var rawP = spellP * n, charP = num >= n ? 1 : rawP - Math.floor(rawP);
1055
+ var full = charP >= 1 ? num : num - 1;
1056
+ ctx.fillStyle = '#e2e8f0'; ctx.textAlign = 'left';
1057
+ if(full > 0) { ctx.globalAlpha = a; ctx.fillText(_restText.slice(0, full), _textStart, cy); }
1058
+ if(full < num) {
1059
+ var prevW = full > 0 ? ctx.measureText(_restText.slice(0, full)).width : 0;
1060
+ ctx.globalAlpha = a * (0.3 + 0.7 * charP);
1061
+ ctx.fillText(_restText[full], _textStart + prevW, cy);
1062
+ }
1063
+ }
1064
+ ctx.globalAlpha = 1;
1065
+ }
1066
+
1067
+ function _getOpenPts(s) {
1068
+ if(_openPts && _openPts._s === s) return _openPts;
1069
+ 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;
1070
+ var pts=[];
1071
+ for(var t=0;t<=1;t+=0.05) pts.push({x:cx-fe*t,y:topY});
1072
+ 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};
1073
+ 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});}
1074
+ for(var t=0;t<=1;t+=0.05) pts.push({x:(cx-fe)+fe*t,y:botY});
1075
+ pts._s=s; _openPts=pts; return pts;
1076
+ }
1077
+
1078
+ function _getClosedPts(s) {
1079
+ if(_closedPts && _closedPts._s === s) return _closedPts;
1080
+ 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;
1081
+ var pts=[];
1082
+ for(var t=0;t<=1;t+=0.03) pts.push({x:cx-fe*t,y:topY});
1083
+ 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};
1084
+ 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});}
1085
+ for(var t=0;t<=1;t+=0.03) pts.push({x:(cx-fe)+2*fe*t,y:botY});
1086
+ 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};
1087
+ 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});}
1088
+ for(var t=0;t<=1;t+=0.03) pts.push({x:(cx+fe)-fe*t,y:topY});
1089
+ pts._s=s; _closedPts=pts; return pts;
1090
+ }
1091
+
1092
+ function _frame(ts) {
1093
+ if(!_c) return;
1094
+ if(!_start) _start = ts;
1095
+ var e = ts - _start, ctx = _ctx, s = _s;
1096
+ ctx.clearRect(0, 0, _c.width, s);
1097
+ switch(_state) {
1098
+ case 'drawon':
1099
+ var bp = _easeInOut(Math.min(1, e / 1400));
1100
+ _drawBracket(ctx, s, TOOL.color, bp);
1101
+ var la = Math.max(0, Math.min(1, (e - 900) / 400));
1102
+ if(la > 0) {
1103
+ ctx.font = _font; ctx.textBaseline = 'middle';
1104
+ ctx.globalAlpha = la; ctx.fillStyle = TOOL.color; ctx.textAlign = 'center';
1105
+ ctx.fillText(TOOL.letter, _cx, s/2 + s*0.02); ctx.globalAlpha = 1;
1106
+ }
1107
+ if(e > 1100 && _restText.length > 0) {
1108
+ var sp = Math.min(1, (e - 1100) / (120 * _restText.length));
1109
+ var n = _restText.length, num = Math.min(n, Math.ceil(sp * n));
1110
+ if(num > 0) {
1111
+ ctx.font = _font; ctx.textBaseline = 'middle';
1112
+ var cy = s/2 + s*0.02, rawP = sp * n;
1113
+ var charP = num >= n ? 1 : rawP - Math.floor(rawP);
1114
+ var full = charP >= 1 ? num : num - 1;
1115
+ ctx.fillStyle = '#e2e8f0'; ctx.textAlign = 'left';
1116
+ if(full > 0) ctx.fillText(_restText.slice(0, full), _textStart, cy);
1117
+ if(full < num) {
1118
+ var prevW = full > 0 ? ctx.measureText(_restText.slice(0, full)).width : 0;
1119
+ ctx.globalAlpha = 0.3 + 0.7 * charP;
1120
+ ctx.fillText(_restText[full], _textStart + prevW, cy); ctx.globalAlpha = 1;
1121
+ }
1122
+ }
1123
+ }
1124
+ if(e > 1100 + 120 * _restText.length + 300) { _state = 'idle'; _start = ts; }
1125
+ break;
1126
+ case 'idle':
1127
+ var breathe = 0.5 + 0.5 * (0.5 + 0.5 * Math.sin(e / 1200));
1128
+ _bracket(ctx, s, TOOL.color, breathe);
1129
+ var textBreath = 0.88 + 0.12 * (0.5 + 0.5 * Math.sin(e / 1800));
1130
+ _drawName(ctx, s, 1, textBreath);
1131
+ break;
1132
+ case 'shimmer':
1133
+ _bracket(ctx, s, TOOL.color, 0.2);
1134
+ var spts = _getOpenPts(s), sspeed = 1800;
1135
+ var spos = (e % sspeed) / sspeed;
1136
+ var sidx = Math.floor(spos * (spts.length - 1));
1137
+ var spt = spts[sidx];
1138
+ var sgrad = ctx.createRadialGradient(spt.x, spt.y, 0, spt.x, spt.y, s * 0.10);
1139
+ sgrad.addColorStop(0, TOOL.color + 'aa'); sgrad.addColorStop(1, 'transparent');
1140
+ ctx.fillStyle = sgrad; ctx.fillRect(0, 0, _c.width, s);
1141
+ var strailFrac = 0.18;
1142
+ var si0 = Math.max(0, Math.floor((spos - strailFrac) * (spts.length - 1)));
1143
+ ctx.strokeStyle = TOOL.color; ctx.lineWidth = s * LW; ctx.lineCap = 'round';
1144
+ ctx.globalAlpha = 0.85; ctx.beginPath(); ctx.moveTo(spts[si0].x, spts[si0].y);
1145
+ for(var si = si0 + 1; si <= sidx; si++) ctx.lineTo(spts[si].x, spts[si].y);
1146
+ ctx.stroke(); ctx.globalAlpha = 1;
1147
+ _drawName(ctx, s, 1, undefined);
1148
+ break;
1149
+ case 'orbit':
1150
+ _bracket(ctx, s, TOOL.color, 0.15);
1151
+ _drawName(ctx, s, 1, 0.4);
1152
+ var pts = _getOpenPts(s), speed = 1200, trailFrac = 0.28;
1153
+ var halfCycle = (e % speed) / speed;
1154
+ var cycle = (e % (speed * 2)) / (speed * 2);
1155
+ var pos = cycle < 0.5 ? halfCycle : 1 - halfCycle;
1156
+ var headIdx = Math.floor(pos * (pts.length - 1));
1157
+ var trailLen = Math.floor(trailFrac * pts.length);
1158
+ var dir = cycle < 0.5 ? 1 : -1;
1159
+ ctx.lineWidth = s * LW; ctx.lineCap = 'round';
1160
+ for(var i = 0; i < trailLen; i++) {
1161
+ var idx = headIdx - dir * (trailLen - i);
1162
+ if(idx < 0 || idx >= pts.length) continue;
1163
+ var nxt = idx + dir;
1164
+ if(nxt < 0 || nxt >= pts.length) continue;
1165
+ ctx.globalAlpha = (i / trailLen) * 0.7; ctx.strokeStyle = TOOL.color;
1166
+ ctx.beginPath(); ctx.moveTo(pts[idx].x, pts[idx].y); ctx.lineTo(pts[nxt].x, pts[nxt].y); ctx.stroke();
1167
+ }
1168
+ ctx.globalAlpha = 1;
1169
+ break;
1170
+ case 'dim':
1171
+ var dim = 0.1 + 0.08 * Math.sin(e / 2000);
1172
+ _bracket(ctx, s, TOOL.color, dim);
1173
+ _drawName(ctx, s, 1, 0.2);
1174
+ break;
1175
+ }
1176
+ // Animate idle canvases
1177
+ var idles = document.querySelectorAll('.grain-idle');
1178
+ for(var i = 0; i < idles.length; i++) {
1179
+ var ic = idles[i], ictx = ic.getContext('2d'), is = ic.width;
1180
+ ictx.clearRect(0, 0, is, is);
1181
+ var off = i * 400, cx = is/2;
1182
+ var b = 0.25 + 0.35 * (0.5 + 0.5 * Math.sin((ts + off) / 1500));
1183
+ var lw=is*LW, icy=is/2, gw=is*0.72, gh=is*0.68, topY=icy-gh/2, botY=icy+gh/2, fe=gw*0.30;
1184
+ ictx.globalAlpha=b; ictx.strokeStyle=TOOL.color; ictx.lineWidth=lw; ictx.lineCap='round'; ictx.lineJoin='round';
1185
+ ictx.beginPath(); ictx.moveTo(cx,topY); ictx.lineTo(cx-fe,topY);
1186
+ ictx.bezierCurveTo(cx-gw*0.52,icy-gh*0.32, cx-gw*0.52,icy+gh*0.24, cx-fe,botY);
1187
+ ictx.lineTo(cx,botY); ictx.stroke();
1188
+ ictx.globalAlpha = 0.3 + 0.2 * (0.5 + 0.5 * Math.sin((ts + off) / 1500));
1189
+ ictx.fillStyle=TOOL.color; ictx.textAlign='center'; ictx.textBaseline='middle';
1190
+ ictx.font='800 '+(is*0.38)+'px -apple-system,"SF Pro Display","Helvetica Neue",Arial,sans-serif';
1191
+ ictx.fillText(TOOL.letter, cx, icy+is*0.02); ictx.globalAlpha=1;
1192
+ }
1193
+ _raf = requestAnimationFrame(_frame);
1194
+ }
1195
+
1196
+ // Init: measure text, size canvas, start animation
1197
+ _c = document.getElementById('grainLogo');
1198
+ if(_c) {
1199
+ _c.style.width = '0px';
1200
+ _s = 256;
1201
+ var fontRatio = 0.38;
1202
+ var dh = 64;
1203
+ _c.height = _s; _c.width = 1024;
1204
+ _ctx = _c.getContext('2d');
1205
+ _cx = _s / 2;
1206
+ _restText = TOOL.name.slice(1);
1207
+ _font = '800 ' + (_s * fontRatio) + 'px -apple-system,"SF Pro Display","Helvetica Neue",Arial,sans-serif';
1208
+ _ctx.font = _font;
1209
+ var letterW = _ctx.measureText(TOOL.letter).width;
1210
+ var restW = _restText.length > 0 ? _ctx.measureText(_restText).width : 0;
1211
+ _textStart = _cx + letterW / 2 + _s * 0.02;
1212
+ var totalW = Math.ceil(_textStart + restW + _s * 0.12);
1213
+ _c.width = totalW;
1214
+ _ctx = _c.getContext('2d');
1215
+ _c.style.height = dh + 'px';
1216
+ _c.style.width = Math.round(totalW / _s * dh) + 'px';
1217
+ _state = 'drawon'; _start = null;
1218
+ _raf = requestAnimationFrame(_frame);
1219
+ }
1220
+
1221
+ window._grainSetState = function(state) {
1222
+ if(_state === state) return;
1223
+ if(_state === 'drawon') { _pendingState = state; return; }
1224
+ _state = state; _start = null;
1225
+ if(!_raf) _raf = requestAnimationFrame(_frame);
1226
+ };
1227
+ })();
1228
+ </script>
1229
+ </body>
1230
+ </html>