sonorance 0.1.0-beta.4.1

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 (43) hide show
  1. package/LICENSE +174 -0
  2. package/README.md +103 -0
  3. package/build/icon.png +0 -0
  4. package/package.json +86 -0
  5. package/skill/SKILL.md +41 -0
  6. package/skill/scripts/sonorance.mjs +31 -0
  7. package/src/azure-monitor.mjs +221 -0
  8. package/src/cli.mjs +315 -0
  9. package/src/comment-client.mjs +75 -0
  10. package/src/engine-default.mjs +136 -0
  11. package/src/feedback.mjs +151 -0
  12. package/src/gitignore.mjs +27 -0
  13. package/src/grammar.mjs +55 -0
  14. package/src/identity.mjs +126 -0
  15. package/src/otlp.mjs +166 -0
  16. package/src/plugins/deliberate/contribute.mjs +28 -0
  17. package/src/plugins/deliberate/domain.mjs +40 -0
  18. package/src/plugins/deliberate/frontmatter.mjs +85 -0
  19. package/src/plugins/deliberate/gitignore.mjs +21 -0
  20. package/src/plugins/deliberate/kinds.mjs +44 -0
  21. package/src/plugins/deliberate/markdown.mjs +42 -0
  22. package/src/plugins/deliberate/paths.mjs +245 -0
  23. package/src/plugins/deliberate/stages.mjs +27 -0
  24. package/src/plugins/deliberate/vault.mjs +1043 -0
  25. package/src/plugins.mjs +91 -0
  26. package/src/release-config.mjs +2 -0
  27. package/src/scrubber.mjs +64 -0
  28. package/src/server/index.mjs +993 -0
  29. package/src/sources.mjs +80 -0
  30. package/src/telemetry-schema.mjs +187 -0
  31. package/src/telemetry.mjs +390 -0
  32. package/src/ui/active-line.mjs +42 -0
  33. package/src/ui/app.js +3553 -0
  34. package/src/ui/at-mention.mjs +67 -0
  35. package/src/ui/comments-plugin.mjs +107 -0
  36. package/src/ui/diff-plugin.mjs +73 -0
  37. package/src/ui/editor.mjs +210 -0
  38. package/src/ui/index.html +1723 -0
  39. package/src/ui/md.mjs +233 -0
  40. package/src/ui/paste-md.mjs +54 -0
  41. package/src/ui/shell.html +1374 -0
  42. package/src/ui/slash.mjs +122 -0
  43. package/src/vault-registry.mjs +150 -0
