@treelocator/runtime 0.4.7 → 0.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.
Files changed (166) hide show
  1. package/.eslintignore +1 -0
  2. package/dist/_generated_styles.d.ts +1 -1
  3. package/dist/_generated_styles.js +20 -0
  4. package/dist/_generated_tree_icon.d.ts +1 -1
  5. package/dist/adapters/HtmlElementTreeNode.d.ts +2 -2
  6. package/dist/adapters/HtmlElementTreeNode.js +4 -6
  7. package/dist/adapters/createTreeNode.js +17 -44
  8. package/dist/adapters/detectFramework.d.ts +8 -0
  9. package/dist/adapters/detectFramework.js +25 -0
  10. package/dist/adapters/detectFramework.test.d.ts +1 -0
  11. package/dist/adapters/detectFramework.test.js +60 -0
  12. package/dist/adapters/jsx/jsxAdapter.js +54 -89
  13. package/dist/adapters/jsx/jsxAdapter.test.d.ts +1 -0
  14. package/dist/adapters/jsx/jsxAdapter.test.js +273 -0
  15. package/dist/adapters/nextjs/parseNextjsDataAttributes.js +1 -1
  16. package/dist/adapters/nextjs/parseNextjsDataAttributes.test.d.ts +1 -0
  17. package/dist/adapters/nextjs/parseNextjsDataAttributes.test.js +158 -0
  18. package/dist/adapters/react/findFiberByHtmlElement.d.ts +1 -1
  19. package/dist/adapters/react/findFiberByHtmlElement.js +1 -1
  20. package/dist/adapters/react/getAllParentsElementsAndRootComponent.js +4 -0
  21. package/dist/adapters/resolveAdapter.d.ts +1 -1
  22. package/dist/adapters/resolveAdapter.js +4 -8
  23. package/dist/adapters/svelte/svelteAdapter.test.d.ts +1 -0
  24. package/dist/adapters/svelte/svelteAdapter.test.js +280 -0
  25. package/dist/adapters/vue/vueAdapter.test.d.ts +1 -0
  26. package/dist/adapters/vue/vueAdapter.test.js +222 -0
  27. package/dist/browserApi.d.ts +148 -0
  28. package/dist/browserApi.js +146 -5
  29. package/dist/browserApi.test.d.ts +1 -0
  30. package/dist/browserApi.test.js +287 -0
  31. package/dist/components/RecordingPillButton.d.ts +11 -0
  32. package/dist/components/RecordingPillButton.js +202 -0
  33. package/dist/components/RecordingResults.d.ts +2 -0
  34. package/dist/components/RecordingResults.js +213 -78
  35. package/dist/components/Runtime.js +161 -554
  36. package/dist/components/SettingsPanel.d.ts +5 -0
  37. package/dist/components/SettingsPanel.js +312 -0
  38. package/dist/consoleCapture.d.ts +9 -0
  39. package/dist/consoleCapture.js +95 -0
  40. package/dist/dejitter/recorder.d.ts +7 -1
  41. package/dist/dejitter/recorder.js +64 -1
  42. package/dist/functions/cssRuleInspector.d.ts +83 -0
  43. package/dist/functions/cssRuleInspector.js +608 -0
  44. package/dist/functions/cssRuleInspector.test.d.ts +1 -0
  45. package/dist/functions/cssRuleInspector.test.js +439 -0
  46. package/dist/functions/deduplicateLabels.test.d.ts +1 -0
  47. package/dist/functions/deduplicateLabels.test.js +178 -0
  48. package/dist/functions/enrichAncestrySourceMaps.js +0 -1
  49. package/dist/functions/extractComputedStyles.d.ts +51 -0
  50. package/dist/functions/extractComputedStyles.js +447 -0
  51. package/dist/functions/extractComputedStyles.test.d.ts +1 -0
  52. package/dist/functions/extractComputedStyles.test.js +549 -0
  53. package/dist/functions/formatAncestryChain.d.ts +8 -0
  54. package/dist/functions/formatAncestryChain.js +21 -1
  55. package/dist/functions/formatAncestryChain.test.js +18 -0
  56. package/dist/functions/getUsableName.test.d.ts +1 -0
  57. package/dist/functions/getUsableName.test.js +219 -0
  58. package/dist/functions/isCombinationModifiersPressed.test.d.ts +1 -0
  59. package/dist/functions/isCombinationModifiersPressed.test.js +192 -0
  60. package/dist/functions/mergeRects.test.js +210 -1
  61. package/dist/functions/namedSnapshots.d.ts +52 -0
  62. package/dist/functions/namedSnapshots.js +161 -0
  63. package/dist/functions/namedSnapshots.test.d.ts +1 -0
  64. package/dist/functions/namedSnapshots.test.js +85 -0
  65. package/dist/functions/normalizeFilePath.test.d.ts +1 -0
  66. package/dist/functions/normalizeFilePath.test.js +66 -0
  67. package/dist/functions/parseDataId.test.d.ts +1 -0
  68. package/dist/functions/parseDataId.test.js +101 -0
  69. package/dist/hooks/getStorage.d.ts +3 -0
  70. package/dist/hooks/getStorage.js +17 -0
  71. package/dist/hooks/useEventListeners.d.ts +15 -0
  72. package/dist/hooks/useEventListeners.js +56 -0
  73. package/dist/hooks/useLocatorStorage.d.ts +18 -0
  74. package/dist/hooks/useLocatorStorage.js +41 -0
  75. package/dist/hooks/useLocatorStorage.test.d.ts +1 -0
  76. package/dist/hooks/useLocatorStorage.test.js +124 -0
  77. package/dist/hooks/useRecordingState.d.ts +43 -0
  78. package/dist/hooks/useRecordingState.js +387 -0
  79. package/dist/hooks/useSettings.d.ts +13 -0
  80. package/dist/hooks/useSettings.js +66 -0
  81. package/dist/index.d.ts +5 -2
  82. package/dist/index.js +4 -2
  83. package/dist/initRuntime.d.ts +3 -1
  84. package/dist/initRuntime.js +4 -1
  85. package/dist/mcpBridge.d.ts +61 -0
  86. package/dist/mcpBridge.js +534 -0
  87. package/dist/mcpBridge.test.d.ts +1 -0
  88. package/dist/mcpBridge.test.js +248 -0
  89. package/dist/output.css +20 -0
  90. package/dist/visualDiff/diff.d.ts +9 -0
  91. package/dist/visualDiff/diff.js +209 -0
  92. package/dist/visualDiff/diff.test.d.ts +1 -0
  93. package/dist/visualDiff/diff.test.js +253 -0
  94. package/dist/visualDiff/settle.d.ts +3 -0
  95. package/dist/visualDiff/settle.js +50 -0
  96. package/dist/visualDiff/settle.test.d.ts +1 -0
  97. package/dist/visualDiff/settle.test.js +65 -0
  98. package/dist/visualDiff/snapshot.d.ts +4 -0
  99. package/dist/visualDiff/snapshot.js +84 -0
  100. package/dist/visualDiff/snapshot.test.d.ts +1 -0
  101. package/dist/visualDiff/snapshot.test.js +245 -0
  102. package/dist/visualDiff/types.d.ts +37 -0
  103. package/dist/visualDiff/types.js +1 -0
  104. package/package.json +2 -2
  105. package/scripts/wrapCSS.js +1 -1
  106. package/scripts/wrapImage.js +1 -1
  107. package/src/_generated_styles.ts +21 -1
  108. package/src/_generated_tree_icon.ts +1 -1
  109. package/src/adapters/HtmlElementTreeNode.ts +10 -7
  110. package/src/adapters/createTreeNode.ts +12 -51
  111. package/src/adapters/detectFramework.test.ts +73 -0
  112. package/src/adapters/detectFramework.ts +28 -0
  113. package/src/adapters/jsx/jsxAdapter.test.ts +240 -0
  114. package/src/adapters/jsx/jsxAdapter.ts +53 -106
  115. package/src/adapters/nextjs/parseNextjsDataAttributes.test.ts +212 -0
  116. package/src/adapters/nextjs/parseNextjsDataAttributes.ts +1 -1
  117. package/src/adapters/react/findDebugSource.ts +5 -6
  118. package/src/adapters/react/findFiberByHtmlElement.ts +3 -3
  119. package/src/adapters/react/getAllParentsElementsAndRootComponent.ts +3 -0
  120. package/src/adapters/react/reactAdapter.ts +1 -2
  121. package/src/adapters/resolveAdapter.ts +4 -14
  122. package/src/adapters/svelte/svelteAdapter.test.ts +334 -0
  123. package/src/adapters/vue/vueAdapter.test.ts +259 -0
  124. package/src/browserApi.test.ts +329 -0
  125. package/src/browserApi.ts +351 -4
  126. package/src/components/RecordingPillButton.tsx +301 -0
  127. package/src/components/RecordingResults.tsx +114 -13
  128. package/src/components/Runtime.tsx +176 -621
  129. package/src/components/SettingsPanel.tsx +339 -0
  130. package/src/consoleCapture.ts +113 -0
  131. package/src/dejitter/recorder.ts +67 -3
  132. package/src/functions/cssRuleInspector.test.ts +517 -0
  133. package/src/functions/cssRuleInspector.ts +708 -0
  134. package/src/functions/deduplicateLabels.test.ts +115 -0
  135. package/src/functions/enrichAncestrySourceMaps.ts +6 -3
  136. package/src/functions/extractComputedStyles.test.ts +681 -0
  137. package/src/functions/extractComputedStyles.ts +768 -0
  138. package/src/functions/formatAncestryChain.test.ts +23 -1
  139. package/src/functions/formatAncestryChain.ts +22 -1
  140. package/src/functions/getUsableName.test.ts +242 -0
  141. package/src/functions/isCombinationModifiersPressed.test.ts +156 -0
  142. package/src/functions/mergeRects.test.ts +111 -1
  143. package/src/functions/namedSnapshots.test.ts +106 -0
  144. package/src/functions/namedSnapshots.ts +232 -0
  145. package/src/functions/normalizeFilePath.test.ts +80 -0
  146. package/src/functions/parseDataId.test.ts +125 -0
  147. package/src/hooks/getStorage.ts +26 -0
  148. package/src/hooks/useEventListeners.ts +97 -0
  149. package/src/hooks/useLocatorStorage.test.ts +127 -0
  150. package/src/hooks/useLocatorStorage.ts +60 -0
  151. package/src/hooks/useRecordingState.ts +516 -0
  152. package/src/hooks/useSettings.ts +83 -0
  153. package/src/index.ts +10 -5
  154. package/src/initRuntime.ts +5 -0
  155. package/src/mcpBridge.test.ts +260 -0
  156. package/src/mcpBridge.ts +677 -0
  157. package/src/visualDiff/diff.test.ts +167 -0
  158. package/src/visualDiff/diff.ts +242 -0
  159. package/src/visualDiff/settle.test.ts +77 -0
  160. package/src/visualDiff/settle.ts +62 -0
  161. package/src/visualDiff/snapshot.test.ts +200 -0
  162. package/src/visualDiff/snapshot.ts +119 -0
  163. package/src/visualDiff/types.ts +40 -0
  164. package/tsconfig.json +3 -1
  165. package/vitest.config.ts +18 -0
  166. package/jest.config.ts +0 -195
