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