cssprobe-cli 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.
- package/AGENTS.md +77 -0
- package/LICENSE +21 -0
- package/README.md +218 -0
- package/bin/cssprobe-cli.js +6 -0
- package/dist/browser/launcher.d.ts +25 -0
- package/dist/browser/launcher.js +213 -0
- package/dist/cli/command.d.ts +27 -0
- package/dist/cli/command.js +84 -0
- package/dist/cli/commands.d.ts +42 -0
- package/dist/cli/commands.js +166 -0
- package/dist/cli/minimist.d.ts +9 -0
- package/dist/cli/minimist.js +120 -0
- package/dist/cli/output.d.ts +26 -0
- package/dist/cli/output.js +80 -0
- package/dist/cli/program.d.ts +1 -0
- package/dist/cli/program.js +381 -0
- package/dist/collector-bundle.js +342 -0
- package/dist/config/config.d.ts +23 -0
- package/dist/config/config.js +128 -0
- package/dist/config/helpGenerator.d.ts +10 -0
- package/dist/config/helpGenerator.js +156 -0
- package/dist/engine/analyzer.d.ts +2 -0
- package/dist/engine/analyzer.js +385 -0
- package/dist/engine/collector.d.ts +3 -0
- package/dist/engine/collector.js +291 -0
- package/dist/engine/renderer.d.ts +3 -0
- package/dist/engine/renderer.js +159 -0
- package/dist/engine/types.d.ts +130 -0
- package/dist/engine/types.js +4 -0
- package/dist/help.json +107 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +6 -0
- package/dist/installer/index.d.ts +3 -0
- package/dist/installer/index.js +12 -0
- package/dist/installer/skill-template.d.ts +9 -0
- package/dist/installer/skill-template.js +274 -0
- package/dist/installer/targets/registry.d.ts +9 -0
- package/dist/installer/targets/registry.js +121 -0
- package/dist/installer/targets/shared.d.ts +9 -0
- package/dist/installer/targets/shared.js +112 -0
- package/dist/installer/targets/types.d.ts +22 -0
- package/dist/installer/targets/types.js +3 -0
- package/docs/guide/installation.md +230 -0
- package/package.json +51 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Node-side analyzer. Pure functions: Snapshot → Finding[].
|
|
3
|
+
// No browser or filesystem access — directly unit-testable.
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.analyze = analyze;
|
|
6
|
+
// ─── Confidence helpers ───
|
|
7
|
+
/** Pick the winning declared value for a property (inline wins, else first rule). */
|
|
8
|
+
function declaredFor(node, prop) {
|
|
9
|
+
const arr = node.declared && node.declared[prop];
|
|
10
|
+
if (!arr || arr.length === 0)
|
|
11
|
+
return null;
|
|
12
|
+
return arr.find(d => d.selector === 'inline') || arr[0];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Confidence of a declaration-backed claim:
|
|
16
|
+
* - accessible inline/rule value → DEFINITE
|
|
17
|
+
* - accessible value that is a % (resolves at runtime) → INDEFINITE
|
|
18
|
+
* - only inaccessible (blocked sheet) or missing declaration → UNVERIFIABLE
|
|
19
|
+
*/
|
|
20
|
+
function confidenceFor(node, props) {
|
|
21
|
+
let sawAccessible = false;
|
|
22
|
+
let sawPercent = false;
|
|
23
|
+
for (const prop of props) {
|
|
24
|
+
const d = declaredFor(node, prop);
|
|
25
|
+
if (!d)
|
|
26
|
+
continue;
|
|
27
|
+
if (!d.accessible)
|
|
28
|
+
return 'UNVERIFIABLE';
|
|
29
|
+
sawAccessible = true;
|
|
30
|
+
if (d.value.endsWith('%'))
|
|
31
|
+
sawPercent = true;
|
|
32
|
+
}
|
|
33
|
+
if (!sawAccessible)
|
|
34
|
+
return 'UNVERIFIABLE';
|
|
35
|
+
return sawPercent ? 'INDEFINITE' : 'DEFINITE';
|
|
36
|
+
}
|
|
37
|
+
function evDeclared(d) {
|
|
38
|
+
return {
|
|
39
|
+
type: 'declared',
|
|
40
|
+
property: 'height',
|
|
41
|
+
value: d.value,
|
|
42
|
+
source: `${d.selector} (${d.href || 'inline'})`,
|
|
43
|
+
accessible: d.accessible,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function evComputed(node, prop) {
|
|
47
|
+
return {
|
|
48
|
+
type: 'computed',
|
|
49
|
+
property: prop,
|
|
50
|
+
value: node.props[prop] ?? '',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const ABS_UNIT = /^(0|[1-9]\d*)(\.\d+)?(px|pt|cm|mm|in|vh|vw|vmin|vmax|rem|em)$/;
|
|
54
|
+
function nodeLabel(n) {
|
|
55
|
+
const cls = (n.classes || []).join('.');
|
|
56
|
+
return `${n.tag}.${cls}`;
|
|
57
|
+
}
|
|
58
|
+
function makeFinding(partial) {
|
|
59
|
+
return {
|
|
60
|
+
id: partial.id,
|
|
61
|
+
level: partial.level,
|
|
62
|
+
message: partial.message,
|
|
63
|
+
confidence: partial.confidence ?? 'UNVERIFIABLE',
|
|
64
|
+
evidence: partial.evidence ?? [],
|
|
65
|
+
...(partial.location ? { location: partial.location } : {}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
// ─── Ancestors ───
|
|
69
|
+
function findAncestor(ancestors, predicate) {
|
|
70
|
+
return ancestors.find(predicate);
|
|
71
|
+
}
|
|
72
|
+
function allAncestorsContentSized(ancestors) {
|
|
73
|
+
return ancestors.length > 0 && ancestors.every(a => a.shape.heightStrategy === 'content');
|
|
74
|
+
}
|
|
75
|
+
function walkTree(root) {
|
|
76
|
+
const scrollNodes = [];
|
|
77
|
+
const constraintNodes = [];
|
|
78
|
+
const flexPairs = [];
|
|
79
|
+
const cbNodes = [];
|
|
80
|
+
const walk = (n, parent) => {
|
|
81
|
+
if (!n)
|
|
82
|
+
return;
|
|
83
|
+
if (n.flags.hasScrollY || n.props.overflowY === 'auto' || n.props.overflowY === 'scroll') {
|
|
84
|
+
scrollNodes.push(n);
|
|
85
|
+
}
|
|
86
|
+
const parentHealthy = parent && parent.metrics.clientHeight > 0;
|
|
87
|
+
if (n.flags.overflowsParent && !(n.props.overflowY === 'auto' || n.props.overflowY === 'scroll') && parentHealthy) {
|
|
88
|
+
constraintNodes.push(n);
|
|
89
|
+
}
|
|
90
|
+
if (parent && (parent.props.display === 'flex' || parent.props.display === 'inline-flex')) {
|
|
91
|
+
flexPairs.push({ parent, child: n });
|
|
92
|
+
}
|
|
93
|
+
if (n.containingBlockModifiers && n.containingBlockModifiers.length > 0) {
|
|
94
|
+
cbNodes.push(n);
|
|
95
|
+
}
|
|
96
|
+
for (const child of n.children)
|
|
97
|
+
walk(child, n);
|
|
98
|
+
};
|
|
99
|
+
walk(root, null);
|
|
100
|
+
if (root && root.flags.overflowsParent && !(root.props.overflowY === 'auto' || root.props.overflowY === 'scroll')) {
|
|
101
|
+
constraintNodes.push(root);
|
|
102
|
+
}
|
|
103
|
+
return { scrollNodes, constraintNodes, flexPairs, cbNodes };
|
|
104
|
+
}
|
|
105
|
+
// ─── Analyzers ───
|
|
106
|
+
function analyzeAnchors(snapshot) {
|
|
107
|
+
const findings = [];
|
|
108
|
+
const ancestors = snapshot.ancestors;
|
|
109
|
+
const anchorInfo = ancestors.map(a => {
|
|
110
|
+
const dh = declaredFor(a, 'height');
|
|
111
|
+
const mh = declaredFor(a, 'max-height');
|
|
112
|
+
return {
|
|
113
|
+
node: a,
|
|
114
|
+
heightDeclared: dh,
|
|
115
|
+
maxHeightDeclared: mh,
|
|
116
|
+
computedHeight: a.props.height,
|
|
117
|
+
hasAbsolute: !!(dh && ABS_UNIT.test(dh.value)),
|
|
118
|
+
hasPercent: !!(dh && dh.value.endsWith('%')),
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
const anchored = anchorInfo.find(x => x.hasAbsolute);
|
|
122
|
+
// Main anchor claim
|
|
123
|
+
const message = anchored
|
|
124
|
+
? `chain has absolute-unit height at ${anchored.node.label}: ${anchored.heightDeclared.value}`
|
|
125
|
+
: 'all heights are % or missing → chain depends on containing block runtime resolution (may resolve to auto)';
|
|
126
|
+
const anchorConf = anchored
|
|
127
|
+
? confidenceFor(anchored.node, ['height'])
|
|
128
|
+
: (() => {
|
|
129
|
+
const confs = anchorInfo.map(x => x.heightDeclared ? (x.heightDeclared.accessible ? (x.hasPercent ? 'INDEFINITE' : 'DEFINITE') : 'UNVERIFIABLE') : 'UNVERIFIABLE');
|
|
130
|
+
return confs.includes('UNVERIFIABLE') ? 'UNVERIFIABLE' : (confs.includes('INDEFINITE') ? 'INDEFINITE' : 'DEFINITE');
|
|
131
|
+
})();
|
|
132
|
+
findings.push(makeFinding({
|
|
133
|
+
id: anchored ? 'anchor-present' : 'anchor-missing',
|
|
134
|
+
level: anchored ? 'info' : 'warning',
|
|
135
|
+
message,
|
|
136
|
+
confidence: anchorConf,
|
|
137
|
+
evidence: anchorInfo.slice(0, 6).flatMap(x => {
|
|
138
|
+
const ev = [];
|
|
139
|
+
if (x.heightDeclared)
|
|
140
|
+
ev.push({ type: 'declared', property: 'height', value: x.heightDeclared.value, source: `${x.heightDeclared.selector} (${x.heightDeclared.href || 'inline'})`, accessible: x.heightDeclared.accessible });
|
|
141
|
+
ev.push({ type: 'computed', property: 'height', value: x.computedHeight });
|
|
142
|
+
return ev;
|
|
143
|
+
}),
|
|
144
|
+
location: anchored ? anchored.node.label : ancestors[ancestors.length - 1]?.label,
|
|
145
|
+
}));
|
|
146
|
+
// max-height-only cap note
|
|
147
|
+
const maxOnly = anchorInfo.filter(x => !x.heightDeclared && x.maxHeightDeclared);
|
|
148
|
+
if (maxOnly.length) {
|
|
149
|
+
const evs = maxOnly.slice(0, 3).flatMap(x => x.maxHeightDeclared ? [evDeclared(x.maxHeightDeclared)] : []);
|
|
150
|
+
findings.push(makeFinding({
|
|
151
|
+
id: 'max-height-only',
|
|
152
|
+
level: 'warning',
|
|
153
|
+
message: `these nodes only declare max-height (cap, not anchor) → % children still resolve to auto: ${maxOnly.map(x => x.node.label).join(', ')}`,
|
|
154
|
+
confidence: 'DEFINITE',
|
|
155
|
+
evidence: evs,
|
|
156
|
+
location: maxOnly[0].node.label,
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
return findings;
|
|
160
|
+
}
|
|
161
|
+
function analyzeScroll(snapshot) {
|
|
162
|
+
const findings = [];
|
|
163
|
+
const { scrollNodes, constraintNodes } = walkTree(snapshot.tree);
|
|
164
|
+
for (const n of constraintNodes.slice(0, 8)) {
|
|
165
|
+
findings.push(makeFinding({
|
|
166
|
+
id: 'constraint-candidate',
|
|
167
|
+
level: 'warning',
|
|
168
|
+
message: `${nodeLabel(n)} overflows parent but is not a scroll container → constraint candidate (flex child min-height:auto or parent height unconstrained)`,
|
|
169
|
+
confidence: 'DEFINITE',
|
|
170
|
+
evidence: [
|
|
171
|
+
evComputed(n, 'overflowY'),
|
|
172
|
+
{ type: 'computed', property: 'scrollHeight', value: String(n.metrics.scrollHeight) },
|
|
173
|
+
{ type: 'computed', property: 'clientHeight', value: String(n.metrics.clientHeight) },
|
|
174
|
+
],
|
|
175
|
+
location: nodeLabel(n),
|
|
176
|
+
}));
|
|
177
|
+
}
|
|
178
|
+
for (const n of scrollNodes) {
|
|
179
|
+
const isScrollDeclared = n.props.overflowY === 'auto' || n.props.overflowY === 'scroll';
|
|
180
|
+
// Computed overflow-y is a fact from getComputedStyle — always DEFINITE
|
|
181
|
+
const conf = 'DEFINITE';
|
|
182
|
+
let id;
|
|
183
|
+
let message;
|
|
184
|
+
let level;
|
|
185
|
+
if (n.metrics.clientHeight === 0) {
|
|
186
|
+
id = 'scroll-collapsed';
|
|
187
|
+
message = `${nodeLabel(n)} height collapsed to 0 → overflow-y:${n.props.overflowY} has no viewport → height chain unconstrained (anchor issue)`;
|
|
188
|
+
level = 'error';
|
|
189
|
+
}
|
|
190
|
+
else if (n.flags.scrollable) {
|
|
191
|
+
id = 'scrollable';
|
|
192
|
+
message = `${nodeLabel(n)} scrollable (scrollHeight ${n.metrics.scrollHeight} > clientHeight ${n.metrics.clientHeight})`;
|
|
193
|
+
level = 'info';
|
|
194
|
+
}
|
|
195
|
+
else if (!n.flags.hasScrollY && isScrollDeclared && Math.abs(n.metrics.offsetHeight - n.metrics.scrollHeight) <= 1) {
|
|
196
|
+
id = 'scroll-content-sized';
|
|
197
|
+
message = `${nodeLabel(n)} content-sized (${n.metrics.offsetHeight}px, scrollHeight==clientHeight) → overflow-y:auto never triggers → height chain unconstrained (anchor issue)`;
|
|
198
|
+
level = 'warning';
|
|
199
|
+
}
|
|
200
|
+
else if (!n.flags.hasScrollY) {
|
|
201
|
+
id = 'no-overflow';
|
|
202
|
+
message = `${nodeLabel(n)} no overflow, no scroll needed`;
|
|
203
|
+
level = 'info';
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
id = 'overflow-clipped';
|
|
207
|
+
message = `${nodeLabel(n)} content overflows (${n.metrics.scrollHeight}>${n.metrics.clientHeight}) clipped by overflow=${n.props.overflowY} → height chain unconstrained (anchor issue)`;
|
|
208
|
+
level = 'warning';
|
|
209
|
+
}
|
|
210
|
+
findings.push(makeFinding({
|
|
211
|
+
id,
|
|
212
|
+
level,
|
|
213
|
+
message,
|
|
214
|
+
confidence: conf,
|
|
215
|
+
evidence: [
|
|
216
|
+
evComputed(n, 'overflowY'),
|
|
217
|
+
{ type: 'computed', property: 'scrollHeight', value: String(n.metrics.scrollHeight) },
|
|
218
|
+
{ type: 'computed', property: 'clientHeight', value: String(n.metrics.clientHeight) },
|
|
219
|
+
],
|
|
220
|
+
location: nodeLabel(n),
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
223
|
+
return findings;
|
|
224
|
+
}
|
|
225
|
+
function analyzeFlex(snapshot) {
|
|
226
|
+
const findings = [];
|
|
227
|
+
const { flexPairs } = walkTree(snapshot.tree);
|
|
228
|
+
if (flexPairs.length === 0) {
|
|
229
|
+
findings.push(makeFinding({
|
|
230
|
+
id: 'no-flex',
|
|
231
|
+
level: 'info',
|
|
232
|
+
message: 'no flex parent-child relationships found in the tree',
|
|
233
|
+
confidence: 'DEFINITE',
|
|
234
|
+
}));
|
|
235
|
+
return findings;
|
|
236
|
+
}
|
|
237
|
+
for (const { parent, child } of flexPairs.slice(0, 10)) {
|
|
238
|
+
const pLabel = nodeLabel(parent);
|
|
239
|
+
const cLabel = nodeLabel(child);
|
|
240
|
+
const pDir = parent.props.flexDirection || 'row';
|
|
241
|
+
const cMinH = parent.props.minHeight;
|
|
242
|
+
const cMinW = child.props.minWidth;
|
|
243
|
+
const cFlexGrow = child.props.flexGrow;
|
|
244
|
+
const cFlexShrink = child.props.flexShrink;
|
|
245
|
+
if (pDir === 'column') {
|
|
246
|
+
if ((cMinH === 'auto' || cMinH === '0px') && child.metrics.scrollHeight > child.metrics.clientHeight + 1) {
|
|
247
|
+
findings.push(makeFinding({
|
|
248
|
+
id: 'flex-col-overflow',
|
|
249
|
+
level: 'warning',
|
|
250
|
+
message: `${cLabel} is flex-col child with min-height:${cMinH} → content (${child.metrics.scrollHeight}px) may overflow parent (${parent.metrics.clientHeight}px)`,
|
|
251
|
+
confidence: 'DEFINITE',
|
|
252
|
+
evidence: [
|
|
253
|
+
evComputed(parent, 'display'),
|
|
254
|
+
evComputed(parent, 'flexDirection'),
|
|
255
|
+
evComputed(child, 'minHeight'),
|
|
256
|
+
{ type: 'computed', property: 'scrollHeight', value: String(child.metrics.scrollHeight) },
|
|
257
|
+
{ type: 'computed', property: 'clientHeight', value: String(child.metrics.clientHeight) },
|
|
258
|
+
],
|
|
259
|
+
location: cLabel,
|
|
260
|
+
}));
|
|
261
|
+
}
|
|
262
|
+
if (cFlexGrow === '0' && cFlexShrink === '0' && cMinH === 'auto') {
|
|
263
|
+
findings.push(makeFinding({
|
|
264
|
+
id: 'flex-zero-auto',
|
|
265
|
+
level: 'info',
|
|
266
|
+
message: `${cLabel}: flex:0 0 auto in ${pLabel}(column) → height is content-driven, not constrained by flex`,
|
|
267
|
+
confidence: 'DEFINITE',
|
|
268
|
+
evidence: [evComputed(child, 'flexGrow'), evComputed(child, 'flexShrink'), evComputed(child, 'minHeight')],
|
|
269
|
+
location: cLabel,
|
|
270
|
+
}));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
if ((cMinW === 'auto' || cMinW === '0px') && child.metrics.scrollWidth > child.metrics.clientWidth + 1) {
|
|
275
|
+
findings.push(makeFinding({
|
|
276
|
+
id: 'flex-row-overflow',
|
|
277
|
+
level: 'warning',
|
|
278
|
+
message: `${cLabel} is flex-row child with min-width:${cMinW} → content (${child.metrics.scrollWidth}px) may overflow parent`,
|
|
279
|
+
confidence: 'DEFINITE',
|
|
280
|
+
evidence: [evComputed(parent, 'display'), evComputed(child, 'minWidth'), evComputed(child, 'overflowX')],
|
|
281
|
+
location: cLabel,
|
|
282
|
+
}));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return findings;
|
|
287
|
+
}
|
|
288
|
+
function analyzeContainingBlock(snapshot) {
|
|
289
|
+
const findings = [];
|
|
290
|
+
const { cbNodes } = walkTree(snapshot.tree);
|
|
291
|
+
const ancestorsWithCB = snapshot.ancestors.filter(a => a.containingBlockModifiers.length > 0);
|
|
292
|
+
const allCB = [
|
|
293
|
+
...cbNodes.slice(0, 5).map(n => ({ label: nodeLabel(n), mods: n.containingBlockModifiers, node: n })),
|
|
294
|
+
...ancestorsWithCB.map(a => ({ label: a.label, mods: a.containingBlockModifiers, node: a })),
|
|
295
|
+
];
|
|
296
|
+
if (allCB.length === 0) {
|
|
297
|
+
findings.push(makeFinding({
|
|
298
|
+
id: 'no-cb',
|
|
299
|
+
level: 'info',
|
|
300
|
+
message: 'no containing block modifiers found in the tree',
|
|
301
|
+
confidence: 'DEFINITE',
|
|
302
|
+
}));
|
|
303
|
+
return findings;
|
|
304
|
+
}
|
|
305
|
+
const hasFixed = snapshot.ancestors.some(a => a.shape.role === 'fixed') ||
|
|
306
|
+
(snapshot.tree && snapshot.tree.props.position === 'fixed');
|
|
307
|
+
for (const cb of allCB.slice(0, 5)) {
|
|
308
|
+
const isRootFixed = cb.node.props.position === 'fixed';
|
|
309
|
+
findings.push(makeFinding({
|
|
310
|
+
id: 'cb-modifier',
|
|
311
|
+
level: isRootFixed ? 'error' : 'info',
|
|
312
|
+
message: `${cb.label}: CB modifiers: ${cb.mods.join(', ')}${isRootFixed ? ' → fixed/absolute descendants resolve % against this ancestor (containing block hijack)' : ''}`,
|
|
313
|
+
confidence: 'DEFINITE',
|
|
314
|
+
evidence: cb.mods.map(m => ({ type: 'declared', property: m.split(':')[0].trim(), value: m.split(':').slice(1).join(':').trim(), accessible: true })),
|
|
315
|
+
location: cb.label,
|
|
316
|
+
}));
|
|
317
|
+
}
|
|
318
|
+
if (hasFixed && snapshot.ancestors.every(a => a.shape.heightStrategy === 'content')) {
|
|
319
|
+
findings.push(makeFinding({
|
|
320
|
+
id: 'fixed-no-anchor',
|
|
321
|
+
level: 'error',
|
|
322
|
+
message: 'fixed ancestor + no height anchor → children % resolve to auto → content-sized overflow',
|
|
323
|
+
confidence: 'DEFINITE',
|
|
324
|
+
}));
|
|
325
|
+
}
|
|
326
|
+
return findings;
|
|
327
|
+
}
|
|
328
|
+
function detectPatterns(snapshot) {
|
|
329
|
+
const findings = [];
|
|
330
|
+
const ancestors = snapshot.ancestors;
|
|
331
|
+
const { scrollNodes } = walkTree(snapshot.tree);
|
|
332
|
+
const anchored = ancestors.some(a => {
|
|
333
|
+
const dh = declaredFor(a, 'height');
|
|
334
|
+
return !!(dh && ABS_UNIT.test(dh.value));
|
|
335
|
+
});
|
|
336
|
+
const hasFlexCol = ancestors.some(a => a.shape.role === 'flex-col');
|
|
337
|
+
const hasFixed = ancestors.some(a => a.shape.role === 'fixed');
|
|
338
|
+
const scrollableExists = scrollNodes.some(n => n.flags.scrollable);
|
|
339
|
+
if (hasFixed && !anchored) {
|
|
340
|
+
findings.push(makeFinding({
|
|
341
|
+
id: 'pattern-fixed-no-anchor',
|
|
342
|
+
level: 'info',
|
|
343
|
+
message: 'Pattern: fixed ancestor + no height anchor → children % resolve to auto → content-sized overflow',
|
|
344
|
+
confidence: 'DEFINITE',
|
|
345
|
+
}));
|
|
346
|
+
}
|
|
347
|
+
if (hasFlexCol) {
|
|
348
|
+
const fc = ancestors.find(a => a.shape.role === 'flex-col');
|
|
349
|
+
findings.push(makeFinding({
|
|
350
|
+
id: 'pattern-flex-col',
|
|
351
|
+
level: 'info',
|
|
352
|
+
message: `Pattern: flex-col at ${fc.label} → children height governed by flex-grow/shrink/min-height`,
|
|
353
|
+
confidence: 'DEFINITE',
|
|
354
|
+
}));
|
|
355
|
+
}
|
|
356
|
+
if (scrollableExists && !anchored) {
|
|
357
|
+
findings.push(makeFinding({
|
|
358
|
+
id: 'pattern-scroll-no-anchor',
|
|
359
|
+
level: 'info',
|
|
360
|
+
message: 'Pattern: scroll container exists but no height anchor above → overflow:auto never triggers (content-sized)',
|
|
361
|
+
confidence: 'DEFINITE',
|
|
362
|
+
}));
|
|
363
|
+
}
|
|
364
|
+
if (allAncestorsContentSized(ancestors)) {
|
|
365
|
+
findings.push(makeFinding({
|
|
366
|
+
id: 'pattern-content-sized',
|
|
367
|
+
level: 'info',
|
|
368
|
+
message: 'Pattern: all ancestors are content-sized → height entirely depends on content, no constraints propagate',
|
|
369
|
+
confidence: 'DEFINITE',
|
|
370
|
+
}));
|
|
371
|
+
}
|
|
372
|
+
return findings;
|
|
373
|
+
}
|
|
374
|
+
// ─── Public entry ───
|
|
375
|
+
function analyze(snapshot) {
|
|
376
|
+
const findings = [
|
|
377
|
+
...analyzeAnchors(snapshot),
|
|
378
|
+
...analyzeScroll(snapshot),
|
|
379
|
+
...analyzeFlex(snapshot),
|
|
380
|
+
...analyzeContainingBlock(snapshot),
|
|
381
|
+
...detectPatterns(snapshot),
|
|
382
|
+
];
|
|
383
|
+
return findings;
|
|
384
|
+
}
|
|
385
|
+
//# sourceMappingURL=analyzer.js.map
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Browser-side collector. Bundled by esbuild into an IIFE (global `__cssprobe_cli`),
|
|
3
|
+
// injected into the page via addScriptTag, then invoked with `window.__cssprobe_cli.collect(cfg)`.
|
|
4
|
+
// This module runs entirely in the browser context — no Node imports at runtime.
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.classifyShape = classifyShape;
|
|
7
|
+
exports.collect = collect;
|
|
8
|
+
// ─── Declared-value scanning ───
|
|
9
|
+
const WANTED_PROPS = [
|
|
10
|
+
// layout
|
|
11
|
+
'height', 'max-height', 'min-height', 'width', 'max-width', 'min-width',
|
|
12
|
+
'overflow', 'overflow-x', 'overflow-y',
|
|
13
|
+
'position', 'display', 'box-sizing',
|
|
14
|
+
'flex', 'flex-grow', 'flex-shrink', 'flex-basis', 'flex-direction',
|
|
15
|
+
'align-items', 'justify-content', 'align-self',
|
|
16
|
+
'grid-template-columns', 'grid-template-rows',
|
|
17
|
+
'top', 'left', 'right', 'bottom',
|
|
18
|
+
'margin', 'margin-top', 'margin-bottom', 'margin-left', 'margin-right',
|
|
19
|
+
'padding', 'padding-top', 'padding-bottom',
|
|
20
|
+
'transform', 'filter', 'perspective', 'will-change', 'contain',
|
|
21
|
+
'z-index', 'visibility', 'opacity',
|
|
22
|
+
// visual (colors / backgrounds / fonts)
|
|
23
|
+
'color', 'background', 'background-color', 'background-image',
|
|
24
|
+
'background-size', 'background-position', 'background-repeat',
|
|
25
|
+
'font-family', 'font-size', 'font-weight', 'font-style', 'line-height',
|
|
26
|
+
'text-align', 'letter-spacing', 'text-transform',
|
|
27
|
+
];
|
|
28
|
+
function matchDeclarations(el, wantedProps, crossOriginBlocked, blockedSheetUrls) {
|
|
29
|
+
const out = {};
|
|
30
|
+
let scanned = 0;
|
|
31
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
32
|
+
let rules = null;
|
|
33
|
+
let accessible = true;
|
|
34
|
+
try {
|
|
35
|
+
rules = sheet.cssRules;
|
|
36
|
+
}
|
|
37
|
+
catch (_e) {
|
|
38
|
+
accessible = false;
|
|
39
|
+
crossOriginBlocked.count++;
|
|
40
|
+
const short = (sheet.href || 'unknown').split('/').pop() || 'unknown';
|
|
41
|
+
if (!blockedSheetUrls.includes(short))
|
|
42
|
+
blockedSheetUrls.push(short);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (!rules)
|
|
46
|
+
continue;
|
|
47
|
+
for (const rule of Array.from(rules)) {
|
|
48
|
+
const styleRule = rule;
|
|
49
|
+
if (styleRule.selectorText && el.matches && el.matches(styleRule.selectorText)) {
|
|
50
|
+
const decls = styleRule.style;
|
|
51
|
+
for (const p of wantedProps) {
|
|
52
|
+
const v = decls.getPropertyValue(p);
|
|
53
|
+
if (v) {
|
|
54
|
+
(out[p] = out[p] || []).push({
|
|
55
|
+
selector: styleRule.selectorText,
|
|
56
|
+
value: v.trim(),
|
|
57
|
+
href: (sheet.href || '').split('/').slice(-2).join('/'),
|
|
58
|
+
accessible,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
scanned += 1;
|
|
64
|
+
if (scanned > 8000)
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (const p of wantedProps) {
|
|
69
|
+
const iv = el.style.getPropertyValue(p);
|
|
70
|
+
if (iv && !(out[p] || []).some(d => d.selector === 'inline')) {
|
|
71
|
+
(out[p] = out[p] || []).push({ selector: 'inline', value: iv.trim(), href: '', accessible: true });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
// ─── Shape classification ───
|
|
77
|
+
function classifyShape(cs, declared) {
|
|
78
|
+
const display = cs.display;
|
|
79
|
+
const position = cs.position;
|
|
80
|
+
const flexDir = cs.flexDirection;
|
|
81
|
+
const overflowY = cs.overflowY;
|
|
82
|
+
const overflowX = cs.overflowX;
|
|
83
|
+
let role = 'block';
|
|
84
|
+
if (position === 'fixed')
|
|
85
|
+
role = 'fixed';
|
|
86
|
+
else if (position === 'absolute')
|
|
87
|
+
role = 'absolute';
|
|
88
|
+
else if (position === 'sticky')
|
|
89
|
+
role = 'sticky';
|
|
90
|
+
else if (display === 'grid' || display === 'inline-grid')
|
|
91
|
+
role = 'grid';
|
|
92
|
+
else if (display === 'flex' || display === 'inline-flex') {
|
|
93
|
+
role = flexDir === 'column' ? 'flex-col' : 'flex-row';
|
|
94
|
+
}
|
|
95
|
+
else if (display === 'inline' || display === 'inline-block')
|
|
96
|
+
role = 'inline';
|
|
97
|
+
const isScrollY = overflowY === 'auto' || overflowY === 'scroll';
|
|
98
|
+
const isScrollX = overflowX === 'auto' || overflowX === 'scroll';
|
|
99
|
+
const scrollTag = isScrollY ? (isScrollX ? 'scroll-xy' : 'scroll-y') : (isScrollX ? 'scroll-x' : '');
|
|
100
|
+
const getDeclared = (prop) => {
|
|
101
|
+
const arr = declared && declared[prop];
|
|
102
|
+
if (!arr || arr.length === 0)
|
|
103
|
+
return null;
|
|
104
|
+
const inline = arr.find(d => d.selector === 'inline');
|
|
105
|
+
return inline ? inline.value : arr[0].value;
|
|
106
|
+
};
|
|
107
|
+
const sizeClass = (prop, maxProp) => {
|
|
108
|
+
const val = getDeclared(prop);
|
|
109
|
+
const maxVal = getDeclared(maxProp);
|
|
110
|
+
if (val) {
|
|
111
|
+
if (/^(0|[1-9]\d*)(\.\d+)?(px|pt|cm|mm|in)$/.test(val))
|
|
112
|
+
return 'fixed';
|
|
113
|
+
if (/^(0|[1-9]\d*)(\.\d+)?(vh|vw|vmin|vmax)$/.test(val))
|
|
114
|
+
return 'viewport';
|
|
115
|
+
if (val.endsWith('%'))
|
|
116
|
+
return 'percent';
|
|
117
|
+
if (/^calc\(/.test(val))
|
|
118
|
+
return 'calc';
|
|
119
|
+
if (val === 'min-content' || val === 'max-content' || val === 'fit-content')
|
|
120
|
+
return 'content';
|
|
121
|
+
}
|
|
122
|
+
if (maxVal && maxVal !== 'none' && maxVal !== 'auto')
|
|
123
|
+
return 'constrained';
|
|
124
|
+
return 'content';
|
|
125
|
+
};
|
|
126
|
+
const heightStrategy = sizeClass('height', 'max-height');
|
|
127
|
+
const widthStrategy = sizeClass('width', 'max-width');
|
|
128
|
+
const isFlexChild = !!(getDeclared('flex-grow') || getDeclared('flex-shrink') || getDeclared('flex-basis') || getDeclared('flex'));
|
|
129
|
+
return { role, scrollTag, heightStrategy, widthStrategy, isFlexChild };
|
|
130
|
+
}
|
|
131
|
+
// ─── Node collection ───
|
|
132
|
+
const CB_PROPS = ['transform', 'filter', 'perspective', 'willChange', 'contain'];
|
|
133
|
+
function collectNode(el, maxText, crossOriginBlocked, blockedSheetUrls) {
|
|
134
|
+
try {
|
|
135
|
+
const cs = getComputedStyle(el);
|
|
136
|
+
const rect = el.getBoundingClientRect();
|
|
137
|
+
if (!rect)
|
|
138
|
+
return null;
|
|
139
|
+
const props = {};
|
|
140
|
+
const LAYOUT_PROPS = [
|
|
141
|
+
'position', 'display', 'height', 'width', 'minHeight', 'maxHeight',
|
|
142
|
+
'overflowX', 'overflowY', 'boxSizing', 'margin', 'padding',
|
|
143
|
+
'flexDirection', 'flexGrow', 'flexShrink', 'flexBasis', 'alignItems', 'justifyContent',
|
|
144
|
+
'transform', 'filter', 'perspective', 'willChange', 'contain',
|
|
145
|
+
'zIndex', 'top', 'left', 'right', 'bottom', 'visibility', 'opacity',
|
|
146
|
+
];
|
|
147
|
+
for (const p of LAYOUT_PROPS)
|
|
148
|
+
props[p] = cs[p] ?? '';
|
|
149
|
+
const text = (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, maxText);
|
|
150
|
+
const declared = matchDeclarations(el, WANTED_PROPS, crossOriginBlocked, blockedSheetUrls);
|
|
151
|
+
const shape = classifyShape(cs, declared);
|
|
152
|
+
const rawClass = el.getAttribute('class') || '';
|
|
153
|
+
return {
|
|
154
|
+
tag: el.tagName.toLowerCase(),
|
|
155
|
+
id: el.id || null,
|
|
156
|
+
classes: rawClass.split(/\s+/).filter(Boolean),
|
|
157
|
+
inlineStyle: el.getAttribute('style') || null,
|
|
158
|
+
text,
|
|
159
|
+
props,
|
|
160
|
+
declared,
|
|
161
|
+
shape,
|
|
162
|
+
metrics: {
|
|
163
|
+
rect: {
|
|
164
|
+
x: +rect.x.toFixed(1),
|
|
165
|
+
y: +rect.y.toFixed(1),
|
|
166
|
+
width: +rect.width.toFixed(1),
|
|
167
|
+
height: +rect.height.toFixed(1),
|
|
168
|
+
},
|
|
169
|
+
rectBottom: +rect.bottom.toFixed(1),
|
|
170
|
+
rectRight: +rect.right.toFixed(1),
|
|
171
|
+
offsetHeight: el.offsetHeight,
|
|
172
|
+
clientHeight: el.clientHeight,
|
|
173
|
+
scrollHeight: el.scrollHeight,
|
|
174
|
+
clientWidth: el.clientWidth,
|
|
175
|
+
scrollWidth: el.scrollWidth,
|
|
176
|
+
},
|
|
177
|
+
flags: {
|
|
178
|
+
overflowsViewport: rect.bottom > innerHeight + 1 || rect.right > innerWidth + 1,
|
|
179
|
+
overflowsParent: rect.bottom > (el.parentElement ? el.parentElement.getBoundingClientRect().bottom + 1 : innerHeight),
|
|
180
|
+
hasScrollY: el.scrollHeight > el.clientHeight + 1,
|
|
181
|
+
hasScrollX: el.scrollWidth > el.clientWidth + 1,
|
|
182
|
+
scrollable: (cs.overflowY === 'auto' || cs.overflowY === 'scroll') &&
|
|
183
|
+
el.scrollHeight > el.clientHeight + 1 &&
|
|
184
|
+
el.clientHeight > 0,
|
|
185
|
+
},
|
|
186
|
+
containingBlockModifiers: CB_PROPS.filter(p => {
|
|
187
|
+
const v = cs[p];
|
|
188
|
+
return v && v !== 'none' && v !== 'auto' && v !== 'normal' && v !== 'visible' && v !== 'false';
|
|
189
|
+
}).map(p => `${p}: ${cs[p]}`),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
catch (_e) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function isSameShape(a, b) {
|
|
197
|
+
return !!a && !!b && a.tagName === b.tagName && a.id === b.id &&
|
|
198
|
+
(a.getAttribute('class') || '') === (b.getAttribute('class') || '');
|
|
199
|
+
}
|
|
200
|
+
function buildTree(el, depth, state, maxNodes, crossOriginBlocked, blockedSheetUrls) {
|
|
201
|
+
if (!el || state.count >= maxNodes || depth < 0)
|
|
202
|
+
return null;
|
|
203
|
+
state.count += 1;
|
|
204
|
+
const collected = collectNode(el, 40, crossOriginBlocked, blockedSheetUrls);
|
|
205
|
+
if (!collected)
|
|
206
|
+
return null;
|
|
207
|
+
const node = { ...collected, children: [] };
|
|
208
|
+
const kids = Array.from(el.children);
|
|
209
|
+
if (depth > 0) {
|
|
210
|
+
let i = 0;
|
|
211
|
+
while (i < kids.length) {
|
|
212
|
+
const run = [kids[i]];
|
|
213
|
+
let j = i + 1;
|
|
214
|
+
while (j < kids.length && isSameShape(kids[i], kids[j])) {
|
|
215
|
+
run.push(kids[j]);
|
|
216
|
+
j += 1;
|
|
217
|
+
}
|
|
218
|
+
const rep = buildTree(run[0], depth - 1, state, maxNodes, crossOriginBlocked, blockedSheetUrls);
|
|
219
|
+
if (rep) {
|
|
220
|
+
node.children.push({ ...rep, repeat: run.length });
|
|
221
|
+
}
|
|
222
|
+
i = j;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return node;
|
|
226
|
+
}
|
|
227
|
+
function walkUp(el, stopTag) {
|
|
228
|
+
const chain = [];
|
|
229
|
+
let cur = el;
|
|
230
|
+
while (cur && cur.tagName.toLowerCase() !== stopTag) {
|
|
231
|
+
chain.unshift(cur);
|
|
232
|
+
cur = cur.parentElement;
|
|
233
|
+
}
|
|
234
|
+
if (cur)
|
|
235
|
+
chain.unshift(cur);
|
|
236
|
+
return chain;
|
|
237
|
+
}
|
|
238
|
+
// ─── Entry point (invoked from Node via window.__cssprobe_cli.collect) ───
|
|
239
|
+
function collect(cfg) {
|
|
240
|
+
const crossOriginBlocked = { count: 0 };
|
|
241
|
+
const blockedSheetUrls = [];
|
|
242
|
+
const viewport = { width: window.innerWidth, height: window.innerHeight };
|
|
243
|
+
const root = document.querySelector(cfg.rootSelector);
|
|
244
|
+
if (!root) {
|
|
245
|
+
const candidates = Array.from(document.querySelectorAll('.s-kit-modal, [class*="dialog"], [class*="modal"], [class*="popup"]'))
|
|
246
|
+
.slice(0, 5)
|
|
247
|
+
.map(el => `${el.tagName}.${(el.getAttribute('class') || '').split(/\s+/).filter(Boolean).join('.')}`);
|
|
248
|
+
return {
|
|
249
|
+
rootSelector: cfg.rootSelector,
|
|
250
|
+
upTo: cfg.upTo,
|
|
251
|
+
downDepth: cfg.downDepth,
|
|
252
|
+
maxNodes: cfg.maxNodes,
|
|
253
|
+
nodeCount: 0,
|
|
254
|
+
viewport,
|
|
255
|
+
ancestors: [],
|
|
256
|
+
tree: null,
|
|
257
|
+
crossOriginBlocked: crossOriginBlocked.count,
|
|
258
|
+
blockedSheetUrls: blockedSheetUrls.slice(0, 5),
|
|
259
|
+
error: `未找到 ${cfg.rootSelector}`,
|
|
260
|
+
candidates,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
const ancestors = walkUp(root, cfg.upTo)
|
|
264
|
+
.map(el => collectNode(el, 30, crossOriginBlocked, blockedSheetUrls))
|
|
265
|
+
.filter((n) => n !== null)
|
|
266
|
+
.map(n => ({
|
|
267
|
+
label: `${n.tag}.${n.classes.join('.')}`,
|
|
268
|
+
props: n.props,
|
|
269
|
+
metrics: n.metrics,
|
|
270
|
+
flags: n.flags,
|
|
271
|
+
declared: n.declared,
|
|
272
|
+
shape: n.shape,
|
|
273
|
+
containingBlockModifiers: n.containingBlockModifiers,
|
|
274
|
+
inlineStyle: n.inlineStyle,
|
|
275
|
+
}));
|
|
276
|
+
const state = { count: 0 };
|
|
277
|
+
const tree = buildTree(root, cfg.downDepth, state, cfg.maxNodes, crossOriginBlocked, blockedSheetUrls);
|
|
278
|
+
return {
|
|
279
|
+
rootSelector: cfg.rootSelector,
|
|
280
|
+
upTo: cfg.upTo,
|
|
281
|
+
downDepth: cfg.downDepth,
|
|
282
|
+
maxNodes: cfg.maxNodes,
|
|
283
|
+
nodeCount: state.count,
|
|
284
|
+
viewport,
|
|
285
|
+
ancestors,
|
|
286
|
+
tree,
|
|
287
|
+
crossOriginBlocked: crossOriginBlocked.count,
|
|
288
|
+
blockedSheetUrls: blockedSheetUrls.slice(0, 5),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
//# sourceMappingURL=collector.js.map
|