@eyeglass/inspector 0.1.0

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.
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Captures a semantic snapshot of a DOM element
3
+ */
4
+ import type { SemanticSnapshot } from '@eyeglass/types';
5
+ /**
6
+ * Capture a complete semantic snapshot of an element
7
+ */
8
+ export declare function captureSnapshot(element: Element): SemanticSnapshot;
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Captures a semantic snapshot of a DOM element
3
+ */
4
+ import { extractFrameworkInfo } from './fiber-walker.js';
5
+ /**
6
+ * Get computed accessibility properties
7
+ */
8
+ function getA11yInfo(element) {
9
+ const ariaLabel = element.getAttribute('aria-label');
10
+ const ariaDescribedBy = element.getAttribute('aria-describedby');
11
+ const ariaDisabled = element.getAttribute('aria-disabled');
12
+ const ariaExpanded = element.getAttribute('aria-expanded');
13
+ const ariaChecked = element.getAttribute('aria-checked');
14
+ const ariaHidden = element.getAttribute('aria-hidden');
15
+ // Get description from aria-describedby if it points to an element
16
+ let description = null;
17
+ if (ariaDescribedBy) {
18
+ const descElement = document.getElementById(ariaDescribedBy);
19
+ description = descElement?.textContent?.trim() || null;
20
+ }
21
+ // Check if element is disabled
22
+ const disabled = ariaDisabled === 'true' ||
23
+ element.disabled ||
24
+ element.hasAttribute('disabled');
25
+ return {
26
+ label: ariaLabel || element.getAttribute('title') || null,
27
+ description,
28
+ disabled,
29
+ expanded: ariaExpanded ? ariaExpanded === 'true' : undefined,
30
+ checked: ariaChecked === 'true'
31
+ ? true
32
+ : ariaChecked === 'false'
33
+ ? false
34
+ : ariaChecked === 'mixed'
35
+ ? 'mixed'
36
+ : undefined,
37
+ hidden: ariaHidden === 'true' || element.hidden || false,
38
+ };
39
+ }
40
+ /**
41
+ * Get geometry information
42
+ */
43
+ function getGeometry(element) {
44
+ const rect = element.getBoundingClientRect();
45
+ return {
46
+ x: Math.round(rect.x),
47
+ y: Math.round(rect.y),
48
+ width: Math.round(rect.width),
49
+ height: Math.round(rect.height),
50
+ visible: rect.width > 0 && rect.height > 0,
51
+ };
52
+ }
53
+ /**
54
+ * Get targeted computed styles
55
+ */
56
+ function getStyles(element) {
57
+ const computed = getComputedStyle(element);
58
+ return {
59
+ display: computed.display,
60
+ position: computed.position,
61
+ flexDirection: computed.flexDirection !== 'row' ? computed.flexDirection : undefined,
62
+ gridTemplate: computed.display === 'grid'
63
+ ? `${computed.gridTemplateColumns} / ${computed.gridTemplateRows}`
64
+ : undefined,
65
+ padding: computed.padding,
66
+ margin: computed.margin,
67
+ color: computed.color,
68
+ backgroundColor: computed.backgroundColor,
69
+ fontFamily: computed.fontFamily,
70
+ zIndex: computed.zIndex,
71
+ };
72
+ }
73
+ /**
74
+ * Get the accessible role of an element
75
+ */
76
+ function getRole(element) {
77
+ // Explicit role takes precedence
78
+ const explicitRole = element.getAttribute('role');
79
+ if (explicitRole)
80
+ return explicitRole;
81
+ // Implicit roles based on tag
82
+ const tag = element.tagName.toLowerCase();
83
+ const roleMap = {
84
+ a: 'link',
85
+ button: 'button',
86
+ input: element.type || 'textbox',
87
+ select: 'combobox',
88
+ textarea: 'textbox',
89
+ img: 'img',
90
+ nav: 'navigation',
91
+ main: 'main',
92
+ header: 'banner',
93
+ footer: 'contentinfo',
94
+ aside: 'complementary',
95
+ article: 'article',
96
+ section: 'region',
97
+ form: 'form',
98
+ ul: 'list',
99
+ ol: 'list',
100
+ li: 'listitem',
101
+ table: 'table',
102
+ tr: 'row',
103
+ td: 'cell',
104
+ th: 'columnheader',
105
+ dialog: 'dialog',
106
+ h1: 'heading',
107
+ h2: 'heading',
108
+ h3: 'heading',
109
+ h4: 'heading',
110
+ h5: 'heading',
111
+ h6: 'heading',
112
+ };
113
+ return roleMap[tag] || 'generic';
114
+ }
115
+ /**
116
+ * Get the accessible name of an element
117
+ */
118
+ function getAccessibleName(element) {
119
+ // aria-label
120
+ const ariaLabel = element.getAttribute('aria-label');
121
+ if (ariaLabel)
122
+ return ariaLabel;
123
+ // aria-labelledby
124
+ const labelledBy = element.getAttribute('aria-labelledby');
125
+ if (labelledBy) {
126
+ const labelElement = document.getElementById(labelledBy);
127
+ if (labelElement)
128
+ return labelElement.textContent?.trim() || '';
129
+ }
130
+ // For inputs, check associated label
131
+ if (element.tagName === 'INPUT' || element.tagName === 'SELECT' || element.tagName === 'TEXTAREA') {
132
+ const id = element.getAttribute('id');
133
+ if (id) {
134
+ const label = document.querySelector(`label[for="${id}"]`);
135
+ if (label)
136
+ return label.textContent?.trim() || '';
137
+ }
138
+ }
139
+ // For images, use alt
140
+ if (element.tagName === 'IMG') {
141
+ return element.alt || '';
142
+ }
143
+ // Text content (truncated)
144
+ const text = element.textContent?.trim() || '';
145
+ return text.length > 50 ? text.slice(0, 50) + '...' : text;
146
+ }
147
+ /**
148
+ * Get element identifiers (id, className, data-* attributes)
149
+ */
150
+ function getElementIdentifiers(element) {
151
+ const result = {};
152
+ // Get id
153
+ const id = element.getAttribute('id');
154
+ if (id) {
155
+ result.id = id;
156
+ }
157
+ // Get class names
158
+ const className = element.getAttribute('class');
159
+ if (className?.trim()) {
160
+ result.className = className.trim();
161
+ }
162
+ // Get data-* attributes
163
+ const dataAttrs = {};
164
+ for (let i = 0; i < element.attributes.length; i++) {
165
+ const attr = element.attributes[i];
166
+ if (attr.name.startsWith('data-')) {
167
+ dataAttrs[attr.name] = attr.value;
168
+ }
169
+ }
170
+ if (Object.keys(dataAttrs).length > 0) {
171
+ result.dataAttributes = dataAttrs;
172
+ }
173
+ return result;
174
+ }
175
+ /**
176
+ * Capture a complete semantic snapshot of an element
177
+ */
178
+ export function captureSnapshot(element) {
179
+ const identifiers = getElementIdentifiers(element);
180
+ return {
181
+ role: getRole(element),
182
+ name: getAccessibleName(element),
183
+ tagName: element.tagName.toLowerCase(),
184
+ ...identifiers,
185
+ framework: extractFrameworkInfo(element),
186
+ a11y: getA11yInfo(element),
187
+ geometry: getGeometry(element),
188
+ styles: getStyles(element),
189
+ timestamp: Date.now(),
190
+ url: window.location.href,
191
+ };
192
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,283 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { captureSnapshot } from './snapshot.js';
3
+ describe('captureSnapshot', () => {
4
+ beforeEach(() => {
5
+ document.body.innerHTML = '';
6
+ });
7
+ describe('basic element info', () => {
8
+ it('should capture tag name', () => {
9
+ const button = document.createElement('button');
10
+ document.body.appendChild(button);
11
+ const snapshot = captureSnapshot(button);
12
+ expect(snapshot.tagName).toBe('button');
13
+ });
14
+ it('should capture text content as name', () => {
15
+ const button = document.createElement('button');
16
+ button.textContent = 'Click me';
17
+ document.body.appendChild(button);
18
+ const snapshot = captureSnapshot(button);
19
+ expect(snapshot.name).toBe('Click me');
20
+ });
21
+ it('should truncate long text content', () => {
22
+ const div = document.createElement('div');
23
+ div.textContent = 'A'.repeat(100);
24
+ document.body.appendChild(div);
25
+ const snapshot = captureSnapshot(div);
26
+ expect(snapshot.name).toHaveLength(53); // 50 chars + '...'
27
+ expect(snapshot.name.endsWith('...')).toBe(true);
28
+ });
29
+ });
30
+ describe('element identifiers', () => {
31
+ it('should capture id', () => {
32
+ const div = document.createElement('div');
33
+ div.id = 'my-element';
34
+ document.body.appendChild(div);
35
+ const snapshot = captureSnapshot(div);
36
+ expect(snapshot.id).toBe('my-element');
37
+ });
38
+ it('should capture className', () => {
39
+ const div = document.createElement('div');
40
+ div.className = 'foo bar baz';
41
+ document.body.appendChild(div);
42
+ const snapshot = captureSnapshot(div);
43
+ expect(snapshot.className).toBe('foo bar baz');
44
+ });
45
+ it('should capture data attributes', () => {
46
+ const div = document.createElement('div');
47
+ div.setAttribute('data-testid', 'my-test');
48
+ div.setAttribute('data-value', '42');
49
+ document.body.appendChild(div);
50
+ const snapshot = captureSnapshot(div);
51
+ expect(snapshot.dataAttributes).toEqual({
52
+ 'data-testid': 'my-test',
53
+ 'data-value': '42',
54
+ });
55
+ });
56
+ it('should not include empty identifiers', () => {
57
+ const div = document.createElement('div');
58
+ document.body.appendChild(div);
59
+ const snapshot = captureSnapshot(div);
60
+ expect(snapshot.id).toBeUndefined();
61
+ expect(snapshot.className).toBeUndefined();
62
+ expect(snapshot.dataAttributes).toBeUndefined();
63
+ });
64
+ });
65
+ describe('role detection', () => {
66
+ it('should use explicit role attribute', () => {
67
+ const div = document.createElement('div');
68
+ div.setAttribute('role', 'navigation');
69
+ document.body.appendChild(div);
70
+ const snapshot = captureSnapshot(div);
71
+ expect(snapshot.role).toBe('navigation');
72
+ });
73
+ it('should infer role from button tag', () => {
74
+ const button = document.createElement('button');
75
+ document.body.appendChild(button);
76
+ const snapshot = captureSnapshot(button);
77
+ expect(snapshot.role).toBe('button');
78
+ });
79
+ it('should infer role from link tag', () => {
80
+ const link = document.createElement('a');
81
+ document.body.appendChild(link);
82
+ const snapshot = captureSnapshot(link);
83
+ expect(snapshot.role).toBe('link');
84
+ });
85
+ it('should infer role from semantic tags', () => {
86
+ const nav = document.createElement('nav');
87
+ document.body.appendChild(nav);
88
+ const snapshot = captureSnapshot(nav);
89
+ expect(snapshot.role).toBe('navigation');
90
+ });
91
+ it('should return generic for unknown tags', () => {
92
+ const div = document.createElement('div');
93
+ document.body.appendChild(div);
94
+ const snapshot = captureSnapshot(div);
95
+ expect(snapshot.role).toBe('generic');
96
+ });
97
+ it('should detect input type as role', () => {
98
+ const input = document.createElement('input');
99
+ input.type = 'checkbox';
100
+ document.body.appendChild(input);
101
+ const snapshot = captureSnapshot(input);
102
+ expect(snapshot.role).toBe('checkbox');
103
+ });
104
+ });
105
+ describe('accessibility info', () => {
106
+ it('should capture aria-label', () => {
107
+ const button = document.createElement('button');
108
+ button.setAttribute('aria-label', 'Close dialog');
109
+ document.body.appendChild(button);
110
+ const snapshot = captureSnapshot(button);
111
+ expect(snapshot.a11y.label).toBe('Close dialog');
112
+ expect(snapshot.name).toBe('Close dialog');
113
+ });
114
+ it('should capture title as label fallback', () => {
115
+ const button = document.createElement('button');
116
+ button.setAttribute('title', 'Helpful tooltip');
117
+ document.body.appendChild(button);
118
+ const snapshot = captureSnapshot(button);
119
+ expect(snapshot.a11y.label).toBe('Helpful tooltip');
120
+ });
121
+ it('should capture aria-describedby', () => {
122
+ const desc = document.createElement('span');
123
+ desc.id = 'desc-text';
124
+ desc.textContent = 'This is the description';
125
+ document.body.appendChild(desc);
126
+ const button = document.createElement('button');
127
+ button.setAttribute('aria-describedby', 'desc-text');
128
+ document.body.appendChild(button);
129
+ const snapshot = captureSnapshot(button);
130
+ expect(snapshot.a11y.description).toBe('This is the description');
131
+ });
132
+ it('should capture disabled state from aria-disabled', () => {
133
+ const button = document.createElement('button');
134
+ button.setAttribute('aria-disabled', 'true');
135
+ document.body.appendChild(button);
136
+ const snapshot = captureSnapshot(button);
137
+ expect(snapshot.a11y.disabled).toBe(true);
138
+ });
139
+ it('should capture disabled state from disabled attribute', () => {
140
+ const button = document.createElement('button');
141
+ button.disabled = true;
142
+ document.body.appendChild(button);
143
+ const snapshot = captureSnapshot(button);
144
+ expect(snapshot.a11y.disabled).toBe(true);
145
+ });
146
+ it('should capture aria-expanded', () => {
147
+ const button = document.createElement('button');
148
+ button.setAttribute('aria-expanded', 'true');
149
+ document.body.appendChild(button);
150
+ const snapshot = captureSnapshot(button);
151
+ expect(snapshot.a11y.expanded).toBe(true);
152
+ });
153
+ it('should capture aria-checked states', () => {
154
+ const checkbox1 = document.createElement('input');
155
+ checkbox1.type = 'checkbox';
156
+ checkbox1.setAttribute('aria-checked', 'true');
157
+ document.body.appendChild(checkbox1);
158
+ const checkbox2 = document.createElement('input');
159
+ checkbox2.type = 'checkbox';
160
+ checkbox2.setAttribute('aria-checked', 'mixed');
161
+ document.body.appendChild(checkbox2);
162
+ expect(captureSnapshot(checkbox1).a11y.checked).toBe(true);
163
+ expect(captureSnapshot(checkbox2).a11y.checked).toBe('mixed');
164
+ });
165
+ it('should capture hidden state', () => {
166
+ const div = document.createElement('div');
167
+ div.setAttribute('aria-hidden', 'true');
168
+ document.body.appendChild(div);
169
+ const snapshot = captureSnapshot(div);
170
+ expect(snapshot.a11y.hidden).toBe(true);
171
+ });
172
+ });
173
+ describe('accessible name computation', () => {
174
+ it('should use aria-labelledby', () => {
175
+ const label = document.createElement('span');
176
+ label.id = 'my-label';
177
+ label.textContent = 'Field Label';
178
+ document.body.appendChild(label);
179
+ const input = document.createElement('input');
180
+ input.setAttribute('aria-labelledby', 'my-label');
181
+ document.body.appendChild(input);
182
+ const snapshot = captureSnapshot(input);
183
+ expect(snapshot.name).toBe('Field Label');
184
+ });
185
+ it('should use associated label for inputs', () => {
186
+ const label = document.createElement('label');
187
+ label.setAttribute('for', 'my-input');
188
+ label.textContent = 'Email Address';
189
+ document.body.appendChild(label);
190
+ const input = document.createElement('input');
191
+ input.id = 'my-input';
192
+ document.body.appendChild(input);
193
+ const snapshot = captureSnapshot(input);
194
+ expect(snapshot.name).toBe('Email Address');
195
+ });
196
+ it('should use alt text for images', () => {
197
+ const img = document.createElement('img');
198
+ img.alt = 'Company logo';
199
+ document.body.appendChild(img);
200
+ const snapshot = captureSnapshot(img);
201
+ expect(snapshot.name).toBe('Company logo');
202
+ });
203
+ });
204
+ describe('geometry', () => {
205
+ it('should capture bounding rect', () => {
206
+ const div = document.createElement('div');
207
+ div.style.width = '200px';
208
+ div.style.height = '100px';
209
+ document.body.appendChild(div);
210
+ const snapshot = captureSnapshot(div);
211
+ expect(snapshot.geometry.width).toBeGreaterThanOrEqual(0);
212
+ expect(snapshot.geometry.height).toBeGreaterThanOrEqual(0);
213
+ expect(typeof snapshot.geometry.x).toBe('number');
214
+ expect(typeof snapshot.geometry.y).toBe('number');
215
+ });
216
+ it('should mark zero-size elements as not visible', () => {
217
+ const div = document.createElement('div');
218
+ div.style.width = '0px';
219
+ div.style.height = '0px';
220
+ document.body.appendChild(div);
221
+ const snapshot = captureSnapshot(div);
222
+ expect(snapshot.geometry.visible).toBe(false);
223
+ });
224
+ });
225
+ describe('styles', () => {
226
+ it('should capture display', () => {
227
+ const div = document.createElement('div');
228
+ div.style.display = 'flex';
229
+ document.body.appendChild(div);
230
+ const snapshot = captureSnapshot(div);
231
+ expect(snapshot.styles.display).toBe('flex');
232
+ });
233
+ it('should capture position', () => {
234
+ const div = document.createElement('div');
235
+ div.style.position = 'absolute';
236
+ document.body.appendChild(div);
237
+ const snapshot = captureSnapshot(div);
238
+ expect(snapshot.styles.position).toBe('absolute');
239
+ });
240
+ it('should capture flex direction when not default', () => {
241
+ const div = document.createElement('div');
242
+ div.style.display = 'flex';
243
+ div.style.flexDirection = 'column';
244
+ document.body.appendChild(div);
245
+ const snapshot = captureSnapshot(div);
246
+ expect(snapshot.styles.flexDirection).toBe('column');
247
+ });
248
+ it('should capture colors', () => {
249
+ const div = document.createElement('div');
250
+ div.style.color = 'red';
251
+ div.style.backgroundColor = 'blue';
252
+ document.body.appendChild(div);
253
+ const snapshot = captureSnapshot(div);
254
+ expect(snapshot.styles.color).toBeDefined();
255
+ expect(snapshot.styles.backgroundColor).toBeDefined();
256
+ });
257
+ });
258
+ describe('framework detection', () => {
259
+ it('should default to vanilla when no framework detected', () => {
260
+ const div = document.createElement('div');
261
+ document.body.appendChild(div);
262
+ const snapshot = captureSnapshot(div);
263
+ expect(snapshot.framework.name).toBe('vanilla');
264
+ });
265
+ });
266
+ describe('metadata', () => {
267
+ it('should include timestamp', () => {
268
+ const div = document.createElement('div');
269
+ document.body.appendChild(div);
270
+ const before = Date.now();
271
+ const snapshot = captureSnapshot(div);
272
+ const after = Date.now();
273
+ expect(snapshot.timestamp).toBeGreaterThanOrEqual(before);
274
+ expect(snapshot.timestamp).toBeLessThanOrEqual(after);
275
+ });
276
+ it('should include URL', () => {
277
+ const div = document.createElement('div');
278
+ document.body.appendChild(div);
279
+ const snapshot = captureSnapshot(div);
280
+ expect(snapshot.url).toBe(window.location.href);
281
+ });
282
+ });
283
+ });
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@eyeglass/inspector",
3
+ "version": "0.1.0",
4
+ "description": "Browser inspector component for Eyeglass",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "scripts": {
12
+ "build": "tsc",
13
+ "dev": "tsc --watch",
14
+ "test": "vitest run --project browser",
15
+ "prepublishOnly": "npm run build"
16
+ },
17
+ "keywords": [
18
+ "eyeglass",
19
+ "inspector",
20
+ "web-component",
21
+ "react",
22
+ "vue",
23
+ "svelte"
24
+ ],
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/donutboyband/eyeglass.git",
29
+ "directory": "packages/inspector"
30
+ },
31
+ "sideEffects": true,
32
+ "dependencies": {
33
+ "@eyeglass/types": "^0.1.0"
34
+ },
35
+ "devDependencies": {
36
+ "typescript": "^5.3.0"
37
+ }
38
+ }