@cairnvibe/sdk 0.2.10 → 0.2.11

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.
@@ -3,11 +3,9 @@
3
3
  // the build-time manifest, and not limited to elements a developer
4
4
  // remembered to tag with data-ai. This is what lets the agent address a
5
5
  // dynamically-rendered row (a session card, a list item) it was never told
6
- // about ahead of time. Deliberately narrow in what counts as "interactive":
7
- // the same semantic-clickable set element-ladder.ts's own fallback search
8
- // already uses, plus anything carrying data-ai a plain `<div onClick>`
9
- // with no semantic role is invisible to this, same limit the existing
10
- // ladder already has.
6
+ // about ahead of time AND a plain `<div onClick>` styled as a button (see
7
+ // hasClickHandler below), which a selector alone could never catch, since
8
+ // React never writes an "onclick" HTML attribute for a JSX onClick prop.
11
9
  Object.defineProperty(exports, "__esModule", { value: true });
12
10
  exports.scanInteractiveElements = scanInteractiveElements;
13
11
  exports.createLiveElementRegistry = createLiveElementRegistry;
@@ -75,13 +73,41 @@ function labelFor(el) {
75
73
  const trimmed = raw.replace(/\s+/g, " ").trim();
76
74
  return trimmed.length > MAX_LABEL_LENGTH ? `${trimmed.slice(0, MAX_LABEL_LENGTH - 1)}…` : trimmed;
77
75
  }