package/src/ui/app.js ADDED
@@ -0,0 +1,3553 @@
1
+ import { createEditor, mdBlocks } from './editor.mjs';
2
+ (function(){
3
+ "use strict";
4
+ /* ==========================================================================
5
+ Deliberate workbench — a zero-build, dependency-free web app over the vault.
6
+ Talks to the local daemon (/api/*). Renderer + block editor are hand-rolled
7
+ so it works fully offline and stays token-driven and testable.
8
+ ========================================================================== */
9
+
10
+ // ---- tiny helpers ---------------------------------------------------------
11
+ const $ = (s, r=document) => r.querySelector(s);
12
+ const esc = (s) => String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
13
+ const iconEl = (paths) => `<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
14
+ const ICON = {
15
+ queue: '<path d="M4 6h16M4 12h16M4 18h10"/><circle cx="19" cy="18" r="2"/>',
16
+ inbox: '<path d="M4 13v5a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5"/><path d="M4 13l2.4-6.7A2 2 0 0 1 8.3 5h7.4a2 2 0 0 1 1.9 1.3L20 13"/><path d="M4 13h4l1.2 2.2a1 1 0 0 0 .9.5h3.8a1 1 0 0 0 .9-.5L16 13h4"/>',
17
+ brief: '<rect x="5" y="3" width="14" height="18" rx="2"/><path d="M9 8h6M9 12h6M9 16h4"/>',
18
+ readout: '<path d="M4 19V5M4 19h16"/><path d="M7 15l4-4 3 2 5-6"/><circle cx="7" cy="15" r=".8" fill="currentColor" stroke="none"/><circle cx="11" cy="11" r=".8" fill="currentColor" stroke="none"/><circle cx="14" cy="13" r=".8" fill="currentColor" stroke="none"/><circle cx="19" cy="7" r=".8" fill="currentColor" stroke="none"/>',
19
+ matchup: '<rect x="3" y="4" width="7" height="16" rx="1.5"/><rect x="14" y="4" width="7" height="16" rx="1.5"/><path d="M12 3v18"/>',
20
+ menu: '<path d="M4 7h16M4 12h16M4 17h16"/>',
21
+ chevron: '<path d="M9 6l6 6-6 6"/>',
22
+ chevrud: '<path d="M8 9l4 4 4-4"/><path d="M8 15l4-4 4 4" opacity=".45"/>',
23
+ caret: '<path d="M9 6l6 6-6 6"/>',
24
+ folder: '<path d="M3 7a2 2 0 0 1 2-2h3.6a2 2 0 0 1 1.4.6L11.4 7H19a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
25
+ newfolder: '<path d="M3 7a2 2 0 0 1 2-2h3.6a2 2 0 0 1 1.4.6L11.4 7H19a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M12 11v5M9.5 13.5h5"/>',
26
+ plus: '<path d="M12 5v14M5 12h14"/>',
27
+ doc: '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/>',
28
+ collapseAll: '<path d="M7 20l5-5 5 5"/><path d="M7 4l5 5 5-5"/>',
29
+ expandAll: '<path d="M7 15l5 5 5-5"/><path d="M7 9l5-5 5 5"/>',
30
+ x: '<path d="M6 6l12 12M18 6L6 18"/>',
31
+ search: '<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>',
32
+ vault: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18"/>',
33
+ check: '<path d="M20 6L9 17l-5-5"/>',
34
+ external: '<path d="M14 4h6v6"/><path d="M20 4l-8.5 8.5"/><path d="M18 13.5V18a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4.5"/>',
35
+ sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5L19 19M19 5l-1.5 1.5M6.5 17.5L5 19"/>',
36
+ moon: '<path d="M20 14.5A8 8 0 1 1 9.5 4 6.2 6.2 0 0 0 20 14.5z"/>',
37
+ auto: '<circle cx="12" cy="12" r="8"/><path d="M12 4a8 8 0 0 0 0 16z" fill="currentColor" stroke="none"/>',
38
+ settings: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.83 2.83-.06-.06a1.7 1.7 0 0 0-1.88-.34 1.7 1.7 0 0 0-1.03 1.56V21h-4v-.08A1.7 1.7 0 0 0 9 19.37a1.7 1.7 0 0 0-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 0 0 4.63 15 1.7 1.7 0 0 0 3.08 14H3v-4h.08A1.7 1.7 0 0 0 4.63 9a1.7 1.7 0 0 0-.34-1.88l-.06-.06 2.83-2.83.06.06A1.7 1.7 0 0 0 9 4.63 1.7 1.7 0 0 0 10 3.08V3h4v.08A1.7 1.7 0 0 0 15 4.63a1.7 1.7 0 0 0 1.88-.34l.06-.06 2.83 2.83-.06.06A1.7 1.7 0 0 0 19.37 9 1.7 1.7 0 0 0 20.92 10H21v4h-.08A1.7 1.7 0 0 0 19.4 15z"/>',
39
+ comment: '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>',
40
+ feedback: '<path d="M3 11v2a2 2 0 0 0 2 2h2l3 5h3l-2.5-5H12l8 3V6l-8 3H5a2 2 0 0 0-2 2z"/><path d="M7 9v6M20 10l2-1M20 14l2 1"/>',
41
+ thumbUp: '<path d="M7 10v11H3V10zM7 19h9.2a2 2 0 0 0 2-1.6l1.4-7A2 2 0 0 0 17.6 8H14l.5-3.1A2.4 2.4 0 0 0 12.1 2L7 10z"/>',
42
+ thumbDown: '<path d="M7 14V3H3v11zM7 5h9.2a2 2 0 0 1 2 1.6l1.4 7a2 2 0 0 1-2 2.4H14l.5 3.1a2.4 2.4 0 0 1-2.4 2.9L7 14z"/>',
43
+ rocket: '<path d="M14 4c2.8-2.8 5.5-2 6-1.5s1.3 3.2-1.5 6L13 14l-3-3z"/><path d="M14 4l6 6M10 11l-4 .5-3 3 5 1.5 1.5 5 3-3 .5-4"/><path d="M5 19l-2 2M7 17l-4 4"/>',
44
+ mic: '<path d="M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z"/><path d="M19 10v1a7 7 0 0 1-14 0v-1"/><path d="M12 18v4M8 22h8"/>',
45
+ send: '<path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4 20-7z"/>',
46
+ more: '<circle cx="5" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.4" fill="currentColor" stroke="none"/>',
47
+ trash: '<path d="M4 7h16"/><path d="M10 11v6M14 11v6"/><path d="M6 7l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13"/><path d="M9 7V4h6v3"/>',
48
+ proto: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18"/><path d="M6.5 6.5h.01M9 6.5h.01"/>',
49
+ copy: '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h8"/>',
50
+ pencil: '<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/>',
51
+ gitbranch: '<circle cx="6" cy="6" r="2.4"/><circle cx="6" cy="18" r="2.4"/><circle cx="18" cy="8" r="2.4"/><path d="M6 8.4v7.2"/><path d="M18 10.4a6 6 0 0 1-6 6H8"/>',
52
+ gitfile: '<path d="M13.5 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8.5z"/><path d="M13.5 3v5h5"/><circle cx="9.4" cy="13.6" r="1.15"/><circle cx="9.4" cy="18" r="1.15"/><circle cx="14" cy="14.9" r="1.15"/><path d="M9.4 14.75v2.1"/><path d="M14 16.05a1.9 1.9 0 0 1-1.9 1.9H9.4"/>',
53
+ newfile: '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h4"/><path d="M14 3v5h5"/><path d="M17 14v6M14 17h6"/>',
54
+ revert: '<path d="M9 14L4 9l5-5"/><path d="M4 9h11a5 5 0 0 1 5 5v1"/>',
55
+ bold: '<path d="M7 5h6a3.4 3.4 0 0 1 0 6.8H7z"/><path d="M7 11.8h7a3.6 3.6 0 0 1 0 7.2H7z"/>',
56
+ italic: '<path d="M19 4h-7M14 20H5M15 4l-6 16"/>',
57
+ link: '<path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1"/>',
58
+ heading: '<path d="M6 4v16M18 4v16M6 12h12"/>',
59
+ };
60
+
61
+ // ---- markdown rendering (a focused, safe subset) --------------------------
62
+ // Emphasis grammar for the read-only render path (`inline`, used by briefs, the inbox, and
63
+ // previews — the editable surface is Tiptap). Factories return a fresh /g regex per call (no
64
+ // shared lastIndex).
65
+ const MD_RE = {
66
+ bold: () => /\*\*([^*\n]+)\*\*/g,
67
+ emStar: () => /(^|[^*\w])\*([^*\n]+)\*(?!\w)/g,
68
+ emUnd: () => /(^|[^\w])_([^_\n]+)_(?![\w])/g,
69
+ };
70
+ function sanitizeHref(h){ return /^(https?:|mailto:|\/|\.\/|#)/i.test(h) ? h : '#'; }
71
+ // The project-relative path of the doc currently rendered (a generic file, a case record, or a
72
+ // brief) — the base a Markdown image's relative `src` resolves against.
73
+ function currentDocRel(){ return (S.file && S.file.path) || S.recFile || ''; }
74
+ // Resolve a Markdown image `src` to a URL the browser can load. External/inline sources pass
75
+ // through untouched; a relative (or root-relative) path is resolved against the doc's folder
76
+ // (a leading `/` means the vault root) and served by the daemon's /api/asset endpoint, which is
77
+ // vault-confined and extension-gated. Keeps images working offline with no external fetches.
78
+ function resolveAssetSrc(src){
79
+ const raw = String(src || '').trim();
80
+ if (!raw) return '';
81
+ if (/^(https?:|data:|blob:)/i.test(raw)) return raw;
82
+ const dir = currentDocRel().split('/').slice(0, -1);
83
+ const parts = raw.startsWith('/') ? raw.replace(/^\/+/, '').split('/') : dir.concat(raw.split('/'));
84
+ const out = [];
85
+ for (const seg of parts){ if (seg === '..') out.pop(); else if (seg && seg !== '.') out.push(seg); }
86
+ return out.length ? '/api/asset?path=' + encodeURIComponent(out.join('/')) + (PP() ? '&project=' + encodeURIComponent(S.projectSlug || S.project.id) : '') : '';
87
+ }
88
+ function inline(src){
89
+ let s = esc(src);
90
+ const codes = [];
91
+ s = s.replace(/`([^`]+)`/g, (_m, c) => { codes.push(c); return '\u0000' + (codes.length-1) + '\u0000'; });
92
+ // Images (`![alt](src)`) MUST run before links — an image literally contains a `[alt](src)`
93
+ // link, so the link rule would otherwise eat it and leave a stray `!`. Relative srcs resolve
94
+ // against the doc's folder via the daemon (/api/asset); external/data URLs pass through.
95
+ s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+&quot;[^&]*&quot;)?\)/g,
96
+ (_m, alt, src2) => { const u = resolveAssetSrc(src2); return u ? `<img class="md-img" src="${esc(u)}" alt="${alt}" loading="lazy">` : alt; });
97
+ s = s.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+&quot;[^&]*&quot;)?\)/g,
98
+ (_m, t, h) => `<a href="${sanitizeHref(h)}" target="_blank" rel="noopener noreferrer">${t}</a>`);
99
+ s = s.replace(MD_RE.bold(), '<strong>$1</strong>');
100
+ s = s.replace(MD_RE.emStar(), '$1<em>$2</em>');
101
+ s = s.replace(MD_RE.emUnd(), '$1<em>$2</em>');
102
+ s = s.replace(/\u0000(\d+)\u0000/g, (_m, i) => `<code>${codes[+i]}</code>`);
103
+ return s;
104
+ }
105
+ function splitRow(r){ return r.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(x => x.trim()); }
106
+ function renderTable(rows){
107
+ const head = splitRow(rows[0]);
108
+ let h = '<table class="md-tbl"><thead><tr>' + head.map(c => `<th>${inline(c)}</th>`).join('') + '</tr></thead><tbody>';
109
+ for (const r of rows.slice(2)){ const cells = splitRow(r); h += '<tr>' + head.map((_c, j) => `<td>${inline(cells[j] ?? '')}</td>`).join('') + '</tr>'; }
110
+ // Wrap so the table (and only the table) scrolls horizontally when its columns exceed
111
+ // the content width — the prose around it never scrolls.
112
+ return '<div class="md-tblwrap">' + h + '</tbody></table></div>';
113
+ }
114
+ function renderList(buf){
115
+ const items = [];
116
+ for (const line of buf){
117
+ const m = line.match(/^(\s*)([-*+]|\d+\.)\s+(.*)$/);
118
+ if (m) items.push({ indent: m[1].length, ordered: /\d+\./.test(m[2]), text: m[3] });
119
+ else if (items.length) items[items.length-1].text += ' ' + line.trim();
120
+ }
121
+ if (!items.length) return '';
122
+ const base = Math.min(...items.map(i => i.indent));
123
+ const ordered = items[0].ordered;
124
+ let h = `<${ordered?'ol':'ul'} class="md-list">`, i = 0;
125
+ while (i < items.length){
126
+ const it = items[i];
127
+ if (it.indent <= base){
128
+ let j = i+1; const kids = [];
129
+ while (j < items.length && items[j].indent > base){ kids.push(items[j]); j++; }
130
+ h += taskLi(it.text);
131
+ if (kids.length){ const ord2 = kids[0].ordered; h += `<${ord2?'ol':'ul'} class="md-list">` + kids.map(k => taskLi(k.text) + '</li>').join('') + `</${ord2?'ol':'ul'}>`; }
132
+ h += '</li>'; i = j;
133
+ } else i++;
134
+ }
135
+ return h + `</${ordered?'ol':'ul'}>`;
136
+ }
137
+ // A list item's opening `<li>` (caller closes it). A GitHub-style task item (`[ ]`/`[x]`
138
+ // after the bullet) becomes an interactive checkbox that toggles the source line in place;
139
+ // the checkbox is a contenteditable island so a click never drops the caret into it. The
140
+ // literal `[ ]`/`[x]` stays in the Markdown source — only the RENDERED view shows a box.
141
+ const TASK_RE = /^\[([ xX])\]\s+(.*)$/s;
142
+ function taskLi(text){
143
+ const m = String(text).match(TASK_RE);
144
+ if (!m) return `<li>${inline(text)}`;
145
+ const done = m[1].toLowerCase() === 'x';
146
+ return `<li class="md-task"><input type="checkbox" class="md-taskbox" contenteditable="false"${done?' checked':''} aria-label="Toggle task"><span class="md-tasktxt">${inline(m[2])}</span>`;
147
+ }
148
+ function renderMarkdown(md){
149
+ const lines = String(md||'').replace(/<!--[\s\S]*?-->/g, '').replace(/\r\n?/g, '\n').split('\n');
150
+ const blank = l => /^\s*$/.test(l);
151
+ const special = l => /^\s*(#{1,6}\s|>|```|[-*+]\s|\d+\.\s)/.test(l) || /^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(l);
152
+ let html = '', i = 0;
153
+ while (i < lines.length){
154
+ const l = lines[i];
155
+ if (blank(l)){ i++; continue; }
156
+ if (/^\s*```/.test(l)){ i++; const buf = []; while (i < lines.length && !/^\s*```/.test(lines[i])){ buf.push(lines[i]); i++; } i++; html += `<pre class="md-pre"><code>${esc(buf.join('\n'))}</code></pre>`; continue; }
157
+ const h = l.match(/^\s*(#{1,6})\s+(.*)$/); // tolerate leading indentation (matches `special`)
158
+ if (h){ const n = h[1].length; html += `<h${n} class="md-h md-h${n}">${inline(h[2].trim())}</h${n}>`; i++; continue; }
159
+ if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(l)){ html += '<hr class="md-hr">'; i++; continue; }
160
+ if (/^\s*>\s?/.test(l)){ const buf = []; while (i < lines.length && /^\s*>\s?/.test(lines[i])){ buf.push(lines[i].replace(/^\s*>\s?/, '')); i++; } html += `<blockquote class="md-bq">${renderMarkdown(buf.join('\n'))}</blockquote>`; continue; }
161
+ if (l.includes('|') && i+1 < lines.length && lines[i+1].includes('-') && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i+1])){
162
+ const rows = []; while (i < lines.length && lines[i].includes('|') && !blank(lines[i])){ rows.push(lines[i]); i++; } html += renderTable(rows); continue;
163
+ }
164
+ if (/^\s*([-*+]|\d+\.)\s+/.test(l)){ const buf = []; while (i < lines.length && (/^\s*([-*+]|\d+\.)\s+/.test(lines[i]) || /^\s{2,}\S/.test(lines[i]))){ buf.push(lines[i]); i++; } html += renderList(buf); continue; }
165
+ const buf = []; while (i < lines.length && !blank(lines[i]) && !special(lines[i])){ buf.push(lines[i]); i++; }
166
+ // Forward-progress guard: a `special` line that no block branch above consumed would leave
167
+ // buf empty and `i` unmoved → an infinite loop that grows `html` until the string exceeds
168
+ // V8's max length (RangeError: Invalid string length), freezing the tab for ~20s. Never let
169
+ // `i` stall — fall back to emitting the current line as prose so the walk always advances.
170
+ if (!buf.length){ buf.push(l); i++; }
171
+ html += `<p class="md-p">${inline(buf.join('\n')).replace(/\n/g, '<br>')}</p>`;
172
+ }
173
+ return html;
174
+ }
175
+
176
+ // ---- raw ↔ blocks (round-trip safe: reconstruct === original when unedited) --
177
+ function segment(raw){
178
+ const lines = String(raw).split('\n');
179
+ const items = []; let cur = null, inFence = false;
180
+ const push = (kind, line, fresh) => { if (fresh || !cur || cur.kind !== kind){ cur = { kind, lines: [] }; items.push(cur); } cur.lines.push(line); };
181
+ let start = 0;
182
+ // Leading YAML frontmatter is ONE atomic block. Its blank lines and YAML `#` comments must
183
+ // NOT be split into gap/heading segments — that shatters it so the reader can no longer hide
184
+ // it, dumping raw metadata into the reading view when frontmatter carries indented `# …`
185
+ // YAML comments that look like headings.
186
+ if (/^---\s*$/.test(lines[0] || '')){
187
+ let end = -1;
188
+ for (let k = 1; k < lines.length; k++){ if (/^---\s*$/.test(lines[k])){ end = k; break; } }
189
+ if (end > 0){ items.push({ kind: 'block', lines: lines.slice(0, end + 1) }); start = end + 1; }
190
+ }
191
+ for (let li = start; li < lines.length; li++){
192
+ const line = lines[li];
193
+ const fence = /^\s*```/.test(line);
194
+ if (inFence){ push('block', line); if (fence) inFence = false; continue; }
195
+ if (fence){ push('block', line); inFence = true; continue; }
196
+ if (/^\s*$/.test(line)){ push('gap', line); continue; }
197
+ // An ATX heading is its own single-line block. Body text glued directly beneath a
198
+ // heading (no blank line between) must become its OWN paragraph block — otherwise the
199
+ // whole run inherits the heading's block type and renders at heading size in the editor.
200
+ if (/^\s*#{1,6}\s/.test(line)){ push('block', line, true); cur = null; continue; }
201
+ push('block', line);
202
+ }
203
+ return items;
204
+ }
205
+ const reconstruct = (items) => items.flatMap(it => it.lines).join('\n');
206
+
207
+ function verdict(score){
208
+ if (score == null) return { cls:'neutral', label:'—', word:'Not yet scored' };
209
+ const n = Number(score).toFixed(1);
210
+ return score >= 7 ? { cls:'advance', label:n, word:'Advance' } : score >= 5 ? { cls:'shelve', label:n, word:'Shelve' } : { cls:'reject', label:n, word:'Reject' };
211
+ }
212
+ // The go/no-go call, promoted to a first-class band at the top of the record. Built from
213
+ // the case's score score (not the prose), so the decision is the first thing the reader
214
+ // sees — with a jump into the reasoning. Sits in .canvas above the editable #doc.
215
+ // The record's score reasoning, if present. Detected from the source markdown so
216
+ // the top-strip score pill can decide (before render) whether to be a jump button.
217
+ function reasoningInSource(){
218
+ return !!(S.rec && S.rec.exists && /^#{2,4}\s+.*(why this score|score|verdict)\b/im.test(S.rec.text));
219
+ }
220
+ function findReasoningTarget(){
221
+ const doc = $('#doc'); if (!doc) return null;
222
+ const heads = [...doc.querySelectorAll('h2,h3,h4')];
223
+ return heads.find(h => /why this score/i.test(h.textContent))
224
+ || heads.find(h => /^\s*score\s*$/i.test(h.textContent))
225
+ || heads.find(h => /score|score|verdict/i.test(h.textContent))
226
+ || null;
227
+ }
228
+ function jumpToReasoning(){
229
+ const target = findReasoningTarget();
230
+ if (target) target.scrollIntoView({ behavior: prefersReduced() ? 'auto' : 'smooth', block:'start' });
231
+ }
232
+ const relTime = (ts) => {
233
+ if (!ts) return '';
234
+ const s = Math.floor((Date.now() - ts) / 1000);
235
+ if (s < 60) return 'just now';
236
+ const m = Math.floor(s/60); if (m < 60) return m + 'm ago';
237
+ const h = Math.floor(m/60); if (h < 24) return h + 'h ago';
238
+ const d = Math.floor(h/24); return d < 7 ? d + 'd ago' : new Date(ts).toLocaleDateString();
239
+ };
240
+
241
+ // ---- API ------------------------------------------------------------------
242
+ async function api(method, path, body){
243
+ const o = { method, headers: { 'content-type':'application/json' } };
244
+ if (body) o.body = JSON.stringify(body);
245
+ // Scope every API call to the current project (the URL's /p/<slug>) so the server resolves
246
+ // the right vault per-request — the app can serve several projects at once, one per browser tab.
247
+ let url = path;
248
+ const slug = S.projectSlug || (S.project && S.project.id);
249
+ if (slug && path.startsWith('/api/')) url += (path.includes('?') ? '&' : '?') + 'project=' + encodeURIComponent(slug);
250
+ let r;
251
+ try { r = await fetch(url, o); } catch { return { error:'offline', netError:true }; }
252
+ // A stale/old `serve` process returns a non-JSON 404 ("not found") for routes it lacks —
253
+ // never throw on that (the old bug swallowed it silently); surface a structured error so
254
+ // callers can tell the user the workbench is out of date, and threads never look saved.
255
+ const status = typeof r.status === 'number' ? r.status : 200;
256
+ const ct = (r.headers && r.headers.get) ? (r.headers.get('content-type') || '') : 'application/json';
257
+ if (!ct.includes('json')) return { error: status === 404 ? 'endpoint-missing' : ('http-' + status), httpStatus: status };
258
+ let j; try { j = await r.json(); } catch { return { error:'bad-response', httpStatus: status }; }
259
+ if (j && typeof j === 'object' && !('error' in j) && r.ok === false) j.error = 'http-' + status;
260
+ return j;
261
+ }
262
+
263
+ // ---- app state ------------------------------------------------------------
264
+ // Branch A: the app's identity + canonical command hints are app-owned and STABLE — always
265
+ // Sonorance, on every vault. A plugin (e.g. Deliberate) surfaces per-project via kinds/inbox, it
266
+ // never renames the app or its commands. These constants are the single UI source for that.
267
+ const BRAND = 'Sonorance';
268
+ const SERVE_CMD = 'sonorance serve';
269
+ const ADDRESS_CMD = '/sonorance address';
270
+ const S = { project:null, projectSlug:null, projects:[], cases:[], briefs:[], readouts:[], matchups:[], inventory:null, kinds:[], tree:[], treeBase:'', explorer:{ mode:'collapsed', ex:new Set() }, isGit:false, query:'', tabs:[], activeTab:-1, feedbackDraft:null,
271
+ caseId:null, rec:null, proto:null, items:[], mode:'read', brief:null, readout:null, matchup:null, file:null,
272
+ comments:[], es:null, cNavIdx:-1, recFile:null,
273
+ dirty:false, saving:false, saveTimer:null, cfgPending:{}, cfgTimer:null,
274
+ diffMode:false, diffBase:null, git:{ branch:'', changed:[] } };
275
+
276
+ // ---- git: the Explorer's diff mode (uncommitted-changes filter) ------------
277
+ // When diffMode is on, the Explorer lists ONLY files with uncommitted changes and each
278
+ // opens in its Diff view by default. `diffFilterSet()` is the set of changed paths to
279
+ // filter by (null when diff mode is off or the vault isn't a git repo).
280
+ function diffFilterSet(){ return (S.diffMode && S.isGit) ? new Set((S.git.changed || []).filter(c => /\.md$/i.test(c.path)).map(c => c.path)) : null; }
281
+ // ---- comment markers + filter (a SECOND, amber channel alongside git) ------
282
+ // S.commentCounts maps a project-relative file path → its open-comment count. It's a distinct
283
+ // channel from git: git tints the NAME + shows an A/M/D letter badge; comments show an amber
284
+ // dot/count in their OWN slot (and an amber dot beside a folder name) so the two never collide.
285
+ async function loadCommentCounts(){
286
+ try {
287
+ const r = await api('GET', '/api/comments-summary');
288
+ if (r && !r.error && r.counts) {
289
+ S.commentCounts = r.counts;
290
+ TEL.gauge('project_open_comments', totalCommentCount());
291
+ }
292
+ }
293
+ catch { /* best-effort; markers just stay as they were */ }
294
+ }
295
+ function fileCommentCount(path){ return (S.commentCounts && S.commentCounts[path]) || 0; }
296
+ // Open-comment total for a FOLDER — any commented file at any depth under it.
297
+ function dirCommentCount(dirPath){
298
+ if (!dirPath || !S.commentCounts) return 0;
299
+ const prefix = dirPath + '/'; let n = 0;
300
+ for (const [p, c] of Object.entries(S.commentCounts)){ if (p.startsWith(prefix)) n += c; }
301
+ return n;
302
+ }
303
+ const totalCommentCount = () => Object.values(S.commentCounts || {}).reduce((a, n) => a + n, 0);
304
+ // The set of files (+ their ancestor folders, via prefix) with open comments — powers the
305
+ // Explorer's comments-only filter, mirroring the git diff-mode filter.
306
+ function commentFilterSet(){ return S.commentFilter ? new Set(Object.keys(S.commentCounts || {}).filter(p => S.commentCounts[p] > 0)) : null; }
307
+ // An amber comment marker for a file row: a small dot with the count. Its own slot, so it sits
308
+ // beside (never on top of) the git badge.
309
+ function commentBadge(path){
310
+ const n = fileCommentCount(path); if (!n) return '';
311
+ const tip = `${n} open comment${n > 1 ? 's' : ''}`;
312
+ return `<span class="cmtb" title="${tip}" aria-label="${tip}">${iconEl(ICON.comment)}${n > 1 ? n : ''}</span>`;
313
+ }
314
+ // A folder that contains commented files (at any depth) gets a small amber dot beside its name —
315
+ // distinct from git's folder name-tint, so both channels read at once.
316
+ function dirCommentBadge(dirPath){
317
+ const n = dirCommentCount(dirPath); if (!n) return '';
318
+ const tip = `${n} open comment${n > 1 ? 's' : ''} in this folder`;
319
+ return `<span class="cmtb dot" title="${tip}" aria-label="${tip}"></span>`;
320
+ }
321
+ function gitStatusOf(path){ const c = (S.git.changed || []).find(x => x.path === path); return c ? c.status : ''; }
322
+ // The number of changed *documents* (markdown records) — what the Explorer can show; the
323
+ // raw git set also includes machine-state files (.config) the workbench never lists.
324
+ function changedDocCount(){ return (S.git.changed || []).filter(c => /\.md$/i.test(c.path)).length; }
325
+ // A compact, legible change badge for a file row (letter + tint — never color alone):
326
+ // A(dded/new), M(odified), D(eleted). Matches the diff-add / warn / diff-del vocabulary.
327
+ function gitBadge(path){
328
+ const st = gitStatusOf(path); if (!st) return '';
329
+ const isAdd = st.includes('A') || st.includes('?');
330
+ const isDel = st.includes('D');
331
+ const cls = isAdd ? 'add' : isDel ? 'del' : 'mod';
332
+ const letter = isAdd ? 'A' : isDel ? 'D' : 'M';
333
+ const tip = isAdd ? 'Added — a new file not yet committed'
334
+ : isDel ? 'Deleted — removed since the last commit'
335
+ : 'Modified — uncommitted changes since the last commit';
336
+ return `<span class="gitb ${cls}" role="img" title="${tip}" aria-label="${tip}">${letter}</span>`;
337
+ }
338
+ // The git-status class for a FILE row colours its name alongside the letter
339
+ // badge (so files read by both colour AND letter). '' when the file has no uncommitted change.
340
+ function fileGitClass(path){
341
+ const st = gitStatusOf(path); if (!st) return '';
342
+ if (st.includes('D')) return 'gs-del';
343
+ if (st.includes('A') || st.includes('?')) return 'gs-add';
344
+ return 'gs-mod';
345
+ }
346
+ // The aggregate change status of a FOLDER: a folder containing any changed
347
+ // markdown document — at any depth — is tinted by that change rather than carrying a badge, which
348
+ // is calmer/less distracting in a dense tree. Pure additions read `add`, pure deletions `del`, and
349
+ // anything modified or mixed reads `mod`. The authoritative per-file status still shows as a letter
350
+ // badge on the files themselves, so the folder colour is a hint, never the sole signal. Returns
351
+ // `{ cls, tip }` or null when the folder holds no changed documents.
352
+ function dirGitStatus(dirPath){
353
+ if (!S.isGit || !dirPath) return null;
354
+ const prefix = dirPath + '/';
355
+ const kids = (S.git.changed || []).filter(c => /\.md$/i.test(c.path) && c.path.startsWith(prefix));
356
+ if (!kids.length) return null;
357
+ let added = false, deleted = false, modified = false;
358
+ for (const c of kids){
359
+ if (c.status.includes('D')) deleted = true;
360
+ else if (c.status.includes('A') || c.status.includes('?')) added = true;
361
+ else modified = true;
362
+ }
363
+ const n = kids.length;
364
+ const [cls, what] = (modified || (added && deleted)) ? ['mod', 'with uncommitted changes']
365
+ : added ? ['add', 'added and not yet committed']
366
+ : ['del', 'deleted'];
367
+ return { cls, tip: `${n} document${n > 1 ? 's' : ''} ${what} in this folder` };
368
+ }
369
+
370
+ // ---- a lightweight popover menu (tab actions, project switcher) ------------
371
+ function closeMenu(){ const m = $('#menu'); if (m) m.remove(); document.removeEventListener('mousedown', menuAway, true); }
372
+ function menuAway(e){ if (!e.target.closest('#menu')) closeMenu(); }
373
+ function openMenu(x, y, items, opts){
374
+ closeMenu();
375
+ const m = document.createElement('div'); m.className = 'menu'; m.id = 'menu';
376
+ // A row is a bare <button> unless it carries a `trail` (a secondary action, e.g. Remove a
377
+ // vault): then it's wrapped so the trailing button sits alongside the main one and reveals
378
+ // only on hover / keyboard focus — present when wanted, invisible at rest.
379
+ const rowMarkup = (it, i) => {
380
+ const main = `<button data-mi="${i}" ${it.on ? 'class="on"' : ''}>${it.icon ? iconEl(ICON[it.icon]) : ''}<span>${esc(it.label)}</span></button>`;
381
+ if (!it.trail) return main;
382
+ const t = it.trail;
383
+ return `<div class="menu-row">${main}<button class="menu-trail${t.danger ? ' danger' : ''}" data-mt="${i}" title="${esc(t.label)}" aria-label="${esc(t.label)}">${iconEl(ICON[t.icon])}</button></div>`;
384
+ };
385
+ m.innerHTML = items.map((it, i) => it.sep ? '<div class="sep"></div>' : rowMarkup(it, i)).join('');
386
+ // A menu anchored to a wide trigger (e.g. the project switcher) reads as broken when it's
387
+ // narrower than the control it dropped from — pin its min-width to the trigger's width so it
388
+ // lines up edge-to-edge, just like a native <select>.
389
+ if (opts && opts.minWidth) m.style.minWidth = Math.round(opts.minWidth) + 'px';
390
+ document.body.appendChild(m);
391
+ const w = m.offsetWidth || 190, h = m.offsetHeight || 40, vw = window.innerWidth, vh = window.innerHeight;
392
+ m.style.left = Math.min(x, vw - w - 8) + 'px';
393
+ m.style.top = Math.min(y, vh - h - 8) + 'px';
394
+ m.querySelectorAll('[data-mt]').forEach(b => b.addEventListener('click', (e) => { e.stopPropagation(); const it = items[+b.dataset.mt]; closeMenu(); it.trail.act && it.trail.act(); }));
395
+ m.querySelectorAll('[data-mi]').forEach(b => b.addEventListener('click', () => { const it = items[+b.dataset.mi]; closeMenu(); it.act && it.act(); }));
396
+ setTimeout(() => document.addEventListener('mousedown', menuAway, true), 0);
397
+ }
398
+
399
+ // ---- theme ----------------------------------------------------------------
400
+ function mqDark(){ try { return window.matchMedia('(prefers-color-scheme: dark)').matches; } catch { return false; } }
401
+ function themePref(){ try { return localStorage.getItem('dlb-theme') || ''; } catch { return ''; } }
402
+ function applyTheme(t){
403
+ const root = document.documentElement;
404
+ if (t) root.setAttribute('data-theme', t); else root.removeAttribute('data-theme');
405
+ try { localStorage.setItem('dlb-theme', t); } catch {}
406
+ const dark = t === 'dark' || (!t && mqDark());
407
+ const meta = $('meta[name=theme-color]'); if (meta) meta.setAttribute('content', dark ? '#0a0e14' : '#e6ecf1');
408
+ syncThemeControls();
409
+ }
410
+
411
+ // ---- sidebar --------------------------------------------------------------
412
+ function renderSidebar(){
413
+ const sb = $('#sidebar'); if (!sb) return;
414
+ sb.innerHTML = `
415
+ <div class="sb-head">
416
+ <button class="projsel" data-projsel aria-haspopup="menu" title="${S.project ? 'Switch project · ' + esc(S.project.name) : 'Switch project'}">
417
+ <span class="pname">${S.project ? esc(S.project.name) : 'No project'}</span>
418
+ ${S.isGit && S.git.branch ? `<span class="pbranch" title="Current branch">${iconEl(ICON.gitbranch)}<span class="pbn">${esc(S.git.branch)}</span></span>` : ''}
419
+ ${iconEl(ICON.chevrud).replace('class="ico"','class="ico cv"')}
420
+ </button>
421
+ </div>
422
+ <div class="search">
423
+ ${iconEl(ICON.search)}
424
+ <input type="search" id="casesearch" placeholder="Search…" autocomplete="off" spellcheck="false" value="${esc(S.query)}" aria-label="Search records">
425
+ <button class="clr ${S.query?'show':''}" id="searchclr" aria-label="Clear search">${iconEl(ICON.x)}</button>
426
+ </div>
427
+ <div class="expbar">
428
+ <span class="exptitle">${S.diffMode ? 'Changes' : 'Explorer'}</span>
429
+ <button class="expact" data-newmenu title="New file or folder" aria-label="New file or folder">${iconEl(ICON.plus)}</button>
430
+ ${S.isGit ? `<button class="expact difftoggle ${S.diffMode?'on':''}" data-diffmode aria-pressed="${S.diffMode}" title="${S.diffMode ? 'Show all files' : 'Show only files with uncommitted changes'}" aria-label="${S.diffMode ? 'Show all files' : 'Show only changed files'}">${iconEl(ICON.gitbranch)}${S.diffMode && changedDocCount() ? `<span class="cbadge">${changedDocCount()}</span>` : ''}</button>` : ''}
431
+ <button class="expact cmttoggle ${S.commentFilter?'on':''}" data-cfilter aria-pressed="${!!S.commentFilter}" title="${S.commentFilter ? 'Show all files' : 'Show only files with open comments'}" aria-label="${S.commentFilter ? 'Show all files' : 'Show only files with comments'}">${iconEl(ICON.comment)}${!S.commentFilter && totalCommentCount() ? `<span class="cbadge amber">${totalCommentCount()}</span>` : ''}</button>
432
+ <button class="collapseall" data-collapseall title="${allTopCollapsed()?'Expand all folders':'Collapse all folders'}" aria-label="${allTopCollapsed()?'Expand all folders':'Collapse all folders'}">${iconEl(allTopCollapsed()?ICON.expandAll:ICON.collapseAll)}</button>
433
+ </div>
434
+ <div class="caselist explorerlist" id="explorerlist"></div>`;
435
+ fillExplorer();
436
+ sb.querySelector('[data-projsel]').addEventListener('click', projMenu);
437
+ const si = sb.querySelector('#casesearch');
438
+ si.addEventListener('input', () => { const wasEmpty = !S.query; S.query = si.value; if (wasEmpty && S.query){ TEL.count('searches', 1); TEL.event('ui.action', { area:'explorer', action:'search-open' }); } sb.querySelector('#searchclr').classList.toggle('show', !!S.query); fillExplorer(); });
439
+ sb.querySelector('#searchclr').addEventListener('click', () => { S.query = ''; sb.querySelector('#searchclr').classList.remove('show'); const el = sb.querySelector('#casesearch'); el.value = ''; el.focus(); fillExplorer(); });
440
+ sb.querySelector('[data-collapseall]')?.addEventListener('click', () => { TEL.event('ui.action', { area: 'explorer', action: allTopCollapsed() ? 'expand-all' : 'collapse-all' }); toggleCollapseAll(); });
441
+ sb.querySelector('[data-newmenu]')?.addEventListener('click', (e) => { const r = e.currentTarget.getBoundingClientRect(); openNewMenu(S.treeBase || '', r.left, r.bottom + 4); });
442
+ sb.querySelector('[data-diffmode]')?.addEventListener('click', () => { TEL.event('ui.action', { area: 'explorer', action: 'diff' }); setDiffMode(!S.diffMode); });
443
+ sb.querySelector('[data-cfilter]')?.addEventListener('click', () => { TEL.event('ui.action', { area: 'explorer', action: 'comments' }); setCommentFilter(!S.commentFilter); });
444
+ updateTopGlobal();
445
+ }
446
+ function explorerInventory(nodes = S.tree){
447
+ let files = 0, folders = 0;
448
+ for (const node of nodes || []){
449
+ if (node.type === 'dir'){ folders++; const child = explorerInventory(node.children || []); files += child.files; folders += child.folders; }
450
+ else if (node.type === 'file') files++;
451
+ }
452
+ return { files, folders };
453
+ }
454
+ function captureProjectInventory(){
455
+ const tree = explorerInventory();
456
+ TEL.gauge('open_tabs', S.tabs.length);
457
+ TEL.gauge('explorer_files', tree.files);
458
+ TEL.gauge('explorer_folders', tree.folders);
459
+ TEL.gauge('project_cases', S.inventory?.cases ?? S.cases.length);
460
+ TEL.gauge('project_briefs', S.inventory?.briefs ?? S.briefs.length);
461
+ TEL.gauge('project_readouts', S.inventory?.readouts ?? S.readouts.length);
462
+ TEL.gauge('project_matchups', S.inventory?.matchups ?? S.matchups.length);
463
+ TEL.gauge('project_open_comments', totalCommentCount());
464
+ }
465
+ // The persistent top-bar utility cluster (global chrome, right of the per-record actions).
466
+ function updateTopGlobal(){
467
+ const ib = $('#inboxbtn'); if (ib) ib.classList.toggle('active', route().name === 'inbox');
468
+ const fb = $('#feedbackbtn'); if (fb) fb.classList.toggle('active', route().name === 'feedback');
469
+ const sb = $('#settingsbtn'); if (sb) sb.classList.toggle('active', route().name === 'settings');
470
+ }
471
+ function syncThemeControls(){
472
+ const pref = themePref();
473
+ document.querySelectorAll('[data-theme-value]').forEach((btn) => {
474
+ const selected = btn.dataset.themeValue === pref;
475
+ btn.classList.toggle('selected', selected);
476
+ btn.setAttribute('aria-checked', String(selected));
477
+ });
478
+ }
479
+ // Git diff mode: fetch the branch + the set of files with uncommitted changes, then filter
480
+ // the Explorer to only those (each opens in its Diff view). Kept fresh on toggle-on, after a
481
+ // discard, and when the agent edits a record (SSE). No-op when the vault isn't a git repo.
482
+ async function loadGit(){
483
+ if (!S.isGit){ S.git = { branch:'', changed:[] }; syncDocDiff(); return; }
484
+ let g; try { g = await api('GET', '/api/git'); } catch { return; }
485
+ if (!g || g.error) return;
486
+ S.git = { branch: g.branch || '', changed: Array.isArray(g.changed) ? g.changed : [] };
487
+ syncDocDiff(); // the file's change-state may have flipped (e.g. reverted)
488
+ }
489
+ async function setDiffMode(on){
490
+ const v = !!on && S.isGit;
491
+ if (v === S.diffMode) return;
492
+ S.diffMode = v;
493
+ if (v){ S.query = ''; S.commentFilter = false; await loadGit(); } // fresh status; the two filters are mutually exclusive
494
+ renderSidebar();
495
+ }
496
+ // The comments-only Explorer filter (mirrors the git diff-mode filter): show only files with
497
+ // open comments (and their ancestor folders). Refreshes counts on enable.
498
+ async function setCommentFilter(on){
499
+ const v = !!on;
500
+ if (v === !!S.commentFilter) return;
501
+ S.commentFilter = v;
502
+ if (v){ S.query = ''; S.diffMode = false; await loadCommentCounts(); }
503
+ renderSidebar();
504
+ }
505
+ // ---- Explorer auto-refresh (filesystem ⇄ tree) ----------------------------
506
+ // Re-pull the file tree (and git change set) and re-render the Explorer when the on-disk
507
+ // folder changes — a file added outside the app appears, a deleted one drops out. Driven by
508
+ // the server's `fs` SSE push (instant) with a slow interval as a fallback where recursive
509
+ // watch isn't available. Cheap and idempotent: it only re-renders when the tree or change
510
+ // set actually moved, and avoids a full sidebar rebuild while the search box has focus (which
511
+ // would drop the caret) — refilling just the list instead.
512
+ let treeRefreshTimer = null, treePollTimer = null, treeRefreshing = false;
513
+ async function refreshTree(){
514
+ if (!S.project || treeRefreshing) return; // reentrancy guard: never stack concurrent refreshes
515
+ treeRefreshing = true;
516
+ try {
517
+ let tr; try { tr = await api('GET', '/api/tree'); } catch { return; }
518
+ if (!tr || tr.error) return;
519
+ const nextTree = tr.tree || [];
520
+ const prevInventory = JSON.stringify(S.inventory);
521
+ if (tr.inventory) S.inventory = tr.inventory;
522
+ const prevChanged = JSON.stringify(S.git.changed || []);
523
+ // Keep the git change set fresh whenever the repo is git-tracked — the Explorer shows a
524
+ // per-file A/M/D badge in ALL views (not only diff mode), so every refresh needs current status.
525
+ if (S.isGit) await loadGit();
526
+ const treeMoved = JSON.stringify(nextTree) !== JSON.stringify(S.tree);
527
+ const inventoryMoved = JSON.stringify(S.inventory) !== prevInventory;
528
+ const gitMoved = JSON.stringify(S.git.changed || []) !== prevChanged;
529
+ if (typeof tr.base === 'string') S.treeBase = tr.base;
530
+ if (!treeMoved && !gitMoved && !inventoryMoved) return;
531
+ S.tree = nextTree;
532
+ captureProjectInventory();
533
+ const searching = document.activeElement && document.activeElement.id === 'casesearch';
534
+ // Preserve the Explorer's scroll across the refresh — renderSidebar() rebuilds the whole
535
+ // sidebar (a fresh #explorerlist at scrollTop 0), so a refresh (e.g. after a drag-move or an
536
+ // external file change) would otherwise jump the tree back to the top.
537
+ const prevScroll = $('#explorerlist')?.scrollTop || 0;
538
+ if (searching) fillExplorer(); else renderSidebar();
539
+ const nl = $('#explorerlist'); if (nl) nl.scrollTop = prevScroll;
540
+ } finally { treeRefreshing = false; }
541
+ }
542
+ function scheduleTreeRefresh(){ if (treeRefreshTimer) return; treeRefreshTimer = setTimeout(() => { treeRefreshTimer = null; refreshTree(); }, 150); }
543
+ // On a real on-disk change (fs events fire only for content — .git/.sonorance are filtered), refresh
544
+ // git status for the OPEN record so its Document⇄Diff toggle reflects reality — e.g. it DISABLES when
545
+ // the file is reverted (manually, by another tool, or `git checkout`). Coalesced; a no-op if nothing
546
+ // is open or the repo isn't git. (Kept off the idle poll so large repos aren't `git status`-ed every 4s.)
547
+ let gitSyncTimer = null;
548
+ function scheduleGitSync(){
549
+ if (gitSyncTimer || !S.isGit || !(S.file || S.caseId || S.brief || S.readout || S.matchup)) return;
550
+ gitSyncTimer = setTimeout(() => { gitSyncTimer = null; loadGit().then(() => { if (S.diffMode) renderSidebar(); }); }, 250);
551
+ gitSyncTimer.unref?.();
552
+ }
553
+ function startTreePolling(){
554
+ if (treePollTimer) return;
555
+ treePollTimer = setInterval(() => { if (document.visibilityState !== 'hidden'){ refreshTree(); reloadOpenDoc(); } }, 4000);
556
+ treePollTimer.unref?.();
557
+ }
558
+ // ---- project config: buffered periodic flush -------------------------------
559
+ // UI state that changes rapidly (Explorer collapse/expand, and any future per-project config)
560
+ // is written to disk on a timer, not on every interaction: `queueConfig` records the latest
561
+ // value in memory; `flushConfig` POSTs the accumulated patch every ~1.5s when dirty (and on
562
+ // unload / project switch). This keeps `.sonorance/config.json` writes coarse instead of one
563
+ // per keystroke-equivalent. Reusable for any project-scoped config, not just the Explorer.
564
+ function queueConfig(key, value){ S.cfgPending[key] = value; }
565
+ async function flushConfig(){
566
+ if (!Object.keys(S.cfgPending).length) return;
567
+ const patch = S.cfgPending; S.cfgPending = {};
568
+ try { await api('POST', '/api/config', { patch }); }
569
+ catch { for (const k in patch) if (!(k in S.cfgPending)) S.cfgPending[k] = patch[k]; } // re-queue on failure
570
+ }
571
+ function startConfigFlush(){
572
+ if (S.cfgTimer) return;
573
+ S.cfgTimer = setInterval(() => { flushConfig(); }, 1500);
574
+ S.cfgTimer.unref?.();
575
+ }
576
+ // Persist the current Explorer collapse/expand state (baseline mode + exception paths).
577
+ function queueExplorer(){ queueConfig('explorer', { mode: S.explorer.mode, ex: [...S.explorer.ex] }); }
578
+ // The Explorer's collapse/expand state is stored compactly as a baseline `mode`
579
+ // ('collapsed' | 'expanded') plus a set of EXCEPTION paths that differ from it — so a mostly-
580
+ // collapsed tree persists only the few expanded folders (and vice-versa). A fresh project
581
+ // defaults to fully collapsed. `folderOpen` is the single source of truth for a folder's state.
582
+ function folderOpen(path){ return S.explorer.mode === 'expanded' ? !S.explorer.ex.has(path) : S.explorer.ex.has(path); }
583
+ // True when the tree is fully collapsed (baseline collapsed, no expanded exceptions) — drives
584
+ // the collapse-all ⇄ expand-all toggle button.
585
+ function allTopCollapsed(){ return S.explorer.mode === 'collapsed' && S.explorer.ex.size === 0; }
586
+ function collapseAllFolders(){ S.explorer = { mode:'collapsed', ex:new Set() }; queueExplorer(); renderSidebar(); }
587
+ function expandAllFolders(){ S.explorer = { mode:'expanded', ex:new Set() }; queueExplorer(); renderSidebar(); }
588
+ function toggleCollapseAll(){ if (allTopCollapsed()) expandAllFolders(); else collapseAllFolders(); }
589
+ let explorerIntroShown = false;
590
+ function flourish(list, has){ if (has){ list.classList.toggle('intro', !explorerIntroShown); explorerIntroShown = true; } else list.classList.remove('intro'); }
591
+ // Fill the Explorer (kept focus-stable so typing in search never loses the caret — we only
592
+ // replace the list body, never the search input). The Explorer is a file tree only.
593
+ function fillExplorer(){ const list = $('#explorerlist'); if (!list) return; renderTree(list); }
594
+
595
+ // ---- Explorer: full Markdown file tree -----------------------------------------------
596
+ // The tree the Explorer renders. In diff mode it is S.tree AUGMENTED with DELETED files
597
+ // (git status 'D'): those no longer exist on disk, so the server's file-system walk misses
598
+ // them, but their removal is part of the change set and must still be reviewable. Each is
599
+ // grafted in as a leaf (creating any missing parent folders) that opens straight into its
600
+ // Diff (the removal). Outside diff mode the raw tree is used unchanged.
601
+ function treeForRender(){
602
+ if (!(S.diffMode && S.isGit)) return S.tree;
603
+ const present = new Set();
604
+ (function walk(ns){ for (const n of ns || []){ if (n.type === 'dir') walk(n.children); else present.add(n.path); } })(S.tree);
605
+ const deleted = (S.git.changed || [])
606
+ .filter(c => /\.md$/i.test(c.path) && c.status.includes('D') && !present.has(c.path))
607
+ .map(c => c.path);
608
+ if (!deleted.length) return S.tree;
609
+ const clone = cloneTreeNodes(S.tree);
610
+ for (const path of deleted) graftDeletedLeaf(clone, path);
611
+ sortTreeNodes(clone);
612
+ return clone;
613
+ }
614
+ function cloneTreeNodes(nodes){ return (nodes || []).map(n => n.type === 'dir' ? { ...n, children: cloneTreeNodes(n.children) } : { ...n }); }
615
+ // Insert a deleted-file leaf at `path`, creating intermediate folder nodes as needed. The
616
+ // leaf is marked `deleted` so a click opens its removal Diff (the file can't be read).
617
+ function graftDeletedLeaf(nodes, path){
618
+ const parts = path.split('/');
619
+ const name = parts.pop();
620
+ let level = nodes, prefix = '';
621
+ for (const seg of parts){
622
+ prefix = prefix ? `${prefix}/${seg}` : seg;
623
+ let dir = level.find(n => n.type === 'dir' && n.name === seg);
624
+ if (!dir){ dir = { name: seg, path: prefix, type: 'dir', children: [] }; level.push(dir); }
625
+ if (!dir.children) dir.children = [];
626
+ level = dir.children;
627
+ }
628
+ if (!level.some(n => n.type === 'file' && n.path === path))
629
+ level.push({ name, path, type: 'file', kind: 'file', id: null, deleted: true });
630
+ }
631
+ function sortTreeNodes(nodes){
632
+ nodes.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1));
633
+ for (const n of nodes) if (n.type === 'dir') sortTreeNodes(n.children || []);
634
+ }
635
+ // Renders S.tree with disclosure carets, per-depth indentation, single-child folder-path
636
+ // compaction, collapse/expand, and search that keeps a matching file's folders.
637
+ function renderTree(list){
638
+ const q = S.query.trim().toLowerCase();
639
+ const changed = diffFilterSet();
640
+ const commented = commentFilterSet();
641
+ const baseTree = treeForRender();
642
+ const prevScroll = list.scrollTop; // preserve scroll across the innerHTML swap (a refresh — e.g. after a move — must not jump to the top)
643
+ // Files must pass the diff filter, the comments filter (when on) AND the search query.
644
+ const pred = (n) => (!changed || changed.has(n.path)) && (!commented || commented.has(n.path)) && (!q || matchFileNode(n, q));
645
+ const roots = (q || changed || commented) ? pruneTree(baseTree, pred) : baseTree;
646
+ if (!roots.length){
647
+ list.classList.remove('intro');
648
+ const msg = commented ? 'No files with open comments.' : changed ? 'No files with uncommitted changes.' : (q ? 'No files match.' : 'No markdown files yet.');
649
+ list.innerHTML = `<div style="padding:8px 12px;color:var(--ink-faint);font-size:13px">${msg}</div>`;
650
+ return;
651
+ }
652
+ const html = [];
653
+ renderTreeNodes(roots, 0, !!(q || changed || commented), html);
654
+ list.innerHTML = '<div class="tree" role="tree">' + html.join('') + '</div>';
655
+ list.scrollTop = prevScroll; // restore the scroll position (clamped by the browser if the tree got shorter)
656
+ flourish(list, true);
657
+ // Folder disclosure: toggle collapse; the whole row toggles too.
658
+ list.querySelectorAll('[data-dir]').forEach(el => el.addEventListener('click', (e) => { e.preventDefault(); toggleFolder(el.dataset.dir); }));
659
+ wireExplorerLeaves(list);
660
+ wireDragDrop(list);
661
+ applyTrimTitles(list);
662
+ }
663
+ // ---- drag-and-drop move ---------------------------------------------------
664
+ // Drag a file or folder onto another folder (or the empty area = vault root) to move it there,
665
+ // subtree and all. The drop target is highlighted while dragging; an invalid drop (into itself,
666
+ // or back where it already is) simply doesn't highlight or accept.
667
+ let dragSrc = null; // { path, isDir } for the row being dragged
668
+ // The destination FOLDER for a drop landing on `el`: a folder row → that folder; a file row →
669
+ // its parent folder; anything else (empty list space) → the vault root ('').
670
+ function dropDirFor(el){
671
+ if (!el || !el.closest) return '';
672
+ const dir = el.closest('.trow.dir'); if (dir) return dir.dataset.dir;
673
+ const leaf = el.closest('.trow[data-open="file"]'); if (leaf){ const k = leaf.dataset.path || leaf.dataset.key || ''; const i = k.lastIndexOf('/'); return i >= 0 ? k.slice(0, i) : ''; }
674
+ return '';
675
+ }
676
+ function validDrop(src, destDir){
677
+ if (!src || destDir == null) return false;
678
+ const srcParent = src.path.includes('/') ? src.path.slice(0, src.path.lastIndexOf('/')) : '';
679
+ if (destDir === srcParent) return false; // already lives here → nothing to do
680
+ if (src.isDir && (destDir === src.path || destDir.startsWith(src.path + '/'))) return false; // into itself / own subtree
681
+ return true;
682
+ }
683
+ function clearDropHi(){
684
+ const list = $('#explorerlist'); if (!list) return;
685
+ list.classList.remove('droproot');
686
+ list.querySelectorAll('.trow.droptarget').forEach(r => r.classList.remove('droptarget'));
687
+ }
688
+ function highlightDrop(destDir){
689
+ const list = $('#explorerlist'); if (!list) return;
690
+ list.classList.toggle('droproot', destDir === '');
691
+ list.querySelectorAll('.trow.dir').forEach(r => r.classList.toggle('droptarget', destDir !== '' && r.dataset.dir === destDir));
692
+ }
693
+ function wireDragDrop(list){
694
+ list.querySelectorAll('.trow[draggable="true"]').forEach(row => {
695
+ const isDir = row.classList.contains('dir');
696
+ const path = isDir ? row.dataset.dir : row.dataset.path;
697
+ if (path == null) return;
698
+ row.addEventListener('dragstart', (e) => {
699
+ dragSrc = { path, isDir };
700
+ try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', path); } catch {}
701
+ row.classList.add('dragging');
702
+ });
703
+ row.addEventListener('dragend', () => { dragSrc = null; clearDropHi(); list.querySelectorAll('.dragging').forEach(r => r.classList.remove('dragging')); });
704
+ });
705
+ list.addEventListener('dragover', (e) => {
706
+ if (!dragSrc) return;
707
+ const destDir = dropDirFor(e.target);
708
+ if (!validDrop(dragSrc, destDir)){ clearDropHi(); return; }
709
+ e.preventDefault(); try { e.dataTransfer.dropEffect = 'move'; } catch {}
710
+ highlightDrop(destDir);
711
+ });
712
+ list.addEventListener('dragleave', (e) => { if (e.target === list) clearDropHi(); });
713
+ list.addEventListener('drop', (e) => {
714
+ if (!dragSrc) return;
715
+ const destDir = dropDirFor(e.target);
716
+ const src = dragSrc; dragSrc = null; clearDropHi();
717
+ if (!validDrop(src, destDir)) return;
718
+ e.preventDefault();
719
+ moveEntry(src.path, destDir);
720
+ });
721
+ }
722
+ // POST a move, then re-point any open tabs under the moved path and refresh the tree.
723
+ async function moveEntry(srcPath, destDir){
724
+ const r = await api('POST', '/api/move', { src: srcPath, dest: destDir });
725
+ if (!r || r.error){
726
+ showToast(r && r.error === 'into-self' ? "Can't move a folder into itself."
727
+ : r && r.error === 'exists' ? 'Something with that name already exists there.'
728
+ : r && r.error === 'endpoint-missing' ? `Editor is out of date — restart ${SERVE_CMD}.`
729
+ : 'Could not move.');
730
+ return;
731
+ }
732
+ TEL.event('ui.action', { area:'explorer', action:'move' });
733
+ repointFileTabs(r.from || srcPath, r.path);
734
+ if (destDir) ensureFolderOpen(destDir);
735
+ await refreshTree();
736
+ }
737
+ // Rename a file or folder in place (same parent, new basename) — the Explorer's "Rename…". The
738
+ // dialog is seeded with the current name and pre-selects the editable stem (name without the
739
+ // extension for a file), so a quick retype keeps `.md`. Reuses the move plumbing to keep any open
740
+ // tab pointed at the renamed file (or files under a renamed folder) and its URL correct.
741
+ async function renameEntry(path, isDir){
742
+ if (!path) return;
743
+ const cur = baseName(path);
744
+ const dot = cur.lastIndexOf('.');
745
+ const selectEnd = (!isDir && dot > 0) ? dot : cur.length; // select the stem (files) / whole name (folders)
746
+ const name = await promptDialog({
747
+ title: isDir ? 'Rename folder' : 'Rename file',
748
+ label: isDir ? 'Folder name' : 'File name',
749
+ value: cur, selectEnd, confirmLabel: 'Rename',
750
+ });
751
+ if (name == null || name === cur) return;
752
+ const r = await api('POST', '/api/rename', { path, name });
753
+ if (!r || r.error){
754
+ showToast(r && r.error === 'exists' ? 'Something with that name already exists here.'
755
+ : r && r.error === 'bad name' ? 'That name isn’t allowed.'
756
+ : r && r.error === 'endpoint-missing' ? `Editor is out of date — restart ${SERVE_CMD}.`
757
+ : 'Could not rename.');
758
+ return;
759
+ }
760
+ TEL.event('ui.action', { area:'explorer', action:'rename' });
761
+ repointFileTabs(r.from || path, r.path);
762
+ if (isDir) ensureFolderOpen(r.path); // keep the renamed folder revealed
763
+ await refreshTree();
764
+ }
765
+ // After a move, update open FILE tabs (and the live document) whose path was the moved item or
766
+ // lived under it, so the file stays open at its new location and the URL matches.
767
+ function repointFileTabs(fromPath, toPath){
768
+ const remap = (id) => id === fromPath ? toPath : (id.startsWith(fromPath + '/') ? toPath + id.slice(fromPath.length) : null);
769
+ let changed = false;
770
+ for (const t of S.tabs){ if (t.kind !== 'file') continue; const n = remap(t.id); if (n != null){ t.id = n; changed = true; } }
771
+ if (S.file && S.file.path){ const n = remap(S.file.path); if (n != null){ S.file.path = n; S.recFile = n; } }
772
+ if (!changed) return;
773
+ persistTabs(); renderTabs();
774
+ const at = S.tabs[S.activeTab];
775
+ if (at && at.kind === 'file'){ try { history.replaceState({}, '', PP() + tabRoute(at)); } catch {} }
776
+ }
777
+ function renderTreeNodes(nodes, depth, searching, out){
778
+ for (const n of nodes){
779
+ if (n.type === 'dir'){
780
+ // Fold a chain of single-child folders into "a/b/c".
781
+ let names = [n.name], cur = n;
782
+ while (cur.children && cur.children.length === 1 && cur.children[0].type === 'dir'){ cur = cur.children[0]; names.push(cur.name); }
783
+ const open = searching || folderOpen(cur.path);
784
+ const gs = dirGitStatus(cur.path); // colour the folder name by aggregate change, not a badge
785
+ out.push(`<div class="trow dir ${open?'open':''}${gs ? ' gs-' + gs.cls : ''}" role="treeitem" aria-expanded="${open}" draggable="true" data-dir="${esc(cur.path)}" data-full="${esc(cur.path)}"${gs ? ` data-gs-tip="${esc(gs.tip)}"` : ''} style="--depth:${depth}">
786
+ <span class="tw">${iconEl(ICON.caret)}</span><span class="lbl">${esc(names.join('/'))}</span>${rowActs({ newfile: cur.path, del: cur.path, dir: true })}<span class="gitslot">${dirCommentBadge(cur.path)}</span></div>`);
787
+ if (open) renderTreeNodes(cur.children || [], depth + 1, searching, out);
788
+ } else {
789
+ const label = fileNodeLabel(n);
790
+ const active = fileNodeActive(n);
791
+ // Tooltip: a plain file shows its path (folder context); a case/brief shows its
792
+ // full title/period (the record path — analysis.md — is not meaningful to a reader).
793
+ const tip = n.kind === 'file' ? n.path : label;
794
+ out.push(explorerLeaf(n.kind, n.kind === 'file' ? n.path : n.id, label, active, depth, 0, '', n.path, tip, gitBadge(n.path)));
795
+ }
796
+ }
797
+ }
798
+ // One clickable leaf (a document) in the file tree. `openKey` routes the click: a case/brief
799
+ // id, or a file path. `depth` sets indentation; `tip` is the full name/path surfaced as a
800
+ // hover tooltip when the label is truncated (see applyTrimTitles). `badge` is trusted inline
801
+ // HTML (a git-change badge), rendered right-aligned; `meta` is escaped text.
802
+ function explorerLeaf(kind, openKey, label, active, depth, i, meta, path, tip, badge){
803
+ const href = PP() + (kind === 'file' ? '/file/' + encFilePath(path || openKey) : '/' + kind + '/' + openKey);
804
+ // No icons (space is at a premium). A leaf leads by one caret-width (--lead) so its name
805
+ // lines up under sibling folder names.
806
+ const vars = `--depth:${depth};--lead:var(--ind-caret);`;
807
+ const gs = fileGitClass(path || openKey); // colour the name by git status for EVERY record (file/case/brief), not just plain files
808
+ const tail = (meta ? `<span class="meta">${esc(meta)}</span>` : '') + rowActs({ del: path || openKey, ntKind: kind, ntKey: openKey }) + commentBadge(path || openKey) + `<span class="gitslot">${badge || ''}</span>`;
809
+ const drag = kind === 'file' ? ' draggable="true"' : ''; // plain files are movable (records keep their id-keyed tab)
810
+ return `<a class="trow ${kind} ${gs} ${active?'active':''}" role="treeitem" style="${vars}--i:${Math.min(i||0,14)}"${drag} data-path="${esc(path || openKey)}"
811
+ href="${href}" data-open="${kind}" data-key="${esc(String(openKey))}" data-full="${esc(tip || label)}"><span class="lbl">${esc(label)}</span>${tail}</a>`;
812
+ }
813
+ // The hover-revealed row actions: "open in new tab" (documents),
814
+ // "new file here" (folders) and "delete". Buttons — not links — so a click acts, never
815
+ // navigates; each stops the row's own open/toggle handler. Kept out of the tab order (the
816
+ // context menu + header cover keyboard). "Open in new tab" is the discoverable, browser-safe
817
+ // alternative to a modifier-click (which anchors reserve for the browser's own new tab/window).
818
+ function rowActs({ newfile, del, dir, ntKind, ntKey } = {}){
819
+ const nt = ntKind != null ? `<button type="button" class="rowact" tabindex="-1" data-newtab-kind="${esc(ntKind)}" data-newtab-key="${esc(String(ntKey))}" title="Open in new tab" aria-label="Open in new tab">${iconEl(ICON.external)}</button>` : '';
820
+ const nf = newfile != null ? `<button type="button" class="rowact" tabindex="-1" data-newin="${esc(newfile)}" title="New file or folder here" aria-label="New file or folder here">${iconEl(ICON.plus)}</button>` : '';
821
+ const rm = del != null ? `<button type="button" class="rowact" tabindex="-1" data-del="${esc(del)}" data-isdir="${dir ? '1' : ''}" title="${dir ? 'Delete folder' : 'Delete file'}" aria-label="${dir ? 'Delete folder' : 'Delete file'}">${iconEl(ICON.trash)}</button>` : '';
822
+ return (nt || nf || rm) ? `<span class="rowacts">${nt}${nf}${rm}</span>` : '';
823
+ }
824
+ function wireExplorerLeaves(list){
825
+ list.querySelectorAll('[data-open]').forEach(a => {
826
+ // One predictable click: open in place (replace the active tab). "Open in new tab" is an
827
+ // explicit act — the row's hover button, or the right-click menu — never a modifier/middle
828
+ // click (in a real browser those are reserved by the anchor: new browser tab/window/download).
829
+ a.addEventListener('click', (e) => { e.preventDefault(); closeNav(); openExplorerItem(a.dataset.open, a.dataset.key, { newTab: false }); });
830
+ a.addEventListener('contextmenu', (e) => { e.preventDefault(); e.stopPropagation(); rowContextMenu(e, { kind: a.dataset.open, key: a.dataset.key, file: a.dataset.key, path: a.dataset.path }); });
831
+ });
832
+ // Row action buttons (hover-revealed) + folder context menus. Buttons stop propagation so
833
+ // they never trigger the row's open/toggle; the right-click menu is the power-user path.
834
+ wireRowActs(list);
835
+ }
836
+ // Wire the delete / new-file-here buttons and folder right-click menus for a rendered tree.
837
+ function wireRowActs(list){
838
+ list.querySelectorAll('[data-newtab-kind]').forEach(b => {
839
+ b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); closeNav(); openExplorerItem(b.dataset.newtabKind, b.dataset.newtabKey, { newTab: true }); });
840
+ });
841
+ list.querySelectorAll('[data-del]').forEach(b => {
842
+ if (b.tagName !== 'BUTTON') return;
843
+ b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); confirmDeleteEntry(b.dataset.del, b.dataset.isdir === '1'); });
844
+ });
845
+ list.querySelectorAll('[data-newin]').forEach(b => {
846
+ b.addEventListener('click', (e) => {
847
+ e.preventDefault(); e.stopPropagation();
848
+ const r = b.getBoundingClientRect();
849
+ openNewMenu(b.dataset.newin, r.left, r.bottom + 4);
850
+ });
851
+ });
852
+ list.querySelectorAll('.trow.dir[data-dir]').forEach(el => {
853
+ el.addEventListener('contextmenu', (e) => { e.preventDefault(); e.stopPropagation(); rowContextMenu(e, { dir: el.dataset.dir }); });
854
+ });
855
+ }
856
+ // The Explorer right-click menu — reliable in the browser (the same contextmenu event the tab
857
+ // strip uses). A folder offers New file/folder + Copy path + Delete; a document offers Open in a
858
+ // new tab + Copy path + Delete. "Copy relative path" yields the project-root-relative path;
859
+ // "Copy full path" prepends the vault's absolute directory.
860
+ function rowContextMenu(e, { kind, key, file, dir, path } = {}){
861
+ const rel = dir || path || file || key;
862
+ const copyItems = [
863
+ { label: 'Copy relative path', icon: 'copy', act: () => copyExplorerPath(rel, false) },
864
+ { label: 'Copy full path', icon: 'copy', act: () => copyExplorerPath(rel, true) },
865
+ ];
866
+ const items = dir
867
+ ? [{ label: 'New file here', icon: 'newfile', act: () => promptNewFile(dir) },
868
+ { label: 'New folder here', icon: 'newfolder', act: () => promptNewFolder(dir) },
869
+ { sep: true }, { label: 'Rename', icon: 'pencil', act: () => renameEntry(dir, true) }, ...copyItems, { sep: true },
870
+ { label: 'Delete folder', icon: 'trash', act: () => confirmDeleteEntry(dir, true) }]
871
+ : [{ label: 'Open in new tab', icon: 'external', act: () => openExplorerItem(kind, key, { newTab: true }) },
872
+ { sep: true }, { label: 'Rename', icon: 'pencil', act: () => renameEntry(path || file || key, false) }, ...copyItems, { sep: true },
873
+ { label: 'Delete file', icon: 'trash', act: () => confirmDeleteEntry(path || file || key, false) }];
874
+ openMenu(e.clientX, e.clientY, items);
875
+ }
876
+ // Copy a tree entry's path. `full` prepends the vault's absolute directory (the full system path);
877
+ // otherwise it's the project-root-relative path. Uses the async clipboard with a legacy fallback.
878
+ async function copyExplorerPath(rel, full){
879
+ const relPath = String(rel || '');
880
+ const base = S.project && S.project.dir ? String(S.project.dir).replace(/[\\/]+$/, '') : '';
881
+ const out = full && base ? base + '/' + relPath : relPath;
882
+ let ok = false; try { await navigator.clipboard.writeText(out); ok = true; } catch { ok = copyFallback(out); }
883
+ showToast(ok ? (full ? 'Full path copied.' : 'Relative path copied.') : 'Could not copy the path.');
884
+ }
885
+ function openExplorerItem(kind, key, opts){
886
+ if (kind === 'case') return openCaseTab(key, opts);
887
+ if (kind === 'brief') return openBriefTab(key, opts);
888
+ if (kind === 'readout') return openReadoutTab(key, opts);
889
+ if (kind === 'matchup') return openMatchupTab(key, opts);
890
+ return openFileTab(key, opts);
891
+ }
892
+ // Flip one folder's open state and record it as an exception to (or back in line with) the
893
+ // baseline mode, then persist (buffered) and re-render.
894
+ function toggleFolder(path){
895
+ const wantOpen = !folderOpen(path);
896
+ const baselineOpen = S.explorer.mode === 'expanded';
897
+ if (wantOpen === baselineOpen) S.explorer.ex.delete(path); else S.explorer.ex.add(path);
898
+ queueExplorer(); fillExplorer();
899
+ }
900
+ // Force a folder OPEN (idempotent) — used after creating/moving so the affected folder is revealed.
901
+ function ensureFolderOpen(path){
902
+ if (!path || folderOpen(path)) return;
903
+ if (S.explorer.mode === 'expanded') S.explorer.ex.delete(path); else S.explorer.ex.add(path);
904
+ queueExplorer();
905
+ }
906
+ // Native hover tooltip with the full name/path — but ONLY when the label is actually
907
+ // truncated (its rendered text is wider than the row can show). Recomputed after every
908
+ // Explorer render and whenever the sidebar width changes, so resizing wider drops the
909
+ // now-redundant tooltips and resizing narrower adds them back.
910
+ function applyTrimTitles(list){
911
+ const root = list || $('#explorerlist'); if (!root) return;
912
+ root.querySelectorAll('.trow').forEach(row => {
913
+ const lbl = row.querySelector('.lbl'); if (!lbl) return;
914
+ const truncated = lbl.scrollWidth - lbl.clientWidth > 1;
915
+ // A truncated name shows its full path on hover; otherwise a git-tinted folder shows what
916
+ // changed (so the colour is backed by a description), and everything else has no tooltip.
917
+ if (truncated) row.title = row.dataset.full || lbl.textContent;
918
+ else if (row.dataset.gsTip) row.title = row.dataset.gsTip;
919
+ else row.removeAttribute('title');
920
+ });
921
+ }
922
+ // The display label for a file node is its filename — the sidebar is a generic file tree
923
+ // with no case/brief-specific chrome (a file is distinguished by its folder). Search still
924
+ // matches the H1 title via matchFileNode.
925
+ function fileNodeLabel(n){ return n.name; }
926
+ function fileNodeActive(n){
927
+ const r = route();
928
+ if (n.kind === 'case') return r.name === 'case' && r.id === n.id;
929
+ if (n.kind === 'brief') return r.name === 'brief' && r.id === n.id;
930
+ if (n.kind === 'readout') return r.name === 'readout' && r.id === n.id;
931
+ if (n.kind === 'matchup') return r.name === 'matchup' && r.id === n.id;
932
+ return r.name === 'file' && r.id === n.path;
933
+ }
934
+ // Prune the tree to files that satisfy `pred`, keeping their ancestor folders (used for
935
+ // both search and the git diff filter). A file matches search by path (filename + folder
936
+ // names) or H1 — so searching a folder name surfaces the files under it.
937
+ function pruneTree(nodes, pred){
938
+ const out = [];
939
+ for (const n of nodes){
940
+ if (n.type === 'dir'){
941
+ const kids = pruneTree(n.children || [], pred);
942
+ if (kids.length) out.push({ ...n, children: kids });
943
+ } else if (pred(n)){
944
+ out.push(n);
945
+ }
946
+ }
947
+ return out;
948
+ }
949
+ function matchFileNode(n, q){
950
+ return `${n.path || ''} ${n.h1 || ''} ${fileNodeLabel(n)}`.toLowerCase().includes(q);
951
+ }
952
+ // ---- briefs (project-scoped landscape reports) ----------------------------
953
+ // A compact label for a brief: the window it covers (e.g. "Apr 4 – Jul 4, 2026").
954
+ function briefLabel(b){
955
+ if (b && b.period_start && b.period_end){
956
+ const fmt = (ts, y) => new Date(ts).toLocaleDateString('en-US', y ? { month:'short', day:'numeric', year:'numeric', timeZone:'UTC' } : { month:'short', day:'numeric', timeZone:'UTC' });
957
+ return `${fmt(b.period_start, false)} – ${fmt(b.period_end, true)}`;
958
+ }
959
+ return 'Brief';
960
+ }
961
+ function readoutLabel(r){ return r ? briefLabel(r).replace(/^Brief$/, 'Readout') : 'Readout'; }
962
+ // ---- matchups (project-scoped single-competitor head-to-heads) ------------
963
+ // A compact label for a matchup: the competitor it sizes us up against (e.g. "Acme Corp").
964
+ function matchupLabel(m){ return (m && m.competitor) ? m.competitor : 'Matchup'; }
965
+ // ---- project switcher (item 8) --------------------------------------------
966
+ function projMenu(e){
967
+ const btn = e.currentTarget.getBoundingClientRect();
968
+ const cur = S.project && S.project.id;
969
+ const list = S.projects.slice();
970
+ if (S.project && !list.some(p => p.id === cur)) list.unshift(S.project); // always show the current one
971
+ // A vault whose folder is gone can't be switched to — its row IS the Remove action (it's never
972
+ // hidden). A live vault switches on click; every live vault EXCEPT the current one also carries a
973
+ // hover/focus-revealed trailing Remove (you switch away before forgetting the one you're viewing).
974
+ const items = list.map(p => {
975
+ if (p.exists === false) return { label: `${p.name} — missing`, icon: 'trash', act: () => removeVault(p) };
976
+ const isCur = p.id === cur;
977
+ const item = { label: p.name, icon: isCur ? 'check' : null, on: isCur, act: () => switchProject(p.id) };
978
+ if (!isCur) item.trail = { icon: 'trash', danger: true, label: `Remove ${p.name} from the vault list`, act: () => removeVault(p) };
979
+ return item;
980
+ });
981
+ if (items.length) items.push({ sep: true });
982
+ items.push({ label: 'Open folder…', icon: 'folder', act: () => openFolderPrompt() });
983
+ openMenu(btn.left, btn.bottom + 4, items, { minWidth: btn.width });
984
+ }
985
+ // Open a folder as a vault. A browser can't pick a
986
+ // server-side folder, so the daemon serves a directory browser (folderDialog) the user drills
987
+ // through; the picked absolute path is registered + switched to.
988
+ async function openFolderPrompt(){
989
+ const start = S.project && S.project.dir ? S.project.dir.replace(/[/\\][^/\\]*[/\\]?$/, '') : null; // start near the current vault
990
+ const dir = await folderDialog(start);
991
+ if (dir == null) return;
992
+ const path = String(dir).trim(); if (!path) return;
993
+ flushSave();
994
+ await flushConfig();
995
+ await TEL.flush();
996
+ const r = await api('POST', '/api/open', { dir: path });
997
+ if (!r || r.error){
998
+ showToast(r && r.error === 'not found' ? 'That folder no longer exists.'
999
+ : r && r.error === 'unsupported' ? `This build can’t open other folders — restart ${SERVE_CMD}.`
1000
+ : 'Could not open that folder.');
1001
+ return;
1002
+ }
1003
+ S.tabs = []; S.activeTab = -1; S.query = '';
1004
+ await boot(); // the daemon already switched to the opened vault
1005
+ }
1006
+ // Forget a vault from the switcher list (never deletes anything on disk — the folder, its files,
1007
+ // and its `.sonorance/` identity/state all stay, so re-adding it later restores the same vault).
1008
+ async function removeVault(p){
1009
+ const ok = await confirmDialog({
1010
+ title: 'Remove vault?',
1011
+ body: `Remove <b>${esc(p.name)}</b> from the vault list? This only forgets it here — it never deletes the folder or anything inside it, and you can add it back anytime.`,
1012
+ confirmLabel: 'Remove',
1013
+ });
1014
+ if (!ok) return;
1015
+ await api('POST', '/api/remove', { id: p.id });
1016
+ await boot();
1017
+ }
1018
+ async function switchProject(id){
1019
+ if (S.project && id === S.project.id) return;
1020
+ flushSave(); // persist any pending edit before leaving the project
1021
+ await flushConfig(); // persist buffered config (Explorer state) for THIS project first
1022
+ await TEL.flush(); // old-project events must arrive before the server changes telemetry context
1023
+ try { await api('POST', '/api/switch', { id }); } catch {} // mark the target last-active on the server
1024
+ S.tabs = []; S.activeTab = -1; S.query = '';
1025
+ history.pushState({}, '', '/p/' + encodeURIComponent(id) + '/'); // the URL carries the project; boot reads it
1026
+ await boot(); // reloads /api/state for the new project (its own tabs/state)
1027
+ }
1028
+ // ---- tabs (item 7) ---------------------------------------------------------
1029
+ const tabRoute = (t) => t.kind === 'inbox' ? '/inbox' : t.kind === 'settings' ? '/settings' : t.kind === 'feedback' ? '/feedback' : t.kind === 'file' ? '/file/' + encFilePath(t.id) : (t.kind === 'brief' ? '/brief/' : t.kind === 'readout' ? '/readout/' : t.kind === 'matchup' ? '/matchup/' : '/case/') + t.id;
1030
+ const baseName = (p) => String(p || '').split('/').pop() || 'File';
1031
+ function tabTitle(t){
1032
+ if (t.kind === 'inbox') return 'Inbox';
1033
+ if (t.kind === 'settings') return 'Settings';
1034
+ if (t.kind === 'feedback') return 'Feedback';
1035
+ if (t.kind === 'file') return baseName(t.id);
1036
+ if (t.kind === 'brief'){ const b = S.briefs.find(x => x.id === t.id); return b ? briefLabel(b) : 'Brief'; }
1037
+ if (t.kind === 'readout'){ const r = S.readouts.find(x => x.id === t.id); return r ? readoutLabel(r) : 'Readout'; }
1038
+ if (t.kind === 'matchup'){ const m = S.matchups.find(x => x.id === t.id); return m ? matchupLabel(m) : 'Matchup'; }
1039
+ const s = S.cases.find(x => x.id === t.id); return s ? (s.title || 'Untitled') : 'Case';
1040
+ }
1041
+ function activeSpec(){ const t = S.tabs[S.activeTab]; return t ? { kind: t.kind, id: t.id || null } : null; }
1042
+ function persistTabs(){ TEL.gauge('open_tabs', S.tabs.length); api('POST', '/api/tabs', { tabs: S.tabs.map(t => ({ kind: t.kind, id: t.id || null })), active: activeSpec() }).catch(() => {}); }
1043
+ function renderTabs(){
1044
+ const bar = $('#tabs'); if (!bar) return;
1045
+ // Only fixed utility tabs carry icons. Content tabs
1046
+ // (file / case / brief) are named by their title, so an icon is just clutter in a dense strip.
1047
+ const tabIco = (t) => t.kind === 'inbox' ? iconEl(ICON.inbox) : t.kind === 'settings' ? iconEl(ICON.settings) : t.kind === 'feedback' ? iconEl(ICON.feedback) : '';
1048
+ bar.innerHTML = S.tabs.map((t, i) =>
1049
+ `<div class="tab ${i===S.activeTab?'active':''} ${t.kind==='brief'?'brief':''} ${t.kind==='readout'?'readout':''} ${t.kind==='matchup'?'matchup':''} ${t.kind==='file'?'file':''}" data-tab="${i}" id="tab-${i}"
1050
+ role="tab" aria-selected="${i===S.activeTab}" aria-controls="main" tabindex="${i===S.activeTab?0:-1}"
1051
+ title="${esc(tabTitle(t))}">
1052
+ <span class="tab-ico">${tabIco(t)}</span>
1053
+ <span class="tab-label">${esc(tabTitle(t))}</span>
1054
+ <button class="tab-close" data-close="${i}" aria-label="Close ${esc(tabTitle(t))}" title="Close" tabindex="-1">${iconEl(ICON.x)}</button>
1055
+ </div>`).join('');
1056
+ bar.querySelectorAll('.tab').forEach(el => {
1057
+ const idx = +el.dataset.tab;
1058
+ el.addEventListener('mousedown', (e) => { if (e.target.closest('.tab-close')) return; if (e.button === 1){ e.preventDefault(); closeTab(idx); return; } if (e.button === 0) activateTab(idx); });
1059
+ el.addEventListener('contextmenu', (e) => { e.preventDefault(); tabMenu(e, idx); });
1060
+ el.addEventListener('keydown', (e) => onTabKeydown(e, idx));
1061
+ el.querySelector('.tab-close').addEventListener('click', (e) => { e.stopPropagation(); closeTab(idx); });
1062
+ });
1063
+ updateTabMark();
1064
+ updateTabScroll();
1065
+ scrollActiveTabIntoView();
1066
+ }
1067
+ // ---- tab-strip overflow scrolling (chevrons, not a system scrollbar) -------
1068
+ // When more tabs are open than fit, each tab keeps its min width and the strip scrolls. The two
1069
+ // chevrons appear only then; each dims at the scroll end it points past. The active tab is kept
1070
+ // in view so keyboard/Explorer navigation never lands on an off-screen tab.
1071
+ function updateTabScroll(){
1072
+ const wrap = $('#tabs'), L = $('#tabscrollL'), R = $('#tabscrollR');
1073
+ if (!wrap || !L || !R) return;
1074
+ const over = wrap.scrollWidth - wrap.clientWidth > 1;
1075
+ L.classList.toggle('show', over); R.classList.toggle('show', over);
1076
+ if (over){
1077
+ L.disabled = wrap.scrollLeft <= 1;
1078
+ R.disabled = wrap.scrollLeft + wrap.clientWidth >= wrap.scrollWidth - 1;
1079
+ }
1080
+ }
1081
+ function scrollActiveTabIntoView(){
1082
+ const wrap = $('#tabs'); if (!wrap) return;
1083
+ if (wrap.scrollWidth - wrap.clientWidth <= 1) return; // nothing to scroll (also: no layout in headless)
1084
+ const el = document.getElementById('tab-' + S.activeTab);
1085
+ if (el && typeof el.scrollIntoView === 'function') el.scrollIntoView({ inline: 'nearest', block: 'nearest' });
1086
+ }
1087
+ function nudgeTabs(dir){
1088
+ const wrap = $('#tabs'); if (!wrap) return;
1089
+ wrap.scrollBy({ left: dir * Math.max(120, wrap.clientWidth * 0.8), behavior: 'smooth' });
1090
+ }
1091
+ // The semi-translucent brand wordmark, centred in the tab bar. It names the injected product
1092
+ // (Sonorance standalone, Deliberate when hosted) and is HIDDEN on the empty "home" surface —
1093
+ // there the branded hero canvas carries the name instead, so brand presence is never doubled.
1094
+ function updateTabMark(){
1095
+ const m = $('#tabmark'); if (!m) return;
1096
+ m.textContent = BRAND;
1097
+ // Hidden whenever a branded hero is on screen (the empty Inbox home) — mutual exclusion, so
1098
+ // the product name is never shown twice at once.
1099
+ m.classList.toggle('hide', !!document.querySelector('#main .hero'));
1100
+ measureTabMark();
1101
+ }
1102
+ // Keep the centred wordmark clear of its neighbours: hide it whenever a tab / the + button
1103
+ // (on the left) or the page controls (on the right) come within a margin of it — so it recedes
1104
+ // cleanly as tabs fill rather than crowding or overlapping. Re-measured on tab changes and on
1105
+ // resize. Where there is no layout (headless tests), it stays shown.
1106
+ function measureTabMark(){
1107
+ const m = $('#tabmark'); if (!m || m.classList.contains('hide')) return;
1108
+ const bar = $('#tabbar'); if (!bar) return;
1109
+ m.classList.remove('crowded'); // reveal to measure its true geometry
1110
+ const barR = bar.getBoundingClientRect();
1111
+ if (!barR.width) return; // no layout (headless) → leave it shown
1112
+ const markR = m.getBoundingClientRect();
1113
+ const add = $('#tabadd');
1114
+ const acts = $('#topactions'), glob = $('#topglobal');
1115
+ const leftEdge = add ? add.getBoundingClientRect().right : barR.left;
1116
+ const rc = (acts && acts.getBoundingClientRect().width) ? acts : glob;
1117
+ const rightEdge = rc ? rc.getBoundingClientRect().left : barR.right;
1118
+ const PAD = 24;
1119
+ const crowded = (markR.left - leftEdge < PAD) || (rightEdge - markR.right < PAD);
1120
+ m.classList.toggle('crowded', crowded);
1121
+ }
1122
+ // Keyboard operation of the tab strip (WAI-ARIA tabs pattern): arrows/Home/End move
1123
+ // focus and activate, Enter/Space activate, Delete/Backspace closes the focused tab.
1124
+ function onTabKeydown(e, idx){
1125
+ const n = S.tabs.length; if (!n) return;
1126
+ const go = (j) => { const k = ((j % n) + n) % n; activateTab(k); focusTab(k); };
1127
+ switch (e.key){
1128
+ case 'ArrowRight': case 'ArrowDown': e.preventDefault(); go(idx + 1); break;
1129
+ case 'ArrowLeft': case 'ArrowUp': e.preventDefault(); go(idx - 1); break;
1130
+ case 'Home': e.preventDefault(); go(0); break;
1131
+ case 'End': e.preventDefault(); go(n - 1); break;
1132
+ case 'Enter': case ' ': e.preventDefault(); activateTab(idx); break;
1133
+ case 'Delete': case 'Backspace': {
1134
+ e.preventDefault();
1135
+ closeTab(idx);
1136
+ if (S.tabs.length) focusTab(Math.min(idx, S.tabs.length - 1));
1137
+ else $('#tabadd')?.focus();
1138
+ break;
1139
+ }
1140
+ }
1141
+ }
1142
+ // Focus a tab by index after any (async) re-render settles.
1143
+ function focusTab(idx){ setTimeout(() => { const el = $('#tab-' + idx); if (el) el.focus(); }, 0); }
1144
+ function tabMenu(e, idx){
1145
+ openMenu(e.clientX, e.clientY, [
1146
+ { label: 'Close', icon: 'x', act: () => closeTab(idx) },
1147
+ { label: 'Close others', act: () => closeOthers(idx) },
1148
+ { label: 'Close all', act: () => closeAll() },
1149
+ ]);
1150
+ }
1151
+ // Open a document. Default: replace the ACTIVE tab in place — one predictable
1152
+ // click, no tab explosion from browsing. `newTab:true` opens (and focuses) a NEW tab. A doc that
1153
+ // is already open is simply focused (no duplicate). The Inbox home is never clobbered — opening a
1154
+ // doc while it's active makes a new tab. (A future `pinned` tab is likewise never replaced.)
1155
+ function openDoc(kind, id, { newTab = false } = {}){
1156
+ const same = S.tabs.findIndex(t => t.kind === kind && t.id === id);
1157
+ if (same >= 0) return activateTab(same); // already open → just focus it
1158
+ // Content-free open signal: a semantic record is an event {kind}; a raw file is an aggregate.
1159
+ if (kind === 'file') TEL.count('files_opened', 1);
1160
+ else if (kind === 'case' || kind === 'brief' || kind === 'readout' || kind === 'matchup') TEL.event('record.opened', { kind });
1161
+ const active = S.tabs[S.activeTab];
1162
+ const replace = !newTab && active && !['inbox', 'settings', 'feedback'].includes(active.kind) && !active.pinned;
1163
+ const spec = { kind, id };
1164
+ if (replace) S.tabs[S.activeTab] = spec;
1165
+ else { S.tabs.push(spec); S.activeTab = S.tabs.length - 1; }
1166
+ persistTabs(); navigate(docRoute(kind, id));
1167
+ }
1168
+ function docRoute(kind, id){ return kind === 'inbox' ? '/inbox' : kind === 'settings' ? '/settings' : kind === 'feedback' ? '/feedback' : kind === 'file' ? '/file/' + encFilePath(id) : '/' + kind + '/' + id; }
1169
+ function openCaseTab(id, opts){ return openDoc('case', id, opts || {}); }
1170
+ function openInboxTab(){ if (S.tabs.findIndex(t => t.kind === 'inbox') < 0){ S.tabs.push({ kind:'inbox' }); persistTabs(); } navigate('/inbox'); }
1171
+ function openSettingsTab(){ if (S.tabs.findIndex(t => t.kind === 'settings') < 0){ S.tabs.push({ kind:'settings' }); persistTabs(); } navigate('/settings'); }
1172
+ function openFeedbackTab(){ if (S.tabs.findIndex(t => t.kind === 'feedback') < 0){ S.tabs.push({ kind:'feedback' }); persistTabs(); } TEL.event('ui.action', { area:'toolbar', action:'feedback-open' }); navigate('/feedback'); }
1173
+ function openBriefTab(id, opts){ return openDoc('brief', id, opts || {}); }
1174
+ function openReadoutTab(id, opts){ return openDoc('readout', id, opts || {}); }
1175
+ function openMatchupTab(id, opts){ return openDoc('matchup', id, opts || {}); }
1176
+ // A plain markdown file (context/*.md and any other .md not backed by a case/brief),
1177
+ // opened from the Explorer's file-tree view. `path` is the project-relative path.
1178
+ function openFileTab(path, opts){ return openDoc('file', path, opts || {}); }
1179
+ function activateTab(idx){ const t = S.tabs[idx]; if (!t) return; S.activeTab = idx; navigate(tabRoute(t)); }
1180
+ function closeTab(idx){
1181
+ const wasActive = idx === S.activeTab;
1182
+ S.tabs.splice(idx, 1);
1183
+ if (idx < S.activeTab) S.activeTab--;
1184
+ if (S.activeTab >= S.tabs.length) S.activeTab = S.tabs.length - 1;
1185
+ persistTabs();
1186
+ if (!S.tabs.length){ S.activeTab = -1; return navigate('/'); }
1187
+ if (wasActive) navigate(tabRoute(S.tabs[S.activeTab])); else renderTabs();
1188
+ }
1189
+ function closeOthers(idx){ const keep = S.tabs[idx]; S.tabs = [keep]; S.activeTab = 0; persistTabs(); navigate(tabRoute(keep)); }
1190
+ function closeAll(){ S.tabs = []; S.activeTab = -1; persistTabs(); navigate('/'); }
1191
+ // Keep the tab bar in sync with the current route: focus the matching tab, else open one.
1192
+ // (Explicit opens go through openDoc; this covers intent-less route changes — deep links,
1193
+ // browser back/forward — where a new tab is the safe, non-destructive default.)
1194
+ function syncTabsToRoute(){
1195
+ const r = route();
1196
+ const spec = r.name === 'inbox' ? { kind:'inbox' } : r.name === 'settings' ? { kind:'settings' } : r.name === 'feedback' ? { kind:'feedback' } : r.name === 'case' ? { kind:'case', id:r.id } : r.name === 'brief' ? { kind:'brief', id:r.id } : r.name === 'readout' ? { kind:'readout', id:r.id } : r.name === 'matchup' ? { kind:'matchup', id:r.id } : r.name === 'file' ? { kind:'file', id:r.id } : null;
1197
+ if (!spec){ if (S.activeTab !== -1){ S.activeTab = -1; persistTabs(); } renderTabs(); return; }
1198
+ const prevActive = S.activeTab;
1199
+ let idx = S.tabs.findIndex(t => t.kind === spec.kind && (['inbox', 'settings', 'feedback'].includes(spec.kind) || t.id === spec.id));
1200
+ if (idx < 0){
1201
+ S.tabs.push(spec); idx = S.tabs.length - 1;
1202
+ S.activeTab = idx; persistTabs();
1203
+ } else {
1204
+ S.activeTab = idx;
1205
+ if (idx !== prevActive) persistTabs(); // remember which tab is focused, per project
1206
+ }
1207
+ renderTabs();
1208
+ }
1209
+
1210
+ // ---- routing --------------------------------------------------------------
1211
+ // The project slug prefix for in-app URLs (`/p/<slug>`). Every route lives under it so
1212
+ // multiple projects can be open in separate browser tabs at once. Empty until a project loads.
1213
+ function PP(){ const s = S.projectSlug || (S.project && S.project.id); return s ? '/p/' + encodeURIComponent(s) : ''; }
1214
+ // File paths keep their slashes in the URL (`/file/a/b/c.md`) — far prettier than one
1215
+ // percent-encoded blob (`/file/a%2Fb%2Fc.md`) — but each SEGMENT is still encoded so a space,
1216
+ // `#`, `?` or `%` in a name never breaks the path. `decFilePath` is the exact inverse.
1217
+ const encFilePath = (path) => String(path || '').split('/').map(encodeURIComponent).join('/');
1218
+ const decFilePath = (seg) => String(seg || '').split('/').map(s => { try { return decodeURIComponent(s); } catch { return s; } }).join('/');
1219
+ function route(){
1220
+ let p = location.pathname;
1221
+ let project = null;
1222
+ const m = p.match(/^\/p\/([^/]+)(\/.*)?$/); // /p/<slug>/<inner> → strip the project prefix
1223
+ if (m){ project = decodeURIComponent(m[1]); p = m[2] || '/'; }
1224
+ let r;
1225
+ if (p.startsWith('/case/')) r = { name:'case', id: decodeURIComponent(p.slice(6)) };
1226
+ else if (p.startsWith('/brief/')) r = { name:'brief', id: decodeURIComponent(p.slice(7)) };
1227
+ else if (p.startsWith('/readout/')) r = { name:'readout', id: decodeURIComponent(p.slice(9)) };
1228
+ else if (p.startsWith('/matchup/')) r = { name:'matchup', id: decodeURIComponent(p.slice(9)) };
1229
+ else if (p.startsWith('/file/')) r = { name:'file', id: decFilePath(p.slice(6)) };
1230
+ else if (p === '/inbox') r = { name:'inbox' };
1231
+ else if (p === '/settings') r = { name:'settings' };
1232
+ else if (p === '/feedback') r = { name:'feedback' };
1233
+ else r = { name:'home' };
1234
+ r.project = project;
1235
+ return r;
1236
+ }
1237
+ // Route builders return the INNER path (`/inbox`, `/case/x`); navigate() prepends the current
1238
+ // project prefix so callers never have to thread it through.
1239
+ function navigate(path){ const full = path.startsWith('/p/') ? path : (PP() + path); if (location.pathname !== full) history.pushState({}, '', full); render(); }
1240
+ // Back/forward: within a project, re-render; across projects (the URL slug changed), re-boot
1241
+ // so the other project's tabs/tree/state load. Guards against a no-op when nothing changed.
1242
+ window.addEventListener('popstate', () => {
1243
+ const rp = route().project;
1244
+ if (rp && S.project && rp !== S.project.id){ S.projectSlug = rp; S.tabs = []; S.activeTab = -1; S.query = ''; boot(); }
1245
+ else render();
1246
+ });
1247
+
1248
+ let lastTelemetryPage = null;
1249
+ async function render(){
1250
+ const r = route();
1251
+ if (r.name === 'home'){
1252
+ // No standalone empty page: home always resolves to the Inbox (opening its tab if needed).
1253
+ return openInboxTab();
1254
+ }
1255
+ const telemetryPage = ['inbox', 'settings', 'feedback'].includes(r.name) ? r.name : null;
1256
+ if (telemetryPage !== lastTelemetryPage){
1257
+ lastTelemetryPage = telemetryPage;
1258
+ if (telemetryPage) TEL.event('ui.page.viewed', { page:telemetryPage });
1259
+ }
1260
+ renderSidebar();
1261
+ syncTabsToRoute();
1262
+ if (r.name === 'inbox') return renderInbox();
1263
+ if (r.name === 'settings') return renderSettings();
1264
+ if (r.name === 'feedback') return renderFeedback();
1265
+ if (r.name === 'brief') return openBrief(r.id);
1266
+ if (r.name === 'readout') return openReadout(r.id);
1267
+ if (r.name === 'matchup') return openMatchup(r.id);
1268
+ if (r.name === 'file') return openFile(r.id);
1269
+ if (r.name === 'case') return openCase(r.id);
1270
+ }
1271
+
1272
+ // ---- inbox: a recent-activity overview ------------------------------------
1273
+ // With no manual case states, the inbox is a calm recency view: the most recently
1274
+ // updated cases and the latest briefs — the two things a returning user wants to resume.
1275
+ const caseTs = (s) => s.last_stage_at || s.updated_at || s.created_at || 0;
1276
+ // A short, human status for a case row — derived from its funnel position (no manual states).
1277
+ // Note: 'done' means the analysis reached launch — NOT that a prototype exists. The prototype is
1278
+ // a recomputable companion detected by file presence (S.proto.exists), never a case state.
1279
+ function caseStateLabel(s){
1280
+ switch (s.state) {
1281
+ case 'done': return 'Analysis complete';
1282
+ case 'error': return 'A stage needs a re-run';
1283
+ case 'new': return 'Draft — not yet analysed';
1284
+ default: return 'Being generated…'; // score / shape / launch — mid-funnel
1285
+ }
1286
+ }
1287
+ // The inbox is the ONE Deliberate-aware surface, and it is driven by the declarative kind
1288
+ // registry (S.kinds from /api/state): each kind with an `inbox` block contributes a section,
1289
+ // pulling recent items from its `source` collection and rendering them with a small generic
1290
+ // widget vocabulary (badge: 'score'|'brief', subtitle: a field | 'const:<text>'). Adding a
1291
+ // document type = adding a registry entry; no bespoke inbox code.
1292
+ function inboxTs(x){ return x.last_stage_at || x.updated_at || x.created_at || 0; }
1293
+ function inboxLabel(source, x){ return source === 'briefs' ? briefLabel(x) : source === 'readouts' ? readoutLabel(x) : source === 'matchups' ? matchupLabel(x) : (x.title || 'Untitled'); }
1294
+ function inboxSubtitle(spec, x){
1295
+ if (!spec) return '';
1296
+ if (spec.startsWith('const:')) return esc(spec.slice(6));
1297
+ if (spec === 'state') return esc(caseStateLabel(x));
1298
+ return esc(String(x[spec] ?? ''));
1299
+ }
1300
+ function inboxBadge(kind, x){
1301
+ if (kind === 'score'){
1302
+ const v = x.score != null ? verdict(x.score) : null;
1303
+ return v
1304
+ ? `<span class="pill ${v.cls}" title="${v.word} · ${v.label}" aria-label="Verdict ${v.word}, score ${v.label}">${v.label}</span>`
1305
+ : `<span class="pill neutral" title="Not scored yet" aria-label="Not scored yet">—</span>`;
1306
+ }
1307
+ if (kind === 'brief') return `<span class="pill brief" title="Landscape brief" aria-label="Landscape brief">${iconEl(ICON.brief)}</span>`;
1308
+ if (kind === 'readout') return `<span class="pill readout" title="Product readout" aria-label="Product readout">${iconEl(ICON.readout)}</span>`;
1309
+ if (kind === 'matchup') return `<span class="pill matchup" title="Competitor matchup" aria-label="Competitor matchup">${iconEl(ICON.matchup)}</span>`;
1310
+ return '';
1311
+ }
1312
+ function renderSettings(){
1313
+ setTopActions('');
1314
+ const pref = themePref();
1315
+ const themes = [
1316
+ { value:'', label:'System', icon:'auto', note:'Follow this device' },
1317
+ { value:'light', label:'Light', icon:'sun', note:'Always light' },
1318
+ { value:'dark', label:'Dark', icon:'moon', note:'Always dark' },
1319
+ ];
1320
+ renderMain(`<div class="settings-wrap"><section class="settings-page" id="settings-page">
1321
+ <header class="settings-head"><h1>Settings</h1><p>Preferences apply across all projects on this device.</p></header>
1322
+ <section class="settings-section" aria-labelledby="appearance-title">
1323
+ <div class="settings-copy"><h2 id="appearance-title">Appearance</h2><p>Choose how Sonorance looks.</p></div>
1324
+ <div class="theme-options" role="radiogroup" aria-label="Theme">
1325
+ ${themes.map((t) => `<button type="button" class="theme-option ${pref === t.value ? 'selected' : ''}" role="radio" aria-checked="${pref === t.value}" tabindex="${pref === t.value ? 0 : -1}" data-theme-value="${t.value}">
1326
+ <span class="theme-icon">${iconEl(ICON[t.icon])}</span><span><strong>${t.label}</strong><small>${t.note}</small></span>
1327
+ </button>`).join('')}
1328
+ </div>
1329
+ </section>
1330
+ <section class="settings-section" aria-labelledby="privacy-title">
1331
+ <div class="settings-copy"><h2 id="privacy-title">Privacy</h2><p>Privacy is foundational to Sonorance. We collect only a small amount of anonymous, content-free data—feature usage, performance, and redacted error signals—so we can understand what helps, fix issues, and keep improving the product.</p></div>
1332
+ <label class="settings-toggle">
1333
+ <span><strong>Share anonymous usage data</strong><small>Sonorance never logs your work—not even project, file, or folder names. Document contents, paths, prompts, and completions always stay private.</small></span>
1334
+ <input id="telemetry-setting" type="checkbox" ${S.telemetry && S.telemetry.enabled === false ? '' : 'checked'}>
1335
+ <i aria-hidden="true"></i>
1336
+ </label>
1337
+ </section>
1338
+ </section></div>`);
1339
+ const themeButtons = [...document.querySelectorAll('[data-theme-value]')];
1340
+ themeButtons.forEach((btn, index) => btn.addEventListener('click', () => {
1341
+ const value = btn.dataset.themeValue;
1342
+ applyTheme(value);
1343
+ themeButtons.forEach((option) => { option.tabIndex = option === btn ? 0 : -1; });
1344
+ TEL.event('ui.action', { area:'settings', action:'theme-change', value:value || 'system' });
1345
+ }));
1346
+ themeButtons.forEach((btn, index) => btn.addEventListener('keydown', (event) => {
1347
+ let next = null;
1348
+ if (event.key === 'ArrowRight' || event.key === 'ArrowDown') next = (index + 1) % themeButtons.length;
1349
+ else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') next = (index - 1 + themeButtons.length) % themeButtons.length;
1350
+ else if (event.key === 'Home') next = 0;
1351
+ else if (event.key === 'End') next = themeButtons.length - 1;
1352
+ if (next == null) return;
1353
+ event.preventDefault();
1354
+ themeButtons[next].click();
1355
+ themeButtons[next].focus();
1356
+ }));
1357
+ const telemetry = $('#telemetry-setting');
1358
+ telemetry.addEventListener('change', async () => {
1359
+ const requested = telemetry.checked;
1360
+ telemetry.disabled = true;
1361
+ const enabled = await setTelemetryConsent(requested);
1362
+ telemetry.checked = enabled;
1363
+ telemetry.disabled = false;
1364
+ if (enabled !== requested) toast('Could not update telemetry preference.');
1365
+ });
1366
+ }
1367
+ function renderInbox(){
1368
+ setTopActions('');
1369
+ const hasKinds = (S.kinds || []).some(k => k.inbox);
1370
+ const sections = (S.kinds || []).filter(k => k.inbox).map(k => ({
1371
+ spec: k.inbox,
1372
+ items: [...(S[k.inbox.source] || [])].sort((a, b) => inboxTs(b) - inboxTs(a)).slice(0, k.inbox.limit || 5),
1373
+ }));
1374
+ const total = sections.reduce((n, s) => n + s.items.length, 0);
1375
+ if (!total){
1376
+ // First run should offer an explicit next step, not just a hint. The in-app action everywhere
1377
+ // is "New Markdown file"; on a Deliberate host the Case is created in the coding agent, so its
1378
+ // command is offered as a one-click COPY beside the button (the real path), never the only one.
1379
+ const newFileBtn = `<button type="button" class="tbtn" data-hero-newfile>${iconEl(ICON.newfile)}New Markdown file</button>`;
1380
+ const caseCopy = hasKinds
1381
+ ? `<button type="button" class="tbtn ghost" data-hero-copy='/deliberate case "<idea>"' title="Copy the command to start a Case in your coding agent">${iconEl(ICON.copy)}Copy <code>/deliberate case</code></button>`
1382
+ : '';
1383
+ const out = renderMain(brandedEmpty(
1384
+ hasKinds ? 'Start a Case, or draft a note.' : 'A calm home for your Markdown.',
1385
+ hasKinds
1386
+ ? 'A Case runs the frame → score → shape pipeline in your coding agent. Or draft any Markdown here.'
1387
+ : 'Create a note below, or open an existing file from the Explorer.',
1388
+ `${newFileBtn}${caseCopy}`));
1389
+ const nf = $('#main [data-hero-newfile]'); if (nf) nf.addEventListener('click', () => promptNewFile(S.treeBase || ''));
1390
+ const cc = $('#main [data-hero-copy]');
1391
+ if (cc) cc.addEventListener('click', async () => {
1392
+ try { await navigator.clipboard.writeText(cc.dataset.heroCopy); showToast('Command copied to clipboard.'); }
1393
+ catch { showToast('Copy failed — the command is: ' + cc.dataset.heroCopy); }
1394
+ });
1395
+ return out;
1396
+ }
1397
+
1398
+ const row = (spec, x) => {
1399
+ const openKind = spec.source === 'briefs' ? 'brief' : spec.source === 'readouts' ? 'readout' : spec.source === 'matchups' ? 'matchup' : 'case';
1400
+ return `<button class="qrow" data-open-kind="${openKind}" data-open-id="${x.id}">
1401
+ ${inboxBadge(spec.badge, x)}
1402
+ <span class="qmain"><span class="qt">${esc(inboxLabel(spec.source, x))}</span><span class="qr">${inboxSubtitle(spec.subtitle, x)}</span></span>
1403
+ <span class="qtime">${esc(relTime(inboxTs(x)))}</span>
1404
+ ${iconEl(ICON.chevron).replace('class="ico"','class="ico go"')}
1405
+ </button>`;
1406
+ };
1407
+ const section = (s) => s.items.length ? `<h2 class="qsec">${esc(s.spec.section)}</h2>${s.items.map(x => row(s.spec, x)).join('')}` : '';
1408
+ renderMain(
1409
+ `<div class="qwrap"><div class="queue">
1410
+ <h1>Inbox</h1><p class="sub">Your most recently updated cases and briefs.</p>
1411
+ ${sections.map(section).join('')}
1412
+ </div></div>`);
1413
+ $('#main').querySelectorAll('[data-open-kind]').forEach(b => b.addEventListener('click', () => {
1414
+ if (b.dataset.openKind === 'brief') openBriefTab(b.dataset.openId); else if (b.dataset.openKind === 'readout') openReadoutTab(b.dataset.openId); else if (b.dataset.openKind === 'matchup') openMatchupTab(b.dataset.openId); else openCaseTab(b.dataset.openId);
1415
+ }));
1416
+ }
1417
+
1418
+ // ---- case detail ----------------------------------------------------------
1419
+ // The case currently open (from the state list), or null.
1420
+ function currentCase(){ return S.cases.find(s => s.id === S.caseId) || null; }
1421
+ // A case is "being generated" while its analysis is mid-funnel — frame is done but the
1422
+ // agent is still appending stages (state is a pre-gate stage: score / shape / launch). Its
1423
+ // record is then read-only in the workbench so a hand-edit can't collide with the write in
1424
+ // flight. A brand-new draft ('new') and a complete case ('done') edit freely.
1425
+ const GENERATING_STATES = new Set(['score', 'shape', 'launch']);
1426
+ function caseGenerating(){ const c = currentCase(); return !!(c && GENERATING_STATES.has(c.state)); }
1427
+ // Any record surface that must not be edited in place: a brief, a plain file, or a
1428
+ // still-generating case. Gates the live-editor caret/source reveal + autosave.
1429
+ function docReadOnly(){ return !!(S.brief || S.readout || S.matchup || caseGenerating()); }
1430
+ async function openCase(id){
1431
+ flushSave(); // persist the outgoing case's pending edit before we switch
1432
+ S.caseId = id; S.dirty = false; S.saving = false; S.proto = null; S.comments = []; S.cNavIdx = -1; S.recFile = null; S.brief = null; S.readout = null; S.matchup = null; S.file = null;
1433
+ renderSidebar();
1434
+ let rec;
1435
+ try { rec = await api('GET', '/api/record?id=' + encodeURIComponent(id)); } catch { rec = { error:'offline' }; }
1436
+ if (!rec || rec.error){ setTopActions(''); return renderMain(emptyState('Case not found', 'This case has no record here.')); }
1437
+ S.rec = rec; S.isGit = !!rec.isGit; S.proto = rec.proto || null; S.recFile = rec.file || null;
1438
+ // Always segment the real file text so the record's frontmatter (id/state/gate/…) and
1439
+ // title round-trip byte-for-byte — even a not-yet-analysed case IS an analysis.md now, so
1440
+ // caseing an empty block would clobber that metadata on the first autosave.
1441
+ S.items = rec.text ? segment(rec.text) : [{ kind:'block', lines:[''] }];
1442
+ // One combined view: the record reads as rendered Markdown and reveals a block's
1443
+ // raw source in place when the caret enters it. Diff is the
1444
+ // only other surface (git only).
1445
+ S.mode = defaultRecordMode();
1446
+ paintCase();
1447
+ loadComments(rec.file);
1448
+ }
1449
+ // Load a file's persisted in-record comments (annotations the reader left, anchored to
1450
+ // spans of the file). Works for any file — a case record or a brief. Only OPEN ones show.
1451
+ async function loadComments(file){
1452
+ if (!file) return;
1453
+ const t = await api('GET', '/api/comments?file=' + encodeURIComponent(file));
1454
+ if (S.recFile !== file) return; // the user moved on while we fetched
1455
+ if (t && t.error === 'endpoint-missing'){ maybeStale(); return; } // stale server — warn
1456
+ if (!t || t.error) return;
1457
+ S.comments = t.comments || [];
1458
+ renderComments();
1459
+ }
1460
+
1461
+ // ---- brief detail (read-only landscape report) ----------------------------
1462
+ // A brief is a project-scoped landscape report, not a case record: it reads like one
1463
+ // (rendered Markdown + a table-of-contents rail) but has no verdict, prototype, diff,
1464
+ // or editing — it's regenerated in the coding agent, and the file is the user's to edit.
1465
+ const stripFrontmatter = (text) => { const m = String(text || '').match(/^---\n[\s\S]*?\n---\n?/); return m ? text.slice(m[0].length) : text; };
1466
+ async function openBrief(id){
1467
+ flushSave(); // persist any outgoing case edit before switching
1468
+ S.caseId = null; S.brief = null; S.readout = null; S.matchup = null; S.file = null; S.mode = 'doc'; S.comments = []; S.cNavIdx = -1; S.recFile = null;
1469
+ renderSidebar();
1470
+ let rec;
1471
+ try { rec = await api('GET', '/api/brief?id=' + encodeURIComponent(id)); } catch { rec = { error:'offline' }; }
1472
+ const r = route(); if (r.name === 'brief' && r.id !== id) return; // the user moved on while we fetched
1473
+ if (!rec || rec.error){ setTopActions(''); return renderMain(emptyState('Brief not found', 'This brief has no record here.')); }
1474
+ S.brief = { id, ...rec }; S.recFile = rec.file || null; S.isGit = !!rec.isGit; S.mode = defaultRecordMode();
1475
+ paintBrief();
1476
+ loadComments(rec.file);
1477
+ }
1478
+ // ---- generic markdown file (editable live-preview) ------------------------
1479
+ // Any `.md` opened from the Explorer's file tree. It edits like a case record — the same
1480
+ // live-preview surface + autosave — but keyed by PATH (saved via POST /api/file), with its
1481
+ // YAML frontmatter preserved as a hidden block. Standalone, EVERY document is one of these.
1482
+ async function openFile(path){
1483
+ flushSave();
1484
+ S.caseId = null; S.brief = null; S.readout = null; S.matchup = null; S.file = null; S.mode = 'doc'; S.comments = []; S.cNavIdx = -1; S.recFile = null;
1485
+ S.dirty = false; S.saving = false;
1486
+ renderSidebar();
1487
+ let rec;
1488
+ try { rec = await api('GET', '/api/file?path=' + encodeURIComponent(path)); } catch { rec = { error:'offline' }; }
1489
+ const r = route(); if (r.name === 'file' && r.id !== path) return; // moved on while fetching
1490
+ if (!rec || rec.error){
1491
+ // A deleted file (present in the git change set as removed) no longer exists on disk, so it
1492
+ // can't be read/edited — but its removal is still reviewable. Open it straight into a
1493
+ // read-only Diff instead of a dead "not found" (reached from the Explorer's diff mode).
1494
+ if (isDeletedChange(path)){
1495
+ S.file = { path, text: '', deleted: true }; S.recFile = path; S.isGit = true; S.mode = 'diff';
1496
+ S.items = [{ kind:'block', lines:[''] }];
1497
+ paintFile();
1498
+ return;
1499
+ }
1500
+ setTopActions(''); return renderMain(emptyState('File not found', 'This file isn’t in the folder.'));
1501
+ }
1502
+ S.file = { path, ...rec }; S.recFile = rec.path || null; S.isGit = !!rec.isGit; S.mode = defaultRecordMode();
1503
+ S.items = rec.text ? segment(rec.text) : [{ kind:'block', lines:[''] }];
1504
+ paintFile();
1505
+ loadComments(rec.path);
1506
+ }
1507
+ function fileControls(){ return `${commentNav()}`; }
1508
+ function paintFile(){
1509
+ if (S.file && S.file.deleted) S.mode = 'diff'; // a removed file is diff-only (nothing to edit)
1510
+ renderMain(recordBodyMarkup());
1511
+ setTopActions(fileControls());
1512
+ wireCaseControls();
1513
+ renderLive(); buildToc(); renderComments();
1514
+ if (S.mode === 'diff') loadDiffBase().then(refreshDiffDecorations);
1515
+ updateSaveDot();
1516
+ }
1517
+ function briefControls(){
1518
+ // A brief needs no tab-bar badge: it's read-only and its first paragraph already names the
1519
+ // period it covers, so a "Brief · <dates>" pill would just be redundant chrome. The comment
1520
+ // navigator lives in the canvas command bar (#docdiff), so there are no tab-bar controls.
1521
+ return '';
1522
+ }
1523
+ function paintBrief(){
1524
+ renderMain(recordBodyMarkup());
1525
+ setTopActions(briefControls());
1526
+ wireCaseControls(); // wires the comments prev/next navigator (no-ops for absent controls)
1527
+ const c = $('#canvas'); if (!c) return;
1528
+ const art = document.createElement('article'); art.className = 'doc'; art.id = 'doc';
1529
+ const text = S.brief && S.brief.exists ? stripFrontmatter(S.brief.text) : '';
1530
+ const empty = !text.trim();
1531
+ art.innerHTML = empty ? `<div class="banner">${iconEl(ICON.brief)}<div>This brief is empty. Regenerate it in your coding agent with <code>/deliberate brief</code>.</div></div>`
1532
+ : renderMarkdown(text);
1533
+ // A brief is read-only here (regenerated by the agent, not edited in place), so its task
1534
+ // checkboxes are shown for reference but disabled — there is no source model to toggle.
1535
+ art.querySelectorAll('.md-taskbox').forEach(b => { b.disabled = true; b.style.cursor = 'default'; });
1536
+ c.innerHTML = ''; c.appendChild(art);
1537
+ reindexHeadings(art);
1538
+ buildToc();
1539
+ if (!empty){ wireCommentAffordances(art); renderComments(); } // briefs are annotatable too
1540
+ if (S.mode === 'diff') loadDiffBase().then(refreshDiffDecorations);
1541
+ }
1542
+ // ---- Product Readout detail (read-only metrics + customer evidence) -------
1543
+ async function openReadout(id){
1544
+ flushSave();
1545
+ S.caseId = null; S.brief = null; S.readout = null; S.matchup = null; S.file = null; S.mode = 'doc'; S.comments = []; S.cNavIdx = -1; S.recFile = null;
1546
+ renderSidebar();
1547
+ let rec;
1548
+ try { rec = await api('GET', '/api/readout?id=' + encodeURIComponent(id)); } catch { rec = { error:'offline' }; }
1549
+ const r = route(); if (r.name === 'readout' && r.id !== id) return;
1550
+ if (!rec || rec.error){ setTopActions(''); return renderMain(emptyState('Readout not found', 'This Product Readout has no record here.')); }
1551
+ S.readout = { id, ...rec }; S.recFile = rec.file || null; S.isGit = !!rec.isGit; S.mode = defaultRecordMode();
1552
+ paintReadout();
1553
+ loadComments(rec.file);
1554
+ }
1555
+ function readoutControls(){ return ''; }
1556
+ function paintReadout(){
1557
+ renderMain(recordBodyMarkup());
1558
+ setTopActions(readoutControls());
1559
+ wireCaseControls();
1560
+ const c = $('#canvas'); if (!c) return;
1561
+ const art = document.createElement('article'); art.className = 'doc'; art.id = 'doc';
1562
+ const text = S.readout && S.readout.exists ? stripFrontmatter(S.readout.text) : '';
1563
+ const empty = !text.trim();
1564
+ art.innerHTML = empty ? `<div class="banner">${iconEl(ICON.readout)}<div>This readout is empty. Regenerate it in your coding agent with <code>/deliberate readout</code>.</div></div>`
1565
+ : renderMarkdown(text);
1566
+ art.querySelectorAll('.md-taskbox').forEach(b => { b.disabled = true; b.style.cursor = 'default'; });
1567
+ c.innerHTML = ''; c.appendChild(art);
1568
+ reindexHeadings(art);
1569
+ buildToc();
1570
+ if (!empty){ wireCommentAffordances(art); renderComments(); }
1571
+ if (S.mode === 'diff') loadDiffBase().then(refreshDiffDecorations);
1572
+ }
1573
+ // ---- matchup detail (read-only single-competitor head-to-head) ------------
1574
+ // A matchup reads exactly like a brief — a project-scoped, read-only rendered report with a
1575
+ // table-of-contents rail and no editing (it's regenerated in the coding agent, refresh-in-place
1576
+ // per competitor). It shares the brief's reading surface + comment machinery; only its fetch
1577
+ // key (competitor-keyed id), empty-state copy and icon differ.
1578
+ async function openMatchup(id){
1579
+ flushSave(); // persist any outgoing case edit before switching
1580
+ S.caseId = null; S.brief = null; S.readout = null; S.matchup = null; S.file = null; S.mode = 'doc'; S.comments = []; S.cNavIdx = -1; S.recFile = null;
1581
+ renderSidebar();
1582
+ let rec;
1583
+ try { rec = await api('GET', '/api/matchup?id=' + encodeURIComponent(id)); } catch { rec = { error:'offline' }; }
1584
+ const r = route(); if (r.name === 'matchup' && r.id !== id) return; // the user moved on while we fetched
1585
+ if (!rec || rec.error){ setTopActions(''); return renderMain(emptyState('Matchup not found', 'This matchup has no record here.')); }
1586
+ S.matchup = { id, ...rec }; S.recFile = rec.file || null; S.isGit = !!rec.isGit; S.mode = defaultRecordMode();
1587
+ paintMatchup();
1588
+ loadComments(rec.file);
1589
+ }
1590
+ function matchupControls(){
1591
+ // Like a brief, a matchup is read-only and its first paragraph already names the competitor
1592
+ // and the "As of" date it captures, so no tab-bar badge is needed. The comment navigator lives
1593
+ // in the canvas command bar (#docdiff).
1594
+ return '';
1595
+ }
1596
+ function paintMatchup(){
1597
+ renderMain(recordBodyMarkup());
1598
+ setTopActions(matchupControls());
1599
+ wireCaseControls(); // wires the comments prev/next navigator (no-ops for absent controls)
1600
+ const c = $('#canvas'); if (!c) return;
1601
+ const art = document.createElement('article'); art.className = 'doc'; art.id = 'doc';
1602
+ const text = S.matchup && S.matchup.exists ? stripFrontmatter(S.matchup.text) : '';
1603
+ const empty = !text.trim();
1604
+ const competitor = (S.matchup && S.matchup.competitor) || '<competitor>';
1605
+ art.innerHTML = empty ? `<div class="banner">${iconEl(ICON.matchup)}<div>This matchup is empty. Regenerate it in your coding agent with <code>/deliberate matchup ${esc(competitor)}</code>.</div></div>`
1606
+ : renderMarkdown(text);
1607
+ // A matchup is read-only here (regenerated by the agent, not edited in place), so its task
1608
+ // checkboxes are shown for reference but disabled — there is no source model to toggle.
1609
+ art.querySelectorAll('.md-taskbox').forEach(b => { b.disabled = true; b.style.cursor = 'default'; });
1610
+ c.innerHTML = ''; c.appendChild(art);
1611
+ reindexHeadings(art);
1612
+ buildToc();
1613
+ if (!empty){ wireCommentAffordances(art); renderComments(); } // matchups are annotatable too
1614
+ if (S.mode === 'diff') loadDiffBase().then(refreshDiffDecorations);
1615
+ }
1616
+ // corner, in #docdiff), not the tab bar — see caseCanvasControls / renderCanvasDiff. The tab
1617
+ // bar keeps only the generic editor affordances: the comments navigator + save state.
1618
+ function caseControls(){
1619
+ const cnav = commentNav();
1620
+ if (caseGenerating())
1621
+ return `${cnav}<span class="rochip" title="Being generated by your agent — editing is disabled to avoid conflicts">${iconEl(ICON.queue)}Read-only</span>`;
1622
+ return `${cnav}<span class="savedot" id="savedot"><i></i><span id="savelbl"></span></span>`;
1623
+ }
1624
+ // The verdict pill (jump-to-reasoning when the score section exists). Shared by the canvas
1625
+ // corner controls.
1626
+ function verdictPill(v){
1627
+ if (!v) return '';
1628
+ const jump = v.cls !== 'neutral' && reasoningInSource();
1629
+ return jump
1630
+ ? `<button type="button" class="pill jump ${v.cls}" data-scorejump title="${v.word} · ${v.label} — jump to score reasoning" aria-label="Verdict ${v.word}, score ${v.label} — jump to score reasoning">${v.label}</button>`
1631
+ : `<span class="pill ${v.cls}" title="${v.word} · score ${v.label}" aria-label="Verdict ${v.word}, score ${v.label}">${v.label}</span>`;
1632
+ }
1633
+ // The case-specific controls that float in the canvas corner (#docdiff): the verdict pill and the
1634
+ // View-/Build-prototype button. The prototype is a file, not a state: whenever the case's prototype
1635
+ // HTML exists (S.proto.exists) we show **View prototype** — regardless of verdict (a rejected case
1636
+ // can still have a prototype worth opening); otherwise, once the analysis is complete ('done'), we
1637
+ // offer **Build prototype**. Neither reads a 'prototype' case state (there is none — it's decoupled).
1638
+ function caseCanvasControls(){
1639
+ const kase = S.cases.find(s => s.id === S.caseId);
1640
+ const v = kase && kase.score != null ? verdict(kase.score) : null;
1641
+ const pill = verdictPill(v);
1642
+ if (caseGenerating()) return pill; // prototyping doesn't apply mid-generation
1643
+ const proto = S.proto && S.proto.exists
1644
+ ? `<a class="tbtn" data-proto ${S.proto.external ? `href="${esc(S.proto.url)}" target="_blank" rel="noopener"` : 'role="button" tabindex="0"'}>${iconEl(ICON.external)}View prototype</a>` : '';
1645
+ const build = !proto && kase && kase.state === 'done'
1646
+ ? `<button type="button" class="tbtn ghost" data-buildproto title="Build an interactive prototype for this case">${iconEl(ICON.proto)}Build prototype</button>` : '';
1647
+ return `${pill}${proto}${build}`;
1648
+ }
1649
+ // The comments navigator (prev / count / next) — shared by case and brief controls.
1650
+ // The comments count + prev/next now live in the rail header (renderCommentPanel), not the tab
1651
+ // bar — one home, and it frees tab-bar space. Kept as a no-op so the controls builders still call it.
1652
+ function commentNav(){ return ''; }
1653
+ function setTopActions(html){ const t = $('#topactions'); if (t) t.innerHTML = html || ''; }
1654
+ // The record canvas layout — a centred reading canvas + a sticky rail (table of contents). ONE
1655
+ // layout for BOTH the live document AND the rendered diff, so toggling Document ⇄ Diff only swaps
1656
+ // what fills #canvas (never the surrounding frame): the canvas stays the same width and position,
1657
+ // the corner controls stay put, and the rail persists — a seamless switch, not a separate screen.
1658
+ function recordBodyMarkup(){
1659
+ return `<div class="casebody"><div class="canvaswrap"><div class="canvas" id="canvas"></div><div class="cgutter" id="cgutter" aria-hidden="true"></div></div>
1660
+ <aside class="rail" id="rail"><nav class="toc" id="toc" aria-label="On this page"></nav></aside>
1661
+ <button class="secfab" id="secfab" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="secsheet"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h10"/></svg>Sections</button>
1662
+ <div class="secsheet" id="secsheet" role="menu" aria-label="Jump to section"></div></div>`;
1663
+ }
1664
+ function paintCase(){
1665
+ renderMain(recordBodyMarkup());
1666
+ setTopActions(caseControls());
1667
+ wireCaseControls();
1668
+ renderLive(); buildToc(); renderComments();
1669
+ if (S.mode === 'diff') loadDiffBase().then(refreshDiffDecorations);
1670
+ updateSaveDot();
1671
+ }
1672
+ function wireCaseControls(){
1673
+ renderCanvasDiff(); // verdict pill + prototype + Document⇄Diff toggle live in the canvas corner
1674
+ }
1675
+ // Discard ALL uncommitted changes in the open record's file — destructive, so it goes
1676
+ // through a confirmation dialog. On confirm: revert on the server, refresh git status, and
1677
+ // reload the record (and any open case editor) so the workbench reflects the restored file.
1678
+ async function confirmDiscard(){
1679
+ const file = recFile(); if (!file) return;
1680
+ const ok = await confirmDialog({
1681
+ title: 'Discard changes?',
1682
+ body: `This permanently discards all uncommitted changes in <b>${esc(baseName(file))}</b> and restores it to the last commit. This can’t be undone.`,
1683
+ confirmLabel: 'Discard changes', danger: true,
1684
+ });
1685
+ if (!ok) return;
1686
+ const r = await api('POST', '/api/discard', { path: file });
1687
+ if (!r || r.error){ showToast(r && r.error === 'endpoint-missing' ? `Editor is out of date — restart ${SERVE_CMD}.` : 'Could not discard changes.'); return; }
1688
+ await loadGit();
1689
+ showToast('Changes discarded — file restored to the last commit.');
1690
+ // If the discarded file dropped out of the change set while we're in diff mode, fall back
1691
+ // to the document view; otherwise re-render the diff. Reload the record so edits reflect.
1692
+ if (S.caseId) { await reloadRecord(); if (!recFileChanged() && S.mode === 'diff') setMode('doc'); else repaintRecord(); }
1693
+ else { if (!recFileChanged() && S.mode === 'diff') S.mode = 'doc'; if (S.brief) await openBrief(S.brief.id); else if (S.readout) await openReadout(S.readout.id); else if (S.matchup) await openMatchup(S.matchup.id); else if (S.file) await openFile(S.file.path); }
1694
+ renderSidebar();
1695
+ }
1696
+ // Toggle Document ⇄ Diff on the OPEN record WITHOUT re-rendering the document: the two modes share
1697
+ // the exact same DOM, so switching only adds/removes the change decorations (a background wash + the
1698
+ // inserted removed-block islands) over the already-rendered blocks. This means the unchanged content
1699
+ // never re-renders or re-animates — only the newly-revealed removed sections animate in, and the
1700
+ // green/red washes fade via the block's own background transition. A full paint is reserved for
1701
+ // opening a record / external edits (repaintRecord).
1702
+ function setMode(m){
1703
+ const mode = m === 'diff' ? 'diff' : 'doc';
1704
+ if (mode === S.mode) return;
1705
+ flushSave(); hideAsk();
1706
+ S.mode = mode;
1707
+ TEL.event('ui.action', { area:'editor', action:'mode-change', value:mode });
1708
+ renderCanvasDiff(); // reflect the toggle's pressed state + show/hide Discard
1709
+ const settle = (animate) => { applyDiffDecorations(animate); buildToc(); reserveRailForControls(); if (S.file || S.caseId || S.brief || S.readout || S.matchup) renderComments(); };
1710
+ if (mode === 'diff') loadDiffBase().then(() => settle(true)); // decorations animate IN on entry
1711
+ else settle(false); // leaving diff just clears the decorations
1712
+ }
1713
+
1714
+
1715
+ // The prototype link in analysis.md is a repo-relative path (./prototype/[<surface>/]index.html);
1716
+ // rewrite it to the daemon endpoint so it doesn't resolve against /case/<id>/. A surface slug (a
1717
+ // case can hold one prototype per primary surface) is carried through as the `surface` param.
1718
+ function protoUrl(surface){ const proj = (S.projectSlug || (S.project && S.project.id)); const q = proj ? '&project=' + encodeURIComponent(proj) : ''; const s = surface ? '&surface=' + encodeURIComponent(surface) : ''; return (!surface && S.proto && S.proto.external && S.proto.url) ? S.proto.url : ('/api/proto?id=' + encodeURIComponent(S.caseId) + q + s); }
1719
+ function openProto(surface){ TEL.event('ui.action', { area:'editor', action:'proto-open' }); try { window.open(protoUrl(surface), '_blank', 'noopener'); } catch {} }
1720
+ // No prototype yet: prototyping is an explicit, asked-for step the coding agent does —
1721
+ // so instead of a build button that can't work here, surface the exact `/deliberate`
1722
+ // command (with the case id filled in), one click to copy. Anchored under the button.
1723
+ function showBuildProto(anchor){
1724
+ const kase = currentCase();
1725
+ const ref = kase && kase.id != null ? String(kase.id) : '';
1726
+ const cmd = `/deliberate case ${ref} prototype`.replace(/\s+/g, ' ').trim();
1727
+ openCmdPopover(anchor, {
1728
+ title: 'Build the prototype',
1729
+ desc: 'Prototyping is an explicit step your coding agent runs. Paste this into your agent:',
1730
+ command: cmd,
1731
+ });
1732
+ }
1733
+ // A small popover that presents a runnable command with a one-click Copy — reuses the
1734
+ // `#menu` id so the existing closeMenu / click-away logic dismisses it.
1735
+ function openCmdPopover(anchor, { title, desc, command }){
1736
+ closeMenu();
1737
+ const m = document.createElement('div'); m.className = 'cmdpop'; m.id = 'menu'; m.setAttribute('role', 'dialog'); m.setAttribute('aria-label', title);
1738
+ m.innerHTML = `<div class="cmdpop-t">${esc(title)}</div><p class="cmdpop-d">${esc(desc)}</p>
1739
+ <div class="cmdpop-row"><code class="cmdpop-c">${esc(command)}</code>
1740
+ <button class="cmdpop-copy" type="button" aria-label="Copy command">${iconEl(ICON.copy)}<span>Copy</span></button></div>`;
1741
+ document.body.appendChild(m);
1742
+ const r = anchor.getBoundingClientRect();
1743
+ const w = m.offsetWidth || 340, vw = window.innerWidth;
1744
+ m.style.left = Math.max(8, Math.min(r.right - w, vw - w - 8)) + 'px';
1745
+ m.style.top = (r.bottom + 8) + 'px';
1746
+ const copyBtn = m.querySelector('.cmdpop-copy');
1747
+ copyBtn.addEventListener('click', async () => {
1748
+ let ok = false;
1749
+ try { await navigator.clipboard.writeText(command); ok = true; } catch { ok = copyFallback(command); }
1750
+ showToast(ok ? 'Copied — paste it into your coding agent.' : 'Select the command and copy it manually.');
1751
+ closeMenu();
1752
+ });
1753
+ copyBtn.focus();
1754
+ setTimeout(() => document.addEventListener('mousedown', menuAway, true), 0);
1755
+ }
1756
+ // Clipboard fallback for insecure contexts / older browsers (execCommand copy on a temp textarea).
1757
+ function copyFallback(text){
1758
+ try { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); const ok = document.execCommand('copy'); ta.remove(); return ok; }
1759
+ catch { return false; }
1760
+ }
1761
+ function rewriteProtoLinks(el){
1762
+ el.querySelectorAll('a[href]').forEach(a => {
1763
+ const href = a.getAttribute('href') || '';
1764
+ const m = href.match(/(?:^|\/)prototype\/(?:([^/]+)\/)?index\.html\/?$/) || (/(?:^|\/)prototype\/?$/.test(href) ? [] : null);
1765
+ if (m){
1766
+ const surface = (m && m[1]) || null; // a named primary surface, or the default (flat) prototype
1767
+ const external = !surface && S.proto && S.proto.external;
1768
+ a.setAttribute('href', protoUrl(surface)); a.setAttribute('target', '_blank'); a.setAttribute('rel', 'noopener');
1769
+ if (!external){ a.dataset.protoLocal = '1'; a.addEventListener('click', (e) => { e.preventDefault(); openProto(surface); }); }
1770
+ }
1771
+ });
1772
+ }
1773
+ // Open a rendered Markdown link from the editor: an in-page `#anchor` scrolls to its heading,
1774
+ // everything else opens in a new tab.
1775
+ function openDocLink(a){
1776
+ const href = a.getAttribute('href') || '';
1777
+ if (!href || href === '#') return;
1778
+ if (href.charAt(0) === '#'){ const t = document.getElementById(href.slice(1)); if (t) t.scrollIntoView({ behavior: prefersReduced() ? 'auto' : 'smooth', block: 'start' }); return; }
1779
+ try { window.open(a.href, '_blank', 'noopener,noreferrer'); } catch { /* popup blocked */ }
1780
+ }
1781
+ // A block that renders to nothing but isn't blank — a standalone HTML-comment section marker
1782
+ // (machine state, like frontmatter). Kept in the document model, hidden from the reading view;
1783
+ // used by the comment line-mapping and the diff so those markers are never shown as content.
1784
+ const isMetaOnly = (src) => src.trim() !== '' && renderMarkdown(src).trim() === '';
1785
+
1786
+ // ---- The editor (Tiptap / ProseMirror over Markdown) ----------------------
1787
+ // The record's Markdown is mounted into a Tiptap editor (see editor.mjs). Save,
1788
+ // ToC, comments and diff all route through the same seams the rest of the app
1789
+ // uses (currentSource / buildToc / renderComments / applyDiffDecorations), so
1790
+ // this section only owns the mount + the debounced structure refresh.
1791
+ //
1792
+ // Sonorance's value was never in re-implementing a Markdown editor — that's a
1793
+ // commodity. So the editing surface is a proven library (ProseMirror via
1794
+ // Tiptap) and the effort goes into what's actually differentiated: comments,
1795
+ // diff, the ToC, slash commands, and the agent workflow on top.
1796
+ let wysiwygCtl = null;
1797
+ // True while the user's caret is live in the editor — used to keep the file poll from
1798
+ // re-rendering (and so blowing away the caret / edit session) out from under an active edit.
1799
+ function wysiwygFocused(){ return !!(wysiwygCtl && wysiwygCtl.hasFocus()); }
1800
+
1801
+ // Mount (or re-mount) the editor over the open record's Markdown. Read-only for briefs and for
1802
+ // a case the agent is mid-generating; editable otherwise. Contextual banners live OUTSIDE the
1803
+ // editor article (Tiptap owns its element's DOM) so they never collide with the ProseMirror view.
1804
+ function renderLive(){
1805
+ const c = $('#canvas'); if (!c) return;
1806
+ const ro = docReadOnly();
1807
+ hideAsk(); hideTableTools(); // clear any floating affordance from the previous surface
1808
+ if (wysiwygCtl){ try { wysiwygCtl.destroy(); } catch {} wysiwygCtl = null; }
1809
+ c.innerHTML = '';
1810
+ if (caseGenerating()){
1811
+ // The agent is writing this record (mid-funnel). Editing is locked so a hand-edit can't
1812
+ // collide with the in-flight save; comments still work (they don't touch the file).
1813
+ const b = document.createElement('div'); b.className = 'banner gen'; b.setAttribute('contenteditable', 'false');
1814
+ b.innerHTML = `${iconEl(ICON.queue)}<div><strong>Being generated by your agent.</strong> Editing is disabled here while the analysis is written, to avoid conflicts — it unlocks when the case is complete. You can still add comments.</div>`;
1815
+ c.appendChild(b);
1816
+ } else if (S.rec && !S.rec.exists){
1817
+ const b = document.createElement('div'); b.className = 'banner'; b.setAttribute('contenteditable', 'false');
1818
+ b.innerHTML = `${iconEl(ICON.queue)}<div>No analysis has run yet — start typing to draft this record, or run the funnel in your coding agent (<code>/deliberate case</code>).${
1819
+ S.rec.idea ? `<div style="margin-top:8px;color:var(--ink-faint)">Case: ${esc(S.rec.idea)}</div>` : ''}</div>`;
1820
+ c.appendChild(b);
1821
+ }
1822
+ const art = document.createElement('article'); art.className = 'doc' + (ro ? ' ro' : ''); art.id = 'doc';
1823
+ c.appendChild(art);
1824
+ wysiwygCtl = createEditor({
1825
+ element: art,
1826
+ editable: !ro,
1827
+ placeholder: 'Start writing…',
1828
+ onChange: onWysiwygChange,
1829
+ onCommentClick: (id) => focusComment(id),
1830
+ onAtComment: openAtComment,
1831
+ onLinkClick: (a, e) => { if (e) e.preventDefault(); openDocLink(a); },
1832
+ });
1833
+ wysiwygCtl.setMarkdown(reconstruct(S.items));
1834
+ reindexHeadings(art);
1835
+ buildToc();
1836
+ renderComments();
1837
+ wireCommentAffordances(art);
1838
+ if (S.mode === 'diff') applyDiffDecorations(); // re-layer change highlights after a (re)mount
1839
+ }
1840
+
1841
+ function onWysiwygChange(){
1842
+ if (docReadOnly()) return;
1843
+ S.dirty = true; updateSaveDot(); scheduleSave(); syncDocDiff();
1844
+ scheduleDocStructure(); // ToC/heading-ids/diff are debounced off the keystroke (INP)
1845
+ }
1846
+ // The per-keystroke handler must stay O(1) beyond Tiptap's own work, so the O(document)
1847
+ // structure work — re-slugging headings, rebuilding the ToC, and re-serializing the doc to
1848
+ // re-derive the diff — is coalesced here and only runs once typing settles. INP is a hard
1849
+ // priority: this keeps a burst of keystrokes from rebuilding the ToC + diffing the whole doc on
1850
+ // every key. A short debounce (not rAF) so sustained typing doesn't diff every frame either.
1851
+ let docStructTimer = null;
1852
+ function scheduleDocStructure(){ clearTimeout(docStructTimer); docStructTimer = setTimeout(refreshDocStructure, 150); }
1853
+ function refreshDocStructure(){
1854
+ clearTimeout(docStructTimer); docStructTimer = null;
1855
+ if (!wysiwygCtl) return;
1856
+ const art = $('#doc'); if (art) reindexHeadings(art);
1857
+ buildToc();
1858
+ if (S.mode === 'diff') applyDiffDecorations();
1859
+ }
1860
+ // Stable heading ids for the ToC / scroll-spy / thread anchoring — matched on the rendered
1861
+ // headings whether the surface is an editable case or a read-only brief.
1862
+ function reindexHeadings(art){
1863
+ let n = 0;
1864
+ art.querySelectorAll(':is(h1,h2,h3,h4,h5,h6)').forEach(h => { h.id = 'sec-' + n++; });
1865
+ }
1866
+ // The comment affordances (selection "Comment" bubble + the + gutter add-handle) — wired on
1867
+ // every rendered surface (editable case or read-only brief/file) so any file is annotatable.
1868
+ function wireCommentAffordances(art){
1869
+ const onUp = () => setTimeout(() => { maybeShowAsk(art); maybeShowTableTools(); }, 0); // the Comment affordance works whether reading or editing
1870
+ art.addEventListener('mouseup', onUp);
1871
+ art.addEventListener('keyup', onUp);
1872
+ art.addEventListener('mousemove', onDocHoverComment); // the + add-comment handle tracks the hovered block
1873
+ const wrap = art.closest('.canvaswrap'); if (wrap && !wrap._cleave){ wrap._cleave = true; wrap.addEventListener('mouseleave', hideAddHandle); }
1874
+ }
1875
+ // Dismiss the selection affordances (Comment button / formatting bubble) when the
1876
+ // selection clears. Registered once, in boot().
1877
+ function onDocSelectionChange(){
1878
+ if (askPop) return; // composing — keep it up
1879
+ maybeShowTableTools(); // the caret may have moved in/out of a table
1880
+ if (!askBtn && !bubbleBar) return;
1881
+ const sel = window.getSelection && window.getSelection();
1882
+ const q = sel && !sel.isCollapsed ? sel.toString().trim() : '';
1883
+ if (!q) hideSelectionAffordance(); // deselected → dismiss the bubble/button
1884
+ }
1885
+ // The current document's Markdown — the live editor's serialization (the on-disk truth).
1886
+ function currentSource(){
1887
+ return wysiwygCtl ? wysiwygCtl.getMarkdown() : reconstruct(S.items);
1888
+ }
1889
+ function flushSave(){ if (S.saveTimer){ clearTimeout(S.saveTimer); S.saveTimer = null; } if (S.dirty) saveNow(); }
1890
+ function prefersReduced(){ try { return window.matchMedia('(prefers-reduced-motion:reduce)').matches; } catch { return false; } }
1891
+
1892
+ // ---- table of contents + scroll-spy ---------------------------------------
1893
+ // Built from the rendered document's H2/H3 headings. The rail is sticky (always
1894
+ // visible while scrolling) and the entry for the section currently on screen is
1895
+ // highlighted. Falls back to one level (H2) if two won't fit the viewport.
1896
+
1897
+ // The document's heading elements, in order, excluding those inside a REMOVED-block decoration
1898
+ // (`.diffdel`) since they aren't part of the resulting document. Re-queried LIVE on every call:
1899
+ // Tiptap owns the `#doc` subtree and both strips the `id` attributes we set on its headings AND
1900
+ // replaces the heading nodes themselves on re-render, so neither a `getElementById` nor a captured
1901
+ // element reference survives. Jump/scroll-spy therefore address headings by their ordinal position
1902
+ // (`data-hidx`) and resolve them against this live list at click/scroll time.
1903
+ function liveHeadings(){
1904
+ const doc = $('#doc'); if (!doc) return [];
1905
+ return [...doc.querySelectorAll('h2,h3')].filter(h => !h.closest('.diffdel'));
1906
+ }
1907
+ function buildToc(){
1908
+ const toc = $('#toc'); if (!toc) return;
1909
+ const abs = liveHeadings();
1910
+ buildMobileSections(abs);
1911
+ if (abs.length < 2){ toc.innerHTML = ''; return; }
1912
+ let idxs = abs.map((_, i) => i);
1913
+ const avail = (window.innerHeight || 800) - 130;
1914
+ if (abs.length * 27 + 26 > avail){ const h2 = idxs.filter(i => abs[i].tagName === 'H2'); if (h2.length >= 2) idxs = h2; }
1915
+ const withC = sectionsWithComments();
1916
+ toc.innerHTML = `<div class="toc-h">On this page</div>` + idxs.map((ai, i) => { const h = abs[ai];
1917
+ return `<a href="#${h.id}" data-idx="${i}" data-hidx="${ai}" class="lvl${h.tagName === 'H3' ? 3 : 2}">${esc(h.textContent)}${withC.has(h) ? '<span class="tdot" aria-label="has comments"></span>' : ''}</a>`; }).join('');
1918
+ toc.querySelectorAll('a').forEach(a => a.addEventListener('click', (e) => {
1919
+ e.preventDefault();
1920
+ TEL.event('ui.action', { area: 'toc', action: 'toc-nav' });
1921
+ const el = liveHeadings()[+a.dataset.hidx];
1922
+ if (el) el.scrollIntoView({ behavior: prefersReduced() ? 'auto' : 'smooth', block: 'start' });
1923
+ }));
1924
+ wireScrollspy(idxs);
1925
+ reserveRailForControls();
1926
+ }
1927
+ // Keep the floating corner controls (#docdiff: verdict pill + prototype + Document⇄Diff toggle)
1928
+ // from overlapping the table-of-contents rail. Rather than move the controls out of the corner,
1929
+ // reserve exactly enough space at the rail's top for the cluster to sit above the ToC — measured
1930
+ // from the live geometry, so it adapts to the cluster's width and to a wide prototype label that
1931
+ // wraps. No-op in diff view / on narrow layouts where the rail isn't shown.
1932
+ function reserveRailForControls(){
1933
+ const rail = $('#rail'), dd = $('#docdiff');
1934
+ if (!rail) return;
1935
+ if (!dd || dd.hidden){ rail.style.paddingTop = ''; return; }
1936
+ const r = rail.getBoundingClientRect(), d = dd.getBoundingClientRect();
1937
+ const overlap = d.bottom - r.top;
1938
+ // Pad the rail's top so its content (the ToC, or the comment hint + cards) starts BELOW the
1939
+ // floating command bar rather than under it. Both the hint and the cards scroll normally, so this
1940
+ // only needs to hold at the top of the scroll — no sticky offset to maintain.
1941
+ rail.style.paddingTop = overlap > 0 ? `${Math.ceil(overlap) + 12}px` : '';
1942
+ }
1943
+ // The mobile spine: mirror the full outline into the floating Sections sheet so the
1944
+ // record stays navigable once the desktop rail collapses (≤1080px).
1945
+ function buildMobileSections(heads){
1946
+ const fab = $('#secfab'), sheet = $('#secsheet');
1947
+ document.body.classList.remove('sec-open');
1948
+ document.body.classList.toggle('has-sections', heads.length >= 2);
1949
+ if (fab) fab.setAttribute('aria-expanded', 'false');
1950
+ if (!sheet){ return; }
1951
+ if (heads.length < 2){ sheet.innerHTML = ''; return; }
1952
+ sheet.innerHTML = heads.map((h, i) =>
1953
+ `<a href="#${h.id}" data-idx="${i}" data-hidx="${i}" class="lvl${h.tagName === 'H3' ? 3 : 2}" role="menuitem">${esc(h.textContent)}</a>`).join('');
1954
+ const close = () => { document.body.classList.remove('sec-open'); if (fab) fab.setAttribute('aria-expanded', 'false'); };
1955
+ if (fab) fab.onclick = (e) => { e.stopPropagation(); const open = document.body.classList.toggle('sec-open'); fab.setAttribute('aria-expanded', open ? 'true' : 'false'); };
1956
+ sheet.querySelectorAll('a').forEach(a => a.addEventListener('click', (e) => {
1957
+ e.preventDefault();
1958
+ const el = liveHeadings()[+a.dataset.hidx];
1959
+ if (el) el.scrollIntoView({ behavior: prefersReduced() ? 'auto' : 'smooth', block: 'start' });
1960
+ close();
1961
+ }));
1962
+ if (!S._secOutsideWired){
1963
+ S._secOutsideWired = true;
1964
+ document.addEventListener('click', (e) => {
1965
+ if (!document.body.classList.contains('sec-open')) return;
1966
+ if (e.target.closest('#secsheet') || e.target.closest('#secfab')) return;
1967
+ close();
1968
+ });
1969
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
1970
+ }
1971
+ }
1972
+ let spyHandler = null;
1973
+ function wireScrollspy(idxs){
1974
+ const main = $('#main'), toc = $('#toc'); if (!main || !toc) return;
1975
+ if (spyHandler){ main.removeEventListener('scroll', spyHandler); spyHandler = null; }
1976
+ const setActive = (j) => toc.querySelectorAll('a').forEach(a => a.classList.toggle('active', +a.dataset.idx === j));
1977
+ let ticking = false;
1978
+ const update = () => {
1979
+ ticking = false;
1980
+ const live = liveHeadings();
1981
+ const top = main.getBoundingClientRect().top;
1982
+ let active = 0;
1983
+ // Address headings by ordinal position against the live list (Tiptap replaces the nodes and
1984
+ // strips their ids). The last heading whose top has passed the reading line is the current one.
1985
+ for (let j = 0; j < idxs.length; j++){ const el = live[idxs[j]]; if (!el) continue;
1986
+ if (el.getBoundingClientRect().top - top <= 60) active = j; else break; }
1987
+ setActive(active);
1988
+ };
1989
+ spyHandler = () => { if (!ticking){ ticking = true; (window.requestAnimationFrame || setTimeout)(update); } };
1990
+ main.addEventListener('scroll', spyHandler, { passive: true });
1991
+ update();
1992
+ }
1993
+
1994
+ // ---- in-record comments (annotations) -------------------------------------
1995
+ // The reader leaves comments anchored to spans of ANY file (a case record OR a brief);
1996
+ // the agent works through them with `/deliberate address` and resolves each. Only OPEN
1997
+ // comments are shown — resolved ones disappear. Presentation lives in an OVERLAY over the
1998
+ // reading canvas (never inside the contenteditable #doc): an inline highlight per anchored
1999
+ // span, a left-gutter pin per commented block, and a click-to-open card.
2000
+ const openCmts = () => (S.comments || []).filter(c => c.status === 'open');
2001
+ // The block-level container of a node inside the document — a top-level Markdown block. On the
2002
+ // Tiptap surface the blocks are the direct children of `.ProseMirror`; on the read-only brief
2003
+ // surface (plain rendered HTML) they're the direct children of `#doc`. One helper so the comment
2004
+ // layer works identically on both surfaces.
2005
+ function blockOf(el){
2006
+ const doc = $('#doc'); if (!doc || !el) return null;
2007
+ const container = doc.querySelector('.ProseMirror') || doc;
2008
+ let n = el.nodeType === 3 ? el.parentElement : el;
2009
+ if (!n || !container.contains(n)) return null;
2010
+ while (n && n.parentElement && n.parentElement !== container) n = n.parentElement;
2011
+ return (n && n.parentElement === container) ? n : null;
2012
+ }
2013
+ // The rendered anchor of a comment: its highlighted span if found, else the heading
2014
+ // block that names its section (a fallback when the quote was edited away or spans blocks).
2015
+ function commentAnchor(c){
2016
+ const doc = $('#doc'); if (!doc) return null;
2017
+ const mark = doc.querySelector(`.cmark[data-comment="${c.id}"]`);
2018
+ if (mark) return { el: mark, blk: blockOf(mark) };
2019
+ const blk = commentBlockEl(c) || doc.querySelector(`.cmark-blk[data-comment="${c.id}"]`); // the line-anchored / block-highlighted block
2020
+ if (blk) return { el: blk, blk };
2021
+ const head = c.anchor && c.anchor.heading && c.anchor.heading.trim();
2022
+ if (head){
2023
+ const h = [...doc.querySelectorAll('h1,h2,h3,h4,h5,h6')].find(x => x.textContent.trim() === head);
2024
+ if (h) return { el: h, blk: blockOf(h) };
2025
+ }
2026
+ return null;
2027
+ }
2028
+ // The id of the nearest heading at or before an element — for ToC comment dots.
2029
+ function precedingHeading(el){
2030
+ const doc = $('#doc'); if (!doc || !el) return null;
2031
+ let head = null;
2032
+ for (const h of doc.querySelectorAll('h1,h2,h3,h4,h5,h6')){
2033
+ if (h === el || (h.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)) head = h; else break;
2034
+ }
2035
+ return head;
2036
+ }
2037
+ // The set of ToC heading ELEMENTS that contain at least one open comment. Element-keyed (not
2038
+ // id-keyed) because Tiptap strips the heading ids after render; see buildToc.
2039
+ function sectionsWithComments(){
2040
+ const set = new Set();
2041
+ for (const c of openCmts()){ const a = commentAnchor(c); if (!a) continue; const h = precedingHeading(a.el); if (h) set.add(h); }
2042
+ return set;
2043
+ }
2044
+ // The source line where S.items[i] begins, and the block index that owns a given source line —
2045
+ // the basis for LINE-anchored comments (a stable-ish reference that survives duplicate/removed
2046
+ // quote text far better than a bare content search; see commentAnchor).
2047
+ function blockStartLine(i){ let n = 0; for (let k = 0; k < i && k < S.items.length; k++) n += (S.items[k].lines || []).length; return n; }
2048
+ function blockIndexForLine(line){ let n = 0; for (let i = 0; i < S.items.length; i++){ const len = (S.items[i].lines || []).length; if (line >= n && line < n + len) return i; n += len; } return -1; }
2049
+ // The rendered .blk that a comment is anchored to, resolved robustly: prefer the block its saved
2050
+ // source LINE lands in (unambiguous even if the quote appears elsewhere or was edited away), then
2051
+ // its quote within that block. Legacy/brief comments (no line) fall back to a global quote search.
2052
+ function commentBlockEl(c){
2053
+ const doc = $('#doc'); if (!doc) return null;
2054
+ const line = c.anchor && typeof c.anchor.line === 'number' ? c.anchor.line : -1;
2055
+ if (line >= 0){
2056
+ if (S.caseId || S.file){ const bi = blockIndexForLine(line); if (bi >= 0){ const blk = doc.querySelector(`.blk[data-i="${bi}"]`); if (blk) return blk; } }
2057
+ else if (S.brief || S.readout || S.matchup){ const idx = briefBlockAtLine(line); if (idx >= 0){ const blk = briefBlockKids()[idx]; if (blk) return blk; } }
2058
+ }
2059
+ return null;
2060
+ }
2061
+ // Comments are shown as ALWAYS-VISIBLE amber cards — in the right rail (mode 'rail', the default,
2062
+ // which replaces the ToC while there are open comments) or, when the rail can't fit (narrow window
2063
+ // / split-screen), inline in the document flow (mode 'inline'). There is no manual switch: the mode
2064
+ // follows the available width. The anchored span keeps a soft amber highlight in both modes.
2065
+ function commentModeOn(){ return railAvailable() ? 'rail' : 'inline'; }
2066
+ function railAvailable(){ try { return window.innerWidth > 1080; } catch { return true; } }
2067
+ function renderComments(){
2068
+ const doc = $('#doc'); if (!doc) return;
2069
+ doc.querySelectorAll('.cnote-island').forEach(el => el.remove()); // clear stale inline islands (incl. the hint island) before re-marking
2070
+ if (wysiwygCtl) wysiwygCtl.setComments(openCmts()); // highlights via ProseMirror decorations (never mutate the PM DOM)
2071
+ const open = openCmts();
2072
+ ensureAddHandle($('#cgutter'));
2073
+ renderCommentPanel(open);
2074
+ if (commentModeOn() === 'inline' && open.length){ renderCommentHintIsland(); renderCommentIslands(open); }
2075
+ updateTocDots();
2076
+ refreshCommentNav();
2077
+ // Inline comment islands touch the DOM; if we're in diff mode, re-assert the green/red diff
2078
+ // decorations so comment rendering never wipes the diff wash (they're separate colour channels).
2079
+ if (S.mode === 'diff') applyDiffDecorations();
2080
+ }
2081
+ // Inline mode (narrow, rail hidden): the global hint rides at the very top of the document as a
2082
+ // non-editable island, so the "run /sonorance address" guidance is still shown once somewhere.
2083
+ function renderCommentHintIsland(){
2084
+ const doc = $('#doc'); if (!doc) return;
2085
+ const el = document.createElement('div'); el.className = 'cnote-island cnote-hint-island'; el.contentEditable = 'false';
2086
+ el.innerHTML = commentHintHTML();
2087
+ doc.insertBefore(el, doc.firstChild);
2088
+ wireCommentHint(el);
2089
+ }
2090
+ // One always-visible comment card, shared by the rail column and the inline islands. No quoted
2091
+ // text (it's redundant beside the highlighted anchor and eats space); the reader can Edit or
2092
+ // Delete their own comment — the AGENT resolves them, the reader deletes them.
2093
+ function commentCardHTML(c){
2094
+ return `<article class="cnote" data-comment="${esc(c.id)}" tabindex="0" role="button" aria-label="Comment — ${esc(String(c.body || '').slice(0, 60))}">
2095
+ <div class="cnote-b">${esc(c.body)}</div>
2096
+ <footer class="cnote-f"><span class="cnote-who">You</span><span class="cnote-t">${esc(relTime(c.ts))}</span><span class="cnote-sp"></span>
2097
+ <button type="button" class="cnote-act" data-edit="${esc(c.id)}" title="Edit comment" aria-label="Edit comment">${iconEl(ICON.pencil)}</button>
2098
+ <button type="button" class="cnote-act cnote-del" data-del="${esc(c.id)}" title="Delete comment" aria-label="Delete comment">${iconEl(ICON.trash)}</button></footer>
2099
+ </article>`;
2100
+ }
2101
+ // Open comments in document order (top-to-bottom), so both the rail list and the nav step
2102
+ // through them the way the reader reads.
2103
+ function orderComments(list){
2104
+ return list.map(c => ({ c, a: commentAnchor(c) })).sort((p, q) => {
2105
+ if (!p.a) return 1; if (!q.a) return -1;
2106
+ return (p.a.el.compareDocumentPosition(q.a.el) & Node.DOCUMENT_POSITION_FOLLOWING) ? -1 : 1;
2107
+ }).map(x => x.c);
2108
+ }
2109
+ // The rail comment layout: a NON-sticky hint box ("run /sonorance address") that sits below the
2110
+ // floating command bar and above the anchor-aligned comment cards, scrolling with them. The comment
2111
+ // count + prev/next navigator does NOT live here — it's a control in the command bar (#docdiff, see
2112
+ // commentNavCmd). In 'inline' mode (narrow, rail hidden) the hint rides at the top of the document.
2113
+ function renderCommentPanel(open){
2114
+ const rail = $('#rail'); if (!rail) return;
2115
+ const toc = rail.querySelector('#toc');
2116
+ let head = rail.querySelector('.crail-head');
2117
+ let panel = rail.querySelector('#comments');
2118
+ const n = open.length;
2119
+ if (!n){ // no comments → plain ToC, no header/column
2120
+ if (head) head.remove();
2121
+ if (panel) panel.remove();
2122
+ rail.classList.remove('cm-aligned');
2123
+ if (toc) toc.style.display = '';
2124
+ return;
2125
+ }
2126
+ if (commentModeOn() === 'rail'){
2127
+ if (!head){ head = document.createElement('div'); head.className = 'crail-head'; rail.insertBefore(head, rail.firstChild); }
2128
+ head.innerHTML = commentHintHTML();
2129
+ wireCommentHint(head);
2130
+ if (toc) toc.style.display = 'none';
2131
+ if (!panel){ panel = document.createElement('div'); panel.id = 'comments'; panel.className = 'comments'; rail.appendChild(panel); }
2132
+ panel.classList.add('aligned'); rail.classList.add('cm-aligned');
2133
+ panel.innerHTML = orderComments(open).map(c => commentCardHTML(c)).join('');
2134
+ wireCommentCards(panel);
2135
+ positionRailComments(); // Google-Docs alignment: each card sits beside its anchor line
2136
+ requestAnimationFrame(positionRailComments); // re-run once layout has settled (fonts/highlights)
2137
+ } else {
2138
+ if (head) head.remove();
2139
+ rail.classList.remove('cm-aligned');
2140
+ if (panel) panel.remove();
2141
+ if (toc) toc.style.display = '';
2142
+ }
2143
+ }
2144
+ // The single global "run /sonorance address" hint (shown once, not per comment), with a one-click
2145
+ // copy of the command. Shared by the rail (below the command bar) and the inline hint island.
2146
+ function commentHintHTML(){
2147
+ return `<div class="crail-hint"><span class="crail-hint-t">Run <code>${esc(ADDRESS_CMD)}</code> in your agent to resolve comments.</span>
2148
+ <button type="button" class="crail-copy" data-copyaddr title="Copy “${esc(ADDRESS_CMD)}”" aria-label="Copy the command">${iconEl(ICON.copy)}</button></div>`;
2149
+ }
2150
+ function wireCommentHint(root){
2151
+ root.querySelector('[data-copyaddr]')?.addEventListener('click', async () => {
2152
+ let ok = false; try { await navigator.clipboard.writeText(ADDRESS_CMD); ok = true; } catch { ok = copyFallback(ADDRESS_CMD); }
2153
+ showToast(ok ? 'Command copied.' : 'Could not copy.');
2154
+ });
2155
+ }
2156
+ // The comment navigator for the canvas command bar (#docdiff): just the comment glyph + open count
2157
+ // and prev/next arrows — no "comments" word (the icon carries the meaning, and space is tight next
2158
+ // to the Diff toggle). Built by renderCanvasDiff so it sits beside the other canvas controls.
2159
+ function commentNavCmd(){
2160
+ const n = openCmts().length;
2161
+ if (!n) return '';
2162
+ const lbl = `${n} open comment${n > 1 ? 's' : ''}`;
2163
+ return `<span class="cnavcmd" role="group" aria-label="${lbl}">
2164
+ <button type="button" class="cnavcmd-step" data-cnav="prev" title="Previous comment" aria-label="Previous comment">‹</button>
2165
+ <span class="cnavcmd-count" title="${lbl}">${iconEl(ICON.comment)}${n}</span>
2166
+ <button type="button" class="cnavcmd-step" data-cnav="next" title="Next comment" aria-label="Next comment">›</button>
2167
+ </span>`;
2168
+ }
2169
+ // Position each rail comment card vertically next to the line it's anchored to (Google-Docs
2170
+ // margin notes), pushing a card down when it would overlap the one above. Positions are computed
2171
+ // in the (non-sticky, full-height) comments column's own coordinate space, so they scroll with the
2172
+ // document — no per-scroll recompute needed. Re-run on resize (wired once in boot).
2173
+ function positionRailComments(){
2174
+ const panel = $('#comments'); if (!panel || !panel.classList.contains('aligned')) return;
2175
+ const rail = $('#rail'); const head = rail && rail.querySelector('.crail-head');
2176
+ const prect = panel.getBoundingClientRect();
2177
+ const gap = 10;
2178
+ // Floor at the header's BOTTOM in the column's own coordinate space (the header doesn't start at
2179
+ // the column's top — rail padding + header margins offset it), so no card ever slides under it.
2180
+ let prevBottom = head ? (head.getBoundingClientRect().bottom - prect.top + 8) : 0;
2181
+ for (const card of panel.querySelectorAll('.cnote')){
2182
+ const c = (S.comments || []).find(x => x.id === card.dataset.comment);
2183
+ const a = c && commentAnchor(c);
2184
+ let top = a && a.el ? (a.el.getBoundingClientRect().top - prect.top) : prevBottom;
2185
+ top = Math.max(top, prevBottom);
2186
+ card.style.top = top + 'px';
2187
+ prevBottom = top + card.getBoundingClientRect().height + gap;
2188
+ }
2189
+ }
2190
+ // Inline mode: an amber card island right after each block that carries comments — a
2191
+ // contenteditable=false island (like the diff's removed-line blocks, third colour), so it never
2192
+ // enters the editing model. Re-inserted on every render (renderLive wipes them first).
2193
+ function renderCommentIslands(open){
2194
+ const doc = $('#doc'); if (!doc) return;
2195
+ const groups = new Map();
2196
+ for (const c of open){ const a = commentAnchor(c); if (!a || !a.blk) continue; if (!groups.has(a.blk)) groups.set(a.blk, []); groups.get(a.blk).push(c); }
2197
+ for (const [blk, list] of groups){
2198
+ const island = document.createElement('div'); island.className = 'cnote-island'; island.contentEditable = 'false';
2199
+ island.innerHTML = list.map(c => commentCardHTML(c)).join('');
2200
+ blk.insertAdjacentElement('afterend', island);
2201
+ wireCommentCards(island);
2202
+ }
2203
+ }
2204
+ // Edit / Delete on a card; click/keyboard elsewhere on the card focuses its anchor.
2205
+ function wireCommentCards(root){
2206
+ root.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', (e) => { e.stopPropagation(); deleteCommentUI(b.dataset.del); }));
2207
+ root.querySelectorAll('[data-edit]').forEach(b => b.addEventListener('click', (e) => { e.stopPropagation(); startEditComment(b.dataset.edit); }));
2208
+ root.querySelectorAll('.cnote').forEach(card => {
2209
+ card.addEventListener('click', (e) => { if (!e.target.closest('.cnote-act') && !card.classList.contains('editing')) focusComment(card.dataset.comment); });
2210
+ card.addEventListener('keydown', (e) => { if ((e.key === 'Enter' || e.key === ' ') && !card.classList.contains('editing') && e.target === card){ e.preventDefault(); focusComment(card.dataset.comment); } });
2211
+ });
2212
+ }
2213
+ // Inline-edit a comment's body: swap the card body for a textarea + Save/Cancel; on Save,
2214
+ // persist (POST /api/comment-edit) and update in place. The reader edits; the agent resolves.
2215
+ function startEditComment(id){
2216
+ const c = (S.comments || []).find(x => x.id === id); if (!c) return;
2217
+ const card = [...document.querySelectorAll('.cnote')].find(x => x.dataset.comment === id); if (!card || card.classList.contains('editing')) return;
2218
+ card.classList.add('editing');
2219
+ const bodyEl = card.querySelector('.cnote-b');
2220
+ const prev = c.body;
2221
+ bodyEl.innerHTML = `<textarea class="cnote-edit" rows="2">${esc(prev)}</textarea>
2222
+ <div class="cnote-editrow"><button type="button" class="cbtn ghost" data-cedit="cancel">Cancel</button><button type="button" class="cbtn primary" data-cedit="save">Save</button></div>`;
2223
+ const ta = bodyEl.querySelector('textarea'); ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length);
2224
+ positionRailComments(); // the card just grew — reflow the rail so later cards don't overlap
2225
+ ta.addEventListener('input', positionRailComments); // keep them clear as the textarea auto-grows while typing
2226
+ const finish = () => renderComments();
2227
+ ta.addEventListener('keydown', (e) => { e.stopPropagation(); if (e.key === 'Escape'){ e.preventDefault(); finish(); } if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)){ e.preventDefault(); save(); } });
2228
+ bodyEl.querySelector('[data-cedit="cancel"]').addEventListener('click', (e) => { e.stopPropagation(); finish(); });
2229
+ bodyEl.querySelector('[data-cedit="save"]').addEventListener('click', (e) => { e.stopPropagation(); save(); });
2230
+ async function save(){
2231
+ const val = ta.value.trim(); if (!val || val === prev){ finish(); return; }
2232
+ c.body = val; // optimistic; re-render shows it immediately
2233
+ finish();
2234
+ const file = S.recFile;
2235
+ const r = await api('POST', '/api/comment-edit', { commentId: id, body: val });
2236
+ if (S.recFile !== file) return;
2237
+ if (!r || r.error){ c.body = prev; renderComments(); showToast(r && r.error === 'endpoint-missing' ? `Editor is out of date — restart ${SERVE_CMD}.` : 'Could not save the edit.'); }
2238
+ }
2239
+ }
2240
+ // Bring a comment into focus: scroll to + flash its anchored span, and mark its card active.
2241
+ // Replaces the old click-to-open popover — the card is already visible, so this just links the two.
2242
+ function focusComment(id){
2243
+ const c = (S.comments || []).find(x => x.id === id); if (!c) return;
2244
+ document.querySelectorAll('.cmark.on, .cmark-blk.on').forEach(x => x.classList.remove('on')); // clear the previous active anchor (inline OR block)
2245
+ const a = commentAnchor(c);
2246
+ if (a && a.el){
2247
+ try { a.el.scrollIntoView({ behavior: prefersReduced() ? 'auto' : 'smooth', block: 'center' }); } catch {}
2248
+ a.el.classList && a.el.classList.add('on', 'hot'); setTimeout(() => a.el.classList && a.el.classList.remove('hot'), 1400);
2249
+ }
2250
+ document.querySelectorAll('.cnote.active').forEach(x => x.classList.remove('active'));
2251
+ const card = [...document.querySelectorAll('.cnote')].find(x => x.dataset.comment === id);
2252
+ if (card){ card.classList.add('active'); try { card.scrollIntoView({ block: 'nearest' }); } catch {} }
2253
+ }
2254
+ const openCommentPop = focusComment; // legacy callers (nav, marks) now link to the visible card
2255
+ // The comment count in the top controls changes as comments are created/resolved — keep
2256
+ // the navigator (and its prev/next) in sync without disturbing the rest of the controls.
2257
+ // View-aware: a brief uses briefControls(), a case uses caseControls().
2258
+ function refreshCommentNav(){
2259
+ setTopActions(S.brief ? briefControls() : S.readout ? readoutControls() : S.matchup ? matchupControls() : caseControls());
2260
+ wireCaseControls();
2261
+ if (!S.brief && !S.readout && !S.matchup) updateSaveDot();
2262
+ }
2263
+ function updateTocDots(){
2264
+ const toc = $('#toc'); if (!toc) return;
2265
+ const withC = sectionsWithComments();
2266
+ const live = liveHeadings();
2267
+ toc.querySelectorAll('a').forEach(a => {
2268
+ const has = withC.has(live[+a.dataset.hidx]); let dot = a.querySelector('.tdot');
2269
+ if (has && !dot){ dot = document.createElement('span'); dot.className = 'tdot'; dot.setAttribute('aria-label', 'has comments'); a.appendChild(dot); }
2270
+ else if (!has && dot) dot.remove();
2271
+ });
2272
+ }
2273
+ // The always-available "+" add-comment handle (Notion-style) that tracks the hovered
2274
+ // block — the primary discoverability affordance so annotating isn't hidden behind a
2275
+ // text selection. Created once; positioned by onDocHoverComment.
2276
+ let addHandle = null;
2277
+ function ensureAddHandle(gutter){
2278
+ if (addHandle && addHandle.isConnected) return;
2279
+ addHandle = document.createElement('button'); addHandle.className = 'cadd'; addHandle.type = 'button';
2280
+ addHandle.title = 'Comment on this block'; addHandle.setAttribute('aria-label', 'Comment on this block');
2281
+ addHandle.innerHTML = iconEl(ICON.comment);
2282
+ addHandle.addEventListener('mousedown', (e) => e.preventDefault());
2283
+ addHandle.addEventListener('click', (e) => { e.stopPropagation(); if (addHandle._blk) commentOnBlock(addHandle._blk); });
2284
+ gutter.appendChild(addHandle);
2285
+ }
2286
+ function onDocHoverComment(e){
2287
+ if (!addHandle) return;
2288
+ const sel = window.getSelection && window.getSelection();
2289
+ if (sel && !sel.isCollapsed){ addHandle.classList.remove('show'); return; } // a selection owns the affordance — never show both
2290
+ const blk = blockOf(e.target); const wrap = $('#cgutter') && $('#cgutter').parentElement;
2291
+ if (!blk || !wrap || blk.classList.contains('cnote-island')){ addHandle.classList.remove('show'); return; }
2292
+ const r = blk.getBoundingClientRect(), wrapRect = wrap.getBoundingClientRect();
2293
+ addHandle._blk = blk;
2294
+ // Sit the handle in the left gutter, vertically centred on the block. Its RIGHT edge is kept a
2295
+ // clear gap before the text's left edge so it never renders over the words; the canvas carries a
2296
+ // wider left padding (a comment gutter) so there's always room for this. Floored at the container
2297
+ // edge, and — if the doc is ever too tight — nudged as far left as needed to avoid overlap.
2298
+ const size = 26, gap = 12, edge = 2;
2299
+ const textLeft = r.left - wrapRect.left;
2300
+ let left = Math.max(edge, textLeft - gap - size);
2301
+ if (left + size > textLeft - 4) left = Math.max(0, textLeft - 4 - size); // hard no-overlap guard
2302
+ addHandle.style.top = Math.max(0, r.top - wrapRect.top + (r.height - size) / 2) + 'px';
2303
+ addHandle.style.left = left + 'px';
2304
+ addHandle.classList.add('show');
2305
+ }
2306
+ function hideAddHandle(){ if (addHandle) addHandle.classList.remove('show'); }
2307
+ // Comment on a whole block (from the + handle): anchor on the block's text + section.
2308
+ function commentOnBlock(blk){
2309
+ if (!blk) return;
2310
+ const quote = (blk.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 300);
2311
+ if (!quote) return;
2312
+ let rect; try { rect = blk.getBoundingClientRect(); } catch { rect = { left: 40, top: 80, bottom: 96 }; }
2313
+ pendingAnchor = { quote, heading: headingFor(blk), rect, blk, endBlk: blk, whole: true };
2314
+ openCommentComposer();
2315
+ }
2316
+ // Delete the reader's own comment outright (DELETE /api/comment) — it is removed from the
2317
+ // record, not resolved. The agent's resolution is a separate, agent-only action.
2318
+ async function deleteCommentUI(id){
2319
+ const file = S.recFile;
2320
+ const r = await api('DELETE', '/api/comment', { commentId: id });
2321
+ if (S.recFile !== file) return;
2322
+ if (r && r.ok){ dropComment(id); }
2323
+ else if (r && r.error === 'endpoint-missing') maybeStale();
2324
+ else showToast(commentErrorText(r));
2325
+ }
2326
+ // Remove a comment from the view (deleted by the reader, or resolved by the agent): drop
2327
+ // its state, unwrap its highlight, and re-render the layer / nav. Records the id so a LATE
2328
+ // SSE `comment` echo (which can arrive after a fast create→delete) can't resurrect it.
2329
+ const droppedComments = new Set();
2330
+ function dropComment(id){
2331
+ droppedComments.add(id);
2332
+ S.comments = (S.comments || []).filter(c => c.id !== id);
2333
+ const doc = $('#doc'); const mark = doc && doc.querySelector(`.cmark[data-comment="${id}"]`);
2334
+ if (mark){ const parent = mark.parentNode; while (mark.firstChild) parent.insertBefore(mark.firstChild, mark); parent.removeChild(mark); parent.normalize && parent.normalize(); }
2335
+ const blkMark = doc && doc.querySelector(`.cmark-blk[data-comment="${id}"]`); // whole-block anchor: clear its highlight too
2336
+ if (blkMark){ blkMark.classList.remove('cmark-blk', 'on', 'hot'); delete blkMark.dataset.comment; }
2337
+ renderComments();
2338
+ }
2339
+ // The comments navigator (prev / next in the top controls): step through open comments in
2340
+ // document order, scrolling to each and opening its card.
2341
+ function stepComment(dir){
2342
+ const doc = $('#doc'); if (!doc) return;
2343
+ const ordered = openCmts().map(c => ({ c, a: commentAnchor(c) })).filter(x => x.a)
2344
+ .sort((p, q) => (p.a.el.compareDocumentPosition(q.a.el) & Node.DOCUMENT_POSITION_FOLLOWING) ? -1 : 1);
2345
+ if (!ordered.length) return;
2346
+ S.cNavIdx = ((S.cNavIdx + dir) % ordered.length + ordered.length) % ordered.length;
2347
+ const target = ordered[S.cNavIdx];
2348
+ target.a.el.scrollIntoView && target.a.el.scrollIntoView({ behavior: prefersReduced() ? 'auto' : 'smooth', block: 'center' });
2349
+ openCommentPop(target.c.id);
2350
+ }
2351
+
2352
+ // ---- the selection Comment affordance + composer --------------------------
2353
+ let askBtn = null, askPop = null, pendingAnchor = null, speech = null;
2354
+ function hideAsk(){ hideSelectionAffordance(); closeAskPop(); }
2355
+ function closeAskPop(){ if (speech){ try { speech.stop(); } catch {} speech = null; } if (askPop){ askPop.remove(); askPop = null; } }
2356
+ // The nearest preceding heading of a node — the section a comment is anchored in.
2357
+ function headingFor(node){
2358
+ let el = node.nodeType === 3 ? node.parentElement : node;
2359
+ const doc = $('#doc'); if (!doc || !el) return '';
2360
+ const heads = [...doc.querySelectorAll('h1,h2,h3,h4,h5,h6')];
2361
+ let best = '';
2362
+ for (const h of heads){ if (h.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING) best = h.textContent.trim(); else break; }
2363
+ return best;
2364
+ }
2365
+ function maybeShowAsk(doc){
2366
+ if (askPop) return; // already composing
2367
+ const sel = window.getSelection && window.getSelection();
2368
+ if (!sel || sel.isCollapsed || !sel.rangeCount){ hideSelectionAffordance(); return; }
2369
+ const range = sel.getRangeAt(0);
2370
+ const root = $('#doc') || doc; // a selection may span sections → anchor on the whole document
2371
+ if (!root || !root.contains(range.commonAncestorContainer)){ hideSelectionAffordance(); return; }
2372
+ const quote = sel.toString().replace(/\s+/g, ' ').trim();
2373
+ if (quote.length < 2){ hideSelectionAffordance(); return; }
2374
+ let rect; try { rect = range.getBoundingClientRect(); } catch { rect = { left: 40, top: 80, bottom: 96 }; }
2375
+ pendingAnchor = { quote, heading: headingFor(range.startContainer), rect, blk: blockOf(range.startContainer), endBlk: blockOf(range.endContainer) };
2376
+ hideAddHandle(); // a selection owns the affordance — never show the gutter + handle at the same time
2377
+ // In edit mode the selection raises the formatting bubble (which carries its own
2378
+ // Comment button) — so the standalone Comment button never doubles up. Read-only
2379
+ // surfaces (briefs, a generating case) can't be formatted, so they keep the plain
2380
+ // Comment button.
2381
+ if (wysiwygCtl && wysiwygCtl.isEditable()) showBubble(rect);
2382
+ else showAskButton(rect);
2383
+ }
2384
+ // Tear down whichever selection affordance is up (the read-only Comment button OR the
2385
+ // edit-mode bubble). Called when the selection clears or a composer opens.
2386
+ function hideSelectionAffordance(){
2387
+ if (askBtn){ askBtn.remove(); askBtn = null; }
2388
+ hideBubble();
2389
+ }
2390
+ function showAskButton(rect){
2391
+ hideBubble();
2392
+ if (!askBtn){
2393
+ askBtn = document.createElement('button'); askBtn.className = 'askbtn';
2394
+ askBtn.innerHTML = iconEl(ICON.comment);
2395
+ askBtn.title = 'Comment'; askBtn.setAttribute('aria-label', 'Comment on selection');
2396
+ askBtn.addEventListener('mousedown', (e) => e.preventDefault());
2397
+ askBtn.addEventListener('click', openCommentComposer);
2398
+ document.body.appendChild(askBtn);
2399
+ }
2400
+ const vw = window.innerWidth || 1200;
2401
+ askBtn.style.left = Math.max(8, Math.min(rect.left, vw - 40)) + 'px';
2402
+ askBtn.style.top = Math.max(8, (rect.top || 0) - 36) + 'px';
2403
+ }
2404
+ // The selection formatting bubble (edit mode only): bold / italic / link / H2, then a
2405
+ // Comment button — the same composer the read-only Comment button opens. Buttons drive
2406
+ // the editor through the controller (never Tiptap directly). `mousedown` is prevented so
2407
+ // clicking a button doesn't collapse the selection it acts on.
2408
+ let bubbleBar = null;
2409
+ function showBubble(rect){
2410
+ if (askBtn){ askBtn.remove(); askBtn = null; }
2411
+ const ctl = wysiwygCtl; if (!ctl) return;
2412
+ if (!bubbleBar){
2413
+ bubbleBar = document.createElement('div'); bubbleBar.className = 'bubblebar'; bubbleBar.setAttribute('role', 'toolbar'); bubbleBar.setAttribute('aria-label', 'Format selection');
2414
+ const btn = (bb, icon, label) => `<button type="button" data-bb="${bb}" class="bbtn" title="${label}" aria-label="${label}">${iconEl(icon)}</button>`;
2415
+ bubbleBar.innerHTML =
2416
+ btn('bold', ICON.bold, 'Bold') + btn('italic', ICON.italic, 'Italic') +
2417
+ btn('link', ICON.link, 'Link') + btn('h2', ICON.heading, 'Heading') +
2418
+ `<span class="bbsep"></span>` + btn('comment', ICON.comment, 'Comment');
2419
+ bubbleBar.addEventListener('mousedown', (e) => e.preventDefault());
2420
+ bubbleBar.addEventListener('click', onBubbleClick);
2421
+ document.body.appendChild(bubbleBar);
2422
+ }
2423
+ paintBubbleState();
2424
+ const vw = window.innerWidth || 1200;
2425
+ const w = bubbleBar.offsetWidth || 200;
2426
+ bubbleBar.style.left = Math.max(8, Math.min((rect.left || 40), vw - w - 8)) + 'px';
2427
+ bubbleBar.style.top = Math.max(8, (rect.top || 0) - 42) + 'px';
2428
+ bubbleBar.classList.add('show');
2429
+ }
2430
+ function paintBubbleState(){
2431
+ if (!bubbleBar || !wysiwygCtl) return;
2432
+ const on = (bb, active) => { const b = bubbleBar.querySelector(`[data-bb="${bb}"]`); if (b) b.classList.toggle('on', !!active); };
2433
+ on('bold', wysiwygCtl.isActive('bold'));
2434
+ on('italic', wysiwygCtl.isActive('italic'));
2435
+ on('link', wysiwygCtl.isActive('link'));
2436
+ on('h2', wysiwygCtl.isActive('heading', { level: 2 }));
2437
+ }
2438
+ function onBubbleClick(e){
2439
+ const b = e.target.closest && e.target.closest('[data-bb]'); if (!b || !wysiwygCtl) return;
2440
+ const kind = b.dataset.bb;
2441
+ if (kind === 'comment'){ openCommentComposer(); return; }
2442
+ if (kind === 'bold') wysiwygCtl.toggleBold();
2443
+ else if (kind === 'italic') wysiwygCtl.toggleItalic();
2444
+ else if (kind === 'h2') wysiwygCtl.toggleHeading(2);
2445
+ else if (kind === 'link'){
2446
+ const cur = wysiwygCtl.isActive('link');
2447
+ const href = cur ? '' : (window.prompt && window.prompt('Link URL'));
2448
+ if (href === null || href === undefined) return; // cancelled
2449
+ wysiwygCtl.setLink(href);
2450
+ }
2451
+ paintBubbleState();
2452
+ }
2453
+ function hideBubble(){ if (bubbleBar){ bubbleBar.remove(); bubbleBar = null; } }
2454
+ // Open the comment composer on the block the caret sits in — the "@agent" mention's
2455
+ // landing (see at-mention.mjs). Reuses commentOnBlock so the anchor matches the + handle.
2456
+ function openAtComment(){
2457
+ const sel = window.getSelection && window.getSelection();
2458
+ const node = sel && sel.rangeCount ? (sel.anchorNode || sel.getRangeAt(0).startContainer) : null;
2459
+ const blk = node ? blockOf(node) : null;
2460
+ if (blk) commentOnBlock(blk);
2461
+ }
2462
+ // The in-table toolbar (edit mode only): add/remove rows & columns, or drop the table.
2463
+ // Shown whenever the caret is inside a table; hidden otherwise. Table structure editing
2464
+ // is a library capability — this only surfaces it.
2465
+ let tableTools = null;
2466
+ function maybeShowTableTools(){
2467
+ const ctl = wysiwygCtl;
2468
+ if (!ctl || !ctl.isEditable() || !ctl.isActive('table')){ hideTableTools(); return; }
2469
+ const sel = window.getSelection && window.getSelection();
2470
+ const node = sel && sel.rangeCount ? (sel.anchorNode || sel.getRangeAt(0).startContainer) : null;
2471
+ const el = node ? (node.nodeType === 3 ? node.parentElement : node) : null;
2472
+ const table = el && el.closest ? el.closest('#doc .ProseMirror table') : null;
2473
+ if (!table){ hideTableTools(); return; }
2474
+ showTableTools(table);
2475
+ }
2476
+ function showTableTools(table){
2477
+ if (!tableTools){
2478
+ tableTools = document.createElement('div'); tableTools.className = 'tabletools'; tableTools.setAttribute('role', 'toolbar'); tableTools.setAttribute('aria-label', 'Edit table');
2479
+ const b = (tt, label) => `<button type="button" data-tt="${tt}" class="ttbtn" title="${label}" aria-label="${label}">${label}</button>`;
2480
+ tableTools.innerHTML =
2481
+ b('row+', '+ Row') + b('col+', '+ Col') + b('row-', '- Row') + b('col-', '- Col') +
2482
+ `<span class="ttsep"></span>` +
2483
+ `<button type="button" data-tt="del" class="ttbtn danger" title="Delete table" aria-label="Delete table">${iconEl(ICON.trash)}</button>`;
2484
+ tableTools.addEventListener('mousedown', (e) => e.preventDefault());
2485
+ tableTools.addEventListener('click', onTableToolsClick);
2486
+ document.body.appendChild(tableTools);
2487
+ }
2488
+ let r; try { r = table.getBoundingClientRect(); } catch { r = { left: 40, top: 80 }; }
2489
+ const vw = window.innerWidth || 1200, w = tableTools.offsetWidth || 220;
2490
+ tableTools.style.left = Math.max(8, Math.min((r.left || 40), vw - w - 8)) + 'px';
2491
+ tableTools.style.top = Math.max(8, (r.top || 0) - 40) + 'px';
2492
+ tableTools.classList.add('show');
2493
+ }
2494
+ function onTableToolsClick(e){
2495
+ const b = e.target.closest && e.target.closest('[data-tt]'); if (!b || !wysiwygCtl) return;
2496
+ const tt = b.dataset.tt;
2497
+ if (tt === 'row+') wysiwygCtl.addRow();
2498
+ else if (tt === 'col+') wysiwygCtl.addColumn();
2499
+ else if (tt === 'row-') wysiwygCtl.deleteRow();
2500
+ else if (tt === 'col-') wysiwygCtl.deleteColumn();
2501
+ else if (tt === 'del'){ wysiwygCtl.deleteTable(); hideTableTools(); return; }
2502
+ requestAnimationFrame(maybeShowTableTools); // reposition after the structure changed
2503
+ }
2504
+ function hideTableTools(){ if (tableTools){ tableTools.remove(); tableTools = null; } }
2505
+ function openCommentComposer(){
2506
+ if (!pendingAnchor) return;
2507
+ hideSelectionAffordance();
2508
+ askPop = document.createElement('div'); askPop.className = 'askpop';
2509
+ const mic = supportsSpeech() ? `<button class="mic" id="askmic" title="Dictate" aria-label="Dictate">${iconEl(ICON.mic)}</button>` : '';
2510
+ askPop.innerHTML = `
2511
+ <div class="askhd">${iconEl(ICON.comment)}<span>Leave a comment for the agent to address</span></div>
2512
+ <div class="aq">${esc(pendingAnchor.quote)}</div>
2513
+ <textarea id="askta" rows="3" placeholder="A question or a change to make…"></textarea>
2514
+ <div class="arow">${mic}<span class="sp"></span>
2515
+ <button class="ghost" id="askcancel">Cancel</button>
2516
+ <button class="send" id="asksend">${iconEl(ICON.send)}Comment</button>
2517
+ </div>`;
2518
+ document.body.appendChild(askPop);
2519
+ const vw = window.innerWidth || 1200, vh = window.innerHeight || 800;
2520
+ const w = askPop.offsetWidth || 340, h = askPop.offsetHeight || 160;
2521
+ const r = pendingAnchor.rect;
2522
+ askPop.style.left = Math.max(8, Math.min((r.left || 40), vw - w - 8)) + 'px';
2523
+ askPop.style.top = Math.min((r.bottom || 96) + 8, vh - h - 8) + 'px';
2524
+ $('#askcancel').addEventListener('click', hideAsk);
2525
+ $('#asksend').addEventListener('click', submitComment);
2526
+ const ta = $('#askta');
2527
+ ta.addEventListener('keydown', (e) => {
2528
+ if (e.key === 'Escape'){ e.preventDefault(); hideAsk(); }
2529
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)){ e.preventDefault(); submitComment(); }
2530
+ });
2531
+ const mb = $('#askmic'); if (mb) mb.addEventListener('click', toggleMic);
2532
+ ta.focus();
2533
+ }
2534
+ async function submitComment(){
2535
+ const ta = $('#askta'); const body = (ta && ta.value || '').trim();
2536
+ if (!body || !pendingAnchor) return;
2537
+ const { line, endLine } = anchorSourceRange(pendingAnchor); // the source LINE RANGE the anchor spans (robust anchor)
2538
+ const anchor = { quote: pendingAnchor.quote.slice(0, 300), heading: pendingAnchor.heading, line, endLine };
2539
+ hideAsk();
2540
+ try { const sel = window.getSelection && window.getSelection(); sel && sel.removeAllRanges(); } catch {}
2541
+ await createComment(anchor.quote, anchor.heading, body, line, endLine);
2542
+ }
2543
+ // The source LINE RANGE [line, endLine] (0-based) a pending comment anchors to — the primary,
2544
+ // content-independent anchor (robust to duplicate headings / edited-away quotes). Derived from the
2545
+ // rendered block(s) the selection (or +-handle block) covers. { -1, -1 } when it can't be resolved
2546
+ // (e.g. a surface with no source-line model).
2547
+ function anchorSourceRange(pa){
2548
+ const start = pa && pa.blk ? sourceLineRange(pa.blk) : null;
2549
+ const end = pa && pa.endBlk ? sourceLineRange(pa.endBlk) : null;
2550
+ if (!start && !end) return { line: -1, endLine: -1 };
2551
+ const s = start || end, e = end || start;
2552
+ return { line: s.line, endLine: Math.max(s.endLine, e.endLine) };
2553
+ }
2554
+ // The 0-based source-line range a rendered block spans. Cases/files use the live S.items model
2555
+ // (file-absolute lines); briefs map the rendered block's position to the source it was rendered
2556
+ // from. null when the surface has no source-line model.
2557
+ function sourceLineRange(blk){
2558
+ if (!blk) return null;
2559
+ if (blk.dataset && blk.dataset.i != null && S.items && S.items.length){
2560
+ const i = +blk.dataset.i; const len = ((S.items[i] && S.items[i].lines) || ['']).length;
2561
+ const line = blockStartLine(i); return { line, endLine: line + Math.max(0, len - 1) };
2562
+ }
2563
+ if (S.brief || S.readout || S.matchup){ const map = briefBlockLineMap(); const idx = briefBlockIndex(blk); return (map && idx >= 0 && idx < map.length) ? map[idx] : null; }
2564
+ return null;
2565
+ }
2566
+ // The rendered block-children of a brief (in document order), excluding the diff/hint/comment
2567
+ // islands — a 1:1 match with briefBlockLineMap()'s source blocks.
2568
+ function briefBlockKids(){
2569
+ const doc = $('#doc'); if (!doc) return [];
2570
+ return [...doc.children].filter(el => el.nodeType === 1 && !el.classList.contains('diffdel') && !el.classList.contains('banner') && !el.classList.contains('cnote-island'));
2571
+ }
2572
+ function briefBlockIndex(blk){ return briefBlockKids().indexOf(blk); }
2573
+ // Per-rendered-block source line ranges for a brief, from the same segmentation the diff uses, so
2574
+ // index N of the map lines up with the Nth rendered block. Lines are relative to the rendered
2575
+ // (frontmatter-stripped) brief body.
2576
+ function briefBlockLineMap(){
2577
+ const doc = S.brief || S.readout || S.matchup;
2578
+ if (!doc) return null;
2579
+ const items = segment(stripFrontmatter(doc.text || ''));
2580
+ const map = []; let acc = 0;
2581
+ for (const it of items){
2582
+ const src = (it.lines || []).join('\n');
2583
+ if (it.kind === 'block' && !isMetaOnly(src)) map.push({ line: acc, endLine: acc + Math.max(0, (it.lines || []).length - 1) });
2584
+ acc += (it.lines || []).length;
2585
+ }
2586
+ return map;
2587
+ }
2588
+ function briefBlockAtLine(line){
2589
+ const map = briefBlockLineMap(); if (!map) return -1;
2590
+ for (let i = 0; i < map.length; i++){ if (line >= map[i].line && line <= map[i].endLine) return i; }
2591
+ return -1;
2592
+ }
2593
+ // Create an in-record comment on the current file: POST to the bridge, then reflect it
2594
+ // inline. On a stale server (404) surface the version-skew banner rather than a vanish.
2595
+ async function createComment(quote, heading, body, line, endLine){
2596
+ const file = S.recFile; if (!file) return;
2597
+ const r = await api('POST', '/api/comment', { file, quote, heading, body, line, endLine });
2598
+ if (S.recFile !== file) return;
2599
+ if (r && r.ok && r.commentId){
2600
+ // The SSE `comment` broadcast can arrive before this POST resolves, so dedup: only
2601
+ // add if that push hasn't already landed it (otherwise the same comment counts twice).
2602
+ if (!(S.comments || []).some(x => x.id === r.commentId))
2603
+ S.comments = [...(S.comments || []), { id: r.commentId, file: r.file, anchor: { quote, heading, line, endLine }, body, status: 'open', ts: Date.now() }];
2604
+ renderComments();
2605
+ requestAnimationFrame(() => { positionRailComments(); focusComment(r.commentId); }); // co-locate the new card once its highlight is laid out
2606
+ } else {
2607
+ if (r && r.error === 'endpoint-missing') maybeStale();
2608
+ else showToast(commentErrorText(r));
2609
+ }
2610
+ }
2611
+ function commentErrorText(r){
2612
+ if (!r || r.netError) return 'Couldn’t reach the workbench — is it still running?';
2613
+ if (r.error === 'invalid file' || r.error === 'no file') return 'Couldn’t save — this file can’t be annotated.';
2614
+ if (r.error === 'no project') return 'Couldn’t save — no project is open.';
2615
+ if (r.error) return 'Couldn’t save — ' + r.error + '.';
2616
+ return 'Couldn’t save the comment.';
2617
+ }
2618
+ // Voice input via the browser Web Speech API (progressive enhancement — the mic only
2619
+ // appears when supported). Click to dictate into the composer; click again to stop.
2620
+ function supportsSpeech(){ return !!(window.SpeechRecognition || window.webkitSpeechRecognition); }
2621
+ function toggleMic(){
2622
+ const SR = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SR) return;
2623
+ const btn = $('#askmic'), ta = $('#askta');
2624
+ if (speech){ try { speech.stop(); } catch {} speech = null; btn && btn.classList.remove('rec'); return; }
2625
+ speech = new SR(); speech.lang = 'en-US'; speech.interimResults = false;
2626
+ speech.onresult = (e) => { const tx = [...e.results].map(x => x[0].transcript).join(' ').trim(); if (ta && tx) ta.value = (ta.value ? ta.value + ' ' : '') + tx; };
2627
+ speech.onend = () => { btn && btn.classList.remove('rec'); speech = null; };
2628
+ speech.onerror = () => { btn && btn.classList.remove('rec'); speech = null; };
2629
+ try { speech.start(); btn && btn.classList.add('rec'); } catch { speech = null; }
2630
+ }
2631
+
2632
+ // ---- stale-server (version skew) banner -----------------------------------
2633
+ // Shown when the workbench is served by an out-of-date `serve` process (new UI, old
2634
+ // routes). Idempotent + dismissible; tells the user the one thing that fixes it.
2635
+ let staleShown = false;
2636
+ function maybeStale(){ if (staleShown) return; staleShown = true; showStaleBar(); }
2637
+ function showStaleBar(){
2638
+ if ($('#stalebar')) return;
2639
+ const bar = document.createElement('div');
2640
+ bar.id = 'stalebar'; bar.className = 'stalebar';
2641
+ bar.innerHTML = `<span>This editor is out of date — restart <code>${esc(SERVE_CMD)}</code> to reconnect the comment bridge.</span><button class="x" aria-label="Dismiss">${iconEl(ICON.x)}</button>`;
2642
+ document.body.appendChild(bar);
2643
+ bar.querySelector('.x').addEventListener('click', () => bar.remove());
2644
+ }
2645
+ // A brief transient message (comment save error, agent addressing) — auto-dismisses.
2646
+ let toastTimer = null;
2647
+ function showToast(text, opts){
2648
+ let t = $('#dlbtoast');
2649
+ if (!t){ t = document.createElement('div'); t.id = 'dlbtoast'; t.className = 'caddr'; document.body.appendChild(t); }
2650
+ t.innerHTML = (opts && opts.spin ? `<span class="spin"></span>` : '') + `<span>${esc(text)}</span>`;
2651
+ clearTimeout(toastTimer); toastTimer = setTimeout(() => { t.remove(); }, (opts && opts.ms) || 3600);
2652
+ }
2653
+ // A reusable, accessible confirmation dialog for destructive actions: a native <dialog>
2654
+ // (focus-trapped + Escape-to-cancel + backdrop-click cancels), returning a Promise<boolean>.
2655
+ function confirmDialog({ title, body, confirmLabel = 'Confirm', cancelLabel = 'Cancel', danger = false }){
2656
+ return new Promise((resolve) => {
2657
+ const dlg = document.createElement('dialog');
2658
+ dlg.className = 'confirm';
2659
+ dlg.innerHTML = `<form method="dialog" class="confirm-in">
2660
+ <h2>${esc(title)}</h2>
2661
+ <div class="confirm-b">${body}</div>
2662
+ <div class="confirm-actions">
2663
+ <button type="button" class="cbtn ghost" data-x="0">${esc(cancelLabel)}</button>
2664
+ <button type="submit" class="cbtn ${danger ? 'danger' : 'primary'}" data-x="1">${esc(confirmLabel)}</button>
2665
+ </div>
2666
+ </form>`;
2667
+ document.body.appendChild(dlg);
2668
+ let done = false;
2669
+ const finish = (v) => { if (done) return; done = true; try { dlg.close(); } catch {} dlg.remove(); resolve(v); };
2670
+ dlg.querySelector('[data-x="0"]').addEventListener('click', () => finish(false));
2671
+ dlg.querySelector('form').addEventListener('submit', (e) => { e.preventDefault(); finish(true); });
2672
+ dlg.addEventListener('cancel', (e) => { e.preventDefault(); finish(false); }); // Escape
2673
+ dlg.addEventListener('click', (e) => { if (e.target === dlg) finish(false); }); // backdrop
2674
+ try { dlg.showModal(); } catch { dlg.setAttribute('open', ''); } // jsdom / no <dialog>
2675
+ (dlg.querySelector('[data-x="1"]') || dlg).focus();
2676
+ });
2677
+ }
2678
+ // A reusable single-line text-input dialog (native <dialog>, focus-trapped, Escape/backdrop
2679
+ // cancels) → Promise<string|null>. Used to name a new file. An optional `prefix` shows the
2680
+ // fixed folder the name is created under (read-only context, not part of the returned value).
2681
+ function promptDialog({ title, label, placeholder = '', value = '', prefix = '', confirmLabel = 'Create', selectEnd = null }){
2682
+ return new Promise((resolve) => {
2683
+ const dlg = document.createElement('dialog');
2684
+ dlg.className = 'confirm prompt';
2685
+ dlg.innerHTML = `<form method="dialog" class="confirm-in">
2686
+ <h2>${esc(title)}</h2>
2687
+ ${prefix ? `<div class="prompt-path"><span class="prompt-path-cap">In</span><span class="prompt-path-val">${esc(prefix.replace(/\/$/, ''))}/</span></div>` : ''}
2688
+ <label class="prompt-l">${esc(label)}
2689
+ <span class="prompt-row"><input class="prompt-i" type="text" value="${esc(value)}" placeholder="${esc(placeholder)}" autocomplete="off" spellcheck="false"></span>
2690
+ </label>
2691
+ <div class="confirm-actions">
2692
+ <button type="button" class="cbtn ghost" data-x="0">Cancel</button>
2693
+ <button type="submit" class="cbtn primary" data-x="1">${esc(confirmLabel)}</button>
2694
+ </div>
2695
+ </form>`;
2696
+ document.body.appendChild(dlg);
2697
+ const input = dlg.querySelector('.prompt-i');
2698
+ let done = false;
2699
+ const finish = (v) => { if (done) return; done = true; try { dlg.close(); } catch {} dlg.remove(); resolve(v); };
2700
+ dlg.querySelector('[data-x="0"]').addEventListener('click', () => finish(null));
2701
+ dlg.querySelector('form').addEventListener('submit', (e) => { e.preventDefault(); const val = input.value.trim(); finish(val || null); });
2702
+ dlg.addEventListener('cancel', (e) => { e.preventDefault(); finish(null); }); // Escape
2703
+ dlg.addEventListener('click', (e) => { if (e.target === dlg) finish(null); }); // backdrop
2704
+ try { dlg.showModal(); } catch { dlg.setAttribute('open', ''); } // jsdom / no <dialog>
2705
+ // Focus the field; when renaming, pre-select the editable part (the name without its
2706
+ // extension) so a retype keeps `.md` — otherwise leave the caret at the end.
2707
+ setTimeout(() => { try { input.focus(); if (selectEnd != null) input.setSelectionRange(0, selectEnd); } catch {} }, 0);
2708
+ });
2709
+ }
2710
+ // A server-side folder browser → Promise<string|null> (the chosen absolute path). A browser
2711
+ // can't return a real filesystem path from a native picker, and the daemon needs one — so the
2712
+ // daemon lists directories (/api/browse) and the user drills in:
2713
+ // click a folder to enter it, the breadcrumb / Backspace to go up, then "Open this folder".
2714
+ function folderDialog(startPath){
2715
+ return new Promise((resolve) => {
2716
+ const opener = document.activeElement; // restore focus on close (WCAG)
2717
+ const dlg = document.createElement('dialog');
2718
+ dlg.className = 'confirm folderpick';
2719
+ dlg.innerHTML = `<div class="confirm-in fp">
2720
+ <div class="fp-head">
2721
+ <h2>Open folder</h2>
2722
+ <nav class="fp-crumbs" aria-label="Folder path"></nav>
2723
+ </div>
2724
+ <div class="fp-body"><ul class="fp-list" aria-label="Sub-folders"></ul></div>
2725
+ <div class="fp-foot">
2726
+ <div class="confirm-actions">
2727
+ <button type="button" class="cbtn ghost" data-x="0">Cancel</button>
2728
+ <button type="button" class="cbtn primary" data-x="1">Open this folder</button>
2729
+ </div>
2730
+ </div>
2731
+ </div>`;
2732
+ document.body.appendChild(dlg);
2733
+ const crumbs = dlg.querySelector('.fp-crumbs');
2734
+ const list = dlg.querySelector('.fp-list');
2735
+ const body = dlg.querySelector('.fp-body');
2736
+ const openBtn = dlg.querySelector('[data-x="1"]');
2737
+ let cur = null, home = null, parentPath = null, sel = -1, rows = [], done = false;
2738
+ const finish = (v) => { if (done) return; done = true; try { dlg.close(); } catch {} dlg.remove(); try { opener && opener.focus(); } catch {} resolve(v); };
2739
+ const sep = () => `<span class="fp-sep">${iconEl(ICON.chevron)}</span>`;
2740
+
2741
+ function renderCrumbs(path){
2742
+ const segs = path.split('/').filter(Boolean);
2743
+ let acc = '';
2744
+ const parts = [`<button type="button" class="fp-crumb" data-path="${esc(home || '')}">Home</button>`];
2745
+ segs.forEach((s, i) => { acc += '/' + s; parts.push(sep(), `<button type="button" class="fp-crumb" data-path="${esc(acc)}"${i === segs.length - 1 ? ' aria-current="true"' : ''}>${esc(s)}</button>`); });
2746
+ crumbs.innerHTML = parts.join('');
2747
+ crumbs.querySelectorAll('.fp-crumb').forEach(b => b.addEventListener('click', () => { const p = b.dataset.path || null; if (p !== cur) load(p); }));
2748
+ }
2749
+ function renderRows(entries){
2750
+ rows = entries; sel = -1;
2751
+ if (!entries.length){ list.innerHTML = `<li class="fp-empty">No sub-folders here. Open this folder, or go up.</li>`; return; }
2752
+ list.innerHTML = entries.map((e, i) =>
2753
+ `<li><button type="button" class="fp-row" data-i="${i}" title="${esc(e.name)}">
2754
+ <span class="fp-fico">${iconEl(ICON.folder)}</span><span class="fp-name">${esc(e.name)}</span><span class="fp-go">${iconEl(ICON.chevron)}</span>
2755
+ </button></li>`).join('');
2756
+ list.querySelectorAll('.fp-row').forEach(r => r.addEventListener('click', () => load(entries[+r.dataset.i].path)));
2757
+ }
2758
+ async function load(path){
2759
+ dlg.classList.add('fp-loading');
2760
+ let r; try { r = await api('GET', '/api/browse' + (path ? '?path=' + encodeURIComponent(path) : '')); } catch { r = { error: 'offline' }; }
2761
+ dlg.classList.remove('fp-loading');
2762
+ if (!r || r.error){
2763
+ if (cur == null){ crumbs.innerHTML = ''; list.innerHTML = `<li class="fp-empty">Couldn’t read that folder.</li>`; }
2764
+ else showToast('Couldn’t open that folder.');
2765
+ return;
2766
+ }
2767
+ cur = r.path; home = r.home || home; parentPath = r.parent || null;
2768
+ renderCrumbs(cur); renderRows(r.entries); body.scrollTop = 0;
2769
+ }
2770
+ dlg.addEventListener('keydown', (e) => {
2771
+ if (e.key === 'Escape'){ e.preventDefault(); return finish(null); }
2772
+ if (e.key === 'Backspace'){ e.preventDefault(); if (parentPath) load(parentPath); return; }
2773
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp'){
2774
+ e.preventDefault(); if (!rows.length) return;
2775
+ sel = e.key === 'ArrowDown' ? Math.min(rows.length - 1, sel + 1) : Math.max(0, sel < 0 ? 0 : sel - 1);
2776
+ const els = list.querySelectorAll('.fp-row'); els.forEach(x => x.classList.remove('sel'));
2777
+ const el = els[sel]; if (el){ el.classList.add('sel'); el.scrollIntoView({ block: 'nearest' }); el.focus(); }
2778
+ return;
2779
+ }
2780
+ if (e.key === 'Enter' && sel >= 0 && rows[sel]){ e.preventDefault(); load(rows[sel].path); }
2781
+ });
2782
+ dlg.querySelector('[data-x="0"]').addEventListener('click', () => finish(null));
2783
+ openBtn.addEventListener('click', () => finish(cur));
2784
+ dlg.addEventListener('cancel', (e) => { e.preventDefault(); finish(null); }); // Escape (native)
2785
+ dlg.addEventListener('click', (e) => { if (e.target === dlg) finish(null); }); // backdrop
2786
+ try { dlg.showModal(); } catch { dlg.setAttribute('open', ''); }
2787
+ load(startPath || null);
2788
+ setTimeout(() => { try { openBtn.focus(); } catch {} }, 0);
2789
+ });
2790
+ }
2791
+ // ---- Explorer file management (create / delete) ---------------------------
2792
+ // Create a new markdown file, optionally under `dir` (a project-relative folder, or the
2793
+ // content root from the header "New file"). `.md` is appended when omitted. On success the
2794
+ // The New file / New folder chooser — shared by the Explorer header + button and each folder
2795
+ // row's + action, so both offer the same two options anchored under the clicked button.
2796
+ function openNewMenu(dir, x, y){
2797
+ openMenu(x, y, [
2798
+ { label: 'New file…', icon: 'newfile', act: () => promptNewFile(dir) },
2799
+ { label: 'New folder…', icon: 'newfolder', act: () => promptNewFolder(dir) },
2800
+ ]);
2801
+ }
2802
+ // tree refreshes and the new file opens (pinned) for editing.
2803
+ async function promptNewFile(dir){
2804
+ const prefix = dir ? dir.replace(/\/?$/, '/') : '';
2805
+ const name = await promptDialog({ title: 'New file', label: 'File name', placeholder: 'notes.md', prefix });
2806
+ if (name == null) return;
2807
+ let rel = (prefix + name).replace(/\/{2,}/g, '/').replace(/^\//, '').trim();
2808
+ if (!rel) return;
2809
+ if (!/\.md$/i.test(rel)) rel += '.md';
2810
+ const r = await api('POST', '/api/create', { path: rel, text: '' });
2811
+ if (!r || r.error){
2812
+ showToast(r && r.error === 'exists' ? 'A file with that name already exists.'
2813
+ : r && r.error === 'endpoint-missing' ? `Editor is out of date — restart ${SERVE_CMD}.`
2814
+ : 'Could not create the file.');
2815
+ return;
2816
+ }
2817
+ TEL.event('ui.action', { area:'explorer', action:'create-file' });
2818
+ await refreshTree();
2819
+ openFileTab(r.path, { newTab: true });
2820
+ }
2821
+ // Create a new folder, optionally under `dir`. A folder is shown in the Explorer only once it
2822
+ // holds a Markdown file (the tree shows exactly the folders on a path to a `.md`), so creating a
2823
+ // folder flows straight into naming its first file — otherwise the new folder would be invisible.
2824
+ // Cancelling the file step leaves an empty folder on disk that simply doesn't appear until it has
2825
+ // content.
2826
+ async function promptNewFolder(dir){
2827
+ const prefix = dir ? dir.replace(/\/?$/, '/') : '';
2828
+ const name = await promptDialog({ title: 'New folder', label: 'Folder name', placeholder: 'ideas', prefix });
2829
+ if (name == null) return;
2830
+ const rel = (prefix + name).replace(/\/{2,}/g, '/').replace(/^\//, '').replace(/\/$/, '').trim();
2831
+ if (!rel) return;
2832
+ const r = await api('POST', '/api/mkdir', { path: rel });
2833
+ if (!r || r.error){
2834
+ showToast(r && r.error === 'exists' ? 'A folder with that name already exists.'
2835
+ : r && r.error === 'endpoint-missing' ? `Editor is out of date — restart ${SERVE_CMD}.`
2836
+ : 'Could not create the folder.');
2837
+ return;
2838
+ }
2839
+ TEL.event('ui.action', { area:'explorer', action:'create-folder' });
2840
+ if (dir) ensureFolderOpen(dir); // reveal the parent
2841
+ ensureFolderOpen(r.path); // and pre-open the new folder
2842
+ await promptNewFile(r.path); // name its first file so the folder appears
2843
+ }
2844
+ // Delete a file or a folder (recursive) from the Explorer — destructive, so it confirms
2845
+ // first. On success: close any tabs pointing inside it, refresh the tree, and toast.
2846
+ async function confirmDeleteEntry(path, isDir){
2847
+ if (!path) return;
2848
+ const ok = await confirmDialog({
2849
+ title: isDir ? 'Delete folder?' : 'Delete file?',
2850
+ body: isDir
2851
+ ? `This permanently deletes the folder <b>${esc(baseName(path))}</b> and everything inside it. This can’t be undone.`
2852
+ : `This permanently deletes <b>${esc(baseName(path))}</b>. This can’t be undone.`,
2853
+ confirmLabel: 'Delete', danger: true,
2854
+ });
2855
+ if (!ok) return;
2856
+ const r = await api('DELETE', '/api/file', { path });
2857
+ if (!r || r.error){
2858
+ showToast(r && r.error === 'endpoint-missing' ? `Editor is out of date — restart ${SERVE_CMD}.` : 'Could not delete.');
2859
+ return;
2860
+ }
2861
+ TEL.event('ui.action', { area:'explorer', action:isDir ? 'delete-folder' : 'delete-file' });
2862
+ dropFileTabs(id => id === path || id.startsWith(path + '/'));
2863
+ await refreshTree();
2864
+ showToast(isDir ? 'Folder deleted.' : 'File deleted.');
2865
+ }
2866
+ // Close every open FILE tab whose path matches `pred` (a deleted file / folder), fixing the
2867
+ // active-tab index and navigating away if the active tab was the one closed.
2868
+ function dropFileTabs(pred){
2869
+ for (let i = S.tabs.length - 1; i >= 0; i--){
2870
+ const t = S.tabs[i];
2871
+ if (t.kind === 'file' && pred(t.id)) closeTab(i);
2872
+ }
2873
+ }
2874
+
2875
+ // ---- the comment bridge over SSE (server → browser push) ------------------
2876
+ // Debounced refresh of the Explorer's comment markers/filter after any comment change (on any
2877
+ // file). The open document's own comments are handled separately (S.comments + renderComments).
2878
+ let cmtCountTimer = null;
2879
+ function scheduleCommentCountsRefresh(){
2880
+ if (cmtCountTimer) return;
2881
+ cmtCountTimer = setTimeout(async () => { cmtCountTimer = null; await loadCommentCounts(); fillExplorer(); if (S.commentFilter) renderSidebar(); }, 300);
2882
+ }
2883
+ function initEvents(){
2884
+ if (!window.EventSource) return;
2885
+ try { if (S.es) S.es.close(); } catch {}
2886
+ try { S.es = new EventSource('/api/events'); } catch { S.es = null; return; }
2887
+ S.es.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } handleBridgeEvent(m); };
2888
+ S.es.onerror = () => { /* the browser auto-reconnects */ };
2889
+ }
2890
+ function handleBridgeEvent(m){
2891
+ if (!m || !m.type) return;
2892
+ if (m.type === 'fs'){ scheduleTreeRefresh(); scheduleGitSync(); scheduleDocReload(); return; } // disk changed → refresh Explorer, the open file's diff-state, and its content
2893
+ if (m.type === 'addressing'){ showToast('Agent is working through your comments…', { spin: true, ms: 8000 }); return; }
2894
+ if (m.type === 'comment' || m.type === 'resolve' || m.type === 'delete') scheduleCommentCountsRefresh(); // keep Explorer markers/filter fresh for ANY file
2895
+ if (m.file && m.file !== S.recFile) return; // an event for a file we're not viewing
2896
+ if (m.type === 'resolve'){
2897
+ dropComment(m.commentId);
2898
+ if (m.revised) reloadRecord();
2899
+ } else if (m.type === 'delete'){
2900
+ dropComment(m.commentId);
2901
+ } else if (m.type === 'comment'){
2902
+ if (droppedComments.has(m.comment.id)) return; // a late echo of a just-deleted comment
2903
+ const at = (S.comments || []).findIndex(x => x.id === m.comment.id);
2904
+ if (at >= 0){ S.comments[at] = m.comment; renderComments(); } // an EDIT echo → update in place
2905
+ else { S.comments = [...(S.comments || []), m.comment]; renderComments(); } // a new comment
2906
+ }
2907
+ }
2908
+ // The agent may edit analysis.md while addressing a comment; re-pull the record so the
2909
+ // live document and diff reflect it (reviewable before the user commits).
2910
+ async function reloadRecord(){
2911
+ if (wysiwygFocused()) return; // never yank a live edit out from under the caret
2912
+ const id = S.caseId; let rec;
2913
+ try { rec = await api('GET', '/api/record?id=' + encodeURIComponent(id)); } catch { return; }
2914
+ if (S.caseId !== id || !rec || rec.error) return;
2915
+ if (rec.text === reconstruct(S.items)) { S.rec = rec; return; } // no external change (e.g. our own save) → don't flicker
2916
+ S.rec = rec; if (rec.text) S.items = segment(rec.text);
2917
+ renderLive(); buildToc(); renderComments();
2918
+ if (S.mode === 'diff') loadDiffBase().then(refreshDiffDecorations);
2919
+ // The agent just wrote the file → the git change set moved; refresh it so the Explorer's
2920
+ // diff mode + badges stay accurate.
2921
+ if (S.isGit){ await loadGit(); if (S.diffMode) renderSidebar(); }
2922
+ }
2923
+ // Keep the OPEN document in sync with the file on disk when it changes OUTSIDE the app (an
2924
+ // editor, git checkout, the agent). Debounced, driven by the `fs` bridge event + the poll.
2925
+ // Never clobbers unsaved local edits (skips while dirty/saving), and skips identical content so
2926
+ // the app's own autosave doesn't cause a reload/flicker.
2927
+ let docReloadTimer = null;
2928
+ function scheduleDocReload(){ if (docReloadTimer) return; docReloadTimer = setTimeout(() => { docReloadTimer = null; reloadOpenDoc(); }, 200); }
2929
+ async function reloadOpenDoc(){
2930
+ if (S.dirty || S.saving || wysiwygFocused()) return; // don't overwrite unsaved / in-flight / actively-edited docs
2931
+ if (S.caseId) return reloadRecord();
2932
+ if (S.brief){ const id = S.brief.id; let rec; try { rec = await api('GET', '/api/brief?id=' + encodeURIComponent(id)); } catch { return; } if (S.brief?.id === id && rec && !rec.error && rec.text !== (S.brief.text || '')){ S.brief = { id, ...rec }; paintBrief(); } return; }
2933
+ if (S.readout){ const id = S.readout.id; let rec; try { rec = await api('GET', '/api/readout?id=' + encodeURIComponent(id)); } catch { return; } if (S.readout?.id === id && rec && !rec.error && rec.text !== (S.readout.text || '')){ S.readout = { id, ...rec }; paintReadout(); } return; }
2934
+ if (S.matchup){ const id = S.matchup.id; let rec; try { rec = await api('GET', '/api/matchup?id=' + encodeURIComponent(id)); } catch { return; } if (S.matchup?.id === id && rec && !rec.error && rec.text !== (S.matchup.text || '')){ S.matchup = { id, ...rec }; paintMatchup(); } return; }
2935
+ if (S.file && !S.file.deleted){
2936
+ const path = S.file.path; let rec;
2937
+ try { rec = await api('GET', '/api/file?path=' + encodeURIComponent(path)); } catch { return; }
2938
+ if (!rec || rec.error || !S.file || S.file.path !== path) return;
2939
+ S.isGit = !!rec.isGit;
2940
+ if (rec.text === reconstruct(S.items)) return; // unchanged (e.g. our own save)
2941
+ S.file = { path, ...rec }; S.recFile = rec.path || path;
2942
+ S.items = rec.text ? segment(rec.text) : [{ kind: 'block', lines: [''] }];
2943
+ renderLive(); buildToc(); renderComments();
2944
+ if (S.mode === 'diff') loadDiffBase().then(refreshDiffDecorations);
2945
+ }
2946
+ }
2947
+ // The project-relative file of the record currently open (case / brief / plain file).
2948
+ function recFile(){ return S.caseId ? (S.rec && S.rec.file) : S.recFile; }
2949
+ // True when that file has uncommitted changes (drives the Discard button).
2950
+ function recFileChanged(){ const f = recFile(); return !!(f && (S.git.changed || []).some(c => c.path === f)); }
2951
+ // ---- Diff mode = the LIVE EDITOR with change decorations ------------------
2952
+ // Diff mode is NOT a separate view: it is the exact same live-preview document (same layout,
2953
+ // typography, editing, checkboxes, comments, table-of-contents). The ONLY difference is a
2954
+ // decoration pass layered on top: blocks ADDED since the last commit get a green wash, MODIFIED
2955
+ // blocks a green wash above their removed (red) form, and removed blocks are shown inline as
2956
+ // read-only red decorations. `S.diffBase` caches the committed (HEAD) text for the open record;
2957
+ // the working side is always the live S.items, so highlights update as you type.
2958
+ async function loadDiffBase(){
2959
+ S.diffBase = null;
2960
+ const q = S.caseId ? ('id=' + encodeURIComponent(S.caseId)) : ('path=' + encodeURIComponent(recFile() || ''));
2961
+ let d; try { d = await api('GET', '/api/diff?' + q); } catch { d = null; }
2962
+ if (d && d.isGit && typeof d.base === 'string') S.diffBase = d.base.replace(/\r\n?/g, '\n');
2963
+ }
2964
+ // Apply (or refresh) the change decorations over the live document: serialize the Tiptap doc to
2965
+ // per-block Markdown, LCS-diff it against the committed base (normalized the SAME way via
2966
+ // mdBlocks), then hand the result to the editor's diff plugin — added/modified blocks get a
2967
+ // class, removed blocks render as read-only red islands before the block that follows them.
2968
+ // Block-level. `animate` is accepted for call-site compatibility (the plugin owns its reveal).
2969
+ function applyDiffDecorations(animate){
2970
+ const ctl = wysiwygCtl; if (!ctl) return;
2971
+ if (S.mode !== 'diff' || S.diffBase == null){ ctl.setDiff(null); return; }
2972
+ const oldBlocks = mdBlocks(S.diffBase);
2973
+ const newBlocks = ctl.blockSources();
2974
+ const ops = lcsDiff(oldBlocks, newBlocks);
2975
+ const nodeClasses = new Array(newBlocks.length).fill('');
2976
+ const deletions = []; let ni = 0, pendingDel = [];
2977
+ const flush = () => {
2978
+ if (!pendingDel.length) return;
2979
+ const html = pendingDel.map(src => `<div class="diffdel" role="note" aria-label="Removed since the last commit">${renderMarkdown(src) || '<p class="dempty">(removed blank line)</p>'}</div>`).join('');
2980
+ deletions.push({ at: ni < newBlocks.length ? ni : -1, html, key: String(deletions.length) });
2981
+ pendingDel = [];
2982
+ };
2983
+ for (const o of ops){
2984
+ if (o.op === 'del'){ pendingDel.push(o.a); continue; }
2985
+ const hadDel = pendingDel.length > 0;
2986
+ flush();
2987
+ if (o.op === 'add') nodeClasses[ni] = hadDel ? 'diff-mod' : 'diff-add';
2988
+ ni++;
2989
+ }
2990
+ flush();
2991
+ ctl.setDiff({ nodeClasses, deletions, count: newBlocks.length });
2992
+ }
2993
+ // Re-apply the change decorations (and rebuild the ToC so removed-heading decorations are
2994
+ // excluded). Used after entering diff mode and after edits.
2995
+ function refreshDiffDecorations(){ if (S.mode === 'diff'){ applyDiffDecorations(true); buildToc(); reserveRailForControls(); } }
2996
+ // Longest-common-subsequence diff of two arrays → an ordered list of {op:'eq'|'del'|'add'}.
2997
+ // Used for both the block-level rendered diff and the line-level change counts. Guarded against a
2998
+ // pathological product so a huge file never blocks the UI (it degrades to a whole-document swap).
2999
+ function lcsDiff(a, b){
3000
+ const n = a.length, m = b.length;
3001
+ if (n * m > 4000000){ return [...a.map(x => ({ op:'del', a:x })), ...b.map(x => ({ op:'add', b:x }))]; }
3002
+ const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
3003
+ for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--)
3004
+ dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
3005
+ const out = []; let i = 0, j = 0;
3006
+ while (i < n && j < m){
3007
+ if (a[i] === b[j]){ out.push({ op:'eq', a:a[i], b:b[j] }); i++; j++; }
3008
+ else if (dp[i + 1][j] >= dp[i][j + 1]){ out.push({ op:'del', a:a[i] }); i++; }
3009
+ else { out.push({ op:'add', b:b[j] }); j++; }
3010
+ }
3011
+ while (i < n){ out.push({ op:'del', a:a[i] }); i++; }
3012
+ while (j < m){ out.push({ op:'add', b:b[j] }); j++; }
3013
+ return out;
3014
+ }
3015
+
3016
+ // The Document/Diff toggle (git repos only) + a Discard button in Diff view when
3017
+ // the file actually has uncommitted changes. Shared by the case, brief and file surfaces.
3018
+ function viewSeg(){
3019
+ if (!S.isGit) return '';
3020
+ const deleted = !!(S.file && S.file.deleted);
3021
+ const discard = ((S.mode === 'diff' || deleted) && recFileChanged())
3022
+ ? `<button type="button" class="tbtn danger" data-discard title="${deleted ? 'Restore this file from the last commit' : 'Discard all uncommitted changes in this file'}">${iconEl(ICON.revert)}${deleted ? 'Restore file' : 'Discard changes'}</button>` : '';
3023
+ // A deleted file has no Document surface — only its removal Diff (+ a Restore).
3024
+ if (deleted) return `${discard}<span class="rochip" title="This file was deleted — reviewing its removal">${iconEl(ICON.trash)}Deleted</span>`;
3025
+ // A single compact toggle (not a two-button bar): the source-control-over-a-file glyph flips
3026
+ // this file between its rendered document and its diff-against-committed. It's DISABLED when the
3027
+ // file has no uncommitted changes (nothing to diff — no empty "no changes" dead-end); enabled
3028
+ // with a gold dot when it does; pressed (ember) while the diff is showing.
3029
+ const inDiff = S.mode === 'diff';
3030
+ const changed = recFileChanged() || !!S.dirty; // git-tracked change OR unsaved local edits (instant)
3031
+ const label = !changed ? 'No uncommitted changes in this file'
3032
+ : inDiff ? 'Show the document' : 'Show changes in this file';
3033
+ const dot = (changed && !inDiff) ? '<span class="cdot" aria-hidden="true"></span>' : '';
3034
+ return `${discard}<button type="button" class="viewtoggle ${inDiff ? 'on' : ''}" data-view="${inDiff ? 'doc' : 'diff'}"
3035
+ aria-pressed="${inDiff}" ${changed ? '' : 'disabled'} title="${label}" aria-label="${label}">${iconEl(ICON.gitbranch)}${dot}</button>`;
3036
+ }
3037
+ // True when `path` is in the git change set as a removal (status includes 'D').
3038
+ function isDeletedChange(path){ return (S.git.changed || []).some(c => c.path === path && c.status.includes('D')); }
3039
+ // Re-paint whichever record surface is open (used by setMode + after a discard).
3040
+ function repaintRecord(){ if (S.caseId) paintCase(); else if (S.brief) paintBrief(); else if (S.readout) paintReadout(); else if (S.matchup) paintMatchup(); else if (S.file) paintFile(); }
3041
+ // The default view mode when opening a record: Diff when the Explorer is in diff mode
3042
+ // (reviewing changes), else the Document.
3043
+ function defaultRecordMode(){ return (S.diffMode && S.isGit) ? 'diff' : 'doc'; }
3044
+
3045
+ // ---- autosave -------------------------------------------------------------
3046
+ function scheduleSave(){ clearTimeout(S.saveTimer); S.saveTimer = setTimeout(saveNow, 600); }
3047
+ async function saveNow(){
3048
+ if (!S.dirty || docReadOnly()) return;
3049
+ const text = currentSource();
3050
+ if (S.caseId){
3051
+ const caseId = S.caseId; // capture: the case may switch during the async POST
3052
+ S.saving = true; updateSaveDot();
3053
+ try { await api('POST', '/api/record', { id: caseId, text }); if (S.caseId === caseId){ S.dirty = false; S.items = segment(text); if (S.rec) S.rec.exists = true; } TEL.count('autosaves', 1); }
3054
+ catch {}
3055
+ if (S.caseId === caseId){ S.saving = false; updateSaveDot(); await refreshDocDiffAfterSave(); }
3056
+ return;
3057
+ }
3058
+ if (S.file){
3059
+ const path = S.file.path; // capture: the file may switch during the async POST
3060
+ S.saving = true; updateSaveDot();
3061
+ try { await api('POST', '/api/file', { path, text }); if (S.file && S.file.path === path){ S.dirty = false; S.file.text = text; S.items = segment(text); } TEL.count('autosaves', 1); }
3062
+ catch {}
3063
+ if (S.file && S.file.path === path){ S.saving = false; updateSaveDot(); await refreshDocDiffAfterSave(); }
3064
+ }
3065
+ }
3066
+ // After an autosave, the file's diff-state may have changed in EITHER direction — a clean file now
3067
+ // differs, or an edited file was reverted back to its committed content. Refresh git status so the
3068
+ // canvas Document⇄Diff toggle reflects reality (loadGit re-syncs it). Cheap; the watcher filters
3069
+ // .git so this never triggers a refresh loop.
3070
+ async function refreshDocDiffAfterSave(){
3071
+ if (S.isGit){ try { await loadGit(); } catch { /* poll covers it */ } }
3072
+ syncDocDiff();
3073
+ }
3074
+ // Best-effort synchronous flush for tab-close / navigation-away, when a debounced save
3075
+ // would otherwise be dropped. sendBeacon survives unload; a sync XHR is the fallback.
3076
+ function flushOnUnload(){
3077
+ TEL.beacon(); // drain any buffered content-free telemetry
3078
+ // Buffered project config (Explorer state, …): beacon the pending patch so it survives unload.
3079
+ if (Object.keys(S.cfgPending).length){
3080
+ try {
3081
+ const patch = S.cfgPending; S.cfgPending = {};
3082
+ const body = JSON.stringify({ patch });
3083
+ if (navigator.sendBeacon){ navigator.sendBeacon('/api/config', new Blob([body], { type: 'application/json' })); }
3084
+ else { const x = new XMLHttpRequest(); x.open('POST', '/api/config', false); x.setRequestHeader('content-type', 'application/json'); x.send(body); }
3085
+ } catch {}
3086
+ }
3087
+ if (S.saveTimer){ clearTimeout(S.saveTimer); S.saveTimer = null; }
3088
+ if (!S.dirty || docReadOnly()) return;
3089
+ const target = S.caseId ? { url: '/api/record', body: { id: S.caseId, text: currentSource() } }
3090
+ : S.file ? { url: '/api/file', body: { path: S.file.path, text: currentSource() } } : null;
3091
+ if (!target) return;
3092
+ try {
3093
+ const body = JSON.stringify(target.body);
3094
+ if (navigator.sendBeacon){ navigator.sendBeacon(target.url, new Blob([body], { type: 'application/json' })); }
3095
+ else { const x = new XMLHttpRequest(); x.open('POST', target.url, false); x.setRequestHeader('content-type', 'application/json'); x.send(body); }
3096
+ S.dirty = false;
3097
+ } catch {}
3098
+ }
3099
+ function updateSaveDot(){
3100
+ const d = $('#savedot'), l = $('#savelbl'); if (!d) return;
3101
+ d.classList.toggle('dirty', S.dirty && !S.saving);
3102
+ d.classList.toggle('saving', S.saving);
3103
+ if (l) l.textContent = S.saving ? 'Saving…' : S.dirty ? 'Unsaved' : '';
3104
+ }
3105
+
3106
+ // ---- shell helpers --------------------------------------------------------
3107
+ function renderMain(html){ const m = $('#main'); if (m) m.innerHTML = html; const dd = $('#docdiff'); if (dd){ dd.hidden = true; dd.innerHTML = ''; } updateTabMark(); }
3108
+ // The Document⇄Diff toggle (+ its Discard action) rendered into the reading-canvas corner
3109
+ // (#docdiff), not the tab bar. Shown only for a git-backed open record; hidden on the inbox and
3110
+ // empty states (renderMain resets it). Called by wireCaseControls, so it re-renders on every
3111
+ // paint and on setMode — staying present across the document AND diff views.
3112
+ function renderCanvasDiff(){
3113
+ const host = $('#docdiff'); if (!host) return;
3114
+ const caseCtl = S.caseId ? caseCanvasControls() : ''; // verdict pill + prototype (in the canvas corner)
3115
+ const seg = (S.isGit && (S.file || S.brief || S.readout || S.matchup || S.caseId)) ? viewSeg() : '';
3116
+ const cnav = commentNavCmd(); // comment count + prev/next, beside the Diff toggle
3117
+ const markup = caseCtl + cnav + seg;
3118
+ host.innerHTML = markup; host.hidden = !markup;
3119
+ if (!markup) return;
3120
+ host.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => setMode(b.dataset.view)));
3121
+ host.querySelectorAll('[data-cnav]').forEach(b => b.addEventListener('click', () => stepComment(b.dataset.cnav === 'prev' ? -1 : 1)));
3122
+ const dc = host.querySelector('[data-discard]'); if (dc) dc.addEventListener('click', () => confirmDiscard());
3123
+ const sj = host.querySelector('[data-scorejump]'); if (sj) sj.addEventListener('click', jumpToReasoning);
3124
+ const pb = host.querySelector('[data-proto]');
3125
+ if (pb && !(S.proto && S.proto.external)){ pb.addEventListener('click', () => openProto()); pb.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' '){ e.preventDefault(); openProto(); } }); }
3126
+ const bp = host.querySelector('[data-buildproto]'); if (bp) bp.addEventListener('click', () => showBuildProto(bp));
3127
+ reserveRailForControls();
3128
+ }
3129
+ // Reactively reflect the current file's change-state in the canvas Document⇄Diff toggle WITHOUT a
3130
+ // full re-render (cheap enough to run per keystroke) — so it ENABLES the instant the file is edited
3131
+ // (S.dirty) or git reports it changed, instead of only after a refresh. Never disabled mid-diff.
3132
+ function syncDocDiff(){
3133
+ const changed = recFileChanged() || !!S.dirty;
3134
+ // Changes vanished (reverted, discarded, or committed) while the diff was showing → the diff is
3135
+ // now empty, so fall back to the document view. setMode repaints (which re-renders this control).
3136
+ if (!changed && S.mode === 'diff' && (S.file || S.caseId || S.brief || S.readout || S.matchup)){ setMode('doc'); return; }
3137
+ const host = $('#docdiff'); if (!host || host.hidden) return;
3138
+ const btn = host.querySelector('.viewtoggle'); if (!btn) return;
3139
+ const inDiff = S.mode === 'diff';
3140
+ btn.disabled = !changed && !inDiff;
3141
+ const label = !changed ? 'No uncommitted changes in this file' : inDiff ? 'Show the document' : 'Show changes in this file';
3142
+ btn.title = label; btn.setAttribute('aria-label', label);
3143
+ let dot = btn.querySelector('.cdot');
3144
+ if (changed && !inDiff){ if (!dot){ dot = document.createElement('span'); dot.className = 'cdot'; dot.setAttribute('aria-hidden', 'true'); btn.appendChild(dot); } }
3145
+ else if (dot){ dot.remove(); }
3146
+ }
3147
+ function emptyState(h, p){ return `<div class="canvaswrap"><div class="empty"><h2>${esc(h)}</h2><p>${p}</p></div></div>`; }
3148
+ // The branded empty surface — the ember mark + injected product name as a calm welcome,
3149
+ // shown on the empty Inbox (the app's home). `sub` is a short line; `hint` is trusted inline
3150
+ // HTML (it may carry a <code> command). Its counterpart, the tab-bar wordmark, is hidden while
3151
+ // a `.hero` is on screen so brand presence is never doubled (see updateTabMark).
3152
+ function brandedEmpty(sub, hint, actions){
3153
+ const name = esc(BRAND);
3154
+ return `<div class="canvaswrap"><div class="hero">
3155
+ <span class="hero-mark" aria-hidden="true"><i></i></span>
3156
+ <h1 class="hero-name">${name}</h1>
3157
+ ${sub ? `<p class="hero-sub">${esc(sub)}</p>` : ''}
3158
+ ${actions ? `<div class="hero-actions">${actions}</div>` : ''}
3159
+ ${hint ? `<p class="hero-hint">${hint}</p>` : ''}
3160
+ </div></div>`;
3161
+ }
3162
+ function openNav(){ document.body.classList.add('nav-open'); }
3163
+ function closeNav(){ document.body.classList.remove('nav-open'); }
3164
+
3165
+ // ---- client telemetry (content-free; batched OFF the interaction hot path) ----------------
3166
+ // The browser can't reach the OTLP endpoint (no direct network, no secrets), so it accumulates
3167
+ // content-free events + aggregate rollups and POSTs them to /api/telemetry, where the server
3168
+ // re-emits them through the shared client (re-validating the schema allow-list). Enqueue is O(1);
3169
+ // the network flush is debounced + coalesced, and a pagehide beacon drains the tail — so telemetry
3170
+ // never sits on the render/interaction path (INP is a hard priority). No-op when consent is off.
3171
+ const TEL = (() => {
3172
+ let enabled = false, queue = [], timer = null;
3173
+ const inFlight = new Set();
3174
+ const counters = Object.create(null), gauges = Object.create(null), histos = Object.create(null);
3175
+ const clear = () => { queue = []; for (const k of Object.keys(counters)) delete counters[k]; for (const k of Object.keys(gauges)) delete gauges[k]; for (const k of Object.keys(histos)) delete histos[k]; };
3176
+ const pending = () => queue.length || Object.keys(counters).length || Object.keys(gauges).length || Object.keys(histos).length;
3177
+ const schedule = () => { if (!enabled || timer) return; timer = setTimeout(() => { timer = null; flush(); }, 10000); if (timer && timer.unref) timer.unref(); };
3178
+ const drain = () => { const body = { events: queue, counters: { ...counters }, gauges: { ...gauges }, histograms: { ...histos } }; clear(); return body; };
3179
+ async function flush(){
3180
+ if (enabled && pending()) {
3181
+ const body = drain();
3182
+ try {
3183
+ let request;
3184
+ request = api('POST', '/api/telemetry', body).catch(() => {}).finally(() => inFlight.delete(request));
3185
+ inFlight.add(request);
3186
+ } catch { /* best-effort */ }
3187
+ }
3188
+ if (inFlight.size) await Promise.all([...inFlight]);
3189
+ }
3190
+ function beacon(){
3191
+ if (!enabled || !pending()) return;
3192
+ const body = drain();
3193
+ try {
3194
+ if (navigator.sendBeacon) navigator.sendBeacon('/api/telemetry', new Blob([JSON.stringify(body)], { type: 'application/json' }));
3195
+ else api('POST', '/api/telemetry', body);
3196
+ } catch { /* best-effort */ }
3197
+ }
3198
+ // Best-effort interaction-latency (INP-ish) sampling — aggregated into a histogram, never one
3199
+ // event per interaction. Guarded: absent in jsdom/older browsers, so it simply doesn't run.
3200
+ const startINP = () => {
3201
+ if (typeof PerformanceObserver !== 'function') return;
3202
+ try {
3203
+ const po = new PerformanceObserver((list) => { for (const e of list.getEntries()) { if (e.duration) observe('inp_ms', Math.round(e.duration)); } });
3204
+ po.observe({ type: 'event', buffered: true, durationThreshold: 40 });
3205
+ } catch { /* unsupported entry type → skip */ }
3206
+ };
3207
+ function event(name, props){ if (!enabled) return; queue.push({ name, props: props || {} }); if (queue.length >= 40) flush(); else schedule(); }
3208
+ function count(metric, n){ if (!enabled) return; counters[metric] = (counters[metric] || 0) + (Number(n) || 0); schedule(); }
3209
+ function gauge(metric, v){ if (!enabled) return; const x = Number(v); if (!Number.isFinite(x) || x < 0) return; gauges[metric] = x; schedule(); }
3210
+ function observe(metric, v){ if (!enabled) return; const x = Number(v); if (!Number.isFinite(x)) return; (histos[metric] || (histos[metric] = [])).push(x); schedule(); }
3211
+ return {
3212
+ setEnabled(v){ const was = enabled; enabled = !!v; if (!enabled) clear(); else if (!was) startINP(); },
3213
+ isEnabled(){ return enabled; },
3214
+ event, count, gauge, observe, flush, beacon,
3215
+ _buffers(){ return { queue, counters, gauges, histos, inFlight }; },
3216
+ };
3217
+ })();
3218
+
3219
+ // ---- feedback + telemetry consent (the two user-facing surfaces) --------------------------
3220
+ // Feedback is a first-class tab so users can switch away and return without losing their draft.
3221
+ // User-authored words stay verbatim; the category-specific prompt only nudges useful framing.
3222
+ function renderFeedback(){
3223
+ setTopActions('');
3224
+ const d = S.feedbackDraft || { category:'other', message:'', sentiment:null, contact:'' };
3225
+ const prompts = {
3226
+ other: 'Tell us what is working, what is not, or anything else you want us to know.',
3227
+ idea: 'What are you trying to achieve, and what gets in the way today? Describe the problem—not the solution you think should be built.',
3228
+ bug: 'What happened? Describe what you expected, what happened instead, and the precise steps to reproduce it.',
3229
+ };
3230
+ const types = [
3231
+ { value:'other', label:'General', note:'Thoughts, questions, or praise' },
3232
+ { value:'idea', label:'Feature request', note:'A problem you cannot solve today' },
3233
+ { value:'bug', label:'Bug report', note:'Something is not working' },
3234
+ ];
3235
+ const sentiments = [
3236
+ { value:'up', label:'Positive', icon:'thumbUp' },
3237
+ { value:'down', label:'Could be better', icon:'thumbDown' },
3238
+ { value:'excited', label:'Excited', icon:'rocket' },
3239
+ ];
3240
+ renderMain(`<div class="feedback-wrap"><form class="feedback-page" id="feedback-page" novalidate>
3241
+ <header class="feedback-head">
3242
+ <h1>Send feedback</h1><p>Tell us what would make Sonorance more useful.</p>
3243
+ </header>
3244
+ <div class="feedback-form">
3245
+ <fieldset class="feedback-type"><legend>What kind of feedback?</legend>
3246
+ <div class="feedback-type-options" role="radiogroup" aria-label="Feedback type">
3247
+ ${types.map((type) => `<button type="button" class="feedback-type-option ${d.category === type.value ? 'selected' : ''}" role="radio" aria-checked="${d.category === type.value}" tabindex="${d.category === type.value ? 0 : -1}" data-feedback-category="${type.value}">
3248
+ <strong>${type.label}</strong><small>${type.note}</small>
3249
+ </button>`).join('')}
3250
+ </div>
3251
+ </fieldset>
3252
+ <label class="feedback-field" id="fbmsgl"><span>${d.category === 'bug' ? 'What went wrong?' : 'Your feedback'}</span>
3253
+ <textarea id="fbmsg" rows="6" placeholder="${esc(prompts[d.category] || prompts.other)}">${esc(d.message)}</textarea>
3254
+ </label>
3255
+ <label class="feedback-field compact"><span>Email <em>Optional, for a reply</em></span>
3256
+ <input type="email" id="fbcontact" value="${esc(d.contact)}" placeholder="you@example.com" autocomplete="email">
3257
+ </label>
3258
+ <fieldset class="feedback-sentiment"><legend>Sentiment <span>Optional</span></legend>
3259
+ <div class="feedback-sentiment-options" role="group" aria-label="Sentiment">
3260
+ ${sentiments.map((item) => `<button type="button" class="feedback-sentiment-option ${d.sentiment === item.value ? 'selected' : ''}" data-sentiment="${item.value}" aria-label="${item.label}" aria-pressed="${d.sentiment === item.value}" title="${item.label}">${iconEl(ICON[item.icon])}<span>${item.label}</span></button>`).join('')}
3261
+ </div>
3262
+ </fieldset>
3263
+ <div class="feedback-trust">${iconEl(ICON.check)}<span>Only this form is submitted. Sonorance never reads your documents to create feedback.</span></div>
3264
+ <p class="fb-note" id="fbhint" role="status"></p>
3265
+ <div class="fb-actions"><button class="fb-send tbtn" id="fbsend" type="submit">${iconEl(ICON.send)}Send feedback</button></div>
3266
+ </div>
3267
+ </form></div>`);
3268
+ const wrap = $('#feedback-page');
3269
+ const categoryButtons = [...wrap.querySelectorAll('[data-feedback-category]')];
3270
+ const captureDraft = () => {
3271
+ S.feedbackDraft = {
3272
+ category:wrap.dataset.category,
3273
+ message:wrap.querySelector('#fbmsg').value,
3274
+ sentiment:wrap.dataset.sentiment || null,
3275
+ contact:wrap.querySelector('#fbcontact').value,
3276
+ };
3277
+ };
3278
+ const selectCategory = (button) => {
3279
+ const category = button.dataset.feedbackCategory;
3280
+ wrap.dataset.category = category;
3281
+ categoryButtons.forEach((option) => {
3282
+ const selected = option === button;
3283
+ option.classList.toggle('selected', selected);
3284
+ option.setAttribute('aria-checked', String(selected));
3285
+ option.tabIndex = selected ? 0 : -1;
3286
+ });
3287
+ wrap.querySelector('#fbmsgl span').textContent = category === 'bug' ? 'What went wrong?' : 'Your feedback';
3288
+ wrap.querySelector('#fbmsg').placeholder = prompts[category] || prompts.other;
3289
+ captureDraft();
3290
+ };
3291
+ wrap.dataset.category = d.category;
3292
+ wrap.dataset.sentiment = d.sentiment || '';
3293
+ categoryButtons.forEach((button, index) => {
3294
+ button.addEventListener('click', () => selectCategory(button));
3295
+ button.addEventListener('keydown', (event) => {
3296
+ let next = null;
3297
+ if (event.key === 'ArrowRight' || event.key === 'ArrowDown') next = (index + 1) % categoryButtons.length;
3298
+ else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') next = (index - 1 + categoryButtons.length) % categoryButtons.length;
3299
+ else if (event.key === 'Home') next = 0;
3300
+ else if (event.key === 'End') next = categoryButtons.length - 1;
3301
+ if (next == null) return;
3302
+ event.preventDefault();
3303
+ selectCategory(categoryButtons[next]);
3304
+ categoryButtons[next].focus();
3305
+ });
3306
+ });
3307
+ wrap.querySelectorAll('textarea,input[type="email"]').forEach((el) => el.addEventListener('input', captureDraft));
3308
+ wrap.querySelectorAll('[data-sentiment]').forEach((button) => button.addEventListener('click', () => {
3309
+ const sentiment = wrap.dataset.sentiment === button.dataset.sentiment ? '' : button.dataset.sentiment;
3310
+ wrap.dataset.sentiment = sentiment;
3311
+ wrap.querySelectorAll('[data-sentiment]').forEach((option) => {
3312
+ const selected = option.dataset.sentiment === sentiment;
3313
+ option.classList.toggle('selected', selected);
3314
+ option.setAttribute('aria-pressed', String(selected));
3315
+ });
3316
+ captureDraft();
3317
+ }));
3318
+ wrap.addEventListener('submit', (event) => { event.preventDefault(); captureDraft(); submitFeedbackUI(wrap, wrap.dataset.sentiment || null); });
3319
+ }
3320
+ function openFeedback(){ openFeedbackTab(); }
3321
+ function closeFeedback(){
3322
+ const idx = S.tabs.findIndex(t => t.kind === 'feedback');
3323
+ if (idx >= 0) closeTab(idx);
3324
+ }
3325
+ async function submitFeedbackUI(wrap, sentiment){
3326
+ const cat = wrap.dataset.category;
3327
+ const message = wrap.querySelector('#fbmsg').value.trim();
3328
+ const hint = wrap.querySelector('#fbhint');
3329
+ if (!message){ hint.textContent = 'Please add a few words before sending.'; hint.classList.add('warn'); return; }
3330
+ const payload = { category: cat, message, rating: sentiment, contact: wrap.querySelector('#fbcontact').value.trim() || null };
3331
+ const send = wrap.querySelector('#fbsend'); send.disabled = true; send.textContent = 'Sending…';
3332
+ const r = await api('POST', '/api/feedback', payload);
3333
+ if (r && r.ok){ S.feedbackDraft = null; renderFeedback(); toast('Thanks — your feedback was sent.'); }
3334
+ else { send.disabled = false; send.innerHTML = `${iconEl(ICON.send)}Send feedback`; hint.textContent = 'Could not send right now — it was saved locally and you can try again.'; hint.classList.add('warn'); }
3335
+ }
3336
+ // A quiet, self-dismissing confirmation (bottom-center). No dependency on any framework.
3337
+ function toast(msg){
3338
+ let t = $('#dlbtoast'); if (t) t.remove();
3339
+ t = document.createElement('div'); t.id = 'dlbtoast'; t.className = 'dlb-toast'; t.textContent = msg;
3340
+ document.body.appendChild(t);
3341
+ requestAnimationFrame(() => t.classList.add('show'));
3342
+ setTimeout(() => { t.classList.remove('show'); setTimeout(() => t.remove(), 300); }, 2600);
3343
+ }
3344
+ // Flip the opt-out choice: persist server-side (shared with the CLI) + update the live client.
3345
+ async function setTelemetryConsent(enabled){
3346
+ const previous = !(S.telemetry && S.telemetry.enabled === false);
3347
+ const r = await api('POST', '/api/consent', { enabled: !!enabled });
3348
+ if (!r || !r.ok) return previous;
3349
+ const on = r.enabled !== false;
3350
+ S.telemetry = { enabled: on };
3351
+ TEL.setEnabled(on);
3352
+ return on;
3353
+ }
3354
+
3355
+ // ---- boot -----------------------------------------------------------------
3356
+ async function boot(){
3357
+ lastTelemetryPage = null;
3358
+ applyTheme(themePref());
3359
+ applySavedSidebarWidth();
3360
+ initSidebarResize();
3361
+ $('#scrim') && $('#scrim').addEventListener('click', closeNav);
3362
+ const add = $('#tabadd'); if (add && !add.__wired){ add.__wired = true; add.addEventListener('click', openInboxTab); }
3363
+ const tsl = $('#tabscrollL'); if (tsl && !tsl.__wired){ tsl.__wired = true; tsl.addEventListener('click', () => nudgeTabs(-1)); }
3364
+ const tsr = $('#tabscrollR'); if (tsr && !tsr.__wired){ tsr.__wired = true; tsr.addEventListener('click', () => nudgeTabs(1)); }
3365
+ const ts = $('#tabs'); if (ts && !ts.__wired){ ts.__wired = true; ts.addEventListener('scroll', updateTabScroll, { passive: true }); }
3366
+ const nb = $('#navbtn'); if (nb && !nb.__wired){ nb.__wired = true; nb.addEventListener('click', openNav); }
3367
+ const ib = $('#inboxbtn'); if (ib && !ib.__wired){ ib.__wired = true; ib.addEventListener('click', () => { closeNav(); openInboxTab(); }); }
3368
+ const sb = $('#settingsbtn'); if (sb && !sb.__wired){ sb.__wired = true; sb.addEventListener('click', openSettingsTab); }
3369
+ const fbb = $('#feedbackbtn'); if (fbb && !fbb.__wired){ fbb.__wired = true; fbb.addEventListener('click', openFeedbackTab); }
3370
+ if (!document.__selWired){ document.__selWired = true; document.addEventListener('selectionchange', onDocSelectionChange); }
3371
+ if (!window.__saveGuardWired){
3372
+ window.__saveGuardWired = true;
3373
+ window.addEventListener('pagehide', flushOnUnload);
3374
+ window.addEventListener('beforeunload', flushOnUnload);
3375
+ document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') flushOnUnload(); });
3376
+ }
3377
+ initAtmosphere();
3378
+ {
3379
+ S.projectSlug = route().project || null; // from /p/<slug>/… — scopes the first /api/state below
3380
+ const st = await api('GET', '/api/state');
3381
+ // Version-skew guard: a stale `serve` process serves the fresh UI but old routes. It
3382
+ // returns /api/state without the `bridge` capability (or 404s it entirely) — warn the
3383
+ // user to restart, instead of silently 404-ing every ask (the "disappears" bug).
3384
+ if (st && (st.error === 'endpoint-missing' || (!st.error && !st.netError && st.bridge !== true))) maybeStale();
3385
+ if (st && !st.error){
3386
+ Object.assign(S, { project: st.project, projects: st.projects || [], cases: st.cases || [], briefs: st.briefs || [], readouts: st.readouts || [], matchups: st.matchups || [], kinds: st.kinds || [], isGit: !!st.isGit });
3387
+ S.telemetry = st.telemetry || { enabled: true }; // content-free product telemetry (opt-out); UI mirrors the server's resolved state
3388
+ TEL.setEnabled(S.telemetry.enabled !== false);
3389
+ S.projectSlug = st.project ? st.project.id : null; // canonical slug from the server (may differ from the URL)
3390
+ try { document.title = BRAND; } catch {}
3391
+ // Restore persisted tabs, dropping any that point at cases/briefs no longer present.
3392
+ S.tabs = (st.tabs || []).filter(t => t && (t.kind === 'inbox' || t.kind === 'settings' || t.kind === 'feedback' || t.kind === 'file'
3393
+ || (t.kind === 'brief' ? S.briefs.some(b => b.id === t.id) : t.kind === 'readout' ? S.readouts.some(r => r.id === t.id) : t.kind === 'matchup' ? S.matchups.some(m => m.id === t.id) : S.cases.some(s => s.id === t.id))))
3394
+ .map(t => ({ kind: t.kind, id: t.id || undefined }));
3395
+ S.activeTab = -1;
3396
+ S._bootActive = st.activeTab || null; // this project's last-focused tab {kind,id}
3397
+ // Restore the Explorer collapse/expand state (baseline mode + exceptions). A project with
3398
+ // no saved state defaults to fully collapsed.
3399
+ S.explorer = (st.explorer && st.explorer.mode)
3400
+ ? { mode: st.explorer.mode === 'expanded' ? 'expanded' : 'collapsed', ex: new Set(Array.isArray(st.explorer.ex) ? st.explorer.ex : []) }
3401
+ : { mode: 'collapsed', ex: new Set() };
3402
+ try {
3403
+ const tr = await api('GET', '/api/tree');
3404
+ S.tree = (tr && !tr.error && tr.tree) || [];
3405
+ S.treeBase = (tr && typeof tr.base === 'string') ? tr.base : '';
3406
+ S.inventory = (tr && tr.inventory) || { cases: S.cases.length, briefs: S.briefs.length, readouts: S.readouts.length, matchups: S.matchups.length };
3407
+ } catch {
3408
+ S.tree = []; S.treeBase = '';
3409
+ S.inventory = { cases: S.cases.length, briefs: S.briefs.length, readouts: S.readouts.length, matchups: S.matchups.length };
3410
+ }
3411
+ await loadGit(); // current branch + changed set (for the diff-mode toggle)
3412
+ await loadCommentCounts(); // per-file open-comment counts (Explorer markers + filter)
3413
+ } else { S.cases = []; S.briefs = []; S.readouts = []; S.matchups = []; S.inventory = null; S.kinds = []; S.tree = []; S.projects = []; S.tabs = []; S._bootActive = null; S.explorer = { mode:'collapsed', ex:new Set() }; }
3414
+ }
3415
+ initEvents();
3416
+ startTreePolling();
3417
+ startConfigFlush();
3418
+ renderSidebar();
3419
+ renderTabs();
3420
+ restoreBootRoute();
3421
+ await render();
3422
+ captureProjectInventory();
3423
+ }
3424
+ // On (re)boot — including a project switch — the URL may point at the previous project's
3425
+ // case. Respect only a deep link valid in THIS project; otherwise re-focus the project's
3426
+ // own last-active tab (or its first tab, or home). This keeps tabs strictly per-project.
3427
+ function specRoute(spec){ return spec.kind === 'inbox' ? '/inbox' : spec.kind === 'settings' ? '/settings' : spec.kind === 'feedback' ? '/feedback' : spec.kind === 'file' ? '/file/' + encFilePath(spec.id) : (spec.kind === 'brief' ? '/brief/' : spec.kind === 'readout' ? '/readout/' : spec.kind === 'matchup' ? '/matchup/' : '/case/') + spec.id; }
3428
+ function tabSpecValid(spec){
3429
+ if (!spec) return false;
3430
+ if (spec.kind === 'inbox') return S.tabs.some(t => t.kind === 'inbox');
3431
+ if (spec.kind === 'settings') return S.tabs.some(t => t.kind === 'settings');
3432
+ if (spec.kind === 'feedback') return S.tabs.some(t => t.kind === 'feedback');
3433
+ if (spec.kind === 'file') return S.tabs.some(t => t.kind === 'file' && t.id === spec.id);
3434
+ if (spec.kind === 'brief') return S.briefs.some(b => b.id === spec.id) && S.tabs.some(t => t.kind === 'brief' && t.id === spec.id);
3435
+ if (spec.kind === 'readout') return S.readouts.some(r => r.id === spec.id) && S.tabs.some(t => t.kind === 'readout' && t.id === spec.id);
3436
+ if (spec.kind === 'matchup') return S.matchups.some(m => m.id === spec.id) && S.tabs.some(t => t.kind === 'matchup' && t.id === spec.id);
3437
+ return S.cases.some(s => s.id === spec.id) && S.tabs.some(t => t.kind === 'case' && t.id === spec.id);
3438
+ }
3439
+ function restoreBootRoute(){
3440
+ const PFX = S.project ? '/p/' + encodeURIComponent(S.project.id) : '';
3441
+ const r = route();
3442
+ const innerValid =
3443
+ (r.name === 'case' && S.cases.some(s => s.id === r.id)) ||
3444
+ (r.name === 'brief' && S.briefs.some(b => b.id === r.id)) ||
3445
+ (r.name === 'readout' && S.readouts.some(x => x.id === r.id)) ||
3446
+ (r.name === 'matchup' && S.matchups.some(m => m.id === r.id)) ||
3447
+ (r.name === 'file' && S.tabs.some(t => t.kind === 'file' && t.id === r.id)) ||
3448
+ (r.name === 'inbox' && S.tabs.some(t => t.kind === 'inbox')) ||
3449
+ r.name === 'settings' ||
3450
+ r.name === 'feedback';
3451
+ let inner = null;
3452
+ if (innerValid) inner = specRoute({ kind: r.name, id: r.id }); // a valid deep link into THIS project
3453
+ else if (tabSpecValid(S._bootActive)) inner = specRoute(S._bootActive); // else the project's last-focused tab
3454
+ else if (S.tabs.length) inner = specRoute({ kind: S.tabs[0].kind, id: S.tabs[0].id });
3455
+ const dest = PFX + (inner || '/'); // always carries the /p/<slug> prefix
3456
+ if (location.pathname !== dest) history.replaceState({}, '', dest);
3457
+ }
3458
+ // Subtle parallax: the main scroll gently drifts the atmospheric backdrop (--py),
3459
+ // rAF-throttled and disabled under reduced-motion. Also drops blur while resizing.
3460
+ function initAtmosphere(){
3461
+ let reduce = false;
3462
+ try { reduce = window.matchMedia('(prefers-reduced-motion:reduce)').matches; } catch {}
3463
+ const root = document.documentElement;
3464
+ if (!reduce){
3465
+ let ticking = false, idle = null;
3466
+ const main = $('#main');
3467
+ const onScroll = () => {
3468
+ // Pause the glass backdrop-blur while the parallax is moving the atmosphere (it makes
3469
+ // re-blur cheap → smooth scroll); restore it shortly after scrolling stops.
3470
+ if (!root.classList.contains('scrolling')) root.classList.add('scrolling');
3471
+ clearTimeout(idle); idle = setTimeout(() => root.classList.remove('scrolling'), 140);
3472
+ if (ticking) return; ticking = true;
3473
+ requestAnimationFrame(() => {
3474
+ const y = main ? main.scrollTop : 0;
3475
+ root.style.setProperty('--py', String(Math.min(y * 0.46, 720)));
3476
+ ticking = false;
3477
+ });
3478
+ };
3479
+ main && main.addEventListener('scroll', onScroll, { passive:true });
3480
+ }
3481
+ let rt;
3482
+ window.addEventListener('resize', () => {
3483
+ root.classList.add('resizing');
3484
+ measureTabMark(); updateTabScroll();
3485
+ clearTimeout(rt); rt = setTimeout(() => { root.classList.remove('resizing'); applyTrimTitles(); measureTabMark(); updateTabScroll(); reserveRailForControls(); if (S.file || S.caseId || S.brief || S.readout || S.matchup) renderComments(); }, 180);
3486
+ }, { passive:true });
3487
+ }
3488
+ // ---- resizable sidebar -----------------------------------------------------
3489
+ // The left rail is drag-resizable via the #sbresize divider on its boundary. Width lives
3490
+ // in the `--sb` custom property (the grid's first column) and is clamped to [--sb-min,
3491
+ // --sb-max] and persisted. Keyboard: ←/→ nudge, Home/End jump to the bounds, double-click
3492
+ // resets to the default.
3493
+ const SB_DEFAULT = 288;
3494
+ function clampSb(w){
3495
+ const cs = getComputedStyle(document.documentElement);
3496
+ const min = parseInt(cs.getPropertyValue('--sb-min')) || 220;
3497
+ const max = parseInt(cs.getPropertyValue('--sb-max')) || 560;
3498
+ return Math.max(min, Math.min(max, Math.round(w)));
3499
+ }
3500
+ function curSb(){ return parseInt(getComputedStyle(document.documentElement).getPropertyValue('--sb')) || SB_DEFAULT; }
3501
+ function setSidebarWidth(w, persist){
3502
+ const px = clampSb(w);
3503
+ document.documentElement.style.setProperty('--sb', px + 'px');
3504
+ if (persist){ try { localStorage.setItem('dlb-sb', String(px)); } catch {} }
3505
+ applyTrimTitles();
3506
+ measureTabMark();
3507
+ return px;
3508
+ }
3509
+ function applySavedSidebarWidth(){
3510
+ let w; try { w = parseInt(localStorage.getItem('dlb-sb') || '', 10); } catch {}
3511
+ if (w) document.documentElement.style.setProperty('--sb', clampSb(w) + 'px');
3512
+ }
3513
+ function initSidebarResize(){
3514
+ const h = $('#sbresize'); if (!h || h.__wired) return; h.__wired = true;
3515
+ let startX = 0, startW = 0, dragging = false;
3516
+ const move = (e) => { if (dragging) setSidebarWidth(startW + (e.clientX - startX), false); };
3517
+ const up = () => { if (!dragging) return; dragging = false; document.body.classList.remove('resizing-sb'); window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); try { localStorage.setItem('dlb-sb', String(curSb())); } catch {} };
3518
+ h.addEventListener('pointerdown', (e) => { if (e.button !== 0) return; e.preventDefault(); dragging = true; startX = e.clientX; startW = curSb(); document.body.classList.add('resizing-sb'); window.addEventListener('pointermove', move); window.addEventListener('pointerup', up); });
3519
+ h.addEventListener('dblclick', () => setSidebarWidth(SB_DEFAULT, true));
3520
+ h.addEventListener('keydown', (e) => {
3521
+ const k = e.key;
3522
+ if (k === 'ArrowLeft'){ e.preventDefault(); setSidebarWidth(curSb() - 16, true); }
3523
+ else if (k === 'ArrowRight'){ e.preventDefault(); setSidebarWidth(curSb() + 16, true); }
3524
+ else if (k === 'Home'){ e.preventDefault(); setSidebarWidth(0, true); } // clamps to --sb-min
3525
+ else if (k === 'End'){ e.preventDefault(); setSidebarWidth(9999, true); } // clamps to --sb-max
3526
+ });
3527
+ }
3528
+ try {
3529
+ window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').addEventListener &&
3530
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { if (!themePref()) applyTheme(''); });
3531
+ } catch {}
3532
+
3533
+ // expose pure fns + actions for tests
3534
+ window.DLB = { renderMarkdown, inline, segment, reconstruct, verdict, boot, render, navigate, resolveAssetSrc, openDocLink,
3535
+ openCase, openBrief, openReadout, openMatchup, renderInbox, renderSettings, renderFeedback, applyTheme, openCaseTab, openBriefTab, openReadoutTab, openMatchupTab, openInboxTab, openSettingsTab, openFeedbackTab, closeTab, closeOthers, closeAll,
3536
+ switchProject, projMenu, openFolderPrompt, folderDialog, removeVault, briefLabel, readoutLabel, matchupLabel, fillExplorer, renderTree, pruneTree, matchFileNode, toggleFolder, folderOpen, collapseAllFolders, expandAllFolders, toggleCollapseAll, allTopCollapsed, queueConfig, flushConfig, queueExplorer, openFile, openFileTab, renderLive, renderComments, submitComment, createComment, maybeStale, handleBridgeEvent, loadComments,
3537
+ openCommentPop, focusComment, deleteCommentUI, dropComment, stepComment, startEditComment, commentOnBlock,
3538
+ currentSource, reloadOpenDoc, scheduleDocReload,
3539
+ flushSave, flushOnUnload, saveNow,
3540
+ applyTrimTitles, setSidebarWidth, initSidebarResize, curSb,
3541
+ setDiffMode, refreshTree, promptNewFile, promptNewFolder, confirmDeleteEntry, moveEntry, renameEntry, validDrop, dropDirFor, route, encFilePath, decFilePath, updateTabMark, measureTabMark, updateTabScroll, nudgeTabs, syncDocDiff,
3542
+ reserveRailForControls, dirGitStatus, loadDiffBase, applyDiffDecorations, refreshDiffDecorations, buildToc,
3543
+ maybeShowAsk: () => maybeShowAsk($('#doc')),
3544
+ maybeShowTableTools, openAtComment,
3545
+ openFeedback, closeFeedback, submitFeedbackUI, setTelemetryConsent, tel: () => TEL,
3546
+ wysiwyg: () => wysiwygCtl,
3547
+ setMode, state: () => S };
3548
+
3549
+ if (!window.__DLB_NO_AUTOBOOT){
3550
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
3551
+ else boot();
3552
+ }
3553
+ })();