@comty.io/core 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,17 +10,19 @@ Comments use the pathname and hash routes starting with `#/` or `#!/`. Plain fra
10
10
 
11
11
  Target matching combines content, attributes and surrounding structure. Small changes inside a container can preserve its comments; ambiguous matches do not place a pin. The sidebar shows **Target missing** when no target is found and **Target uncertain** when placement cannot be determined reliably. Both still open the full thread. Closed or unmounted known overlays do not count as deleted elements, and their comments never attach to matching content outside that overlay.
12
12
 
13
+ New anchors exclude editable content from inputs, textareas, selects and rich-text editors, including when comparing their surrounding containers. Repeated fields use their attributes and question context to distinguish them. React-generated IDs are not treated as permanent identities. Existing comments are resolved again on reload without rewriting their stored anchors.
14
+
13
15
  Run `npm run test:anchors` for route, reload, ambiguity and overlay regressions.
14
16
 
15
17
  ## Comment screenshots
16
18
 
17
- Posting immediately displays a pending comment and sends the JSON request. After the API confirms the save, the SDK captures and uploads the viewport separately. Screenshot failures do not roll back comments; save failures restore the draft for retry. Screenshot annotations reuse the real Comty pin and hover card (avatar, author, date and content). They are measured in an invisible, inert host, then cloned with their computed styles and embedded resources into the screenshot. The temporary host is removed after cloning and never appears on screen.
19
+ Posting immediately displays a pending comment, captures the viewport, then sends the comment and screenshot in one multipart request. The API stores the screenshot before creating the comment. Capture or save failures restore the draft for retry instead of silently creating a comment without an image. Screenshot annotations reuse the real Comty pin and hover card (avatar, author, date and content). They are measured in an invisible, inert host, then cloned with their computed styles and embedded resources into the screenshot. The temporary host is removed after cloning and never appears on screen.
18
20
 
19
21
  The bundled capture engine is based on `html-to-image` 1.11.13 (MIT), not SnapDOM. It embeds web fonts, responsive images, CSS backgrounds and pseudo-elements, preserves scroll offsets, and yields during DOM traversal. Each font face keeps its source when selecting WOFF2, including consecutive subset and weight rules. Transparent page backgrounds are composited over the browser's light or dark canvas so the image viewer cannot tint the screenshot. Offscreen leaf boxes keep their layout dimensions without their rendering cost. PNG is preferred for text; large images fall back to JPEG. Captures are capped at 2× resolution / 2560 pixels per side and have a 15-second processing deadline.
20
22
 
21
23
  Resources are fetched directly first. Public cross-origin assets blocked by CORS can use `/api/sdk/capture-resource`, authenticated with the SDK token and restricted to the project's allowed domain. The API validates and pins public DNS addresses on every redirect, limits bytes/concurrency/time, and never forwards credentials to resource hosts. Deploy the API alongside the SDK to enable this fallback.
22
24
 
23
- The capture never locks or restyles the live page. If the URL, viewport or page scroll changes before cloning finishes, it skips that screenshot rather than attaching a mismatched image. Browser restrictions still apply to private resources, closed shadow roots and embedded frames; inaccessible frames retain an empty layout box. The final SVG serialization/rasterization still uses browser APIs on the main thread, so zero processing cost on every possible site cannot be guaranteed.
25
+ The capture never locks or restyles the live page. If the URL, viewport or page scroll changes before cloning finishes, posting fails and restores the draft for retry rather than attaching a mismatched image. Browser restrictions still apply to private resources, closed shadow roots and embedded frames; inaccessible frames retain an empty layout box. The final SVG serialization/rasterization still uses browser APIs on the main thread, so zero processing cost on every possible site cannot be guaranteed.
24
26
 
25
27
  `npm run build` reproducibly bundles the pinned library with small checked hooks for cooperative scheduling, resource fetching, cloning and exact font sizes; its MIT notice is included. No runtime package install is needed by SDK consumers. `npm run test:capture` runs Chrome/Chromium visual and posting tests; install Chromium with `npx playwright install chromium` on machines without Chrome. Comparison PNGs are written to `test-results/`.
26
28
 
