@cfbender/cesium 0.6.2 → 0.7.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 (36) hide show
  1. package/CHANGELOG.md +82 -1
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/index.ts +2 -0
  5. package/src/prompt/system-fragment.md +68 -8
  6. package/src/render/annotate-frozen.ts +90 -0
  7. package/src/render/blocks/render.ts +20 -0
  8. package/src/render/blocks/renderers/callout.ts +3 -2
  9. package/src/render/blocks/renderers/code.ts +17 -2
  10. package/src/render/blocks/renderers/compare-table.ts +3 -2
  11. package/src/render/blocks/renderers/diagram.ts +3 -2
  12. package/src/render/blocks/renderers/diff.ts +23 -9
  13. package/src/render/blocks/renderers/hero.ts +3 -2
  14. package/src/render/blocks/renderers/kv.ts +3 -2
  15. package/src/render/blocks/renderers/list.ts +5 -4
  16. package/src/render/blocks/renderers/pill-row.ts +3 -2
  17. package/src/render/blocks/renderers/prose.ts +8 -2
  18. package/src/render/blocks/renderers/raw-html.ts +8 -2
  19. package/src/render/blocks/renderers/risk-table.ts +3 -2
  20. package/src/render/blocks/renderers/section.ts +4 -2
  21. package/src/render/blocks/renderers/timeline.ts +3 -2
  22. package/src/render/blocks/renderers/tldr.ts +3 -2
  23. package/src/render/client-js.ts +803 -6
  24. package/src/render/critique.ts +5 -335
  25. package/src/render/theme.ts +455 -6
  26. package/src/render/validate.ts +353 -97
  27. package/src/render/wrap.ts +67 -9
  28. package/src/server/api.ts +162 -3
  29. package/src/storage/index-gen.ts +4 -2
  30. package/src/storage/mutate.ts +433 -27
  31. package/src/tools/annotate.ts +336 -0
  32. package/src/tools/ask.ts +2 -6
  33. package/src/tools/critique.ts +15 -45
  34. package/src/tools/publish.ts +16 -56
  35. package/src/tools/styleguide.ts +7 -1
  36. package/src/tools/wait.ts +77 -24
