egregore-artifacts 0.9.8 → 0.9.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,866 @@
1
+ // Browser-side editor for /edit. Pure-JS (no framework), bootstrapped by
2
+ // edit-shell.js. Reads token + doc from URL params, fetches source-mapped
3
+ // HTML + sidecar from the loopback API, and provides the annotation UX:
4
+ //
5
+ // - Selection layer: drag-to-highlight → popup with classification + comment
6
+ // - Auto-classification: keyword-only, ambiguous → null (per Contract §6.3)
7
+ // - Sidebar: filterable + section-grouped list, threaded replies
8
+ // - "Send to session" composer: builds #egregore-edit:v1 payload, copies it
9
+ //
10
+ // Spec references:
11
+ // Contract §3 API surface
12
+ // Contract §2.2 sidecar / annotation schema
13
+ // Contract §4 source-map data attributes
14
+ // Contract §5 bearer token
15
+ // Contract §6.3 keyword-only auto-classification
16
+ //
17
+ // This file is shipped to the browser as a literal string (see edit-shell.js).
18
+ // `export function editClientScript()` returns the JS source so the shell can
19
+ // inline it inside <script>…</script>.
20
+
21
+ export function editClientScript() {
22
+ // The IIFE below is the actual browser code. Everything inside the template
23
+ // literal runs in the browser (not Node). Don't import anything in here.
24
+ return `
25
+ (function(){
26
+ 'use strict';
27
+
28
+ // ── State ───────────────────────────────────────────────────────────
29
+ var state = {
30
+ token: null,
31
+ doc: null,
32
+ docEncoded: null,
33
+ sidecar: null, // {schemaVersion, doc, sessionId, annotations}
34
+ paragraphIndex: [], // from /api/edit/render
35
+ sourceText: '',
36
+ activeFilter: 'all', // 'all' | 'green' | 'yellow' | 'red' | 'null'
37
+ initiativeMode: 'review_each_yellow',
38
+ pendingSelection: null, // {anchor, snippet, range}
39
+ };
40
+
41
+ var API = ''; // same-origin (the editor is served by view-server)
42
+
43
+ // ── Keyword auto-classification (Contract §6.3) ─────────────────────
44
+ // Exposed on window for the smoke test.
45
+ var KEYWORDS = {
46
+ green: ['keep','lands','love','great','nice','yes','good'],
47
+ yellow: ['tighten','unclear','needs work','rephrase','revise','expand'],
48
+ red: ['cut','drop','remove','redundant','delete','kill','no'],
49
+ };
50
+
51
+ function classifyByKeyword(text) {
52
+ if (!text) return null;
53
+ var lower = text.toLowerCase();
54
+ var hits = [];
55
+ for (var color in KEYWORDS) {
56
+ var list = KEYWORDS[color];
57
+ var matched = false;
58
+ for (var i = 0; i < list.length; i++) {
59
+ // word-ish boundary: surrounded by non-letters or string edges
60
+ var kw = list[i].toLowerCase();
61
+ var idx = lower.indexOf(kw);
62
+ while (idx !== -1) {
63
+ var before = idx === 0 ? ' ' : lower.charAt(idx - 1);
64
+ var after = idx + kw.length >= lower.length ? ' ' : lower.charAt(idx + kw.length);
65
+ if (!/[a-z0-9]/.test(before) && !/[a-z0-9]/.test(after)) { matched = true; break; }
66
+ idx = lower.indexOf(kw, idx + 1);
67
+ }
68
+ if (matched) break;
69
+ }
70
+ if (matched) hits.push(color);
71
+ }
72
+ if (hits.length === 1) return hits[0];
73
+ return null;
74
+ }
75
+ window.__egEditClassify = classifyByKeyword;
76
+
77
+ // ── DOM helpers ─────────────────────────────────────────────────────
78
+ function el(tag, attrs, children) {
79
+ var node = document.createElement(tag);
80
+ if (attrs) {
81
+ for (var k in attrs) {
82
+ if (k === 'class') node.className = attrs[k];
83
+ else if (k === 'style') node.setAttribute('style', attrs[k]);
84
+ else if (k === 'text') node.textContent = attrs[k];
85
+ else if (k === 'html') node.innerHTML = attrs[k];
86
+ else if (k.indexOf('on') === 0) node[k] = attrs[k];
87
+ else if (k.indexOf('data-') === 0 || k === 'role' || k === 'aria-label')
88
+ node.setAttribute(k, attrs[k]);
89
+ else node[k] = attrs[k];
90
+ }
91
+ }
92
+ if (children) {
93
+ for (var i = 0; i < children.length; i++) {
94
+ var c = children[i];
95
+ if (c == null) continue;
96
+ node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
97
+ }
98
+ }
99
+ return node;
100
+ }
101
+
102
+ function $(sel) { return document.querySelector(sel); }
103
+
104
+ function escapeHtml(s) {
105
+ return String(s).replace(/[&<>"']/g, function(c) {
106
+ return { '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c];
107
+ });
108
+ }
109
+
110
+ // ── HTTP ────────────────────────────────────────────────────────────
111
+ function api(method, pathname, opts) {
112
+ opts = opts || {};
113
+ var headers = { 'Authorization': 'Bearer ' + state.token };
114
+ if (opts.body) headers['Content-Type'] = 'application/json';
115
+ return fetch(API + pathname, {
116
+ method: method,
117
+ headers: headers,
118
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
119
+ }).then(function(r) {
120
+ return r.text().then(function(t) {
121
+ var json = null;
122
+ try { json = JSON.parse(t); } catch (e) { json = { raw: t }; }
123
+ if (!r.ok) {
124
+ var msg = (json && (json.error || json.code)) || ('HTTP ' + r.status);
125
+ var err = new Error(msg);
126
+ err.status = r.status;
127
+ err.body = json;
128
+ throw err;
129
+ }
130
+ return json;
131
+ });
132
+ });
133
+ }
134
+
135
+ // ── Toast ───────────────────────────────────────────────────────────
136
+ var toastEl = null;
137
+ function toast(msg, isError) {
138
+ if (!toastEl) {
139
+ toastEl = el('div', { class: 'eg-edit-toast' });
140
+ document.body.appendChild(toastEl);
141
+ }
142
+ toastEl.textContent = msg;
143
+ toastEl.className = 'eg-edit-toast show' + (isError ? ' error' : '');
144
+ clearTimeout(toastEl._t);
145
+ toastEl._t = setTimeout(function() {
146
+ toastEl.classList.remove('show');
147
+ }, 2400);
148
+ }
149
+
150
+ // ── Bootstrap ───────────────────────────────────────────────────────
151
+ function bootstrap() {
152
+ var params = new URLSearchParams(location.search);
153
+ state.token = params.get('token') || sessionStorage.getItem('eg-edit-token');
154
+ state.docEncoded = params.get('doc');
155
+
156
+ // /edit/<base64url> path form — fall back to extracting from the path
157
+ if (!state.docEncoded) {
158
+ var m = location.pathname.match(/^\\/edit\\/([A-Za-z0-9_-]+)$/);
159
+ if (m) state.docEncoded = m[1];
160
+ }
161
+
162
+ if (!state.token) {
163
+ showStatus('Missing session token. Re-run /edit <doc> in your terminal.', true);
164
+ return;
165
+ }
166
+ if (!state.docEncoded) {
167
+ showStatus('Missing doc parameter.', true);
168
+ return;
169
+ }
170
+
171
+ // Persist token in sessionStorage (NOT localStorage — Contract §5.3)
172
+ sessionStorage.setItem('eg-edit-token', state.token);
173
+
174
+ try {
175
+ state.doc = atob(state.docEncoded.replace(/-/g, '+').replace(/_/g, '/'));
176
+ } catch (e) {
177
+ state.doc = '(invalid encoding)';
178
+ }
179
+ $('#eg-edit-title').textContent = state.doc;
180
+
181
+ loadAll();
182
+ }
183
+
184
+ function showStatus(msg, isError) {
185
+ var body = $('#eg-edit-body');
186
+ body.innerHTML = '';
187
+ body.appendChild(el('div', { class: 'eg-edit-status' + (isError ? ' error' : ''), text: msg }));
188
+ }
189
+
190
+ function loadAll() {
191
+ showStatus('Loading…');
192
+ return Promise.all([
193
+ api('GET', '/api/edit/render?doc=' + encodeURIComponent(state.docEncoded)),
194
+ api('GET', '/api/edit/annotations?doc=' + encodeURIComponent(state.docEncoded)),
195
+ ]).then(function(results) {
196
+ var render = results[0];
197
+ var sidecar = results[1];
198
+ state.sourceText = render.sourceText || '';
199
+ state.paragraphIndex = render.paragraphIndex || [];
200
+ state.sidecar = sidecar;
201
+ renderBody(render.html);
202
+ renderSidebar();
203
+ decorateAnnotations();
204
+ }).catch(function(err) {
205
+ showStatus('Load failed: ' + err.message, true);
206
+ });
207
+ }
208
+
209
+ // ── Body rendering ──────────────────────────────────────────────────
210
+ function renderBody(html) {
211
+ var body = $('#eg-edit-body');
212
+ body.innerHTML = html;
213
+ }
214
+
215
+ // ── Selection layer ─────────────────────────────────────────────────
216
+ function findParagraphIndexEntry(paragraphId) {
217
+ for (var i = 0; i < state.paragraphIndex.length; i++) {
218
+ if (state.paragraphIndex[i].paragraphId === paragraphId) return state.paragraphIndex[i];
219
+ }
220
+ return null;
221
+ }
222
+
223
+ function findAncestorWith(node, attr) {
224
+ var n = node;
225
+ while (n && n !== document.body) {
226
+ if (n.nodeType === 1 && n.hasAttribute && n.hasAttribute(attr)) return n;
227
+ n = n.parentNode;
228
+ }
229
+ return null;
230
+ }
231
+
232
+ // Compute char offset of a Range start within a [data-para-id] node by
233
+ // walking text nodes. The paragraph index 'text' field gives the canonical
234
+ // source string; we compute offsets relative to the rendered text content,
235
+ // which mirrors source after the inline-markdown pass.
236
+ function offsetWithinParagraph(paraNode, range) {
237
+ if (!paraNode.contains(range.startContainer)) return null;
238
+ var walker = document.createTreeWalker(paraNode, NodeFilter.SHOW_TEXT, null);
239
+ var offset = 0;
240
+ var n;
241
+ while ((n = walker.nextNode())) {
242
+ if (n === range.startContainer) {
243
+ return offset + range.startOffset;
244
+ }
245
+ offset += n.nodeValue.length;
246
+ }
247
+ return null;
248
+ }
249
+
250
+ function selectionToAnchor() {
251
+ var sel = window.getSelection();
252
+ if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return null;
253
+ var range = sel.getRangeAt(0);
254
+ var snippet = sel.toString();
255
+ if (!snippet || !snippet.trim()) return null;
256
+
257
+ // Anchor is rooted at the START paragraph. Multi-paragraph selections are
258
+ // supported: the end gets clamped to the start paragraph's last char and
259
+ // the full snippet (across paragraphs) is preserved in the fingerprint.
260
+ // The agent processing the annotation has the fingerprint to recover the
261
+ // exact span; the anchor stays single-paragraph for storage simplicity.
262
+ var paraNode = findAncestorWith(range.startContainer, 'data-para-id');
263
+ if (!paraNode) return null;
264
+
265
+ var paragraphId = paraNode.getAttribute('data-para-id');
266
+ var sectionNode = findAncestorWith(paraNode, 'data-section') ||
267
+ findRecentSection(paraNode);
268
+ var section = sectionNode
269
+ ? sectionNode.getAttribute('data-section')
270
+ : '§0. unsectioned';
271
+
272
+ var entry = findParagraphIndexEntry(paragraphId);
273
+ var paraText = entry ? entry.text : (paraNode.textContent || '');
274
+
275
+ var startOffset = offsetWithinParagraph(paraNode, range);
276
+ if (startOffset == null) return null;
277
+
278
+ // Compute end offset within the START paragraph. If the end is outside
279
+ // (multi-paragraph selection), clamp to paraText length.
280
+ var endOffset = (function() {
281
+ var walker = document.createTreeWalker(paraNode, NodeFilter.SHOW_TEXT, null);
282
+ var off = 0;
283
+ var n;
284
+ while ((n = walker.nextNode())) {
285
+ if (n === range.endContainer) return off + range.endOffset;
286
+ off += n.nodeValue.length;
287
+ }
288
+ return null;
289
+ })();
290
+
291
+ var spansMultipleParas = false;
292
+ if (endOffset == null) {
293
+ // End is in a different paragraph — clamp to end of start paragraph.
294
+ endOffset = paraText.length;
295
+ spansMultipleParas = true;
296
+ }
297
+
298
+ if (endOffset <= startOffset) {
299
+ // Defensive: if the end somehow precedes the start in the para,
300
+ // expand to end of paragraph.
301
+ endOffset = paraText.length;
302
+ if (endOffset <= startOffset) return null;
303
+ }
304
+
305
+ var fingerprint = snippet.trim().slice(0, 64);
306
+
307
+ return {
308
+ anchor: {
309
+ section: section,
310
+ paragraphId: paragraphId,
311
+ charRange: [startOffset, endOffset],
312
+ fingerprint: fingerprint,
313
+ },
314
+ snippet: snippet,
315
+ paraText: paraText,
316
+ range: range,
317
+ spansMultipleParas: spansMultipleParas,
318
+ };
319
+ }
320
+
321
+ // ── Draft highlight overlay (persists while popup is open) ──────────
322
+ // The browser's live selection clears when the popup textarea takes focus,
323
+ // so we paint absolutely-positioned overlays over the selection rects.
324
+ // Standard annotation-tool pattern (Hypothesis, GitHub PR comments).
325
+ function paintDraftHighlight(range) {
326
+ var rects = range.getClientRects();
327
+ var overlays = [];
328
+ for (var i = 0; i < rects.length; i++) {
329
+ var r = rects[i];
330
+ if (r.width < 1 || r.height < 1) continue;
331
+ var div = document.createElement('div');
332
+ div.className = 'eg-edit-draft-highlight';
333
+ div.style.top = (r.top + window.scrollY) + 'px';
334
+ div.style.left = (r.left + window.scrollX) + 'px';
335
+ div.style.width = r.width + 'px';
336
+ div.style.height = r.height + 'px';
337
+ document.body.appendChild(div);
338
+ overlays.push(div);
339
+ }
340
+ return function clearHighlight() {
341
+ for (var i = 0; i < overlays.length; i++) {
342
+ if (overlays[i].parentNode) overlays[i].parentNode.removeChild(overlays[i]);
343
+ }
344
+ };
345
+ }
346
+
347
+ function findRecentSection(node) {
348
+ // Walk up + previousSibling to find the last [data-section] heading.
349
+ var n = node;
350
+ while (n && n !== document.body) {
351
+ var sib = n.previousElementSibling;
352
+ while (sib) {
353
+ if (sib.hasAttribute && sib.hasAttribute('data-section')) return sib;
354
+ var inner = sib.querySelector && sib.querySelector('[data-section]');
355
+ if (inner) return inner;
356
+ sib = sib.previousElementSibling;
357
+ }
358
+ n = n.parentNode;
359
+ }
360
+ return null;
361
+ }
362
+
363
+ // ── Popup ───────────────────────────────────────────────────────────
364
+ function showPopup(payload, anchorRect) {
365
+ closePopup();
366
+ state.pendingSelection = payload;
367
+
368
+ var popup = el('div', { class: 'eg-edit-popup', id: 'eg-edit-popup' });
369
+
370
+ popup.appendChild(el('div', { class: 'eg-edit-popup-snippet', text: payload.snippet }));
371
+
372
+ var textarea = el('textarea', {
373
+ class: 'eg-edit-popup-textarea',
374
+ placeholder: 'Add a comment (try keywords: cut, tighten, keep…)',
375
+ });
376
+ popup.appendChild(textarea);
377
+
378
+ var suggestNote = el('div', { class: 'eg-edit-popup-suggest', id: 'eg-edit-suggest', text: '' });
379
+ popup.appendChild(suggestNote);
380
+
381
+ var colorRow = el('div', { class: 'eg-edit-color-row' });
382
+ var selected = { color: undefined }; // undefined = nothing chosen yet
383
+ function makeColorBtn(color, label) {
384
+ var btn = el('button', { class: 'eg-edit-color-btn', 'data-color': color === null ? 'null' : color });
385
+ btn.appendChild(el('span', { class: 'eg-edit-color-dot' }));
386
+ btn.appendChild(document.createTextNode(label));
387
+ btn.onclick = function() {
388
+ selected.color = color;
389
+ // userOverride if user picks something different from the suggested
390
+ if (suggestion && color !== suggestion) suggestion = null;
391
+ Array.prototype.forEach.call(colorRow.children, function(c) { c.classList.remove('selected'); });
392
+ btn.classList.add('selected');
393
+ };
394
+ colorRow.appendChild(btn);
395
+ return btn;
396
+ }
397
+ var greenBtn = makeColorBtn('green', 'green');
398
+ var yellowBtn = makeColorBtn('yellow', 'yellow');
399
+ var redBtn = makeColorBtn('red', 'red');
400
+ var noColorBtn = makeColorBtn(null, 'no color');
401
+ popup.appendChild(colorRow);
402
+
403
+ var actions = el('div', { class: 'eg-edit-popup-actions' });
404
+ var cancel = el('button', { class: 'eg-edit-popup-cancel', text: 'Cancel' });
405
+ cancel.onclick = closePopup;
406
+ var save = el('button', { class: 'eg-edit-popup-save', text: 'Save' });
407
+ save.onclick = function() {
408
+ saveAnnotation(textarea.value.trim(), selected.color);
409
+ };
410
+ actions.appendChild(cancel);
411
+ actions.appendChild(save);
412
+ popup.appendChild(actions);
413
+
414
+ var suggestion = null;
415
+ var userOverrode = false;
416
+
417
+ textarea.addEventListener('input', function() {
418
+ var sug = classifyByKeyword(textarea.value);
419
+ // refresh suggestion display only if the user hasn't picked manually
420
+ Array.prototype.forEach.call(colorRow.children, function(c) {
421
+ c.classList.remove('suggested');
422
+ });
423
+ if (sug && selected.color === undefined) {
424
+ suggestion = sug;
425
+ // mark the matching button as suggested
426
+ for (var i = 0; i < colorRow.children.length; i++) {
427
+ if (colorRow.children[i].getAttribute('data-color') === sug) {
428
+ colorRow.children[i].classList.add('suggested');
429
+ break;
430
+ }
431
+ }
432
+ suggestNote.textContent = 'suggested: ' + sug + ' (click to accept, or pick another)';
433
+ } else {
434
+ suggestion = null;
435
+ suggestNote.textContent = '';
436
+ }
437
+ });
438
+
439
+ // Paint the persistent draft-highlight overlay BEFORE focusing the
440
+ // textarea (which would clear the live selection).
441
+ var clearDraftHighlight = paintDraftHighlight(payload.range);
442
+
443
+ // Multi-paragraph hint in the snippet block
444
+ if (payload.spansMultipleParas) {
445
+ var hint = el('div', {
446
+ class: 'eg-edit-popup-multipara-hint',
447
+ text: 'Spans multiple paragraphs — anchor rooted at the start; the full selection is captured in the fingerprint.',
448
+ });
449
+ popup.insertBefore(hint, popup.children[1] || null);
450
+ }
451
+
452
+ // Position the popup near the selection
453
+ document.body.appendChild(popup);
454
+ var top = anchorRect.bottom + window.scrollY + 6;
455
+ var left = Math.min(anchorRect.left + window.scrollX, window.innerWidth - 340);
456
+ popup.style.top = top + 'px';
457
+ popup.style.left = Math.max(8, left) + 'px';
458
+
459
+ // Defer focus so the live selection's removal doesn't visually flash —
460
+ // the draft-highlight overlay is already painted, so losing the live
461
+ // selection is now safe.
462
+ setTimeout(function() { textarea.focus(); }, 0);
463
+
464
+ // Stash so saveAnnotation can read suggestion state
465
+ popup._getMeta = function() {
466
+ return {
467
+ suggestion: suggestion,
468
+ userPicked: selected.color !== undefined,
469
+ chosen: selected.color,
470
+ };
471
+ };
472
+ popup._clearDraftHighlight = clearDraftHighlight;
473
+ }
474
+
475
+ function closePopup() {
476
+ var p = document.getElementById('eg-edit-popup');
477
+ if (p) {
478
+ if (typeof p._clearDraftHighlight === 'function') {
479
+ try { p._clearDraftHighlight(); } catch (_) {}
480
+ }
481
+ p.remove();
482
+ }
483
+ state.pendingSelection = null;
484
+ }
485
+
486
+ function saveAnnotation(comment, chosenColor) {
487
+ if (!state.pendingSelection) return;
488
+ if (!comment) {
489
+ toast('Comment required', true);
490
+ return;
491
+ }
492
+ var popup = document.getElementById('eg-edit-popup');
493
+ var meta = popup && popup._getMeta ? popup._getMeta() : { suggestion: null, userPicked: false, chosen: chosenColor };
494
+
495
+ var classification;
496
+ var autoClassified = false;
497
+ if (meta.userPicked) {
498
+ classification = meta.chosen; // null if "no color" pressed
499
+ // If the user picked the suggested color exactly, autoClassified = true
500
+ if (meta.suggestion && meta.chosen === meta.suggestion) autoClassified = true;
501
+ } else {
502
+ // No manual pick — fall back to keyword classifier (may be null)
503
+ var sug = classifyByKeyword(comment);
504
+ classification = sug;
505
+ autoClassified = !!sug;
506
+ }
507
+
508
+ var anchor = state.pendingSelection.anchor;
509
+ var fingerprint = anchor.fingerprint || (state.pendingSelection.snippet || '').trim().slice(0, 64);
510
+ var body = {
511
+ selection: { ...anchor, fingerprint: fingerprint },
512
+ classification: classification,
513
+ autoClassified: autoClassified,
514
+ comment: comment,
515
+ author: window.__egEditAuthor || 'browser-user',
516
+ status: 'active',
517
+ thread: [],
518
+ };
519
+
520
+ api('POST', '/api/edit/annotations?doc=' + encodeURIComponent(state.docEncoded), { body: body })
521
+ .then(function(res) {
522
+ toast('Saved');
523
+ closePopup();
524
+ // Refresh sidecar only (HTML hasn't changed)
525
+ return api('GET', '/api/edit/annotations?doc=' + encodeURIComponent(state.docEncoded));
526
+ })
527
+ .then(function(sidecar) {
528
+ if (sidecar) {
529
+ state.sidecar = sidecar;
530
+ renderSidebar();
531
+ decorateAnnotations();
532
+ }
533
+ })
534
+ .catch(function(err) {
535
+ toast('Save failed: ' + err.message, true);
536
+ });
537
+ }
538
+
539
+ // ── Selection trigger ───────────────────────────────────────────────
540
+ document.addEventListener('mouseup', function(e) {
541
+ // Skip if click is inside the popup itself
542
+ if (e.target.closest && e.target.closest('.eg-edit-popup')) return;
543
+ if (e.target.closest && e.target.closest('.eg-edit-sidebar')) return;
544
+ if (e.target.closest && e.target.closest('.eg-edit-topbar')) return;
545
+ setTimeout(function() {
546
+ var payload = selectionToAnchor();
547
+ if (!payload) {
548
+ // If user clicked elsewhere with nothing selected, close popup
549
+ if (!window.getSelection().toString()) closePopup();
550
+ return;
551
+ }
552
+ var rect = payload.range.getBoundingClientRect();
553
+ showPopup(payload, rect);
554
+ }, 0);
555
+ });
556
+
557
+ document.addEventListener('keydown', function(e) {
558
+ if (e.key === 'Escape') closePopup();
559
+ });
560
+
561
+ // ── Sidebar ─────────────────────────────────────────────────────────
562
+ function renderSidebar() {
563
+ var list = $('#eg-edit-sidebar-list');
564
+ list.innerHTML = '';
565
+ var anns = (state.sidecar && state.sidecar.annotations) || [];
566
+
567
+ // Filter
568
+ var filter = state.activeFilter;
569
+ if (filter !== 'all') {
570
+ anns = anns.filter(function(a) {
571
+ if (filter === 'null') return a.classification == null;
572
+ return a.classification === filter;
573
+ });
574
+ }
575
+
576
+ if (anns.length === 0) {
577
+ list.appendChild(el('div', { class: 'eg-edit-status', text: 'No annotations yet. Highlight text to add one.' }));
578
+ return;
579
+ }
580
+
581
+ // Group by section
582
+ var groups = {};
583
+ var sectionOrder = [];
584
+ anns.forEach(function(a) {
585
+ var s = (a.selection && a.selection.section) || '§0. unsectioned';
586
+ if (!groups[s]) { groups[s] = []; sectionOrder.push(s); }
587
+ groups[s].push(a);
588
+ });
589
+
590
+ sectionOrder.forEach(function(section) {
591
+ var group = el('div', { class: 'eg-edit-section-group' });
592
+ group.appendChild(el('div', { class: 'eg-edit-section-label', text: section }));
593
+ groups[section].forEach(function(a) {
594
+ group.appendChild(renderAnnotationCard(a));
595
+ });
596
+ list.appendChild(group);
597
+ });
598
+ }
599
+
600
+ function renderAnnotationCard(a) {
601
+ var color = a.classification == null ? 'null' : a.classification;
602
+ var classes = ['eg-edit-ann-card'];
603
+ if (a.status === 'orphaned') classes.push('eg-orphaned');
604
+
605
+ var card = el('div', {
606
+ class: classes.join(' '),
607
+ 'data-color': color,
608
+ 'data-ann-id': a.id,
609
+ });
610
+
611
+ var meta = el('div', { class: 'eg-edit-ann-meta' });
612
+ meta.appendChild(el('span', { class: 'eg-edit-color-badge', 'data-color': color }));
613
+ meta.appendChild(el('span', { class: 'eg-edit-ann-author', text: a.author || '?' }));
614
+ var statusText = a.status === 'orphaned' ? 'orphaned · approximate' : a.status;
615
+ meta.appendChild(el('span', { class: 'eg-edit-status-badge', 'data-status': a.status, text: statusText }));
616
+ card.appendChild(meta);
617
+
618
+ var snippet = (a.selection && a.selection.fingerprint) || '';
619
+ if (snippet) {
620
+ card.appendChild(el('div', { class: 'eg-edit-ann-snippet', text: '"' + snippet.slice(0, 40) + (snippet.length > 40 ? '…' : '') + '"' }));
621
+ }
622
+ card.appendChild(el('div', { class: 'eg-edit-ann-comment', text: a.comment || '' }));
623
+
624
+ var threadCount = (a.thread || []).length;
625
+ card.appendChild(el('div', { class: 'eg-edit-ann-thread-count', text: threadCount + ' repl' + (threadCount === 1 ? 'y' : 'ies') }));
626
+
627
+ // Thread block (toggled on expand)
628
+ var thread = el('div', { class: 'eg-edit-thread' });
629
+ (a.thread || []).forEach(function(r) {
630
+ var reply = el('div', { class: 'eg-edit-reply' });
631
+ reply.appendChild(el('div', { class: 'eg-edit-reply-meta', text: (r.author || '?') + ' · ' + (r.kind || '') + ' · ' + ((r.createdAt || '').slice(0, 19)) }));
632
+ reply.appendChild(el('div', { class: 'eg-edit-reply-text', text: r.text || '' }));
633
+ thread.appendChild(reply);
634
+ });
635
+ var form = el('div', { class: 'eg-edit-reply-form' });
636
+ var input = el('input', { class: 'eg-edit-reply-input', type: 'text', placeholder: 'Reply…' });
637
+ var send = el('button', { class: 'eg-edit-reply-btn', text: 'Reply' });
638
+ send.onclick = function(ev) {
639
+ ev.stopPropagation();
640
+ var text = input.value.trim();
641
+ if (!text) return;
642
+ postReply(a.id, text).then(function() { input.value = ''; });
643
+ };
644
+ input.onkeydown = function(ev) {
645
+ ev.stopPropagation();
646
+ if (ev.key === 'Enter') { send.click(); }
647
+ };
648
+ input.onclick = function(ev) { ev.stopPropagation(); };
649
+ form.appendChild(input);
650
+ form.appendChild(send);
651
+ thread.appendChild(form);
652
+ card.appendChild(thread);
653
+
654
+ card.onclick = function() {
655
+ // Expand thread
656
+ var alreadyExpanded = card.classList.contains('expanded');
657
+ // Collapse all other expanded
658
+ Array.prototype.forEach.call(document.querySelectorAll('.eg-edit-ann-card.expanded'), function(c) {
659
+ if (c !== card) c.classList.remove('expanded');
660
+ });
661
+ if (!alreadyExpanded) card.classList.add('expanded');
662
+
663
+ // Scroll body to the anchor
664
+ scrollToAnnotation(a);
665
+ };
666
+
667
+ return card;
668
+ }
669
+
670
+ function postReply(annId, text) {
671
+ var body = {
672
+ author: window.__egEditAuthor || 'browser-user',
673
+ kind: 'discussion',
674
+ text: text,
675
+ };
676
+ return api('POST', '/api/edit/replies?doc=' + encodeURIComponent(state.docEncoded) + '&id=' + encodeURIComponent(annId), { body: body })
677
+ .then(function() {
678
+ return api('GET', '/api/edit/annotations?doc=' + encodeURIComponent(state.docEncoded));
679
+ })
680
+ .then(function(sidecar) {
681
+ state.sidecar = sidecar;
682
+ renderSidebar();
683
+ // Re-expand the card we just replied to
684
+ var card = document.querySelector('[data-ann-id="' + annId + '"]');
685
+ if (card) card.classList.add('expanded');
686
+ })
687
+ .catch(function(err) { toast('Reply failed: ' + err.message, true); });
688
+ }
689
+
690
+ function scrollToAnnotation(a) {
691
+ var paraId = a.selection && a.selection.paragraphId;
692
+ if (!paraId) return;
693
+ var node = document.querySelector('#eg-edit-body [data-para-id="' + cssEscape(paraId) + '"]');
694
+ if (!node) return;
695
+ node.scrollIntoView({ behavior: 'smooth', block: 'center' });
696
+ // Find the highlight span if it's been decorated and flash it
697
+ var hl = node.querySelector('.eg-edit-highlight[data-ann-id="' + cssEscape(a.id) + '"]');
698
+ if (hl) {
699
+ hl.classList.remove('eg-flash');
700
+ void hl.offsetWidth; // restart animation
701
+ hl.classList.add('eg-flash');
702
+ }
703
+ }
704
+
705
+ function cssEscape(s) {
706
+ if (window.CSS && window.CSS.escape) return window.CSS.escape(s);
707
+ return String(s).replace(/[^a-zA-Z0-9_-]/g, function(c) { return '\\\\' + c; });
708
+ }
709
+
710
+ // ── Decorate body with highlight spans ──────────────────────────────
711
+ function decorateAnnotations() {
712
+ // Strip any existing highlights first
713
+ Array.prototype.forEach.call(document.querySelectorAll('#eg-edit-body .eg-edit-highlight'), function(el) {
714
+ var parent = el.parentNode;
715
+ while (el.firstChild) parent.insertBefore(el.firstChild, el);
716
+ parent.removeChild(el);
717
+ parent.normalize();
718
+ });
719
+
720
+ var anns = (state.sidecar && state.sidecar.annotations) || [];
721
+ anns.forEach(function(a) {
722
+ if (a.status === 'discarded') return;
723
+ var paraId = a.selection && a.selection.paragraphId;
724
+ if (!paraId) return;
725
+ var node = document.querySelector('#eg-edit-body [data-para-id="' + cssEscape(paraId) + '"]');
726
+ if (!node) return;
727
+ try { wrapRange(node, a); } catch (e) { /* skip on failure */ }
728
+ });
729
+ }
730
+
731
+ function wrapRange(paraNode, ann) {
732
+ var range = ann.selection.charRange;
733
+ if (!range) return;
734
+ var start = range[0], end = range[1];
735
+ var walker = document.createTreeWalker(paraNode, NodeFilter.SHOW_TEXT, null);
736
+ var off = 0;
737
+ var startNode = null, startOff = 0, endNode = null, endOff = 0;
738
+ var n;
739
+ while ((n = walker.nextNode())) {
740
+ var nlen = n.nodeValue.length;
741
+ if (!startNode && off + nlen >= start) {
742
+ startNode = n;
743
+ startOff = start - off;
744
+ }
745
+ if (!endNode && off + nlen >= end) {
746
+ endNode = n;
747
+ endOff = end - off;
748
+ break;
749
+ }
750
+ off += nlen;
751
+ }
752
+ if (!startNode || !endNode) return;
753
+
754
+ var r = document.createRange();
755
+ r.setStart(startNode, startOff);
756
+ r.setEnd(endNode, endOff);
757
+
758
+ var span = document.createElement('span');
759
+ span.className = 'eg-edit-highlight';
760
+ span.setAttribute('data-color', ann.classification == null ? 'null' : ann.classification);
761
+ span.setAttribute('data-ann-id', ann.id);
762
+ span.title = (ann.author || '') + ': ' + (ann.comment || '');
763
+ try {
764
+ r.surroundContents(span);
765
+ } catch (e) {
766
+ // Range crosses element boundaries — extract and wrap
767
+ var frag = r.extractContents();
768
+ span.appendChild(frag);
769
+ r.insertNode(span);
770
+ }
771
+ }
772
+
773
+ // ── Filter buttons ──────────────────────────────────────────────────
774
+ function setupFilterBar() {
775
+ var bar = $('#eg-edit-filter');
776
+ var buttons = bar.querySelectorAll('.eg-edit-filter-btn');
777
+ Array.prototype.forEach.call(buttons, function(btn) {
778
+ btn.onclick = function() {
779
+ Array.prototype.forEach.call(buttons, function(b) { b.classList.remove('active'); });
780
+ btn.classList.add('active');
781
+ state.activeFilter = btn.getAttribute('data-color') || 'all';
782
+ renderSidebar();
783
+ };
784
+ });
785
+ bar.querySelector('[data-color="all"]').classList.add('active');
786
+ }
787
+
788
+ // ── Refresh button ──────────────────────────────────────────────────
789
+ function setupRefresh() {
790
+ $('#eg-edit-refresh').onclick = function() {
791
+ loadAll();
792
+ };
793
+ }
794
+
795
+ // ── Initiative-mode toggle ──────────────────────────────────────────
796
+ function setupModeToggle() {
797
+ var inputs = document.querySelectorAll('input[name="eg-init-mode"]');
798
+ Array.prototype.forEach.call(inputs, function(inp) {
799
+ inp.onchange = function() {
800
+ if (inp.checked) state.initiativeMode = inp.value;
801
+ };
802
+ });
803
+ }
804
+
805
+ // ── Send to session ─────────────────────────────────────────────────
806
+ function setupSendButton() {
807
+ $('#eg-edit-send').onclick = function() {
808
+ var anns = (state.sidecar && state.sidecar.annotations) || [];
809
+ // Active annotations only (skip discarded/accepted)
810
+ var active = anns.filter(function(a) { return a.status === 'active' || a.status === 'orphaned'; });
811
+ if (active.length === 0) {
812
+ toast('No active annotations to send', true);
813
+ return;
814
+ }
815
+ var sessionId = (state.sidecar && state.sidecar.sessionId) || '';
816
+ var bundle = {
817
+ schemaVersion: 1,
818
+ docPath: state.doc,
819
+ sessionId: sessionId,
820
+ deliveredAt: new Date().toISOString(),
821
+ initiativeMode: state.initiativeMode,
822
+ annotations: active,
823
+ };
824
+ var marker = '#egregore-edit:v1 ' + state.doc;
825
+ var payload = marker + '\\n\\n' + JSON.stringify(bundle, null, 2);
826
+ copyToClipboard(payload).then(function() {
827
+ toast('Copied. Paste into your terminal session.');
828
+ }, function() {
829
+ toast('Copy failed — select payload manually.', true);
830
+ });
831
+ };
832
+ }
833
+
834
+ function copyToClipboard(text) {
835
+ if (navigator.clipboard && navigator.clipboard.writeText) {
836
+ return navigator.clipboard.writeText(text);
837
+ }
838
+ return new Promise(function(resolve, reject) {
839
+ try {
840
+ var ta = document.createElement('textarea');
841
+ ta.value = text;
842
+ ta.style.position = 'fixed';
843
+ ta.style.opacity = '0';
844
+ document.body.appendChild(ta);
845
+ ta.select();
846
+ var ok = document.execCommand('copy');
847
+ document.body.removeChild(ta);
848
+ ok ? resolve() : reject(new Error('execCommand failed'));
849
+ } catch (e) { reject(e); }
850
+ });
851
+ }
852
+
853
+ // ── Init ────────────────────────────────────────────────────────────
854
+ document.addEventListener('DOMContentLoaded', function() {
855
+ setupFilterBar();
856
+ setupRefresh();
857
+ setupSendButton();
858
+ setupModeToggle();
859
+ bootstrap();
860
+ });
861
+
862
+ // Expose for tests / dev
863
+ window.__egEditState = state;
864
+ })();
865
+ `;
866
+ }