@bvdm/delano 0.2.7 → 0.2.9

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,2478 @@
1
+ const { useState, useEffect, useMemo, useCallback } = React;
2
+
3
+ /* ================================================================
4
+ Icons — hairline 1.4px stroke, 24×24 viewBox
5
+ ================================================================ */
6
+ const Icon = ({ d, size = 16, fill = "none", stroke = "currentColor" }) => (
7
+ <svg width={size} height={size} viewBox="0 0 24 24" fill={fill} stroke={stroke}
8
+ strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
9
+ {typeof d === "string" ? <path d={d} /> : d}
10
+ </svg>
11
+ );
12
+
13
+ const I = {
14
+ home: <><path d="M3 11.5 12 4l9 7.5"/><path d="M5 10v10h14V10"/></>,
15
+ list: <><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><circle cx="4" cy="6" r="1"/><circle cx="4" cy="12" r="1"/><circle cx="4" cy="18" r="1"/></>,
16
+ block: <><circle cx="12" cy="12" r="8.5"/><path d="M6 6l12 12"/></>,
17
+ trend: <><path d="M3 17l6-6 4 4 8-8"/><path d="M14 7h7v7"/></>,
18
+ check: <><rect x="3.5" y="3.5" width="17" height="17" rx="2"/><path d="M8 12.5l3 3 5-6"/></>,
19
+ checkMark: <path d="M5 12.5l4 4 10-11"/>,
20
+ copy: <><rect x="8" y="4" width="11" height="14" rx="1.5"/><path d="M16 18v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2"/></>,
21
+ warn: <><path d="M12 3.5 21 19H3z"/><path d="M12 10v4.5"/><circle cx="12" cy="17" r="0.6" fill="currentColor"/></>,
22
+ doc: <><path d="M6 3.5h8l4 4V20.5H6z"/><path d="M14 3.5V8h4"/></>,
23
+ plan: <><path d="M4 5.5h16"/><path d="M4 12h16"/><path d="M4 18.5h10"/></>,
24
+ scale: <><path d="M12 4v16"/><path d="M5 8h14"/><path d="M5 8 3 13h4z"/><path d="M19 8l-2 5h4z"/></>,
25
+ clock: <><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></>,
26
+ grid: <><rect x="4" y="4" width="6" height="6" rx="1"/><rect x="14" y="4" width="6" height="6" rx="1"/><rect x="4" y="14" width="6" height="6" rx="1"/><rect x="14" y="14" width="6" height="6" rx="1"/></>,
27
+ task: <><rect x="3.5" y="3.5" width="17" height="17" rx="2"/><path d="M7 8.5h10"/><path d="M7 13h7"/></>,
28
+ folder: <><path d="M3.5 6.5a2 2 0 0 1 2-2h3.5l2 2h7.5a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2z"/></>,
29
+ gear: <><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 0 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 0 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3h.1a1.7 1.7 0 0 0 1-1.5V3a2 2 0 0 1 4 0v.1a1.7 1.7 0 0 0 1 1.5h.1a1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v.1a1.7 1.7 0 0 0 1.5 1H21a2 2 0 0 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></>,
30
+ code: <><path d="m9 8-5 4 5 4"/><path d="m15 8 5 4-5 4"/></>,
31
+ folderOpen:<><path d="M3 7a2 2 0 0 1 2-2h3l2 2h7a2 2 0 0 1 2 2v1H3z"/><path d="M3 10h18l-2 8a2 2 0 0 1-2 1.5H5a2 2 0 0 1-2-1.5z"/></>,
32
+ user: <><circle cx="12" cy="8" r="3.5"/><path d="M5 20c1.5-3.5 4-5 7-5s5.5 1.5 7 5"/></>,
33
+ chevR: <path d="m9 6 6 6-6 6"/>,
34
+ chevD: <path d="m6 9 6 6 6-6"/>,
35
+ chevU: <path d="m6 15 6-6 6 6"/>,
36
+ arrowL: <><path d="M19 12H5"/><path d="m12 5-7 7 7 7"/></>,
37
+ lock: <><rect x="4.5" y="10.5" width="15" height="10" rx="1.5"/><path d="M8 10.5V7a4 4 0 0 1 8 0v3.5"/></>,
38
+ search: <><circle cx="11" cy="11" r="6"/><path d="m20 20-4.3-4.3"/></>,
39
+ };
40
+
41
+ /* ================================================================
42
+ Status utilities
43
+ ================================================================ */
44
+ const STATUS_TONE = {
45
+ "Planned": { dot: "var(--ink-40)" },
46
+ "In Progress": { dot: "var(--accent)" },
47
+ "Complete": { dot: "var(--ok)" },
48
+ "Blocked": { dot: "var(--warn)" },
49
+ };
50
+
51
+ const NAV_STATE_KEY = "delano.viewer.navigation.v1";
52
+ const NAV_STATE_VERSION = 1;
53
+ const DEFAULT_WORKSPACE_ROUTE = "workspace-projects";
54
+ const WORKSPACE_PAGE_SIZE = 10;
55
+
56
+ function statusLabel(raw) {
57
+ if (!raw) return "Planned";
58
+ const s = String(raw).toLowerCase().replace(/[-_]+/g, " ").trim();
59
+ if (s.includes("progress") || s === "active") return "In Progress";
60
+ if (s === "blocked") return "Blocked";
61
+ if (["complete", "done", "approved", "closed"].includes(s)) return "Complete";
62
+ if (["planned", "draft", "ready"].includes(s)) return "Planned";
63
+ return "Planned";
64
+ }
65
+
66
+ const StatusChip = ({ children }) => {
67
+ const label = statusLabel(children);
68
+ const tone = STATUS_TONE[label] || STATUS_TONE["Planned"];
69
+ return (
70
+ <span className="chip">
71
+ <span className="chip-dot" style={{ background: tone.dot }} />
72
+ {label}
73
+ </span>
74
+ );
75
+ };
76
+
77
+ /* ================================================================
78
+ HTML / text utilities
79
+ ================================================================ */
80
+ const escapeHtml = (s) =>
81
+ String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
82
+
83
+ const titleCase = (s) =>
84
+ String(s || "").replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
85
+
86
+ const normalizeCopyValue = (value) => {
87
+ if (value == null) return "";
88
+ if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean).join(", ");
89
+ if (typeof value === "boolean" || typeof value === "number") return String(value);
90
+ if (typeof value === "object") return JSON.stringify(value);
91
+ return String(value).trim();
92
+ };
93
+
94
+ const isCopyableMetaKey = (key) => {
95
+ const normalized = String(key || "").toLowerCase();
96
+ return (
97
+ normalized === "id" ||
98
+ normalized === "slug" ||
99
+ normalized === "workstream" ||
100
+ normalized === "depends_on" ||
101
+ normalized === "conflicts_with" ||
102
+ /(?:^|_)(?:id|ids)$/.test(normalized)
103
+ );
104
+ };
105
+
106
+ const copyLabelFromMetaKey = (key, role) => {
107
+ const normalized = String(key || "").toLowerCase();
108
+ if (normalized === "id" && role) return `${titleCase(role)} ID`;
109
+ if (normalized === "workstream") return "workstream ID";
110
+ if (normalized === "depends_on") return "dependency IDs";
111
+ if (normalized === "conflicts_with") return "conflict IDs";
112
+ return normalized.replace(/_/g, " ") || "value";
113
+ };
114
+
115
+ async function copyTextToClipboard(text) {
116
+ if (navigator.clipboard && navigator.clipboard.writeText) {
117
+ try {
118
+ await navigator.clipboard.writeText(text);
119
+ return true;
120
+ } catch (_) {
121
+ /* fall through */
122
+ }
123
+ }
124
+
125
+ const textArea = document.createElement("textarea");
126
+ textArea.value = text;
127
+ textArea.setAttribute("readonly", "");
128
+ textArea.style.position = "fixed";
129
+ textArea.style.top = "-1000px";
130
+ textArea.style.opacity = "0";
131
+ document.body.appendChild(textArea);
132
+ textArea.select();
133
+ let ok = false;
134
+ try {
135
+ ok = document.execCommand("copy");
136
+ } catch (_) {
137
+ ok = false;
138
+ }
139
+ document.body.removeChild(textArea);
140
+ return ok;
141
+ }
142
+
143
+ function announceCopy(label) {
144
+ const live = document.getElementById("copy-live");
145
+ if (!live) return;
146
+ live.textContent = "";
147
+ window.setTimeout(() => {
148
+ live.textContent = `Copied ${label || "value"}`;
149
+ }, 10);
150
+ }
151
+
152
+ const formatShortDateTime = (value) => {
153
+ if (!value) return "";
154
+ const date = new Date(value);
155
+ if (Number.isNaN(date.getTime())) return "";
156
+ return date.toLocaleString("en-US", {
157
+ month: "short",
158
+ day: "numeric",
159
+ hour: "2-digit",
160
+ minute: "2-digit",
161
+ hour12: false,
162
+ });
163
+ };
164
+
165
+ const stripRepeatedTitle = (title, text) => {
166
+ const source = String(text || "").trim();
167
+ const heading = String(title || "").trim();
168
+ if (!source || !heading) return source;
169
+ const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
170
+ return source
171
+ .replace(new RegExp(`^#{1,6}\\s+${escaped}\\s*`, "i"), "")
172
+ .replace(new RegExp(`^${escaped}\\s+`, "i"), "")
173
+ .trim();
174
+ };
175
+
176
+ /* ================================================================
177
+ Markdown rendering (ported from original Delano viewer)
178
+ ================================================================ */
179
+ function inlineMd(text) {
180
+ let s = escapeHtml(text);
181
+ s = s.replace(/`([^`]+)`/g, "<code>$1</code>");
182
+ s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
183
+ s = s.replace(/(^|[^*\w])\*([^*\n]+)\*(?=[^*\w]|$)/g, "$1<em>$2</em>");
184
+ s = s.replace(/\[\[([^\]]+)\]\]/g, '<span class="wikilink">$1</span>');
185
+ s = s.replace(
186
+ /\[([^\]]+)\]\(((?:https?:\/\/|mailto:|\.\.?\/|\/)[^)]+)\)/g,
187
+ '<a href="$2" target="_blank" rel="noreferrer noopener">$1</a>'
188
+ );
189
+ return s;
190
+ }
191
+
192
+ function isTableSeparator(line) {
193
+ return line ? /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line) : false;
194
+ }
195
+
196
+ function isBlockStart(line, nextLine) {
197
+ if (!line) return false;
198
+ if (/^\s*```/.test(line)) return true;
199
+ if (/^#{1,6}\s+/.test(line)) return true;
200
+ if (/^\s*-{3,}\s*$/.test(line)) return true;
201
+ if (/^\s*>/.test(line)) return true;
202
+ if (/^\s*(?:[-*+]|\d+\.)\s+/.test(line)) return true;
203
+ if (line.includes("|") && nextLine && isTableSeparator(nextLine)) return true;
204
+ return false;
205
+ }
206
+
207
+ function renderCodeBlock(text, lang) {
208
+ const langBadge = lang ? `<span class="code-lang">${escapeHtml(lang)}</span>` : "";
209
+ const padClass = lang ? " has-lang" : "";
210
+ return `<pre class="code${padClass}">${langBadge}<code>${escapeHtml(text)}</code></pre>`;
211
+ }
212
+
213
+ function renderTable(tableLines) {
214
+ const parseRow = (line) => {
215
+ let s = line.trim();
216
+ if (s.startsWith("|")) s = s.slice(1);
217
+ if (s.endsWith("|")) s = s.slice(0, -1);
218
+ return s.split("|").map((c) => c.trim());
219
+ };
220
+ const aligns = parseRow(tableLines[1]).map((s) => {
221
+ if (/^:-+:$/.test(s)) return "center";
222
+ if (/^-+:$/.test(s)) return "right";
223
+ if (/^:-+$/.test(s)) return "left";
224
+ return null;
225
+ });
226
+ const headers = parseRow(tableLines[0]);
227
+ const headerHtml =
228
+ "<thead><tr>" +
229
+ headers.map((h, j) => {
230
+ const a = aligns[j] ? ` style="text-align:${aligns[j]}"` : "";
231
+ return `<th${a}>${inlineMd(h)}</th>`;
232
+ }).join("") +
233
+ "</tr></thead>";
234
+ const rowsHtml = tableLines
235
+ .slice(2)
236
+ .map((line) => {
237
+ const cells = parseRow(line);
238
+ return (
239
+ "<tr>" +
240
+ cells.map((c, j) => {
241
+ const a = aligns[j] ? ` style="text-align:${aligns[j]}"` : "";
242
+ return `<td${a}>${inlineMd(c)}</td>`;
243
+ }).join("") +
244
+ "</tr>"
245
+ );
246
+ })
247
+ .join("");
248
+ return `<div class="table-wrap"><table>${headerHtml}<tbody>${rowsHtml}</tbody></table></div>`;
249
+ }
250
+
251
+ function parseList(lines, start, baseIndent) {
252
+ const items = [];
253
+ let i = start;
254
+ let listType = null;
255
+ let isTaskList = false;
256
+
257
+ while (i < lines.length) {
258
+ const line = lines[i];
259
+ const m = line.match(/^(\s*)([-*+]|\d+\.)\s+(.*)$/);
260
+ if (!m) break;
261
+ const indent = m[1].length;
262
+ if (indent !== baseIndent) break;
263
+ const isOrdered = /^\d+\./.test(m[2]);
264
+ if (listType === null) listType = isOrdered ? "ol" : "ul";
265
+ else if ((listType === "ol") !== isOrdered) break;
266
+
267
+ const content = m[3];
268
+ const taskMatch = content.match(/^\[([ xX])\]\s+(.*)$/);
269
+ let itemHtml;
270
+ const itemClasses = [];
271
+ if (taskMatch) {
272
+ isTaskList = true;
273
+ const checked = taskMatch[1].toLowerCase() === "x";
274
+ itemClasses.push("task-item");
275
+ if (checked) itemClasses.push("checked");
276
+ const checkSvg = checked
277
+ ? '<svg viewBox="0 0 14 14" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3,7 6,10 11,4"/></svg>'
278
+ : "";
279
+ itemHtml = `<span class="task-marker">${checkSvg}</span><span class="task-text">${inlineMd(taskMatch[2])}</span>`;
280
+ } else {
281
+ itemHtml = inlineMd(content);
282
+ }
283
+
284
+ i++;
285
+ let nestedHtml = "";
286
+ while (i < lines.length) {
287
+ const nextLine = lines[i];
288
+ if (!nextLine.trim()) {
289
+ if (i + 1 < lines.length) {
290
+ const peek = lines[i + 1].match(/^(\s*)([-*+]|\d+\.)\s+/);
291
+ if (peek && peek[1].length > baseIndent) { i++; continue; }
292
+ }
293
+ break;
294
+ }
295
+ const nextMatch = nextLine.match(/^(\s*)([-*+]|\d+\.)\s+/);
296
+ if (nextMatch && nextMatch[1].length > baseIndent) {
297
+ const nested = parseList(lines, i, nextMatch[1].length);
298
+ nestedHtml += nested.html;
299
+ i = nested.next;
300
+ } else {
301
+ break;
302
+ }
303
+ }
304
+
305
+ const cls = itemClasses.length ? ` class="${itemClasses.join(" ")}"` : "";
306
+ items.push(`<li${cls}>${itemHtml}${nestedHtml}</li>`);
307
+ }
308
+
309
+ const tag = listType || "ul";
310
+ const cls = isTaskList ? ' class="task-list"' : "";
311
+ return { html: `<${tag}${cls}>${items.join("")}</${tag}>`, next: i };
312
+ }
313
+
314
+ function parseBlocks(lines) {
315
+ const out = [];
316
+ let i = 0;
317
+ while (i < lines.length) {
318
+ const line = lines[i];
319
+
320
+ const fence = line.match(/^\s*```(.*)$/);
321
+ if (fence) {
322
+ const lang = (fence[1] || "").trim();
323
+ const codeLines = [];
324
+ i++;
325
+ while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) { codeLines.push(lines[i]); i++; }
326
+ i++;
327
+ out.push(renderCodeBlock(codeLines.join("\n"), lang));
328
+ continue;
329
+ }
330
+
331
+ if (!line.trim()) { i++; continue; }
332
+
333
+ if (/^\s*-{3,}\s*$/.test(line) || /^\s*\*{3,}\s*$/.test(line)) {
334
+ out.push("<hr/>");
335
+ i++;
336
+ continue;
337
+ }
338
+
339
+ const heading = line.match(/^(#{1,6})\s+(.+?)\s*$/);
340
+ if (heading) {
341
+ const level = heading[1].length;
342
+ out.push(`<h${level}>${inlineMd(heading[2])}</h${level}>`);
343
+ i++;
344
+ continue;
345
+ }
346
+
347
+ if (line.includes("|") && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
348
+ const tableLines = [lines[i], lines[i + 1]];
349
+ i += 2;
350
+ while (i < lines.length && lines[i].trim() && lines[i].includes("|")) { tableLines.push(lines[i]); i++; }
351
+ out.push(renderTable(tableLines));
352
+ continue;
353
+ }
354
+
355
+ if (/^\s*>/.test(line)) {
356
+ const quoteLines = [];
357
+ while (i < lines.length && /^\s*>/.test(lines[i])) { quoteLines.push(lines[i].replace(/^\s*>\s?/, "")); i++; }
358
+ out.push(`<blockquote>${parseBlocks(quoteLines)}</blockquote>`);
359
+ continue;
360
+ }
361
+
362
+ if (/^\s*(?:[-*+]|\d+\.)\s+/.test(line)) {
363
+ const indent = (line.match(/^(\s*)/)[1] || "").length;
364
+ const list = parseList(lines, i, indent);
365
+ out.push(list.html);
366
+ i = list.next;
367
+ continue;
368
+ }
369
+
370
+ const paraLines = [];
371
+ while (i < lines.length && lines[i].trim() && !isBlockStart(lines[i], lines[i + 1])) {
372
+ paraLines.push(lines[i]);
373
+ i++;
374
+ }
375
+ if (paraLines.length) {
376
+ out.push(`<p>${inlineMd(paraLines.join(" "))}</p>`);
377
+ } else {
378
+ i++;
379
+ }
380
+ }
381
+ return out.join("\n");
382
+ }
383
+
384
+ function renderMarkdown(markdown) {
385
+ const body = markdown.replace(/^---[\s\S]*?\n---\r?\n/, "");
386
+ return parseBlocks(body.split(/\r?\n/)) || '<p class="empty-state">This document is empty.</p>';
387
+ }
388
+
389
+ /* ================================================================
390
+ Data helpers
391
+ ================================================================ */
392
+ function byPath(docs, path) {
393
+ return docs.find((d) => d.path === path);
394
+ }
395
+
396
+ function getProjectData(index, slug) {
397
+ if (!index) return { project: null, docs: [] };
398
+ const project = index.projects.find((p) => p.slug === slug) || index.projects[0];
399
+ if (!project) return { project: null, docs: [] };
400
+ const docs = project.docs.map((p) => byPath(index.docs, p)).filter(Boolean);
401
+ return { project, docs };
402
+ }
403
+
404
+ function computeHealth(docs) {
405
+ const tasks = docs.filter((d) => d.role === "task");
406
+ if (!tasks.length) return { pct: 0, label: "No tasks" };
407
+ const done = tasks.filter((d) => statusLabel(d.status) === "Complete").length;
408
+ const pct = Math.round((done / tasks.length) * 100);
409
+ const remaining = tasks.length - done;
410
+ return { pct, label: remaining > 0 ? `${remaining} of ${tasks.length} incomplete` : "All tasks complete" };
411
+ }
412
+
413
+ function computeWarnings(project, docs) {
414
+ const warnings = [];
415
+ const tasks = docs.filter((d) => d.role === "task");
416
+ const noStatus = tasks.filter((d) => !d.status);
417
+ if (noStatus.length) {
418
+ warnings.push({ sev: "Medium", note: `${noStatus.length} task(s) missing status field`, ws: "Tasks" });
419
+ }
420
+ if (project.outline) {
421
+ const ws = project.outline.workstreams || [];
422
+ const emptyWs = ws.filter((w) => !w.tasks || !w.tasks.length);
423
+ if (emptyWs.length) {
424
+ warnings.push({ sev: "Low", note: `${emptyWs.length} workstream(s) have no linked tasks`, ws: "Workstreams" });
425
+ }
426
+ if (project.outline.unassignedTasks?.length) {
427
+ warnings.push({ sev: "Low", note: `${project.outline.unassignedTasks.length} task(s) not assigned to a workstream`, ws: "Tasks" });
428
+ }
429
+ }
430
+ const blocked = tasks.filter((d) => statusLabel(d.status) === "Blocked");
431
+ if (blocked.length) {
432
+ warnings.push({ sev: "Medium", note: `${blocked.length} task(s) currently blocked`, ws: "Tasks" });
433
+ }
434
+ return warnings;
435
+ }
436
+
437
+ function getDashboardModel(project, docs) {
438
+ const tasks = docs.filter((d) => d.role === "task");
439
+ const currentWork = tasks.filter((d) => statusLabel(d.status) === "In Progress");
440
+ const blockers = tasks.filter((d) => statusLabel(d.status) === "Blocked");
441
+ const progressDocs = docs
442
+ .filter((d) => d.role === "progress")
443
+ .sort((a, b) => (b.updated || "").localeCompare(a.updated || ""));
444
+ const health = computeHealth(docs);
445
+ const warnings = computeWarnings(project, docs);
446
+ const workstreams = project.outline?.workstreams || [];
447
+ const wsLookup = {};
448
+
449
+ workstreams.forEach((ws) => {
450
+ (ws.tasks || []).forEach((taskPath) => {
451
+ wsLookup[taskPath] = ws;
452
+ });
453
+ });
454
+
455
+ return { tasks, currentWork, blockers, progressDocs, health, warnings, workstreams, wsLookup };
456
+ }
457
+
458
+ function getWorkspaceModel(index) {
459
+ const model = {
460
+ current: [],
461
+ blockers: [],
462
+ validation: [],
463
+ progress: [],
464
+ warnings: [],
465
+ counts: { projects: 0, current: 0, blockers: 0, validation: 0, progress: 0, warnings: 0 },
466
+ };
467
+
468
+ if (!index) return model;
469
+
470
+ for (const project of index.projects || []) {
471
+ if (!project.outline) continue;
472
+ const docs = (project.docs || []).map((p) => byPath(index.docs, p)).filter(Boolean);
473
+ const dashboard = getDashboardModel(project, docs);
474
+ const withProject = (item, extra = {}) => ({ ...item, project, ...extra });
475
+
476
+ dashboard.currentWork.forEach((task) => {
477
+ model.current.push(withProject(task, { workstream: dashboard.wsLookup[task.path] || null }));
478
+ });
479
+ dashboard.blockers.forEach((task) => {
480
+ model.blockers.push(withProject(task, { workstream: dashboard.wsLookup[task.path] || null }));
481
+ });
482
+ dashboard.tasks.forEach((task) => {
483
+ model.validation.push(withProject(task, { workstream: dashboard.wsLookup[task.path] || null }));
484
+ });
485
+ dashboard.progressDocs.forEach((doc) => {
486
+ model.progress.push(withProject(doc));
487
+ });
488
+ dashboard.warnings.forEach((warning) => {
489
+ model.warnings.push({ ...warning, project });
490
+ });
491
+ }
492
+
493
+ const byUpdatedDesc = (a, b) => (b.updated || b.project?.updated || "").localeCompare(a.updated || a.project?.updated || "");
494
+ model.current.sort(byUpdatedDesc);
495
+ model.blockers.sort(byUpdatedDesc);
496
+ model.validation.sort(byUpdatedDesc);
497
+ model.progress.sort(byUpdatedDesc);
498
+ model.warnings.sort((a, b) => (b.project?.updated || "").localeCompare(a.project?.updated || ""));
499
+
500
+ model.counts.current = model.current.length;
501
+ model.counts.blockers = model.blockers.length;
502
+ model.counts.validation = model.validation.length;
503
+ model.counts.progress = model.progress.length;
504
+ model.counts.warnings = model.warnings.length;
505
+ model.counts.projects = (index.projects || []).filter((p) => p.outline).length;
506
+
507
+ return model;
508
+ }
509
+
510
+ function getProjectStats(index, project) {
511
+ const docs = (project.docs || []).map((p) => byPath(index.docs, p)).filter(Boolean);
512
+ const dashboard = getDashboardModel(project, docs);
513
+ const relatedAssets = docs.filter((doc) => !["task", "workstream"].includes(doc.role)).length;
514
+ const openTasks = dashboard.tasks.filter((task) => statusLabel(task.status) !== "Complete");
515
+ const latestDoc = docs
516
+ .slice()
517
+ .sort((a, b) => (b.updated || "").localeCompare(a.updated || ""))[0];
518
+
519
+ return {
520
+ project,
521
+ docs,
522
+ dashboard,
523
+ tasks: dashboard.tasks,
524
+ openTasks,
525
+ workstreams: dashboard.workstreams,
526
+ relatedAssets,
527
+ updated: latestDoc?.updated || project.created || "",
528
+ };
529
+ }
530
+
531
+ function formatShortDate(value) {
532
+ if (!value) return "-";
533
+ const date = new Date(value);
534
+ if (Number.isNaN(date.getTime())) return "-";
535
+ return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
536
+ }
537
+
538
+ function pageCountFor(items, pageSize = WORKSPACE_PAGE_SIZE) {
539
+ return Math.max(1, Math.ceil((items?.length || 0) / pageSize));
540
+ }
541
+
542
+ function clampPage(page, totalPages) {
543
+ const parsed = Number(page);
544
+ const next = Number.isFinite(parsed) ? Math.floor(parsed) : 1;
545
+ return Math.min(Math.max(1, next), Math.max(1, totalPages));
546
+ }
547
+
548
+ function paginateItems(items, page, pageSize = WORKSPACE_PAGE_SIZE) {
549
+ const totalPages = pageCountFor(items, pageSize);
550
+ const safePage = clampPage(page, totalPages);
551
+ const start = (safePage - 1) * pageSize;
552
+ return {
553
+ visible: (items || []).slice(start, start + pageSize),
554
+ safePage,
555
+ totalPages,
556
+ };
557
+ }
558
+
559
+ const LinkButton = ({ children, title, className = "", ...props }) => (
560
+ <button
561
+ {...props}
562
+ className={`link${className ? ` ${className}` : ""}`}
563
+ title={title || (typeof children === "string" ? children : undefined)}
564
+ type={props.type || "button"}
565
+ >
566
+ {children}
567
+ </button>
568
+ );
569
+
570
+ const Pagination = ({ page, totalPages, onPageChange }) => {
571
+ if (totalPages <= 1) return null;
572
+ return (
573
+ <div className="pagination" aria-label="Pagination">
574
+ <button
575
+ className="btn"
576
+ type="button"
577
+ onClick={() => onPageChange(Math.max(1, page - 1))}
578
+ disabled={page === 1}
579
+ >
580
+ Previous
581
+ </button>
582
+ <span className="mono">Page {page} of {totalPages}</span>
583
+ <button
584
+ className="btn"
585
+ type="button"
586
+ onClick={() => onPageChange(Math.min(totalPages, page + 1))}
587
+ disabled={page === totalPages}
588
+ >
589
+ Next
590
+ </button>
591
+ </div>
592
+ );
593
+ };
594
+
595
+ /* ================================================================
596
+ Reusable components
597
+ ================================================================ */
598
+ const CopyButton = ({ value, label = "value", className = "" }) => {
599
+ const [copied, setCopied] = useState(false);
600
+ const text = normalizeCopyValue(value);
601
+
602
+ useEffect(() => {
603
+ if (!copied) return undefined;
604
+ const timeout = window.setTimeout(() => setCopied(false), 1200);
605
+ return () => window.clearTimeout(timeout);
606
+ }, [copied]);
607
+
608
+ if (!text) return null;
609
+
610
+ const stateLabel = copied ? `Copied ${label}` : `Copy ${label}`;
611
+ const handleClick = async (event) => {
612
+ event.preventDefault();
613
+ event.stopPropagation();
614
+ const ok = await copyTextToClipboard(text);
615
+ if (!ok) return;
616
+ setCopied(true);
617
+ announceCopy(label);
618
+ };
619
+
620
+ return (
621
+ <button
622
+ className={`copy-btn${copied ? " is-copied" : ""}${className ? ` ${className}` : ""}`}
623
+ type="button"
624
+ onClick={handleClick}
625
+ aria-label={stateLabel}
626
+ title={stateLabel}
627
+ >
628
+ <Icon d={copied ? I.checkMark : I.copy} size={13} />
629
+ </button>
630
+ );
631
+ };
632
+
633
+ const Field = ({ label, children, mono, copyValue, copyLabel }) => (
634
+ <div className="field">
635
+ <div className="field-label">{label}</div>
636
+ <div className={"field-value" + (mono ? " mono" : "")}>
637
+ {children}
638
+ <CopyButton value={copyValue} label={copyLabel || label} />
639
+ </div>
640
+ </div>
641
+ );
642
+
643
+ const SectionHeader = ({ title, count, right, collapsible, open, onToggle }) => (
644
+ <div
645
+ className={"section-head" + (collapsible ? " is-collapsible" : "")}
646
+ onClick={collapsible ? onToggle : undefined}
647
+ role={collapsible ? "button" : undefined}
648
+ >
649
+ <div className="section-title">
650
+ <span>{title}</span>
651
+ {count != null && <span className="count">{count}</span>}
652
+ </div>
653
+ <div className="section-right">
654
+ {right}
655
+ {collapsible && (
656
+ <span className="caret">
657
+ <Icon d={open ? I.chevU : I.chevD} size={16} />
658
+ </span>
659
+ )}
660
+ </div>
661
+ </div>
662
+ );
663
+
664
+ const Block = ({ title, children }) => (
665
+ <section className="ws-block">
666
+ <h3 className="ws-h">{title}</h3>
667
+ <div className="ws-body">{children}</div>
668
+ </section>
669
+ );
670
+
671
+ /* ================================================================
672
+ Navigation definitions
673
+ ================================================================ */
674
+ const NAV = [
675
+ { id: "overview", label: "Overview", icon: I.home },
676
+ { id: "current", label: "Current Work", icon: I.list },
677
+ { id: "blockers", label: "Blockers", icon: I.block },
678
+ { id: "progress", label: "Progress", icon: I.trend },
679
+ { id: "validation", label: "Validation", icon: I.check },
680
+ { id: "warnings", label: "Warnings", icon: I.warn },
681
+ ];
682
+
683
+ const GLOBAL_NAV = [
684
+ { id: "workspace-projects", label: "Projects", icon: I.grid, countKey: "projects" },
685
+ { id: "workspace-current", label: "Open work", icon: I.list, countKey: "current" },
686
+ { id: "workspace-progress", label: "Progress", icon: I.trend, countKey: "progress" },
687
+ { id: "workspace-validation", label: "Validation", icon: I.check, countKey: "validation" },
688
+ { id: "workspace-warnings", label: "Warnings", icon: I.warn, countKey: "warnings" },
689
+ { id: "workspace-blockers", label: "Blockers", icon: I.block, countKey: "blockers" },
690
+ ];
691
+
692
+ const GLOBAL_ROUTES = new Set(GLOBAL_NAV.map((item) => item.id));
693
+
694
+ function readStoredNavigation() {
695
+ try {
696
+ const raw = window.localStorage.getItem(NAV_STATE_KEY);
697
+ if (!raw) return null;
698
+ const parsed = JSON.parse(raw);
699
+ return parsed?.version === NAV_STATE_VERSION ? parsed : null;
700
+ } catch (_) {
701
+ return null;
702
+ }
703
+ }
704
+
705
+ function sanitizeWorkspacePages(value) {
706
+ const pages = {};
707
+ if (!value || typeof value !== "object") return pages;
708
+ GLOBAL_NAV.forEach((item) => {
709
+ const page = Number(value[item.id]);
710
+ if (Number.isFinite(page) && page > 1) pages[item.id] = Math.floor(page);
711
+ });
712
+ return pages;
713
+ }
714
+
715
+ function findProjectForDoc(index, docPath) {
716
+ if (!docPath) return null;
717
+ return (index.projects || []).find((project) => (project.docs || []).includes(docPath)) || null;
718
+ }
719
+
720
+ function projectHasWorkstream(project, wsPath) {
721
+ return !!project?.outline?.workstreams?.some((ws) => ws.path === wsPath);
722
+ }
723
+
724
+ function fallbackRouteForProject(project) {
725
+ return project?.outline ? "overview" : "list";
726
+ }
727
+
728
+ function makeDefaultNavigation(index) {
729
+ const firstProject = (index.projects || []).find((p) => p.outline) || (index.projects || [])[0] || null;
730
+ return {
731
+ projectSlug: firstProject?.slug || null,
732
+ route: DEFAULT_WORKSPACE_ROUTE,
733
+ section: null,
734
+ docPath: null,
735
+ wsPath: null,
736
+ workspacePages: {},
737
+ };
738
+ }
739
+
740
+ function restoreNavigation(index) {
741
+ const fallback = makeDefaultNavigation(index);
742
+ const stored = readStoredNavigation();
743
+ if (!stored) return fallback;
744
+
745
+ let project = (index.projects || []).find((p) => p.slug === stored.projectSlug) || null;
746
+ if (!project) project = (index.projects || []).find((p) => p.slug === fallback.projectSlug) || null;
747
+ if (!project) return fallback;
748
+
749
+ const workspacePages = sanitizeWorkspacePages(stored.workspacePages);
750
+ if (GLOBAL_ROUTES.has(stored.route)) {
751
+ return {
752
+ ...fallback,
753
+ projectSlug: project.slug,
754
+ route: stored.route,
755
+ workspacePages,
756
+ };
757
+ }
758
+
759
+ if (stored.route === "document" && stored.docPath) {
760
+ const docProject = findProjectForDoc(index, stored.docPath);
761
+ if (docProject) {
762
+ return {
763
+ projectSlug: docProject.slug,
764
+ route: "document",
765
+ section: stored.docPath,
766
+ docPath: stored.docPath,
767
+ wsPath: projectHasWorkstream(docProject, stored.wsPath) ? stored.wsPath : null,
768
+ workspacePages,
769
+ };
770
+ }
771
+ }
772
+
773
+ if (stored.route === "workstream" && projectHasWorkstream(project, stored.wsPath)) {
774
+ return {
775
+ projectSlug: project.slug,
776
+ route: "workstream",
777
+ section: null,
778
+ docPath: null,
779
+ wsPath: stored.wsPath,
780
+ workspacePages,
781
+ };
782
+ }
783
+
784
+ const projectRoutes = project.outline ? new Set(["overview", "workstreams", "tasks"]) : new Set(["list"]);
785
+ if (projectRoutes.has(stored.route)) {
786
+ return {
787
+ projectSlug: project.slug,
788
+ route: stored.route,
789
+ section: stored.section || (stored.route === "overview" ? "overview" : null),
790
+ docPath: null,
791
+ wsPath: null,
792
+ workspacePages,
793
+ };
794
+ }
795
+
796
+ return {
797
+ ...fallback,
798
+ projectSlug: project.slug,
799
+ route: fallbackRouteForProject(project),
800
+ section: project.outline ? "overview" : null,
801
+ workspacePages,
802
+ };
803
+ }
804
+
805
+ function getTaskNavigation(index, project, taskDoc) {
806
+ if (!index || !project?.outline || !taskDoc || taskDoc.role !== "task") return null;
807
+ const workstreams = project.outline.workstreams || [];
808
+ let parent = workstreams.find((ws) => (ws.tasks || []).includes(taskDoc.path)) || null;
809
+ if (!parent && taskDoc.workstreamId) {
810
+ parent = workstreams.find((ws) => ws.id === taskDoc.workstreamId) || null;
811
+ }
812
+
813
+ const tasks = (project.docs || [])
814
+ .map((path) => byPath(index.docs, path))
815
+ .filter((doc) => doc?.role === "task");
816
+ const tasksById = {};
817
+ tasks.forEach((task) => {
818
+ if (task.taskId) tasksById[String(task.taskId).toUpperCase()] = task;
819
+ });
820
+
821
+ return {
822
+ parent,
823
+ siblings: parent ? (parent.tasks || []).map((path) => byPath(index.docs, path)).filter(Boolean) : [],
824
+ tasksById,
825
+ };
826
+ }
827
+
828
+ function listValue(value) {
829
+ if (Array.isArray(value)) return value.map(String).filter(Boolean);
830
+ if (value == null || value === "") return [];
831
+ return [String(value)];
832
+ }
833
+
834
+ /* ================================================================
835
+ Sidebar
836
+ ================================================================ */
837
+ function Sidebar({ index, projectSlug, route, section, onNavigate, onSelectProject }) {
838
+ const projects = index?.projects || [];
839
+ const current = projects.find((p) => p.slug === projectSlug);
840
+ const hasOutline = current?.outline;
841
+ const globalCounts = useMemo(
842
+ () => (index ? getWorkspaceModel(index).counts : {}),
843
+ [index]
844
+ );
845
+
846
+ const contractItems = useMemo(() => {
847
+ if (!hasOutline) return [];
848
+ const items = [];
849
+ if (current.outline.spec) items.push({ id: "spec", label: "Spec", icon: I.doc, path: current.outline.spec });
850
+ if (current.outline.plan) items.push({ id: "plan", label: "Plan", icon: I.plan, path: current.outline.plan });
851
+ (current.outline.decisions || []).forEach((p, i) =>
852
+ items.push({ id: `dec-${i}`, label: "Decisions", icon: I.scale, path: p })
853
+ );
854
+ (current.outline.progress || []).forEach((p, i) =>
855
+ items.push({ id: `prog-${i}`, label: i === 0 ? "Progress log" : `Progress ${i + 1}`, icon: I.clock, path: p })
856
+ );
857
+ return items;
858
+ }, [current, hasOutline]);
859
+
860
+ return (
861
+ <aside className="sidebar">
862
+ <div className="brand" aria-label="Delano">
863
+ <img className="brand-logo" src="/delano-logo.svg" alt="Delano" />
864
+ </div>
865
+
866
+ <div className="nav-section">Workspace</div>
867
+ <nav className="nav">
868
+ {GLOBAL_NAV.map((it) => (
869
+ <button
870
+ key={it.id}
871
+ className={"nav-item nav-item-count" + (route === it.id ? " is-active" : "")}
872
+ onClick={() => onNavigate(it.id)}
873
+ type="button"
874
+ >
875
+ <span className="nav-ico">
876
+ <Icon d={it.icon} size={16} />
877
+ </span>
878
+ <span className="nav-label">{it.label}</span>
879
+ <span className="nav-count mono">{globalCounts[it.countKey] || 0}</span>
880
+ </button>
881
+ ))}
882
+ </nav>
883
+
884
+ <div className="nav-section">Selected project</div>
885
+ <div className="project-select-v3">
886
+ <span className="project-select-mark">
887
+ <Icon d={current?.outline ? I.grid : I.folder} size={15} />
888
+ </span>
889
+ <select
890
+ className="project-select-control"
891
+ value={projectSlug || ""}
892
+ onChange={(e) => onSelectProject(e.target.value)}
893
+ aria-label="Project"
894
+ >
895
+ {projects.map((p) => (
896
+ <option key={p.slug} value={p.slug}>
897
+ {p.title}
898
+ </option>
899
+ ))}
900
+ </select>
901
+ </div>
902
+
903
+ {hasOutline && (
904
+ <>
905
+ <nav className="nav">
906
+ <button
907
+ className={"nav-item" + (route === "overview" ? " is-active" : "")}
908
+ onClick={() => onNavigate("overview")}
909
+ type="button"
910
+ >
911
+ <span className="nav-ico">
912
+ <Icon d={I.home} size={16} />
913
+ </span>
914
+ <span>Project overview</span>
915
+ </button>
916
+ </nav>
917
+
918
+
919
+ <div className="nav-section">Source contracts</div>
920
+ <nav className="nav source-nav-v2">
921
+ {contractItems.filter((it) => !it.id.startsWith("prog-")).map((it) => (
922
+ <button key={it.id} className={"nav-item" + (route === "document" && section === it.path ? " is-active" : "")} onClick={() => onNavigate("document", it.path)} type="button">
923
+ <span className="nav-ico"><Icon d={it.icon} size={16} /></span>
924
+ <span>{it.label}</span>
925
+ </button>
926
+ ))}
927
+ <button className={"nav-item" + (route === "workstreams" ? " is-active" : "")} onClick={() => onNavigate("workstreams")} type="button">
928
+ <span className="nav-ico"><Icon d={I.grid} size={16} /></span>
929
+ <span>Workstreams</span>
930
+ </button>
931
+ <button className={"nav-item" + (route === "tasks" ? " is-active" : "")} onClick={() => onNavigate("tasks")} type="button">
932
+ <span className="nav-ico"><Icon d={I.task} size={16} /></span>
933
+ <span>Tasks</span>
934
+ </button>
935
+ <div className="nav-break">Progress</div>
936
+ {contractItems.filter((it) => it.id.startsWith("prog-")).map((it) => (
937
+ <button key={it.id} className={"nav-item" + (route === "document" && section === it.path ? " is-active" : "")} onClick={() => onNavigate("document", it.path)} type="button">
938
+ <span className="nav-ico"><Icon d={it.icon} size={16} /></span>
939
+ <span>{it.label}</span>
940
+ </button>
941
+ ))}
942
+ </nav>
943
+ </>
944
+ )}
945
+
946
+ <div className="sidebar-foot">
947
+ <button className="nav-item" type="button">
948
+ <span className="nav-ico">
949
+ <Icon d={I.gear} size={16} />
950
+ </span>
951
+ <span>Viewer settings</span>
952
+ </button>
953
+ </div>
954
+ </aside>
955
+ );
956
+ }
957
+
958
+ /* ================================================================
959
+ Topbar
960
+ ================================================================ */
961
+ function Topbar({ project, index, docPath, onOpenAction }) {
962
+ const spec = project?.outline?.spec;
963
+ const specDoc = spec && index ? byPath(index.docs, spec) : null;
964
+ const title = project?.title || "Delano";
965
+ const status = specDoc?.status || project?.status;
966
+ const updated = specDoc?.updated || "";
967
+ const dateStr = updated
968
+ ? new Date(updated).toLocaleString("en-US", {
969
+ month: "short", day: "numeric", year: "numeric",
970
+ hour: "2-digit", minute: "2-digit", hour12: false,
971
+ })
972
+ : "";
973
+
974
+ return (
975
+ <header className="topbar">
976
+ <div className="tb-project">
977
+ <span className="tb-title">{title}</span>
978
+ {status && <StatusChip>{status}</StatusChip>}
979
+ </div>
980
+ <div className="tb-meta">
981
+ {dateStr && (
982
+ <>
983
+ <span>Last updated <strong>{dateStr}</strong></span>
984
+ <span className="tb-sep" />
985
+ </>
986
+ )}
987
+ <span className="tb-readonly">
988
+ <Icon d={I.lock} size={13} /> Read-only
989
+ </span>
990
+ </div>
991
+ <div className="tb-actions">
992
+ {docPath && (
993
+ <>
994
+ <button className="btn" onClick={() => onOpenAction("code", docPath)}>
995
+ <Icon d={I.code} size={14} /> Open in IDE
996
+ </button>
997
+ <button className="btn" onClick={() => onOpenAction("explorer", docPath)}>
998
+ <Icon d={I.folderOpen} size={14} /> Open folder
999
+ </button>
1000
+ </>
1001
+ )}
1002
+ </div>
1003
+ </header>
1004
+ );
1005
+ }
1006
+
1007
+ /* ================================================================
1008
+ Overview
1009
+ ================================================================ */
1010
+ function Overview({ index, project, docs, scrollTarget, onOpenWorkstream, onOpenDoc, onOpenTasks }) {
1011
+ const [open, setOpen] = useState({ current: true, blockers: true, validation: false, progress: false, warnings: false });
1012
+ const toggle = (k) => setOpen((o) => ({ ...o, [k]: !o[k] }));
1013
+
1014
+ const dashboard = useMemo(() => getDashboardModel(project, docs), [project, docs]);
1015
+ const { tasks, blockers, progressDocs, health, warnings, workstreams, wsLookup } = dashboard;
1016
+ const taskByPath = {};
1017
+ tasks.forEach((task) => {
1018
+ taskByPath[task.path] = task;
1019
+ });
1020
+ const openTasks = tasks
1021
+ .filter((task) => statusLabel(task.status) !== "Complete")
1022
+ .sort((a, b) => {
1023
+ const rank = { Blocked: 0, "In Progress": 1, Planned: 2, Complete: 3 };
1024
+ const statusDelta = (rank[statusLabel(a.status)] ?? 4) - (rank[statusLabel(b.status)] ?? 4);
1025
+ if (statusDelta !== 0) return statusDelta;
1026
+ return (b.updated || "").localeCompare(a.updated || "");
1027
+ });
1028
+ const workstreamRows = workstreams
1029
+ .map((ws) => {
1030
+ const wsTasks = (ws.tasks || []).map((path) => taskByPath[path]).filter(Boolean);
1031
+ const openCount = wsTasks.filter((task) => statusLabel(task.status) !== "Complete").length;
1032
+ return { ...ws, tasks: wsTasks, openCount };
1033
+ })
1034
+ .sort((a, b) => b.openCount - a.openCount || a.title.localeCompare(b.title));
1035
+
1036
+ const nextAction = blockers.length
1037
+ ? "Resolve blocked tasks"
1038
+ : health.pct < 100
1039
+ ? "Complete remaining tasks"
1040
+ : "All tasks complete";
1041
+
1042
+ // Auto-open + scroll to section from sidebar nav
1043
+ useEffect(() => {
1044
+ if (!scrollTarget || scrollTarget === "overview") return;
1045
+ const sectionKey = scrollTarget.replace("-nav", "");
1046
+ if (["blockers", "validation", "progress", "warnings", "current"].includes(sectionKey)) {
1047
+ setOpen((o) => ({ ...o, [sectionKey]: true }));
1048
+ setTimeout(() => {
1049
+ const el = document.getElementById(`section-${sectionKey}`);
1050
+ if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
1051
+ }, 100);
1052
+ }
1053
+ }, [scrollTarget]);
1054
+
1055
+ return (
1056
+ <div className="page overview-v1">
1057
+ <div className="overview-v1-head">
1058
+ <h1 className="page-title">Overview</h1>
1059
+ <div className="overview-signal-strip signal-color-filled">
1060
+ <button className={"signal-pill signal-pill-warning" + (warnings.length ? " has-count" : " is-zero")} onClick={() => toggle("warnings")} type="button">
1061
+ <Icon d={I.warn} size={14} />
1062
+ <span>Warnings</span>
1063
+ <span className="mono">{warnings.length}</span>
1064
+ </button>
1065
+ <button className={"signal-pill signal-pill-blocker" + (blockers.length ? " has-count" : " is-zero")} onClick={() => toggle("blockers")} type="button">
1066
+ <Icon d={I.block} size={14} />
1067
+ <span>Blockers</span>
1068
+ <span className="mono">{blockers.length}</span>
1069
+ </button>
1070
+ <button className={"signal-pill signal-pill-validation" + (tasks.length ? " has-count" : " is-zero")} onClick={() => toggle("validation")} type="button">
1071
+ <Icon d={I.check} size={14} />
1072
+ <span>Validation</span>
1073
+ <span className="mono">{tasks.length}</span>
1074
+ </button>
1075
+ <button className={"signal-pill signal-pill-progress" + (progressDocs.length ? " has-count" : " is-zero")} onClick={() => toggle("progress")} type="button">
1076
+ <Icon d={I.trend} size={14} />
1077
+ <span>Progress</span>
1078
+ <span className="mono">{progressDocs.length}</span>
1079
+ </button>
1080
+ </div>
1081
+ </div>
1082
+
1083
+ <section className="summary overview-summary">
1084
+ <Field label="Project" copyValue={project.slug} copyLabel="project ID">
1085
+ <span>{project.title}</span>
1086
+ <span className="field-id mono">{project.slug}</span>
1087
+ </Field>
1088
+ <Field label="Status"><StatusChip>{project.status || "Planned"}</StatusChip></Field>
1089
+ <Field label="Health">
1090
+ <span className="health">
1091
+ <span className="health-bar"><span className="health-fill" style={{ width: `${health.pct}%`, background: health.pct === 100 ? "var(--ok)" : undefined }} /></span>
1092
+ <span className="health-label">{health.label}</span>
1093
+ </span>
1094
+ </Field>
1095
+ <Field label="Next action">{nextAction}</Field>
1096
+ </section>
1097
+
1098
+ <section className="overview-delivery">
1099
+ <div className="delivery-panel">
1100
+ <SectionHeader title="Workstreams" count={workstreams.length} />
1101
+ {workstreamRows.length > 0 ? (
1102
+ <div className="delivery-list">
1103
+ {workstreamRows.map((ws) => (
1104
+ <button className="delivery-row" key={ws.path} type="button" onClick={() => onOpenWorkstream(ws.path)}>
1105
+ <span className="delivery-row-main">
1106
+ <span className="delivery-title">{ws.title}</span>
1107
+ <span className="delivery-meta">{ws.tasks.length} task{ws.tasks.length !== 1 ? "s" : ""}</span>
1108
+ </span>
1109
+ <span className="delivery-row-right">
1110
+ <span className="mono delivery-count">{ws.openCount} open</span>
1111
+ <StatusChip>{ws.status || "Planned"}</StatusChip>
1112
+ </span>
1113
+ </button>
1114
+ ))}
1115
+ </div>
1116
+ ) : (
1117
+ <div className="empty-state">No workstreams in this project.</div>
1118
+ )}
1119
+ </div>
1120
+
1121
+ <div className="delivery-panel">
1122
+ <SectionHeader
1123
+ title="Open tasks"
1124
+ count={openTasks.length}
1125
+ right={<button className="link-muted" type="button" onClick={onOpenTasks}>All tasks</button>}
1126
+ />
1127
+ {openTasks.length > 0 ? (
1128
+ <div className="delivery-list">
1129
+ {openTasks.slice(0, 7).map((task) => {
1130
+ const ws = wsLookup[task.path];
1131
+ return (
1132
+ <div className="delivery-task-row" key={task.path}>
1133
+ <LinkButton onClick={() => onOpenDoc(task.path)} title={task.title}>{task.title}</LinkButton>
1134
+ <span className="td-muted">{ws?.title || "Unassigned"}</span>
1135
+ <StatusChip>{task.status || "Planned"}</StatusChip>
1136
+ </div>
1137
+ );
1138
+ })}
1139
+ {openTasks.length > 7 && (
1140
+ <button className="delivery-more" type="button" onClick={onOpenTasks}>
1141
+ {openTasks.length - 7} more open task{openTasks.length - 7 !== 1 ? "s" : ""}
1142
+ </button>
1143
+ )}
1144
+ </div>
1145
+ ) : (
1146
+ <div className="empty-state">No open tasks.</div>
1147
+ )}
1148
+ </div>
1149
+ </section>
1150
+
1151
+ <section className="overview-priority">
1152
+ <div className="overview-priority-main">
1153
+ <SectionHeader title="Warnings" count={warnings.length} collapsible open={open.warnings} onToggle={() => toggle("warnings")} />
1154
+ {warnings.length > 0 ? (
1155
+ <div className="preview-list">
1156
+ {warnings.slice(0, open.warnings ? warnings.length : 3).map((w, i) => (
1157
+ <div className="preview-row preview-row-warn" key={i}>
1158
+ <span className={`chip ${w.sev === "Medium" ? "chip-warn" : "chip-low"}`}><span className="chip-dot" /> {w.sev}</span>
1159
+ <span className="td-primary">{w.note}</span>
1160
+ <span className="td-muted">{w.ws}</span>
1161
+ </div>
1162
+ ))}
1163
+ </div>
1164
+ ) : (
1165
+ <div className="empty-state">No warnings.</div>
1166
+ )}
1167
+ </div>
1168
+ </section>
1169
+
1170
+ <section className="block">
1171
+ <SectionHeader title="Progress" count={progressDocs.length} collapsible open={open.progress} onToggle={() => toggle("progress")} />
1172
+ {progressDocs.length > 0 ? (
1173
+ <div className="preview-list">
1174
+ {progressDocs.slice(0, open.progress ? progressDocs.length : 3).map((doc, i) => {
1175
+ const progressMeta = formatShortDateTime(doc.updated);
1176
+ const progressSnippet = stripRepeatedTitle(doc.title, doc.snippet);
1177
+ return (
1178
+ <div className="preview-row preview-row-progress" key={i}>
1179
+ <span className="mono preview-meta-time">{progressMeta}</span>
1180
+ <LinkButton onClick={() => onOpenDoc(doc.path)} title={doc.title}>{doc.title}</LinkButton>
1181
+ <span className="preview-copy">{progressSnippet}</span>
1182
+ </div>
1183
+ );
1184
+ })}
1185
+ </div>
1186
+ ) : (
1187
+ <div className="empty-state">No progress entries.</div>
1188
+ )}
1189
+ </section>
1190
+
1191
+ <section className="block">
1192
+ <SectionHeader title="Validation" count={tasks.length} collapsible open={open.validation} onToggle={() => toggle("validation")} />
1193
+ <div className="preview-list preview-list-validation">
1194
+ {tasks.slice(0, open.validation ? tasks.length : 3).map((task, i) => {
1195
+ const label = statusLabel(task.status);
1196
+ const chipClass = label === "Complete" ? "chip-ok" : label === "Blocked" ? "chip-warn" : "chip-low";
1197
+ return (
1198
+ <div className="preview-row validation-row" key={i}>
1199
+ <span className={`chip ${chipClass}`}><span className="chip-dot" /> {label}</span>
1200
+ <LinkButton onClick={() => onOpenDoc(task.path)} title={task.title}>{task.title}</LinkButton>
1201
+ <span className="td-muted">{(wsLookup[task.path]?.title) || "Unassigned"}</span>
1202
+ </div>
1203
+ );
1204
+ })}
1205
+ </div>
1206
+ </section>
1207
+
1208
+ <div className="page-foot mono">viewer · read-only · generated from contracts at <span>{index.generatedAt || ""}</span></div>
1209
+ </div>
1210
+ );
1211
+ }
1212
+
1213
+ /* ================================================================
1214
+ Workspace Pages
1215
+ ================================================================ */
1216
+ function WorkspacePage({ index, view, page, onPageChange, onOpenProject, onOpenProjectDoc, onOpenProjectWorkstream }) {
1217
+ const [projectFilter, setProjectFilter] = useState("all");
1218
+ const workspace = useMemo(() => getWorkspaceModel(index), [index]);
1219
+ const projectStats = useMemo(
1220
+ () => (index.projects || []).filter((p) => p.outline).map((project) => getProjectStats(index, project)),
1221
+ [index]
1222
+ );
1223
+ const projectFilterCounts = useMemo(() => {
1224
+ const active = projectStats.filter((stat) => statusLabel(stat.project.status) !== "Complete").length;
1225
+ return {
1226
+ all: projectStats.length,
1227
+ active,
1228
+ complete: projectStats.length - active,
1229
+ };
1230
+ }, [projectStats]);
1231
+ const filteredProjectStats = useMemo(() => {
1232
+ if (projectFilter === "active") {
1233
+ return projectStats.filter((stat) => statusLabel(stat.project.status) !== "Complete");
1234
+ }
1235
+ if (projectFilter === "complete") {
1236
+ return projectStats.filter((stat) => statusLabel(stat.project.status) === "Complete");
1237
+ }
1238
+ return projectStats;
1239
+ }, [projectFilter, projectStats]);
1240
+ const currentPage = page || 1;
1241
+
1242
+ const titleMap = {
1243
+ "workspace-projects": "Projects",
1244
+ "workspace-current": "Open work",
1245
+ "workspace-progress": "Progress",
1246
+ "workspace-validation": "Validation",
1247
+ "workspace-warnings": "Warnings",
1248
+ "workspace-blockers": "Blockers",
1249
+ };
1250
+ const title = titleMap[view] || "Workspace";
1251
+ const itemsForView =
1252
+ view === "workspace-projects" ? filteredProjectStats :
1253
+ view === "workspace-current" ? workspace.current :
1254
+ view === "workspace-blockers" ? workspace.blockers :
1255
+ view === "workspace-validation" ? workspace.validation :
1256
+ view === "workspace-progress" ? workspace.progress :
1257
+ view === "workspace-warnings" ? workspace.warnings :
1258
+ [];
1259
+
1260
+ useEffect(() => {
1261
+ const totalPages = pageCountFor(itemsForView);
1262
+ const safePage = clampPage(currentPage, totalPages);
1263
+ if (safePage !== currentPage) onPageChange(safePage);
1264
+ }, [view, itemsForView.length, currentPage, onPageChange]);
1265
+
1266
+ const projectButton = (project) => (
1267
+ <LinkButton onClick={() => onOpenProject(project.slug)} title={project.title}>
1268
+ {project.title}
1269
+ </LinkButton>
1270
+ );
1271
+
1272
+ const workstreamButton = (item) =>
1273
+ item.workstream ? (
1274
+ <LinkButton onClick={() => onOpenProjectWorkstream(item.project.slug, item.workstream.path)} title={item.workstream.title}>
1275
+ {item.workstream.title}
1276
+ </LinkButton>
1277
+ ) : (
1278
+ <span className="td-muted">-</span>
1279
+ );
1280
+
1281
+ const renderPagination = (pagination) => (
1282
+ <Pagination page={pagination.safePage} totalPages={pagination.totalPages} onPageChange={onPageChange} />
1283
+ );
1284
+
1285
+ const projectFilterControl = view === "workspace-projects" ? (
1286
+ <div className="project-filter" aria-label="Project status filter">
1287
+ {[
1288
+ ["all", "All"],
1289
+ ["active", "Active"],
1290
+ ["complete", "Complete"],
1291
+ ].map(([value, label]) => (
1292
+ <button
1293
+ className={"project-filter-option" + (projectFilter === value ? " is-active" : "")}
1294
+ type="button"
1295
+ onClick={() => setProjectFilter(value)}
1296
+ aria-pressed={projectFilter === value}
1297
+ key={value}
1298
+ >
1299
+ <span>{label}</span>
1300
+ <span className="mono">{projectFilterCounts[value]}</span>
1301
+ </button>
1302
+ ))}
1303
+ </div>
1304
+ ) : null;
1305
+
1306
+ const renderProjects = () =>
1307
+ filteredProjectStats.length > 0 ? (
1308
+ <div className="project-grid">
1309
+ {filteredProjectStats.map((stat) => (
1310
+ <button className="project-card" key={stat.project.slug} type="button" onClick={() => onOpenProject(stat.project.slug)}>
1311
+ <span className="project-card-head">
1312
+ <span className="project-card-title">{stat.project.title}</span>
1313
+ <StatusChip>{stat.project.status || "Planned"}</StatusChip>
1314
+ </span>
1315
+ <span className="project-card-dates">
1316
+ <span><span>Created</span> {formatShortDate(stat.project.created)}</span>
1317
+ <span><span>Updated</span> {formatShortDate(stat.updated)}</span>
1318
+ </span>
1319
+ <span className="project-card-stats">
1320
+ <span><strong>{stat.workstreams.length}</strong> Workstreams</span>
1321
+ <span><strong>{stat.openTasks.length}</strong> Open tasks</span>
1322
+ <span><strong>{stat.tasks.length}</strong> Tasks</span>
1323
+ <span><strong>{stat.relatedAssets}</strong> Assets</span>
1324
+ </span>
1325
+ </button>
1326
+ ))}
1327
+ </div>
1328
+ ) : (
1329
+ <div className="empty-state">No projects match this filter.</div>
1330
+ );
1331
+
1332
+ const renderTaskRows = (items, emptyText, kind) => {
1333
+ const pagination = paginateItems(items, currentPage);
1334
+ return (
1335
+ <>
1336
+ {pagination.visible.length > 0 ? (
1337
+ <div className="table table-workspace">
1338
+ <div className="tr th">
1339
+ <div>Task</div>
1340
+ <div>Project</div>
1341
+ <div>Workstream</div>
1342
+ <div>State</div>
1343
+ </div>
1344
+ {pagination.visible.map((task) => (
1345
+ <div className="tr" key={`${task.project.slug}:${task.path}`}>
1346
+ <div className="td-primary">
1347
+ <LinkButton onClick={() => onOpenProjectDoc(task.project.slug, task.path)} title={task.title}>
1348
+ {kind === "blocked" && <span className="dot dot-warn" />} {task.title}
1349
+ </LinkButton>
1350
+ </div>
1351
+ <div>{projectButton(task.project)}</div>
1352
+ <div>{workstreamButton(task)}</div>
1353
+ <div>
1354
+ <StatusChip>{task.status}</StatusChip>
1355
+ </div>
1356
+ </div>
1357
+ ))}
1358
+ </div>
1359
+ ) : (
1360
+ <div className="empty-state">{emptyText}</div>
1361
+ )}
1362
+ {renderPagination(pagination)}
1363
+ </>
1364
+ );
1365
+ };
1366
+
1367
+ const renderProgress = () => {
1368
+ const pagination = paginateItems(workspace.progress, currentPage);
1369
+
1370
+ return (
1371
+ <>
1372
+ {pagination.visible.length > 0 ? (
1373
+ <div className="timeline timeline-workspace">
1374
+ {pagination.visible.map((doc) => {
1375
+ const date = doc.updated
1376
+ ? new Date(doc.updated).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
1377
+ : "";
1378
+ return (
1379
+ <div className="tl-row" key={`${doc.project.slug}:${doc.path}`}>
1380
+ <div className="tl-date mono">{date}</div>
1381
+ <div className="tl-bullet">
1382
+ <span />
1383
+ </div>
1384
+ <div className="tl-body">
1385
+ <div className="workspace-line">
1386
+ <LinkButton onClick={() => onOpenProjectDoc(doc.project.slug, doc.path)} title={doc.title}>
1387
+ {doc.title}
1388
+ </LinkButton>
1389
+ <span className="td-muted-inline">{doc.project.title}</span>
1390
+ </div>
1391
+ <div className="td-muted small">{doc.snippet}</div>
1392
+ </div>
1393
+ </div>
1394
+ );
1395
+ })}
1396
+ </div>
1397
+ ) : (
1398
+ <div className="empty-state">No progress entries across projects.</div>
1399
+ )}
1400
+ {renderPagination(pagination)}
1401
+ </>
1402
+ );
1403
+ };
1404
+
1405
+ const renderWarnings = () => {
1406
+ const pagination = paginateItems(workspace.warnings, currentPage);
1407
+ return (
1408
+ <>
1409
+ {pagination.visible.length > 0 ? (
1410
+ <div className="table table-workspace-warnings">
1411
+ <div className="tr th">
1412
+ <div>Severity</div>
1413
+ <div>Project</div>
1414
+ <div>Note</div>
1415
+ <div>Source</div>
1416
+ </div>
1417
+ {pagination.visible.map((w, i) => (
1418
+ <div className="tr" key={`${w.project.slug}:${w.note}:${i}`}>
1419
+ <div>
1420
+ <span className={`chip ${w.sev === "Medium" ? "chip-warn" : "chip-low"}`}>
1421
+ <span className="chip-dot" /> {w.sev}
1422
+ </span>
1423
+ </div>
1424
+ <div>{projectButton(w.project)}</div>
1425
+ <div className="td-primary">{w.note}</div>
1426
+ <div className="td-muted">{w.ws}</div>
1427
+ </div>
1428
+ ))}
1429
+ </div>
1430
+ ) : (
1431
+ <div className="empty-state" style={{ color: "var(--ok)" }}>
1432
+ No warnings across projects.
1433
+ </div>
1434
+ )}
1435
+ {renderPagination(pagination)}
1436
+ </>
1437
+ );
1438
+ };
1439
+
1440
+ const renderBody = () => {
1441
+ if (view === "workspace-projects") return renderProjects();
1442
+ if (view === "workspace-current") return renderTaskRows(workspace.current, "No active tasks across projects.");
1443
+ if (view === "workspace-blockers") return renderTaskRows(workspace.blockers, "No blockers across projects.", "blocked");
1444
+ if (view === "workspace-validation") return renderTaskRows(workspace.validation, "No tasks to validate across projects.");
1445
+ if (view === "workspace-progress") return renderProgress();
1446
+ if (view === "workspace-warnings") return renderWarnings();
1447
+ return null;
1448
+ };
1449
+
1450
+ const count =
1451
+ view === "workspace-projects" ? filteredProjectStats.length :
1452
+ view === "workspace-current" ? workspace.counts.current :
1453
+ view === "workspace-blockers" ? workspace.counts.blockers :
1454
+ view === "workspace-validation" ? workspace.counts.validation :
1455
+ view === "workspace-progress" ? workspace.counts.progress :
1456
+ view === "workspace-warnings" ? workspace.counts.warnings :
1457
+ null;
1458
+
1459
+ return (
1460
+ <div className="page">
1461
+ <section className="block">
1462
+ <SectionHeader title={title} count={count} right={projectFilterControl} />
1463
+ {renderBody()}
1464
+ </section>
1465
+ </div>
1466
+ );
1467
+ }
1468
+
1469
+ /* ================================================================
1470
+ Project Outline Pages
1471
+ ================================================================ */
1472
+ function ProjectWorkstreamsPage({ index, project, docs, onOpenWorkstream, onOpenDoc }) {
1473
+ const workstreams = project.outline?.workstreams || [];
1474
+ const taskDocs = useMemo(() => {
1475
+ const byTaskPath = {};
1476
+ docs.filter((doc) => doc.role === "task").forEach((task) => {
1477
+ byTaskPath[task.path] = task;
1478
+ });
1479
+ return byTaskPath;
1480
+ }, [docs]);
1481
+
1482
+ return (
1483
+ <div className="page">
1484
+ <h1 className="page-title">Workstreams</h1>
1485
+ <section className="block">
1486
+ <SectionHeader title="Project outline" count={workstreams.length} />
1487
+ {workstreams.length > 0 ? (
1488
+ <div className="outline-list">
1489
+ {workstreams.map((ws) => {
1490
+ const tasks = (ws.tasks || []).map((path) => taskDocs[path]).filter(Boolean);
1491
+ return (
1492
+ <div className="outline-row" key={ws.path}>
1493
+ <button className="outline-main" onClick={() => onOpenWorkstream(ws.path)} type="button">
1494
+ <span className="outline-title">{ws.title}</span>
1495
+ <StatusChip>{ws.status || "Planned"}</StatusChip>
1496
+ </button>
1497
+ {tasks.length > 0 && (
1498
+ <div className="outline-sublist">
1499
+ {tasks.map((task) => (
1500
+ <button className="outline-subitem" key={task.path} onClick={() => onOpenDoc(task.path)} type="button">
1501
+ <span className="mono">{task.taskId || task.path.split("/").pop()?.replace(/\.md$/, "")}</span>
1502
+ <span title={task.title}>{task.title}</span>
1503
+ <StatusChip>{task.status || "Planned"}</StatusChip>
1504
+ </button>
1505
+ ))}
1506
+ </div>
1507
+ )}
1508
+ </div>
1509
+ );
1510
+ })}
1511
+ </div>
1512
+ ) : (
1513
+ <div className="empty-state">No workstreams in this project.</div>
1514
+ )}
1515
+ </section>
1516
+ </div>
1517
+ );
1518
+ }
1519
+
1520
+ function ProjectTasksPage({ project, docs, onOpenDoc, onOpenWorkstream }) {
1521
+ const dashboard = useMemo(() => getDashboardModel(project, docs), [project, docs]);
1522
+ const { tasks, wsLookup } = dashboard;
1523
+
1524
+ return (
1525
+ <div className="page">
1526
+ <h1 className="page-title">Tasks</h1>
1527
+ <section className="block">
1528
+ <SectionHeader title="Project tasks" count={tasks.length} />
1529
+ {tasks.length > 0 ? (
1530
+ <div className="table table-4">
1531
+ <div className="tr th">
1532
+ <div>Task</div>
1533
+ <div>Workstream</div>
1534
+ <div>Status</div>
1535
+ <div>Source</div>
1536
+ </div>
1537
+ {tasks.map((task) => {
1538
+ const ws = wsLookup[task.path];
1539
+ return (
1540
+ <div className="tr" key={task.path}>
1541
+ <div className="td-primary">
1542
+ <LinkButton onClick={() => onOpenDoc(task.path)} title={task.title}>{task.title}</LinkButton>
1543
+ </div>
1544
+ <div>
1545
+ {ws ? (
1546
+ <LinkButton onClick={() => onOpenWorkstream(ws.path)} title={ws.title}>{ws.title}</LinkButton>
1547
+ ) : (
1548
+ <span className="td-muted">Unassigned</span>
1549
+ )}
1550
+ </div>
1551
+ <div><StatusChip>{task.status || "Planned"}</StatusChip></div>
1552
+ <div className="mono td-muted">{task.path.split("/").pop()}</div>
1553
+ </div>
1554
+ );
1555
+ })}
1556
+ </div>
1557
+ ) : (
1558
+ <div className="empty-state">No tasks in this project.</div>
1559
+ )}
1560
+ </section>
1561
+ </div>
1562
+ );
1563
+ }
1564
+
1565
+ /* ================================================================
1566
+ Dashboard Pages
1567
+ ================================================================ */
1568
+ function DashboardPage({ project, docs, view, onOpenWorkstream, onOpenDoc }) {
1569
+ const dashboard = useMemo(() => getDashboardModel(project, docs), [project, docs]);
1570
+ const { tasks, currentWork, blockers, progressDocs, warnings, wsLookup } = dashboard;
1571
+ const title = NAV.find((item) => item.id === view)?.label || "Dashboard";
1572
+
1573
+ const renderCurrentWork = () =>
1574
+ currentWork.length > 0 ? (
1575
+ <div className="table table-4">
1576
+ <div className="tr th">
1577
+ <div>Task</div>
1578
+ <div>Workstream</div>
1579
+ <div>Status</div>
1580
+ <div>Source</div>
1581
+ </div>
1582
+ {currentWork.map((task, i) => {
1583
+ const ws = wsLookup[task.path];
1584
+ return (
1585
+ <div className="tr" key={i}>
1586
+ <div className="td-primary">
1587
+ <LinkButton onClick={() => onOpenDoc(task.path)} title={task.title}>
1588
+ {task.title}
1589
+ </LinkButton>
1590
+ </div>
1591
+ <div>
1592
+ {ws ? (
1593
+ <LinkButton onClick={() => onOpenWorkstream(ws.path)} title={ws.title}>
1594
+ {ws.title}
1595
+ </LinkButton>
1596
+ ) : (
1597
+ <span className="td-muted">-</span>
1598
+ )}
1599
+ </div>
1600
+ <div>
1601
+ <StatusChip>{task.status}</StatusChip>
1602
+ </div>
1603
+ <div className="mono td-muted">{task.path.split("/").pop()}</div>
1604
+ </div>
1605
+ );
1606
+ })}
1607
+ </div>
1608
+ ) : (
1609
+ <div className="empty-state">No active tasks.</div>
1610
+ );
1611
+
1612
+ const renderBlockers = () =>
1613
+ blockers.length > 0 ? (
1614
+ <div className="table table-3">
1615
+ <div className="tr th">
1616
+ <div>Task</div>
1617
+ <div>Workstream</div>
1618
+ <div>Details</div>
1619
+ </div>
1620
+ {blockers.map((task, i) => {
1621
+ const ws = wsLookup[task.path];
1622
+ return (
1623
+ <div className="tr" key={i}>
1624
+ <div className="td-primary">
1625
+ <LinkButton onClick={() => onOpenDoc(task.path)} title={task.title}>
1626
+ <span className="dot dot-warn" /> {task.title}
1627
+ </LinkButton>
1628
+ </div>
1629
+ <div>
1630
+ {ws ? (
1631
+ <LinkButton onClick={() => onOpenWorkstream(ws.path)} title={ws.title}>
1632
+ {ws.title}
1633
+ </LinkButton>
1634
+ ) : (
1635
+ <span className="td-muted">-</span>
1636
+ )}
1637
+ </div>
1638
+ <div className="td-muted">{task.snippet || "-"}</div>
1639
+ </div>
1640
+ );
1641
+ })}
1642
+ </div>
1643
+ ) : (
1644
+ <div className="empty-state">No blockers.</div>
1645
+ );
1646
+
1647
+ const renderValidation = () =>
1648
+ tasks.length > 0 ? (
1649
+ <div className="table table-3">
1650
+ <div className="tr th">
1651
+ <div>Task</div>
1652
+ <div>Workstream</div>
1653
+ <div>State</div>
1654
+ </div>
1655
+ {tasks.map((task, i) => {
1656
+ const ws = wsLookup[task.path];
1657
+ const label = statusLabel(task.status);
1658
+ const chipClass = label === "Complete" ? "chip-ok" : label === "Blocked" ? "chip-warn" : "chip-low";
1659
+ return (
1660
+ <div className="tr" key={i}>
1661
+ <div className="td-primary">
1662
+ <LinkButton onClick={() => onOpenDoc(task.path)} title={task.title}>
1663
+ {task.title}
1664
+ </LinkButton>
1665
+ </div>
1666
+ <div>
1667
+ {ws ? (
1668
+ <LinkButton onClick={() => onOpenWorkstream(ws.path)} title={ws.title}>
1669
+ {ws.title}
1670
+ </LinkButton>
1671
+ ) : (
1672
+ <span className="td-muted">-</span>
1673
+ )}
1674
+ </div>
1675
+ <div>
1676
+ <span className={`chip ${chipClass}`}>
1677
+ <span className="chip-dot" /> {label}
1678
+ </span>
1679
+ </div>
1680
+ </div>
1681
+ );
1682
+ })}
1683
+ </div>
1684
+ ) : (
1685
+ <div className="empty-state">No tasks to validate.</div>
1686
+ );
1687
+
1688
+ const renderProgress = () =>
1689
+ progressDocs.length > 0 ? (
1690
+ <div className="timeline">
1691
+ {progressDocs.map((doc, i) => {
1692
+ const date = doc.updated
1693
+ ? new Date(doc.updated).toLocaleDateString("en-US", { month: "short", day: "numeric" })
1694
+ : "";
1695
+ return (
1696
+ <div className="tl-row" key={i}>
1697
+ <div className="tl-date mono">{date}</div>
1698
+ <div className="tl-bullet">
1699
+ <span />
1700
+ </div>
1701
+ <div className="tl-body">
1702
+ <div>
1703
+ <LinkButton onClick={() => onOpenDoc(doc.path)} title={doc.title}>
1704
+ {doc.title}
1705
+ </LinkButton>
1706
+ </div>
1707
+ <div className="td-muted small">{doc.snippet}</div>
1708
+ </div>
1709
+ </div>
1710
+ );
1711
+ })}
1712
+ </div>
1713
+ ) : (
1714
+ <div className="empty-state">No progress entries.</div>
1715
+ );
1716
+
1717
+ const renderWarnings = () =>
1718
+ warnings.length > 0 ? (
1719
+ <div className="table table-3">
1720
+ <div className="tr th">
1721
+ <div>Severity</div>
1722
+ <div>Note</div>
1723
+ <div>Source</div>
1724
+ </div>
1725
+ {warnings.map((w, i) => (
1726
+ <div className="tr" key={i}>
1727
+ <div>
1728
+ <span className={`chip ${w.sev === "Medium" ? "chip-warn" : "chip-low"}`}>
1729
+ <span className="chip-dot" /> {w.sev}
1730
+ </span>
1731
+ </div>
1732
+ <div className="td-primary">{w.note}</div>
1733
+ <div className="td-muted">{w.ws}</div>
1734
+ </div>
1735
+ ))}
1736
+ </div>
1737
+ ) : (
1738
+ <div className="empty-state" style={{ color: "var(--ok)" }}>
1739
+ No warnings.
1740
+ </div>
1741
+ );
1742
+
1743
+ const renderBody = () => {
1744
+ if (view === "current") return renderCurrentWork();
1745
+ if (view === "blockers") return renderBlockers();
1746
+ if (view === "validation") return renderValidation();
1747
+ if (view === "progress") return renderProgress();
1748
+ if (view === "warnings") return renderWarnings();
1749
+ return null;
1750
+ };
1751
+
1752
+ const count =
1753
+ view === "current" ? currentWork.length :
1754
+ view === "blockers" ? blockers.length :
1755
+ view === "validation" ? tasks.length :
1756
+ view === "progress" ? progressDocs.length :
1757
+ view === "warnings" ? warnings.length :
1758
+ null;
1759
+
1760
+ return (
1761
+ <div className="page">
1762
+ <h1 className="page-title">{title}</h1>
1763
+ <section className="block">
1764
+ <SectionHeader title={title} count={count} />
1765
+ {renderBody()}
1766
+ </section>
1767
+ </div>
1768
+ );
1769
+ }
1770
+
1771
+ /* ================================================================
1772
+ Workstream Detail
1773
+ ================================================================ */
1774
+ function WorkstreamDetail({ index, project, wsPath, onBack, onOpenDoc }) {
1775
+ const [wsDoc, setWsDoc] = useState(null);
1776
+
1777
+ useEffect(() => {
1778
+ if (!wsPath) return;
1779
+ fetch(`/api/doc?path=${encodeURIComponent(wsPath)}`)
1780
+ .then((r) => r.json())
1781
+ .then(setWsDoc);
1782
+ }, [wsPath]);
1783
+
1784
+ const outline = project.outline;
1785
+ const wsOutline = outline?.workstreams?.find((w) => w.path === wsPath);
1786
+ const tasks = useMemo(
1787
+ () => (wsOutline?.tasks || []).map((p) => byPath(index.docs, p)).filter(Boolean),
1788
+ [wsOutline, index.docs]
1789
+ );
1790
+
1791
+ if (!wsDoc) {
1792
+ return (
1793
+ <div className="page">
1794
+ <div className="empty-state">Loading workstream...</div>
1795
+ </div>
1796
+ );
1797
+ }
1798
+
1799
+ const title = wsDoc.title || wsOutline?.title || "Workstream";
1800
+ const status = wsDoc.status || wsOutline?.status;
1801
+ const owner = wsDoc.frontmatter?.owner || "";
1802
+ const created = wsDoc.frontmatter?.created || "";
1803
+ const updated = wsDoc.updated || "";
1804
+
1805
+ // Parse markdown into named sections
1806
+ const body = (wsDoc.markdown || "").replace(/^---[\s\S]*?\n---\r?\n/, "");
1807
+ const sections = {};
1808
+ let currentSection = "__body";
1809
+ sections[currentSection] = [];
1810
+ for (const line of body.split(/\r?\n/)) {
1811
+ const heading = line.match(/^#{1,3}\s+(.+?)\s*$/);
1812
+ if (heading) {
1813
+ currentSection = heading[1].toLowerCase().replace(/[^a-z0-9]+/g, "-");
1814
+ sections[currentSection] = [];
1815
+ } else {
1816
+ if (!sections[currentSection]) sections[currentSection] = [];
1817
+ sections[currentSection].push(line);
1818
+ }
1819
+ }
1820
+ const sectionHtml = (key) => {
1821
+ const lines = sections[key];
1822
+ if (!lines || !lines.filter((l) => l.trim()).length) return null;
1823
+ return parseBlocks(lines);
1824
+ };
1825
+
1826
+ const summaryHtml = sectionHtml("summary") || sectionHtml("__body");
1827
+ const goalHtml = sectionHtml("goal") || sectionHtml("objective");
1828
+ const scopeHtml = sectionHtml("scope");
1829
+ const notesHtml = sectionHtml("notes");
1830
+ const decisionsHtml = sectionHtml("decisions");
1831
+
1832
+ const fmtDate = (d) =>
1833
+ d ? new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) : "—";
1834
+
1835
+ return (
1836
+ <div className="page">
1837
+ <button className="back" onClick={onBack}>
1838
+ <Icon d={I.arrowL} size={14} /> Back to Overview
1839
+ </button>
1840
+ <div className="ws-eyebrow">Workstream</div>
1841
+ <h1 className="page-title">{title}</h1>
1842
+
1843
+ <section className="summary summary-tight">
1844
+ <Field label="Status">
1845
+ <StatusChip>{status || "Planned"}</StatusChip>
1846
+ </Field>
1847
+ <Field label="Owner">{owner || "—"}</Field>
1848
+ <Field label="Created">{fmtDate(created)}</Field>
1849
+ <Field label="Last updated">{fmtDate(updated)}</Field>
1850
+ </section>
1851
+
1852
+ <div className="two-col">
1853
+ <div className="col-main">
1854
+ {summaryHtml && (
1855
+ <Block title="Summary">
1856
+ <div dangerouslySetInnerHTML={{ __html: summaryHtml }} />
1857
+ </Block>
1858
+ )}
1859
+ {goalHtml && (
1860
+ <Block title="Goal">
1861
+ <div dangerouslySetInnerHTML={{ __html: goalHtml }} />
1862
+ </Block>
1863
+ )}
1864
+ {scopeHtml && (
1865
+ <Block title="Scope">
1866
+ <div dangerouslySetInnerHTML={{ __html: scopeHtml }} />
1867
+ </Block>
1868
+ )}
1869
+
1870
+ <Block title="Tasks">
1871
+ <ul className="checklist">
1872
+ {tasks.map((t, i) => {
1873
+ const done = statusLabel(t.status) === "Complete";
1874
+ const taskId = t.taskId || t.path.split("/").pop()?.replace(/\.md$/, "");
1875
+ return (
1876
+ <li key={i} className={done ? "done" : ""}>
1877
+ <span className="cb">
1878
+ {done && <Icon d={<path d="M5 12.5l4 4 10-11" />} size={11} />}
1879
+ </span>
1880
+ <LinkButton onClick={() => onOpenDoc(t.path, wsPath)} title={t.title}>
1881
+ {t.title}
1882
+ </LinkButton>
1883
+ <span className="checklist-meta">
1884
+ <StatusChip>{t.status || "Planned"}</StatusChip>
1885
+ <span className="task-id-copy mono">
1886
+ <span>{taskId}</span>
1887
+ <CopyButton value={taskId} label="task ID" />
1888
+ </span>
1889
+ </span>
1890
+ </li>
1891
+ );
1892
+ })}
1893
+ {!tasks.length && (
1894
+ <li style={{ color: "var(--ink-50)", listStyle: "none" }}>
1895
+ No tasks linked to this workstream.
1896
+ </li>
1897
+ )}
1898
+ </ul>
1899
+ </Block>
1900
+
1901
+ {notesHtml && (
1902
+ <Block title="Notes">
1903
+ <div dangerouslySetInnerHTML={{ __html: notesHtml }} />
1904
+ </Block>
1905
+ )}
1906
+ {decisionsHtml && (
1907
+ <Block title="Decisions">
1908
+ <div dangerouslySetInnerHTML={{ __html: decisionsHtml }} />
1909
+ </Block>
1910
+ )}
1911
+ </div>
1912
+
1913
+ <aside className="col-side">
1914
+ <div className="side-block">
1915
+ <div className="side-head">Details</div>
1916
+ <dl className="dl">
1917
+ <dt>Status</dt>
1918
+ <dd>
1919
+ <StatusChip>{status || "Planned"}</StatusChip>
1920
+ </dd>
1921
+ {owner && (
1922
+ <>
1923
+ <dt>Owner</dt>
1924
+ <dd>{owner}</dd>
1925
+ </>
1926
+ )}
1927
+ <dt>
1928
+ <span>Source path</span>
1929
+ <CopyButton value={wsPath} label="source path" />
1930
+ </dt>
1931
+ <dd className="mono small">
1932
+ <span className="copy-value">{wsPath}</span>
1933
+ </dd>
1934
+ {wsOutline?.id && (
1935
+ <>
1936
+ <dt>
1937
+ <span>ID</span>
1938
+ <CopyButton value={wsOutline.id} label="workstream ID" />
1939
+ </dt>
1940
+ <dd className="mono">
1941
+ <span>{wsOutline.id}</span>
1942
+ </dd>
1943
+ </>
1944
+ )}
1945
+ </dl>
1946
+ </div>
1947
+
1948
+ <div className="side-block">
1949
+ <div className="side-head">Task summary</div>
1950
+ <dl className="dl">
1951
+ <dt>Total</dt>
1952
+ <dd className="mono">{tasks.length}</dd>
1953
+ <dt>Open</dt>
1954
+ <dd className="mono">{tasks.filter((task) => statusLabel(task.status) !== "Complete").length}</dd>
1955
+ <dt>Complete</dt>
1956
+ <dd className="mono">{tasks.filter((task) => statusLabel(task.status) === "Complete").length}</dd>
1957
+ </dl>
1958
+ </div>
1959
+ </aside>
1960
+ </div>
1961
+ </div>
1962
+ );
1963
+ }
1964
+
1965
+ /* ================================================================
1966
+ Document Reader
1967
+ ================================================================ */
1968
+ function DocumentReader({ doc, project, index, onBack, onOpenAction, onOpenDoc, onOpenWorkstream, onOpenTasks }) {
1969
+ if (!doc) {
1970
+ return (
1971
+ <div className="page">
1972
+ <div className="empty-state">Loading document...</div>
1973
+ </div>
1974
+ );
1975
+ }
1976
+
1977
+ const props = Object.entries(doc.frontmatter || {});
1978
+ const taskNav = getTaskNavigation(index, project, doc);
1979
+ const taskReference = (value) => {
1980
+ if (!taskNav) return null;
1981
+ return taskNav.tasksById[String(value || "").trim().toUpperCase()] || null;
1982
+ };
1983
+ const renderMetaValue = (key, value) => {
1984
+ const values = listValue(value);
1985
+
1986
+ if (doc.role === "task" && key === "workstream" && taskNav?.parent) {
1987
+ return (
1988
+ <LinkButton onClick={() => onOpenWorkstream(taskNav.parent.path)} title={taskNav.parent.title}>
1989
+ {values.join(", ") || taskNav.parent.id || taskNav.parent.title}
1990
+ </LinkButton>
1991
+ );
1992
+ }
1993
+
1994
+ if (doc.role === "task" && ["depends_on", "conflicts_with"].includes(key) && values.length) {
1995
+ return values.map((item, index) => {
1996
+ const relatedTask = taskReference(item);
1997
+ return (
1998
+ <React.Fragment key={`${key}:${item}`}>
1999
+ {index > 0 && <span className="meta-separator">,</span>}
2000
+ {relatedTask ? (
2001
+ <LinkButton onClick={() => onOpenDoc(relatedTask.path, taskNav?.parent?.path || null)} title={relatedTask.title}>
2002
+ {item}
2003
+ </LinkButton>
2004
+ ) : (
2005
+ <span>{item}</span>
2006
+ )}
2007
+ </React.Fragment>
2008
+ );
2009
+ });
2010
+ }
2011
+
2012
+ if (Array.isArray(value)) return value.join(", ");
2013
+ return String(value ?? "");
2014
+ };
2015
+ const fmtDate = (d) =>
2016
+ d
2017
+ ? new Date(d).toLocaleString("en-US", {
2018
+ month: "short", day: "numeric", year: "numeric",
2019
+ hour: "2-digit", minute: "2-digit", hour12: false,
2020
+ })
2021
+ : "—";
2022
+
2023
+ return (
2024
+ <div className="page doc-reader-page">
2025
+ <div className="doc-reader-main">
2026
+ {onBack && (
2027
+ <button className="back" onClick={onBack}>
2028
+ <Icon d={I.arrowL} size={14} /> Back
2029
+ </button>
2030
+ )}
2031
+ <div className="ws-eyebrow">{titleCase(doc.role)}</div>
2032
+ <h1 className="page-title">{doc.title}</h1>
2033
+
2034
+ <article
2035
+ className="md-body"
2036
+ dangerouslySetInnerHTML={{ __html: renderMarkdown(doc.markdown || "") }}
2037
+ />
2038
+ </div>
2039
+
2040
+ <aside className="doc-side" aria-label="Document context">
2041
+ {doc.role === "task" && taskNav && (
2042
+ <div className="side-block task-nav-block">
2043
+ <div className="side-head">Task navigation</div>
2044
+ <div className="task-parent">
2045
+ <div className="side-label">Parent workstream</div>
2046
+ {taskNav.parent ? (
2047
+ <button className="task-parent-link" type="button" onClick={() => onOpenWorkstream(taskNav.parent.path)}>
2048
+ <span>{taskNav.parent.title}</span>
2049
+ <Icon d={I.chevR} size={13} />
2050
+ </button>
2051
+ ) : (
2052
+ <div className="empty-state">No parent workstream found.</div>
2053
+ )}
2054
+ </div>
2055
+ <div className="task-nav-actions">
2056
+ <button className="link-muted" type="button" onClick={onOpenTasks}>All project tasks</button>
2057
+ </div>
2058
+ <div className="side-label side-label-list">
2059
+ Sibling tasks <span className="count">{taskNav.siblings.length}</span>
2060
+ </div>
2061
+ <ul className="side-list task-sibling-list">
2062
+ {taskNav.siblings.map((task) => {
2063
+ const isCurrent = task.path === doc.path;
2064
+ return (
2065
+ <li key={task.path} className={isCurrent ? "is-current" : ""}>
2066
+ <button
2067
+ className="side-list-button"
2068
+ type="button"
2069
+ onClick={() => onOpenDoc(task.path, taskNav.parent?.path || null)}
2070
+ disabled={isCurrent}
2071
+ >
2072
+ <span className="sl-name">{task.title}</span>
2073
+ <span className="sl-right">
2074
+ <StatusChip>{task.status || "Planned"}</StatusChip>
2075
+ {!isCurrent && <Icon d={I.chevR} size={13} />}
2076
+ </span>
2077
+ </button>
2078
+ </li>
2079
+ );
2080
+ })}
2081
+ {!taskNav.siblings.length && (
2082
+ <li className="side-list-empty">No siblings</li>
2083
+ )}
2084
+ </ul>
2085
+ </div>
2086
+ )}
2087
+
2088
+ <div className="doc-meta-panel" aria-label="Document metadata">
2089
+ <div className="doc-meta-title">Metadata</div>
2090
+ <dl className="dl doc-meta-list">
2091
+ <dt>
2092
+ <span>Path</span>
2093
+ <CopyButton value={doc.path} label="source path" />
2094
+ </dt>
2095
+ <dd className="mono">
2096
+ <span className="copy-value">{doc.path}</span>
2097
+ </dd>
2098
+ {doc.status && (
2099
+ <>
2100
+ <dt>Status</dt>
2101
+ <dd><StatusChip>{doc.status}</StatusChip></dd>
2102
+ </>
2103
+ )}
2104
+ <dt>Updated</dt>
2105
+ <dd>{fmtDate(doc.updated)}</dd>
2106
+ {props.map(([k, v]) => {
2107
+ const copyable = isCopyableMetaKey(k);
2108
+ return (
2109
+ <React.Fragment key={k}>
2110
+ <dt>
2111
+ <span>{k}</span>
2112
+ {copyable && (
2113
+ <CopyButton value={v} label={copyLabelFromMetaKey(k, doc.role)} />
2114
+ )}
2115
+ </dt>
2116
+ <dd>
2117
+ {renderMetaValue(k, v)}
2118
+ </dd>
2119
+ </React.Fragment>
2120
+ );
2121
+ })}
2122
+ </dl>
2123
+ </div>
2124
+ </aside>
2125
+ </div>
2126
+ );
2127
+ }
2128
+
2129
+ /* ================================================================
2130
+ Document List (for non-project folders like context, templates)
2131
+ ================================================================ */
2132
+ function DocumentList({ index, project, docs, onOpenDoc }) {
2133
+ const [query, setQuery] = useState("");
2134
+ const filtered = useMemo(() => {
2135
+ if (!query) return docs;
2136
+ const q = query.toLowerCase();
2137
+ return docs.filter((d) => {
2138
+ const haystack = [d.title, d.path, d.snippet, d.role].join(" ").toLowerCase();
2139
+ return haystack.includes(q);
2140
+ });
2141
+ }, [docs, query]);
2142
+
2143
+ return (
2144
+ <div className="page">
2145
+ <h1 className="page-title">{project.title}</h1>
2146
+
2147
+ <div style={{ maxWidth: "400px" }}>
2148
+ <input
2149
+ className="search-input"
2150
+ placeholder="Search documents..."
2151
+ value={query}
2152
+ onChange={(e) => setQuery(e.target.value)}
2153
+ />
2154
+ </div>
2155
+
2156
+ <div className="table table-list">
2157
+ <div className="tr th">
2158
+ <div>Document</div>
2159
+ <div>Role</div>
2160
+ <div>Status</div>
2161
+ <div>Updated</div>
2162
+ </div>
2163
+ {filtered.map((doc, i) => (
2164
+ <div className="tr" key={i} style={{ cursor: "pointer" }} onClick={() => onOpenDoc(doc.path)}>
2165
+ <div>
2166
+ <div className="td-primary">{doc.title}</div>
2167
+ <div className="mono td-muted" style={{ fontSize: "11px", marginTop: "2px" }}>
2168
+ {doc.path}
2169
+ </div>
2170
+ </div>
2171
+ <div className="td-muted">{titleCase(doc.role)}</div>
2172
+ <div>
2173
+ {doc.status ? <StatusChip>{doc.status}</StatusChip> : <span className="td-muted">—</span>}
2174
+ </div>
2175
+ <div className="td-muted">
2176
+ {doc.updated
2177
+ ? new Date(doc.updated).toLocaleDateString("en-US", {
2178
+ month: "short",
2179
+ day: "numeric",
2180
+ year: "numeric",
2181
+ })
2182
+ : "—"}
2183
+ </div>
2184
+ </div>
2185
+ ))}
2186
+ {!filtered.length && (
2187
+ <div style={{ padding: "32px 0", color: "var(--ink-50)", textAlign: "center" }}>
2188
+ {query ? "No documents match your search." : "No documents in this folder."}
2189
+ </div>
2190
+ )}
2191
+ </div>
2192
+
2193
+ <div className="page-foot mono">
2194
+ viewer · read-only · {docs.length} document{docs.length !== 1 ? "s" : ""} · generated at{" "}
2195
+ <span>{index.generatedAt || ""}</span>
2196
+ </div>
2197
+ </div>
2198
+ );
2199
+ }
2200
+
2201
+ /* ================================================================
2202
+ App — root component
2203
+ ================================================================ */
2204
+ function App() {
2205
+ const [index, setIndex] = useState(null);
2206
+ const [projectSlug, setProjectSlug] = useState(null);
2207
+ const [route, setRoute] = useState(DEFAULT_WORKSPACE_ROUTE);
2208
+ const [section, setSection] = useState(null);
2209
+ const [docPath, setDocPath] = useState(null);
2210
+ const [doc, setDoc] = useState(null);
2211
+ const [wsPath, setWsPath] = useState(null);
2212
+ const [workspacePages, setWorkspacePages] = useState({});
2213
+
2214
+ // Load index on mount
2215
+ useEffect(() => {
2216
+ fetch("/api/index")
2217
+ .then((r) => r.json())
2218
+ .then((data) => {
2219
+ const nav = restoreNavigation(data);
2220
+ setIndex(data);
2221
+ setProjectSlug(nav.projectSlug);
2222
+ setRoute(nav.route);
2223
+ setSection(nav.section);
2224
+ setDocPath(nav.docPath);
2225
+ setWsPath(nav.wsPath);
2226
+ setWorkspacePages(nav.workspacePages);
2227
+ });
2228
+ }, []);
2229
+
2230
+ useEffect(() => {
2231
+ if (!index || !projectSlug) return;
2232
+ try {
2233
+ window.localStorage.setItem(NAV_STATE_KEY, JSON.stringify({
2234
+ version: NAV_STATE_VERSION,
2235
+ projectSlug,
2236
+ route,
2237
+ section,
2238
+ docPath,
2239
+ wsPath,
2240
+ workspacePages,
2241
+ }));
2242
+ } catch (_) {
2243
+ /* ignore unavailable storage */
2244
+ }
2245
+ }, [index, projectSlug, route, section, docPath, wsPath, workspacePages]);
2246
+
2247
+ // Load doc when docPath changes
2248
+ useEffect(() => {
2249
+ if (!docPath) {
2250
+ setDoc(null);
2251
+ return;
2252
+ }
2253
+ setDoc(null);
2254
+ fetch(`/api/doc?path=${encodeURIComponent(docPath)}`)
2255
+ .then((r) => r.json())
2256
+ .then(setDoc);
2257
+ }, [docPath]);
2258
+
2259
+ // Scroll to top on route change
2260
+ useEffect(() => {
2261
+ const main = document.querySelector(".main");
2262
+ if (main) main.scrollTo(0, 0);
2263
+ }, [route, wsPath, docPath]);
2264
+
2265
+ const handleWorkspacePageChange = useCallback((view, nextPage) => {
2266
+ const page = Math.max(1, Math.floor(Number(nextPage) || 1));
2267
+ setWorkspacePages((pages) => ({ ...pages, [view]: page }));
2268
+ }, []);
2269
+
2270
+ const updateCurrentWorkspacePage = useCallback(
2271
+ (nextPage) => handleWorkspacePageChange(route, nextPage),
2272
+ [handleWorkspacePageChange, route]
2273
+ );
2274
+
2275
+ if (!index) {
2276
+ return (
2277
+ <div style={{ padding: "48px", color: "var(--ink-50)", fontFamily: "var(--font-sans)" }}>
2278
+ Loading Delano viewer...
2279
+ </div>
2280
+ );
2281
+ }
2282
+
2283
+ const { project, docs } = getProjectData(index, projectSlug);
2284
+ const hasOutline = project?.outline;
2285
+
2286
+ const handleSelectProject = (slug) => {
2287
+ setProjectSlug(slug);
2288
+ const p = index.projects.find((pp) => pp.slug === slug);
2289
+ const nextRoute = fallbackRouteForProject(p);
2290
+ setRoute(nextRoute);
2291
+ setSection(nextRoute === "overview" ? "overview" : null);
2292
+ setDocPath(null);
2293
+ setDoc(null);
2294
+ setWsPath(null);
2295
+ };
2296
+
2297
+ const handleNavigate = (newRoute, newSection) => {
2298
+ if (newRoute === "document" && newSection) {
2299
+ setRoute("document");
2300
+ setDocPath(newSection);
2301
+ setSection(newSection);
2302
+ } else {
2303
+ setRoute(newRoute);
2304
+ setSection(newSection || null);
2305
+ setDocPath(null);
2306
+ setDoc(null);
2307
+ }
2308
+ setWsPath(null);
2309
+ };
2310
+
2311
+ const handleOpenWorkstream = (path) => {
2312
+ setRoute("workstream");
2313
+ setWsPath(path);
2314
+ setSection(null);
2315
+ setDocPath(null);
2316
+ setDoc(null);
2317
+ };
2318
+
2319
+ const handleOpenDoc = (path, contextWsPath) => {
2320
+ setRoute("document");
2321
+ setDocPath(path);
2322
+ setSection(path);
2323
+ if (contextWsPath !== undefined) setWsPath(contextWsPath);
2324
+ };
2325
+
2326
+ const handleOpenProject = (slug) => {
2327
+ const p = index.projects.find((pp) => pp.slug === slug);
2328
+ setProjectSlug(slug);
2329
+ const nextRoute = fallbackRouteForProject(p);
2330
+ setRoute(nextRoute);
2331
+ setSection(nextRoute === "overview" ? "overview" : null);
2332
+ setWsPath(null);
2333
+ setDocPath(null);
2334
+ setDoc(null);
2335
+ };
2336
+
2337
+ const handleOpenProjectDoc = (slug, path) => {
2338
+ setProjectSlug(slug);
2339
+ setRoute("document");
2340
+ setDocPath(path);
2341
+ setSection(path);
2342
+ setWsPath(null);
2343
+ };
2344
+
2345
+ const handleOpenProjectWorkstream = (slug, path) => {
2346
+ setProjectSlug(slug);
2347
+ setRoute("workstream");
2348
+ setWsPath(path);
2349
+ setSection(null);
2350
+ setDocPath(null);
2351
+ setDoc(null);
2352
+ };
2353
+
2354
+ const handleOpenAction = async (target, path) => {
2355
+ try {
2356
+ await fetch(`/api/open?target=${encodeURIComponent(target)}&path=${encodeURIComponent(path)}`, {
2357
+ method: "POST",
2358
+ });
2359
+ } catch (_) {
2360
+ /* ignore */
2361
+ }
2362
+ };
2363
+
2364
+ const handleBack = () => {
2365
+ if (route === "document" && wsPath) {
2366
+ setRoute("workstream");
2367
+ setDocPath(null);
2368
+ setDoc(null);
2369
+ setSection(null);
2370
+ } else {
2371
+ setRoute(hasOutline ? "overview" : "list");
2372
+ setSection(hasOutline ? "overview" : null);
2373
+ setWsPath(null);
2374
+ setDocPath(null);
2375
+ setDoc(null);
2376
+ }
2377
+ };
2378
+
2379
+ let mainContent;
2380
+ if (route === "workstream" && wsPath && hasOutline) {
2381
+ mainContent = (
2382
+ <WorkstreamDetail
2383
+ index={index}
2384
+ project={project}
2385
+ wsPath={wsPath}
2386
+ onBack={handleBack}
2387
+ onOpenDoc={handleOpenDoc}
2388
+ />
2389
+ );
2390
+ } else if (route === "document" && docPath) {
2391
+ mainContent = (
2392
+ <DocumentReader
2393
+ doc={doc}
2394
+ project={project}
2395
+ index={index}
2396
+ onBack={handleBack}
2397
+ onOpenAction={handleOpenAction}
2398
+ onOpenDoc={handleOpenDoc}
2399
+ onOpenWorkstream={handleOpenWorkstream}
2400
+ onOpenTasks={() => handleNavigate("tasks")}
2401
+ />
2402
+ );
2403
+ } else if (GLOBAL_ROUTES.has(route)) {
2404
+ mainContent = (
2405
+ <WorkspacePage
2406
+ index={index}
2407
+ view={route}
2408
+ page={workspacePages[route] || 1}
2409
+ onPageChange={updateCurrentWorkspacePage}
2410
+ onOpenProject={handleOpenProject}
2411
+ onOpenProjectDoc={handleOpenProjectDoc}
2412
+ onOpenProjectWorkstream={handleOpenProjectWorkstream}
2413
+ />
2414
+ );
2415
+ } else if (route === "workstreams" && hasOutline) {
2416
+ mainContent = (
2417
+ <ProjectWorkstreamsPage
2418
+ index={index}
2419
+ project={project}
2420
+ docs={docs}
2421
+ onOpenWorkstream={handleOpenWorkstream}
2422
+ onOpenDoc={handleOpenDoc}
2423
+ />
2424
+ );
2425
+ } else if (route === "tasks" && hasOutline) {
2426
+ mainContent = (
2427
+ <ProjectTasksPage
2428
+ project={project}
2429
+ docs={docs}
2430
+ onOpenDoc={handleOpenDoc}
2431
+ onOpenWorkstream={handleOpenWorkstream}
2432
+ />
2433
+ );
2434
+ } else if (route === "overview" && hasOutline) {
2435
+ mainContent = (
2436
+ <Overview
2437
+ index={index}
2438
+ project={project}
2439
+ docs={docs}
2440
+ scrollTarget={section}
2441
+ onOpenWorkstream={handleOpenWorkstream}
2442
+ onOpenDoc={handleOpenDoc}
2443
+ onOpenTasks={() => handleNavigate("tasks")}
2444
+ />
2445
+ );
2446
+ } else {
2447
+ mainContent = (
2448
+ <DocumentList index={index} project={project} docs={docs} onOpenDoc={handleOpenDoc} />
2449
+ );
2450
+ }
2451
+
2452
+ return (
2453
+ <div className="app">
2454
+ <Sidebar
2455
+ index={index}
2456
+ projectSlug={projectSlug}
2457
+ route={route}
2458
+ section={section}
2459
+ onNavigate={handleNavigate}
2460
+ onSelectProject={handleSelectProject}
2461
+ />
2462
+ <div className="main">
2463
+ <Topbar
2464
+ project={project}
2465
+ index={index}
2466
+ docPath={docPath || (hasOutline ? project.outline.spec : null)}
2467
+ onOpenAction={handleOpenAction}
2468
+ />
2469
+
2470
+ <div className="content content-reader-head-c">{mainContent}</div>
2471
+
2472
+ </div>
2473
+ <div id="copy-live" className="sr-only" aria-live="polite" aria-atomic="true"></div>
2474
+ </div>
2475
+ );
2476
+ }
2477
+
2478
+ ReactDOM.createRoot(document.getElementById("root")).render(<App />);