hale-commenting-system 3.5.2 → 3.6.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.
@@ -1,16 +1,18 @@
1
1
  import * as React from 'react';
2
- import { Comment, Thread } from '../types';
2
+ import { Comment, Thread, ComponentMetadata } from '../types';
3
3
  import { getStoredUser, githubAdapter, isGitHubConfigured } from '../services/githubAdapter';
4
4
 
5
5
  interface CommentContextType {
6
6
  threads: Thread[];
7
7
  commentsEnabled: boolean;
8
8
  setCommentsEnabled: (enabled: boolean) => void;
9
+ showPinsEnabled: boolean;
10
+ setShowPinsEnabled: (enabled: boolean) => void;
9
11
  drawerPinnedOpen: boolean;
10
12
  setDrawerPinnedOpen: (open: boolean) => void;
11
13
  floatingWidgetMode: boolean;
12
14
  setFloatingWidgetMode: (mode: boolean) => void;
13
- addThread: (xPercent: number, yPercent: number, route: string, version?: string) => string;
15
+ addThread: (cssSelector: string, elementDescription: string, componentMetadata: ComponentMetadata | null, xPercent: number, yPercent: number, route: string, version?: string) => string;
14
16
  addReply: (threadId: string, text: string, parentCommentId?: string) => void;
15
17
  syncFromGitHub: (route: string, version?: string) => Promise<void>;
16
18
  retrySync: () => Promise<void>;
@@ -103,6 +105,7 @@ export const CommentProvider: React.FunctionComponent<{ children: React.ReactNod
103
105
  };
104
106
  const STORAGE_KEY = 'hale_comment_threads_v1';
105
107
  const COMMENTS_ENABLED_KEY = 'hale_comments_enabled_v1';
108
+ const SHOW_PINS_ENABLED_KEY = 'hale_show_pins_enabled_v1';
106
109
  const DRAWER_PINNED_OPEN_KEY = 'hale_drawer_pinned_open_v1';
107
110
  const FLOATING_WIDGET_MODE_KEY = 'hale_floating_widget_mode_v1';
108
111
  const HIDDEN_ISSUES_KEY = 'hale_hidden_issue_numbers_v1';
@@ -191,6 +194,14 @@ export const CommentProvider: React.FunctionComponent<{ children: React.ReactNod
191
194
  return false;
192
195
  }
193
196
  });
197
+ const [showPinsEnabled, setShowPinsEnabled] = React.useState<boolean>(() => {
198
+ try {
199
+ const raw = window.localStorage.getItem(SHOW_PINS_ENABLED_KEY);
200
+ return raw === 'true';
201
+ } catch {
202
+ return false;
203
+ }
204
+ });
194
205
  const [syncInFlightCount, setSyncInFlightCount] = React.useState(0);
195
206
  const isSyncing = syncInFlightCount > 0;
196
207
  const syncInFlightByKey = React.useRef<Map<string, Promise<void>>>(new Map());
@@ -236,7 +247,15 @@ export const CommentProvider: React.FunctionComponent<{ children: React.ReactNod
236
247
  }
237
248
  }, [floatingWidgetMode]);
238
249
 