@@ -0,0 +1,708 @@
1
+ /**
2
+ * CSS Rule Inspector
3
+ *
4
+ * Walks all loaded stylesheets, finds every rule that matches an element,
5
+ * calculates specificity, and determines which rule "wins" for each property.
6
+ * Designed to produce AI-friendly output for debugging CSS issues.
7
+ */
8
+
9
+ // ── Types ──────────────────────────────────────────────────────────────
10
+
11
+ export type SpecificityTuple = [number, number, number]; // [ids, classes, elements]
12
+
13
+ export interface CSSRuleInfo {
14
+ selector: string;
15
+ property: string;
16
+ value: string;
17
+ specificity: SpecificityTuple;
18
+ important: boolean;
19
+ source: string; // stylesheet href or 'inline' or '<style>'
20
+ /** Monotonic index in source order — higher = later in cascade. */
21
+ sourceIndex: number;
22
+ }
23
+
24
+ export interface CSSPropertyResult {
25
+ property: string;
26
+ value: string; // the winning (computed) value
27
+ rules: {
28
+ selector: string;
29
+ value: string;
30
+ specificity: SpecificityTuple;
31
+ important: boolean;
32
+ source: string;
33
+ winning: boolean;
34
+ }[];
35
+ }
36
+
37
+ export interface CSSInspectionResult {
38
+ element: string; // e.g. "button.primary#submit"
39
+ properties: CSSPropertyResult[];
40
+ unreachableSheets: string[]; // cross-origin stylesheets that couldn't be read
41
+ }
42
+
43
+ // ── Specificity ────────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * Calculate CSS specificity for a selector string.
47
+ *
48
+ * Returns [ids, classes, elements] where:
49
+ * - ids: count of #id selectors
50
+ * - classes: count of .class, [attr], :pseudo-class (except :not, :is, :where, :has)
51
+ * - elements: count of element, ::pseudo-element
52
+ *
53
+ * This is a lightweight parser that handles the vast majority of real-world
54
+ * selectors without pulling in a full CSS parser.
55
+ */
56
+ export function calculateSpecificity(selector: string): SpecificityTuple {
57
+ // Split compound selectors (comma-separated) — return max specificity.
58
+ // splitOnCommas is depth-aware (parens, brackets, strings).
59
+ const parts = splitOnCommas(selector);
60
+ if (parts.length > 1) {
61
+ let max: SpecificityTuple = [0, 0, 0];
62
+ for (const part of parts) {
63
+ const s = calculateSpecificity(part.trim());
64
+ if (compareSpecificity(s, max) > 0) {
65
+ max = s;
66
+ }
67
+ }
68
+ return max;
69
+ }
70
+
71
+ let ids = 0;
72
+ let classes = 0;
73
+ let elements = 0;
74
+
75
+ let s = selector.trim();
76
+
77
+ // Normalize legacy single-colon pseudo-elements to double-colon so they
78
+ // are correctly counted as element-level specificity (0,0,1) instead of
79
+ // being mis-classified as pseudo-classes (0,1,0).
80
+ s = s.replace(/:(before|after|first-line|first-letter)\b/gi, "::$1");
81
+
82
+ // Extract :not(), :is(), :has(), :where() — depth-aware so nested pseudos work.
83
+ // Process in reverse so removing each one doesn't shift earlier offsets.
84
+ const pseudos = extractFunctionalPseudos(s);
85
+ for (let i = pseudos.length - 1; i >= 0; i--) {
86
+ const p = pseudos[i]!;
87
+ if (p.name !== "where") {
88
+ // :not(), :is(), :has() take the specificity of their argument.
89
+ const inner = calculateSpecificity(p.arg);
90
+ ids += inner[0];
91
+ classes += inner[1];
92
+ elements += inner[2];
93
+ }
94
+ // Remove this pseudo from the working selector — :where() contributes zero.
95
+ s = s.slice(0, p.start) + s.slice(p.end);
96
+ }
97
+
98
+ // Replace string literals (attribute values) with placeholders so chars
99
+ // inside them (like commas, brackets) don't get counted.
100
+ s = stripStrings(s);
101
+
102
+ // Replace bracketed attribute selectors with a single placeholder so the
103
+ // contents (e.g. `[data-foo="a.b#c"]`) don't get counted as classes/IDs.
104
+ // Each [..] block contributes one to the class column.
105
+ let attrCount = 0;
106
+ s = s.replace(/\[[^\]]*\]/g, () => {
107
+ attrCount++;
108
+ return " ";
109
+ });
110
+ classes += attrCount;
111
+
112
+ // Count IDs: #foo
113
+ const idMatches = s.match(/#[a-zA-Z_-][\w-]*/g);
114
+ if (idMatches) ids += idMatches.length;
115
+
116
+ // Count pseudo-elements: ::before, ::after, etc.
117
+ const pseudoElementMatches = s.match(/::[a-zA-Z_-][\w-]*/g);
118
+ if (pseudoElementMatches) elements += pseudoElementMatches.length;
119
+
120
+ // Remove pseudo-elements so they don't interfere
121
+ s = s.replace(/::[a-zA-Z_-][\w-]*/g, " ");
122
+
123
+ // Remove IDs so they don't interfere with class counting
124
+ s = s.replace(/#[a-zA-Z_-][\w-]*/g, " ");
125
+
126
+ // Count classes: .foo
127
+ const classMatches = s.match(/\.[a-zA-Z_-][\w-]*/g);
128
+ if (classMatches) classes += classMatches.length;
129
+
130
+ // Count pseudo-classes: :hover, :first-child, etc.
131
+ const pseudoClassMatches = s.match(/:[a-zA-Z_-][\w-]*/g);
132
+ if (pseudoClassMatches) classes += pseudoClassMatches.length;
133
+
134
+ // Remove everything we've counted to isolate element selectors
135
+ s = s.replace(/\.[a-zA-Z_-][\w-]*/g, " ");
136
+ s = s.replace(/:[a-zA-Z_-][\w-]*/g, " ");
137
+
138
+ // Count element selectors: div, span, etc. (not combinators or *)
139
+ const elementMatches = s.match(/(?:^|[\s>+~])([a-zA-Z][\w-]*)/g);
140
+ if (elementMatches) elements += elementMatches.length;
141
+
142
+ return [ids, classes, elements];
143
+ }
144
+
145
+ /**
146
+ * Find all `:not(...)`, `:is(...)`, `:has(...)`, `:where(...)` segments in a
147
+ * selector with depth-aware parenthesis matching. Handles nested cases like
148
+ * `:not(:is(.a, .b))` and string literals containing parens.
149
+ */
150
+ function extractFunctionalPseudos(
151
+ selector: string
152
+ ): Array<{ name: string; arg: string; start: number; end: number }> {
153
+ const result: Array<{ name: string; arg: string; start: number; end: number }> = [];
154
+ const re = /:(not|is|has|where)\(/gi;
155
+ let match: RegExpExecArray | null;
156
+
157
+ while ((match = re.exec(selector)) !== null) {
158
+ const name = match[1]!.toLowerCase();
159
+ const start = match.index;
160
+ const argStart = match.index + match[0].length;
161
+
162
+ // Walk forward from argStart, tracking depth and string state, to find
163
+ // the matching close paren.
164
+ let depth = 1;
165
+ let i = argStart;
166
+ let inString: '"' | "'" | null = null;
167
+ while (i < selector.length && depth > 0) {
168
+ const ch = selector[i]!;
169
+ if (inString) {
170
+ if (ch === "\\") {
171
+ i += 2;
172
+ continue;
173
+ }
174
+ if (ch === inString) inString = null;
175
+ } else if (ch === '"' || ch === "'") {
176
+ inString = ch;
177
+ } else if (ch === "(") {
178
+ depth++;
179
+ } else if (ch === ")") {
180
+ depth--;
181
+ if (depth === 0) break;
182
+ }
183
+ i++;
184
+ }
185
+
186
+ if (depth !== 0) {
187
+ // Unmatched paren — bail out and return what we have
188
+ break;
189
+ }
190
+
191
+ const arg = selector.slice(argStart, i);
192
+ result.push({ name, arg, start, end: i + 1 });
193
+ // Skip past the close paren so the regex doesn't recurse into nested pseudos
194
+ re.lastIndex = i + 1;
195
+ }
196
+
197
+ return result;
198
+ }
199
+
200
+ /**
201
+ * Replace string literal contents with spaces to neutralize embedded
202
+ * special characters (commas, brackets, etc.) without changing offsets.
203
+ */
204
+ function stripStrings(s: string): string {
205
+ let out = "";
206
+ let inString: '"' | "'" | null = null;
207
+ for (let i = 0; i < s.length; i++) {
208
+ const ch = s[i]!;
209
+ if (inString) {
210
+ if (ch === "\\" && i + 1 < s.length) {
211
+ out += " ";
212
+ i++;
213
+ continue;
214
+ }
215
+ if (ch === inString) {
216
+ inString = null;
217
+ out += ch;
218
+ continue;
219
+ }
220
+ out += " ";
221
+ } else if (ch === '"' || ch === "'") {
222
+ inString = ch;
223
+ out += ch;
224
+ } else {
225
+ out += ch;
226
+ }
227
+ }
228
+ return out;
229
+ }
230
+
231
+ /**
232
+ * Compare two specificity tuples.
233
+ * Returns positive if a > b, negative if a < b, 0 if equal.
234
+ */
235
+ export function compareSpecificity(
236
+ a: SpecificityTuple,
237
+ b: SpecificityTuple
238
+ ): number {
239
+ if (a[0] !== b[0]) return a[0] - b[0];
240
+ if (a[1] !== b[1]) return a[1] - b[1];
241
+ return a[2] - b[2];
242
+ }
243
+
244
+ /**
245
+ * Format specificity tuple as a human-readable string.
246
+ */
247
+ export function formatSpecificity(s: SpecificityTuple): string {
248
+ return `(${s[0]},${s[1]},${s[2]})`;
249
+ }
250
+
251
+ // ── Rule collection ────────────────────────────────────────────────────
252
+
253
+ /**
254
+ * Describe the source of a stylesheet for display.
255
+ */
256
+ function describeSource(sheet: CSSStyleSheet): string {
257
+ if (sheet.href) {
258
+ // For remote stylesheets, show just the filename
259
+ try {
260
+ const url = new URL(sheet.href);
261
+ return url.pathname.split("/").pop() || sheet.href;
262
+ } catch {
263
+ return sheet.href;
264
+ }
265
+ }
266
+ if (sheet.ownerNode instanceof HTMLStyleElement) {
267
+ const id = sheet.ownerNode.id;
268
+ if (id) return `<style#${id}>`;
269
+ // Check for data attributes that might hint at CSS-in-JS
270
+ const dataset = sheet.ownerNode.dataset;
271
+ if (dataset.emotion) return `<style emotion="${dataset.emotion}">`;
272
+ if (dataset.styledComponents) return `<style styled-components>`;
273
+ return "<style>";
274
+ }
275
+ return "unknown";
276
+ }
277
+
278
+ /**
279
+ * Describe an element for display: tagName.class1.class2#id
280
+ */
281
+ export function describeElement(element: Element): string {
282
+ let desc = element.tagName.toLowerCase();
283
+ if (element.id) {
284
+ desc += `#${element.id}`;
285
+ }
286
+ if (element.classList.length > 0) {
287
+ desc += "." + Array.from(element.classList).join(".");
288
+ }
289
+ return desc;
290
+ }
291
+
292
+ /**
293
+ * Compute the specificity of the branch(es) of a selector list that actually
294
+ * match the element. `selectorText` may be comma-separated like
295
+ * `.btn, #app` — `element.matches()` succeeds if any branch matches, but
296
+ * specificity must come from the matching branch (not the max of all of them).
297
+ */
298
+ function specificityForMatchingBranches(
299
+ selectorText: string,
300
+ element: Element
301
+ ): SpecificityTuple {
302
+ const branches = splitOnCommas(selectorText);
303
+ let best: SpecificityTuple | null = null;
304
+
305
+ for (const branch of branches) {
306
+ const trimmed = branch.trim();
307
+ if (!trimmed) continue;
308
+ try {
309
+ if (element.matches(trimmed)) {
310
+ const spec = calculateSpecificity(trimmed);
311
+ if (!best || compareSpecificity(spec, best) > 0) {
312
+ best = spec;
313
+ }
314
+ }
315
+ } catch {
316
+ // Skip branches that error in matches() (rare invalid syntax)
317
+ }
318
+ }
319
+
320
+ // Fallback: should be unreachable since the caller already verified
321
+ // element.matches(selectorText), but stay safe.
322
+ return best ?? calculateSpecificity(selectorText);
323
+ }
324
+
325
+ /**
326
+ * Walk a list of CSS rules (handles @media, @supports, @layer nesting).
327
+ * Mutates `results` and `nextIndex` (a counter object) so that every rule
328
+ * gets a monotonically increasing source-order index.
329
+ */
330
+ function walkRules(
331
+ rules: CSSRuleList,
332
+ element: Element,
333
+ source: string,
334
+ results: CSSRuleInfo[],
335
+ nextIndex: { value: number }
336
+ ): void {
337
+ // Feature-detect the rule constructors so we don't crash in environments
338
+ // (older jsdom, some embedded browsers) where they're undefined.
339
+ const SupportsRuleCtor =
340
+ typeof CSSSupportsRule !== "undefined" ? CSSSupportsRule : null;
341
+ const MediaRuleCtor =
342
+ typeof CSSMediaRule !== "undefined" ? CSSMediaRule : null;
343
+ const StyleRuleCtor =
344
+ typeof CSSStyleRule !== "undefined" ? CSSStyleRule : null;
345
+ const ImportRuleCtor =
346
+ typeof CSSImportRule !== "undefined" ? CSSImportRule : null;
347
+
348
+ for (let i = 0; i < rules.length; i++) {
349
+ const rule = rules[i]!;
350
+
351
+ if (StyleRuleCtor && rule instanceof StyleRuleCtor) {
352
+ // Check if the element matches this selector
353
+ try {
354
+ if (element.matches(rule.selectorText)) {
355
+ // Use the specificity of the *matching branch*, not the whole list.
356
+ const specificity = specificityForMatchingBranches(
357
+ rule.selectorText,
358
+ element
359
+ );
360
+ const style = rule.style;
361
+ for (let j = 0; j < style.length; j++) {
362
+ const property = style[j]!;
363
+ const value = style.getPropertyValue(property);
364
+ const important = style.getPropertyPriority(property) === "important";
365
+ results.push({
366
+ selector: rule.selectorText,
367
+ property,
368
+ value,
369
+ specificity,
370
+ important,
371
+ source,
372
+ sourceIndex: nextIndex.value++,
373
+ });
374
+ }
375
+ }
376
+ } catch {
377
+ // Invalid selector — skip
378
+ }
379
+ } else if (MediaRuleCtor && rule instanceof MediaRuleCtor) {
380
+ // Only recurse if the media query is currently active.
381
+ const mq =
382
+ typeof window !== "undefined" && typeof window.matchMedia === "function"
383
+ ? window.matchMedia(rule.conditionText)
384
+ : null;
385
+ if (!mq || mq.matches) {
386
+ walkRules(rule.cssRules, element, source, results, nextIndex);
387
+ }
388
+ } else if (SupportsRuleCtor && rule instanceof SupportsRuleCtor) {
389
+ // The browser only lists @supports rules whose condition is met.
390
+ walkRules(rule.cssRules, element, source, results, nextIndex);
391
+ } else if (ImportRuleCtor && rule instanceof ImportRuleCtor) {
392
+ // Recurse into @import stylesheets
393
+ try {
394
+ if (rule.styleSheet) {
395
+ const importSource = describeSource(rule.styleSheet);
396
+ walkRules(
397
+ rule.styleSheet.cssRules,
398
+ element,
399
+ importSource,
400
+ results,
401
+ nextIndex
402
+ );
403
+ }
404
+ } catch {
405
+ // Cross-origin stylesheet or failed to load — skip
406
+ }
407
+ } else if ("cssRules" in rule && (rule as any).cssRules) {
408
+ // Handle @layer and other grouping rules
409
+ walkRules((rule as any).cssRules, element, source, results, nextIndex);
410
+ }
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Collect all CSS rules from all stylesheets that match the given element.
416
+ * The shared `nextIndex` counter assigns each rule a monotonic source-order
417
+ * index used as the cascade tie-breaker.
418
+ */
419
+ function collectMatchingRules(
420
+ element: Element,
421
+ nextIndex: { value: number }
422
+ ): { rules: CSSRuleInfo[]; unreachableSheets: string[] } {
423
+ const rules: CSSRuleInfo[] = [];
424
+ const unreachableSheets: string[] = [];
425
+
426
+ for (let i = 0; i < document.styleSheets.length; i++) {
427
+ const sheet = document.styleSheets[i]!;
428
+ const source = describeSource(sheet);
429
+
430
+ try {
431
+ const cssRules = sheet.cssRules;
432
+ walkRules(cssRules, element, source, rules, nextIndex);
433
+ } catch {
434
+ // Cross-origin stylesheet — can't read its rules
435
+ unreachableSheets.push(source);
436
+ }
437
+ }
438
+
439
+ return { rules, unreachableSheets };
440
+ }
441
+
442
+ /**
443
+ * Collect inline styles from an element.
444
+ */
445
+ function collectInlineStyles(
446
+ element: Element,
447
+ nextIndex: { value: number }
448
+ ): CSSRuleInfo[] {
449
+ if (!(element instanceof HTMLElement)) return [];
450
+ const style = element.style;
451
+ const rules: CSSRuleInfo[] = [];
452
+
453
+ for (let i = 0; i < style.length; i++) {
454
+ const property = style[i]!;
455
+ const value = style.getPropertyValue(property);
456
+ const important = style.getPropertyPriority(property) === "important";
457
+ rules.push({
458
+ selector: "element.style",
459
+ property,
460
+ value,
461
+ specificity: [1, 0, 0] as SpecificityTuple, // inline = highest layer (treated as 1,0,0 for sorting)
462
+ important,
463
+ source: "inline",
464
+ sourceIndex: nextIndex.value++,
465
+ });
466
+ }
467
+
468
+ return rules;
469
+ }
470
+
471
+ // ── Main API ───────────────────────────────────────────────────────────
472
+
473
+ /**
474
+ * Sort function for CSS rules: determines cascade winner. Sorts winners FIRST
475
+ * (descending priority), so callers can take `sorted[0]` as the winning rule.
476
+ *
477
+ * Cascade order (highest priority first):
478
+ * 1. !important beats non-important
479
+ * 2. Inline style beats stylesheet rules (for same importance)
480
+ * 3. Higher specificity beats lower
481
+ * 4. Later source order beats earlier
482
+ */
483
+ function cascadeCompare(a: CSSRuleInfo, b: CSSRuleInfo): number {
484
+ // !important always wins
485
+ if (a.important !== b.important) {
486
+ return a.important ? -1 : 1;
487
+ }
488
+ // Inline styles win over stylesheet rules (unless !important reverses it)
489
+ if ((a.source === "inline") !== (b.source === "inline")) {
490
+ return a.source === "inline" ? -1 : 1;
491
+ }
492
+ // Higher specificity wins
493
+ const specCmp = compareSpecificity(a.specificity, b.specificity);
494
+ if (specCmp !== 0) return -specCmp; // negative because we want descending
495
+ // Tie-breaker: later source order wins (higher index sorts first).
496
+ return b.sourceIndex - a.sourceIndex;
497
+ }
498
+
499
+ /**
500
+ * Inspect an element and return all CSS rules grouped by property,
501
+ * showing which rule wins for each property.
502
+ */
503
+ export function inspectCSSRules(element: Element): CSSInspectionResult {
504
+ const nextIndex = { value: 0 };
505
+ const { rules: sheetRules, unreachableSheets } = collectMatchingRules(
506
+ element,
507
+ nextIndex
508
+ );
509
+ const inlineRules = collectInlineStyles(element, nextIndex);
510
+ const allRules = [...sheetRules, ...inlineRules];
511
+
512
+ // Hoist getComputedStyle out of the per-property loop. May be null if the
513
+ // element isn't in the document or in environments without a window.
514
+ let computed: CSSStyleDeclaration | null = null;
515
+ try {
516
+ if (typeof window !== "undefined" && typeof window.getComputedStyle === "function") {
517
+ computed = window.getComputedStyle(element);
518
+ }
519
+ } catch {
520
+ computed = null;
521
+ }
522
+
523
+ // Group by property
524
+ const byProperty = new Map<string, CSSRuleInfo[]>();
525
+ for (const rule of allRules) {
526
+ const existing = byProperty.get(rule.property);
527
+ if (existing) {
528
+ existing.push(rule);
529
+ } else {
530
+ byProperty.set(rule.property, [rule]);
531
+ }
532
+ }
533
+
534
+ // For each property, sort by cascade priority and mark the winner
535
+ const properties: CSSPropertyResult[] = [];
536
+ for (const [property, rules] of byProperty) {
537
+ // Sort: winner first
538
+ const sorted = [...rules].sort(cascadeCompare);
539
+
540
+ // Prefer the browser's computed value; fall back to the winning rule's value.
541
+ let computedValue = sorted[0]?.value || "";
542
+ const cv = computed?.getPropertyValue(property);
543
+ if (cv) computedValue = cv;
544
+
545
+ properties.push({
546
+ property,
547
+ value: computedValue,
548
+ rules: sorted.map((rule, idx) => ({
549
+ selector: rule.selector,
550
+ value: rule.value,
551
+ specificity: rule.specificity,
552
+ important: rule.important,
553
+ source: rule.source,
554
+ winning: idx === 0,
555
+ })),
556
+ });
557
+ }
558
+
559
+ // Sort properties alphabetically for stable output
560
+ properties.sort((a, b) => a.property.localeCompare(b.property));
561
+
562
+ return {
563
+ element: describeElement(element),
564
+ properties,
565
+ unreachableSheets,
566
+ };
567
+ }
568
+
569
+ /**
570
+ * Format CSS inspection result as a human-readable string for AI consumption.
571
+ *
572
+ * Example output:
573
+ *
574
+ * CSS Rules for button.primary#submit
575
+ * ════════════════════════════════════
576
+ *
577
+ * color: #333
578
+ * ✓ .button.primary (0,2,0) — components.css
579
+ * ✗ .button (0,1,0) — base.css
580
+ * ✗ button (0,0,1) — reset.css
581
+ *
582
+ * font-size: 14px
583
+ * ✓ element.style (inline) — inline
584
+ * ✗ .button (0,1,0) — base.css
585
+ */
586
+ export function formatCSSInspection(result: CSSInspectionResult): string {
587
+ const lines: string[] = [];
588
+
589
+ const header = `CSS Rules for ${result.element}`;
590
+ lines.push(header);
591
+ lines.push("═".repeat(header.length));
592
+
593
+ if (result.unreachableSheets.length > 0) {
594
+ lines.push("");
595
+ lines.push(
596
+ `⚠ Cross-origin stylesheets (unreadable): ${result.unreachableSheets.join(", ")}`
597
+ );
598
+ }
599
+
600
+ if (result.properties.length === 0) {
601
+ lines.push("");
602
+ lines.push("No matching CSS rules found.");
603
+ return lines.join("\n");
604
+ }
605
+
606
+ for (const prop of result.properties) {
607
+ lines.push("");
608
+ lines.push(`${prop.property}: ${prop.value}`);
609
+
610
+ for (const rule of prop.rules) {
611
+ const mark = rule.winning ? "✓" : "✗";
612
+ const imp = rule.important ? " !important" : "";
613
+ const spec =
614
+ rule.source === "inline"
615
+ ? "(inline)"
616
+ : formatSpecificity(rule.specificity);
617
+
618
+ lines.push(
619
+ ` ${mark} ${rule.selector} ${spec}${imp} — ${rule.source} ${
620
+ !rule.winning ? `[${rule.value}]` : ""
621
+ }`.trimEnd()
622
+ );
623
+ }
624
+ }
625
+
626
+ return lines.join("\n");
627
+ }
628
+
629
+ // ── Helpers ────────────────────────────────────────────────────────────
630
+
631
+ /**
632
+ * Split a selector on top-level commas. Aware of:
633
+ * - parentheses (`:is(.a, .b)`)
634
+ * - brackets (`[data-value="a,b"]`)
635
+ * - string literals (`"a,b"`, `'a,b'`)
636
+ * - escape sequences (`\,`)
637
+ */
638
+ function splitOnCommas(selector: string): string[] {
639
+ const parts: string[] = [];
640
+ let current = "";
641
+ let parenDepth = 0;
642
+ let bracketDepth = 0;
643
+ let inString: '"' | "'" | null = null;
644
+ let escapeNext = false;
645
+
646
+ for (let i = 0; i < selector.length; i++) {
647
+ const ch = selector[i]!;
648
+
649
+ if (escapeNext) {
650
+ current += ch;
651
+ escapeNext = false;
652
+ continue;
653
+ }
654
+
655
+ if (ch === "\\") {
656
+ current += ch;
657
+ escapeNext = true;
658
+ continue;
659
+ }
660
+
661
+ if (inString) {
662
+ current += ch;
663
+ if (ch === inString) inString = null;
664
+ continue;
665
+ }
666
+
667
+ if (ch === '"' || ch === "'") {
668
+ inString = ch;
669
+ current += ch;
670
+ continue;
671
+ }
672
+
673
+ if (ch === "(") {
674
+ parenDepth++;
675
+ current += ch;
676
+ continue;
677
+ }
678
+
679
+ if (ch === ")") {
680
+ if (parenDepth > 0) parenDepth--;
681
+ current += ch;
682
+ continue;
683
+ }
684
+
685
+ if (ch === "[") {
686
+ bracketDepth++;
687
+ current += ch;
688
+ continue;
689
+ }
690
+
691
+ if (ch === "]") {
692
+ if (bracketDepth > 0) bracketDepth--;
693
+ current += ch;
694
+ continue;
695
+ }
696
+
697
+ if (ch === "," && parenDepth === 0 && bracketDepth === 0) {
698
+ parts.push(current);
699
+ current = "";
700
+ continue;
701
+ }
702
+
703
+ current += ch;
704
+ }
705
+
706
+ parts.push(current);
707
+ return parts;
708
+ }