package/comty-sdk.js CHANGED
@@ -1629,6 +1629,11 @@ function createAnchorMatcher({
1629
1629
  getPresentationLayer,
1630
1630
  isComtyNode,
1631
1631
  }) {
1632
+ function getStableAnchorId(id) {
1633
+ // React useId values change with mount order, including Base UI and Radix IDs.
1634
+ return /^(?:(?:radix|base-ui)-)?(?:_r_[\w-]+_|:r[\w-]+:)$/i.test(id || "") ? "" : (id || "");
1635
+ }
1636
+
1632
1637
  function includesText(haystack, needle) {
1633
1638
  if (!haystack || !needle) return false;
1634
1639
  return haystack.toLowerCase().includes(needle.toLowerCase());
@@ -1652,12 +1657,13 @@ function createAnchorMatcher({
1652
1657
  }
1653
1658
 
1654
1659
  let score = 0;
1655
- const text = getElementTextSignature(el);
1656
- const ownText = getElementOwnTextSignature(el);
1660
+ const text = getElementTextSignature(el, fingerprint.textMode === "static");
1661
+ const ownText = getElementOwnTextSignature(el, fingerprint.textMode === "static");
1657
1662
 
1658
1663
  score += 20;
1659
- if (fingerprint.id) {
1660
- score += el.id.slice(0, 160) === fingerprint.id ? 70 : -30;
1664
+ const stableId = getStableAnchorId(fingerprint.id);
1665
+ if (stableId) {
1666
+ score += el.id.slice(0, 160) === stableId ? 70 : -30;
1661
1667
  }
1662
1668
  const exactAttributes = [
1663
1669
  ["role", "role", 24],
@@ -1752,7 +1758,7 @@ function createAnchorMatcher({
1752
1758
 
1753
1759
  function scoreSibling(el, fingerprint) {
1754
1760
  if (!fingerprint) return 0;
1755
- const sibling = getSiblingFingerprint(el);
1761
+ const sibling = getSiblingFingerprint(el, fingerprint.textMode === "static");
1756
1762
  if (!sibling || sibling.tag !== fingerprint.tag) return -4;
1757
1763
  let score = 4;
1758
1764
  if (fingerprint.ariaLabel && sibling.ariaLabel === fingerprint.ariaLabel) {
@@ -1791,7 +1797,7 @@ function createAnchorMatcher({
1791
1797
  score += Math.min(ancestorScore, 40) * (index === 0 ? 0.5 : 0.2);
1792
1798
  }
1793
1799
  // Distinguish repeated cards by their section, even when dimensions match.
1794
- if (index < 3 && ancestors[index].text && getElementTextSignature(parent) === ancestors[index].text) score += 18;
1800
+ if (ancestors[index].text && getElementTextSignature(parent, ancestors[index].textMode === "static") === ancestors[index].text) score += 18;
1795
1801
  parent = parent.parentElement;
1796
1802
  }
1797
1803
 
@@ -1812,14 +1818,17 @@ function createAnchorMatcher({
1812
1818
  for (const [key, attribute] of Object.entries(attributes)) {
1813
1819
  if (fingerprint[key] && getAttributeSignature(el, attribute) !== fingerprint[key]) return false;
1814
1820
  }
1815
- if (fingerprint.id && el.id !== fingerprint.id) return false;
1821
+ const stableId = getStableAnchorId(fingerprint.id);
1822
+ if (stableId && el.id !== stableId) return false;
1816
1823
  if (fingerprint.testId && !["data-testid", "data-test", "data-cy"].some(name => getAttributeSignature(el, name) === fingerprint.testId)) return false;
1817
- const stableIdentity = (fingerprint.id && el.id === fingerprint.id) ||
1824
+ const stableIdentity = (stableId && el.id === stableId) ||
1818
1825
  (fingerprint.testId && ["data-testid", "data-test", "data-cy"].some(name => getAttributeSignature(el, name) === fingerprint.testId));
1819
- if (fingerprint.ownText && getElementOwnTextSignature(el) !== fingerprint.ownText && !(allowTextChanges && stableIdentity)) return false;
1826
+ const staticText = fingerprint.textMode === "static";
1827
+ const text = getElementTextSignature(el, staticText);
1828
+ if (fingerprint.ownText && getElementOwnTextSignature(el, staticText) !== fingerprint.ownText && !(allowTextChanges && stableIdentity)) return false;
1820
1829
  // Container text is useful for matching remounted drawers and dialogs.
1821
- if (!ignoreDescendantText && !fingerprint.ownText && fingerprint.text && getElementTextSignature(el) !== fingerprint.text && !(allowTextChanges && stableIdentity)) {
1822
- if (!allowTextChanges || !el.children.length || !similarText(getElementTextSignature(el), fingerprint.text)) return false;
1830
+ if (!ignoreDescendantText && !fingerprint.ownText && fingerprint.text && text !== fingerprint.text && !(allowTextChanges && stableIdentity)) {
1831
+ if (!allowTextChanges || !el.children.length || !similarText(text, fingerprint.text)) return false;
1823
1832
  }
1824
1833
  return true;
1825
1834
  }
@@ -1852,10 +1861,11 @@ function createAnchorMatcher({
1852
1861
  .map(el => ({ el, score: scoreAnchorCandidate(el, context) + (context.version === 1 && el === exact ? 16 : 0) }))
1853
1862
  .sort((a, b) => b.score - a.score);
1854
1863
  if (context.candidateCount > 1) {
1864
+ // Creation counts full context matches, not every element with the same text.
1855
1865
  const peers = candidates.filter(el => matchesAnchorIdentity(el, context.target));
1856
- if (peers.length !== context.candidateCount) {
1866
+ if (matches.length !== context.candidateCount && peers.length !== context.candidateCount) {
1857
1867
  // A removed identical sibling must not move its comment to the survivor.
1858
- const distinctive = matches[0] && (context.target.id || context.target.testId);
1868
+ const distinctive = matches[0] && (getStableAnchorId(context.target.id) || context.target.testId);
1859
1869
  if (!distinctive) return { element: null, status: "uncertain" };
1860
1870
  }
1861
1871
  }
@@ -1868,8 +1878,8 @@ function createAnchorMatcher({
1868
1878
  return layer && matchesAnchorIdentity(layer, context.layer, false, false);
1869
1879
  })) return { element: null, status: "hidden" };
1870
1880
  const plausible = matches.length || candidates.some(el =>
1871
- (context.target.id && el.id === context.target.id) ||
1872
- (context.target.text && (getElementTextSignature(el) === context.target.text || similarText(getElementTextSignature(el), context.target.text))));
1881
+ (getStableAnchorId(context.target.id) && el.id === context.target.id) ||
1882
+ (context.target.text && (getElementTextSignature(el, context.target.textMode === "static") === context.target.text || similarText(getElementTextSignature(el, context.target.textMode === "static"), context.target.text))));
1873
1883
  return { element: null, status: plausible ? "uncertain" : "missing" };
1874
1884
  }
1875
1885
 
@@ -2046,7 +2056,7 @@ function createAnchorMatcher({
2046
2056
  return { clientX, clientY };
2047
2057
  }
2048
2058
 
2049
- return { matchesAnchorContext, resolveAnchor, resolveByAnchorContext, isElementTreeHidden, getTopActiveModal, getVisibleAnchorPoint };
2059
+ return { getStableAnchorId, matchesAnchorContext, resolveAnchor, resolveByAnchorContext, isElementTreeHidden, getTopActiveModal, getVisibleAnchorPoint };
2050
2060
  }
2051
2061
 
2052
2062
  const captureEngine = (() => { // Generated from html-to-image 1.11.13 by scripts/capture-engine.mjs.
@@ -3675,7 +3685,7 @@ function initComty(options = {}) {
3675
3685
  const commentTargets = new Map();
3676
3686
 
3677
3687
  const {
3678
- matchesAnchorContext,
3688
+ getStableAnchorId, matchesAnchorContext,
3679
3689
  resolveAnchor, isElementTreeHidden, getTopActiveModal, getVisibleAnchorPoint,
3680
3690
  } = createAnchorMatcher({
3681
3691
  getElementTextSignature,
@@ -5168,8 +5178,11 @@ function initComty(options = {}) {
5168
5178
  }));
5169
5179
  } catch { /* Resume in comment mode if browser storage is unavailable. */ }
5170
5180
  const destination = new URL("/api/auth/sign-in/google", apiUrl);
5171
- destination.searchParams.set("callbackURL", loginUrl);
5172
- setTimeout(() => window.location.assign(guestAllowed ? destination.toString() : loginUrl), 50);
5181
+ const authorizeUrl = new URL(loginUrl);
5182
+ // Bootstrap may predate navigation within a single-page application.
5183
+ authorizeUrl.searchParams.set("returnTo", window.location.href);
5184
+ destination.searchParams.set("callbackURL", authorizeUrl.toString());
5185
+ setTimeout(() => window.location.assign(destination.toString()), 50);
5173
5186
  };
5174
5187
  }
5175
5188
  const guestForm = backdrop.querySelector(".comty-guest-form");
@@ -5777,9 +5790,10 @@ function initComty(options = {}) {
5777
5790
  ) {
5778
5791
  let selector = currentEl.nodeName.toLowerCase();
5779
5792
 
5780
- if (currentEl.id && currentEl.id.length <= 160) {
5793
+ const stableId = getStableAnchorId(currentEl.id);
5794
+ if (stableId && stableId.length <= 160) {
5781
5795
  try {
5782
- selector += `#${CSS.escape(currentEl.id)}`;
5796
+ selector += `#${CSS.escape(stableId)}`;
5783
5797
  } catch {
5784
5798
  // fallback
5785
5799
  }
@@ -5787,7 +5801,7 @@ function initComty(options = {}) {
5787
5801
 
5788
5802
  const stableAttributeSelector = getStableAttributeSelector(currentEl);
5789
5803
  if (
5790
- (!currentEl.id || currentEl.id.length > 160) &&
5804
+ (!stableId || stableId.length > 160) &&
5791
5805
  stableAttributeSelector
5792
5806
  ) {
5793
5807
  selector += stableAttributeSelector;
@@ -5884,12 +5898,16 @@ function initComty(options = {}) {
5884
5898
  return el;
5885
5899
  }
5886
5900
 
5887
- function getElementTextSignature(el) {
5901
+ function isEditableContent(el) {
5902
+ return Boolean(el?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])'));
5903
+ }
5904
+
5905
+ function getElementTextSignature(el, staticText = false) {
5888
5906
  if (!el) return "";
5889
5907
  const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
5890
5908
  let text = "", node;
5891
5909
  while ((node = walker.nextNode()) && text.length < 480) {
5892
- if (!isComtyNode(node.parentElement)) text += ` ${node.textContent || ""}`;
5910
+ if (!isComtyNode(node.parentElement) && !(staticText && isEditableContent(node.parentElement))) text += ` ${node.textContent || ""}`;
5893
5911
  }
5894
5912
  return text.replace(/\s+/g, " ").trim().slice(0, 240);
5895
5913
  }
@@ -5907,8 +5925,8 @@ function initComty(options = {}) {
5907
5925
  .slice(0, 8);
5908
5926
  }
5909
5927
 
5910
- function getElementOwnTextSignature(el) {
5911
- if (!el) return "";
5928
+ function getElementOwnTextSignature(el, staticText = false) {
5929
+ if (!el || (staticText && isEditableContent(el))) return "";
5912
5930
  return [...el.childNodes]
5913
5931
  .filter((node) => node.nodeType === Node.TEXT_NODE)
5914
5932
  .map((node) => node.textContent || "")
@@ -5922,11 +5940,12 @@ function initComty(options = {}) {
5922
5940
  return (el.getAttribute(attribute) || "").trim().slice(0, 240);
5923
5941
  }
5924
5942
 
5925
- function getSiblingFingerprint(el) {
5943
+ function getSiblingFingerprint(el, staticText = false) {
5926
5944
  if (!(el instanceof Element)) return null;
5927
5945
  return {
5928
5946
  tag: el.tagName.toLowerCase(),
5929
- text: getElementOwnTextSignature(el) || getElementTextSignature(el),
5947
+ ...(staticText ? { textMode: "static" } : {}),
5948
+ text: getElementOwnTextSignature(el, staticText) || getElementTextSignature(el, staticText),
5930
5949
  ariaLabel: getAttributeSignature(el, "aria-label"),
5931
5950
  role: getAttributeSignature(el, "role"),
5932
5951
  };
@@ -5941,10 +5960,12 @@ function initComty(options = {}) {
5941
5960
 
5942
5961
  const fingerprint = {
5943
5962
  tag: el.tagName.toLowerCase(),
5944
- id: (el.id || "").slice(0, 160),
5963
+ id: getStableAnchorId(el.id).slice(0, 160),
5945
5964
  classes: getElementClassList(el),
5946
- text: getElementTextSignature(el),
5947
- ownText: getElementOwnTextSignature(el),
5965
+ // Keep editable values out of the field and every ancestor's identity.
5966
+ textMode: "static",
5967
+ text: getElementTextSignature(el, true),
5968
+ ownText: getElementOwnTextSignature(el, true),
5948
5969
  role: getAttributeSignature(el, "role"),
5949
5970
  ariaLabel: getAttributeSignature(el, "aria-label"),
5950
5971
  name: getAttributeSignature(el, "name"),
@@ -5967,8 +5988,9 @@ function initComty(options = {}) {
5967
5988
  if (includeSiblings) {
5968
5989
  fingerprint.previousSibling = getSiblingFingerprint(
5969
5990
  el.previousElementSibling,
5991
+ true,
5970
5992
  );
5971
- fingerprint.nextSibling = getSiblingFingerprint(el.nextElementSibling);
5993
+ fingerprint.nextSibling = getSiblingFingerprint(el.nextElementSibling, true);
5972
5994
  }
5973
5995
 
5974
5996
  return fingerprint;
@@ -6187,33 +6209,6 @@ function initComty(options = {}) {
6187
6209
 
6188
6210
  const { captureViewportScreenshot } = createScreenshotCapture({ apiUrl, projectKey, getHeaders: getSdkHeaders });
6189
6211
 
6190
- async function uploadCommentScreenshot(comment, context, point) {
6191
- try {
6192
- const screenshot = await captureViewportScreenshot({ context, annotation: () => mountCaptureAnnotation(comment, point) });
6193
- if (!screenshot) return;
6194
- const formData = new FormData();
6195
- formData.append("projectKey", projectKey);
6196
- formData.append("screenshot", screenshot.blob, screenshot.blob.type === "image/jpeg" ? "comment.jpg" : "comment.png");
6197
- formData.append("screenshotWidth", String(screenshot.width));
6198
- formData.append("screenshotHeight", String(screenshot.height));
6199
- const response = await fetch(
6200
- `${apiUrl}/api/comments/${encodeURIComponent(comment.id)}/screenshot`,
6201
- { method: "POST", headers: getSdkHeaders(), body: formData },
6202
- );
6203
- if (!response.ok) throw new Error(`Screenshot upload failed (${response.status})`);
6204
- // Updating metadata must not redraw the page or interrupt a new draft.
6205
- const { screenshotUrl } = await response.json();
6206
- const cached = cachedComments.find(item => String(item.id) === String(comment.id));
6207
- if (cached) Object.assign(cached, {
6208
- screenshot_url: screenshotUrl,
6209
- screenshot_width: screenshot.width,
6210
- screenshot_height: screenshot.height,
6211
- });
6212
- } catch (error) {
6213
- console.warn("Comty: comment saved, screenshot unavailable", error);
6214
- }
6215
- }
6216
-
6217
6212
  function addLocalComment(commentId, payload) {
6218
6213
  const comment = {
6219
6214
  id: commentId,
@@ -6681,13 +6676,26 @@ function initComty(options = {}) {
6681
6676
  if (draftAnchor === editorDraft) draftAnchor = null;
6682
6677
  syncPlacementCursor();
6683
6678
  hideInspector();
6684
- if (!isAgentTask) addLocalComment(pendingId, payload);
6679
+ const pendingComment = isAgentTask ? null : addLocalComment(pendingId, payload);
6685
6680
  let post;
6686
6681
  try {
6682
+ let body = JSON.stringify({ ...payload, projectKey });
6683
+ let screenshot;
6684
+ if (!isAgentTask) {
6685
+ screenshot = await captureViewportScreenshot({ context, annotation: () => mountCaptureAnnotation(pendingComment, point) });
6686
+ if (!screenshot) throw new Error("Screenshot unavailable. Keep this page visible and retry.");
6687
+ body = new FormData();
6688
+ for (const [key, value] of Object.entries(payload)) {
6689
+ if (value != null) body.append(key, String(value));
6690
+ }
6691
+ body.append("screenshot", screenshot.blob, screenshot.blob.type === "image/jpeg" ? "comment.jpg" : "comment.png");
6692
+ body.append("screenshotWidth", String(screenshot.width));
6693
+ body.append("screenshotHeight", String(screenshot.height));
6694
+ }
6687
6695
  post = await fetch(payload.kind === "agent" ? `${agentBridgeUrl}/tasks` : `${apiUrl}/api/comments`, {
6688
6696
  method: "POST",
6689
- headers: payload.kind === "agent" ? agentHeaders() : getSdkHeaders({ "Content-Type": "application/json" }),
6690
- body: JSON.stringify(payload.kind === "agent" ? { ...payload, projectKey } : payload),
6697
+ headers: isAgentTask ? agentHeaders() : getSdkHeaders(),
6698
+ body,
6691
6699
  });
6692
6700
  if (!post.ok) throw new Error(`Save failed (${post.status})`);
6693
6701
  const data = await post.json();
@@ -6700,7 +6708,7 @@ function initComty(options = {}) {
6700
6708
  cachedComments = cachedComments.filter(item => String(item.id) !== pendingId);
6701
6709
  if (context.url === location.href) {
6702
6710
  const comment = addLocalComment(String(id), payload);
6703
- void uploadCommentScreenshot(comment, context, point);
6711
+ Object.assign(comment, { screenshot_url: data.screenshotUrl, screenshot_width: screenshot.width, screenshot_height: screenshot.height });
6704
6712
  } else void loadComments({ force: true });
6705
6713
  }
6706
6714
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comty.io/core",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Comty visual comments, inspector and local agent tools",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -37,6 +37,7 @@
37
37
  "test:inspector": "node --test tests/inspector.browser.test.mjs tests/inspect-markup.browser.test.mjs tests/idle-network.browser.test.mjs",
38
38
  "test:placement": "node --test tests/placement.browser.test.mjs",
39
39
  "test:anchors": "node --test tests/anchors.test.mjs tests/anchor-reload.browser.test.mjs",
40
+ "test:auth": "node --test tests/auth-return.browser.test.mjs",
40
41
  "test:vite": "node --test tests/vite.test.mjs",
41
42
  "prepack": "npm run build"
42
43
  },
package/src/anchors.js CHANGED
@@ -6,6 +6,11 @@ export function createAnchorMatcher({
6
6
  getPresentationLayer,
7
7
  isComtyNode,
8
8
  }) {
9
+ function getStableAnchorId(id) {
10
+ // React useId values change with mount order, including Base UI and Radix IDs.
11
+ return /^(?:(?:radix|base-ui)-)?(?:_r_[\w-]+_|:r[\w-]+:)$/i.test(id || "") ? "" : (id || "");
12
+ }
13
+
9
14
  function includesText(haystack, needle) {
10
15
  if (!haystack || !needle) return false;
11
16
  return haystack.toLowerCase().includes(needle.toLowerCase());
@@ -29,12 +34,13 @@ export function createAnchorMatcher({
29
34
  }
30
35
 
31
36
  let score = 0;
32
- const text = getElementTextSignature(el);
33
- const ownText = getElementOwnTextSignature(el);
37
+ const text = getElementTextSignature(el, fingerprint.textMode === "static");
38
+ const ownText = getElementOwnTextSignature(el, fingerprint.textMode === "static");
34
39
 
35
40
  score += 20;
36
- if (fingerprint.id) {
37
- score += el.id.slice(0, 160) === fingerprint.id ? 70 : -30;
41
+ const stableId = getStableAnchorId(fingerprint.id);
42
+ if (stableId) {
43
+ score += el.id.slice(0, 160) === stableId ? 70 : -30;
38
44
  }
39
45
  const exactAttributes = [
40
46
  ["role", "role", 24],
@@ -129,7 +135,7 @@ export function createAnchorMatcher({
129
135
 
130
136
  function scoreSibling(el, fingerprint) {
131
137
  if (!fingerprint) return 0;
132
- const sibling = getSiblingFingerprint(el);
138
+ const sibling = getSiblingFingerprint(el, fingerprint.textMode === "static");
133
139
  if (!sibling || sibling.tag !== fingerprint.tag) return -4;
134
140
  let score = 4;
135
141
  if (fingerprint.ariaLabel && sibling.ariaLabel === fingerprint.ariaLabel) {
@@ -168,7 +174,7 @@ export function createAnchorMatcher({
168
174
  score += Math.min(ancestorScore, 40) * (index === 0 ? 0.5 : 0.2);
169
175
  }
170
176
  // Distinguish repeated cards by their section, even when dimensions match.
171
- if (index < 3 && ancestors[index].text && getElementTextSignature(parent) === ancestors[index].text) score += 18;
177
+ if (ancestors[index].text && getElementTextSignature(parent, ancestors[index].textMode === "static") === ancestors[index].text) score += 18;
172
178
  parent = parent.parentElement;
173
179
  }
174
180
 
@@ -189,14 +195,17 @@ export function createAnchorMatcher({
189
195
  for (const [key, attribute] of Object.entries(attributes)) {
190
196
  if (fingerprint[key] && getAttributeSignature(el, attribute) !== fingerprint[key]) return false;
191
197
  }
192
- if (fingerprint.id && el.id !== fingerprint.id) return false;
198
+ const stableId = getStableAnchorId(fingerprint.id);
199
+ if (stableId && el.id !== stableId) return false;
193
200
  if (fingerprint.testId && !["data-testid", "data-test", "data-cy"].some(name => getAttributeSignature(el, name) === fingerprint.testId)) return false;
194
- const stableIdentity = (fingerprint.id && el.id === fingerprint.id) ||
201
+ const stableIdentity = (stableId && el.id === stableId) ||
195
202
  (fingerprint.testId && ["data-testid", "data-test", "data-cy"].some(name => getAttributeSignature(el, name) === fingerprint.testId));
196
- if (fingerprint.ownText && getElementOwnTextSignature(el) !== fingerprint.ownText && !(allowTextChanges && stableIdentity)) return false;
203
+ const staticText = fingerprint.textMode === "static";
204
+ const text = getElementTextSignature(el, staticText);
205
+ if (fingerprint.ownText && getElementOwnTextSignature(el, staticText) !== fingerprint.ownText && !(allowTextChanges && stableIdentity)) return false;
197
206
  // Container text is useful for matching remounted drawers and dialogs.
198
- if (!ignoreDescendantText && !fingerprint.ownText && fingerprint.text && getElementTextSignature(el) !== fingerprint.text && !(allowTextChanges && stableIdentity)) {
199
- if (!allowTextChanges || !el.children.length || !similarText(getElementTextSignature(el), fingerprint.text)) return false;
207
+ if (!ignoreDescendantText && !fingerprint.ownText && fingerprint.text && text !== fingerprint.text && !(allowTextChanges && stableIdentity)) {
208
+ if (!allowTextChanges || !el.children.length || !similarText(text, fingerprint.text)) return false;
200
209
  }
201
210
  return true;
202
211
  }
@@ -229,10 +238,11 @@ export function createAnchorMatcher({
229
238
  .map(el => ({ el, score: scoreAnchorCandidate(el, context) + (context.version === 1 && el === exact ? 16 : 0) }))
230
239
  .sort((a, b) => b.score - a.score);
231
240
  if (context.candidateCount > 1) {
241
+ // Creation counts full context matches, not every element with the same text.
232
242
  const peers = candidates.filter(el => matchesAnchorIdentity(el, context.target));
233
- if (peers.length !== context.candidateCount) {
243
+ if (matches.length !== context.candidateCount && peers.length !== context.candidateCount) {
234
244
  // A removed identical sibling must not move its comment to the survivor.
235
- const distinctive = matches[0] && (context.target.id || context.target.testId);
245
+ const distinctive = matches[0] && (getStableAnchorId(context.target.id) || context.target.testId);
236
246
  if (!distinctive) return { element: null, status: "uncertain" };
237
247
  }
238
248
  }
@@ -245,8 +255,8 @@ export function createAnchorMatcher({
245
255
  return layer && matchesAnchorIdentity(layer, context.layer, false, false);
246
256
  })) return { element: null, status: "hidden" };
247
257
  const plausible = matches.length || candidates.some(el =>
248
- (context.target.id && el.id === context.target.id) ||
249
- (context.target.text && (getElementTextSignature(el) === context.target.text || similarText(getElementTextSignature(el), context.target.text))));
258
+ (getStableAnchorId(context.target.id) && el.id === context.target.id) ||
259
+ (context.target.text && (getElementTextSignature(el, context.target.textMode === "static") === context.target.text || similarText(getElementTextSignature(el, context.target.textMode === "static"), context.target.text))));
250
260
  return { element: null, status: plausible ? "uncertain" : "missing" };
251
261
  }
252
262
 
@@ -423,5 +433,5 @@ export function createAnchorMatcher({
423
433
  return { clientX, clientY };
424
434
  }
425
435
 
426
- return { matchesAnchorContext, resolveAnchor, resolveByAnchorContext, isElementTreeHidden, getTopActiveModal, getVisibleAnchorPoint };
436
+ return { getStableAnchorId, matchesAnchorContext, resolveAnchor, resolveByAnchorContext, isElementTreeHidden, getTopActiveModal, getVisibleAnchorPoint };
427
437
  }
package/src/browser.js CHANGED
@@ -111,7 +111,7 @@ export function initComty(options = {}) {
111
111
  const commentTargets = new Map();
112
112
 
113
113
  const {
114
- matchesAnchorContext,
114
+ getStableAnchorId, matchesAnchorContext,
115
115
  resolveAnchor, isElementTreeHidden, getTopActiveModal, getVisibleAnchorPoint,
116
116
  } = createAnchorMatcher({
117
117
  getElementTextSignature,
@@ -1604,8 +1604,11 @@ export function initComty(options = {}) {
1604
1604
  }));
1605
1605
  } catch { /* Resume in comment mode if browser storage is unavailable. */ }
1606
1606
  const destination = new URL("/api/auth/sign-in/google", apiUrl);
1607
- destination.searchParams.set("callbackURL", loginUrl);
1608
- setTimeout(() => window.location.assign(guestAllowed ? destination.toString() : loginUrl), 50);
1607
+ const authorizeUrl = new URL(loginUrl);
1608
+ // Bootstrap may predate navigation within a single-page application.
1609
+ authorizeUrl.searchParams.set("returnTo", window.location.href);
1610
+ destination.searchParams.set("callbackURL", authorizeUrl.toString());
1611
+ setTimeout(() => window.location.assign(destination.toString()), 50);
1609
1612
  };
1610
1613
  }
1611
1614
  const guestForm = backdrop.querySelector(".comty-guest-form");
@@ -2213,9 +2216,10 @@ export function initComty(options = {}) {
2213
2216
  ) {
2214
2217
  let selector = currentEl.nodeName.toLowerCase();
2215
2218
 
2216
- if (currentEl.id && currentEl.id.length <= 160) {
2219
+ const stableId = getStableAnchorId(currentEl.id);
2220
+ if (stableId && stableId.length <= 160) {
2217
2221
  try {
2218
- selector += `#${CSS.escape(currentEl.id)}`;
2222
+ selector += `#${CSS.escape(stableId)}`;
2219
2223
  } catch {
2220
2224
  // fallback
2221
2225
  }
@@ -2223,7 +2227,7 @@ export function initComty(options = {}) {
2223
2227
 
2224
2228
  const stableAttributeSelector = getStableAttributeSelector(currentEl);
2225
2229
  if (
2226
- (!currentEl.id || currentEl.id.length > 160) &&
2230
+ (!stableId || stableId.length > 160) &&
2227
2231
  stableAttributeSelector
2228
2232
  ) {
2229
2233
  selector += stableAttributeSelector;
@@ -2320,12 +2324,16 @@ export function initComty(options = {}) {
2320
2324
  return el;
2321
2325
  }
2322
2326
 
2323
- function getElementTextSignature(el) {
2327
+ function isEditableContent(el) {
2328
+ return Boolean(el?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])'));
2329
+ }
2330
+
2331
+ function getElementTextSignature(el, staticText = false) {
2324
2332
  if (!el) return "";
2325
2333
  const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
2326
2334
  let text = "", node;
2327
2335
  while ((node = walker.nextNode()) && text.length < 480) {
2328
- if (!isComtyNode(node.parentElement)) text += ` ${node.textContent || ""}`;
2336
+ if (!isComtyNode(node.parentElement) && !(staticText && isEditableContent(node.parentElement))) text += ` ${node.textContent || ""}`;
2329
2337
  }
2330
2338
  return text.replace(/\s+/g, " ").trim().slice(0, 240);
2331
2339
  }
@@ -2343,8 +2351,8 @@ export function initComty(options = {}) {
2343
2351
  .slice(0, 8);
2344
2352
  }
2345
2353
 
2346
- function getElementOwnTextSignature(el) {
2347
- if (!el) return "";
2354
+ function getElementOwnTextSignature(el, staticText = false) {
2355
+ if (!el || (staticText && isEditableContent(el))) return "";
2348
2356
  return [...el.childNodes]
2349
2357
  .filter((node) => node.nodeType === Node.TEXT_NODE)
2350
2358
  .map((node) => node.textContent || "")
@@ -2358,11 +2366,12 @@ export function initComty(options = {}) {
2358
2366
  return (el.getAttribute(attribute) || "").trim().slice(0, 240);
2359
2367
  }
2360
2368
 
2361
- function getSiblingFingerprint(el) {
2369
+ function getSiblingFingerprint(el, staticText = false) {
2362
2370
  if (!(el instanceof Element)) return null;
2363
2371
  return {
2364
2372
  tag: el.tagName.toLowerCase(),
2365
- text: getElementOwnTextSignature(el) || getElementTextSignature(el),
2373
+ ...(staticText ? { textMode: "static" } : {}),
2374
+ text: getElementOwnTextSignature(el, staticText) || getElementTextSignature(el, staticText),
2366
2375
  ariaLabel: getAttributeSignature(el, "aria-label"),
2367
2376
  role: getAttributeSignature(el, "role"),
2368
2377
  };
@@ -2377,10 +2386,12 @@ export function initComty(options = {}) {
2377
2386
 
2378
2387
  const fingerprint = {
2379
2388
  tag: el.tagName.toLowerCase(),
2380
- id: (el.id || "").slice(0, 160),
2389
+ id: getStableAnchorId(el.id).slice(0, 160),
2381
2390
  classes: getElementClassList(el),
2382
- text: getElementTextSignature(el),
2383
- ownText: getElementOwnTextSignature(el),
2391
+ // Keep editable values out of the field and every ancestor's identity.
2392
+ textMode: "static",
2393
+ text: getElementTextSignature(el, true),
2394
+ ownText: getElementOwnTextSignature(el, true),
2384
2395
  role: getAttributeSignature(el, "role"),
2385
2396
  ariaLabel: getAttributeSignature(el, "aria-label"),
2386
2397
  name: getAttributeSignature(el, "name"),
@@ -2403,8 +2414,9 @@ export function initComty(options = {}) {
2403
2414
  if (includeSiblings) {
2404
2415
  fingerprint.previousSibling = getSiblingFingerprint(
2405
2416
  el.previousElementSibling,
2417
+ true,
2406
2418
  );
2407
- fingerprint.nextSibling = getSiblingFingerprint(el.nextElementSibling);
2419
+ fingerprint.nextSibling = getSiblingFingerprint(el.nextElementSibling, true);
2408
2420
  }
2409
2421
 
2410
2422
  return fingerprint;
@@ -2623,33 +2635,6 @@ export function initComty(options = {}) {
2623
2635
 
2624
2636
  const { captureViewportScreenshot } = createScreenshotCapture({ apiUrl, projectKey, getHeaders: getSdkHeaders });
2625
2637
 
2626
- async function uploadCommentScreenshot(comment, context, point) {
2627
- try {
2628
- const screenshot = await captureViewportScreenshot({ context, annotation: () => mountCaptureAnnotation(comment, point) });
2629
- if (!screenshot) return;
2630
- const formData = new FormData();
2631
- formData.append("projectKey", projectKey);
2632
- formData.append("screenshot", screenshot.blob, screenshot.blob.type === "image/jpeg" ? "comment.jpg" : "comment.png");
2633
- formData.append("screenshotWidth", String(screenshot.width));
2634
- formData.append("screenshotHeight", String(screenshot.height));
2635
- const response = await fetch(
2636
- `${apiUrl}/api/comments/${encodeURIComponent(comment.id)}/screenshot`,
2637
- { method: "POST", headers: getSdkHeaders(), body: formData },
2638
- );
2639
- if (!response.ok) throw new Error(`Screenshot upload failed (${response.status})`);
2640
- // Updating metadata must not redraw the page or interrupt a new draft.
2641
- const { screenshotUrl } = await response.json();
2642
- const cached = cachedComments.find(item => String(item.id) === String(comment.id));
2643
- if (cached) Object.assign(cached, {
2644
- screenshot_url: screenshotUrl,
2645
- screenshot_width: screenshot.width,
2646
- screenshot_height: screenshot.height,
2647
- });
2648
- } catch (error) {
2649
- console.warn("Comty: comment saved, screenshot unavailable", error);
2650
- }
2651
- }
2652
-
2653
2638
  function addLocalComment(commentId, payload) {
2654
2639
  const comment = {
2655
2640
  id: commentId,
@@ -3117,13 +3102,26 @@ export function initComty(options = {}) {
3117
3102
  if (draftAnchor === editorDraft) draftAnchor = null;
3118
3103
  syncPlacementCursor();
3119
3104
  hideInspector();
3120
- if (!isAgentTask) addLocalComment(pendingId, payload);
3105
+ const pendingComment = isAgentTask ? null : addLocalComment(pendingId, payload);
3121
3106
  let post;
3122
3107
  try {
3108
+ let body = JSON.stringify({ ...payload, projectKey });
3109
+ let screenshot;
3110
+ if (!isAgentTask) {
3111
+ screenshot = await captureViewportScreenshot({ context, annotation: () => mountCaptureAnnotation(pendingComment, point) });
3112
+ if (!screenshot) throw new Error("Screenshot unavailable. Keep this page visible and retry.");
3113
+ body = new FormData();
3114
+ for (const [key, value] of Object.entries(payload)) {
3115
+ if (value != null) body.append(key, String(value));
3116
+ }
3117
+ body.append("screenshot", screenshot.blob, screenshot.blob.type === "image/jpeg" ? "comment.jpg" : "comment.png");
3118
+ body.append("screenshotWidth", String(screenshot.width));
3119
+ body.append("screenshotHeight", String(screenshot.height));
3120
+ }
3123
3121
  post = await fetch(payload.kind === "agent" ? `${agentBridgeUrl}/tasks` : `${apiUrl}/api/comments`, {
3124
3122
  method: "POST",
3125
- headers: payload.kind === "agent" ? agentHeaders() : getSdkHeaders({ "Content-Type": "application/json" }),
3126
- body: JSON.stringify(payload.kind === "agent" ? { ...payload, projectKey } : payload),
3123
+ headers: isAgentTask ? agentHeaders() : getSdkHeaders(),
3124
+ body,
3127
3125
  });
3128
3126
  if (!post.ok) throw new Error(`Save failed (${post.status})`);
3129
3127
  const data = await post.json();
@@ -3136,7 +3134,7 @@ export function initComty(options = {}) {
3136
3134
  cachedComments = cachedComments.filter(item => String(item.id) !== pendingId);
3137
3135
  if (context.url === location.href) {
3138
3136
  const comment = addLocalComment(String(id), payload);
3139
- void uploadCommentScreenshot(comment, context, point);
3137
+ Object.assign(comment, { screenshot_url: data.screenshotUrl, screenshot_width: screenshot.width, screenshot_height: screenshot.height });
3140
3138
  } else void loadComments({ force: true });
3141
3139
  }
3142
3140
  } catch (error) {