239
- const addThread = (xPercent: number, yPercent: number, route: string, version?: string): string => {
250
+ React.useEffect(() => {
251
+ try {
252
+ window.localStorage.setItem(SHOW_PINS_ENABLED_KEY, String(showPinsEnabled));
253
+ } catch {
254
+ // ignore
255
+ }
256
+ }, [showPinsEnabled]);
257
+
258
+ const addThread = (cssSelector: string, elementDescription: string, componentMetadata: ComponentMetadata | null, xPercent: number, yPercent: number, route: string, version?: string): string => {
240
259
  const threadId = `thread-${Date.now()}`;
241
260
  const isConfigured = isGitHubConfigured();
242
261
 
@@ -244,6 +263,8 @@ export const CommentProvider: React.FunctionComponent<{ children: React.ReactNod
244
263
  threadId,
245
264
  route,
246
265
  version,
266
+ cssSelector,
267
+ elementDescription,
247
268
  xPercent: xPercent.toFixed(1),
248
269
  yPercent: yPercent.toFixed(1),
249
270
  isGitHubConfigured: isConfigured,
@@ -251,6 +272,9 @@ export const CommentProvider: React.FunctionComponent<{ children: React.ReactNod
251
272
 
252
273
  const newThread: Thread = {
253
274
  id: threadId,
275
+ cssSelector,
276
+ elementDescription,
277
+ componentMetadata: componentMetadata || undefined,
254
278
  xPercent,
255
279
  yPercent,
256
280
  route,
@@ -329,29 +353,44 @@ export const CommentProvider: React.FunctionComponent<{ children: React.ReactNod
329
353
  return threadId;
330
354
  };
331
355
 
332
- const parseCoordsFromIssueBody = (body: string): { xPercent: number; yPercent: number } | null => {
333
- const match = body.match(/Coordinates:\s*`?\(([\d.]+)%?,\s*([\d.]+)%?\)`?/i);
334
- if (!match) return null;
335
- const x = Number(match[1]);
336
- const y = Number(match[2]);
337
- if (Number.isNaN(x) || Number.isNaN(y)) return null;
338
- return { xPercent: x, yPercent: y };
339
- };
356
+ const parseMetadataFromIssueBody = (body: string): {
357
+ cssSelector?: string;
358
+ elementDescription?: string;
359
+ xPercent: number;
360
+ yPercent: number;
361
+ } => {
362
+ let cssSelector: string | undefined;
363
+ let elementDescription: string | undefined;
364
+ let xPercent = 0;
365
+ let yPercent = 0;
366
+
367
+ // Parse CSS Selector
368
+ const selectorMatch = body.match(/CSS Selector:\s*`([^`]+)`/i);
369
+ if (selectorMatch) {
370
+ cssSelector = selectorMatch[1];
371
+ }
372
+
373
+ // Parse Target Component
374
+ const componentMatch = body.match(/Target Component:\s*`([^`]+)`/i);
375
+ if (componentMatch) {
376
+ elementDescription = componentMatch[1];
377
+ }
378
+
379
+ // Parse Fallback Position (new format) or Coordinates (old format)
380
+ const fallbackMatch = body.match(/Fallback Position:\s*`?\(([\d.]+)%?,\s*([\d.]+)%?\)`?/i);
381
+ const coordMatch = body.match(/Coordinates:\s*`?\(([\d.]+)%?,\s*([\d.]+)%?\)`?/i);
382
+
383
+ const match = fallbackMatch || coordMatch;
384
+ if (match) {
385
+ const x = Number(match[1]);
386
+ const y = Number(match[2]);
387
+ if (!Number.isNaN(x) && !Number.isNaN(y)) {
388
+ xPercent = x;
389
+ yPercent = y;
390
+ }
391
+ }
340
392
 
341
- const parseCoordsFromIssueLabels = (issue: any): { xPercent: number; yPercent: number } | null => {
342
- const labels = issue?.labels;
343
- if (!Array.isArray(labels)) return null;
344
- const names = labels
345
- .map((l: any) => (typeof l === 'string' ? l : l?.name))
346
- .filter((n: any) => typeof n === 'string') as string[];
347
- const coord = names.find((n) => n.startsWith('coords:'));
348
- if (!coord) return null;
349
- const raw = coord.replace('coords:', '');
350
- const parts = raw.split(',').map((p) => Number(p.trim()));
351
- if (parts.length !== 2) return null;
352
- const [x, y] = parts;
353
- if (Number.isNaN(x) || Number.isNaN(y)) return null;
354
- return { xPercent: x, yPercent: y };
393
+ return { cssSelector, elementDescription, xPercent, yPercent };
355
394
  };
356
395
 
357
396
  const syncFromGitHub = async (route: string, version?: string) => {
@@ -395,10 +434,7 @@ export const CommentProvider: React.FunctionComponent<{ children: React.ReactNod
395
434
  const issueUrl = issue?.html_url as string | undefined;
396
435
  if (!issueNumber) continue;
397
436
 
398
- const coords =
399
- parseCoordsFromIssueBody(issue?.body || '') ||
400
- parseCoordsFromIssueLabels(issue) ||
401
- { xPercent: 0, yPercent: 0 };
437
+ const metadata = parseMetadataFromIssueBody(issue?.body || '');
402
438
 
403
439
  const commentsResult = await githubAdapter.fetchIssueComments(issueNumber);
404
440
  const ghComments = commentsResult.success && commentsResult.data ? commentsResult.data : [];
@@ -429,8 +465,10 @@ export const CommentProvider: React.FunctionComponent<{ children: React.ReactNod
429
465
  id: `gh-${issueNumber}`,
430
466
  route,
431
467
  version,
432
- xPercent: coords.xPercent,
433
- yPercent: coords.yPercent,
468
+ cssSelector: metadata.cssSelector,
469
+ elementDescription: metadata.elementDescription,
470
+ xPercent: metadata.xPercent,
471
+ yPercent: metadata.yPercent,
434
472
  comments: mappedComments,
435
473
  issueNumber,
436
474
  issueUrl,
@@ -555,8 +593,10 @@ export const CommentProvider: React.FunctionComponent<{ children: React.ReactNod
555
593
  if (!ensuredIssueNumber) {
556
594
  const created = await githubAdapter.createIssue({
557
595
  title: `Feedback: ${thread.route}`,
558
- body: `Thread created from pin at (${thread.xPercent.toFixed(1)}%, ${thread.yPercent.toFixed(1)}%).`,
596
+ body: `Thread created from pin${thread.elementDescription ? ` on ${thread.elementDescription}` : ''}.`,
559
597
  route: thread.route,
598
+ cssSelector: thread.cssSelector,
599
+ elementDescription: thread.elementDescription,
560
600
  xPercent: thread.xPercent,
561
601
  yPercent: thread.yPercent,
562
602
  version: thread.version,
@@ -947,8 +987,10 @@ export const CommentProvider: React.FunctionComponent<{ children: React.ReactNod
947
987
  setThreads((prev) => prev.map((x) => (x.id === t.id ? { ...x, syncStatus: 'syncing', syncError: undefined } : x)));
948
988
  const created = await githubAdapter.createIssue({
949
989
  title: `Feedback: ${t.route}`,
950
- body: `Thread created from pin at (${t.xPercent.toFixed(1)}%, ${t.yPercent.toFixed(1)}%).`,
990
+ body: `Thread created from pin${t.elementDescription ? ` on ${t.elementDescription}` : ''}.`,
951
991
  route: t.route,
992
+ cssSelector: t.cssSelector,
993
+ elementDescription: t.elementDescription,
952
994
  xPercent: t.xPercent,
953
995
  yPercent: t.yPercent,
954
996
  version: t.version,
@@ -1012,6 +1054,8 @@ export const CommentProvider: React.FunctionComponent<{ children: React.ReactNod
1012
1054
  threads,
1013
1055
  commentsEnabled,
1014
1056
  setCommentsEnabled,
1057
+ showPinsEnabled,
1058
+ setShowPinsEnabled,
1015
1059
  drawerPinnedOpen,
1016
1060
  setDrawerPinnedOpen,
1017
1061
  floatingWidgetMode,
@@ -14,4 +14,7 @@ export { FloatingWidget } from './components/FloatingWidget';
14
14
  export { githubAdapter, isGitHubConfigured } from './services/githubAdapter';
15
15
 
16
16
  // Types
17
- export type { Comment, Thread, SyncStatus, ThreadStatus } from './types';
17
+ export type { Comment, Thread, SyncStatus, ThreadStatus, ComponentMetadata } from './types';
18
+
19
+ // Utils
20
+ export { getComponentMetadata, getComponentPath, findNearestComponentElement } from './utils/componentUtils';
@@ -151,6 +151,8 @@ export const githubAdapter = {
151
151
  title: string;
152
152
  body: string;
153
153
  route: string;
154
+ cssSelector?: string;
155
+ elementDescription?: string;
154
156
  xPercent: number;
155
157
  yPercent: number;
156
158
  version?: string;
@@ -164,7 +166,9 @@ export const githubAdapter = {
164
166
  const metadata = [
165
167
  `- Route: \`${params.route}\``,
166
168
  params.version ? `- Version: \`${params.version}\`` : null,
167
- `- Coordinates: \`(${params.xPercent.toFixed(1)}%, ${params.yPercent.toFixed(1)}%)\``,
169
+ params.cssSelector ? `- Target Component: \`${params.elementDescription || 'unknown'}\`` : null,
170
+ params.cssSelector ? `- CSS Selector: \`${params.cssSelector}\`` : null,
171
+ `- Fallback Position: \`(${params.xPercent.toFixed(1)}%, ${params.yPercent.toFixed(1)}%)\``,
168
172
  ]
169
173
  .filter(Boolean)
170
174
  .join('\n');
@@ -181,8 +185,11 @@ export const githubAdapter = {
181
185
  const labels: string[] = [
182
186
  'hale-comment',
183
187
  `route:${params.route}`,
184
- `coords:${Math.round(params.xPercent)},${Math.round(params.yPercent)}`,
185
188
  ];
189
+ if (params.cssSelector && params.elementDescription) {
190
+ labels.push(`component:${params.elementDescription}`);
191
+ }
192
+ labels.push(`coords:${Math.round(params.xPercent)},${Math.round(params.yPercent)}`);
186
193
  if (params.version) labels.push(`version:${params.version}`);
187
194
  await githubProxyRequest('POST', `/repos/${owner}/${repo}/issues/${data.number}/labels`, { labels });
188
195
  } catch {
@@ -11,10 +11,22 @@ export interface Comment {
11
11
  export type SyncStatus = 'synced' | 'local' | 'pending' | 'syncing' | 'error';
12
12
  export type ThreadStatus = 'open' | 'closed';
13
13
 
14
+ export interface ComponentMetadata {
15
+ componentName?: string;
16
+ componentType?: string; // 'function' | 'class' | 'forwardRef' | 'memo' | 'lazy' | 'native' | 'unknown'
17
+ props?: Record<string, unknown>;
18
+ displayName?: string;
19
+ key?: string | number | null;
20
+ componentPath?: string[]; // Component tree path (e.g., ["App", "Dashboard", "Button"])
21
+ }
22
+
14
23
  export interface Thread {
15
24
  id: string;
16
- xPercent: number; // Percentage from left (0-100)
17
- yPercent: number; // Percentage from top (0-100)
25
+ cssSelector?: string; // CSS selector for target element
26
+ elementDescription?: string; // Simplified element name for display (e.g., "button.pf-c-button")
27
+ componentMetadata?: ComponentMetadata; // React component information (component-based)
28
+ xPercent: number; // Percentage from left (0-100) - used as fallback when element is deleted
29
+ yPercent: number; // Percentage from top (0-100) - used as fallback when element is deleted
18
30
  route: string;
19
31
  version?: string;
20
32
  comments: Comment[];
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Utilities for React component detection and metadata extraction
3
+ * Similar to Chrome DevTools component inspection
4
+ */
5
+
6
+ import { ComponentMetadata } from '../types';
7
+
8
+ /**
9
+ * Get React fiber node from a DOM element
10
+ * Uses React DevTools internal API if available, otherwise traverses up the DOM tree
11
+ */
12
+ export function getFiberFromElement(element: Element | null): any {
13
+ if (!element) return null;
14
+
15
+ // Try React DevTools internal API first (if DevTools is installed)
16
+ const key = Object.keys(element).find((k) => k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance'));
17
+ if (key) {
18
+ return (element as any)[key];
19
+ }
20
+
21
+ // Fallback: traverse up the DOM tree to find a React fiber
22
+ let current: Node | null = element;
23
+ while (current) {
24
+ const keys = Object.keys(current);
25
+ const fiberKey = keys.find((k) => k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance'));
26
+ if (fiberKey) {
27
+ return (current as any)[fiberKey];
28
+ }
29
+ current = current.parentNode;
30
+ }
31
+
32
+ return null;
33
+ }
34
+
35
+ /**
36
+ * Get component name from a React fiber node
37
+ */
38
+ export function getComponentName(fiber: any): string | undefined {
39
+ if (!fiber) return undefined;
40
+
41
+ // Try different fiber types
42
+ const type = fiber.type;
43
+ if (!type) return undefined;
44
+
45
+ // Function component
46
+ if (typeof type === 'function') {
47
+ return type.displayName || type.name || 'Anonymous';
48
+ }
49
+
50
+ // Forward ref
51
+ if (type.$$typeof === Symbol.for('react.forward_ref')) {
52
+ return type.render?.displayName || type.render?.name || 'ForwardRef';
53
+ }
54
+
55
+ // Memo
56
+ if (type.$$typeof === Symbol.for('react.memo')) {
57
+ const innerType = type.type;
58
+ if (typeof innerType === 'function') {
59
+ return innerType.displayName || innerType.name || 'Memo';
60
+ }
61
+ return 'Memo';
62
+ }
63
+
64
+ // String (native element)
65
+ if (typeof type === 'string') {
66
+ return type;
67
+ }
68
+
69
+ return undefined;
70
+ }
71
+
72
+ /**
73
+ * Get component type (function, class, etc.)
74
+ */
75
+ function getComponentType(fiber: any): ComponentMetadata['componentType'] {
76
+ if (!fiber) return 'unknown';
77
+
78
+ const type = fiber.type;
79
+ if (!type) return 'unknown';
80
+
81
+ if (typeof type === 'function') {
82
+ // Check if it's a class component
83
+ if (type.prototype && type.prototype.isReactComponent) {
84
+ return 'class';
85
+ }
86
+ return 'function';
87
+ }
88
+
89
+ if (type.$$typeof === Symbol.for('react.forward_ref')) {
90
+ return 'forwardRef';
91
+ }
92
+
93
+ if (type.$$typeof === Symbol.for('react.memo')) {
94
+ return 'memo';
95
+ }
96
+
97
+ if (type.$$typeof === Symbol.for('react.lazy')) {
98
+ return 'lazy';
99
+ }
100
+
101
+ if (typeof type === 'string') {
102
+ return 'native';
103
+ }
104
+
105
+ return 'unknown';
106
+ }
107
+
108
+ /**
109
+ * Extract component metadata from a DOM element
110
+ * Returns React component information if available
111
+ */
112
+ export function getComponentMetadata(element: Element | null): ComponentMetadata | null {
113
+ if (!element) return null;
114
+
115
+ const fiber = getFiberFromElement(element);
116
+ if (!fiber) return null;
117
+
118
+ const componentName = getComponentName(fiber);
119
+ const componentType = getComponentType(fiber);
120
+
121
+ // Get props (may be null for native elements)
122
+ const props = fiber.memoizedProps || fiber.pendingProps || undefined;
123
+
124
+ // Get key
125
+ const key = fiber.key !== null && fiber.key !== undefined ? fiber.key : undefined;
126
+
127
+ // Get display name
128
+ const type = fiber.type;
129
+ const displayName =
130
+ (typeof type === 'function' && (type.displayName || type.name)) ||
131
+ (type?.$$typeof === Symbol.for('react.forward_ref') && (type.render?.displayName || type.render?.name)) ||
132
+ undefined;
133
+
134
+ return {
135
+ componentName,
136
+ componentType,
137
+ props: props ? sanitizeProps(props) : undefined,
138
+ displayName,
139
+ key,
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Sanitize props for display (remove functions, circular refs, etc.)
145
+ */
146
+ function sanitizeProps(props: Record<string, unknown>): Record<string, unknown> {
147
+ const sanitized: Record<string, unknown> = {};
148
+ const seen = new WeakSet();
149
+
150
+ const sanitize = (value: unknown, depth = 0): unknown => {
151
+ if (depth > 3) return '[Max Depth]'; // Prevent infinite recursion
152
+ if (value === null || value === undefined) return value;
153
+ if (typeof value === 'function') return '[Function]';
154
+ if (typeof value === 'symbol') return '[Symbol]';
155
+ if (typeof value === 'bigint') return `[BigInt: ${value}]`;
156
+
157
+ if (typeof value === 'object') {
158
+ if (seen.has(value)) return '[Circular]';
159
+ if (value instanceof Date) return value.toISOString();
160
+ if (value instanceof RegExp) return value.toString();
161
+ if (Array.isArray(value)) {
162
+ seen.add(value);
163
+ return value.map((item) => sanitize(item, depth + 1));
164
+ }
165
+ if (value instanceof Set) return `[Set(${value.size})]`;
166
+ if (value instanceof Map) return `[Map(${value.size})]`;
167
+
168
+ // React elements
169
+ if ((value as any).$$typeof) {
170
+ const type = (value as any).type;
171
+ if (typeof type === 'string') return `<${type} />`;
172
+ if (typeof type === 'function') return `<${type.displayName || type.name || 'Component'} />`;
173
+ return '[React Element]';
174
+ }
175
+
176
+ seen.add(value);
177
+ const obj: Record<string, unknown> = {};
178
+ for (const [key, val] of Object.entries(value)) {
179
+ // Skip internal React props
180
+ if (key.startsWith('__') || key === 'ref' || key === 'key') continue;
181
+ obj[key] = sanitize(val, depth + 1);
182
+ }
183
+ return obj;
184
+ }
185
+
186
+ return value;
187
+ };
188
+
189
+ for (const [key, value] of Object.entries(props)) {
190
+ // Skip internal React props
191
+ if (key.startsWith('__') || key === 'ref' || key === 'key') continue;
192
+ sanitized[key] = sanitize(value);
193
+ }
194
+
195
+ return sanitized;
196
+ }
197
+
198
+ /**
199
+ * Get component hierarchy path (component tree path)
200
+ */
201
+ export function getComponentPath(element: Element | null): string[] {
202
+ if (!element) return [];
203
+
204
+ const path: string[] = [];
205
+ let current: Element | null = element;
206
+
207
+ while (current) {
208
+ const fiber = getFiberFromElement(current);
209
+ if (fiber) {
210
+ const name = getComponentName(fiber);
211
+ if (name && name !== 'Anonymous') {
212
+ path.unshift(name);
213
+ }
214
+ }
215
+ current = current.parentElement;
216
+ }
217
+
218
+ return path;
219
+ }
220
+
221
+ /**
222
+ * Find the nearest React component element (not a native DOM element)
223
+ */
224
+ export function findNearestComponentElement(element: Element | null): Element | null {
225
+ if (!element) return null;
226
+
227
+ let current: Element | null = element;
228
+
229
+ while (current) {
230
+ const fiber = getFiberFromElement(current);
231
+ if (fiber) {
232
+ const type = fiber.type;
233
+ // If it's a React component (not a native element), return it
234
+ if (type && typeof type !== 'string') {
235
+ return current;
236
+ }
237
+ }
238
+ current = current.parentElement;
239
+ }
240
+
241
+ return element; // Fallback to original element
242
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Utilities for generating and working with CSS selectors for element-based commenting
3
+ * Now includes React component detection for component-based commenting
4
+ */
5
+
6
+ import { getComponentMetadata, getComponentPath, findNearestComponentElement } from './componentUtils';
7
+ import { ComponentMetadata } from '../types';
8
+
9
+ /**
10
+ * Generate a stable, unique CSS selector for a DOM element.
11
+ * Priority order:
12
+ * 1. data-testid or data-id attributes (most stable)
13
+ * 2. id attribute
14
+ * 3. Combination of tag + class + aria attributes
15
+ * 4. Fall back to nth-child path from body
16
+ */
17
+ export function generateSelectorForElement(element: Element): string {
18
+ // Strategy 1: Use data-testid or data-id (testing attributes are most stable)
19
+ const testId = element.getAttribute('data-testid') || element.getAttribute('data-id');
20
+ if (testId) {
21
+ return `[data-testid="${testId}"]`;
22
+ }
23
+
24
+ // Strategy 2: Use id attribute
25
+ const id = element.getAttribute('id');
26
+ if (id) {
27
+ return `#${CSS.escape(id)}`;
28
+ }
29
+
30
+ // Strategy 3: Build selector from tag + class + aria attributes
31
+ const tagName = element.tagName.toLowerCase();
32
+ const classList = Array.from(element.classList);
33
+ const ariaLabel = element.getAttribute('aria-label');
34
+ const role = element.getAttribute('role');
35
+ const type = element.getAttribute('type');
36
+
37
+ // Build a selector with tag and primary class
38
+ let selector = tagName;
39
+
40
+ // Add first class if available (usually the main component class)
41
+ if (classList.length > 0) {
42
+ selector += `.${CSS.escape(classList[0])}`;
43
+ }
44
+
45
+ // Add aria-label for uniqueness if available
46
+ if (ariaLabel) {
47
+ selector += `[aria-label="${CSS.escape(ariaLabel)}"]`;
48
+ } else if (role) {
49
+ // Fall back to role
50
+ selector += `[role="${CSS.escape(role)}"]`;
51
+ } else if (type) {
52
+ // For inputs, include type
53
+ selector += `[type="${CSS.escape(type)}"]`;
54
+ }
55
+
56
+ // Test if this selector is unique enough
57
+ const matches = document.querySelectorAll(selector);
58
+ if (matches.length === 1 && matches[0] === element) {
59
+ return selector;
60
+ }
61
+
62
+ // Strategy 4: Fall back to nth-child path (less stable but guaranteed unique)
63
+ return getNthChildPath(element);
64
+ }
65
+
66
+ /**
67
+ * Generate nth-child based path from body to element
68
+ * This is a fallback when other strategies don't provide uniqueness
69
+ */
70
+ function getNthChildPath(element: Element): string {
71
+ const path: string[] = [];
72
+ let current: Element | null = element;
73
+
74
+ while (current && current !== document.body && current.parentElement) {
75
+ const parent = current.parentElement;
76
+ const siblings = Array.from(parent.children);
77
+ const index = siblings.indexOf(current) + 1;
78
+
79
+ const tagName = current.tagName.toLowerCase();
80
+ const classList = Array.from(current.classList);
81
+
82
+ if (classList.length > 0) {
83
+ path.unshift(`${tagName}.${CSS.escape(classList[0])}:nth-child(${index})`);
84
+ } else {
85
+ path.unshift(`${tagName}:nth-child(${index})`);
86
+ }
87
+
88
+ current = parent;
89
+ }
90
+
91
+ return path.join(' > ');
92
+ }
93
+
94
+ /**
95
+ * Get a simplified, human-readable description of an element for display.
96
+ * Prioritizes React component name if available, otherwise falls back to DOM element description.
97
+ * Format: "ComponentName" (React) or "tagName.primaryClass" (DOM)
98
+ * Examples: "Button", "Card", "button.pf-c-button", "div.pf-c-card", "input"
99
+ */
100
+ export function getElementDescription(element: Element): string {
101
+ // Try to get React component name first
102
+ const componentMeta = getComponentMetadata(element);
103
+ if (componentMeta?.componentName && componentMeta.componentType !== 'native') {
104
+ return componentMeta.componentName;
105
+ }
106
+
107
+ // Fall back to DOM element description
108
+ const tagName = element.tagName.toLowerCase();
109
+ const classList = Array.from(element.classList);
110
+
111
+ // If element has classes, use the first one (usually the main component class)
112
+ if (classList.length > 0) {
113
+ return `${tagName}.${classList[0]}`;
114
+ }
115
+
116
+ // Fall back to just tag name
117
+ return tagName;
118
+ }
119
+
120
+ /**
121
+ * Get component metadata for an element (React component information)
122
+ */
123
+ export function getElementComponentMetadata(element: Element): ComponentMetadata | null {
124
+ // Try to find the nearest React component element
125
+ const componentElement = findNearestComponentElement(element);
126
+ if (!componentElement) return null;
127
+
128
+ const metadata = getComponentMetadata(componentElement);
129
+ if (!metadata) return null;
130
+
131
+ // Add component path
132
+ const path = getComponentPath(componentElement);
133
+ return {
134
+ ...metadata,
135
+ componentPath: path.length > 0 ? path : undefined,
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Query the DOM for an element matching the given CSS selector.
141
+ * Returns the element if found, null otherwise.
142
+ */
143
+ export function findElementBySelector(selector: string | undefined): Element | null {
144
+ if (!selector) {
145
+ return null;
146
+ }
147
+
148
+ try {
149
+ return document.querySelector(selector);
150
+ } catch (error) {
151
+ // Invalid selector
152
+ console.warn('Invalid CSS selector:', selector, error);
153
+ return null;
154
+ }
155
+ }