@nuniapp/widget 0.1.4 → 0.1.6

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.
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as generateSecret, i as LIMITS, n as MASK_ATTRIBUTE, o as isProjectId, r as MASK_SELECTOR, t as CAPTURE_MARK } from "./mark-BeysEAHp.js";
1
+ import { a as REACTIONS, i as LIMITS, n as MASK_ATTRIBUTE, o as generateSecret, r as MASK_SELECTOR, s as isProjectId, t as CAPTURE_MARK } from "./mark-B8wojiFu.js";
2
2
  //#region ../shared/src/path.ts
3
3
  /**
4
4
  * Normalize a URL to the key comments are matched by. Query and hash are
@@ -91,6 +91,13 @@ function stripHtmlUrlQueries(html) {
91
91
  }));
92
92
  }
93
93
  //#endregion
94
+ //#region ../shared/src/prompt.ts
95
+ const PACKAGES = {
96
+ widget: "@nuniapp/widget",
97
+ react: "@nuniapp/react",
98
+ cli: "@nuniapp/cli"
99
+ };
100
+ //#endregion
94
101
  //#region ../shared/src/comment-prompt.ts
95
102
  /** A code fence longer than any backtick run inside `content`. */
96
103
  function fence(content, lang = "") {
@@ -114,6 +121,11 @@ function buildCommentPrompt(comment, options = {}) {
114
121
  const { anchor, page } = comment;
115
122
  const out = [];
116
123
  out.push("Fix this piece of feedback that was left on the live site with Nuni.", "", "## Comment", "", quote(comment.body), "", `From ${comment.authorName} on ${new Date(comment.createdAt).toISOString().slice(0, 10)}` + (comment.status === "resolved" ? " (already resolved)" : ""));
124
+ if (comment.replies?.length) {
125
+ out.push("", `## Replies (${comment.replies.length})`, "");
126
+ for (const reply of comment.replies) out.push(`${reply.authorName}${reply.isOwner ? " (owner)" : ""}:`, quote(reply.body), "");
127
+ out.pop();
128
+ }
117
129
  out.push("", "## Where", "");
118
130
  out.push(`- Page: ${page.title ? `${page.title} ` : ""}(${page.path})`);
119
131
  out.push(`- Open it: ${commentUrl(page, comment._id)}`);
@@ -122,6 +134,12 @@ function buildCommentPrompt(comment, options = {}) {
122
134
  out.push("", "## Element", "");
123
135
  out.push(`- Tag: <${anchor.tag}>`);
124
136
  if (anchor.text) out.push(`- Text: ${inline(anchor.text)}`);
137
+ if (anchor.quote) out.push(`- Selected text: ${inline(anchor.quote.exact)}`);
138
+ if (anchor.region) {
139
+ const pct = (n) => `${Math.round(n * 100)}%`;
140
+ const r = anchor.region;
141
+ out.push(`- Area: ${pct(r.w)} × ${pct(r.h)} of the element, from ${pct(r.x)} left and ${pct(r.y)} top`);
142
+ }
125
143
  if (anchor.componentName) out.push(`- React component: ${anchor.componentName}`);
126
144
  if (anchor.selectors.testId) out.push(`- Test ID: ${inline(anchor.selectors.testId)}`);
127
145
  if (anchor.selectors.id) out.push(`- ID: ${inline(anchor.selectors.id)}`);
@@ -142,19 +160,27 @@ function buildCommentPrompt(comment, options = {}) {
142
160
  for (const r of context.network) out.push(`- ${r.method} ${r.url} → ${r.status === 0 ? "network error" : r.status}`);
143
161
  }
144
162
  if (includeContext && comment.screenshotUrl) out.push("", "## Screenshot", "", comment.screenshotUrl);
145
- out.push("", "## Steps", "", "1. Find the code that renders this element. Search for the text, the component name, the test ID or the selector.", "2. Make the change the comment asks for, and keep the fix focused on it.", "3. Tell me what you changed, so the comment can be resolved in Nuni.");
163
+ out.push("", "## Steps", "", "1. Find the code that renders this element. Search for the text, the component name, the test ID or the selector.", "2. Make the change the comment asks for, and keep the fix focused on it.", "3. Tell me what you changed, so the comment can be resolved in Nuni.", ` If the Nuni CLI is signed in to this project (\`npx ${PACKAGES.cli}@latest login\`), resolve it with \`npx ${PACKAGES.cli}@latest resolve ${comment._id}\` or the \`resolve_comment\` MCP tool.`);
146
164
  return out.join("\n");
147
165
  }
148
166
  //#endregion
149
167
  //#region src/config.ts
150
- const VERSION = "0.1.4";
168
+ const VERSION = "0.1.6";
151
169
  function resolveConfig(options) {
152
170
  return {
153
171
  project: options.project,
154
172
  convexUrl: options.convexUrl ?? "https://greedy-giraffe-33.convex.cloud",
155
173
  convexSiteUrl: options.convexSiteUrl ?? "https://greedy-giraffe-33.convex.site",
156
174
  appUrl: (options.appUrl ?? "https://nuni.praveenjuge.com").replace(/\/$/, ""),
157
- getPageKey: options.getPageKey,
175
+ getPageKey: options.getPageKey ?? pageKeyFor(options.pageKey),
176
+ position: oneOf(options.position, POSITIONS, "bottom-right"),
177
+ accentColor: options.accentColor?.trim() || null,
178
+ theme: oneOf(options.theme, THEMES, "auto"),
179
+ label: options.label?.trim() || null,
180
+ hotkey: options.hotkey === false ? null : options.hotkey?.trim().slice(0, 1).toLowerCase() || "c",
181
+ zIndex: typeof options.zIndex === "number" && Number.isFinite(options.zIndex) ? Math.round(options.zIndex) : null,
182
+ locale: options.locale?.trim() || void 0,
183
+ messages: options.messages ?? {},
158
184
  capture: {
159
185
  console: options.capture?.console ?? true,
160
186
  network: options.capture?.network ?? true,
@@ -163,6 +189,35 @@ function resolveConfig(options) {
163
189
  }
164
190
  };
165
191
  }
192
+ const POSITIONS = [
193
+ "bottom-right",
194
+ "bottom-left",
195
+ "top-right",
196
+ "top-left"
197
+ ];
198
+ const THEMES = [
199
+ "auto",
200
+ "light",
201
+ "dark"
202
+ ];
203
+ function oneOf(value, allowed, fallback) {
204
+ return allowed.includes(value) ? value : fallback;
205
+ }
206
+ /** Query parameters that never tell pages apart. */
207
+ const IGNORED_PARAMS = /^(nuni|utm_.*|fbclid|gclid|msclkid|ref)$/i;
208
+ function pageKeyFor(preset = "path") {
209
+ return (url) => {
210
+ const path = normalizePath(url);
211
+ if (preset === "path+search") {
212
+ const params = [...url.searchParams].filter(([name]) => !IGNORED_PARAMS.test(name)).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
213
+ return params.length ? `${path}?${new URLSearchParams(params).toString()}` : path;
214
+ }
215
+ if (preset === "path+hash" && url.hash.length > 1 && !path.includes("#")) {
216
+ if (!/^#!?\//.test(url.hash)) return `${path}${url.hash}`;
217
+ }
218
+ return path;
219
+ };
220
+ }
166
221
  //#endregion
167
222
  //#region \0@oxc-project+runtime@0.151.0/helpers/esm/typeof.js
168
223
  function _typeof(o) {
@@ -513,6 +568,463 @@ function attributeAllowed(name, options) {
513
568
  return !prefixes || !prefixes.some((p) => name.startsWith(p));
514
569
  }
515
570
  //#endregion
571
+ //#region ../anchor/src/text.ts
572
+ const SKIP_TEXT_TAGS = /* @__PURE__ */ new Set([
573
+ "SCRIPT",
574
+ "STYLE",
575
+ "NOSCRIPT",
576
+ "TEMPLATE",
577
+ "SVG"
578
+ ]);
579
+ function normalizeText(value) {
580
+ return value.replace(/\s+/g, " ").trim();
581
+ }
582
+ /**
583
+ * Visible-ish text of an element, whitespace-normalized and truncated.
584
+ * Areas marked data-nuni-mask count as empty, both when capturing and when
585
+ * resolving, so their (private) text is never stored and matching stays
586
+ * consistent.
587
+ */
588
+ function elementText(el, max = 120) {
589
+ if (el.closest(MASK_SELECTOR)) return "";
590
+ if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") return normalizeText(el.placeholder || el.getAttribute("aria-label") || "").slice(0, max);
591
+ let out = "";
592
+ const walk = (node) => {
593
+ if (out.length > max * 2) return;
594
+ if (node.nodeType === 3) {
595
+ out += node.nodeValue ?? "";
596
+ return;
597
+ }
598
+ if (node.nodeType !== 1) return;
599
+ const tag = node.tagName.toUpperCase();
600
+ if (SKIP_TEXT_TAGS.has(tag)) return;
601
+ if (node.hasAttribute("data-nuni-mask")) return;
602
+ if (node.hasAttribute("data-nuni")) return;
603
+ if (tag === "IMG") {
604
+ out += " " + (node.getAttribute("alt") ?? "") + " ";
605
+ return;
606
+ }
607
+ const shadow = node.shadowRoot;
608
+ if (shadow) {
609
+ for (const child of Array.from(shadow.childNodes)) walk(child);
610
+ return;
611
+ }
612
+ if (tag === "SLOT") {
613
+ const assigned = node.assignedNodes?.({ flatten: true });
614
+ if (assigned?.length) {
615
+ for (const child of assigned) walk(child);
616
+ return;
617
+ }
618
+ }
619
+ for (const child of Array.from(node.childNodes)) walk(child);
620
+ if (tag === "BR" || tag === "P" || tag === "DIV" || tag === "LI") out += " ";
621
+ };
622
+ walk(el);
623
+ return normalizeText(out).slice(0, max);
624
+ }
625
+ function bigrams(value) {
626
+ const s = value.toLowerCase();
627
+ const map = /* @__PURE__ */ new Map();
628
+ for (let i = 0; i < s.length - 1; i++) {
629
+ const gram = s.slice(i, i + 2);
630
+ map.set(gram, (map.get(gram) ?? 0) + 1);
631
+ }
632
+ return map;
633
+ }
634
+ /** Sørensen-Dice coefficient on character bigrams, 0..1. */
635
+ function textSimilarity(a, b) {
636
+ if (a === b) return 1;
637
+ const na = a.toLowerCase();
638
+ const nb = b.toLowerCase();
639
+ if (na === nb) return 1;
640
+ if (na.length < 2 || nb.length < 2) return 0;
641
+ const ga = bigrams(na);
642
+ const gb = bigrams(nb);
643
+ let overlap = 0;
644
+ for (const [gram, count] of ga) {
645
+ const other = gb.get(gram);
646
+ if (other) overlap += Math.min(count, other);
647
+ }
648
+ return 2 * overlap / (na.length - 1 + (nb.length - 1));
649
+ }
650
+ //#endregion
651
+ //#region ../anchor/src/quote.ts
652
+ const SKIP_TAGS = /* @__PURE__ */ new Set([
653
+ "SCRIPT",
654
+ "STYLE",
655
+ "NOSCRIPT",
656
+ "TEMPLATE",
657
+ "SVG"
658
+ ]);
659
+ /** Elements whose edges read as a space between words. */
660
+ const BLOCK_TAGS = /* @__PURE__ */ new Set([
661
+ "ADDRESS",
662
+ "ARTICLE",
663
+ "ASIDE",
664
+ "BLOCKQUOTE",
665
+ "BR",
666
+ "DD",
667
+ "DIV",
668
+ "DL",
669
+ "DT",
670
+ "FIGCAPTION",
671
+ "FIGURE",
672
+ "FOOTER",
673
+ "FORM",
674
+ "H1",
675
+ "H2",
676
+ "H3",
677
+ "H4",
678
+ "H5",
679
+ "H6",
680
+ "HEADER",
681
+ "HR",
682
+ "LI",
683
+ "MAIN",
684
+ "NAV",
685
+ "OL",
686
+ "P",
687
+ "PRE",
688
+ "SECTION",
689
+ "TABLE",
690
+ "TD",
691
+ "TH",
692
+ "TR",
693
+ "UL"
694
+ ]);
695
+ /** Formatting inside a paragraph: a quote climbs out of these. */
696
+ const INLINE_TAGS = /* @__PURE__ */ new Set([
697
+ "A",
698
+ "ABBR",
699
+ "B",
700
+ "BDI",
701
+ "BDO",
702
+ "CITE",
703
+ "CODE",
704
+ "DATA",
705
+ "DEL",
706
+ "DFN",
707
+ "EM",
708
+ "I",
709
+ "INS",
710
+ "KBD",
711
+ "LABEL",
712
+ "MARK",
713
+ "Q",
714
+ "S",
715
+ "SAMP",
716
+ "SMALL",
717
+ "SPAN",
718
+ "STRONG",
719
+ "SUB",
720
+ "SUP",
721
+ "TIME",
722
+ "U",
723
+ "VAR"
724
+ ]);
725
+ /** Longest text searched for a quote inside one element. */
726
+ const MAX_TEXT = 2e4;
727
+ /** Longest text searched for an edited quote, word by word. */
728
+ const MAX_FUZZY_TEXT = 6e3;
729
+ const FUZZY_MIN = .75;
730
+ const WORD = /[\p{L}\p{N}]/u;
731
+ function textMap(el) {
732
+ const map = {
733
+ text: "",
734
+ nodes: [],
735
+ node: [],
736
+ offset: []
737
+ };
738
+ if (el.closest(MASK_SELECTOR)) return map;
739
+ const chars = [];
740
+ let space = true;
741
+ const push = (c, node, offset) => {
742
+ chars.push(c);
743
+ map.node.push(node);
744
+ map.offset.push(offset);
745
+ };
746
+ const gap = () => {
747
+ if (space || !map.nodes.length) return;
748
+ const last = map.nodes.length - 1;
749
+ push(" ", last, map.nodes[last].length);
750
+ space = true;
751
+ };
752
+ const walk = (node) => {
753
+ if (chars.length >= MAX_TEXT) return;
754
+ if (node.nodeType === 3) {
755
+ const value = node.nodeValue ?? "";
756
+ if (!value) return;
757
+ const index = map.nodes.push(node) - 1;
758
+ for (let i = 0; i < value.length && chars.length < MAX_TEXT; i++) {
759
+ const c = value[i];
760
+ if (c === " " || c === "\n" || c === " " || c === "\r" || c === "\f") {
761
+ if (!space) push(" ", index, i);
762
+ space = true;
763
+ } else {
764
+ push(c, index, i);
765
+ space = false;
766
+ }
767
+ }
768
+ return;
769
+ }
770
+ if (node.nodeType !== 1) return;
771
+ const el = node;
772
+ const tag = el.tagName.toUpperCase();
773
+ if (SKIP_TAGS.has(tag)) return;
774
+ if (el.hasAttribute("data-nuni-mask") || el.hasAttribute("data-nuni")) return;
775
+ const block = BLOCK_TAGS.has(tag);
776
+ if (block) gap();
777
+ for (const child of Array.from(el.childNodes)) walk(child);
778
+ if (block) gap();
779
+ };
780
+ walk(el);
781
+ while (chars.length && chars[chars.length - 1] === " ") {
782
+ chars.pop();
783
+ map.node.pop();
784
+ map.offset.pop();
785
+ }
786
+ map.text = chars.join("");
787
+ return map;
788
+ }
789
+ /**
790
+ * Where a DOM boundary point falls in the element's text: the index of the
791
+ * first character at or after it.
792
+ */
793
+ function textOffset(map, container, offset) {
794
+ if (!map.text.length) return 0;
795
+ const point = (container.ownerDocument ?? container).createRange();
796
+ point.setStart(container, offset);
797
+ point.collapse(true);
798
+ let lo = 0;
799
+ let hi = map.text.length;
800
+ try {
801
+ while (lo < hi) {
802
+ const mid = lo + hi >> 1;
803
+ const node = map.nodes[map.node[mid]];
804
+ if (point.comparePoint(node, map.offset[mid]) < 0) lo = mid + 1;
805
+ else hi = mid;
806
+ }
807
+ } catch {
808
+ return -1;
809
+ }
810
+ return lo;
811
+ }
812
+ /** A Range over characters [start, end) of the element's text. */
813
+ function rangeFor(map, start, end) {
814
+ const first = map.nodes[map.node[start]];
815
+ const last = map.nodes[map.node[end - 1]];
816
+ const range = first.ownerDocument.createRange();
817
+ range.setStart(first, map.offset[start]);
818
+ range.setEnd(last, Math.min(last.length, map.offset[end - 1] + 1));
819
+ return range;
820
+ }
821
+ /**
822
+ * The element a text selection is anchored to: the closest element holding
823
+ * all of it, out of inline formatting (a <strong>, a link) so it is the
824
+ * paragraph or heading, which is easier to find again.
825
+ */
826
+ function quoteContainer(range) {
827
+ let node = range.commonAncestorContainer;
828
+ if (node.nodeType !== 1) node = node.parentNode;
829
+ if (!node || node.nodeType !== 1) return null;
830
+ let el = node;
831
+ const body = el.ownerDocument.body;
832
+ while (INLINE_TAGS.has(el.tagName.toUpperCase()) && el.parentElement && el.parentElement !== body) el = el.parentElement;
833
+ return el;
834
+ }
835
+ /** The quoted words of a selection inside `el`, with context around them. */
836
+ function captureQuote(range, el) {
837
+ const map = textMap(el);
838
+ let start = textOffset(map, range.startContainer, range.startOffset);
839
+ let end = textOffset(map, range.endContainer, range.endOffset);
840
+ if (start < 0 || end < 0) return null;
841
+ const { text } = map;
842
+ while (start < end && text[start] === " ") start++;
843
+ while (end > start && text[end - 1] === " ") end--;
844
+ if (end <= start) return null;
845
+ const exact = text.slice(start, Math.min(end, start + LIMITS.quoteMaxLength));
846
+ const after = start + exact.length;
847
+ return {
848
+ exact,
849
+ prefix: text.slice(Math.max(0, start - LIMITS.quoteContextLength), start),
850
+ suffix: text.slice(after, after + LIMITS.quoteContextLength)
851
+ };
852
+ }
853
+ /**
854
+ * Find a quote again inside `el`. When it is not there (the sentence moved
855
+ * to the next paragraph), look in the parent and grandparent, but only take
856
+ * a match there whose surrounding text still agrees.
857
+ */
858
+ function findQuote(el, quote) {
859
+ const own = findIn(el, quote, 0);
860
+ if (own) return own;
861
+ let node = el.parentElement;
862
+ const body = el.ownerDocument.body;
863
+ for (let i = 0; node && node !== body && i < 2; i++) {
864
+ const hit = findIn(node, quote, .6);
865
+ if (hit) return hit;
866
+ node = node.parentElement;
867
+ }
868
+ return null;
869
+ }
870
+ function affixSimilarity(actual, expected) {
871
+ if (!expected) return actual ? .8 : 1;
872
+ return textSimilarity(actual, expected);
873
+ }
874
+ /** How well the text around a match agrees with the quote's context. */
875
+ function contextScore(text, start, end, quote) {
876
+ const before = text.slice(Math.max(0, start - quote.prefix.length), start);
877
+ const after = text.slice(end, end + quote.suffix.length);
878
+ return (affixSimilarity(before.trimStart(), quote.prefix.trimStart()) + affixSimilarity(after.trimEnd(), quote.suffix.trimEnd())) / 2;
879
+ }
880
+ function findIn(el, quote, minContext) {
881
+ const { exact } = quote;
882
+ if (!exact) return null;
883
+ const map = textMap(el);
884
+ const { text } = map;
885
+ if (!text) return null;
886
+ let best = -1;
887
+ let bestScore = -1;
888
+ for (let i = text.indexOf(exact); i >= 0; i = text.indexOf(exact, i + 1)) {
889
+ const score = contextScore(text, i, i + exact.length, quote);
890
+ if (score > bestScore) {
891
+ best = i;
892
+ bestScore = score;
893
+ }
894
+ }
895
+ if (best >= 0 && bestScore >= minContext) return {
896
+ range: rangeFor(map, best, best + exact.length),
897
+ exact: true
898
+ };
899
+ if (exact.length < 8 || text.length > MAX_FUZZY_TEXT) return null;
900
+ const fuzzy = closestSpan(text, quote);
901
+ if (!fuzzy) return null;
902
+ const context = contextScore(text, fuzzy.start, fuzzy.end, quote);
903
+ if (fuzzy.score < FUZZY_MIN || context < Math.max(minContext, .3)) return null;
904
+ return {
905
+ range: rangeFor(map, fuzzy.start, fuzzy.end),
906
+ exact: false
907
+ };
908
+ }
909
+ /** Word-aligned spans of `text` scored by similarity to the quote. */
910
+ function closestSpan(text, quote) {
911
+ const word = (c) => c !== void 0 && WORD.test(c);
912
+ const starts = [];
913
+ const ends = [];
914
+ for (let i = 0; i < text.length; i++) {
915
+ const c = text[i];
916
+ if (c === " ") continue;
917
+ const prev = text[i - 1];
918
+ const next = text[i + 1];
919
+ if (prev === void 0 || prev === " " || word(c) && !word(prev)) starts.push(i);
920
+ if (next === void 0 || next === " " || word(c) && !word(next)) ends.push(i + 1);
921
+ }
922
+ const length = quote.exact.length;
923
+ let best = null;
924
+ let e = 0;
925
+ for (const start of starts) {
926
+ while (e < ends.length && ends[e] <= start) e++;
927
+ for (let j = e; j < ends.length; j++) {
928
+ const span = ends[j] - start;
929
+ if (span < length * .7) continue;
930
+ if (span > length * 1.3) break;
931
+ const score = textSimilarity(text.slice(start, ends[j]), quote.exact);
932
+ if (!best || score > best.score) best = {
933
+ start,
934
+ end: ends[j],
935
+ score
936
+ };
937
+ }
938
+ }
939
+ return best;
940
+ }
941
+ //#endregion
942
+ //#region ../anchor/src/scope.ts
943
+ function isShadowRoot(node) {
944
+ return Boolean(node && node.nodeType === 11 && "host" in node);
945
+ }
946
+ /** The shadow root or document the element lives in. */
947
+ function scopeRootOf(el) {
948
+ const root = el.getRootNode();
949
+ return isShadowRoot(root) ? root : el.ownerDocument;
950
+ }
951
+ function scopeDocument(root) {
952
+ return isShadowRoot(root) ? root.ownerDocument : root;
953
+ }
954
+ /** The <iframe> showing this document, when the parent is same-origin. */
955
+ function frameElementOf(doc) {
956
+ try {
957
+ return doc.defaultView?.frameElement ?? null;
958
+ } catch {
959
+ return null;
960
+ }
961
+ }
962
+ /** A same-origin iframe's document, or null (cross-origin, not loaded). */
963
+ function frameDocument(el) {
964
+ if (el.tagName !== "IFRAME" && el.tagName !== "FRAME") return null;
965
+ try {
966
+ return el.contentDocument;
967
+ } catch {
968
+ return null;
969
+ }
970
+ }
971
+ /**
972
+ * Shadow hosts and iframes between the page's document and the element,
973
+ * outermost first.
974
+ */
975
+ function scopeChain(el, top) {
976
+ const chain = [];
977
+ let node = el;
978
+ for (let depth = 0; depth < 8; depth++) {
979
+ const root = node.getRootNode();
980
+ if (isShadowRoot(root)) {
981
+ chain.unshift({
982
+ kind: "shadow",
983
+ host: root.host
984
+ });
985
+ node = root.host;
986
+ continue;
987
+ }
988
+ if (node.ownerDocument === top) break;
989
+ const frame = frameElementOf(node.ownerDocument);
990
+ if (!frame) break;
991
+ chain.unshift({
992
+ kind: "frame",
993
+ host: frame
994
+ });
995
+ node = frame;
996
+ }
997
+ return chain;
998
+ }
999
+ /**
1000
+ * The element's box in the page's viewport: getBoundingClientRect plus the
1001
+ * offset of every iframe it is inside.
1002
+ */
1003
+ function viewportRect(el, top) {
1004
+ const r = el.getBoundingClientRect();
1005
+ let left = r.left;
1006
+ let topPx = r.top;
1007
+ let clipped = false;
1008
+ let doc = el.ownerDocument;
1009
+ for (let depth = 0; depth < 8 && doc !== top; depth++) {
1010
+ const frame = frameElementOf(doc);
1011
+ if (!frame) break;
1012
+ const view = doc.defaultView;
1013
+ if (view && (left + r.width < 0 || topPx + r.height < 0 || left > view.innerWidth || topPx > view.innerHeight)) clipped = true;
1014
+ const f = frame.getBoundingClientRect();
1015
+ left += f.left + frame.clientLeft;
1016
+ topPx += f.top + frame.clientTop;
1017
+ doc = frame.ownerDocument;
1018
+ }
1019
+ return {
1020
+ left,
1021
+ top: topPx,
1022
+ width: r.width,
1023
+ height: r.height,
1024
+ clipped
1025
+ };
1026
+ }
1027
+ //#endregion
516
1028
  //#region ../anchor/src/stable.ts
517
1029
  /**
518
1030
  * Heuristics that decide whether an id, class or attribute is a stable,
@@ -666,7 +1178,7 @@ function nthOfType(el) {
666
1178
  return index;
667
1179
  }
668
1180
  function countOfType(el) {
669
- const parent = el.parentElement;
1181
+ const parent = el.parentNode;
670
1182
  if (!parent) return 1;
671
1183
  let count = 0;
672
1184
  for (const child of Array.from(parent.children)) if (child.tagName === el.tagName) count++;
@@ -679,21 +1191,23 @@ function queryCount(root, selector) {
679
1191
  return Infinity;
680
1192
  }
681
1193
  }
682
- function isUniqueId(doc, id) {
683
- return queryCount(doc, `#${cssEscape(id)}`) === 1;
1194
+ function isUniqueId(root, id) {
1195
+ return queryCount(root, `#${cssEscape(id)}`) === 1;
684
1196
  }
685
1197
  /**
686
1198
  * Structural path: nth-of-type chain from the nearest ancestor with a
687
1199
  * stable unique id (or from <html>). Always resolvable when the DOM is
688
- * unchanged; the last-resort selector.
1200
+ * unchanged; the last-resort selector. Inside a shadow root it starts at
1201
+ * the root's top-level element instead of <html>.
689
1202
  */
690
1203
  function buildPath(el) {
1204
+ const root = scopeRootOf(el);
691
1205
  const doc = el.ownerDocument;
692
1206
  const parts = [];
693
1207
  let node = el;
694
1208
  while (node && node !== doc.documentElement) {
695
1209
  const id = stableIdOf(node);
696
- if (id && node !== el && isUniqueId(doc, id)) {
1210
+ if (id && node !== el && isUniqueId(root, id)) {
697
1211
  parts.unshift(`#${cssEscape(id)}`);
698
1212
  return parts.join(" > ");
699
1213
  }
@@ -701,8 +1215,28 @@ function buildPath(el) {
701
1215
  parts.unshift(countOfType(node) > 1 ? `${tag}:nth-of-type(${nthOfType(node)})` : tag);
702
1216
  node = node.parentElement;
703
1217
  }
1218
+ if (isShadowRoot(root)) return parts.join(" > ");
704
1219
  return parts.length ? `html > ${parts.join(" > ")}` : "html";
705
1220
  }
1221
+ /**
1222
+ * The element a path points to in a scope. Shadow paths are matched from
1223
+ * the root's top-level elements, like document paths are from <html>.
1224
+ */
1225
+ function queryPath(root, path) {
1226
+ let matches;
1227
+ try {
1228
+ matches = Array.from(root.querySelectorAll(path)).slice(0, 50);
1229
+ } catch {
1230
+ return null;
1231
+ }
1232
+ if (!isShadowRoot(root) || path.startsWith("#")) return matches[0] ?? null;
1233
+ const depth = path.split(" > ").length;
1234
+ return matches.find((el) => {
1235
+ let node = el;
1236
+ for (let i = 1; i < depth && node; i++) node = node.parentElement;
1237
+ return node?.parentNode === root;
1238
+ }) ?? null;
1239
+ }
706
1240
  function describeSegment(el, options) {
707
1241
  const tag = tagOf(el);
708
1242
  const testId = testIdOf(el, options);
@@ -731,8 +1265,9 @@ function describeSegment(el, options) {
731
1265
  */
732
1266
  function buildCssSelector(el, options = {}) {
733
1267
  const doc = el.ownerDocument;
1268
+ const root = scopeRootOf(el);
734
1269
  const id = stableIdOf(el);
735
- if (id && isUniqueId(doc, id)) return `#${cssEscape(id)}`;
1270
+ if (id && isUniqueId(root, id)) return `#${cssEscape(id)}`;
736
1271
  const segments = [];
737
1272
  let node = el;
738
1273
  for (let depth = 0; node && depth < 6; depth++) {
@@ -741,92 +1276,25 @@ function buildCssSelector(el, options = {}) {
741
1276
  break;
742
1277
  }
743
1278
  const nodeId = stableIdOf(node);
744
- if (depth > 0 && nodeId && isUniqueId(doc, nodeId)) {
1279
+ if (depth > 0 && nodeId && isUniqueId(root, nodeId)) {
745
1280
  segments.unshift(`#${cssEscape(nodeId)}`);
746
1281
  const selector = segments.join(" > ");
747
- if (queryCount(doc, selector) === 1) return selector;
1282
+ if (queryCount(root, selector) === 1) return selector;
748
1283
  break;
749
1284
  }
750
1285
  segments.unshift(describeSegment(node, options));
751
1286
  let selector = segments.join(" > ");
752
- if (queryCount(doc, selector) === 1) return selector;
1287
+ if (queryCount(root, selector) === 1) return selector;
753
1288
  const parent = node.parentElement;
754
1289
  if (parent && queryCount(parent, `:scope > ${segments[0]}`) > 1) {
755
1290
  segments[0] = `${segments[0]}:nth-of-type(${nthOfType(node)})`;
756
1291
  selector = segments.join(" > ");
757
- if (queryCount(doc, selector) === 1) return selector;
1292
+ if (queryCount(root, selector) === 1) return selector;
758
1293
  }
759
1294
  node = node.parentElement;
760
1295
  }
761
1296
  const selector = segments.join(" > ");
762
- return queryCount(doc, selector) === 1 ? selector : void 0;
763
- }
764
- //#endregion
765
- //#region ../anchor/src/text.ts
766
- const SKIP_TEXT_TAGS = /* @__PURE__ */ new Set([
767
- "SCRIPT",
768
- "STYLE",
769
- "NOSCRIPT",
770
- "TEMPLATE",
771
- "SVG"
772
- ]);
773
- function normalizeText(value) {
774
- return value.replace(/\s+/g, " ").trim();
775
- }
776
- /**
777
- * Visible-ish text of an element, whitespace-normalized and truncated.
778
- * Areas marked data-nuni-mask count as empty, both when capturing and when
779
- * resolving, so their (private) text is never stored and matching stays
780
- * consistent.
781
- */
782
- function elementText(el, max = 120) {
783
- if (el.closest(MASK_SELECTOR)) return "";
784
- if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return normalizeText(el.placeholder || el.getAttribute("aria-label") || "").slice(0, max);
785
- let out = "";
786
- const walk = (node) => {
787
- if (out.length > max * 2) return;
788
- if (node.nodeType === 3) {
789
- out += node.nodeValue ?? "";
790
- return;
791
- }
792
- if (node.nodeType !== 1) return;
793
- const tag = node.tagName.toUpperCase();
794
- if (SKIP_TEXT_TAGS.has(tag)) return;
795
- if (node.hasAttribute("data-nuni-mask")) return;
796
- if (tag === "IMG") {
797
- out += " " + (node.getAttribute("alt") ?? "") + " ";
798
- return;
799
- }
800
- for (const child of Array.from(node.childNodes)) walk(child);
801
- if (tag === "BR" || tag === "P" || tag === "DIV" || tag === "LI") out += " ";
802
- };
803
- walk(el);
804
- return normalizeText(out).slice(0, max);
805
- }
806
- function bigrams(value) {
807
- const s = value.toLowerCase();
808
- const map = /* @__PURE__ */ new Map();
809
- for (let i = 0; i < s.length - 1; i++) {
810
- const gram = s.slice(i, i + 2);
811
- map.set(gram, (map.get(gram) ?? 0) + 1);
812
- }
813
- return map;
814
- }
815
- /** Sørensen-Dice coefficient on character bigrams, 0..1. */
816
- function textSimilarity(a, b) {
817
- if (a === b) return 1;
818
- const na = a.toLowerCase();
819
- const nb = b.toLowerCase();
820
- if (na === nb) return 1;
821
- if (na.length < 2 || nb.length < 2) return 0;
822
- const ga = bigrams(na);
823
- const gb = bigrams(nb);
824
- let overlap = 0;
825
- for (const [gram, count] of ga) {
826
- const other = gb.get(gram);
827
- if (other) overlap += Math.min(count, other);
828
- }
829
- return 2 * overlap / (na.length - 1 + (nb.length - 1));
1297
+ return queryCount(root, selector) === 1 ? selector : void 0;
830
1298
  }
831
1299
  //#endregion
832
1300
  //#region ../anchor/src/capture.ts
@@ -835,7 +1303,7 @@ function capturedAttributes(el, options) {
835
1303
  if (el.closest(MASK_SELECTOR)) return attrs;
836
1304
  for (const name of CAPTURED_ATTRIBUTES) {
837
1305
  if (!attributeAllowed(name, options)) continue;
838
- if (name === "value" && !(el instanceof HTMLButtonElement || el.getAttribute("type") === "submit")) continue;
1306
+ if (name === "value" && !(el.tagName === "BUTTON" || el.getAttribute("type") === "submit")) continue;
839
1307
  const value = el.getAttribute(name);
840
1308
  if (value) attrs[name] = value.slice(0, 200);
841
1309
  }
@@ -863,8 +1331,22 @@ function ancestorsOf(el) {
863
1331
  /**
864
1332
  * Record everything needed to find `el` again later.
865
1333
  * `point` is the click position in viewport (client) coordinates.
1334
+ *
1335
+ * An element inside open shadow roots or same-origin iframes also records
1336
+ * each host and iframe on the way (`scope`), captured the same way, so it
1337
+ * can be found again level by level.
866
1338
  */
867
1339
  function captureAnchor(el, point, options = {}) {
1340
+ const anchor = captureLocal(el, point, options);
1341
+ const chain = scopeChain(el, options.document ?? (typeof document === "undefined" ? el.ownerDocument : document));
1342
+ if (chain.length) anchor.scope = chain.map(({ kind, host }) => ({
1343
+ kind,
1344
+ host: captureLocal(host, void 0, options)
1345
+ }));
1346
+ return anchor;
1347
+ }
1348
+ /** The anchor of `el` within its own scope (document or shadow root). */
1349
+ function captureLocal(el, point, options) {
868
1350
  const doc = el.ownerDocument;
869
1351
  const rect = documentRect(el);
870
1352
  const viewport = viewportInfo(doc);
@@ -880,7 +1362,7 @@ function captureAnchor(el, point, options = {}) {
880
1362
  const testId = testIdOf(el, options);
881
1363
  const css = buildCssSelector(el, options);
882
1364
  const role = el.getAttribute("role") ?? void 0;
883
- const parent = el.parentElement;
1365
+ const parent = el.parentNode;
884
1366
  const siblings = parent ? Array.from(parent.children) : [el];
885
1367
  const anchor = {
886
1368
  v: 1,
@@ -907,8 +1389,135 @@ function captureAnchor(el, point, options = {}) {
907
1389
  if (componentName) anchor.componentName = componentName;
908
1390
  return anchor;
909
1391
  }
1392
+ /**
1393
+ * A text comment: the anchor of the paragraph (or heading, cell...) holding
1394
+ * the selected text, plus the words themselves and the text around them.
1395
+ * Null when the selection holds no text.
1396
+ */
1397
+ function captureSelection(range, options = {}) {
1398
+ const element = quoteContainer(range);
1399
+ if (!element) return null;
1400
+ const quote = captureQuote(range, element);
1401
+ if (!quote) return null;
1402
+ const first = range.getClientRects()[0] ?? range.getBoundingClientRect();
1403
+ const anchor = captureAnchor(element, {
1404
+ x: first.left,
1405
+ y: first.top + first.height / 2
1406
+ }, options);
1407
+ anchor.quote = quote;
1408
+ return {
1409
+ element,
1410
+ anchor
1411
+ };
1412
+ }
1413
+ /**
1414
+ * The element an area comment belongs to: the smallest one that covers the
1415
+ * whole box (in viewport coordinates).
1416
+ */
1417
+ function areaContainer(doc, box, isIgnored = () => false) {
1418
+ const right = box.left + box.width;
1419
+ const bottom = box.top + box.height;
1420
+ const corners = [
1421
+ [box.left + 1, box.top + 1],
1422
+ [right - 1, box.top + 1],
1423
+ [box.left + 1, bottom - 1],
1424
+ [right - 1, bottom - 1]
1425
+ ];
1426
+ const ignored = (el) => isIgnored(el) || el.closest("[data-nuni]") !== null;
1427
+ let common = null;
1428
+ for (const [x, y] of corners) {
1429
+ const hit = doc.elementsFromPoint(x, y).find((el) => !ignored(el));
1430
+ if (hit) common = common ? commonAncestor(common, hit) : hit;
1431
+ }
1432
+ let el = common ?? doc.body;
1433
+ while (el.parentElement && el !== doc.body) {
1434
+ const r = el.getBoundingClientRect();
1435
+ if (r.left <= box.left + 1 && r.top <= box.top + 1 && r.right >= right - 1 && r.bottom >= bottom - 1) break;
1436
+ el = el.parentElement;
1437
+ }
1438
+ return el;
1439
+ }
1440
+ function commonAncestor(a, b) {
1441
+ const seen = /* @__PURE__ */ new Set();
1442
+ for (let n = a; n; n = n.parentElement) seen.add(n);
1443
+ for (let n = b; n; n = n.parentElement) if (seen.has(n)) return n;
1444
+ return a.ownerDocument.body;
1445
+ }
1446
+ /**
1447
+ * An area comment: the covering element's anchor plus the box as fractions
1448
+ * of that element's size, so it scales with the layout.
1449
+ */
1450
+ function captureArea(el, box, options = {}) {
1451
+ const anchor = captureAnchor(el, {
1452
+ x: box.left + box.width,
1453
+ y: box.top
1454
+ }, options);
1455
+ const r = el.getBoundingClientRect();
1456
+ const x = r.width ? clamp01((box.left - r.left) / r.width) : 0;
1457
+ const y = r.height ? clamp01((box.top - r.top) / r.height) : 0;
1458
+ anchor.region = {
1459
+ x,
1460
+ y,
1461
+ w: r.width ? Math.min(1 - x, box.width / r.width) : 1,
1462
+ h: r.height ? Math.min(1 - y, box.height / r.height) : 1
1463
+ };
1464
+ return anchor;
1465
+ }
910
1466
  //#endregion
911
1467
  //#region ../anchor/src/resolve.ts
1468
+ function createResolveCache() {
1469
+ return {
1470
+ byTag: /* @__PURE__ */ new Map(),
1471
+ elements: /* @__PURE__ */ new Map(),
1472
+ text: /* @__PURE__ */ new WeakMap(),
1473
+ content: /* @__PURE__ */ new WeakMap(),
1474
+ words: /* @__PURE__ */ new WeakMap()
1475
+ };
1476
+ }
1477
+ function textOf(el, max, cache) {
1478
+ if (!cache) return elementText(el, max);
1479
+ let byMax = cache.text.get(el);
1480
+ if (!byMax) cache.text.set(el, byMax = /* @__PURE__ */ new Map());
1481
+ let text = byMax.get(max);
1482
+ if (text === void 0) byMax.set(max, text = elementText(el, max));
1483
+ return text;
1484
+ }
1485
+ function contentOf(el, cache) {
1486
+ if (!cache) return el.textContent ?? "";
1487
+ let content = cache.content.get(el);
1488
+ if (content === void 0) cache.content.set(el, content = el.textContent ?? "");
1489
+ return content;
1490
+ }
1491
+ /** All of an element's text with whitespace collapsed, for quote checks. */
1492
+ function wordsOf(el, cache) {
1493
+ let words = cache?.words.get(el);
1494
+ if (words === void 0) {
1495
+ words = normalizeText(contentOf(el, cache));
1496
+ cache?.words.set(el, words);
1497
+ }
1498
+ return words;
1499
+ }
1500
+ function elementsByTag(root, tag, cache) {
1501
+ const find = () => isShadowRoot(root) ? root.querySelectorAll(tag) : root.getElementsByTagName(tag);
1502
+ if (!cache) return find();
1503
+ let tags = cache.byTag.get(root);
1504
+ if (!tags) cache.byTag.set(root, tags = /* @__PURE__ */ new Map());
1505
+ let list = tags.get(tag);
1506
+ if (!list) tags.set(tag, list = Array.from(find()));
1507
+ return list;
1508
+ }
1509
+ /** Every element in the scope, in document order, up to `limit`. */
1510
+ function allElements(root, limit, cache) {
1511
+ const cached = cache?.elements.get(root);
1512
+ if (cached) return cached;
1513
+ const doc = scopeDocument(root);
1514
+ const start = isShadowRoot(root) ? root : doc.body ?? doc.documentElement;
1515
+ const walker = doc.createTreeWalker(start, 1);
1516
+ const out = [];
1517
+ for (let n = walker.nextNode(); n && out.length < limit; n = walker.nextNode()) out.push(n);
1518
+ cache?.elements.set(root, out);
1519
+ return out;
1520
+ }
912
1521
  const THRESHOLD = {
913
1522
  exact: .9,
914
1523
  high: .72,
@@ -976,7 +1585,7 @@ const IDENTIFYING_ATTRS = /* @__PURE__ */ new Set([
976
1585
  "alt",
977
1586
  "title"
978
1587
  ]);
979
- function scoreCandidate(anchor, el, doc, ctx, options) {
1588
+ function scoreCandidate(anchor, el, doc, ctx, options, cache) {
980
1589
  let total = 0;
981
1590
  let weights = 0;
982
1591
  const add = (weight, value) => {
@@ -1023,9 +1632,11 @@ function scoreCandidate(anchor, el, doc, ctx, options) {
1023
1632
  }
1024
1633
  if (anchor.role) add(.5, el.getAttribute("role") === anchor.role ? 1 : 0);
1025
1634
  if (anchor.componentName) add(.5, reactComponentName(el) === anchor.componentName ? 1 : 0);
1026
- const text = elementText(el);
1635
+ const text = textOf(el, 120, cache);
1027
1636
  const textSim = anchor.text ? textSimilarity(text, anchor.text) : text ? .3 : 1;
1028
1637
  add(anchor.text ? 3 : 1, textSim);
1638
+ const quoteHit = anchor.quote ? wordsOf(el, cache).includes(anchor.quote.exact) : null;
1639
+ if (quoteHit !== null) add(3, quoteHit ? 1 : 0);
1029
1640
  let ancTotal = 0;
1030
1641
  let ancWeights = 0;
1031
1642
  let context = null;
@@ -1040,18 +1651,19 @@ function scoreCandidate(anchor, el, doc, ctx, options) {
1040
1651
  else s += .35 * (nodeId ? .5 : 1);
1041
1652
  s += .25 * jaccard(stableClasses(node), expected.classes);
1042
1653
  ancTotal += weight * s;
1043
- if (context === null && expected.text && expected.text !== anchor.text) context = textSimilarity(elementText(node, 80), expected.text);
1654
+ if (context === null && expected.text && expected.text !== anchor.text) context = textSimilarity(textOf(node, 80, cache), expected.text);
1044
1655
  node = node.parentElement;
1045
1656
  });
1046
1657
  if (ancWeights) add(1.5, ancTotal / ancWeights);
1047
1658
  if (context !== null) add(2, context);
1048
- const parent = el.parentElement;
1659
+ const parent = el.parentNode;
1049
1660
  const index = parent ? Array.prototype.indexOf.call(parent.children, el) : 0;
1050
1661
  add(.5, Math.max(0, 1 - Math.abs(index - anchor.siblingIndex) / 3));
1051
1662
  const geoWeight = Math.max(0, 1 - Math.abs(ctx.viewportRatio - 1) * 2);
1663
+ const r = el.getBoundingClientRect();
1664
+ const rendered = r.width + r.height > 0;
1052
1665
  if (geoWeight > 0 && anchor.rect.w + anchor.rect.h > 0) {
1053
- const r = el.getBoundingClientRect();
1054
- if (r.width + r.height > 0) {
1666
+ if (rendered) {
1055
1667
  const view = doc.defaultView;
1056
1668
  const cx = r.left + (view?.scrollX ?? 0) + r.width / 2;
1057
1669
  const cy = r.top + (view?.scrollY ?? 0) + r.height / 2;
@@ -1066,34 +1678,38 @@ function scoreCandidate(anchor, el, doc, ctx, options) {
1066
1678
  }
1067
1679
  let score = weights ? total / weights : 0;
1068
1680
  const structural = (cssHit || !anchor.selectors.css) && pathHit && identifyingAttrsMatch;
1069
- if (anchor.text.length >= 2 && textSim < .999) {
1070
- const factor = peerCount(el) > 0 ? .2 + .8 * textSim * textSim : .7 + .3 * textSim;
1681
+ const identity = ctx.quoteOnly === el ? Math.max(textSim, .9) : textSim;
1682
+ if (anchor.text.length >= 2 && identity < .999) {
1683
+ const factor = peerCount(el) > 0 ? .2 + .8 * identity * identity : .7 + .3 * identity;
1071
1684
  score *= strong || structural ? Math.max(factor, .92) : factor;
1072
1685
  }
1686
+ if (quoteHit === false) score *= .85;
1073
1687
  return {
1074
1688
  el,
1075
1689
  score,
1076
1690
  text: textSim,
1077
1691
  context,
1078
- strong: strong || structural
1692
+ strong: strong || structural,
1693
+ rendered
1079
1694
  };
1080
1695
  }
1081
- function collectCandidates(anchor, doc, options) {
1696
+ function collectCandidates(anchor, root, options, cache) {
1697
+ const doc = scopeDocument(root);
1082
1698
  const candidates = /* @__PURE__ */ new Set();
1083
1699
  const ignored = (el) => el === doc.documentElement || (options.isIgnored?.(el) ?? false);
1084
1700
  const add = (el) => {
1085
1701
  if (el && !ignored(el)) candidates.add(el);
1086
1702
  };
1087
- if (anchor.selectors.id) add(doc.getElementById(anchor.selectors.id));
1703
+ if (anchor.selectors.id) add(isShadowRoot(root) ? safeQueryAll(root, `#${cssEscape(anchor.selectors.id)}`, 1)[0] : root.getElementById(anchor.selectors.id));
1088
1704
  if (anchor.selectors.testId) {
1089
1705
  const parts = splitTestId(anchor.selectors.testId);
1090
- if (parts) safeQueryAll(doc, attrQuery(parts[0], parts[1])).forEach(add);
1706
+ if (parts) safeQueryAll(root, attrQuery(parts[0], parts[1])).forEach(add);
1091
1707
  }
1092
- safeQueryAll(doc, anchor.selectors.css).forEach(add);
1093
- const pathMatch = safeQueryAll(doc, anchor.selectors.path, 1)[0] ?? null;
1708
+ safeQueryAll(root, anchor.selectors.css).forEach(add);
1709
+ const pathMatch = queryPath(root, anchor.selectors.path);
1094
1710
  add(pathMatch);
1095
1711
  const needle = anchor.text.slice(0, 24).toLowerCase();
1096
- const sameTag = doc.getElementsByTagName(anchor.tag);
1712
+ const sameTag = elementsByTag(root, anchor.tag, cache);
1097
1713
  const scanAll = sameTag.length <= MAX_SCAN;
1098
1714
  for (let i = 0; i < sameTag.length && i < MAX_SCAN * 2; i++) {
1099
1715
  const el = sameTag[i];
@@ -1101,20 +1717,16 @@ function collectCandidates(anchor, doc, options) {
1101
1717
  add(el);
1102
1718
  continue;
1103
1719
  }
1104
- const content = el.textContent?.toLowerCase() ?? "";
1720
+ const content = contentOf(el, cache).toLowerCase();
1105
1721
  if (needle && content.includes(needle)) add(el);
1106
1722
  }
1107
1723
  if (anchor.text.length >= 3) {
1108
1724
  const exact = anchor.text.toLowerCase();
1109
- const walker = doc.createTreeWalker(doc.body ?? doc.documentElement, 1);
1110
- let count = 0;
1111
- for (let n = walker.nextNode(); n && count < MAX_SCAN * 3; n = walker.nextNode()) {
1112
- count++;
1113
- const el = n;
1725
+ for (const el of allElements(root, MAX_SCAN * 3, cache)) {
1114
1726
  if (el.tagName.toLowerCase() === anchor.tag) continue;
1115
- const content = el.textContent;
1727
+ const content = contentOf(el, cache);
1116
1728
  if (!content || content.length > exact.length * 3 + 20) continue;
1117
- if (elementText(el).toLowerCase() === exact) add(el);
1729
+ if (textOf(el, 120, cache).toLowerCase() === exact) add(el);
1118
1730
  }
1119
1731
  }
1120
1732
  return {
@@ -1122,44 +1734,104 @@ function collectCandidates(anchor, doc, options) {
1122
1734
  pathMatch
1123
1735
  };
1124
1736
  }
1125
- /** Find the element an anchor was captured on, in the current document. */
1126
- function resolveAnchor(anchor, doc = document, options = {}) {
1127
- if (anchor.tag === "body" || anchor.tag === "html") return {
1737
+ const RANK = {
1738
+ lost: 0,
1739
+ low: 1,
1740
+ high: 2,
1741
+ exact: 3
1742
+ };
1743
+ /**
1744
+ * Find the element an anchor was captured on, in the current document.
1745
+ * Elements in shadow roots and iframes are found host by host first.
1746
+ * Pass one `cache` when resolving many anchors over the same DOM.
1747
+ */
1748
+ function resolveAnchor(anchor, doc = document, options = {}, cache) {
1749
+ let root = doc;
1750
+ let floor = "exact";
1751
+ const lost = {
1752
+ element: null,
1753
+ confidence: "lost",
1754
+ score: 0
1755
+ };
1756
+ for (const step of anchor.scope ?? []) {
1757
+ const host = resolveIn(step.host, root, options, cache);
1758
+ if (!host.element) return lost;
1759
+ let confidence = host.confidence;
1760
+ if (step.host.text) {
1761
+ if (host.text < .9 && RANK[confidence] < RANK.high) return lost;
1762
+ if (host.text >= .999 && host.margin >= .1 && confidence === "low") confidence = "high";
1763
+ }
1764
+ const next = step.kind === "shadow" ? host.element.shadowRoot : frameDocument(host.element);
1765
+ if (!next) return lost;
1766
+ if (RANK[confidence] < RANK[floor]) floor = confidence;
1767
+ root = next;
1768
+ }
1769
+ const { text: _text, margin: _margin, ...result } = resolveIn(anchor, root, options, cache);
1770
+ if (result.element && RANK[floor] < RANK[result.confidence]) result.confidence = floor;
1771
+ if (result.element && anchor.quote) {
1772
+ const quote = findQuote(result.element, anchor.quote);
1773
+ if (quote) result.range = quote.range;
1774
+ if ((!quote || !quote.exact) && RANK[result.confidence] > RANK.low) result.confidence = "low";
1775
+ }
1776
+ return result;
1777
+ }
1778
+ function resolveIn(anchor, root, options, cache) {
1779
+ const doc = scopeDocument(root);
1780
+ if (!isShadowRoot(root) && (anchor.tag === "body" || anchor.tag === "html")) return {
1128
1781
  element: doc.body,
1129
1782
  confidence: "exact",
1130
- score: 1
1783
+ score: 1,
1784
+ text: 1,
1785
+ margin: 1
1131
1786
  };
1132
- const { candidates, pathMatch } = collectCandidates(anchor, doc, options);
1787
+ const { candidates, pathMatch } = collectCandidates(anchor, root, options, cache);
1133
1788
  if (!candidates.size) return {
1134
1789
  element: null,
1135
1790
  confidence: "lost",
1136
- score: 0
1791
+ score: 0,
1792
+ text: 0,
1793
+ margin: 0
1137
1794
  };
1138
1795
  const view = doc.defaultView;
1139
1796
  const viewportRatio = anchor.viewport.w && view?.innerWidth ? view.innerWidth / anchor.viewport.w : 1;
1797
+ let quoteOnly = null;
1798
+ if (anchor.quote) {
1799
+ const exact = anchor.quote.exact;
1800
+ const hits = [...candidates].filter((el) => wordsOf(el, cache).includes(exact));
1801
+ const innermost = hits.filter((el) => !hits.some((other) => other !== el && el.contains(other)));
1802
+ if (innermost.length === 1) quoteOnly = innermost[0];
1803
+ }
1140
1804
  const scored = [];
1141
1805
  for (const el of candidates) scored.push(scoreCandidate(anchor, el, doc, {
1142
1806
  pathMatch,
1143
- viewportRatio
1144
- }, options));
1145
- if (scored.filter((s) => s.text >= .999 && s.el.tagName.toLowerCase() === anchor.tag).length > 1) for (const s of scored) {
1807
+ viewportRatio,
1808
+ quoteOnly
1809
+ }, options, cache));
1810
+ const visibleOnly = anchor.rect.w + anchor.rect.h > 0 && scored.some((s) => s.rendered);
1811
+ const rival = (s) => !visibleOnly || s.rendered;
1812
+ if (scored.filter((s) => s.text >= .999 && s.el.tagName.toLowerCase() === anchor.tag && rival(s)).length > 1) for (const s of scored) {
1146
1813
  if (s.context === null || s.strong) continue;
1147
1814
  s.score *= .2 + .8 * s.context * s.context;
1148
1815
  }
1149
1816
  scored.sort((a, b) => b.score - a.score);
1150
1817
  const best = scored[0];
1151
- const second = scored.find((s) => s.el !== best.el && !best.el.contains(s.el) && !s.el.contains(best.el));
1818
+ const second = scored.find((s) => s.el !== best.el && !best.el.contains(s.el) && !s.el.contains(best.el) && (!best.rendered || rival(s)));
1152
1819
  if (best.score < THRESHOLD.low) return {
1153
1820
  element: null,
1154
1821
  confidence: "lost",
1155
- score: best.score
1822
+ score: best.score,
1823
+ text: best.text,
1824
+ margin: 0
1156
1825
  };
1157
1826
  let confidence = best.score >= THRESHOLD.exact && best.text >= .999 ? "exact" : best.score >= THRESHOLD.high ? "high" : "low";
1158
- if (second && best.score - second.score < AMBIGUITY_MARGIN) confidence = "low";
1827
+ const margin = second ? best.score - second.score : 1;
1828
+ if (margin < AMBIGUITY_MARGIN) confidence = "low";
1159
1829
  return {
1160
1830
  element: best.el,
1161
1831
  confidence,
1162
- score: best.score
1832
+ score: best.score,
1833
+ text: best.text,
1834
+ margin
1163
1835
  };
1164
1836
  }
1165
1837
  //#endregion
@@ -4615,6 +5287,13 @@ var NuniApi = class {
4615
5287
  sessionToken
4616
5288
  }, (comment) => cb(comment), () => {});
4617
5289
  }
5290
+ /** Replies and reactions of one thread, live while its card is open. */
5291
+ onThread(commentId, cb) {
5292
+ return this.client.onUpdate(api.replies.listForComment, {
5293
+ publicId: this.config.project,
5294
+ commentId
5295
+ }, (thread) => cb(thread), () => {});
5296
+ }
4618
5297
  /** One comment by id, for deep links outside the page listing window. */
4619
5298
  getComment(id) {
4620
5299
  return this.client.query(api.comments.getById, {
@@ -4640,6 +5319,25 @@ var NuniApi = class {
4640
5319
  if (!response.ok || !data.id) throw new NuniApiError(data.message ?? "Couldn't post the comment", data.code ?? "error");
4641
5320
  return data.id;
4642
5321
  }
5322
+ /** Through the HTTP action, so replies are rate limited per IP. */
5323
+ async createReply(reply) {
5324
+ let response;
5325
+ try {
5326
+ response = await fetch(`${this.config.convexSiteUrl}/widget/replies`, {
5327
+ method: "POST",
5328
+ headers: { "Content-Type": "application/json" },
5329
+ body: JSON.stringify({
5330
+ publicId: this.config.project,
5331
+ ...reply
5332
+ })
5333
+ });
5334
+ } catch {
5335
+ throw new NuniApiError("Couldn't reach Nuni. Check your connection.", "network");
5336
+ }
5337
+ const data = await response.json().catch(() => ({}));
5338
+ if (!response.ok || !data.id) throw new NuniApiError(data.message ?? "Couldn't post the reply", data.code ?? "error");
5339
+ return data.id;
5340
+ }
4643
5341
  /** Attach a screenshot to the author's own, just-posted comment. */
4644
5342
  async uploadScreenshot(id, authorSecret, image) {
4645
5343
  const response = await fetch(`${this.config.convexSiteUrl}/widget/screenshot`, {
@@ -4689,6 +5387,34 @@ var NuniApi = class {
4689
5387
  sessionToken
4690
5388
  }));
4691
5389
  }
5390
+ editReply(id, authorSecret, body) {
5391
+ return this.run(this.client.mutation(api.replies.editOwn, {
5392
+ id,
5393
+ authorSecret,
5394
+ body
5395
+ }));
5396
+ }
5397
+ deleteReply(id, authorSecret) {
5398
+ return this.run(this.client.mutation(api.replies.deleteOwn, {
5399
+ id,
5400
+ authorSecret
5401
+ }));
5402
+ }
5403
+ removeReply(id, sessionToken) {
5404
+ return this.run(this.client.mutation(api.replies.remove, {
5405
+ id,
5406
+ sessionToken
5407
+ }));
5408
+ }
5409
+ toggleReaction(commentId, targetId, emoji, authorSecret) {
5410
+ return this.run(this.client.mutation(api.reactions.toggle, {
5411
+ publicId: this.config.project,
5412
+ commentId,
5413
+ targetId,
5414
+ emoji,
5415
+ authorSecret
5416
+ }));
5417
+ }
4692
5418
  remove(id, sessionToken) {
4693
5419
  return this.run(this.client.mutation(api.comments.remove, {
4694
5420
  id,
@@ -4723,17 +5449,6 @@ function icon(svg) {
4723
5449
  span.append(document.importNode(parsed.documentElement, true));
4724
5450
  return span;
4725
5451
  }
4726
- function timeAgo(ts, now = Date.now()) {
4727
- const s = Math.max(0, Math.round((now - ts) / 1e3));
4728
- if (s < 45) return "just now";
4729
- const m = Math.round(s / 60);
4730
- if (m < 60) return `${m}m ago`;
4731
- const hr = Math.round(m / 60);
4732
- if (hr < 24) return `${hr}h ago`;
4733
- const d = Math.round(hr / 24);
4734
- if (d < 30) return `${d}d ago`;
4735
- return new Date(ts).toLocaleDateString();
4736
- }
4737
5452
  function initials(name) {
4738
5453
  const parts = name.trim().split(/\s+/).filter(Boolean);
4739
5454
  return (parts.length > 1 ? parts[0][0] + parts[parts.length - 1][0] : (parts[0] ?? "?").slice(0, 2)).toUpperCase();
@@ -4742,7 +5457,7 @@ function initials(name) {
4742
5457
  function colorFor(name) {
4743
5458
  let hash = 0;
4744
5459
  for (const ch of name) hash = hash * 31 + ch.charCodeAt(0) >>> 0;
4745
- return `oklch(0.62 0.17 ${hash % 360})`;
5460
+ return `oklch(0.5 0.17 ${hash % 360})`;
4746
5461
  }
4747
5462
  /** Copy text, falling back to execCommand where the async clipboard is off (plain http previews). */
4748
5463
  async function copyText(text) {
@@ -4764,6 +5479,200 @@ async function copyText(text) {
4764
5479
  area.remove();
4765
5480
  }
4766
5481
  }
5482
+ /** Any CSS color as RGB, using the browser's own parser. */
5483
+ function parseColor(color) {
5484
+ const probe = document.createElement("span");
5485
+ probe.style.color = color;
5486
+ if (!probe.style.color) return null;
5487
+ probe.style.display = "none";
5488
+ document.documentElement.appendChild(probe);
5489
+ const computed = getComputedStyle(probe).color;
5490
+ probe.remove();
5491
+ if (/^rgba?\(/.test(computed)) {
5492
+ const parts = computed.match(/[\d.]+/g)?.map(Number);
5493
+ if (parts && parts.length >= 3) return [
5494
+ parts[0],
5495
+ parts[1],
5496
+ parts[2]
5497
+ ];
5498
+ }
5499
+ try {
5500
+ const canvas = document.createElement("canvas");
5501
+ canvas.width = canvas.height = 1;
5502
+ const ctx = canvas.getContext("2d");
5503
+ if (!ctx) return null;
5504
+ ctx.fillStyle = color;
5505
+ ctx.fillRect(0, 0, 1, 1);
5506
+ const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
5507
+ return [
5508
+ r,
5509
+ g,
5510
+ b
5511
+ ];
5512
+ } catch {
5513
+ return null;
5514
+ }
5515
+ }
5516
+ /** Black or white text, whichever reads better on the color (WCAG). */
5517
+ function readableOn([r, g, b]) {
5518
+ const channel = (c) => {
5519
+ const v = c / 255;
5520
+ return v <= .03928 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
5521
+ };
5522
+ const l = .2126 * channel(r) + .7152 * channel(g) + .0722 * channel(b);
5523
+ return 1.05 / (l + .05) >= (l + .05) / .05 ? "#ffffff" : "#111111";
5524
+ }
5525
+ //#endregion
5526
+ //#region src/i18n.ts
5527
+ /**
5528
+ * Every word the widget shows. Override any of them with the `messages`
5529
+ * option; `locale` sets how times and counts are written.
5530
+ *
5531
+ * `{name}` is filled in. A message with forms (`one`, `other`, and `zero`,
5532
+ * `two`, `few`, `many` where a language has them) is picked by `{count}`.
5533
+ */
5534
+ const EN = {
5535
+ toolbar: "Nuni comments",
5536
+ comment: "Comment",
5537
+ addComment: "Add a comment",
5538
+ addCommentKey: "Add a comment ({key})",
5539
+ cancelAdding: "Cancel adding a comment",
5540
+ pickElement: "Pick an element",
5541
+ allComments: "All comments",
5542
+ openCount: "Comments on this page: {count} open",
5543
+ commentSelection: "Comment on the selected text",
5544
+ commentSelectionKey: "Comment on the selected text ({key})",
5545
+ pickHint: "Click anything to comment, or drag to mark an area",
5546
+ pickHintMobile: "Tap anything to comment",
5547
+ pickHintKeys: "Arrow keys move, Enter comments, Esc cancels",
5548
+ picked: "Selected {element}",
5549
+ comments: "Comments",
5550
+ close: "Close",
5551
+ tabOpen: "Open ({count})",
5552
+ tabResolved: "Resolved ({count})",
5553
+ loading: "Loading…",
5554
+ noOpen: "No open comments on this page.",
5555
+ noOpenKey: "No open comments on this page. Press {key} to add one.",
5556
+ noResolved: "Nothing resolved yet.",
5557
+ notFoundHere: "Couldn't find on this page",
5558
+ otherPages: "Other pages",
5559
+ openOnPage: "{count} open",
5560
+ replyCount: {
5561
+ one: "{count} reply",
5562
+ other: "{count} replies"
5563
+ },
5564
+ edited: "edited",
5565
+ ownSite: "Own this website?",
5566
+ claim: "Claim Nuni",
5567
+ yourName: "Your name",
5568
+ leaveComment: "Leave a comment",
5569
+ notYou: "Not you?",
5570
+ screenshotAlt: "Screenshot that will be attached",
5571
+ screenshotNote: "Screenshot attached. Only the site owner sees it.",
5572
+ remove: "Remove",
5573
+ cancel: "Cancel",
5574
+ post: "Post",
5575
+ posting: "Posting…",
5576
+ save: "Save",
5577
+ send: "{key} + Enter",
5578
+ pinLabel: "Comment by {name}: {body}",
5579
+ commentBy: "Comment by {name}",
5580
+ openScreenshot: "Open the screenshot",
5581
+ screenshotTaken: "Screenshot taken when the comment was left",
5582
+ resolved: "Resolved",
5583
+ leftOn: "Left on {origin}",
5584
+ approximate: "Approximate",
5585
+ approximateHint: "The page changed; this pin is a best guess",
5586
+ notFound: "Element not found",
5587
+ originallyOn: "Originally on: {text}",
5588
+ resolve: "Resolve",
5589
+ reopen: "Reopen",
5590
+ copyForAgent: "Copy for agent",
5591
+ copyForAgentHint: "Copy for your coding agent",
5592
+ loadingContext: "Loading page context…",
5593
+ edit: "Edit",
5594
+ delete: "Delete",
5595
+ owner: "Owner",
5596
+ addReaction: "Add a reaction",
5597
+ reactions: "Reactions",
5598
+ reactWith: "React with {emoji}",
5599
+ reply: "Reply",
5600
+ replyFirst: "Reply to start a thread",
5601
+ sendReply: "Send reply",
5602
+ editReply: "Edit reply",
5603
+ deleteReply: "Delete reply",
5604
+ confirmDeleteComment: "Delete this comment? This can't be undone.",
5605
+ confirmDeleteReply: "Delete this reply?",
5606
+ added: "Comment added",
5607
+ saved: "Saved",
5608
+ deleted: "Deleted",
5609
+ replyDeleted: "Reply deleted",
5610
+ reopened: "Reopened",
5611
+ copied: "Copied for your coding agent",
5612
+ copyFailed: "Couldn't copy",
5613
+ signedIn: "You're signed in as the owner",
5614
+ linkGone: "That comment was deleted or is on another page",
5615
+ enterName: "Enter your name",
5616
+ writeFirst: "Write a comment first",
5617
+ commentTooLong: "Comments are limited to {count} characters",
5618
+ replyTooLong: "Replies are limited to {count} characters",
5619
+ postFailed: "Couldn't post the comment",
5620
+ replyFailed: "Couldn't post the reply",
5621
+ reactFailed: "Couldn't react",
5622
+ failed: "Something went wrong",
5623
+ justNow: "just now"
5624
+ };
5625
+ function isApple() {
5626
+ if (typeof navigator === "undefined") return false;
5627
+ const platform = navigator.userAgentData?.platform ?? navigator.platform ?? "";
5628
+ return /mac|iphone|ipad|ipod/i.test(platform || navigator.userAgent);
5629
+ }
5630
+ function createI18n(locale, overrides = {}) {
5631
+ const messages = {
5632
+ ...EN,
5633
+ ...overrides
5634
+ };
5635
+ let plural = null;
5636
+ let relative = null;
5637
+ try {
5638
+ plural = new Intl.PluralRules(locale);
5639
+ relative = new Intl.RelativeTimeFormat(locale, {
5640
+ numeric: "auto",
5641
+ style: "narrow"
5642
+ });
5643
+ } catch {
5644
+ plural = new Intl.PluralRules();
5645
+ relative = new Intl.RelativeTimeFormat(void 0, {
5646
+ numeric: "auto",
5647
+ style: "narrow"
5648
+ });
5649
+ }
5650
+ const t = (key, vars = {}) => {
5651
+ const message = messages[key] ?? EN[key];
5652
+ return (typeof message === "string" ? message : message[plural.select(Number(vars.count ?? 0))] ?? message.other).replace(/\{(\w+)\}/g, (match, name) => name in vars ? String(vars[name]) : match);
5653
+ };
5654
+ const timeAgo = (ts, now = Date.now()) => {
5655
+ const s = Math.max(0, Math.round((now - ts) / 1e3));
5656
+ if (s < 45) return t("justNow");
5657
+ const m = Math.round(s / 60);
5658
+ if (m < 60) return relative.format(-m, "minute");
5659
+ const hr = Math.round(m / 60);
5660
+ if (hr < 24) return relative.format(-hr, "hour");
5661
+ const d = Math.round(hr / 24);
5662
+ if (d < 30) return relative.format(-d, "day");
5663
+ try {
5664
+ return new Date(ts).toLocaleDateString(locale);
5665
+ } catch {
5666
+ return new Date(ts).toLocaleDateString();
5667
+ }
5668
+ };
5669
+ return {
5670
+ locale,
5671
+ t,
5672
+ timeAgo,
5673
+ mod: isApple() ? "⌘" : "Ctrl"
5674
+ };
5675
+ }
4767
5676
  //#endregion
4768
5677
  //#region src/icons.ts
4769
5678
  const s = (d) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`;
@@ -4777,6 +5686,7 @@ const ICONS = {
4777
5686
  edit: s("<path d=\"M12 20h9\"/><path d=\"M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z\"/>"),
4778
5687
  send: s("<path d=\"m22 2-7 20-4-9-9-4z\"/><path d=\"M22 2 11 13\"/>"),
4779
5688
  bot: s("<path d=\"M12 8V4H8\"/><rect width=\"16\" height=\"12\" x=\"4\" y=\"8\" rx=\"2\"/><path d=\"M2 14h2M20 14h2M15 13v2M9 13v2\"/>"),
5689
+ smile: s("<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M8 14s1.5 2 4 2 4-2 4-2M9 9h.01M15 9h.01\"/>"),
4780
5690
  target: s("<circle cx=\"12\" cy=\"12\" r=\"9\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>"),
4781
5691
  github: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M12 .5a11.5 11.5 0 0 0-3.6 22.4c.6.1.8-.3.8-.6v-2c-3.2.7-3.9-1.5-3.9-1.5-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2a11 11 0 0 1 5.8 0c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.9 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A11.5 11.5 0 0 0 12 .5z"/></svg>`,
4782
5692
  link: s("<path d=\"M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\"/>")
@@ -4959,6 +5869,57 @@ function sha256(data) {
4959
5869
  return out;
4960
5870
  }
4961
5871
  //#endregion
5872
+ //#region src/scroll-to-pin.ts
5873
+ /** Reveal the pin itself, including scroll containers and same-origin frames. */
5874
+ function scrollToPin(element, point) {
5875
+ element.scrollIntoView({
5876
+ block: "center",
5877
+ inline: "center",
5878
+ behavior: "instant"
5879
+ });
5880
+ let node = element;
5881
+ while (node) {
5882
+ const parent = node.parentElement;
5883
+ if (parent) {
5884
+ const style = parent.ownerDocument.defaultView?.getComputedStyle(parent);
5885
+ const scrollX = style && /auto|scroll|hidden/.test(style.overflowX);
5886
+ const scrollY = style && /auto|scroll|hidden/.test(style.overflowY);
5887
+ if (parent !== parent.ownerDocument.scrollingElement && (scrollX || scrollY)) {
5888
+ const rect = viewportRect(parent, document);
5889
+ const pin = point();
5890
+ parent.scrollBy({
5891
+ left: scrollX ? pin.x - rect.left - parent.clientLeft - parent.clientWidth / 2 : 0,
5892
+ top: scrollY ? pin.y - rect.top - parent.clientTop - parent.clientHeight / 2 : 0,
5893
+ behavior: "instant"
5894
+ });
5895
+ }
5896
+ node = parent;
5897
+ continue;
5898
+ }
5899
+ const root = node.getRootNode();
5900
+ if (isShadowRoot(root)) {
5901
+ node = root.host;
5902
+ continue;
5903
+ }
5904
+ const doc = node.ownerDocument;
5905
+ const view = doc.defaultView;
5906
+ const frame = frameElementOf(doc);
5907
+ if (view) {
5908
+ const pin = point();
5909
+ const rect = frame ? viewportRect(frame, document) : {
5910
+ left: 0,
5911
+ top: 0
5912
+ };
5913
+ view.scrollBy({
5914
+ left: pin.x - rect.left - (frame?.clientLeft ?? 0) - view.innerWidth / 2,
5915
+ top: pin.y - rect.top - (frame?.clientTop ?? 0) - view.innerHeight / 2,
5916
+ behavior: "instant"
5917
+ });
5918
+ }
5919
+ node = frame;
5920
+ }
5921
+ }
5922
+ //#endregion
4962
5923
  //#region src/storage.ts
4963
5924
  /** localStorage that never throws (private mode, blocked storage, sandboxed iframes). */
4964
5925
  const memory = /* @__PURE__ */ new Map();
@@ -5013,7 +5974,7 @@ const STYLES = `
5013
5974
  -moz-osx-font-smoothing: grayscale;
5014
5975
  }
5015
5976
  @media (prefers-color-scheme: dark) {
5016
- :host {
5977
+ :host(:not([data-theme="light"])) {
5017
5978
  --n-bg: #1d1b20;
5018
5979
  --n-bg-subtle: #2a272e;
5019
5980
  --n-fg: #f4f2f6;
@@ -5024,6 +5985,18 @@ const STYLES = `
5024
5985
  --n-shadow: 0 1px 2px rgba(0,0,0,0.4), 0 12px 32px rgba(0,0,0,0.5);
5025
5986
  }
5026
5987
  }
5988
+ :host([data-theme="dark"]) {
5989
+ --n-bg: #1d1b20;
5990
+ --n-bg-subtle: #2a272e;
5991
+ --n-fg: #f4f2f6;
5992
+ --n-fg-muted: #a8a2ae;
5993
+ --n-border: rgba(255, 255, 255, 0.12);
5994
+ --n-accent: #ff4d94;
5995
+ --n-accent-soft: rgba(255, 77, 148, 0.18);
5996
+ --n-shadow: 0 1px 2px rgba(0,0,0,0.4), 0 12px 32px rgba(0,0,0,0.5);
5997
+ }
5998
+ :host([data-theme="light"]) { color-scheme: light; }
5999
+ :host([data-theme="dark"]) { color-scheme: dark; }
5027
6000
  *, *::before, *::after { box-sizing: border-box; }
5028
6001
  button, input, textarea { font: inherit; color: inherit; }
5029
6002
  button { cursor: pointer; }
@@ -5077,6 +6050,10 @@ button { cursor: pointer; }
5077
6050
 
5078
6051
  /* Picking */
5079
6052
  .pick-hint {
6053
+ display: flex;
6054
+ gap: 8px;
6055
+ align-items: center;
6056
+ max-width: calc(100vw - 24px);
5080
6057
  position: fixed;
5081
6058
  top: 12px;
5082
6059
  left: 50%;
@@ -5113,6 +6090,94 @@ button { cursor: pointer; }
5113
6090
  text-overflow: ellipsis;
5114
6091
  }
5115
6092
 
6093
+ .drag-box { transition: none; border-style: dashed; }
6094
+
6095
+ /* Text and area comments */
6096
+ .marks { position: fixed; inset: 0; pointer-events: none; }
6097
+ .mark {
6098
+ position: fixed;
6099
+ border-bottom: 2px solid var(--n-accent);
6100
+ background: var(--n-accent-soft);
6101
+ opacity: 0.7;
6102
+ pointer-events: none;
6103
+ }
6104
+ .mark-active { opacity: 1; background: color-mix(in srgb, var(--n-accent) 26%, transparent); }
6105
+ .area {
6106
+ position: fixed;
6107
+ border: 2px dashed var(--n-accent);
6108
+ border-radius: 4px;
6109
+ background: var(--n-accent-soft);
6110
+ pointer-events: none;
6111
+ }
6112
+ .select-btn {
6113
+ position: fixed;
6114
+ left: 0;
6115
+ top: 0;
6116
+ display: inline-flex;
6117
+ align-items: center;
6118
+ gap: 6px;
6119
+ height: 32px;
6120
+ padding: 0 12px;
6121
+ border: 0;
6122
+ border-radius: 999px;
6123
+ background: var(--n-fg);
6124
+ color: var(--n-bg);
6125
+ font-weight: 550;
6126
+ font-size: 13px;
6127
+ box-shadow: var(--n-shadow);
6128
+ pointer-events: auto;
6129
+ }
6130
+ .select-btn[hidden] { display: none; }
6131
+ .quote {
6132
+ margin: 0;
6133
+ padding: 2px 0 2px 10px;
6134
+ border-left: 3px solid var(--n-accent);
6135
+ color: var(--n-fg-muted);
6136
+ font-style: italic;
6137
+ white-space: pre-wrap;
6138
+ overflow-wrap: anywhere;
6139
+ max-height: 4.5em;
6140
+ overflow: hidden;
6141
+ }
6142
+ .item-quote {
6143
+ margin-top: 4px;
6144
+ padding-left: 8px;
6145
+ border-left: 2px solid var(--n-accent);
6146
+ color: var(--n-fg-muted);
6147
+ font-size: 12px;
6148
+ font-style: italic;
6149
+ white-space: nowrap;
6150
+ overflow: hidden;
6151
+ text-overflow: ellipsis;
6152
+ }
6153
+
6154
+ .pick-keys { opacity: 0.7; }
6155
+ .pick-keys::before { content: "·"; margin-right: 8px; }
6156
+ @media (max-width: 900px) { .pick-keys { display: none; } }
6157
+
6158
+ /* Confirm dialog */
6159
+ .confirm-backdrop {
6160
+ position: fixed;
6161
+ inset: 0;
6162
+ display: grid;
6163
+ place-items: center;
6164
+ background: rgba(0, 0, 0, 0.32);
6165
+ pointer-events: auto;
6166
+ }
6167
+ .confirm {
6168
+ width: min(360px, calc(100vw - 32px));
6169
+ padding: 16px;
6170
+ border-radius: var(--n-radius);
6171
+ background: var(--n-bg);
6172
+ color: var(--n-fg);
6173
+ border: 1px solid var(--n-border);
6174
+ box-shadow: var(--n-shadow);
6175
+ display: grid;
6176
+ gap: 12px;
6177
+ }
6178
+ .confirm p { margin: 0; }
6179
+ .btn-danger-solid { background: var(--n-danger); color: #fff; border-color: transparent; }
6180
+
5116
6181
  /* Pins */
5117
6182
  .pin {
5118
6183
  position: fixed;
@@ -5138,6 +6203,22 @@ button { cursor: pointer; }
5138
6203
  .pin:hover, .pin[data-active="true"] { z-index: 2; }
5139
6204
  .pin[data-active="true"] { box-shadow: 0 0 0 3px var(--n-accent), 0 2px 8px rgba(0,0,0,0.25); }
5140
6205
  .pin[data-status="resolved"] { opacity: 0.55; filter: grayscale(0.6); }
6206
+ .pin[data-replies]::after {
6207
+ content: attr(data-replies);
6208
+ position: absolute;
6209
+ top: -6px;
6210
+ right: -8px;
6211
+ min-width: 16px;
6212
+ height: 16px;
6213
+ padding: 0 4px;
6214
+ border-radius: 999px;
6215
+ background: var(--n-bg);
6216
+ color: var(--n-fg);
6217
+ border: 1px solid var(--n-border);
6218
+ font-size: 10px;
6219
+ line-height: 14px;
6220
+ text-align: center;
6221
+ }
5141
6222
  .pin[data-confidence="low"] { border-style: dashed; }
5142
6223
  .pin[hidden] { display: none; }
5143
6224
  .pin-pending { opacity: 0.7; }
@@ -5156,6 +6237,43 @@ button { cursor: pointer; }
5156
6237
  overflow: hidden;
5157
6238
  }
5158
6239
  .card-body { padding: 12px; display: grid; gap: 10px; }
6240
+ .card[data-card="thread"] { display: flex; flex-direction: column; max-height: calc(100vh - 24px); }
6241
+ .thread { border-top: 1px solid var(--n-border); display: grid; min-height: 0; }
6242
+ .replies { display: grid; gap: 12px; padding: 12px; overflow-y: auto; max-height: 45vh; }
6243
+ .reply { display: grid; gap: 6px; }
6244
+ .avatar-sm { width: 20px; height: 20px; font-size: 9px; }
6245
+ .badge-owner { background: var(--n-accent-soft); color: var(--n-accent); }
6246
+ .reply-form { display: flex; align-items: flex-end; gap: 8px; padding: 8px 12px 12px; flex-wrap: wrap; }
6247
+ .reply-form .field { flex: 1 1 100%; }
6248
+ /* A fixed size: growing on focus would move the Send button mid-click. */
6249
+ .reply-form .field-reply { flex: 1 1 0; min-height: 56px; height: 56px; resize: none; }
6250
+ .reactions { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; position: relative; }
6251
+ .reaction {
6252
+ display: inline-flex;
6253
+ align-items: center;
6254
+ gap: 4px;
6255
+ height: 24px;
6256
+ padding: 0 8px;
6257
+ border: 1px solid var(--n-border);
6258
+ border-radius: 999px;
6259
+ background: var(--n-bg);
6260
+ color: var(--n-fg);
6261
+ font-size: 12px;
6262
+ }
6263
+ .reaction:hover { background: var(--n-bg-subtle); }
6264
+ .reaction[aria-pressed="true"] { border-color: var(--n-accent); background: var(--n-accent-soft); }
6265
+ .reaction-add { color: var(--n-fg-muted); padding: 0 6px; }
6266
+ .reaction-picker {
6267
+ display: flex;
6268
+ gap: 2px;
6269
+ padding: 4px;
6270
+ border: 1px solid var(--n-border);
6271
+ border-radius: 999px;
6272
+ background: var(--n-bg);
6273
+ box-shadow: var(--n-shadow);
6274
+ }
6275
+ .reaction-picker .reaction { border-color: transparent; font-size: 14px; }
6276
+ .btn-xs { width: 24px; height: 24px; }
5159
6277
  .card-head { display: flex; align-items: center; gap: 8px; }
5160
6278
  .card-head .spacer { flex: 1; }
5161
6279
  .avatar {
@@ -5330,14 +6448,66 @@ a.item { color: inherit; text-decoration: none; }
5330
6448
  @media (prefers-reduced-motion: reduce) {
5331
6449
  * { transition: none !important; animation: none !important; }
5332
6450
  }
6451
+
6452
+ /* The toolbar's corner (the position option) */
6453
+ :host([data-position$="left"]) .toolbar { right: auto; left: 16px; }
6454
+ :host([data-position^="top"]) .toolbar { bottom: auto; top: 16px; }
6455
+ :host([data-position$="left"]) .panel { right: auto; left: 12px; }
6456
+ :host([data-position^="top"]) .panel { top: 72px; bottom: 12px; }
6457
+ :host([data-position^="top"]) .toast { bottom: auto; top: 72px; }
6458
+ @media (max-width: 640px) {
6459
+ :host([data-position$="left"]) .toolbar { left: 12px; }
6460
+ :host([data-position^="top"]) .toolbar { top: 12px; }
6461
+ }
5333
6462
  `;
5334
6463
  //#endregion
5335
6464
  //#region src/widget.ts
5336
6465
  const PICKING_CLASS = "nuni-picking";
6466
+ const PICK_EVENTS = [
6467
+ "pointerdown",
6468
+ "mousedown",
6469
+ "pointerup",
6470
+ "mouseup"
6471
+ ];
6472
+ /** How far the pointer moves before a press becomes an area drag. */
6473
+ const DRAG_THRESHOLD = 6;
6474
+ /** The rectangles of a range that have a size (lines of text). */
6475
+ function lineRects(range) {
6476
+ return Array.from(range.getClientRects()).filter((r) => r.width > 0 && r.height > 0);
6477
+ }
6478
+ /** An area comment's box on screen, from its element's box. */
6479
+ function regionBox(anchor, el) {
6480
+ const region = anchor.region;
6481
+ if (!region) return null;
6482
+ const r = viewportRect(el, document);
6483
+ return {
6484
+ left: r.left + region.x * r.width,
6485
+ top: r.top + region.y * r.height,
6486
+ width: region.w * r.width,
6487
+ height: region.h * r.height
6488
+ };
6489
+ }
6490
+ const collapse = (text) => text.replace(/\s+/g, " ").trim();
6491
+ /** Run when the browser is idle, or soon. */
6492
+ function whenIdle(fn) {
6493
+ if (typeof requestIdleCallback === "function") requestIdleCallback(fn, { timeout: 100 });
6494
+ else setTimeout(fn, 0);
6495
+ }
6496
+ /** Same-origin iframe documents on the page, nested ones too. */
6497
+ function frameDocuments(doc, depth = 0) {
6498
+ if (depth > 3) return [];
6499
+ const out = [];
6500
+ for (const frame of Array.from(doc.querySelectorAll("iframe"))) {
6501
+ const inner = frameDocument(frame);
6502
+ if (inner?.documentElement) out.push(inner, ...frameDocuments(inner, depth + 1));
6503
+ }
6504
+ return out;
6505
+ }
5337
6506
  const MOBILE_QUERY = "(max-width: 640px)";
5338
6507
  function isTypingTarget(target) {
5339
- if (!(target instanceof HTMLElement)) return false;
5340
- return target.isContentEditable || target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT";
6508
+ if (!target || target.nodeType !== 1) return false;
6509
+ const el = target;
6510
+ return el.isContentEditable || el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT";
5341
6511
  }
5342
6512
  function dataUrl(blob) {
5343
6513
  return new Promise((resolve, reject) => {
@@ -5354,14 +6524,31 @@ function describeElement(el) {
5354
6524
  const text = elementText(el, 40);
5355
6525
  return `${tag}${id}${cls}${text ? ` "${text}"` : ""}`;
5356
6526
  }
6527
+ /**
6528
+ * Build an "Other pages" link target, or null when it would leave the
6529
+ * current origin. The stored path comes from anonymous comment submissions;
6530
+ * one that does not start with "/" (".evil.com/x", "@evil.com/") would
6531
+ * extend or replace the host once the concatenated string is parsed as a
6532
+ * URL, sending the visitor - and the page's query string - to another site.
6533
+ */
6534
+ function otherPageHref(path, loc) {
6535
+ const candidate = /[?#]/.test(path) ? loc.origin + path : loc.origin + path + loc.search;
6536
+ try {
6537
+ return new URL(candidate).origin === loc.origin ? candidate : null;
6538
+ } catch {
6539
+ return null;
6540
+ }
6541
+ }
5357
6542
  var NuniWidget = class {
5358
6543
  constructor(config, runtime) {
5359
6544
  this.config = config;
5360
6545
  this.runtime = runtime;
5361
6546
  _defineProperty(this, "host", void 0);
5362
6547
  _defineProperty(this, "root", void 0);
6548
+ _defineProperty(this, "markLayer", void 0);
5363
6549
  _defineProperty(this, "pinLayer", void 0);
5364
6550
  _defineProperty(this, "overlayLayer", void 0);
6551
+ _defineProperty(this, "selectButton", void 0);
5365
6552
  _defineProperty(this, "uiLayer", void 0);
5366
6553
  _defineProperty(this, "api", void 0);
5367
6554
  _defineProperty(this, "cleanups", []);
@@ -5393,10 +6580,28 @@ var NuniWidget = class {
5393
6580
  _defineProperty(this, "ownerDetailId", null);
5394
6581
  _defineProperty(this, "ownerDetailLoaded", false);
5395
6582
  _defineProperty(this, "ownerDetailUnsub", null);
6583
+ _defineProperty(this, "thread", null);
6584
+ _defineProperty(this, "threadId", null);
6585
+ _defineProperty(this, "threadUnsub", null);
6586
+ _defineProperty(this, "resolveRun", 0);
6587
+ _defineProperty(this, "scopeWatchers", /* @__PURE__ */ new Map());
6588
+ _defineProperty(this, "pickDocs", []);
6589
+ _defineProperty(this, "dragStart", null);
6590
+ _defineProperty(this, "dragBox", null);
6591
+ _defineProperty(this, "dragEnded", false);
6592
+ _defineProperty(this, "selection", null);
6593
+ _defineProperty(this, "pointerDown", false);
6594
+ _defineProperty(this, "selectionFrame", 0);
6595
+ _defineProperty(this, "returnFocus", null);
6596
+ _defineProperty(this, "focusNext", null);
6597
+ _defineProperty(this, "confirming", null);
6598
+ _defineProperty(this, "live", void 0);
5396
6599
  _defineProperty(this, "layoutFrame", 0);
5397
6600
  _defineProperty(this, "pinPositions", /* @__PURE__ */ new Map());
5398
6601
  _defineProperty(this, "resolveTimer", 0);
6602
+ _defineProperty(this, "i18n", void 0);
5399
6603
  _defineProperty(this, "isIgnored", (el) => el === this.host || this.host.contains(el));
6604
+ _defineProperty(this, "onFrameKey", (e) => this.onKeyDown(e));
5400
6605
  _defineProperty(this, "swallow", (e) => {
5401
6606
  if (!this.fromPage(e)) return;
5402
6607
  if (e.type === "pointerdown" && e instanceof PointerEvent) {
@@ -5405,11 +6610,41 @@ var NuniWidget = class {
5405
6610
  this.hoverEl = pickTarget(target);
5406
6611
  this.renderHighlight();
5407
6612
  }
6613
+ this.dragEnded = false;
6614
+ this.dragStart = e.pointerType !== "touch" && e.button === 0 && (target?.ownerDocument ?? null) === document ? {
6615
+ x: e.clientX,
6616
+ y: e.clientY
6617
+ } : null;
5408
6618
  }
6619
+ if (e.type === "pointerup" && this.dragBox) {
6620
+ const box = this.dragBox;
6621
+ this.dragStart = null;
6622
+ this.dragBox = null;
6623
+ this.dragEnded = true;
6624
+ setTimeout(() => {
6625
+ this.dragEnded = false;
6626
+ if (this.picking) this.commentOnArea(box);
6627
+ }, 0);
6628
+ } else if (e.type === "pointerup") this.dragStart = null;
5409
6629
  e.preventDefault();
5410
6630
  e.stopPropagation();
5411
6631
  });
5412
6632
  _defineProperty(this, "onPickMove", (e) => {
6633
+ const start = this.dragStart;
6634
+ if (start) {
6635
+ const dx = e.clientX - start.x;
6636
+ const dy = e.clientY - start.y;
6637
+ if (this.dragBox || Math.hypot(dx, dy) > DRAG_THRESHOLD) {
6638
+ this.dragBox = {
6639
+ left: Math.min(start.x, e.clientX),
6640
+ top: Math.min(start.y, e.clientY),
6641
+ width: Math.abs(dx),
6642
+ height: Math.abs(dy)
6643
+ };
6644
+ this.renderHighlight();
6645
+ return;
6646
+ }
6647
+ }
5413
6648
  const target = this.fromPage(e);
5414
6649
  const next = target ? pickTarget(target) : null;
5415
6650
  if (next === this.hoverEl) return;
@@ -5422,27 +6657,21 @@ var NuniWidget = class {
5422
6657
  e.preventDefault();
5423
6658
  e.stopPropagation();
5424
6659
  e.stopImmediatePropagation();
6660
+ if (this.dragEnded) return;
5425
6661
  const element = pickTarget(target);
5426
6662
  const anchor = captureAnchor(element, {
5427
6663
  x: e.clientX,
5428
6664
  y: e.clientY
5429
- }, { ignoreAttributePrefixes: [] });
5430
- this.setPicking(false);
5431
- const draft = {
6665
+ }, {
6666
+ ignoreAttributePrefixes: [],
6667
+ document
6668
+ });
6669
+ this.openComposer({
5432
6670
  anchor,
5433
6671
  element
5434
- };
5435
- if (this.config.capture.dom) try {
5436
- draft.dom = domContext(element);
5437
- } catch {}
5438
- this.card = {
5439
- kind: "composer",
5440
- draft
5441
- };
5442
- this.render();
5443
- this.focusComposer();
5444
- this.takeScreenshot(draft);
6672
+ });
5445
6673
  });
6674
+ this.i18n = createI18n(config.locale, config.messages);
5446
6675
  let secret = read(KEYS.secret);
5447
6676
  if (!secret) {
5448
6677
  secret = generateSecret();
@@ -5453,15 +6682,56 @@ var NuniWidget = class {
5453
6682
  this.host = document.createElement("div");
5454
6683
  this.host.id = "nuni-root";
5455
6684
  this.host.setAttribute("data-nuni", "");
6685
+ this.applyAppearance();
5456
6686
  this.root = this.host.attachShadow({ mode: "open" });
5457
6687
  const style = document.createElement("style");
5458
6688
  style.textContent = STYLES;
6689
+ this.markLayer = h("div", {
6690
+ class: "marks",
6691
+ "aria-hidden": "true"
6692
+ });
5459
6693
  this.pinLayer = h("div", { class: "pins" });
5460
6694
  this.overlayLayer = h("div", { class: "overlay" });
5461
6695
  this.uiLayer = h("div", { class: "ui" });
5462
- this.root.append(style, this.pinLayer, this.overlayLayer, this.uiLayer);
6696
+ this.selectButton = h("button", {
6697
+ class: "select-btn",
6698
+ type: "button",
6699
+ hidden: true,
6700
+ title: this.withKey("commentSelection", "commentSelectionKey"),
6701
+ onmousedown: (e) => e.preventDefault(),
6702
+ onpointerdown: (e) => e.preventDefault(),
6703
+ onclick: () => this.commentOnSelection()
6704
+ }, icon(ICONS.comment), this.t("comment"));
6705
+ this.live = h("div", {
6706
+ class: "sr",
6707
+ role: "status",
6708
+ "aria-live": "polite"
6709
+ });
6710
+ this.root.append(style, this.markLayer, this.pinLayer, this.overlayLayer, this.uiLayer, this.selectButton, this.live);
6711
+ this.uiLayer.addEventListener("keydown", (e) => this.trapFocus(e));
5463
6712
  this.api = new NuniApi(config);
5464
6713
  }
6714
+ t(key, vars) {
6715
+ return this.i18n.t(key, vars);
6716
+ }
6717
+ /** A message that names the hotkey, or the plain one when it is off. */
6718
+ withKey(plain, keyed) {
6719
+ const key = this.config.hotkey;
6720
+ return key ? this.t(keyed, { key: key.toUpperCase() }) : this.t(plain);
6721
+ }
6722
+ /** Position, theme, brand color and stacking order from the options. */
6723
+ applyAppearance() {
6724
+ const { position, theme, accentColor, zIndex } = this.config;
6725
+ this.host.dataset.position = position;
6726
+ this.host.dataset.theme = theme;
6727
+ if (zIndex !== null) this.host.style.zIndex = String(zIndex);
6728
+ const rgb = accentColor ? parseColor(accentColor) : null;
6729
+ if (!rgb) return;
6730
+ const [r, g, b] = rgb;
6731
+ this.host.style.setProperty("--n-accent", `rgb(${r}, ${g}, ${b})`);
6732
+ this.host.style.setProperty("--n-accent-soft", `rgba(${r}, ${g}, ${b}, 0.14)`);
6733
+ this.host.style.setProperty("--n-accent-fg", readableOn(rgb));
6734
+ }
5465
6735
  start() {
5466
6736
  document.body.appendChild(this.host);
5467
6737
  sha256Hex(this.secret).then((hash) => {
@@ -5494,6 +6764,25 @@ var NuniWidget = class {
5494
6764
  });
5495
6765
  window.addEventListener("resize", onScroll, { passive: true });
5496
6766
  window.addEventListener("message", onMessage);
6767
+ const onSelection = () => this.scheduleSelection();
6768
+ const onDown = (e) => {
6769
+ if (e.composedPath().includes(this.host)) return;
6770
+ this.pointerDown = true;
6771
+ this.scheduleSelection();
6772
+ };
6773
+ const onUp = () => {
6774
+ this.pointerDown = false;
6775
+ this.scheduleSelection();
6776
+ };
6777
+ document.addEventListener("selectionchange", onSelection);
6778
+ document.addEventListener("pointerdown", onDown, true);
6779
+ document.addEventListener("pointerup", onUp, true);
6780
+ this.cleanups.push(() => {
6781
+ document.removeEventListener("selectionchange", onSelection);
6782
+ document.removeEventListener("pointerdown", onDown, true);
6783
+ document.removeEventListener("pointerup", onUp, true);
6784
+ cancelAnimationFrame(this.selectionFrame);
6785
+ });
5497
6786
  this.cleanups.push(() => {
5498
6787
  window.removeEventListener("keydown", onKey, true);
5499
6788
  window.removeEventListener("scroll", onScroll, true);
@@ -5530,6 +6819,10 @@ var NuniWidget = class {
5530
6819
  this.pageUnsub?.();
5531
6820
  this.sessionUnsub?.();
5532
6821
  this.ownerDetailUnsub?.();
6822
+ this.threadUnsub?.();
6823
+ this.resolveRun++;
6824
+ for (const stop of this.scopeWatchers.values()) stop();
6825
+ this.scopeWatchers.clear();
5533
6826
  for (const cleanup of this.cleanups) cleanup();
5534
6827
  cancelAnimationFrame(this.layoutFrame);
5535
6828
  clearTimeout(this.resolveTimer);
@@ -5539,7 +6832,7 @@ var NuniWidget = class {
5539
6832
  }
5540
6833
  currentPageKey() {
5541
6834
  const url = new URL(location.href);
5542
- return this.config.getPageKey?.(url) ?? normalizePath(url);
6835
+ return this.config.getPageKey(url);
5543
6836
  }
5544
6837
  subscribePage() {
5545
6838
  const key = this.currentPageKey();
@@ -5557,7 +6850,7 @@ var NuniWidget = class {
5557
6850
  const merged = extra && !comments.some((c) => c._id === extra._id) ? [...comments, extra] : comments;
5558
6851
  this.comments = merged.sort((a, b) => a.createdAt - b.createdAt);
5559
6852
  this.loaded = true;
5560
- this.resolvePlacements(false);
6853
+ this.resolvePlacements(this.placements.size > 0, this.deepLinkId ?? void 0);
5561
6854
  this.openDeepLink();
5562
6855
  this.render();
5563
6856
  }, () => {});
@@ -5566,7 +6859,7 @@ var NuniWidget = class {
5566
6859
  watchSession(token) {
5567
6860
  this.sessionUnsub?.();
5568
6861
  this.sessionUnsub = this.api.onSession(token, (result) => {
5569
- if (result.valid) this.ownerName = result.ownerName ?? "Owner";
6862
+ if (result.valid) this.ownerName = result.ownerName ?? this.t("owner");
5570
6863
  else {
5571
6864
  this.ownerName = null;
5572
6865
  this.ownerToken = null;
@@ -5595,6 +6888,21 @@ var NuniWidget = class {
5595
6888
  this.render();
5596
6889
  });
5597
6890
  }
6891
+ /** Keep the open thread's replies and reactions subscribed. */
6892
+ syncThread() {
6893
+ const id = this.card?.kind === "thread" ? this.card.id : null;
6894
+ if (id === this.threadId) return;
6895
+ this.threadUnsub?.();
6896
+ this.threadUnsub = null;
6897
+ this.thread = null;
6898
+ this.threadId = id;
6899
+ if (!id) return;
6900
+ this.threadUnsub = this.api.onThread(id, (thread) => {
6901
+ if (this.threadId !== id) return;
6902
+ this.thread = thread;
6903
+ this.render();
6904
+ });
6905
+ }
5598
6906
  get isOwner() {
5599
6907
  return Boolean(this.ownerToken && this.ownerName);
5600
6908
  }
@@ -5608,38 +6916,165 @@ var NuniWidget = class {
5608
6916
  clearTimeout(this.resolveTimer);
5609
6917
  this.resolveTimer = window.setTimeout(() => this.resolvePlacements(true), 200);
5610
6918
  }
6919
+ stillPlaced(comment) {
6920
+ const current = this.placements.get(comment._id);
6921
+ if (!current?.element?.isConnected) return false;
6922
+ const quote = comment.anchor.quote;
6923
+ if (quote) return current.range !== void 0 && collapse(current.range.toString()) === quote.exact;
6924
+ const text = elementText(current.element);
6925
+ const expected = comment.anchor.text;
6926
+ return !expected || text === expected || textSimilarity(text, expected) > .8;
6927
+ }
5611
6928
  /**
5612
6929
  * Find each comment's element. With `onlyStale`, keep placements whose
5613
6930
  * element is still attached and still looks like what was captured.
6931
+ * Works in slices of about 8 ms, so a page with many pins stays
6932
+ * responsive; `first` (a deep-linked comment) is found right away.
5614
6933
  */
5615
- resolvePlacements(onlyStale) {
6934
+ resolvePlacements(onlyStale, first) {
6935
+ const run = ++this.resolveRun;
5616
6936
  const ids = new Set(this.comments.map((c) => c._id));
5617
6937
  for (const id of this.placements.keys()) if (!ids.has(id)) this.placements.delete(id);
5618
- for (const comment of this.comments) {
5619
- const current = this.placements.get(comment._id);
5620
- if (onlyStale && current?.element && current.element.isConnected) {
5621
- const text = elementText(current.element);
5622
- const expected = comment.anchor.text;
5623
- if (!expected || text === expected || textSimilarity(text, expected) > .8) continue;
6938
+ const queue = this.comments.filter((c) => !(onlyStale && this.stillPlaced(c)));
6939
+ const index = first ? queue.findIndex((c) => c._id === first) : -1;
6940
+ if (index > 0) queue.unshift(...queue.splice(index, 1));
6941
+ const cache = createResolveCache();
6942
+ const options = {
6943
+ isIgnored: this.isIgnored,
6944
+ document
6945
+ };
6946
+ const slice = () => {
6947
+ if (run !== this.resolveRun) return;
6948
+ const started = performance.now();
6949
+ while (queue.length) {
6950
+ const comment = queue.shift();
6951
+ const result = resolveAnchor(comment.anchor, document, options, cache);
6952
+ this.placements.set(comment._id, {
6953
+ element: result.element,
6954
+ confidence: result.confidence,
6955
+ range: result.range
6956
+ });
6957
+ if (performance.now() - started > 8) break;
6958
+ }
6959
+ this.watchScopes();
6960
+ this.renderPins();
6961
+ this.layout();
6962
+ if (queue.length) whenIdle(slice);
6963
+ else if ((onlyStale || run > 1) && this.panelOpen) this.render();
6964
+ };
6965
+ slice();
6966
+ }
6967
+ /**
6968
+ * Pins inside shadow roots and iframes: the page's observers don't see
6969
+ * changes or scrolling in there, so watch each of those scopes too.
6970
+ */
6971
+ watchScopes() {
6972
+ const roots = /* @__PURE__ */ new Set();
6973
+ for (const { element } of this.placements.values()) {
6974
+ let node = element;
6975
+ for (let depth = 0; node && depth < 8; depth++) {
6976
+ const root = scopeRootOf(node);
6977
+ if (root === document) break;
6978
+ roots.add(root);
6979
+ node = isShadowRoot(root) ? root.host : frameElementOf(root);
5624
6980
  }
5625
- const result = resolveAnchor(comment.anchor, document, { isIgnored: this.isIgnored });
5626
- this.placements.set(comment._id, {
5627
- element: result.element,
5628
- confidence: result.confidence
5629
- });
5630
6981
  }
5631
- this.renderPins();
5632
- this.layout();
5633
- if (onlyStale && this.panelOpen) this.render();
6982
+ for (const [root, stop] of this.scopeWatchers) if (!roots.has(root)) {
6983
+ stop();
6984
+ this.scopeWatchers.delete(root);
6985
+ }
6986
+ for (const root of roots) if (!this.scopeWatchers.has(root)) this.scopeWatchers.set(root, this.watchScope(root));
6987
+ }
6988
+ watchScope(root) {
6989
+ const observer = new MutationObserver(() => this.scheduleResolve());
6990
+ const doc = isShadowRoot(root) ? null : root;
6991
+ observer.observe(doc ? doc.body ?? doc.documentElement : root, {
6992
+ childList: true,
6993
+ subtree: true,
6994
+ characterData: true,
6995
+ attributes: true,
6996
+ attributeFilter: [
6997
+ "class",
6998
+ "style",
6999
+ "hidden",
7000
+ "open",
7001
+ "id"
7002
+ ]
7003
+ });
7004
+ const onScroll = () => this.scheduleLayout();
7005
+ const target = doc?.defaultView ?? root;
7006
+ target.addEventListener("scroll", onScroll, {
7007
+ capture: true,
7008
+ passive: true
7009
+ });
7010
+ doc?.defaultView?.addEventListener("resize", onScroll, { passive: true });
7011
+ return () => {
7012
+ observer.disconnect();
7013
+ target.removeEventListener("scroll", onScroll, true);
7014
+ doc?.defaultView?.removeEventListener("resize", onScroll);
7015
+ };
5634
7016
  }
5635
7017
  pinPoint(comment) {
5636
- const el = this.placements.get(comment._id)?.element;
7018
+ const placement = this.placements.get(comment._id);
7019
+ const el = placement?.element;
5637
7020
  if (!el || !isRendered(el)) return null;
5638
- const r = el.getBoundingClientRect();
7021
+ if (viewportRect(el, document).clipped) return null;
7022
+ return this.anchorPoint(comment.anchor, el, placement.range);
7023
+ }
7024
+ /**
7025
+ * Where a pin goes: after the last selected word for a text comment, the
7026
+ * top-right corner of an area, or where the element was clicked.
7027
+ */
7028
+ anchorPoint(anchor, el, range) {
7029
+ if (range && range.startContainer.ownerDocument === document) {
7030
+ const last = lineRects(range).at(-1);
7031
+ if (last) return {
7032
+ x: last.right,
7033
+ y: last.top + last.height / 2
7034
+ };
7035
+ }
7036
+ const r = viewportRect(el, document);
5639
7037
  return {
5640
- x: r.left + comment.anchor.offset.x * r.width,
5641
- y: r.top + comment.anchor.offset.y * r.height
7038
+ x: r.left + anchor.offset.x * r.width,
7039
+ y: r.top + anchor.offset.y * r.height
7040
+ };
7041
+ }
7042
+ /**
7043
+ * Text comments underline their words, and the open comment (or the one
7044
+ * being written) also shows its words or its area strongly.
7045
+ */
7046
+ renderMarks() {
7047
+ const marks = [];
7048
+ const add = (box, cls) => {
7049
+ const mark = h("div", { class: cls });
7050
+ Object.assign(mark.style, {
7051
+ left: `${box.left}px`,
7052
+ top: `${box.top}px`,
7053
+ width: `${box.width}px`,
7054
+ height: `${box.height}px`
7055
+ });
7056
+ marks.push(mark);
7057
+ };
7058
+ const show = (anchor, el, range, active) => {
7059
+ if (!el) return;
7060
+ if (range && range.startContainer.ownerDocument === document) for (const r of lineRects(range)) add(r, active ? "mark mark-active" : "mark");
7061
+ if (active) {
7062
+ const box = regionBox(anchor, el);
7063
+ if (box) add(box, "area");
7064
+ }
5642
7065
  };
7066
+ if (this.card?.kind === "composer") {
7067
+ const { draft } = this.card;
7068
+ show(draft.anchor, draft.element, draft.range, true);
7069
+ }
7070
+ for (const comment of this.visibleComments()) {
7071
+ const placement = this.placements.get(comment._id);
7072
+ const active = this.activeId === comment._id;
7073
+ if (!active && (comment.status !== "open" || !comment.anchor.quote)) continue;
7074
+ if (this.pins.get(comment._id)?.hidden) continue;
7075
+ show(comment.anchor, placement?.element, placement?.range, active);
7076
+ }
7077
+ this.markLayer.replaceChildren(...marks);
5643
7078
  }
5644
7079
  scheduleLayout() {
5645
7080
  if (this.layoutFrame) return;
@@ -5672,67 +7107,168 @@ var NuniWidget = class {
5672
7107
  });
5673
7108
  pin.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
5674
7109
  }
7110
+ this.renderMarks();
5675
7111
  this.positionCard();
5676
- if (this.picking && this.hoverEl) this.renderHighlight();
7112
+ if (this.picking && (this.hoverEl || this.dragBox)) this.renderHighlight();
7113
+ this.placeSelectButton();
5677
7114
  }
5678
7115
  setPicking(on) {
5679
7116
  if (this.picking === on) return;
5680
7117
  this.picking = on;
5681
7118
  const html = document.documentElement;
7119
+ this.dragStart = null;
7120
+ this.dragBox = null;
5682
7121
  if (on) {
5683
7122
  this.card = null;
5684
7123
  if (window.matchMedia(MOBILE_QUERY).matches) this.panelOpen = false;
5685
- html.classList.add(PICKING_CLASS);
5686
- this.injectCursorStyle();
5687
- document.addEventListener("pointermove", this.onPickMove, true);
5688
- document.addEventListener("click", this.onPickClick, true);
5689
- for (const type of [
5690
- "pointerdown",
5691
- "mousedown",
5692
- "pointerup",
5693
- "mouseup"
5694
- ]) document.addEventListener(type, this.swallow, {
5695
- capture: true,
5696
- passive: false
5697
- });
7124
+ this.pickDocs = [document, ...frameDocuments(document)];
7125
+ for (const doc of this.pickDocs) {
7126
+ doc.documentElement.classList.add(PICKING_CLASS);
7127
+ this.injectCursorStyle(doc);
7128
+ doc.addEventListener("pointermove", this.onPickMove, true);
7129
+ doc.addEventListener("click", this.onPickClick, true);
7130
+ for (const type of PICK_EVENTS) doc.addEventListener(type, this.swallow, {
7131
+ capture: true,
7132
+ passive: false
7133
+ });
7134
+ if (doc !== document) doc.addEventListener("keydown", this.onFrameKey, true);
7135
+ }
5698
7136
  } else {
5699
7137
  html.classList.remove(PICKING_CLASS);
5700
7138
  this.hoverEl = null;
5701
- document.removeEventListener("pointermove", this.onPickMove, true);
5702
- document.removeEventListener("click", this.onPickClick, true);
5703
- for (const type of [
5704
- "pointerdown",
5705
- "mousedown",
5706
- "pointerup",
5707
- "mouseup"
5708
- ]) document.removeEventListener(type, this.swallow, true);
7139
+ for (const doc of this.pickDocs) {
7140
+ doc.documentElement?.classList.remove(PICKING_CLASS);
7141
+ doc.removeEventListener("pointermove", this.onPickMove, true);
7142
+ doc.removeEventListener("click", this.onPickClick, true);
7143
+ for (const type of PICK_EVENTS) doc.removeEventListener(type, this.swallow, true);
7144
+ doc.removeEventListener("keydown", this.onFrameKey, true);
7145
+ }
7146
+ this.pickDocs = [];
5709
7147
  }
5710
7148
  this.render();
5711
7149
  }
5712
- injectCursorStyle() {
5713
- if (document.getElementById("nuni-cursor-style")) return;
5714
- const style = document.createElement("style");
7150
+ injectCursorStyle(doc) {
7151
+ if (doc.getElementById("nuni-cursor-style")) return;
7152
+ const style = doc.createElement("style");
5715
7153
  style.id = "nuni-cursor-style";
5716
7154
  style.textContent = `html.${PICKING_CLASS}, html.${PICKING_CLASS} * { cursor: crosshair !important; }`;
5717
- document.head.appendChild(style);
7155
+ (doc.head ?? doc.documentElement).appendChild(style);
5718
7156
  this.cleanups.push(() => style.remove());
5719
7157
  }
7158
+ /**
7159
+ * The element the event happened on, inside open shadow roots too
7160
+ * (composedPath), and never Nuni's own UI.
7161
+ */
5720
7162
  fromPage(e) {
5721
- if (e.composedPath().includes(this.host)) return null;
5722
- const target = e.target;
5723
- return target instanceof Element ? target : null;
7163
+ const path = e.composedPath();
7164
+ if (path.includes(this.host)) return null;
7165
+ const target = path[0] ?? e.target;
7166
+ return target && target.nodeType === 1 ? target : null;
7167
+ }
7168
+ /** An area drawn while picking: anchored to the element that covers it. */
7169
+ commentOnArea(box) {
7170
+ const element = areaContainer(document, box, this.isIgnored);
7171
+ const anchor = captureArea(element, box, {
7172
+ ignoreAttributePrefixes: [],
7173
+ document
7174
+ });
7175
+ this.openComposer({
7176
+ anchor,
7177
+ element
7178
+ });
7179
+ }
7180
+ /** The page's selected text, if it can take a text comment. */
7181
+ pageSelection() {
7182
+ const selection = document.getSelection();
7183
+ if (!selection || selection.isCollapsed || !selection.rangeCount) return null;
7184
+ const range = selection.getRangeAt(0);
7185
+ const node = range.commonAncestorContainer;
7186
+ const el = node.nodeType === 1 ? node : node.parentElement;
7187
+ if (!el || el === this.host || this.host.contains(el)) return null;
7188
+ if (el.closest("input, textarea, select, [contenteditable]")) return null;
7189
+ if (el.closest("[data-nuni]")) return null;
7190
+ return collapse(range.toString()) ? range : null;
7191
+ }
7192
+ commentOnSelection() {
7193
+ const range = this.pageSelection();
7194
+ if (!range) return;
7195
+ const captured = captureSelection(range, {
7196
+ ignoreAttributePrefixes: [],
7197
+ document
7198
+ });
7199
+ if (!captured) return;
7200
+ const kept = range.cloneRange();
7201
+ document.getSelection()?.removeAllRanges();
7202
+ this.selection = null;
7203
+ this.openComposer({
7204
+ ...captured,
7205
+ range: kept
7206
+ });
7207
+ }
7208
+ scheduleSelection() {
7209
+ if (this.selectionFrame) return;
7210
+ this.selectionFrame = requestAnimationFrame(() => {
7211
+ this.selectionFrame = 0;
7212
+ this.selection = this.picking || this.pointerDown ? null : this.pageSelection();
7213
+ this.placeSelectButton();
7214
+ });
7215
+ }
7216
+ /** A small "Comment" button under the selected text. */
7217
+ placeSelectButton() {
7218
+ const button = this.selectButton;
7219
+ const range = this.selection;
7220
+ const last = range ? lineRects(range).at(-1) : void 0;
7221
+ const vw = window.innerWidth;
7222
+ const vh = window.innerHeight;
7223
+ if (!range || !last || last.bottom < 0 || last.top > vh) {
7224
+ button.hidden = true;
7225
+ return;
7226
+ }
7227
+ button.hidden = false;
7228
+ const width = button.offsetWidth || 96;
7229
+ const height = button.offsetHeight || 32;
7230
+ let top = last.bottom + 8;
7231
+ if (top + height > vh - 8) top = last.top - height - 8;
7232
+ const left = Math.max(8, Math.min(last.right - width / 2, vw - width - 8));
7233
+ button.style.transform = `translate(${Math.round(left)}px, ${Math.round(top)}px)`;
7234
+ }
7235
+ openComposer(draft) {
7236
+ const { element } = draft;
7237
+ this.setPicking(false);
7238
+ if (this.config.capture.dom) try {
7239
+ draft.dom = domContext(element);
7240
+ } catch {}
7241
+ this.card = {
7242
+ kind: "composer",
7243
+ draft
7244
+ };
7245
+ this.activeId = null;
7246
+ this.render();
7247
+ this.focusComposer();
7248
+ this.takeScreenshot(draft);
5724
7249
  }
5725
7250
  /** Capture while the person types, so posting stays instant. */
5726
7251
  async takeScreenshot(draft) {
5727
7252
  const load = this.runtime.loadScreenshot;
5728
- if (!load) return;
7253
+ if (!load || draft.element.ownerDocument !== document) return;
5729
7254
  const accent = getComputedStyle(this.host).getPropertyValue("--n-accent").trim() || "#d6246e";
5730
7255
  try {
5731
7256
  await new Promise((resolve) => setTimeout(resolve, 60));
5732
7257
  const capture = await load();
5733
- const blob = capture ? await capture(draft.element, {
7258
+ const { range, anchor, element } = draft;
7259
+ const focus = range ? () => range.getBoundingClientRect() : anchor.region ? () => {
7260
+ const box = regionBox(anchor, element);
7261
+ return {
7262
+ left: box.left,
7263
+ top: box.top,
7264
+ right: box.left + box.width,
7265
+ bottom: box.top + box.height
7266
+ };
7267
+ } : void 0;
7268
+ const blob = capture ? await capture(element, {
5734
7269
  exclude: this.host,
5735
- accent
7270
+ accent,
7271
+ focus
5736
7272
  }) : null;
5737
7273
  if (!blob || !this.isComposing(draft) || draft.sending) return;
5738
7274
  const preview = await dataUrl(blob);
@@ -5764,15 +7300,31 @@ var NuniWidget = class {
5764
7300
  }
5765
7301
  copyForAgent(comment) {
5766
7302
  const detail = this.ownerDetail?._id === comment._id ? this.ownerDetail : null;
5767
- copyText(buildCommentPrompt(detail ?? comment, { includeContext: Boolean(detail) })).then((ok) => this.showToast(ok ? "Copied for your coding agent" : "Couldn't copy"));
7303
+ const replies = this.threadId === comment._id ? this.thread?.replies : void 0;
7304
+ copyText(buildCommentPrompt(detail ?? {
7305
+ ...comment,
7306
+ replies
7307
+ }, { includeContext: Boolean(detail) })).then((ok) => this.showToast(ok ? this.t("copied") : this.t("copyFailed")));
5768
7308
  }
5769
7309
  renderHighlight() {
5770
7310
  this.overlayLayer.replaceChildren();
5771
7311
  if (!this.picking) return;
5772
- this.overlayLayer.append(h("div", { class: "pick-hint" }, "Click anything to comment · Esc to cancel"));
7312
+ const mobile = window.matchMedia(MOBILE_QUERY).matches;
7313
+ this.overlayLayer.append(h("div", { class: "pick-hint" }, mobile ? this.t("pickHintMobile") : [h("span", {}, this.t("pickHint")), h("span", { class: "pick-keys" }, this.t("pickHintKeys"))]));
7314
+ if (this.dragBox) {
7315
+ const box = h("div", { class: "highlight drag-box" });
7316
+ Object.assign(box.style, {
7317
+ left: `${this.dragBox.left}px`,
7318
+ top: `${this.dragBox.top}px`,
7319
+ width: `${this.dragBox.width}px`,
7320
+ height: `${this.dragBox.height}px`
7321
+ });
7322
+ this.overlayLayer.append(box);
7323
+ return;
7324
+ }
5773
7325
  const el = this.hoverEl;
5774
- if (!el || el === document.documentElement) return;
5775
- const r = el.getBoundingClientRect();
7326
+ if (!el || el === el.ownerDocument.documentElement) return;
7327
+ const r = viewportRect(el, document);
5776
7328
  const box = h("div", { class: "highlight" }, h("span", { class: "highlight-label" }, describeElement(el)));
5777
7329
  Object.assign(box.style, {
5778
7330
  left: `${r.left - 2}px`,
@@ -5785,19 +7337,171 @@ var NuniWidget = class {
5785
7337
  onKeyDown(e) {
5786
7338
  const inWidget = e.composedPath().includes(this.host);
5787
7339
  if (e.key === "Escape") {
5788
- if (this.picking) this.setPicking(false);
7340
+ if (this.confirming) this.answer(false);
7341
+ else if (this.picking) this.setPicking(false);
5789
7342
  else if (this.card) this.closeCard();
5790
7343
  else if (this.panelOpen) this.togglePanel(false);
5791
7344
  else return;
5792
7345
  e.stopPropagation();
5793
7346
  return;
5794
7347
  }
5795
- if (inWidget || isTypingTarget(e.target) || e.metaKey || e.ctrlKey || e.altKey || e.repeat) return;
5796
- if (e.key === "c" || e.key === "C") {
7348
+ if (isTypingTarget(e.composedPath()[0] ?? e.target) || e.metaKey || e.ctrlKey || e.altKey) return;
7349
+ if (this.picking && !inWidget && this.onPickKey(e)) return;
7350
+ if (inWidget && this.confirming) return;
7351
+ if (e.repeat) return;
7352
+ const hotkey = this.config.hotkey;
7353
+ if (hotkey && e.key.toLowerCase() === hotkey) {
7354
+ e.preventDefault();
7355
+ if (!this.picking && this.pageSelection()) this.commentOnSelection();
7356
+ else this.setPicking(!this.picking);
7357
+ }
7358
+ }
7359
+ /**
7360
+ * Picking without a mouse: arrows move to the parent, first child and
7361
+ * siblings; Enter comments on the highlighted element.
7362
+ */
7363
+ onPickKey(e) {
7364
+ if (![
7365
+ "ArrowUp",
7366
+ "ArrowDown",
7367
+ "ArrowLeft",
7368
+ "ArrowRight",
7369
+ "Enter"
7370
+ ].includes(e.key)) return false;
7371
+ e.preventDefault();
7372
+ e.stopPropagation();
7373
+ let el = this.hoverEl;
7374
+ if (!el || !el.isConnected) {
7375
+ this.movePickTo(this.keyboardStart());
7376
+ return true;
7377
+ }
7378
+ if (e.key === "Enter") {
7379
+ const r = el.getBoundingClientRect();
7380
+ const anchor = captureAnchor(el, {
7381
+ x: r.left + r.width / 2,
7382
+ y: r.top + r.height / 2
7383
+ }, {
7384
+ ignoreAttributePrefixes: [],
7385
+ document
7386
+ });
7387
+ this.openComposer({
7388
+ anchor,
7389
+ element: el
7390
+ });
7391
+ return true;
7392
+ }
7393
+ const visible = (n) => {
7394
+ while (n && (n === this.host || !isRendered(n))) n = e.key === "ArrowLeft" ? n.previousElementSibling : n.nextElementSibling;
7395
+ return n;
7396
+ };
7397
+ const body = el.ownerDocument.body;
7398
+ if (e.key === "ArrowUp") {
7399
+ if (el.parentElement && el !== body) el = el.parentElement;
7400
+ } else if (e.key === "ArrowDown") el = visible(el.firstElementChild) ?? el;
7401
+ else if (e.key === "ArrowLeft") el = visible(el.previousElementSibling) ?? el;
7402
+ else el = visible(el.nextElementSibling) ?? el;
7403
+ this.movePickTo(el);
7404
+ return true;
7405
+ }
7406
+ /** Keyboard picking starts at the focused element, or mid-screen. */
7407
+ keyboardStart() {
7408
+ const focused = document.activeElement;
7409
+ if (focused && focused !== document.body && focused !== this.host) return focused;
7410
+ const middle = document.elementsFromPoint(window.innerWidth / 2, window.innerHeight / 2).find((el) => !this.isIgnored(el));
7411
+ return middle ? pickTarget(middle) : document.body;
7412
+ }
7413
+ movePickTo(el) {
7414
+ this.hoverEl = el;
7415
+ el.scrollIntoView({
7416
+ block: "nearest",
7417
+ inline: "nearest"
7418
+ });
7419
+ this.live.textContent = this.t("picked", { element: describeElement(el) });
7420
+ this.renderHighlight();
7421
+ }
7422
+ /** Tab stays inside an open card, the panel or a dialog. */
7423
+ trapFocus(e) {
7424
+ if (e.key !== "Tab") return;
7425
+ const box = e.composedPath()[0]?.closest?.(".confirm, [data-card], .panel");
7426
+ if (!box) return;
7427
+ const items = Array.from(box.querySelectorAll("button:not([disabled]), input, textarea, a[href], [tabindex=\"0\"]")).filter((el) => !el.closest("[hidden]"));
7428
+ if (!items.length) return;
7429
+ const first = items[0];
7430
+ const last = items[items.length - 1];
7431
+ const active = this.root.activeElement;
7432
+ if (e.shiftKey && active === first) {
7433
+ e.preventDefault();
7434
+ last.focus();
7435
+ } else if (!e.shiftKey && active === last) {
5797
7436
  e.preventDefault();
5798
- this.setPicking(!this.picking);
7437
+ first.focus();
5799
7438
  }
5800
7439
  }
7440
+ /** Remember where focus was, before moving it into the widget. */
7441
+ keepFocus() {
7442
+ if (this.returnFocus) return;
7443
+ const inside = this.root.activeElement;
7444
+ if (inside) {
7445
+ this.returnFocus = inside.dataset.focusKey ?? inside;
7446
+ return;
7447
+ }
7448
+ const active = document.activeElement;
7449
+ this.returnFocus = active && active !== document.body && active !== this.host ? active : null;
7450
+ }
7451
+ /** Back to where focus was before (when that is still there). */
7452
+ restoreFocus() {
7453
+ const back = this.returnFocus;
7454
+ this.returnFocus = null;
7455
+ const target = typeof back === "string" ? this.uiLayer.querySelector(`[data-focus-key="${back}"]`) : back;
7456
+ if (target?.isConnected) target.focus({ preventScroll: true });
7457
+ }
7458
+ /** An "are you sure?" dialog inside the widget. */
7459
+ ask(message, action) {
7460
+ this.confirming?.done(false);
7461
+ const from = this.root.activeElement?.dataset.focusKey ?? null;
7462
+ return new Promise((resolve) => {
7463
+ this.confirming = {
7464
+ message,
7465
+ action,
7466
+ done: resolve,
7467
+ from
7468
+ };
7469
+ this.focusNext = "[data-focus-key=\"confirm-cancel\"]";
7470
+ this.render();
7471
+ });
7472
+ }
7473
+ answer(ok) {
7474
+ const confirming = this.confirming;
7475
+ if (!confirming) return;
7476
+ this.confirming = null;
7477
+ if (!ok && confirming.from) this.focusNext = `[data-focus-key="${confirming.from}"]`;
7478
+ this.render();
7479
+ confirming.done(ok);
7480
+ }
7481
+ renderConfirm() {
7482
+ const c = this.confirming;
7483
+ if (!c) return null;
7484
+ return h("div", {
7485
+ class: "confirm-backdrop",
7486
+ onclick: () => this.answer(false)
7487
+ }, h("div", {
7488
+ class: "confirm",
7489
+ role: "alertdialog",
7490
+ "aria-modal": "true",
7491
+ "aria-labelledby": "nuni-confirm-text",
7492
+ onclick: (e) => e.stopPropagation()
7493
+ }, h("p", { id: "nuni-confirm-text" }, c.message), h("div", { class: "row" }, h("span", { class: "spacer" }), h("button", {
7494
+ class: "btn btn-ghost",
7495
+ type: "button",
7496
+ "data-focus-key": "confirm-cancel",
7497
+ onclick: () => this.answer(false)
7498
+ }, this.t("cancel")), h("button", {
7499
+ class: "btn btn-danger-solid",
7500
+ type: "button",
7501
+ "data-focus-key": "confirm-ok",
7502
+ onclick: () => this.answer(true)
7503
+ }, c.action))));
7504
+ }
5801
7505
  openClaim() {
5802
7506
  const url = new URL(`${this.config.appUrl}/dashboard/claim`);
5803
7507
  url.searchParams.set("project", this.config.project);
@@ -5818,7 +7522,7 @@ var NuniWidget = class {
5818
7522
  this.ownerToken = data.token;
5819
7523
  write(KEYS.session(this.config.project), data.token);
5820
7524
  this.watchSession(data.token);
5821
- this.showToast("You're signed in as the owner");
7525
+ this.showToast(this.t("signedIn"));
5822
7526
  }
5823
7527
  readDeepLink() {
5824
7528
  const url = new URL(location.href);
@@ -5839,12 +7543,12 @@ var NuniWidget = class {
5839
7543
  }
5840
7544
  this.api.getComment(id).then((found) => {
5841
7545
  if (!found || found.page.path !== this.pageKey) {
5842
- this.showToast("That comment was deleted or is on another page");
7546
+ this.showToast(this.t("linkGone"));
5843
7547
  return;
5844
7548
  }
5845
7549
  this.extraComment = found;
5846
7550
  this.comments = [...this.comments.filter((c) => c._id !== found._id), found];
5847
- this.resolvePlacements(false);
7551
+ this.resolvePlacements(false, found._id);
5848
7552
  this.focusDeepLinked(found);
5849
7553
  });
5850
7554
  }
@@ -5852,15 +7556,10 @@ var NuniWidget = class {
5852
7556
  this.focusComment(comment._id);
5853
7557
  }
5854
7558
  focusComment(id) {
5855
- this.placements.get(id)?.element?.scrollIntoView({
5856
- block: "center",
5857
- behavior: "smooth"
5858
- });
5859
- this.activeId = id;
5860
- this.card = {
5861
- kind: "thread",
5862
- id
5863
- };
7559
+ const placement = this.placements.get(id);
7560
+ const comment = this.comments.find((c) => c._id === id);
7561
+ if (placement?.element && comment) scrollToPin(placement.element, () => this.anchorPoint(comment.anchor, placement.element, placement.range));
7562
+ this.openThread(id);
5864
7563
  if (window.matchMedia(MOBILE_QUERY).matches) this.panelOpen = false;
5865
7564
  this.render();
5866
7565
  setTimeout(() => this.scheduleLayout(), 400);
@@ -5868,9 +7567,9 @@ var NuniWidget = class {
5868
7567
  async submitDraft(draft, body, name) {
5869
7568
  const cleanName = name.trim().slice(0, LIMITS.nameMaxLength);
5870
7569
  const cleanBody = body.trim();
5871
- if (!cleanName) return this.setDraftError(draft, "Enter your name");
5872
- if (!cleanBody) return this.setDraftError(draft, "Write a comment first");
5873
- if (cleanBody.length > LIMITS.bodyMaxLength) return this.setDraftError(draft, `Comments are limited to ${LIMITS.bodyMaxLength} characters`);
7570
+ if (!cleanName) return this.setDraftError(draft, this.t("enterName"));
7571
+ if (!cleanBody) return this.setDraftError(draft, this.t("writeFirst"));
7572
+ if (cleanBody.length > LIMITS.bodyMaxLength) return this.setDraftError(draft, this.t("commentTooLong", { count: LIMITS.bodyMaxLength }));
5874
7573
  this.name = cleanName;
5875
7574
  write(KEYS.name, cleanName);
5876
7575
  draft.sending = true;
@@ -5899,13 +7598,14 @@ var NuniWidget = class {
5899
7598
  if (shot) this.attachScreenshot(id, shot.blob);
5900
7599
  this.placements.set(id, {
5901
7600
  element: draft.element,
5902
- confidence: "exact"
7601
+ confidence: "exact",
7602
+ range: draft.range
5903
7603
  });
5904
7604
  if (this.card?.kind === "composer" && this.card.draft === draft) this.card = null;
5905
- this.showToast("Comment added");
7605
+ this.showToast(this.t("added"));
5906
7606
  } catch (error) {
5907
7607
  draft.sending = false;
5908
- draft.error = error instanceof NuniApiError ? error.message : "Couldn't post the comment";
7608
+ draft.error = error instanceof NuniApiError ? error.message : this.t("postFailed");
5909
7609
  }
5910
7610
  this.render();
5911
7611
  }
@@ -5933,7 +7633,7 @@ var NuniWidget = class {
5933
7633
  } catch (error) {
5934
7634
  if (this.card?.kind === "thread" && this.card.id === id) {
5935
7635
  this.card.busy = false;
5936
- this.card.error = error instanceof Error ? error.message : "Something went wrong";
7636
+ this.card.error = error instanceof Error ? error.message : this.t("failed");
5937
7637
  }
5938
7638
  this.render();
5939
7639
  return false;
@@ -5942,21 +7642,21 @@ var NuniWidget = class {
5942
7642
  resolveComment(c) {
5943
7643
  const token = this.ownerToken;
5944
7644
  if (!token) return;
5945
- this.act(c._id, () => this.api.resolve(c._id, token), "Resolved").then((ok) => {
7645
+ this.act(c._id, () => this.api.resolve(c._id, token), this.t("resolved")).then((ok) => {
5946
7646
  if (ok && !(this.panelOpen && this.tab === "resolved") && this.card?.kind === "thread" && this.card.id === c._id) this.closeCard();
5947
7647
  });
5948
7648
  }
5949
7649
  reopenComment(c) {
5950
7650
  const token = this.ownerToken;
5951
7651
  if (!token) return;
5952
- this.act(c._id, () => this.api.reopen(c._id, token), "Reopened");
7652
+ this.act(c._id, () => this.api.reopen(c._id, token), this.t("reopened"));
5953
7653
  }
5954
- deleteComment(c) {
5955
- if (!window.confirm("Delete this comment? This can't be undone.")) return;
7654
+ async deleteComment(c) {
7655
+ if (!await this.ask(this.t("confirmDeleteComment"), this.t("delete"))) return;
5956
7656
  const token = this.ownerToken;
5957
7657
  const action = this.isMine(c) ? () => this.api.deleteOwn(c._id, this.secret) : token ? () => this.api.remove(c._id, token) : null;
5958
7658
  if (!action) return;
5959
- this.act(c._id, action, "Deleted").then((ok) => {
7659
+ this.act(c._id, action, this.t("deleted")).then((ok) => {
5960
7660
  if (!ok) return;
5961
7661
  if (this.extraComment?._id === c._id) this.extraComment = null;
5962
7662
  this.comments = this.comments.filter((x) => x._id !== c._id);
@@ -5964,8 +7664,72 @@ var NuniWidget = class {
5964
7664
  this.closeCard();
5965
7665
  });
5966
7666
  }
7667
+ isMyReply(reply) {
7668
+ return Boolean(this.secretHash) && reply.authorKeyHash === this.secretHash;
7669
+ }
7670
+ async sendReply(comment, body, name) {
7671
+ const card = this.card;
7672
+ if (card?.kind !== "thread" || card.id !== comment._id) return;
7673
+ const authorName = (this.isOwner ? this.ownerName : name)?.trim().slice(0, LIMITS.nameMaxLength);
7674
+ const clean = body.trim();
7675
+ if (!clean) return;
7676
+ if (!authorName) {
7677
+ card.error = this.t("enterName");
7678
+ this.render();
7679
+ return;
7680
+ }
7681
+ if (clean.length > LIMITS.bodyMaxLength) {
7682
+ card.error = this.t("replyTooLong", { count: LIMITS.bodyMaxLength });
7683
+ this.render();
7684
+ return;
7685
+ }
7686
+ if (!this.isOwner) {
7687
+ this.name = authorName;
7688
+ write(KEYS.name, authorName);
7689
+ }
7690
+ card.sendingReply = true;
7691
+ card.error = void 0;
7692
+ this.render();
7693
+ try {
7694
+ await this.api.createReply({
7695
+ commentId: comment._id,
7696
+ body: clean,
7697
+ authorName,
7698
+ authorSecret: this.secret,
7699
+ sessionToken: this.ownerToken ?? void 0
7700
+ });
7701
+ const box = this.uiLayer.querySelector("[data-focus-key=\"reply\"]");
7702
+ if (box) box.value = "";
7703
+ } catch (error) {
7704
+ card.error = error instanceof NuniApiError ? error.message : this.t("replyFailed");
7705
+ }
7706
+ card.sendingReply = false;
7707
+ this.render();
7708
+ }
7709
+ toggleReaction(comment, targetId, emoji) {
7710
+ this.api.toggleReaction(comment._id, targetId, emoji, this.secret).catch((error) => {
7711
+ if (this.card?.kind === "thread" && this.card.id === comment._id) {
7712
+ this.card.error = error instanceof Error ? error.message : this.t("reactFailed");
7713
+ this.render();
7714
+ }
7715
+ });
7716
+ }
7717
+ async deleteReply(comment, reply) {
7718
+ if (!await this.ask(this.t("confirmDeleteReply"), this.t("delete"))) return;
7719
+ const token = this.ownerToken;
7720
+ const action = this.isMyReply(reply) ? () => this.api.deleteReply(reply._id, this.secret) : token ? () => this.api.removeReply(reply._id, token) : null;
7721
+ if (action) this.act(comment._id, action, this.t("replyDeleted"));
7722
+ }
7723
+ saveReplyEdit(comment, reply, body) {
7724
+ this.act(comment._id, () => this.api.editReply(reply._id, this.secret, body), this.t("saved")).then((ok) => {
7725
+ if (ok && this.card?.kind === "thread") {
7726
+ this.card.replyEditing = void 0;
7727
+ this.render();
7728
+ }
7729
+ });
7730
+ }
5967
7731
  saveEdit(c, body) {
5968
- this.act(c._id, () => this.api.editOwn(c._id, this.secret, body), "Saved");
7732
+ this.act(c._id, () => this.api.editOwn(c._id, this.secret, body), this.t("saved"));
5969
7733
  }
5970
7734
  showToast(message) {
5971
7735
  this.toast = message;
@@ -5977,17 +7741,36 @@ var NuniWidget = class {
5977
7741
  this.render();
5978
7742
  }
5979
7743
  togglePanel(open = !this.panelOpen) {
7744
+ if (open === this.panelOpen) return;
5980
7745
  this.panelOpen = open;
5981
- if (open && window.matchMedia(MOBILE_QUERY).matches) this.card = null;
7746
+ if (open) {
7747
+ this.keepFocus();
7748
+ this.focusNext = ".panel [role=\"tab\"][aria-selected=\"true\"]";
7749
+ if (window.matchMedia(MOBILE_QUERY).matches) this.card = null;
7750
+ }
5982
7751
  this.render();
7752
+ if (!open && !this.card) this.restoreFocus();
5983
7753
  }
5984
7754
  closeCard() {
7755
+ const had = this.card !== null;
5985
7756
  this.card = null;
5986
7757
  this.activeId = null;
5987
7758
  this.render();
7759
+ if (had && !this.panelOpen) this.restoreFocus();
7760
+ }
7761
+ /** Open a comment's thread and move focus into it. */
7762
+ openThread(id) {
7763
+ this.keepFocus();
7764
+ this.activeId = id;
7765
+ this.card = {
7766
+ kind: "thread",
7767
+ id
7768
+ };
7769
+ this.focusNext = "[data-card=\"thread\"] .card-head button";
5988
7770
  }
5989
7771
  render() {
5990
7772
  this.syncOwnerDetail();
7773
+ this.syncThread();
5991
7774
  this.renderPins();
5992
7775
  this.renderUi();
5993
7776
  this.renderHighlight();
@@ -6013,11 +7796,7 @@ var NuniWidget = class {
6013
7796
  e.stopPropagation();
6014
7797
  if (this.card?.kind === "thread" && this.card.id === id) this.closeCard();
6015
7798
  else {
6016
- this.activeId = id;
6017
- this.card = {
6018
- kind: "thread",
6019
- id
6020
- };
7799
+ this.openThread(id);
6021
7800
  this.render();
6022
7801
  }
6023
7802
  });
@@ -6026,7 +7805,12 @@ var NuniWidget = class {
6026
7805
  }
6027
7806
  pin.textContent = initials(comment.authorName);
6028
7807
  pin.style.background = colorFor(comment.authorName);
6029
- pin.setAttribute("aria-label", `Comment by ${comment.authorName}: ${comment.body.slice(0, 80)}`);
7808
+ pin.setAttribute("aria-label", this.t("pinLabel", {
7809
+ name: comment.authorName,
7810
+ body: comment.body.slice(0, 80)
7811
+ }));
7812
+ if (comment.replyCount) pin.dataset.replies = String(comment.replyCount);
7813
+ else delete pin.dataset.replies;
6030
7814
  pin.dataset.status = comment.status;
6031
7815
  pin.dataset.active = String(this.activeId === comment._id);
6032
7816
  pin.dataset.confidence = this.placements.get(comment._id)?.confidence ?? "lost";
@@ -6034,9 +7818,7 @@ var NuniWidget = class {
6034
7818
  this.pinLayer.querySelector(".pin-draft")?.remove();
6035
7819
  if (this.card?.kind === "composer") {
6036
7820
  const { draft } = this.card;
6037
- const r = draft.element.getBoundingClientRect();
6038
- const x = r.left + draft.anchor.offset.x * r.width;
6039
- const y = r.top + draft.anchor.offset.y * r.height;
7821
+ const { x, y } = this.anchorPoint(draft.anchor, draft.element, draft.range);
6040
7822
  const pin = h("div", {
6041
7823
  class: "pin pin-draft pin-pending",
6042
7824
  "aria-hidden": "true"
@@ -6055,6 +7837,8 @@ var NuniWidget = class {
6055
7837
  class: "toast",
6056
7838
  role: "status"
6057
7839
  }, this.toast));
7840
+ const confirm = this.renderConfirm();
7841
+ if (confirm) nodes.push(confirm);
6058
7842
  const focused = this.root.activeElement;
6059
7843
  const focusKey = focused?.dataset.focusKey;
6060
7844
  const selection = focused instanceof HTMLTextAreaElement || focused instanceof HTMLInputElement ? {
@@ -6062,7 +7846,11 @@ var NuniWidget = class {
6062
7846
  end: focused.selectionEnd
6063
7847
  } : null;
6064
7848
  this.uiLayer.replaceChildren(...nodes);
6065
- if (focusKey) {
7849
+ const next = this.focusNext;
7850
+ this.focusNext = null;
7851
+ const wanted = next ? this.uiLayer.querySelector(next) : null;
7852
+ if (wanted) wanted.focus();
7853
+ else if (focusKey) {
6066
7854
  const next = this.uiLayer.querySelector(`[data-focus-key="${focusKey}"]`);
6067
7855
  next?.focus();
6068
7856
  if (selection && (next instanceof HTMLTextAreaElement || next instanceof HTMLInputElement)) next.setSelectionRange(selection.start, selection.end);
@@ -6074,20 +7862,22 @@ var NuniWidget = class {
6074
7862
  return h("div", {
6075
7863
  class: "toolbar",
6076
7864
  role: "toolbar",
6077
- "aria-label": "Nuni comments"
7865
+ "aria-label": this.t("toolbar")
6078
7866
  }, h("button", {
6079
7867
  class: "tb-btn",
6080
7868
  type: "button",
6081
7869
  "aria-pressed": String(this.picking),
6082
- "aria-label": this.picking ? "Cancel adding a comment" : "Add a comment",
6083
- title: "Add a comment (C)",
7870
+ "data-focus-key": "tb-comment",
7871
+ "aria-label": this.picking ? this.t("cancelAdding") : this.t("addComment"),
7872
+ title: this.withKey("addComment", "addCommentKey"),
6084
7873
  onclick: () => this.setPicking(!this.picking)
6085
- }, icon(ICONS.comment), h("span", { class: "tb-label" }, this.picking ? "Pick an element" : "Comment")), h("button", {
7874
+ }, icon(ICONS.comment), h("span", { class: "tb-label" }, this.picking ? this.t("pickElement") : this.config.label ?? this.t("comment"))), h("button", {
6086
7875
  class: "tb-btn",
6087
7876
  type: "button",
6088
7877
  "aria-pressed": String(this.panelOpen),
6089
- "aria-label": `Comments on this page: ${open} open`,
6090
- title: "All comments",
7878
+ "data-focus-key": "tb-panel",
7879
+ "aria-label": this.t("openCount", { count: open }),
7880
+ title: this.t("allComments"),
6091
7881
  onclick: () => this.togglePanel()
6092
7882
  }, icon(ICONS.list), h("span", { class: "tb-count" }, String(open))));
6093
7883
  }
@@ -6101,72 +7891,87 @@ var NuniWidget = class {
6101
7891
  const item = (c) => h("button", {
6102
7892
  class: "item",
6103
7893
  type: "button",
7894
+ "data-focus-key": `item-${c._id}`,
6104
7895
  onclick: () => this.focusComment(c._id)
6105
7896
  }, h("div", { class: "row" }, h("span", {
6106
7897
  class: "avatar",
6107
7898
  style: `background:${colorFor(c.authorName)}`
6108
- }, initials(c.authorName)), h("span", { class: "author" }, c.authorName), h("span", { class: "meta" }, timeAgo(c.createdAt)), c.page.origin !== location.origin ? h("span", {
7899
+ }, initials(c.authorName)), h("span", { class: "author" }, c.authorName), h("span", { class: "meta" }, this.i18n.timeAgo(c.createdAt) + (c.replyCount ? ` · ${this.t("replyCount", { count: c.replyCount })}` : "")), c.page.origin !== location.origin ? h("span", {
6109
7900
  class: "badge",
6110
7901
  title: c.page.origin
6111
- }, new URL(c.page.origin).host) : null), h("div", { class: "item-text" }, c.body));
7902
+ }, new URL(c.page.origin).host) : null), c.anchor.quote ? h("div", { class: "item-quote" }, c.anchor.quote.exact) : null, h("div", { class: "item-text" }, c.body));
6112
7903
  const children = [];
6113
- if (!this.loaded) children.push(h("div", { class: "empty" }, "Loading…"));
6114
- else if (!list.length) children.push(h("div", { class: "empty" }, this.tab === "open" ? "No open comments on this page. Press C to add one." : "Nothing resolved yet."));
7904
+ if (!this.loaded) children.push(h("div", { class: "empty" }, this.t("loading")));
7905
+ else if (!list.length) children.push(h("div", { class: "empty" }, this.tab === "open" ? this.withKey("noOpen", "noOpenKey") : this.t("noResolved")));
6115
7906
  else {
6116
7907
  children.push(...placed.map(item));
6117
7908
  if (lost.length) {
6118
- children.push(h("div", { class: "section-label" }, "Couldn't find on this page"));
7909
+ children.push(h("div", { class: "section-label" }, this.t("notFoundHere")));
6119
7910
  children.push(...lost.map(item));
6120
7911
  }
6121
7912
  }
6122
7913
  if (otherPages.length) {
6123
- children.push(h("div", { class: "section-label" }, "Other pages"));
7914
+ children.push(h("div", { class: "section-label" }, this.t("otherPages")));
6124
7915
  for (const p of otherPages.slice(0, 20)) {
6125
- const href = p.path.includes("#") ? location.origin + p.path : location.origin + p.path + location.search;
6126
- children.push(h("a", {
7916
+ const href = otherPageHref(p.path, location);
7917
+ children.push(h(href === null ? "div" : "a", href === null ? { class: "item" } : {
6127
7918
  class: "item",
6128
7919
  href
6129
- }, h("div", { class: "row" }, h("span", { class: "author" }, p.path), h("span", { class: "spacer" }), h("span", { class: "badge" }, `${p.count} open`))));
7920
+ }, h("div", { class: "row" }, h("span", { class: "author" }, p.path), h("span", { class: "spacer" }), h("span", { class: "badge" }, this.t("openOnPage", { count: p.count })))));
6130
7921
  }
6131
7922
  }
6132
7923
  return h("div", {
6133
7924
  class: "panel",
6134
7925
  role: "dialog",
6135
- "aria-label": "Comments"
6136
- }, h("div", { class: "panel-head" }, h("div", { class: "panel-title" }, "Comments"), h("button", {
7926
+ "aria-label": this.t("comments")
7927
+ }, h("div", { class: "panel-head" }, h("div", { class: "panel-title" }, this.t("comments")), h("button", {
6137
7928
  class: "btn btn-ghost btn-icon",
6138
7929
  type: "button",
6139
- "aria-label": "Close",
7930
+ "aria-label": this.t("close"),
6140
7931
  onclick: () => this.togglePanel(false)
6141
7932
  }, icon(ICONS.close))), h("div", {
6142
7933
  class: "tabs",
6143
- role: "tablist"
6144
- }, h("button", {
6145
- class: "tab",
6146
- role: "tab",
6147
- type: "button",
6148
- "aria-selected": String(this.tab === "open"),
6149
- onclick: () => {
6150
- this.tab = "open";
7934
+ role: "tablist",
7935
+ "aria-label": this.t("comments"),
7936
+ onkeydown: (e) => {
7937
+ const key = e.key;
7938
+ if (![
7939
+ "ArrowLeft",
7940
+ "ArrowRight",
7941
+ "Home",
7942
+ "End"
7943
+ ].includes(key)) return;
7944
+ e.preventDefault();
7945
+ this.tab = key === "Home" ? "open" : key === "End" ? "resolved" : this.tab === "open" ? "resolved" : "open";
7946
+ this.focusNext = `[data-focus-key="tab-${this.tab}"]`;
6151
7947
  this.render();
6152
7948
  }
6153
- }, `Open (${open.length})`), h("button", {
7949
+ }, ["open", "resolved"].map((tab) => h("button", {
6154
7950
  class: "tab",
6155
7951
  role: "tab",
6156
7952
  type: "button",
6157
- "aria-selected": String(this.tab === "resolved"),
7953
+ id: `nuni-tab-${tab}`,
7954
+ "data-focus-key": `tab-${tab}`,
7955
+ "aria-selected": String(this.tab === tab),
7956
+ "aria-controls": "nuni-panel-list",
7957
+ tabindex: this.tab === tab ? 0 : -1,
6158
7958
  onclick: () => {
6159
- this.tab = "resolved";
7959
+ this.tab = tab;
6160
7960
  this.render();
6161
7961
  }
6162
- }, `Resolved (${resolved.length})`)), h("div", { class: "panel-list" }, ...children), this.status && !this.status.claimed && !this.isOwner ? this.renderPanelFooter() : null);
7962
+ }, tab === "open" ? this.t("tabOpen", { count: open.length }) : this.t("tabResolved", { count: resolved.length })))), h("div", {
7963
+ class: "panel-list",
7964
+ id: "nuni-panel-list",
7965
+ role: "tabpanel",
7966
+ "aria-labelledby": `nuni-tab-${this.tab}`
7967
+ }, ...children), this.status && !this.status.claimed && !this.isOwner ? this.renderPanelFooter() : null);
6163
7968
  }
6164
7969
  renderPanelFooter() {
6165
- return h("div", { class: "panel-foot" }, h("div", { class: "row" }, h("span", { class: "meta" }, "Own this website?"), h("span", { class: "spacer" }), h("button", {
7970
+ return h("div", { class: "panel-foot" }, h("div", { class: "row" }, h("span", { class: "meta" }, this.t("ownSite")), h("span", { class: "spacer" }), h("button", {
6166
7971
  class: "btn",
6167
7972
  type: "button",
6168
7973
  onclick: () => this.openClaim()
6169
- }, icon(ICONS.github), "Claim Nuni")));
7974
+ }, icon(ICONS.github), this.t("claim"))));
6170
7975
  }
6171
7976
  renderCard() {
6172
7977
  const card = this.card;
@@ -6177,19 +7982,19 @@ var NuniWidget = class {
6177
7982
  const needsName = !this.name;
6178
7983
  const nameInput = h("input", {
6179
7984
  class: "field",
6180
- placeholder: "Your name",
7985
+ placeholder: this.t("yourName"),
6181
7986
  value: this.name,
6182
7987
  maxlength: LIMITS.nameMaxLength,
6183
7988
  autocomplete: "name",
6184
7989
  "data-focus-key": "name",
6185
- "aria-label": "Your name"
7990
+ "aria-label": this.t("yourName")
6186
7991
  });
6187
7992
  const textarea = h("textarea", {
6188
7993
  class: "field",
6189
- placeholder: "Leave a comment",
7994
+ placeholder: this.t("leaveComment"),
6190
7995
  maxlength: LIMITS.bodyMaxLength,
6191
7996
  "data-focus-key": "body",
6192
- "aria-label": "Comment"
7997
+ "aria-label": this.t("comment")
6193
7998
  });
6194
7999
  const prev = this.uiLayer.querySelector("[data-focus-key=\"body\"]");
6195
8000
  if (prev) textarea.value = prev.value;
@@ -6219,28 +8024,28 @@ var NuniWidget = class {
6219
8024
  this.name = "";
6220
8025
  this.render();
6221
8026
  }
6222
- }, "Not you?")), textarea, draft.screenshot ? h("div", { class: "shot-preview" }, h("img", {
8027
+ }, this.t("notYou"))), draft.anchor.quote ? h("blockquote", { class: "quote" }, draft.anchor.quote.exact) : null, textarea, draft.screenshot ? h("div", { class: "shot-preview" }, h("img", {
6223
8028
  src: draft.screenshot.preview,
6224
- alt: "Screenshot that will be attached"
6225
- }), h("div", { class: "shot-note" }, h("span", {}, "Screenshot attached. Only the site owner sees it."), h("span", { class: "spacer" }), h("button", {
8029
+ alt: this.t("screenshotAlt")
8030
+ }), h("div", { class: "shot-note" }, h("span", {}, this.t("screenshotNote")), h("span", { class: "spacer" }), h("button", {
6226
8031
  class: "link",
6227
8032
  type: "button",
6228
8033
  onclick: () => {
6229
8034
  draft.screenshot = void 0;
6230
8035
  this.render();
6231
8036
  }
6232
- }, "Remove"))) : null, draft.error ? h("div", {
8037
+ }, this.t("remove")))) : null, draft.error ? h("div", {
6233
8038
  class: "error",
6234
8039
  role: "alert"
6235
- }, draft.error) : null, h("div", { class: "row" }, h("span", { class: "kbd" }, "⌘ + Enter"), h("span", { class: "spacer" }), h("button", {
8040
+ }, draft.error) : null, h("div", { class: "row" }, h("span", { class: "kbd" }, this.t("send", { key: this.i18n.mod })), h("span", { class: "spacer" }), h("button", {
6236
8041
  class: "btn btn-ghost",
6237
8042
  type: "button",
6238
8043
  onclick: () => this.closeCard()
6239
- }, "Cancel"), h("button", {
8044
+ }, this.t("cancel")), h("button", {
6240
8045
  class: "btn btn-primary",
6241
8046
  type: "submit",
6242
8047
  disabled: Boolean(draft.sending)
6243
- }, icon(ICONS.send), draft.sending ? "Posting…" : "Post"))));
8048
+ }, icon(ICONS.send), draft.sending ? this.t("posting") : this.t("post")))));
6244
8049
  }
6245
8050
  const comment = this.comments.find((c) => c._id === card.id);
6246
8051
  if (!comment) return null;
@@ -6263,39 +8068,39 @@ var NuniWidget = class {
6263
8068
  card.editing = false;
6264
8069
  this.render();
6265
8070
  }
6266
- }, "Cancel"), h("button", {
8071
+ }, this.t("cancel")), h("button", {
6267
8072
  class: "btn btn-primary",
6268
8073
  type: "button",
6269
8074
  disabled: Boolean(card.busy),
6270
8075
  onclick: () => this.saveEdit(comment, textarea.value)
6271
- }, "Save")));
8076
+ }, this.t("save"))));
6272
8077
  } else body = h("div", { class: "card-body" }, h("div", { class: "card-head" }, h("span", {
6273
8078
  class: "avatar",
6274
8079
  style: `background:${colorFor(comment.authorName)}`
6275
- }, initials(comment.authorName)), h("span", { class: "author" }, comment.authorName), h("span", { class: "meta" }, timeAgo(comment.createdAt) + (comment.editedAt ? " · edited" : "")), h("span", { class: "spacer" }), h("button", {
8080
+ }, initials(comment.authorName)), h("span", { class: "author" }, comment.authorName), h("span", { class: "meta" }, this.i18n.timeAgo(comment.createdAt) + (comment.editedAt ? ` · ${this.t("edited")}` : "")), h("span", { class: "spacer" }), h("button", {
6276
8081
  class: "btn btn-ghost btn-icon",
6277
8082
  type: "button",
6278
- "aria-label": "Close",
8083
+ "aria-label": this.t("close"),
6279
8084
  onclick: () => this.closeCard()
6280
- }, icon(ICONS.close))), h("div", { class: "comment-body" }, comment.body), this.ownerDetail?._id === comment._id && this.ownerDetail.screenshotUrl ? h("a", {
8085
+ }, icon(ICONS.close))), comment.anchor.quote ? h("blockquote", { class: "quote" }, comment.anchor.quote.exact) : null, h("div", { class: "comment-body" }, comment.body), this.ownerDetail?._id === comment._id && this.ownerDetail.screenshotUrl ? h("a", {
6281
8086
  class: "shot",
6282
8087
  href: this.ownerDetail.screenshotUrl,
6283
8088
  target: "_blank",
6284
8089
  rel: "noreferrer",
6285
- title: "Open the screenshot"
8090
+ title: this.t("openScreenshot")
6286
8091
  }, h("img", {
6287
8092
  src: this.ownerDetail.screenshotUrl,
6288
- alt: "Screenshot taken when the comment was left"
6289
- })) : null, h("div", { class: "row" }, comment.status === "resolved" ? h("span", { class: "badge badge-ok" }, icon(ICONS.check), "Resolved") : null, comment.page.origin !== location.origin ? h("span", {
8093
+ alt: this.t("screenshotTaken")
8094
+ })) : null, h("div", { class: "row" }, comment.status === "resolved" ? h("span", { class: "badge badge-ok" }, icon(ICONS.check), this.t("resolved")) : null, comment.page.origin !== location.origin ? h("span", {
6290
8095
  class: "badge",
6291
- title: `Left on ${comment.page.origin}`
8096
+ title: this.t("leftOn", { origin: comment.page.origin })
6292
8097
  }, new URL(comment.page.origin).host) : null, placement?.confidence === "low" ? h("span", {
6293
8098
  class: "badge",
6294
- title: "The page changed; this pin is a best guess"
6295
- }, "Approximate") : null, !placement?.element ? h("span", {
8099
+ title: this.t("approximateHint")
8100
+ }, this.t("approximate")) : null, !placement?.element ? h("span", {
6296
8101
  class: "badge",
6297
- title: `Originally on: ${comment.anchor.text || comment.anchor.tag}`
6298
- }, "Element not found") : null), card.error ? h("div", {
8102
+ title: this.t("originallyOn", { text: comment.anchor.text || comment.anchor.tag })
8103
+ }, this.t("notFound")) : null), this.renderReactions(comment, comment._id), card.error ? h("div", {
6299
8104
  class: "error",
6300
8105
  role: "alert"
6301
8106
  }, card.error) : null);
@@ -6306,27 +8111,27 @@ var NuniWidget = class {
6306
8111
  type: "button",
6307
8112
  disabled: Boolean(card.busy),
6308
8113
  onclick: () => this.resolveComment(comment)
6309
- }, icon(ICONS.check), "Resolve") : h("button", {
8114
+ }, icon(ICONS.check), this.t("resolve")) : h("button", {
6310
8115
  class: "btn",
6311
8116
  type: "button",
6312
8117
  disabled: Boolean(card.busy),
6313
8118
  onclick: () => this.reopenComment(comment)
6314
- }, icon(ICONS.undo), "Reopen"));
8119
+ }, icon(ICONS.undo), this.t("reopen")));
6315
8120
  actions.push(h("span", { class: "spacer" }));
6316
8121
  const contextPending = owner && !(this.ownerDetailLoaded && this.ownerDetailId === comment._id);
6317
8122
  actions.push(h("button", {
6318
8123
  class: "btn btn-ghost btn-icon",
6319
8124
  type: "button",
6320
- "aria-label": "Copy for agent",
6321
- title: contextPending ? "Loading page context…" : "Copy for your coding agent",
8125
+ "aria-label": this.t("copyForAgent"),
8126
+ title: contextPending ? this.t("loadingContext") : this.t("copyForAgentHint"),
6322
8127
  disabled: contextPending,
6323
8128
  onclick: () => this.copyForAgent(comment)
6324
8129
  }, icon(ICONS.bot)));
6325
8130
  if (mine) actions.push(h("button", {
6326
8131
  class: "btn btn-ghost btn-icon",
6327
8132
  type: "button",
6328
- "aria-label": "Edit",
6329
- title: "Edit",
8133
+ "aria-label": this.t("edit"),
8134
+ title: this.t("edit"),
6330
8135
  onclick: () => {
6331
8136
  card.editing = true;
6332
8137
  this.render();
@@ -6335,18 +8140,164 @@ var NuniWidget = class {
6335
8140
  if (mine || owner) actions.push(h("button", {
6336
8141
  class: "btn btn-ghost btn-icon btn-danger",
6337
8142
  type: "button",
6338
- "aria-label": "Delete",
6339
- title: "Delete",
8143
+ "aria-label": this.t("delete"),
8144
+ title: this.t("delete"),
8145
+ "data-focus-key": "delete",
6340
8146
  disabled: Boolean(card.busy),
6341
- onclick: () => this.deleteComment(comment)
8147
+ onclick: () => void this.deleteComment(comment)
6342
8148
  }, icon(ICONS.trash)));
6343
8149
  }
6344
8150
  return h("div", {
6345
8151
  class: cls,
6346
8152
  "data-card": "thread",
6347
8153
  role: "dialog",
6348
- "aria-label": `Comment by ${comment.authorName}`
6349
- }, body, actions.length ? h("div", { class: "actions" }, ...actions) : null);
8154
+ "aria-label": this.t("commentBy", { name: comment.authorName })
8155
+ }, body, actions.length ? h("div", { class: "actions" }, ...actions) : null, card.editing ? null : this.renderThread(comment));
8156
+ }
8157
+ /** Emoji chips for one message, plus a picker to add one. */
8158
+ renderReactions(comment, targetId) {
8159
+ const card = this.card;
8160
+ if (card?.kind !== "thread") return null;
8161
+ const summaries = this.threadId === comment._id && this.thread ? this.thread.reactions.filter((r) => r.targetId === targetId) : [];
8162
+ const open = card.picker === targetId;
8163
+ return h("div", { class: "reactions" }, ...summaries.map((r) => {
8164
+ const mine = r.authorKeyHashes.includes(this.secretHash);
8165
+ return h("button", {
8166
+ class: "reaction",
8167
+ type: "button",
8168
+ "aria-pressed": String(mine),
8169
+ "aria-label": `${r.emoji} ${r.count}${mine ? ", including you" : ""}`,
8170
+ onclick: () => this.toggleReaction(comment, targetId, r.emoji)
8171
+ }, `${r.emoji} ${r.count}`);
8172
+ }), h("button", {
8173
+ class: "reaction reaction-add",
8174
+ type: "button",
8175
+ "aria-label": this.t("addReaction"),
8176
+ "aria-expanded": String(open),
8177
+ title: this.t("addReaction"),
8178
+ onclick: () => {
8179
+ card.picker = open ? null : targetId;
8180
+ this.render();
8181
+ }
8182
+ }, icon(ICONS.smile)), open ? h("div", {
8183
+ class: "reaction-picker",
8184
+ role: "group",
8185
+ "aria-label": this.t("reactions")
8186
+ }, ...REACTIONS.map((emoji) => h("button", {
8187
+ class: "reaction",
8188
+ type: "button",
8189
+ "aria-label": this.t("reactWith", { emoji }),
8190
+ onclick: () => {
8191
+ card.picker = null;
8192
+ this.toggleReaction(comment, targetId, emoji);
8193
+ this.render();
8194
+ }
8195
+ }, emoji))) : null);
8196
+ }
8197
+ /** The replies under a comment and the box to add one. */
8198
+ renderThread(comment) {
8199
+ const card = this.card;
8200
+ if (card?.kind !== "thread") return null;
8201
+ const replies = this.threadId === comment._id ? this.thread?.replies ?? [] : [];
8202
+ const owner = this.isOwner;
8203
+ const items = replies.map((reply) => {
8204
+ const mine = this.isMyReply(reply);
8205
+ if (card.replyEditing === reply._id) {
8206
+ const box = h("textarea", {
8207
+ class: "field",
8208
+ maxlength: LIMITS.bodyMaxLength,
8209
+ "data-focus-key": `reply-edit-${reply._id}`,
8210
+ "aria-label": this.t("editReply")
8211
+ });
8212
+ const prev = this.uiLayer.querySelector(`[data-focus-key="reply-edit-${reply._id}"]`);
8213
+ box.value = prev ? prev.value : reply.body;
8214
+ return h("div", { class: "reply" }, box, h("div", { class: "row" }, h("span", { class: "spacer" }), h("button", {
8215
+ class: "btn btn-ghost",
8216
+ type: "button",
8217
+ onclick: () => {
8218
+ card.replyEditing = void 0;
8219
+ this.render();
8220
+ }
8221
+ }, this.t("cancel")), h("button", {
8222
+ class: "btn btn-primary",
8223
+ type: "button",
8224
+ disabled: Boolean(card.busy),
8225
+ onclick: () => this.saveReplyEdit(comment, reply, box.value)
8226
+ }, this.t("save"))));
8227
+ }
8228
+ return h("div", {
8229
+ class: "reply",
8230
+ "data-reply": reply._id
8231
+ }, h("div", { class: "card-head" }, h("span", {
8232
+ class: "avatar avatar-sm",
8233
+ style: `background:${colorFor(reply.authorName)}`
8234
+ }, initials(reply.authorName)), h("span", { class: "author" }, reply.authorName), reply.isOwner ? h("span", { class: "badge badge-owner" }, this.t("owner")) : null, h("span", { class: "meta" }, this.i18n.timeAgo(reply.createdAt) + (reply.editedAt ? ` · ${this.t("edited")}` : "")), h("span", { class: "spacer" }), mine ? h("button", {
8235
+ class: "btn btn-ghost btn-icon btn-xs",
8236
+ type: "button",
8237
+ "aria-label": this.t("editReply"),
8238
+ title: this.t("edit"),
8239
+ onclick: () => {
8240
+ card.replyEditing = reply._id;
8241
+ this.render();
8242
+ }
8243
+ }, icon(ICONS.edit)) : null, mine || owner ? h("button", {
8244
+ class: "btn btn-ghost btn-icon btn-xs btn-danger",
8245
+ type: "button",
8246
+ "aria-label": this.t("deleteReply"),
8247
+ title: this.t("delete"),
8248
+ "data-focus-key": `delete-${reply._id}`,
8249
+ disabled: Boolean(card.busy),
8250
+ onclick: () => void this.deleteReply(comment, reply)
8251
+ }, icon(ICONS.trash)) : null), h("div", { class: "comment-body" }, reply.body), this.renderReactions(comment, reply._id));
8252
+ });
8253
+ const needsName = !owner && !this.name;
8254
+ const nameInput = h("input", {
8255
+ class: "field",
8256
+ placeholder: this.t("yourName"),
8257
+ value: this.name,
8258
+ maxlength: LIMITS.nameMaxLength,
8259
+ autocomplete: "name",
8260
+ "data-focus-key": "reply-name",
8261
+ "aria-label": this.t("yourName")
8262
+ });
8263
+ const prevName = this.uiLayer.querySelector("[data-focus-key=\"reply-name\"]");
8264
+ if (prevName) nameInput.value = prevName.value;
8265
+ const box = h("textarea", {
8266
+ class: "field field-reply",
8267
+ placeholder: replies.length ? this.t("reply") : this.t("replyFirst"),
8268
+ maxlength: LIMITS.bodyMaxLength,
8269
+ "data-focus-key": "reply",
8270
+ "aria-label": this.t("reply")
8271
+ });
8272
+ const prev = this.uiLayer.querySelector("[data-focus-key=\"reply\"]");
8273
+ if (prev) box.value = prev.value;
8274
+ const send = () => void this.sendReply(comment, box.value, needsName ? nameInput.value : this.name);
8275
+ box.addEventListener("keydown", (e) => {
8276
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
8277
+ e.preventDefault();
8278
+ send();
8279
+ }
8280
+ });
8281
+ return h("div", { class: "thread" }, items.length ? h("div", {
8282
+ class: "replies",
8283
+ role: "list",
8284
+ "aria-label": this.t("replyCount", { count: items.length })
8285
+ }, ...items.map((item) => {
8286
+ item.setAttribute("role", "listitem");
8287
+ return item;
8288
+ })) : null, h("form", {
8289
+ class: "reply-form",
8290
+ onsubmit: (e) => {
8291
+ e.preventDefault();
8292
+ send();
8293
+ }
8294
+ }, needsName ? nameInput : null, box, h("button", {
8295
+ class: "btn btn-primary btn-icon",
8296
+ type: "submit",
8297
+ "aria-label": this.t("sendReply"),
8298
+ title: this.t("sendReply"),
8299
+ disabled: Boolean(card.sendingReply)
8300
+ }, icon(ICONS.send))));
6350
8301
  }
6351
8302
  positionCard() {
6352
8303
  const el = this.uiLayer.querySelector("[data-card]");
@@ -6354,11 +8305,7 @@ var NuniWidget = class {
6354
8305
  let point = null;
6355
8306
  if (this.card?.kind === "composer") {
6356
8307
  const { draft } = this.card;
6357
- const r = draft.element.getBoundingClientRect();
6358
- point = {
6359
- x: r.left + draft.anchor.offset.x * r.width,
6360
- y: r.top + draft.anchor.offset.y * r.height
6361
- };
8308
+ point = this.anchorPoint(draft.anchor, draft.element, draft.range);
6362
8309
  } else if (this.card?.kind === "thread") {
6363
8310
  const comment = this.comments.find((c) => this.card?.kind === "thread" && c._id === this.card.id);
6364
8311
  point = comment ? this.pinPositions.get(comment._id) ?? this.pinPoint(comment) : null;
@@ -6374,8 +8321,9 @@ var NuniWidget = class {
6374
8321
  top = point.y - 28;
6375
8322
  if (left + width > vw - 12) left = point.x - width - 12;
6376
8323
  } else {
6377
- left = vw - width - 16;
6378
- top = vh - height - 72;
8324
+ const { position } = this.config;
8325
+ left = position.endsWith("left") ? 16 : vw - width - 16;
8326
+ top = position.startsWith("top") ? 72 : vh - height - 72;
6379
8327
  }
6380
8328
  left = Math.max(12, Math.min(left, vw - width - 12));
6381
8329
  top = Math.max(12, Math.min(top, vh - height - 12));
@@ -6470,7 +8418,7 @@ function unmount(state) {
6470
8418
  * down the widget it was created for.
6471
8419
  */
6472
8420
  function init(options) {
6473
- return mount(options, { loadScreenshot: () => import("./screenshot-B2x2-nI_.js").then((m) => m.captureScreenshot) });
8421
+ return mount(options, { loadScreenshot: () => import("./screenshot-DcJ_SzKF.js").then((m) => m.captureScreenshot) });
6474
8422
  }
6475
8423
  //#endregion
6476
- export { VERSION, init };
8424
+ export { VERSION, init, EN as messages };