78
- function roleFor(el) {
76
+ function roleFor(el, nonSemanticClickable = false) {
79
77
  if (el.getAttribute("role"))
80
78
  return el.getAttribute("role");
81
79
  if (isFormField(el))
82
80
  return "input";
81
+ // A real tag name ("div") tells the model nothing useful about a plain
82
+ // `<div onClick>` — "clickable" is what actually matters to it here.
83
+ if (nonSemanticClickable)
84
+ return "clickable";
83
85
  return el.tagName.toLowerCase();
84
86
  }
87
+ /**
88
+ * True for an element with a real click handler that CANDIDATE_SELECTOR's
89
+ * semantic tags/roles can't catch — a `<div onClick>` or similar, common in
90
+ * component libraries that style a div/span as a button instead of using a
91
+ * real <button>. `el.onclick` covers the plain-JS `el.onclick = fn` case;
92
+ * the __reactProps$/__reactEventHandlers$ scan covers React's synthetic
93
+ * `onClick` prop, which never appears as an "onclick" HTML attribute a CSS
94
+ * selector could match. `addEventListener`-only handlers (React-free code
95
+ * calling it directly) aren't introspectable from page JS at all — an
96
+ * honest, known gap, not attempted here. Whichever way it's wired,
97
+ * el.click() (verb-executor.ts's click execution) still fires it correctly
98
+ * either way — a real click() call dispatches a genuine bubbling
99
+ * MouseEvent, exactly what both a native `onclick` and React's delegated
100
+ * listener are listening for.
101
+ */
102
+ function hasClickHandler(el) {
103
+ if (typeof el.onclick === "function")
104
+ return true;
105
+ const reactPropsKey = Object.keys(el).find((k) => k.startsWith("__reactProps$") || k.startsWith("__reactEventHandlers$"));
106
+ if (!reactPropsKey)
107
+ return false;
108
+ const props = el[reactPropsKey];
109
+ return typeof props?.onClick === "function";
110
+ }
85
111
  /**
86
112
  * Scans the live DOM for interactive elements, on screen right now or just
87
113
  * off it (see viewportDistance) — anything rendered on the page at all, not
@@ -98,7 +124,15 @@ function scanInteractiveElements(root = document) {
98
124
  let counter = 0;
99
125
  if (typeof document === "undefined")
100
126
  return { elements, byId };
101
- const candidates = Array.from(root.querySelectorAll(CANDIDATE_SELECTOR)).filter(isRendered);
127
+ const semanticCandidates = Array.from(root.querySelectorAll(CANDIDATE_SELECTOR)).filter(isRendered);
128
+ const semanticSet = new Set(semanticCandidates);
129
+ // A second pass, only over what the selector-based pass didn't already
130
+ // catch — a plain `<div onClick>`/`<span onClick>` with no semantic tag
131
+ // or role at all (see hasClickHandler's doc comment for why a selector
132
+ // alone can't find these).
133
+ const nonSemanticCandidates = Array.from(root.querySelectorAll("*")).filter((el) => !semanticSet.has(el) && isRendered(el) && hasClickHandler(el));
134
+ const nonSemanticSet = new Set(nonSemanticCandidates);
135
+ const candidates = [...semanticCandidates, ...nonSemanticCandidates];
102
136
  candidates.sort((a, b) => viewportDistance(a) - viewportDistance(b));
103
137
  for (const el of candidates) {
104
138
  if (elements.length >= MAX_ELEMENTS)
@@ -111,7 +145,7 @@ function scanInteractiveElements(root = document) {
111
145
  if (!label)
112
146
  continue; // nothing to address it by — skip rather than send an empty label
113
147
  byId.set(id, el);
114
- elements.push({ id, role: roleFor(el), label });
148
+ elements.push({ id, role: roleFor(el, nonSemanticSet.has(el)), label });
115
149
  }
116
150
  return { elements, byId };
117
151
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cairnvibe/sdk",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
4
4
  "description": "In-app AI copilot — <Copilot/> for React/Next.js, <cairn-widget> for any framework — plus the server handlers and realtime voice relay behind them.",
5
5
  "license": "MIT",
6
6
  "publishConfig": { "access": "public" },
@@ -2,11 +2,9 @@
2
2
  // the build-time manifest, and not limited to elements a developer
3
3
  // remembered to tag with data-ai. This is what lets the agent address a
4
4
  // dynamically-rendered row (a session card, a list item) it was never told
5
- // about ahead of time. Deliberately narrow in what counts as "interactive":
6
- // the same semantic-clickable set element-ladder.ts's own fallback search
7
- // already uses, plus anything carrying data-ai a plain `<div onClick>`
8
- // with no semantic role is invisible to this, same limit the existing
9
- // ladder already has.
5
+ // about ahead of time AND a plain `<div onClick>` styled as a button (see
6
+ // hasClickHandler below), which a selector alone could never catch, since
7
+ // React never writes an "onclick" HTML attribute for a JSX onClick prop.
10
8
 
11
9
  import type { LiveElement } from "@cairnvibe/core";
12
10
 
@@ -85,12 +83,38 @@ function labelFor(el: HTMLElement): string {
85
83
  return trimmed.length > MAX_LABEL_LENGTH ? `${trimmed.slice(0, MAX_LABEL_LENGTH - 1)}…` : trimmed;
86
84
  }
87
85
 
88
- function roleFor(el: HTMLElement): string {
86
+ function roleFor(el: HTMLElement, nonSemanticClickable = false): string {
89
87
  if (el.getAttribute("role")) return el.getAttribute("role")!;
90
88
  if (isFormField(el)) return "input";
89
+ // A real tag name ("div") tells the model nothing useful about a plain
90
+ // `<div onClick>` — "clickable" is what actually matters to it here.
91
+ if (nonSemanticClickable) return "clickable";
91
92
  return el.tagName.toLowerCase();
92
93
  }
93
94
 
95
+ /**
96
+ * True for an element with a real click handler that CANDIDATE_SELECTOR's
97
+ * semantic tags/roles can't catch — a `<div onClick>` or similar, common in
98
+ * component libraries that style a div/span as a button instead of using a
99
+ * real <button>. `el.onclick` covers the plain-JS `el.onclick = fn` case;
100
+ * the __reactProps$/__reactEventHandlers$ scan covers React's synthetic
101
+ * `onClick` prop, which never appears as an "onclick" HTML attribute a CSS
102
+ * selector could match. `addEventListener`-only handlers (React-free code
103
+ * calling it directly) aren't introspectable from page JS at all — an
104
+ * honest, known gap, not attempted here. Whichever way it's wired,
105
+ * el.click() (verb-executor.ts's click execution) still fires it correctly
106
+ * either way — a real click() call dispatches a genuine bubbling
107
+ * MouseEvent, exactly what both a native `onclick` and React's delegated
108
+ * listener are listening for.
109
+ */
110
+ function hasClickHandler(el: HTMLElement): boolean {
111
+ if (typeof (el as unknown as { onclick?: unknown }).onclick === "function") return true;
112
+ const reactPropsKey = Object.keys(el).find((k) => k.startsWith("__reactProps$") || k.startsWith("__reactEventHandlers$"));
113
+ if (!reactPropsKey) return false;
114
+ const props = (el as unknown as Record<string, { onClick?: unknown } | undefined>)[reactPropsKey];
115
+ return typeof props?.onClick === "function";
116
+ }
117
+
94
118
  /**
95
119
  * Scans the live DOM for interactive elements, on screen right now or just
96
120
  * off it (see viewportDistance) — anything rendered on the page at all, not
@@ -108,7 +132,18 @@ export function scanInteractiveElements(root: ParentNode = document): LiveScan {
108
132
 
109
133
  if (typeof document === "undefined") return { elements, byId };
110
134
 
111
- const candidates = Array.from(root.querySelectorAll<HTMLElement>(CANDIDATE_SELECTOR)).filter(isRendered);
135
+ const semanticCandidates = Array.from(root.querySelectorAll<HTMLElement>(CANDIDATE_SELECTOR)).filter(isRendered);
136
+ const semanticSet = new Set(semanticCandidates);
137
+ // A second pass, only over what the selector-based pass didn't already
138
+ // catch — a plain `<div onClick>`/`<span onClick>` with no semantic tag
139
+ // or role at all (see hasClickHandler's doc comment for why a selector
140
+ // alone can't find these).
141
+ const nonSemanticCandidates = Array.from(root.querySelectorAll<HTMLElement>("*")).filter(
142
+ (el) => !semanticSet.has(el) && isRendered(el) && hasClickHandler(el),
143
+ );
144
+ const nonSemanticSet = new Set(nonSemanticCandidates);
145
+
146
+ const candidates = [...semanticCandidates, ...nonSemanticCandidates];
112
147
  candidates.sort((a, b) => viewportDistance(a) - viewportDistance(b));
113
148
 
114
149
  for (const el of candidates) {
@@ -122,7 +157,7 @@ export function scanInteractiveElements(root: ParentNode = document): LiveScan {
122
157
  if (!label) continue; // nothing to address it by — skip rather than send an empty label
123
158
 
124
159
  byId.set(id, el);
125
- elements.push({ id, role: roleFor(el), label });
160
+ elements.push({ id, role: roleFor(el, nonSemanticSet.has(el)), label });
126
161
  }
127
162
 
128
163
  return { elements, byId };