@@ -16,6 +16,16 @@ export function getClientJs(): string {
16
16
  var m = window.location.pathname.match(/^\\/projects\\/([^\\/]+)\\/artifacts\\/([^\\/]+)$/);
17
17
  var apiBase = m ? "/api/sessions/" + m[1] + "/" + m[2] : null;
18
18
 
19
+ // ─── Read cesium-meta ────────────────────────────────────────────────────────
20
+ function readMeta() {
21
+ var el = document.getElementById("cesium-meta");
22
+ if (!el) return null;
23
+ try { return JSON.parse(el.textContent || ""); } catch (e) { return null; }
24
+ }
25
+ var meta = readMeta();
26
+ var interactive = meta && meta.interactive;
27
+ var kind = interactive && interactive.kind;
28
+
19
29
  // ─── File:// / offline banner ───────────────────────────────────────────────
20
30
  if (!apiBase) {
21
31
  document.addEventListener("DOMContentLoaded", function () {
@@ -23,18 +33,18 @@ export function getClientJs(): string {
23
33
  var banner = document.createElement("div");
24
34
  banner.className = "cs-banner cs-banner-offline";
25
35
  banner.textContent =
26
- "Interactive controls require viewing this artifact via the cesium HTTP server. " +
36
+ "Review controls require viewing this artifact via the cesium HTTP server. " +
27
37
  "Run cesium open or visit localhost:3030";
28
38
  document.body.insertBefore(banner, document.body.firstChild);
29
39
  });
30
40
  }
31
41
 
32
42
  // ─── Session-ended banner ───────────────────────────────────────────────────
33
- function showSessionEndedBanner() {
43
+ function showSessionEndedBanner(msg) {
34
44
  if (document.querySelector(".cs-banner-ended")) return;
35
45
  var banner = document.createElement("div");
36
46
  banner.className = "cs-banner cs-banner-ended";
37
- banner.textContent = "Session ended — answers can no longer be submitted.";
47
+ banner.textContent = msg || "Session ended — answers can no longer be submitted.";
38
48
  document.body.insertBefore(banner, document.body.firstChild);
39
49
  // Disable all interactive controls
40
50
  var disabled = document.querySelectorAll(
@@ -290,8 +300,8 @@ export function getClientJs(): string {
290
300
  }
291
301
  }
292
302
 
293
- // ─── Wire all sections on DOMContentLoaded ───────────────────────────────────
294
- document.addEventListener("DOMContentLoaded", function () {
303
+ // ─── wireAsk ─────────────────────────────────────────────────────────────────
304
+ function wireAsk() {
295
305
  var sections = document.querySelectorAll("section[data-question-id]");
296
306
  for (var i = 0; i < sections.length; i++) {
297
307
  var section = sections[i];
@@ -311,6 +321,793 @@ export function getClientJs(): string {
311
321
  wireReact(section);
312
322
  }
313
323
  }
314
- });
324
+ }
325
+
326
+ // ═══════════════════════════════════════════════════════════════════════════
327
+ // ─── wireAnnotate ────────────────────────────────────────────────────────
328
+ // ═══════════════════════════════════════════════════════════════════════════
329
+
330
+ function wireAnnotate(interactiveData) {
331
+ // ─── Status branch: frozen path for post-verdict artifacts ───────────────
332
+ //
333
+ // wireAnnotateFrozen is defined below but hoisted (function declaration).
334
+ // It handles positioning and hover linking only — no API calls.
335
+ if (interactiveData && interactiveData.status !== "open") {
336
+ wireAnnotateFrozen(interactiveData);
337
+ return;
338
+ }
339
+
340
+ // ─── Helpers ────────────────────────────────────────────────────────────
341
+ function escapeHtml(str) {
342
+ return String(str)
343
+ .replace(/&/g, "&amp;")
344
+ .replace(/</g, "&lt;")
345
+ .replace(/>/g, "&gt;")
346
+ .replace(/"/g, "&quot;");
347
+ }
348
+
349
+ function humanizeAnchor(anchor) {
350
+ var parts = String(anchor).split(".");
351
+ var blockPart = parts[0] || "";
352
+ var blockNum = blockPart.replace("block-", "");
353
+ if (parts.length === 1) {
354
+ return "Block " + blockNum;
355
+ }
356
+ var linePart = parts[1] || "";
357
+ var lineNum = linePart.replace("line-", "");
358
+ return "Block " + blockNum + " \u00b7 line " + lineNum;
359
+ }
360
+
361
+ function apiPost(path, body) {
362
+ return fetch(path, {
363
+ method: "POST",
364
+ headers: { "Content-Type": "application/json" },
365
+ body: JSON.stringify(body),
366
+ }).then(function (r) {
367
+ if (r.status === 410) {
368
+ showSessionEndedBanner("Review closed.");
369
+ return r.json().then(function (data) {
370
+ throw new Error(data && data.status ? "session ended: " + data.status : "session ended");
371
+ });
372
+ }
373
+ return r.json().then(function (data) {
374
+ if (!r.ok) {
375
+ var msg = (data && (data.message || data.reason || data.error)) || ("HTTP " + r.status);
376
+ throw new Error(msg);
377
+ }
378
+ return data;
379
+ });
380
+ });
381
+ }
382
+
383
+ function apiDelete(path) {
384
+ return fetch(path, { method: "DELETE" }).then(function (r) {
385
+ if (r.status === 410) {
386
+ showSessionEndedBanner("Review closed.");
387
+ return r.json().then(function (data) {
388
+ throw new Error(data && data.status ? "session ended: " + data.status : "session ended");
389
+ });
390
+ }
391
+ return r.json().then(function (data) {
392
+ if (!r.ok) {
393
+ var msg = (data && (data.message || data.reason || data.error)) || ("HTTP " + r.status);
394
+ throw new Error(msg);
395
+ }
396
+ return data;
397
+ });
398
+ });
399
+ }
400
+
401
+ // ─── State ──────────────────────────────────────────────────────────────
402
+ var state = {
403
+ comments: (interactiveData && Array.isArray(interactiveData.comments))
404
+ ? interactiveData.comments.slice()
405
+ : [],
406
+ verdictMode: (interactiveData && interactiveData.verdictMode) || "approve",
407
+ status: (interactiveData && interactiveData.status) || "open",
408
+ };
409
+
410
+ // Active popup reference (only one at a time)
411
+ var activePopup = null;
412
+
413
+ // ─── Count display ───────────────────────────────────────────────────────
414
+ function updateCount() {
415
+ var countEl = document.querySelector("[data-cesium-comment-count]");
416
+ if (!countEl) return;
417
+ var n = state.comments.length;
418
+ countEl.textContent = n === 1 ? "1 comment" : n + " comments";
419
+ }
420
+
421
+ // ─── Verdict button enablement ───────────────────────────────────────────
422
+ function updateVerdictButtons() {
423
+ var hasComments = state.comments.length > 0;
424
+ var btns = document.querySelectorAll("button.cs-verdict-btn[data-verdict]");
425
+ for (var i = 0; i < btns.length; i++) {
426
+ var btn = btns[i];
427
+ if (!(btn instanceof HTMLButtonElement)) continue;
428
+ var v = btn.getAttribute("data-verdict");
429
+ // approve — always enabled; request_changes + comment — need comments
430
+ if (v === "approve") {
431
+ btn.disabled = false;
432
+ } else {
433
+ btn.disabled = !hasComments;
434
+ }
435
+ }
436
+ }
437
+
438
+ // ─── Banner error (toplevel) ─────────────────────────────────────────────
439
+ function showBannerError(message) {
440
+ var existing = document.querySelector(".cs-banner-error");
441
+ if (existing) existing.remove();
442
+ var banner = document.createElement("div");
443
+ banner.className = "cs-banner cs-banner-error cs-error";
444
+ banner.setAttribute("role", "alert");
445
+ banner.textContent = message;
446
+ banner.style.cssText =
447
+ "position:fixed;top:0;left:0;right:0;padding:0.75rem 1.25rem;" +
448
+ "text-align:center;z-index:200;";
449
+ document.body.insertBefore(banner, document.body.firstChild);
450
+ setTimeout(function () { if (banner.parentNode) banner.remove(); }, 6000);
451
+ }
452
+
453
+ // ─── Comment rail ────────────────────────────────────────────────────────
454
+ function getRail() {
455
+ return document.querySelector("[data-cesium-comment-rail]");
456
+ }
457
+
458
+ function buildBubble(comment) {
459
+ var article = document.createElement("article");
460
+ article.className = "cs-comment-bubble";
461
+ article.setAttribute("data-comment-id", comment.id);
462
+ article.setAttribute("data-anchor", comment.anchor);
463
+
464
+ var head = document.createElement("header");
465
+ head.className = "cs-comment-bubble-head";
466
+
467
+ var label = document.createElement("span");
468
+ label.className = "cs-comment-anchor-label";
469
+ label.textContent = humanizeAnchor(comment.anchor);
470
+
471
+ var delBtn = document.createElement("button");
472
+ delBtn.type = "button";
473
+ delBtn.className = "cs-comment-delete";
474
+ delBtn.setAttribute("aria-label", "Delete comment");
475
+ delBtn.textContent = "\u00d7";
476
+
477
+ head.appendChild(label);
478
+ head.appendChild(delBtn);
479
+
480
+ var textEl = document.createElement("p");
481
+ textEl.className = "cs-comment-text";
482
+ textEl.textContent = comment.comment;
483
+
484
+ article.appendChild(head);
485
+ article.appendChild(textEl);
486
+
487
+ if (comment.selectedText && comment.selectedText.trim() !== "") {
488
+ var quoteEl = document.createElement("blockquote");
489
+ quoteEl.className = "cs-comment-bubble-quote";
490
+ quoteEl.textContent = comment.selectedText;
491
+ article.appendChild(quoteEl);
492
+ }
493
+
494
+ // ─── Delete handler ────────────────────────────────────────────────────
495
+ delBtn.addEventListener("click", function () {
496
+ if (!apiBase) return;
497
+ // Optimistic: mark pending
498
+ article.classList.add("cs-saving");
499
+ delBtn.disabled = true;
500
+ apiDelete(apiBase + "/comments/" + comment.id)
501
+ .then(function () {
502
+ // Remove from state
503
+ state.comments = state.comments.filter(function (c) {
504
+ return c.id !== comment.id;
505
+ });
506
+ article.remove();
507
+ updateCount();
508
+ updateVerdictButtons();
509
+ positionBubbles();
510
+ })
511
+ .catch(function (err) {
512
+ // Restore
513
+ article.classList.remove("cs-saving");
514
+ delBtn.disabled = false;
515
+ showBannerError("Could not delete comment: " + (err instanceof Error ? err.message : String(err)));
516
+ });
517
+ });
518
+
519
+ return article;
520
+ }
521
+
522
+ function mountBubble(comment) {
523
+ var rail = getRail();
524
+ if (!rail) return;
525
+ var bubble = buildBubble(comment);
526
+ rail.appendChild(bubble);
527
+ positionBubbles();
528
+ }
529
+
530
+ function mountAllSeededComments() {
531
+ for (var i = 0; i < state.comments.length; i++) {
532
+ mountBubble(state.comments[i]);
533
+ }
534
+ }
535
+
536
+ // ─── Position bubbles aligned to anchors (marginalia style) ─────────────
537
+ function positionBubbles() {
538
+ var rail = getRail();
539
+ if (!rail) return;
540
+ var railParent = rail.offsetParent || document.body;
541
+ var railParentTop = railParent instanceof HTMLElement
542
+ ? railParent.getBoundingClientRect().top + (window.scrollY || window.pageYOffset || 0)
543
+ : 0;
544
+ var bubbles = rail.querySelectorAll("[data-anchor]");
545
+ for (var i = 0; i < bubbles.length; i++) {
546
+ var bubble = bubbles[i];
547
+ if (!(bubble instanceof HTMLElement)) continue;
548
+ var anchorKey = bubble.getAttribute("data-anchor") || "";
549
+ var anchorEl = document.querySelector("[data-cesium-anchor=\\"" + anchorKey + "\\"]");
550
+ if (anchorEl instanceof HTMLElement) {
551
+ var anchorTop = anchorEl.getBoundingClientRect().top
552
+ + (window.scrollY || window.pageYOffset || 0)
553
+ - railParentTop;
554
+ bubble.style.top = anchorTop + "px";
555
+ bubble.classList.remove("cs-comment-bubble-orphan");
556
+ } else {
557
+ bubble.style.top = "0px";
558
+ bubble.classList.add("cs-comment-bubble-orphan");
559
+ }
560
+ }
561
+ }
562
+
563
+ // ─── Mutual hover linking ────────────────────────────────────────────────
564
+ function wireHoverLinking() {
565
+ var rail = getRail();
566
+ if (!rail) return;
567
+
568
+ // Bubble → anchor
569
+ rail.addEventListener("mouseover", function (e) {
570
+ var bubble = e.target instanceof Element
571
+ ? e.target.closest("[data-anchor]")
572
+ : null;
573
+ if (!(bubble instanceof HTMLElement)) return;
574
+ var anchorKey = bubble.getAttribute("data-anchor") || "";
575
+ var anchorEl = document.querySelector("[data-cesium-anchor=\\"" + anchorKey + "\\"]");
576
+ bubble.classList.add("cs-comment-bubble-active");
577
+ if (anchorEl instanceof HTMLElement) {
578
+ anchorEl.classList.add("cs-anchor-active");
579
+ }
580
+ });
581
+ rail.addEventListener("mouseout", function (e) {
582
+ var bubble = e.target instanceof Element
583
+ ? e.target.closest("[data-anchor]")
584
+ : null;
585
+ if (!(bubble instanceof HTMLElement)) return;
586
+ var anchorKey = bubble.getAttribute("data-anchor") || "";
587
+ var anchorEl = document.querySelector("[data-cesium-anchor=\\"" + anchorKey + "\\"]");
588
+ bubble.classList.remove("cs-comment-bubble-active");
589
+ if (anchorEl instanceof HTMLElement) {
590
+ anchorEl.classList.remove("cs-anchor-active");
591
+ }
592
+ });
593
+
594
+ // Anchor → bubble
595
+ var anchors = document.querySelectorAll("[data-cesium-anchor]");
596
+ for (var i = 0; i < anchors.length; i++) {
597
+ (function (anchorEl) {
598
+ var anchorKey = anchorEl.getAttribute("data-cesium-anchor") || "";
599
+ anchorEl.addEventListener("mouseenter", function () {
600
+ var bubble = rail.querySelector("[data-anchor=\\"" + anchorKey + "\\"]");
601
+ if (bubble instanceof HTMLElement) {
602
+ bubble.classList.add("cs-comment-bubble-active");
603
+ }
604
+ anchorEl.classList.add("cs-anchor-active");
605
+ });
606
+ anchorEl.addEventListener("mouseleave", function () {
607
+ var bubble = rail.querySelector("[data-anchor=\\"" + anchorKey + "\\"]");
608
+ if (bubble instanceof HTMLElement) {
609
+ bubble.classList.remove("cs-comment-bubble-active");
610
+ }
611
+ anchorEl.classList.remove("cs-anchor-active");
612
+ });
613
+ })(anchors[i]);
614
+ }
615
+ }
616
+
617
+ // ─── Resize debounce ─────────────────────────────────────────────────────
618
+ var resizeTimer = null;
619
+ function onResize() {
620
+ if (resizeTimer !== null) clearTimeout(resizeTimer);
621
+ resizeTimer = setTimeout(function () {
622
+ resizeTimer = null;
623
+ positionBubbles();
624
+ }, 150);
625
+ }
626
+
627
+ // ─── Popup ──────────────────────────────────────────────────────────────
628
+ function closePopup() {
629
+ if (activePopup && activePopup.parentNode) {
630
+ activePopup.remove();
631
+ }
632
+ activePopup = null;
633
+ }
634
+
635
+ function openPopup(anchorEl, anchorStr) {
636
+ closePopup();
637
+
638
+ var tmpl = document.getElementById("cs-annotate-comment-popup");
639
+ if (!(tmpl instanceof HTMLTemplateElement)) return;
640
+ var clone = tmpl.content.cloneNode(true);
641
+ var popup = clone instanceof DocumentFragment
642
+ ? clone.firstElementChild
643
+ : null;
644
+ if (!popup) return;
645
+ if (!(popup instanceof HTMLElement)) return;
646
+
647
+ popup.setAttribute("data-popup-anchor", anchorStr);
648
+ popup.style.position = "absolute";
649
+ popup.style.zIndex = "200";
650
+
651
+ // Capture selection — also check data-prefill-text (set by selection menu)
652
+ var sel = window.getSelection ? window.getSelection() : null;
653
+ var selText = "";
654
+ if (sel && sel.rangeCount > 0 && sel.toString().trim() !== "") {
655
+ var range = sel.getRangeAt(0);
656
+ // Check containment: range must intersect anchor element
657
+ if (anchorEl.contains(range.commonAncestorContainer)
658
+ || range.commonAncestorContainer === anchorEl) {
659
+ selText = sel.toString().slice(0, 4096);
660
+ }
661
+ }
662
+ // Fallback: selection menu prefill (text captured before selection was cleared)
663
+ if (!selText) {
664
+ var prefill = anchorEl.getAttribute("data-prefill-text");
665
+ if (prefill && prefill.trim()) selText = prefill;
666
+ }
667
+ if (!selText) {
668
+ selText = (anchorEl.textContent || "").trim().slice(0, 300);
669
+ }
670
+ popup.setAttribute("data-selected-text", selText);
671
+
672
+ // Prepend quote block above textarea if we have text
673
+ if (selText) {
674
+ var quote = document.createElement("blockquote");
675
+ quote.className = "cs-comment-popup-quote";
676
+ quote.textContent = selText.slice(0, 200) + (selText.length > 200 ? "\u2026" : "");
677
+ popup.insertBefore(quote, popup.firstChild);
678
+ }
679
+
680
+ document.body.appendChild(popup);
681
+ activePopup = popup;
682
+
683
+ // Position the popup
684
+ var rect = anchorEl.getBoundingClientRect();
685
+ var scrollX = window.scrollX || window.pageXOffset || 0;
686
+ var scrollY = window.scrollY || window.pageYOffset || 0;
687
+ var viewportW = window.innerWidth || document.documentElement.clientWidth;
688
+ var popupW = 360;
689
+
690
+ var top = rect.bottom + scrollY + 4;
691
+ var left = rect.left + scrollX;
692
+ // Wide viewport: try to place to the right
693
+ if (viewportW > 900 && rect.right + popupW + 16 < viewportW) {
694
+ left = rect.right + scrollX + 12;
695
+ top = rect.top + scrollY;
696
+ }
697
+ // Clamp to viewport
698
+ if (left + popupW > scrollX + viewportW - 16) {
699
+ left = scrollX + viewportW - popupW - 16;
700
+ }
701
+ if (left < scrollX + 8) left = scrollX + 8;
702
+
703
+ popup.style.top = top + "px";
704
+ popup.style.left = left + "px";
705
+
706
+ // Wire save / cancel
707
+ var textarea = popup.querySelector("textarea.cs-comment-input");
708
+ var saveBtn = popup.querySelector("button.cs-comment-save");
709
+ var cancelBtn = popup.querySelector("button.cs-comment-cancel");
710
+
711
+ // Save starts disabled — enable when text is non-empty
712
+ if (saveBtn instanceof HTMLButtonElement) {
713
+ saveBtn.disabled = true;
714
+ }
715
+
716
+ if (textarea instanceof HTMLTextAreaElement && saveBtn instanceof HTMLButtonElement) {
717
+ textarea.addEventListener("input", function () {
718
+ saveBtn.disabled = textarea.value.trim() === "";
719
+ });
720
+ // Cmd/Ctrl+Enter to submit
721
+ textarea.addEventListener("keydown", function (e) {
722
+ if ((e.metaKey || e.ctrlKey) && (e.key === "Enter" || e.keyCode === 13)) {
723
+ if (!saveBtn.disabled) saveBtn.click();
724
+ }
725
+ });
726
+ // Auto-focus
727
+ textarea.focus();
728
+ }
729
+
730
+ if (saveBtn instanceof HTMLButtonElement) {
731
+ saveBtn.addEventListener("click", function () {
732
+ if (!(textarea instanceof HTMLTextAreaElement)) return;
733
+ var commentText = textarea.value;
734
+ if (!commentText.trim()) return;
735
+ if (!apiBase) { closePopup(); return; }
736
+
737
+ // Disable buttons during save
738
+ saveBtn.disabled = true;
739
+ if (cancelBtn instanceof HTMLButtonElement) cancelBtn.disabled = true;
740
+ popup.classList.add("cs-saving");
741
+
742
+ // Remove any prior error
743
+ var priorErr = popup.querySelector(".cs-error");
744
+ if (priorErr) priorErr.remove();
745
+
746
+ apiPost(apiBase + "/comments", {
747
+ anchor: anchorStr,
748
+ selectedText: selText,
749
+ comment: commentText,
750
+ })
751
+ .then(function (resp) {
752
+ var newComment = resp && resp.comment;
753
+ if (newComment) {
754
+ state.comments.push(newComment);
755
+ mountBubble(newComment);
756
+ updateCount();
757
+ updateVerdictButtons();
758
+ }
759
+ closePopup();
760
+ })
761
+ .catch(function (err) {
762
+ popup.classList.remove("cs-saving");
763
+ saveBtn.disabled = textarea.value.trim() === "";
764
+ if (cancelBtn instanceof HTMLButtonElement) cancelBtn.disabled = false;
765
+ var errEl = document.createElement("p");
766
+ errEl.className = "cs-error";
767
+ errEl.setAttribute("role", "alert");
768
+ errEl.textContent = err instanceof Error ? err.message : String(err);
769
+ popup.appendChild(errEl);
770
+ });
771
+ });
772
+ }
773
+
774
+ if (cancelBtn instanceof HTMLButtonElement) {
775
+ cancelBtn.addEventListener("click", closePopup);
776
+ }
777
+
778
+ // Escape closes the popup
779
+ function onKeydown(e) {
780
+ if (e.key === "Escape" || e.keyCode === 27) {
781
+ closePopup();
782
+ document.removeEventListener("keydown", onKeydown);
783
+ }
784
+ }
785
+ document.addEventListener("keydown", onKeydown);
786
+
787
+ // Click outside closes
788
+ setTimeout(function () {
789
+ function onOutside(e) {
790
+ if (activePopup && !activePopup.contains(e.target)) {
791
+ closePopup();
792
+ document.removeEventListener("click", onOutside);
793
+ }
794
+ }
795
+ document.addEventListener("click", onOutside);
796
+ }, 0);
797
+ }
798
+
799
+ // ─── Inject affordances ──────────────────────────────────────────────────
800
+ function injectAffordances() {
801
+ var anchors = document.querySelectorAll("[data-cesium-anchor]");
802
+
803
+ // Chat-bubble SVG glyph (inline, ~16px, currentColor)
804
+ var bubbleGlyphLg = '<svg class="cs-comment-glyph" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="1" width="14" height="10" rx="2.5"/><path d="M4 14l2-3"/></svg>';
805
+ var bubbleGlyphSm = '<svg class="cs-comment-glyph" viewBox="0 0 16 16" width="13" height="13" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="1" width="14" height="10" rx="2.5"/><path d="M4 14l2-3"/></svg>';
806
+
807
+ for (var i = 0; i < anchors.length; i++) {
808
+ var anchorEl = anchors[i];
809
+ if (!(anchorEl instanceof HTMLElement)) continue;
810
+ var anchorStr = anchorEl.getAttribute("data-cesium-anchor") || "";
811
+
812
+ // Determine line vs block affordance
813
+ var isLine = /^block-\\d+\\.line-\\d+$/.test(anchorStr);
814
+
815
+ var btn = document.createElement("button");
816
+ btn.type = "button";
817
+ btn.className = isLine
818
+ ? "cs-anchor-affordance cs-anchor-affordance-line"
819
+ : "cs-anchor-affordance cs-anchor-affordance-block";
820
+ btn.setAttribute("aria-label", "Add comment");
821
+ btn.setAttribute("data-anchor", anchorStr);
822
+
823
+ if (isLine) {
824
+ // Line affordance: icon only, gutter-positioned, visibility:hidden until hover
825
+ btn.innerHTML = bubbleGlyphSm;
826
+ } else {
827
+ // Block affordance: "Comment" button, always visible, top-right of block
828
+ btn.innerHTML = bubbleGlyphLg + " Comment";
829
+ }
830
+
831
+ // Wire click: open popup for this anchor
832
+ (function (el, aStr, affordBtn) {
833
+ affordBtn.addEventListener("click", function (e) {
834
+ e.stopPropagation();
835
+ openPopup(el, aStr);
836
+ });
837
+ })(anchorEl, anchorStr, btn);
838
+
839
+ anchorEl.insertBefore(btn, anchorEl.firstChild);
840
+ }
841
+ }
842
+
843
+ // ─── Floating selection menu ─────────────────────────────────────────────
844
+ function wireSelectionMenu() {
845
+ // Skip in frozen or offline mode
846
+ if (!apiBase) return;
847
+
848
+ // Create menu element (injected once into <body>)
849
+ var menu = document.createElement("div");
850
+ menu.className = "cs-selection-menu";
851
+ menu.setAttribute("role", "toolbar");
852
+ menu.hidden = true;
853
+ menu.innerHTML = '<button type="button" class="cs-selection-comment-btn">' +
854
+ '<svg class="cs-comment-glyph" viewBox="0 0 16 16" width="13" height="13" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="1" width="14" height="10" rx="2.5"/><path d="M4 14l2-3"/></svg>' +
855
+ ' Comment</button>';
856
+ document.body.appendChild(menu);
857
+
858
+ var selectionCommentBtn = menu.querySelector(".cs-selection-comment-btn");
859
+
860
+ // Prevent clearing selection when clicking the button
861
+ menu.addEventListener("mousedown", function (e) {
862
+ e.preventDefault();
863
+ });
864
+
865
+ function findAnchorEl(node) {
866
+ var el = node instanceof Element ? node : node.parentElement;
867
+ while (el) {
868
+ if (el.hasAttribute("data-cesium-anchor")) return el;
869
+ el = el.parentElement;
870
+ }
871
+ return null;
872
+ }
873
+
874
+ function hideMenu() {
875
+ menu.hidden = true;
876
+ }
877
+
878
+ function showMenuNearSelection() {
879
+ // Don't show if popup already open
880
+ if (activePopup !== null) { hideMenu(); return; }
881
+
882
+ var sel = window.getSelection ? window.getSelection() : null;
883
+ if (!sel || sel.rangeCount === 0) { hideMenu(); return; }
884
+
885
+ var text = sel.toString().trim();
886
+ if (text.length < 3) { hideMenu(); return; }
887
+
888
+ var range = sel.getRangeAt(0);
889
+ var commonNode = range.commonAncestorContainer;
890
+ var anchorEl = findAnchorEl(commonNode);
891
+ if (!anchorEl) { hideMenu(); return; }
892
+
893
+ // Position at bottom-right of selection rect
894
+ var rect = range.getBoundingClientRect();
895
+ if (!rect || rect.width === 0 && rect.height === 0) { hideMenu(); return; }
896
+
897
+ menu.hidden = false;
898
+ var menuRect = menu.getBoundingClientRect();
899
+ var top = rect.bottom + window.scrollY + 6;
900
+ var left = rect.right + window.scrollX - menuRect.width;
901
+ // Clamp to viewport
902
+ var vpW = window.innerWidth || document.documentElement.clientWidth;
903
+ if (left + menuRect.width > vpW - 8) left = vpW - menuRect.width - 8;
904
+ if (left < 8) left = 8;
905
+ menu.style.top = top + "px";
906
+ menu.style.left = left + "px";
907
+
908
+ // Store resolved anchor for click handler
909
+ menu.setAttribute("data-resolved-anchor", anchorEl.getAttribute("data-cesium-anchor") || "");
910
+ menu.setAttribute("data-resolved-text", text.slice(0, 4096));
911
+ }
912
+
913
+ document.addEventListener("selectionchange", function () {
914
+ // selectionchange fires frequently; use a short debounce
915
+ showMenuNearSelection();
916
+ });
917
+
918
+ // Also reposition on mouseup (for initial selection end)
919
+ document.addEventListener("mouseup", function () {
920
+ setTimeout(showMenuNearSelection, 10);
921
+ });
922
+
923
+ if (selectionCommentBtn instanceof HTMLButtonElement) {
924
+ selectionCommentBtn.addEventListener("click", function () {
925
+ var anchorStr = menu.getAttribute("data-resolved-anchor") || "";
926
+ var selText = menu.getAttribute("data-resolved-text") || "";
927
+ var anchorEl = anchorStr
928
+ ? document.querySelector("[data-cesium-anchor=\\"" + anchorStr + "\\"]")
929
+ : null;
930
+ hideMenu();
931
+ if (!anchorEl || !(anchorEl instanceof HTMLElement)) return;
932
+ // Store the selText so openPopup can use it
933
+ var sel = window.getSelection ? window.getSelection() : null;
934
+ // openPopup reads the live selection — if it was cleared by focus
935
+ // change we inject it back via a temporary attribute
936
+ anchorEl.setAttribute("data-prefill-text", selText);
937
+ openPopup(anchorEl, anchorStr);
938
+ anchorEl.removeAttribute("data-prefill-text");
939
+ });
940
+ }
941
+
942
+ // Hide when popup opens (activePopup watcher via close/open hooks handled inline above)
943
+ // Hide menu when selection is cleared or moves outside anchored content
944
+ }
945
+
946
+ // ─── Verdict button wiring ───────────────────────────────────────────────
947
+ function wireVerdictButtons() {
948
+ var btns = document.querySelectorAll("button.cs-verdict-btn[data-verdict]");
949
+ for (var i = 0; i < btns.length; i++) {
950
+ (function (btn) {
951
+ btn.addEventListener("click", function () {
952
+ if (!(btn instanceof HTMLButtonElement)) return;
953
+ if (!apiBase) return;
954
+ var verdictValue = btn.getAttribute("data-verdict");
955
+ if (!verdictValue) return;
956
+
957
+ // Two-step confirmation: show confirm/cancel pair inline
958
+ if (btn.getAttribute("data-confirming") === "true") return;
959
+ btn.setAttribute("data-confirming", "true");
960
+
961
+ var originalText = btn.textContent;
962
+ btn.textContent = "Confirm " + originalText + "?";
963
+ btn.setAttribute("aria-label", "Confirm " + originalText);
964
+
965
+ var cancelConfirmBtn = document.createElement("button");
966
+ cancelConfirmBtn.type = "button";
967
+ cancelConfirmBtn.className = "cs-verdict-btn cs-comment-cancel";
968
+ cancelConfirmBtn.style.cssText = "margin-left:8px;";
969
+ cancelConfirmBtn.textContent = "Cancel";
970
+
971
+ btn.parentNode && btn.parentNode.insertBefore(cancelConfirmBtn, btn.nextSibling);
972
+
973
+ var cancelConfirmTimeout = setTimeout(function () {
974
+ btn.removeAttribute("data-confirming");
975
+ btn.textContent = originalText;
976
+ btn.removeAttribute("aria-label");
977
+ if (cancelConfirmBtn.parentNode) cancelConfirmBtn.remove();
978
+ }, 6000);
979
+
980
+ cancelConfirmBtn.addEventListener("click", function () {
981
+ clearTimeout(cancelConfirmTimeout);
982
+ btn.removeAttribute("data-confirming");
983
+ btn.textContent = originalText;
984
+ btn.removeAttribute("aria-label");
985
+ cancelConfirmBtn.remove();
986
+ });
987
+
988
+ btn.addEventListener("click", function onConfirm() {
989
+ clearTimeout(cancelConfirmTimeout);
990
+ cancelConfirmBtn.remove();
991
+
992
+ // Disable all verdict buttons
993
+ var allVerdictBtns = document.querySelectorAll("button.cs-verdict-btn[data-verdict]");
994
+ for (var j = 0; j < allVerdictBtns.length; j++) {
995
+ var vb = allVerdictBtns[j];
996
+ if (vb instanceof HTMLButtonElement) vb.disabled = true;
997
+ }
998
+ btn.textContent = "Submitting\u2026";
999
+ btn.removeEventListener("click", onConfirm);
1000
+
1001
+ apiPost(apiBase + "/verdict", { verdict: verdictValue })
1002
+ .then(function () {
1003
+ window.location.reload();
1004
+ })
1005
+ .catch(function (err) {
1006
+ // Re-enable buttons on error
1007
+ for (var j2 = 0; j2 < allVerdictBtns.length; j2++) {
1008
+ var vb2 = allVerdictBtns[j2];
1009
+ if (vb2 instanceof HTMLButtonElement) vb2.disabled = false;
1010
+ }
1011
+ btn.removeAttribute("data-confirming");
1012
+ btn.textContent = originalText;
1013
+ updateVerdictButtons();
1014
+ showBannerError("Could not submit verdict: " + (err instanceof Error ? err.message : String(err)));
1015
+ });
1016
+ }, { once: true });
1017
+ });
1018
+ })(btns[i]);
1019
+ }
1020
+ }
1021
+
1022
+ // ─── Freeze UI (non-open status) ─────────────────────────────────────────
1023
+ function freezeUi() {
1024
+ // Hide affordances, disable verdict buttons
1025
+ var affordances = document.querySelectorAll(".cs-anchor-affordance");
1026
+ for (var i = 0; i < affordances.length; i++) {
1027
+ var a = affordances[i];
1028
+ if (a instanceof HTMLElement) a.style.display = "none";
1029
+ }
1030
+ var vBtns = document.querySelectorAll("button.cs-verdict-btn[data-verdict]");
1031
+ for (var j = 0; j < vBtns.length; j++) {
1032
+ var vb = vBtns[j];
1033
+ if (vb instanceof HTMLButtonElement) vb.disabled = true;
1034
+ }
1035
+ }
1036
+
1037
+ // ─── wireAnnotateFrozen: read-only path for post-verdict artifacts ────────
1038
+ //
1039
+ // Runs when status !== "open". The client script is kept in the document
1040
+ // so that positionBubbles() and wireHoverLinking() can still run.
1041
+ // All interactive affordances are hidden by CSS (data-cesium-status="complete").
1042
+ function wireAnnotateFrozen(frozenData) {
1043
+ document.addEventListener("DOMContentLoaded", function () {
1044
+ // skip frozen: API wiring is intentionally not registered here.
1045
+ // affordances are hidden by CSS; verdict footer is hidden by CSS.
1046
+ document.body.classList.add("cs-annotate-active");
1047
+
1048
+ showSessionEndedBanner("This review is closed.");
1049
+
1050
+ var rail = getRail();
1051
+
1052
+ // If the rail is already populated server-side, skip client-side mounting.
1053
+ // Defensive: if the rail is empty but interactive.comments has entries
1054
+ // (e.g. older artifact), fall back to mounting from state.
1055
+ if (rail && rail.querySelector(".cs-comment-bubble")) {
1056
+ // Rail pre-populated by setVerdict — positioning only
1057
+ requestAnimationFrame(positionBubbles);
1058
+ } else {
1059
+ // Fallback: mount from state (older artifact without server-side render)
1060
+ var comments = (frozenData && Array.isArray(frozenData.comments))
1061
+ ? frozenData.comments
1062
+ : [];
1063
+ for (var i = 0; i < comments.length; i++) {
1064
+ mountBubble(comments[i]);
1065
+ }
1066
+ requestAnimationFrame(positionBubbles);
1067
+ }
1068
+
1069
+ window.addEventListener("resize", onResize);
1070
+ wireHoverLinking();
1071
+ // NOTE: no injectAffordances, no wireVerdictButtons, no API calls.
1072
+ });
1073
+ }
1074
+
1075
+ // ─── DOMContentLoaded: main init ─────────────────────────────────────────
1076
+ document.addEventListener("DOMContentLoaded", function () {
1077
+ // Add body class for padding-bottom (fallback for browsers without :has)
1078
+ document.body.classList.add("cs-annotate-active");
1079
+
1080
+ if (!apiBase) {
1081
+ // Offline: render seeded comments but hide affordances, disable verdict btns
1082
+ mountAllSeededComments();
1083
+ updateCount();
1084
+ freezeUi();
1085
+ requestAnimationFrame(positionBubbles);
1086
+ window.addEventListener("resize", onResize);
1087
+ wireHoverLinking();
1088
+ return;
1089
+ }
1090
+
1091
+ // Normal wiring
1092
+ injectAffordances();
1093
+ mountAllSeededComments();
1094
+ updateCount();
1095
+ updateVerdictButtons();
1096
+ wireVerdictButtons();
1097
+ wireSelectionMenu();
1098
+ requestAnimationFrame(positionBubbles);
1099
+ window.addEventListener("resize", onResize);
1100
+ wireHoverLinking();
1101
+ });
1102
+ }
1103
+
1104
+ // ─── Dispatch on kind ────────────────────────────────────────────────────────
1105
+ if (kind === "annotate") {
1106
+ wireAnnotate(interactive);
1107
+ } else {
1108
+ // ask (or legacy without kind) — existing wiring
1109
+ document.addEventListener("DOMContentLoaded", wireAsk);
1110
+ }
1111
+
315
1112
  })();`;
316
1113
  }