@bvdm/delano 0.1.7 → 0.1.8

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.
Files changed (113) hide show
  1. package/.delano/README.md +7 -0
  2. package/.delano/viewer/README.md +19 -0
  3. package/.delano/viewer/public/app.js +818 -0
  4. package/.delano/viewer/public/explorer.svg +3 -0
  5. package/.delano/viewer/public/index.html +21 -0
  6. package/.delano/viewer/public/markdown.svg +6 -0
  7. package/.delano/viewer/public/styles.css +1042 -0
  8. package/.delano/viewer/public/vscode.svg +24 -0
  9. package/.delano/viewer/server.js +389 -0
  10. package/HANDBOOK.md +65 -45
  11. package/README.md +10 -2
  12. package/assets/install-manifest.json +112 -35
  13. package/assets/payload/.agents/README.md +31 -6
  14. package/assets/payload/.agents/adapters/claude/README.md +22 -3
  15. package/assets/payload/.agents/adapters/codex/README.md +22 -3
  16. package/assets/payload/.agents/adapters/opencode/README.md +22 -3
  17. package/assets/payload/.agents/adapters/pi/README.md +22 -3
  18. package/assets/payload/.agents/common/log-safety.js +55 -0
  19. package/assets/payload/.agents/eval-fixtures/skill-output/invalid/missing-evidence/output.json +6 -0
  20. package/assets/payload/.agents/eval-fixtures/skill-output/valid/summary/output.json +7 -0
  21. package/assets/payload/.agents/fixtures/github/status-snapshot.json +6 -0
  22. package/assets/payload/.agents/fixtures/linear/issue-snapshot.json +6 -0
  23. package/assets/payload/.agents/hooks/bash-worktree-fix.sh +2 -1
  24. package/assets/payload/.agents/hooks/post-tool-logger.js +2 -1
  25. package/assets/payload/.agents/hooks/user-prompt-logger.js +17 -1
  26. package/assets/payload/.agents/logs/delivery-metrics.md +22 -0
  27. package/assets/payload/.agents/logs/schema.md +20 -1
  28. package/assets/payload/.agents/rules/delivery-modes.md +17 -0
  29. package/assets/payload/.agents/schemas/README.md +22 -0
  30. package/assets/payload/.agents/schemas/artifact-scope.json +237 -0
  31. package/assets/payload/.agents/schemas/artifacts/context.schema.json +11 -0
  32. package/assets/payload/.agents/schemas/artifacts/decision_log.schema.json +12 -0
  33. package/assets/payload/.agents/schemas/artifacts/evidence.schema.json +17 -0
  34. package/assets/payload/.agents/schemas/artifacts/plan.schema.json +83 -0
  35. package/assets/payload/.agents/schemas/artifacts/spec.schema.json +101 -0
  36. package/assets/payload/.agents/schemas/artifacts/task.schema.json +121 -0
  37. package/assets/payload/.agents/schemas/artifacts/update.schema.json +12 -0
  38. package/assets/payload/.agents/schemas/artifacts/workstream.schema.json +66 -0
  39. package/assets/payload/.agents/schemas/evidence-map.json +53 -0
  40. package/assets/payload/.agents/schemas/learning/closeout-learning-proposal.schema.json +20 -0
  41. package/assets/payload/.agents/schemas/learning/delivery-metric-event.schema.json +21 -0
  42. package/assets/payload/.agents/schemas/leases/lease.schema.json +39 -0
  43. package/assets/payload/.agents/schemas/metrics/delivery-event.schema.json +29 -0
  44. package/assets/payload/.agents/schemas/metrics/delivery-events.schema.json +49 -0
  45. package/assets/payload/.agents/schemas/operating-modes.json +42 -0
  46. package/assets/payload/.agents/schemas/status-transitions.json +31 -0
  47. package/assets/payload/.agents/schemas/sync/drift-report.schema.json +25 -0
  48. package/assets/payload/.agents/schemas/sync/drift-taxonomy.json +38 -0
  49. package/assets/payload/.agents/schemas/sync/sync-map.schema.json +39 -0
  50. package/assets/payload/.agents/scripts/README.md +1 -0
  51. package/assets/payload/.agents/scripts/audit-context-files.mjs +54 -0
  52. package/assets/payload/.agents/scripts/audit-context-scoring.mjs +14 -0
  53. package/assets/payload/.agents/scripts/build-drift-report.mjs +133 -0
  54. package/assets/payload/.agents/scripts/check-artifact-schemas.mjs +116 -0
  55. package/assets/payload/.agents/scripts/check-closeout-learning-proposals.mjs +23 -0
  56. package/assets/payload/.agents/scripts/check-context-audit.mjs +61 -0
  57. package/assets/payload/.agents/scripts/check-delivery-metric-events.mjs +35 -0
  58. package/assets/payload/.agents/scripts/check-delivery-metrics.mjs +52 -0
  59. package/assets/payload/.agents/scripts/check-evidence-map.mjs +143 -0
  60. package/assets/payload/.agents/scripts/check-github-status-inspection.mjs +93 -0
  61. package/assets/payload/.agents/scripts/check-github-sync.mjs +159 -0
  62. package/assets/payload/.agents/scripts/check-handoff-summaries.mjs +57 -0
  63. package/assets/payload/.agents/scripts/check-lease-conflicts.mjs +24 -0
  64. package/assets/payload/.agents/scripts/check-lease-contracts.mjs +17 -0
  65. package/assets/payload/.agents/scripts/check-linear-issue-inspection.mjs +63 -0
  66. package/assets/payload/.agents/scripts/check-local-sync-map.mjs +151 -0
  67. package/assets/payload/.agents/scripts/check-log-safety.sh +62 -0
  68. package/assets/payload/.agents/scripts/check-operating-modes.mjs +99 -0
  69. package/assets/payload/.agents/scripts/check-path-standards.sh +1 -1
  70. package/assets/payload/.agents/scripts/check-skill-output-evals.mjs +13 -0
  71. package/assets/payload/.agents/scripts/check-status-transitions.mjs +169 -0
  72. package/assets/payload/.agents/scripts/check-strict-fixtures.mjs +140 -0
  73. package/assets/payload/.agents/scripts/check-sync-schemas.mjs +52 -0
  74. package/assets/payload/.agents/scripts/check-text-safety.mjs +158 -0
  75. package/assets/payload/.agents/scripts/check-worktree-health.mjs +100 -0
  76. package/assets/payload/.agents/scripts/fix-path-standards.sh +1 -1
  77. package/assets/payload/.agents/scripts/inspect-github-sync.mjs +108 -0
  78. package/assets/payload/.agents/scripts/lease-manager.mjs +88 -0
  79. package/assets/payload/.agents/scripts/log-event.js +3 -0
  80. package/assets/payload/.agents/scripts/plan-sync-repairs.mjs +66 -0
  81. package/assets/payload/.agents/scripts/pm/validate.sh +656 -2
  82. package/assets/payload/.agents/scripts/propose-closeout-learning.mjs +20 -0
  83. package/assets/payload/.agents/scripts/read-local-sync-map.mjs +135 -0
  84. package/assets/payload/.agents/scripts/select-next-task.mjs +22 -0
  85. package/assets/payload/.agents/scripts/summarize-project-metrics.mjs +15 -0
  86. package/assets/payload/.agents/skills/closeout-skill/SKILL.md +3 -0
  87. package/assets/payload/.agents/skills/closeout-skill/references/runbook.md +5 -2
  88. package/assets/payload/.agents/skills/closeout-skill/templates/closure-checklist.md +2 -0
  89. package/assets/payload/.agents/skills/closeout-skill/templates/learning-proposal.md +21 -0
  90. package/assets/payload/.agents/skills/closeout-skill/templates/learning-proposals.md +25 -0
  91. package/assets/payload/.agents/validation-fixtures/strict/invalid/broken-dependencies/dependency.md +18 -0
  92. package/assets/payload/.agents/validation-fixtures/strict/invalid/broken-dependencies/task.md +24 -0
  93. package/assets/payload/.agents/validation-fixtures/strict/invalid/invalid-transition/task.md +20 -0
  94. package/assets/payload/.agents/validation-fixtures/strict/invalid/missing-evidence/task.md +27 -0
  95. package/assets/payload/.agents/validation-fixtures/strict/invalid/path-leak/task.md +27 -0
  96. package/assets/payload/.agents/validation-fixtures/strict/invalid/stale-context/context.md +9 -0
  97. package/assets/payload/.agents/validation-fixtures/strict/manifest.json +11 -0
  98. package/assets/payload/.agents/validation-fixtures/strict/valid/minimal-project/task.md +27 -0
  99. package/assets/payload/.delano/viewer/README.md +19 -0
  100. package/assets/payload/.delano/viewer/public/app.js +818 -0
  101. package/assets/payload/.delano/viewer/public/explorer.svg +3 -0
  102. package/assets/payload/.delano/viewer/public/index.html +21 -0
  103. package/assets/payload/.delano/viewer/public/markdown.svg +6 -0
  104. package/assets/payload/.delano/viewer/public/styles.css +1042 -0
  105. package/assets/payload/.delano/viewer/public/vscode.svg +24 -0
  106. package/assets/payload/.delano/viewer/server.js +389 -0
  107. package/assets/payload/.project/templates/plan.md +1 -1
  108. package/assets/payload/.project/templates/spec.md +1 -1
  109. package/assets/payload/.project/templates/task.md +1 -0
  110. package/assets/payload/HANDBOOK.md +65 -45
  111. package/package.json +31 -2
  112. package/src/cli/commands/viewer.js +81 -0
  113. package/src/cli/index.js +8 -0
