@zibby/mcp-browser 0.1.0 → 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.
@@ -1,347 +0,0 @@
1
- /**
2
- * Stable ID Injection Script
3
- * Injected into every page via --init-script to add data-zibby-id attributes
4
- *
5
- * DESIGN GOAL: 100% reliable stable IDs without relying on data-testid
6
- *
7
- * Strategy:
8
- * 1. Use element's intrinsic attributes (id, name, type, etc.)
9
- * 2. Use element's semantic label (aria-label, associated label, text content)
10
- * 3. Use element's semantic context (which form, which section, which landmark)
11
- * 4. Hash everything for a unique, stable ID
12
- */
13
- (function() {
14
- if (window.__zibbyInitialized) return;
15
- window.__zibbyInitialized = true;
16
-
17
- /**
18
- * Get the semantic label for an element
19
- * This is what a user would call the element
20
- */
21
- function getSemanticLabel(el) {
22
- // 1. aria-label is explicit
23
- if (el.getAttribute('aria-label')) return el.getAttribute('aria-label').trim();
24
-
25
- // 2. aria-labelledby
26
- const labelledBy = el.getAttribute('aria-labelledby');
27
- if (labelledBy) {
28
- const labelEl = document.getElementById(labelledBy);
29
- if (labelEl) return labelEl.textContent.trim();
30
- }
31
-
32
- // 3. For form fields, check associated <label>
33
- if (el.id) {
34
- const label = document.querySelector(`label[for="${el.id}"]`);
35
- if (label) return label.textContent.trim();
36
- }
37
-
38
- // 4. Check if wrapped in <label>
39
- const parentLabel = el.closest('label');
40
- if (parentLabel) {
41
- // Get label text without the input's value
42
- const clone = parentLabel.cloneNode(true);
43
- clone.querySelectorAll('input, select, textarea').forEach(e => e.remove());
44
- const text = clone.textContent.trim();
45
- if (text) return text;
46
- }
47
-
48
- // 5. placeholder for inputs
49
- if (el.placeholder) return el.placeholder;
50
-
51
- // 6. Text content for buttons/links
52
- const tag = el.tagName.toLowerCase();
53
- if (tag === 'button' || tag === 'a' || el.getAttribute('role') === 'button') {
54
- return (el.textContent || '').trim().slice(0, 50);
55
- }
56
-
57
- // 7. title attribute
58
- if (el.title) return el.title;
59
-
60
- // 8. value for submit buttons
61
- if (tag === 'input' && (el.type === 'submit' || el.type === 'button')) {
62
- return el.value || '';
63
- }
64
-
65
- return '';
66
- }
67
-
68
- /**
69
- * Get semantic context - which form/section/landmark the element is in
70
- * This distinguishes elements with same attributes in different contexts
71
- */
72
- function getSemanticContext(el) {
73
- const context = [];
74
-
75
- // 1. Form context (most important for form elements)
76
- const form = el.closest('form');
77
- if (form) {
78
- // Use form's identity
79
- if (form.id) context.push('form#' + form.id);
80
- else if (form.name) context.push('form[name=' + form.name + ']');
81
- else if (form.action) {
82
- try {
83
- const action = new URL(form.action, window.location.origin).pathname;
84
- context.push('form[action=' + action + ']');
85
- } catch {
86
- context.push('form[action=' + form.getAttribute('action') + ']');
87
- }
88
- } else {
89
- // Count which form this is on the page
90
- const forms = document.querySelectorAll('form');
91
- const index = Array.from(forms).indexOf(form);
92
- context.push('form:nth(' + index + ')');
93
- }
94
- }
95
-
96
- // 2. Landmark context (header, nav, main, footer, aside)
97
- const landmark = el.closest('header, nav, main, footer, aside, [role="banner"], [role="navigation"], [role="main"], [role="contentinfo"]');
98
- if (landmark) {
99
- const tag = landmark.tagName.toLowerCase();
100
- const role = landmark.getAttribute('role');
101
- context.push(role || tag);
102
- }
103
-
104
- // 3. Section with heading
105
- const section = el.closest('section, article, [role="region"]');
106
- if (section) {
107
- const heading = section.querySelector('h1, h2, h3, h4, h5, h6');
108
- if (heading) {
109
- context.push('section:' + heading.textContent.trim().slice(0, 30));
110
- }
111
- }
112
-
113
- // 4. Fieldset context
114
- const fieldset = el.closest('fieldset');
115
- if (fieldset) {
116
- const legend = fieldset.querySelector('legend');
117
- if (legend) {
118
- context.push('fieldset:' + legend.textContent.trim());
119
- }
120
- }
121
-
122
- // 5. Dialog/modal context
123
- const dialog = el.closest('dialog, [role="dialog"], [role="alertdialog"]');
124
- if (dialog) {
125
- const title = dialog.querySelector('[role="heading"], h1, h2, h3');
126
- if (title) context.push('dialog:' + title.textContent.trim().slice(0, 30));
127
- else context.push('dialog');
128
- }
129
-
130
- return context.join('/');
131
- }
132
-
133
- /**
134
- * Compute stable ID for an element
135
- * Combines: tag, attributes, semantic label, semantic context
136
- */
137
- function computeStableId(el) {
138
- const tag = el.tagName.toLowerCase();
139
- const id = el.id || '';
140
- const name = el.name || '';
141
- const type = el.type || '';
142
- const role = el.getAttribute('role') || '';
143
-
144
- // Safely extract href pathname
145
- let href = '';
146
- if (el.href) {
147
- try {
148
- href = new URL(el.href, window.location.origin).pathname.slice(0, 50);
149
- } catch {
150
- // Fallback if URL construction fails (e.g., about:blank pages)
151
- href = el.getAttribute('href')?.slice(0, 50) || '';
152
- }
153
- }
154
-
155
- // Get semantic label (what user calls this element)
156
- const label = getSemanticLabel(el).slice(0, 50).replace(/\s+/g, ' ');
157
-
158
- // Get semantic context (where this element is)
159
- const context = getSemanticContext(el);
160
-
161
- // Build signature with all identifying info
162
- const sig = [
163
- tag,
164
- id,
165
- name,
166
- type,
167
- role,
168
- href,
169
- label,
170
- context
171
- ].join('|');
172
-
173
- // djb2 hash - fast and good distribution
174
- let hash = 5381;
175
- for (let i = 0; i < sig.length; i++) {
176
- hash = ((hash << 5) + hash) ^ sig.charCodeAt(i);
177
- }
178
-
179
- return 'zibby-' + (hash >>> 0).toString(36);
180
- }
181
-
182
- function injectStableIds() {
183
- if (!document.body) return;
184
-
185
- const selectors = [
186
- 'button', 'a', 'input', 'select', 'textarea',
187
- 'label[for]', // Labels that control inputs
188
- '[role="button"]', '[role="link"]', '[role="textbox"]',
189
- '[role="checkbox"]', '[role="radio"]', '[role="combobox"]',
190
- '[role="menuitem"]', '[role="tab"]', '[role="option"]',
191
- '[role="switch"]', '[role="slider"]',
192
- '[onclick]', '[data-action]'
193
- ].join(', ');
194
-
195
- const idCounts = new Map();
196
-
197
- try {
198
- document.querySelectorAll(selectors).forEach((el) => {
199
- // Skip hidden elements
200
- const style = window.getComputedStyle(el);
201
- if (style.display === 'none' || style.visibility === 'hidden') return;
202
-
203
- let stableId = computeStableId(el);
204
-
205
- // Handle duplicates (same ID means truly identical elements)
206
- const baseId = stableId;
207
- const count = idCounts.get(baseId) || 0;
208
- if (count > 0) stableId = baseId + '-' + count;
209
- idCounts.set(baseId, count + 1);
210
-
211
- el.setAttribute('data-zibby-id', stableId);
212
- });
213
- } catch (e) {
214
- console.error('[Zibby] Error injecting stable IDs:', e);
215
- }
216
- }
217
-
218
- function initialize() {
219
- if (!document.body) {
220
- if (document.readyState === 'loading') {
221
- document.addEventListener('DOMContentLoaded', initialize);
222
- return;
223
- }
224
- }
225
-
226
- injectStableIds();
227
-
228
- // Re-inject on DOM changes (for SPAs)
229
- const observer = new MutationObserver(() => {
230
- requestAnimationFrame(injectStableIds);
231
- });
232
-
233
- if (document.body) {
234
- observer.observe(document.body, { childList: true, subtree: true });
235
- }
236
- }
237
-
238
- // Expose helper for getting stable ID
239
- window.__zibbyGetStableId = function(element) {
240
- return element?.getAttribute('data-zibby-id') || null;
241
- };
242
-
243
- // Track last interacted element - use cookie to persist across subdomains!
244
- // Cookie approach works for: login.example.com -> hr.example.com
245
- function getBaseDomain() {
246
- const parts = window.location.hostname.split('.');
247
- // Get root domain (e.g., example.com from app.example.com)
248
- return parts.length >= 2 ? '.' + parts.slice(-2).join('.') : window.location.hostname;
249
- }
250
-
251
- Object.defineProperty(window, '__zibbyLastStableId', {
252
- get: function() {
253
- const match = document.cookie.match(/(?:^|; )__zibbyLastStableId=([^;]*)/);
254
- return match ? decodeURIComponent(match[1]) : null;
255
- },
256
- set: function(value) {
257
- if (value) {
258
- // Set cookie with domain to share across subdomains, expires in 1 hour
259
- const domain = getBaseDomain();
260
- document.cookie = `__zibbyLastStableId=${encodeURIComponent(value)}; domain=${domain}; path=/; max-age=3600; SameSite=Lax`;
261
- }
262
- }
263
- });
264
-
265
- /**
266
- * Find the actual interactive element from an event target
267
- * Handles: label clicks that activate inputs, etc.
268
- */
269
- function findInteractiveElement(target) {
270
- // 1. Direct match
271
- const el = target.closest('[data-zibby-id]');
272
- if (el) return el;
273
-
274
- // 2. If clicking a label, find its associated input
275
- const label = target.closest('label');
276
- if (label) {
277
- // Check for explicit "for" attribute
278
- if (label.htmlFor) {
279
- const input = document.getElementById(label.htmlFor);
280
- if (input?.getAttribute('data-zibby-id')) return input;
281
- }
282
- // Check for nested input
283
- const nestedInput = label.querySelector('input, select, textarea, [role="checkbox"], [role="radio"]');
284
- if (nestedInput?.getAttribute('data-zibby-id')) return nestedInput;
285
- }
286
-
287
- // 3. Check if we're inside a custom component that wraps an input
288
- const parent = target.closest('[class*="select"], [class*="dropdown"], [class*="checkbox"], [class*="radio"]');
289
- if (parent) {
290
- const input = parent.querySelector('[data-zibby-id]');
291
- if (input) return input;
292
- }
293
-
294
- return null;
295
- }
296
-
297
- /**
298
- * Get stable ID for an element - either existing or computed on-the-fly
299
- * This handles race conditions where IDs haven't been injected yet
300
- */
301
- function getOrComputeStableId(el) {
302
- if (!el) return null;
303
-
304
- // 1. Already has stable ID
305
- const existingId = el.getAttribute('data-zibby-id');
306
- if (existingId) return existingId;
307
-
308
- // 2. Compute on-the-fly and inject
309
- const stableId = computeStableId(el);
310
- el.setAttribute('data-zibby-id', stableId);
311
- return stableId;
312
- }
313
-
314
- document.addEventListener('click', (e) => {
315
- // Try to find element with existing ID first
316
- let target = findInteractiveElement(e.target);
317
-
318
- // If no match, compute ID for the clicked element itself
319
- if (!target && e.target.matches('button, a, input, select, textarea, [role="button"], [onclick]')) {
320
- target = e.target;
321
- }
322
-
323
- if (target) {
324
- window.__zibbyLastStableId = getOrComputeStableId(target);
325
- }
326
- }, true);
327
-
328
- document.addEventListener('focusin', (e) => {
329
- let target = findInteractiveElement(e.target);
330
- if (!target && e.target.matches('input, select, textarea, [role="textbox"], [role="combobox"]')) {
331
- target = e.target;
332
- }
333
- if (target) {
334
- window.__zibbyLastStableId = getOrComputeStableId(target);
335
- }
336
- }, true);
337
-
338
- document.addEventListener('change', (e) => {
339
- let target = findInteractiveElement(e.target);
340
- if (!target) target = e.target;
341
- if (target) {
342
- window.__zibbyLastStableId = getOrComputeStableId(target);
343
- }
344
- }, true);
345
-
346
- initialize();
347
- })();