@bvdm/delano 0.2.3 → 0.2.4

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