@@ -0,0 +1,818 @@
1
+ const state = { index: null, project: 'context', doc: null, query: '', status: 'all', role: 'all', workstream: null, outlineOpen: false, sortBy: 'path', sortDir: 'asc' };
2
+
3
+ const SORT_FIELDS = [
4
+ { value: 'path', label: 'Path' },
5
+ { value: 'title', label: 'Title' },
6
+ { value: 'updated', label: 'Updated' },
7
+ { value: 'status', label: 'Status' },
8
+ { value: 'role', label: 'Role' },
9
+ { value: 'taskId', label: 'Task ID' },
10
+ ];
11
+
12
+ function sortFieldExists(field) {
13
+ return SORT_FIELDS.some((f) => f.value === field);
14
+ }
15
+
16
+ function getSortValue(doc, field) {
17
+ switch (field) {
18
+ case 'title': return (doc.title || '').toLowerCase();
19
+ case 'path': return (doc.path || '').toLowerCase();
20
+ case 'updated': return doc.updated || '';
21
+ case 'status': return (doc.status || '').toLowerCase();
22
+ case 'role': return (doc.role || '').toLowerCase();
23
+ case 'taskId': return doc.taskId || '';
24
+ default: return '';
25
+ }
26
+ }
27
+
28
+ function compareDocs(a, b, field, dir) {
29
+ const va = getSortValue(a, field);
30
+ const vb = getSortValue(b, field);
31
+ const aEmpty = va === '' || va == null;
32
+ const bEmpty = vb === '' || vb == null;
33
+ // Empties always sort to the end regardless of direction.
34
+ if (aEmpty && bEmpty) return 0;
35
+ if (aEmpty) return 1;
36
+ if (bEmpty) return -1;
37
+ const cmp = String(va).localeCompare(String(vb), undefined, { numeric: true, sensitivity: 'base' });
38
+ return dir === 'desc' ? -cmp : cmp;
39
+ }
40
+
41
+ const $ = (sel) => document.querySelector(sel);
42
+ const escapeHtml = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
43
+ const titleCase = (s) => String(s || '').replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
44
+
45
+ const COPY_ICON_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round" aria-hidden="true"><rect x="5" y="2" width="9" height="10" rx="1.6"/><path d="M11 12v1.4A1.6 1.6 0 0 1 9.4 15H2.6A1.6 1.6 0 0 1 1 13.4V6.6A1.6 1.6 0 0 1 2.6 5H4"/></svg>';
46
+
47
+ const copyRegistry = new Map();
48
+ let copyCounter = 0;
49
+
50
+ function resetCopyRegistry() {
51
+ copyRegistry.clear();
52
+ copyCounter = 0;
53
+ }
54
+
55
+ function normalizeCopyValue(value) {
56
+ if (value == null) return '';
57
+ if (Array.isArray(value)) return value.length ? value.join(', ') : '';
58
+ if (typeof value === 'boolean' || typeof value === 'number') return String(value);
59
+ return String(value);
60
+ }
61
+
62
+ function registerCopy(value) {
63
+ const text = normalizeCopyValue(value);
64
+ if (text === '') return null;
65
+ const id = `c${++copyCounter}`;
66
+ copyRegistry.set(id, text);
67
+ return id;
68
+ }
69
+
70
+ function copyButton(value, label, extraClass = '') {
71
+ const id = registerCopy(value);
72
+ if (!id) return '';
73
+ const safeLabel = escapeHtml(label || 'Copy value');
74
+ const cls = ['copy-btn', extraClass].filter(Boolean).join(' ');
75
+ return `<button type="button" class="${cls}" data-copy="${id}" aria-label="${safeLabel}" title="${safeLabel}">${COPY_ICON_SVG}</button>`;
76
+ }
77
+
78
+ async function copyText(text) {
79
+ if (navigator.clipboard && navigator.clipboard.writeText) {
80
+ try { await navigator.clipboard.writeText(text); return true; } catch (_) { /* fall through */ }
81
+ }
82
+ const ta = document.createElement('textarea');
83
+ ta.value = text;
84
+ ta.setAttribute('readonly', '');
85
+ ta.style.position = 'fixed';
86
+ ta.style.top = '-1000px';
87
+ ta.style.opacity = '0';
88
+ document.body.appendChild(ta);
89
+ ta.select();
90
+ let ok = false;
91
+ try { ok = document.execCommand('copy'); } catch (_) { ok = false; }
92
+ document.body.removeChild(ta);
93
+ return ok;
94
+ }
95
+
96
+ function announceCopy(label) {
97
+ const live = document.getElementById('copy-live');
98
+ if (!live) return;
99
+ live.textContent = '';
100
+ setTimeout(() => { live.textContent = `Copied ${label || 'value'}`; }, 10);
101
+ }
102
+
103
+ function attachCopyDelegation() {
104
+ document.addEventListener('click', async (event) => {
105
+ const btn = event.target.closest('[data-copy]');
106
+ if (!btn) return;
107
+ const id = btn.getAttribute('data-copy');
108
+ const value = copyRegistry.get(id);
109
+ if (value == null) return;
110
+ event.preventDefault();
111
+ event.stopPropagation();
112
+ const ok = await copyText(value);
113
+ if (!ok) return;
114
+
115
+ announceCopy(btn.getAttribute('aria-label') || 'value');
116
+
117
+ // Prefer swapping a dedicated label span so adjacent icons survive the swap.
118
+ const labelEl = btn.querySelector('.action-label');
119
+ const hasInlineSvg = !!btn.querySelector('svg');
120
+ const wasCopied = btn.classList.contains('copied');
121
+
122
+ // Cancel any pending restore from an earlier click on the same button.
123
+ if (btn._copyResetId) {
124
+ clearTimeout(btn._copyResetId);
125
+ btn._copyResetId = null;
126
+ }
127
+
128
+ if (!wasCopied) {
129
+ if (labelEl) {
130
+ btn._copyOriginalLabel = labelEl.textContent;
131
+ labelEl.textContent = 'Copied';
132
+ } else if (!hasInlineSvg) {
133
+ btn._copyOriginalText = btn.textContent;
134
+ btn.textContent = 'Copied';
135
+ }
136
+ }
137
+ btn.classList.add('copied');
138
+
139
+ btn._copyResetId = setTimeout(() => {
140
+ btn.classList.remove('copied');
141
+ if (labelEl && typeof btn._copyOriginalLabel === 'string') {
142
+ labelEl.textContent = btn._copyOriginalLabel;
143
+ delete btn._copyOriginalLabel;
144
+ }
145
+ if (typeof btn._copyOriginalText === 'string') {
146
+ btn.textContent = btn._copyOriginalText;
147
+ delete btn._copyOriginalText;
148
+ }
149
+ btn._copyResetId = null;
150
+ }, 1400);
151
+ }, true);
152
+ }
153
+
154
+ function setMoreOpen(open) {
155
+ const dropdown = document.querySelector('.tab-dropdown');
156
+ const moreBtn = document.querySelector('[data-tab-more]');
157
+ if (!dropdown || !moreBtn) return;
158
+ dropdown.setAttribute('data-open', open ? 'true' : 'false');
159
+ moreBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
160
+ }
161
+
162
+ function attachTabDropdownDelegation() {
163
+ document.addEventListener('click', (event) => {
164
+ const moreBtn = event.target.closest('[data-tab-more]');
165
+ if (moreBtn) {
166
+ event.preventDefault();
167
+ event.stopPropagation();
168
+ const dropdown = document.querySelector('.tab-dropdown');
169
+ const isOpen = dropdown && dropdown.getAttribute('data-open') === 'true';
170
+ setMoreOpen(!isOpen);
171
+ return;
172
+ }
173
+ const dropdown = document.querySelector('.tab-dropdown');
174
+ if (!dropdown || dropdown.getAttribute('data-open') !== 'true') return;
175
+ if (!event.target.closest('.tab-dropdown')) {
176
+ setMoreOpen(false);
177
+ }
178
+ }, true);
179
+
180
+ document.addEventListener('keydown', (event) => {
181
+ if (event.key !== 'Escape') return;
182
+ const dropdown = document.querySelector('.tab-dropdown');
183
+ if (dropdown && dropdown.getAttribute('data-open') === 'true') {
184
+ setMoreOpen(false);
185
+ const moreBtn = document.querySelector('[data-tab-more]');
186
+ if (moreBtn) moreBtn.focus();
187
+ }
188
+ });
189
+ }
190
+
191
+ function applyTabOverflow() {
192
+ const tabsContainer = document.querySelector('.tabs');
193
+ if (!tabsContainer) return;
194
+ const moreBtn = tabsContainer.querySelector('.tab-more');
195
+ if (!moreBtn) return;
196
+ const allTabs = [...tabsContainer.querySelectorAll('.tab')];
197
+
198
+ // Reset state to measure honestly.
199
+ allTabs.forEach((t) => t.classList.remove('overflow-hidden'));
200
+ moreBtn.classList.remove('hidden');
201
+ moreBtn.classList.remove('has-active');
202
+
203
+ // Bail out when the tab row is configured to wrap (mobile breakpoint).
204
+ const flexWrap = window.getComputedStyle(tabsContainer).flexWrap;
205
+ if (flexWrap === 'wrap' || flexWrap === 'wrap-reverse') {
206
+ moreBtn.classList.add('hidden');
207
+ return;
208
+ }
209
+
210
+ const containerWidth = tabsContainer.clientWidth;
211
+ const gap = 6;
212
+ const moreWidth = moreBtn.getBoundingClientRect().width;
213
+ const widths = allTabs.map((t) => t.getBoundingClientRect().width);
214
+
215
+ // Total width of all tabs (without the More button).
216
+ let total = 0;
217
+ for (let i = 0; i < allTabs.length; i++) {
218
+ total += widths[i] + (i > 0 ? gap : 0);
219
+ }
220
+ if (total <= containerWidth) {
221
+ moreBtn.classList.add('hidden');
222
+ return;
223
+ }
224
+
225
+ // Pinned tabs are always visible; subtract their width from the container budget upfront.
226
+ const pinnedWidth = allTabs.reduce((acc, t, i) => (
227
+ t.classList.contains('tab-fixed') ? acc + widths[i] + (acc > 0 ? gap : 0) : acc
228
+ ), 0);
229
+ const budget = containerWidth - moreWidth - gap - pinnedWidth - (pinnedWidth > 0 ? gap : 0);
230
+
231
+ let used = 0;
232
+ let activeHidden = false;
233
+ let truncated = false;
234
+
235
+ for (let i = 0; i < allTabs.length; i++) {
236
+ const tab = allTabs[i];
237
+ if (tab.classList.contains('tab-fixed')) continue;
238
+ if (truncated) {
239
+ tab.classList.add('overflow-hidden');
240
+ if (tab.classList.contains('active')) activeHidden = true;
241
+ continue;
242
+ }
243
+ const w = widths[i] + (used > 0 ? gap : 0);
244
+ if (used + w > budget) {
245
+ truncated = true;
246
+ tab.classList.add('overflow-hidden');
247
+ if (tab.classList.contains('active')) activeHidden = true;
248
+ continue;
249
+ }
250
+ used += w;
251
+ }
252
+
253
+ if (!truncated) {
254
+ moreBtn.classList.add('hidden');
255
+ }
256
+ moreBtn.classList.toggle('has-active', activeHidden);
257
+ }
258
+
259
+ function scheduleTabOverflow() {
260
+ requestAnimationFrame(() => requestAnimationFrame(applyTabOverflow));
261
+ }
262
+
263
+ let tabResizeObserver = null;
264
+ function watchTabsResize() {
265
+ if (tabResizeObserver || !('ResizeObserver' in window)) return;
266
+ tabResizeObserver = new ResizeObserver(() => applyTabOverflow());
267
+ tabResizeObserver.observe(document.body);
268
+ }
269
+
270
+ function statusClass(status) { return status ? `pill ${String(status).toLowerCase()}` : 'pill'; }
271
+ function byPath(path) { return state.index.docs.find((d) => d.path === path); }
272
+ function currentProject() { return state.index.projects.find((p) => p.slug === state.project) || state.index.projects[0]; }
273
+ function projectDocs() { return currentProject().docs.map(byPath).filter(Boolean); }
274
+ function isProjectGroup() { return Boolean(currentProject().outline); }
275
+ function availableStatuses() { return [...new Set(projectDocs().map((d) => d.status).filter(Boolean).map((s) => String(s).toLowerCase()))]; }
276
+ function availableRoles() { return [...new Set(projectDocs().map((d) => d.role).filter(Boolean))]; }
277
+
278
+ function currentDocs() {
279
+ const docs = projectDocs();
280
+ const filtered = docs.filter((doc) => {
281
+ const q = state.query.toLowerCase();
282
+ const haystack = [doc.title, doc.path, doc.snippet, doc.role, JSON.stringify(doc.frontmatter)].join(' ').toLowerCase();
283
+ const matchesQ = !q || haystack.includes(q);
284
+ const matchesStatus = state.status === 'all' || String(doc.status || '').toLowerCase() === state.status;
285
+ const matchesRole = state.role === 'all' || doc.role === state.role;
286
+ const matchesWorkstream = !state.workstream || doc.path === state.workstream || doc.workstreamPath === state.workstream;
287
+ return matchesQ && matchesStatus && matchesRole && matchesWorkstream;
288
+ });
289
+ const sortField = sortFieldExists(state.sortBy) ? state.sortBy : 'path';
290
+ const sortDir = state.sortDir === 'desc' ? 'desc' : 'asc';
291
+ filtered.sort((a, b) => compareDocs(a, b, sortField, sortDir));
292
+ return filtered;
293
+ }
294
+
295
+ function inlineMd(text) {
296
+ let s = escapeHtml(text);
297
+ s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
298
+ s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
299
+ s = s.replace(/(^|[^*\w])\*([^*\n]+)\*(?=[^*\w]|$)/g, '$1<em>$2</em>');
300
+ s = s.replace(/\[\[([^\]]+)\]\]/g, '<span class="wikilink" data-target="$1">$1</span>');
301
+ s = s.replace(/\[([^\]]+)\]\(((?:https?:\/\/|mailto:|\.\.?\/|\/)[^)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer noopener">$1</a>');
302
+ return s;
303
+ }
304
+
305
+ function isTableSeparator(line) {
306
+ if (!line) return false;
307
+ return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line);
308
+ }
309
+
310
+ function isBlockStart(line, nextLine) {
311
+ if (!line) return false;
312
+ if (/^\s*```/.test(line)) return true;
313
+ if (/^#{1,6}\s+/.test(line)) return true;
314
+ if (/^\s*-{3,}\s*$/.test(line)) return true;
315
+ if (/^\s*>/.test(line)) return true;
316
+ if (/^\s*(?:[-*+]|\d+\.)\s+/.test(line)) return true;
317
+ if (line.includes('|') && nextLine && isTableSeparator(nextLine)) return true;
318
+ return false;
319
+ }
320
+
321
+ function renderCodeBlock(text, lang) {
322
+ const langClass = lang ? ` class="lang-${escapeHtml(lang)}"` : '';
323
+ const langBadge = lang ? `<span class="code-lang" aria-hidden="true">${escapeHtml(lang)}</span>` : '';
324
+ const padClass = lang ? ' has-lang' : '';
325
+ return `<pre class="code${padClass}">${langBadge}${copyButton(text, lang ? `Copy ${lang} code` : 'Copy code', 'pre-copy')}<code${langClass}>${escapeHtml(text)}</code></pre>`;
326
+ }
327
+
328
+ function renderMermaidFallback(source) {
329
+ return `<figure class="mermaid-block">
330
+ <figcaption>Mermaid diagram (source view)</figcaption>
331
+ <pre class="code has-lang">${`<span class="code-lang" aria-hidden="true">mermaid</span>`}${copyButton(source, 'Copy mermaid source', 'pre-copy')}<code class="lang-mermaid">${escapeHtml(source)}</code></pre>
332
+ </figure>`;
333
+ }
334
+
335
+ function renderTable(tableLines) {
336
+ const parseRow = (line) => {
337
+ let s = line.trim();
338
+ if (s.startsWith('|')) s = s.slice(1);
339
+ if (s.endsWith('|')) s = s.slice(0, -1);
340
+ return s.split('|').map((c) => c.trim());
341
+ };
342
+ const aligns = parseRow(tableLines[1]).map((s) => {
343
+ if (/^:-+:$/.test(s)) return 'center';
344
+ if (/^-+:$/.test(s)) return 'right';
345
+ if (/^:-+$/.test(s)) return 'left';
346
+ return null;
347
+ });
348
+ const headers = parseRow(tableLines[0]);
349
+ const headerHtml = `<thead><tr>${headers.map((h, j) => {
350
+ const a = aligns[j] ? ` style="text-align:${aligns[j]}"` : '';
351
+ return `<th${a}>${inlineMd(h)}</th>`;
352
+ }).join('')}</tr></thead>`;
353
+ const rowsHtml = tableLines.slice(2).map((line) => {
354
+ const cells = parseRow(line);
355
+ return `<tr>${cells.map((c, j) => {
356
+ const a = aligns[j] ? ` style="text-align:${aligns[j]}"` : '';
357
+ return `<td${a}>${inlineMd(c)}</td>`;
358
+ }).join('')}</tr>`;
359
+ }).join('');
360
+ return `<div class="table-wrap"><table>${headerHtml}<tbody>${rowsHtml}</tbody></table></div>`;
361
+ }
362
+
363
+ function parseList(lines, start, baseIndent) {
364
+ const items = [];
365
+ let i = start;
366
+ let listType = null;
367
+ let isTaskList = false;
368
+
369
+ while (i < lines.length) {
370
+ const line = lines[i];
371
+ const m = line.match(/^(\s*)([-*+]|\d+\.)\s+(.*)$/);
372
+ if (!m) break;
373
+ const indent = m[1].length;
374
+ if (indent !== baseIndent) break;
375
+ const isOrdered = /^\d+\./.test(m[2]);
376
+ if (listType === null) listType = isOrdered ? 'ol' : 'ul';
377
+ else if ((listType === 'ol') !== isOrdered) break;
378
+
379
+ const content = m[3];
380
+ const taskMatch = content.match(/^\[([ xX])\]\s+(.*)$/);
381
+ let itemHtml;
382
+ let itemClasses = [];
383
+ if (taskMatch) {
384
+ isTaskList = true;
385
+ const checked = taskMatch[1].toLowerCase() === 'x';
386
+ itemClasses.push('task-item');
387
+ if (checked) itemClasses.push('checked');
388
+ const checkSvg = checked
389
+ ? '<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>'
390
+ : '';
391
+ itemHtml = `<span class="task-marker" aria-hidden="true">${checkSvg}</span><span class="task-text">${inlineMd(taskMatch[2])}</span>`;
392
+ } else {
393
+ itemHtml = inlineMd(content);
394
+ }
395
+
396
+ i++;
397
+
398
+ let nestedHtml = '';
399
+ while (i < lines.length) {
400
+ const nextLine = lines[i];
401
+ if (!nextLine.trim()) {
402
+ if (i + 1 < lines.length) {
403
+ const peek = lines[i + 1].match(/^(\s*)([-*+]|\d+\.)\s+/);
404
+ if (peek && peek[1].length > baseIndent) { i++; continue; }
405
+ }
406
+ break;
407
+ }
408
+ const nextMatch = nextLine.match(/^(\s*)([-*+]|\d+\.)\s+/);
409
+ if (nextMatch && nextMatch[1].length > baseIndent) {
410
+ const nested = parseList(lines, i, nextMatch[1].length);
411
+ nestedHtml += nested.html;
412
+ i = nested.next;
413
+ } else {
414
+ break;
415
+ }
416
+ }
417
+
418
+ const cls = itemClasses.length ? ` class="${itemClasses.join(' ')}"` : '';
419
+ items.push(`<li${cls}>${itemHtml}${nestedHtml}</li>`);
420
+ }
421
+
422
+ const tag = listType || 'ul';
423
+ const cls = isTaskList ? ' class="task-list"' : '';
424
+ return { html: `<${tag}${cls}>${items.join('')}</${tag}>`, next: i };
425
+ }
426
+
427
+ function parseBlocks(lines) {
428
+ const out = [];
429
+ let i = 0;
430
+ while (i < lines.length) {
431
+ const line = lines[i];
432
+
433
+ const fence = line.match(/^\s*```(.*)$/);
434
+ if (fence) {
435
+ const lang = (fence[1] || '').trim();
436
+ const codeLines = [];
437
+ i++;
438
+ while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) {
439
+ codeLines.push(lines[i]);
440
+ i++;
441
+ }
442
+ i++;
443
+ const codeText = codeLines.join('\n');
444
+ out.push(lang.toLowerCase() === 'mermaid' ? renderMermaidFallback(codeText) : renderCodeBlock(codeText, lang));
445
+ continue;
446
+ }
447
+
448
+ if (!line.trim()) { i++; continue; }
449
+
450
+ if (/^\s*-{3,}\s*$/.test(line) || /^\s*\*{3,}\s*$/.test(line)) {
451
+ out.push('<hr class="rule"/>');
452
+ i++;
453
+ continue;
454
+ }
455
+
456
+ const heading = line.match(/^(#{1,6})\s+(.+?)\s*$/);
457
+ if (heading) {
458
+ const level = heading[1].length;
459
+ out.push(`<h${level}>${inlineMd(heading[2])}</h${level}>`);
460
+ i++;
461
+ continue;
462
+ }
463
+
464
+ if (line.includes('|') && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
465
+ const tableLines = [lines[i], lines[i + 1]];
466
+ i += 2;
467
+ while (i < lines.length && lines[i].trim() && lines[i].includes('|')) {
468
+ tableLines.push(lines[i]);
469
+ i++;
470
+ }
471
+ out.push(renderTable(tableLines));
472
+ continue;
473
+ }
474
+
475
+ if (/^\s*>/.test(line)) {
476
+ const quoteLines = [];
477
+ while (i < lines.length && /^\s*>/.test(lines[i])) {
478
+ quoteLines.push(lines[i].replace(/^\s*>\s?/, ''));
479
+ i++;
480
+ }
481
+ out.push(`<blockquote>${parseBlocks(quoteLines)}</blockquote>`);
482
+ continue;
483
+ }
484
+
485
+ if (/^\s*(?:[-*+]|\d+\.)\s+/.test(line)) {
486
+ const indent = (line.match(/^(\s*)/)[1] || '').length;
487
+ const list = parseList(lines, i, indent);
488
+ out.push(list.html);
489
+ i = list.next;
490
+ continue;
491
+ }
492
+
493
+ const paraLines = [];
494
+ while (i < lines.length && lines[i].trim() && !isBlockStart(lines[i], lines[i + 1])) {
495
+ paraLines.push(lines[i]);
496
+ i++;
497
+ }
498
+ if (paraLines.length) {
499
+ out.push(`<p>${inlineMd(paraLines.join(' '))}</p>`);
500
+ } else {
501
+ i++;
502
+ }
503
+ }
504
+ return out.join('\n');
505
+ }
506
+
507
+ function renderMarkdown(markdown) {
508
+ const body = markdown.replace(/^---[\s\S]*?\n---\r?\n/, '');
509
+ const lines = body.split(/\r?\n/);
510
+ const html = parseBlocks(lines);
511
+ return html || '<p class="empty">This document is empty.</p>';
512
+ }
513
+
514
+ async function loadDoc(path) {
515
+ const res = await fetch(`/api/doc?path=${encodeURIComponent(path)}`);
516
+ state.doc = await res.json();
517
+ render();
518
+ }
519
+
520
+ const ELLIPSIS_ICON_SVG = '<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true"><circle cx="3.2" cy="8" r="1.4"/><circle cx="8" cy="8" r="1.4"/><circle cx="12.8" cy="8" r="1.4"/></svg>';
521
+
522
+ function renderTabs() {
523
+ const tabs = state.index.projects.map((p) => {
524
+ const classes = ['tab'];
525
+ if (p.slug === state.project) classes.push('active');
526
+ if (p.pinned) classes.push('tab-fixed');
527
+ return `<button class="${classes.join(' ')}" data-project="${p.slug}">
528
+ <span>${escapeHtml(p.title)}</span>
529
+ <span class="count">${p.docs.length}</span>
530
+ </button>`;
531
+ }).join('');
532
+
533
+ const dropdownProjects = state.index.projects.filter((p) => !p.pinned);
534
+ const dropdownItems = dropdownProjects.map((p) => (
535
+ `<button class="dropdown-item ${p.slug === state.project ? 'active' : ''}" data-project="${p.slug}" role="menuitem">
536
+ <span>${escapeHtml(p.title)}</span>
537
+ <span class="count">${p.docs.length}</span>
538
+ </button>`
539
+ )).join('');
540
+
541
+ const dropdown = dropdownProjects.length
542
+ ? `<div class="tab-dropdown" role="menu" aria-label="More projects">${dropdownItems}</div>`
543
+ : '';
544
+
545
+ return `${tabs}
546
+ <button type="button" class="tab-more hidden" data-tab-more aria-label="More projects" aria-expanded="false" aria-haspopup="true">${ELLIPSIS_ICON_SVG}</button>
547
+ ${dropdown}`;
548
+ }
549
+
550
+ function renderFilters() {
551
+ const roles = availableRoles();
552
+ const statuses = availableStatuses();
553
+ const roleLabels = { context: 'context', template: 'templates', spec: 'spec', plan: 'plan', workstream: 'workstreams', task: 'tasks', decision: 'decisions', progress: 'progress' };
554
+ const roleButtons = roles.map((r) => `<button class="filter ${state.role === r ? 'active' : ''}" data-role="${r}">${roleLabels[r] || r}</button>`).join('');
555
+ const statusButtons = statuses.map((s) => `<button class="filter ${state.status === s ? 'active' : ''}" data-status="${s}">${s}</button>`).join('');
556
+
557
+ const sortOptions = SORT_FIELDS.map((f) => (
558
+ `<option value="${f.value}" ${state.sortBy === f.value ? 'selected' : ''}>${escapeHtml(f.label)}</option>`
559
+ )).join('');
560
+ const dirIsAsc = state.sortDir !== 'desc';
561
+ const dirIcon = dirIsAsc
562
+ ? '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 11l4-6 4 6"/></svg>'
563
+ : '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5l4 6 4-6"/></svg>';
564
+ const dirLabel = dirIsAsc ? 'Sort ascending — click to flip to descending' : 'Sort descending — click to flip to ascending';
565
+
566
+ return `<div class="filter-group">
567
+ <span class="filter-label">Show</span>
568
+ <button class="filter ${state.role === 'all' ? 'active' : ''}" data-role="all">all</button>
569
+ ${roleButtons}
570
+ </div>
571
+ ${statuses.length
572
+ ? `<div class="filter-group"><span class="filter-label">Status</span><button class="filter ${state.status === 'all' ? 'active' : ''}" data-status="all">all</button>${statusButtons}</div>`
573
+ : '<div class="filter-note">No status filters for this folder.</div>'}
574
+ <div class="filter-group sort-group">
575
+ <span class="filter-label">Sort</span>
576
+ <select class="sort-field" data-sort-field aria-label="Sort field">${sortOptions}</select>
577
+ <button type="button" class="sort-dir" data-sort-dir aria-label="${escapeHtml(dirLabel)}" title="${escapeHtml(dirLabel)}" data-direction="${dirIsAsc ? 'asc' : 'desc'}">${dirIcon}</button>
578
+ </div>
579
+ ${state.workstream ? '<button class="workstream-scope" data-clear-workstream>Showing selected workstream and subtasks x</button>' : ''}`;
580
+ }
581
+
582
+ function renderList() {
583
+ const docs = currentDocs();
584
+ const items = docs.map((doc, i) => (
585
+ `<article class="doc reveal ${state.doc?.path === doc.path ? 'active' : ''}" style="--index:${i}" data-doc="${doc.path}">
586
+ <div class="doc-title">
587
+ <span>${escapeHtml(doc.title)}</span>
588
+ ${doc.status ? `<span class="${statusClass(doc.status)}">${escapeHtml(doc.status)}</span>` : `<span class="pill">${escapeHtml(titleCase(doc.role))}</span>`}
589
+ </div>
590
+ <div class="doc-path">${escapeHtml(doc.path)}</div>
591
+ <div class="doc-snippet">${escapeHtml(doc.snippet)}</div>
592
+ </article>`
593
+ )).join('');
594
+
595
+ return `<main class="list reveal">
596
+ <input class="search" placeholder="Search this ${isProjectGroup() ? 'project' : 'folder'}..." value="${escapeHtml(state.query)}" />
597
+ <div class="filters">${renderFilters()}</div>
598
+ ${items || '<div class="empty">No documents match this filter.</div>'}
599
+ </main>`;
600
+ }
601
+
602
+ function renderReader() {
603
+ const doc = state.doc;
604
+ if (!doc) return '<section class="reader reveal"><div class="empty">Select a document.</div></section>';
605
+ const props = Object.entries(doc.frontmatter || {});
606
+ const properties = props.length ? `<div class="properties">${props.map(([k, v]) => {
607
+ const display = normalizeCopyValue(v);
608
+ return `<div class="prop-key">${escapeHtml(k)}</div><div class="prop-value"><span class="prop-text">${escapeHtml(display)}</span>${copyButton(v, `Copy ${k}`)}</div>`;
609
+ }).join('')}</div>` : '';
610
+
611
+ const markdownCopyId = registerCopy(doc.markdown);
612
+
613
+ return `<section class="reader reveal">
614
+ <div class="reader-inner">
615
+ <header class="reader-head">
616
+ <div class="reader-top">
617
+ <div>
618
+ <div class="eyebrow">${escapeHtml(titleCase(doc.role))}</div>
619
+ <h1>${escapeHtml(doc.title)}</h1>
620
+ </div>
621
+ </div>
622
+ <div class="meta">
623
+ <span class="pill path-pill">${escapeHtml(doc.path)}</span>
624
+ ${copyButton(doc.path, 'Copy path')}
625
+ ${doc.status ? `<span class="${statusClass(doc.status)}">${escapeHtml(doc.status)}</span>` : ''}
626
+ <span class="pill">updated ${escapeHtml(String(doc.updated).slice(0, 10))}</span>
627
+ </div>
628
+ <div class="reader-actions">
629
+ <button class="action" data-open="explorer" title="Open containing folder in system explorer" aria-label="Open in system explorer">
630
+ <img class="action-icon" src="/explorer.svg" alt="" aria-hidden="true" />
631
+ <span class="action-label">Open</span>
632
+ </button>
633
+ <button class="action" data-open="code" title="Open this markdown file in VS Code" aria-label="Open in VS Code">
634
+ <img class="action-icon" src="/vscode.svg" alt="" aria-hidden="true" />
635
+ <span class="action-label">Open</span>
636
+ </button>
637
+ ${markdownCopyId ? `<button type="button" class="action" data-copy="${markdownCopyId}" title="Copy the rendered markdown body" aria-label="Copy markdown">
638
+ <img class="action-icon action-icon-md" src="/markdown.svg" alt="" aria-hidden="true" />
639
+ <span class="action-label">Copy</span>
640
+ </button>` : ''}
641
+ </div>
642
+ <div class="open-feedback" aria-live="polite"></div>
643
+ ${properties}
644
+ </header>
645
+ <article class="markdown">${renderMarkdown(doc.markdown)}</article>
646
+ </div>
647
+ </section>`;
648
+ }
649
+
650
+ async function openCurrentDoc(target) {
651
+ if (!state.doc) return;
652
+ const feedback = $('.open-feedback');
653
+ try {
654
+ const res = await fetch(`/api/open?target=${encodeURIComponent(target)}&path=${encodeURIComponent(state.doc.path)}`, { method: 'POST' });
655
+ const data = await res.json().catch(() => ({}));
656
+ if (!res.ok || !data.ok) throw new Error(data.error || 'Open action failed.');
657
+ if (feedback) feedback.textContent = target === 'code' ? 'Opened in VS Code.' : 'Opened in system explorer.';
658
+ } catch (error) {
659
+ if (feedback) feedback.textContent = error.message || String(error);
660
+ }
661
+ }
662
+
663
+ function outlineLink(path, label, extra = '') {
664
+ if (!path) return '';
665
+ const doc = byPath(path);
666
+ const active = state.doc?.path === path ? 'active' : '';
667
+ return `<button class="outline-link ${active}" data-doc="${path}"><span>${escapeHtml(label || doc?.title || path)}</span>${extra}</button>`;
668
+ }
669
+
670
+ function renderProjectOutline() {
671
+ const project = currentProject();
672
+ if (!project.outline) {
673
+ return `<aside class="outline reveal">
674
+ <div class="outline-title">Folder guide</div>
675
+ <p class="outline-help">${project.slug === 'context' ? 'Context is repo-level background. Status filters stay hidden because these documents are not delivery tasks.' : 'Templates are reusable contracts. Status filters stay hidden unless this folder contains statuses.'}</p>
676
+ </aside>`;
677
+ }
678
+
679
+ const outline = project.outline;
680
+ const labelWithId = (id, title) => {
681
+ if (!id) return title;
682
+ return String(title || '').startsWith(id) ? title : `${id} ${title}`;
683
+ };
684
+ const taskLink = (path) => {
685
+ const task = byPath(path);
686
+ const status = task?.status ? `<span class="${statusClass(task.status)}">${escapeHtml(task.status)}</span>` : '';
687
+ return outlineLink(path, `${task?.taskId ? `${task.taskId} ` : ''}${task?.title || path}`, status);
688
+ };
689
+ const decisions = outline.decisions.map((p) => outlineLink(p, byPath(p)?.title || 'Decisions')).join('');
690
+ const progressLink = outline.progress.length
691
+ ? outlineLink(outline.progress[0], `Progress log (${outline.progress.length})`)
692
+ : '';
693
+
694
+ const workstreams = outline.workstreams.map((ws) => (
695
+ `<div class="workstream-block ${state.workstream === ws.path ? 'active' : ''}">
696
+ <button class="outline-link workstream-pick ${state.doc?.path === ws.path ? 'active' : ''}" data-workstream="${ws.path}" data-doc="${ws.path}">
697
+ <span>${escapeHtml(labelWithId(ws.id, ws.title))}</span>
698
+ ${ws.status ? `<span class="${statusClass(ws.status)}">${escapeHtml(ws.status)}</span>` : `<span class="count">${ws.tasks.length}</span>`}
699
+ </button>
700
+ ${state.workstream === ws.path ? `<div class="subtasks">${ws.tasks.map(taskLink).join('') || '<div class="empty small">No subtasks linked yet.</div>'}</div>` : ''}
701
+ </div>`
702
+ )).join('');
703
+
704
+ return `<aside class="outline reveal">
705
+ <div class="outline-title">Project outline</div>
706
+ <p class="outline-help">Select a workstream to focus the list and reveal its subtasks.</p>
707
+ <div class="outline-section">
708
+ <div class="outline-label">Core</div>
709
+ ${outlineLink(outline.spec, 'Spec')}
710
+ ${outlineLink(outline.plan, 'Plan')}
711
+ ${decisions}
712
+ ${progressLink}
713
+ </div>
714
+ <div class="outline-section">
715
+ <div class="outline-label">Workstreams and Tasks</div>
716
+ ${workstreams}
717
+ ${outline.unassignedTasks.length ? `<div class="outline-label">Unassigned tasks</div>${outline.unassignedTasks.map(taskLink).join('')}` : ''}
718
+ </div>
719
+ </aside>`;
720
+ }
721
+
722
+ function resetGroupFilters() {
723
+ state.status = 'all';
724
+ state.role = 'all';
725
+ state.workstream = null;
726
+ }
727
+
728
+ function prepareReveal() {
729
+ const nodes = [...document.querySelectorAll('.reveal')];
730
+ if (!('IntersectionObserver' in window)) {
731
+ nodes.forEach((node) => node.classList.add('visible'));
732
+ return;
733
+ }
734
+ const observer = new IntersectionObserver((entries) => {
735
+ entries.forEach((entry) => {
736
+ if (entry.isIntersecting) {
737
+ entry.target.classList.add('visible');
738
+ observer.unobserve(entry.target);
739
+ }
740
+ });
741
+ }, { threshold: 0.08 });
742
+ nodes.forEach((node) => observer.observe(node));
743
+ }
744
+
745
+ function render() {
746
+ resetCopyRegistry();
747
+ const outlineClass = state.outlineOpen ? 'outline-open' : '';
748
+ $('#app').innerHTML = `<div class="viewer-frame ${outlineClass}">
749
+ <header class="top-bar">
750
+ <div class="brand-mark">Delano</div>
751
+ <nav class="tabs">${renderTabs()}</nav>
752
+ <button class="outline-toggle" data-outline-toggle>${state.outlineOpen ? 'Hide outline' : 'Show outline'}</button>
753
+ </header>
754
+ <div class="shell">${renderList()}${renderReader()}${renderProjectOutline()}</div>
755
+ </div>`;
756
+
757
+ document.querySelectorAll('[data-project]').forEach((el) => el.onclick = () => {
758
+ state.project = el.dataset.project;
759
+ state.doc = null;
760
+ resetGroupFilters();
761
+ const proj = currentProject();
762
+ // Auto-open the outline panel when entering a project view; collapse on context/templates.
763
+ state.outlineOpen = !!(proj && proj.outline);
764
+ // Project views land on the spec by default; fall back to the first sorted doc otherwise.
765
+ if (proj && proj.outline && proj.outline.spec) {
766
+ loadDoc(proj.outline.spec);
767
+ return;
768
+ }
769
+ const first = currentDocs()[0];
770
+ if (first) loadDoc(first.path); else render();
771
+ });
772
+ document.querySelectorAll('[data-doc]').forEach((el) => el.onclick = () => loadDoc(el.dataset.doc));
773
+ document.querySelectorAll('[data-status]').forEach((el) => el.onclick = () => { state.status = el.dataset.status; render(); });
774
+ document.querySelectorAll('[data-role]').forEach((el) => el.onclick = () => { state.role = el.dataset.role; state.workstream = null; render(); });
775
+ document.querySelectorAll('[data-workstream]').forEach((el) => el.onclick = () => { state.workstream = el.dataset.workstream; state.role = 'all'; loadDoc(el.dataset.doc); });
776
+ document.querySelectorAll('[data-clear-workstream]').forEach((el) => el.onclick = () => { state.workstream = null; render(); });
777
+ document.querySelectorAll('[data-outline-toggle]').forEach((el) => el.onclick = () => { state.outlineOpen = !state.outlineOpen; render(); });
778
+ document.querySelectorAll('[data-open]').forEach((el) => el.onclick = () => openCurrentDoc(el.dataset.open));
779
+ const sortField = document.querySelector('[data-sort-field]');
780
+ if (sortField) sortField.onchange = (e) => { state.sortBy = e.target.value; render(); };
781
+ document.querySelectorAll('[data-sort-dir]').forEach((el) => el.onclick = () => { state.sortDir = state.sortDir === 'desc' ? 'asc' : 'desc'; render(); });
782
+ const search = $('.search');
783
+ if (search) search.oninput = (e) => {
784
+ const caret = e.target.selectionStart;
785
+ state.query = e.target.value;
786
+ render();
787
+ const next = $('.search');
788
+ if (next) {
789
+ next.focus();
790
+ try { next.setSelectionRange(caret, caret); } catch (_) { /* non-text input types */ }
791
+ }
792
+ };
793
+ document.querySelectorAll('.wikilink').forEach((el) => el.onclick = () => { state.query = el.dataset.target; render(); });
794
+ prepareReveal();
795
+ scheduleTabOverflow();
796
+ }
797
+
798
+ (async function init() {
799
+ attachCopyDelegation();
800
+ attachTabDropdownDelegation();
801
+ watchTabsResize();
802
+ if (document.fonts && document.fonts.ready) {
803
+ document.fonts.ready.then(() => applyTabOverflow()).catch(() => {});
804
+ }
805
+ const res = await fetch('/api/index');
806
+ state.index = await res.json();
807
+ const initialProject = currentProject();
808
+ // If the initial project is a project view, reveal the outline and land on its spec.
809
+ if (initialProject && initialProject.outline) {
810
+ state.outlineOpen = true;
811
+ if (initialProject.outline.spec) {
812
+ await loadDoc(initialProject.outline.spec);
813
+ return;
814
+ }
815
+ }
816
+ const first = currentDocs()[0] || state.index.docs[0];
817
+ if (first) await loadDoc(first.path); else render();
818
+ })();