@crouton-kit/capture 1.3.21 → 1.4.1
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/bin/capture +2880 -1759
- package/package.json +1 -1
- package/src/capture.ts +3 -2
- package/src/cdp/args.ts +18 -3
- package/src/cdp/commands/measure/explain.ts +22 -7
- package/src/cdp/commands/measure/index.ts +8 -4
- package/src/cdp/commands/measure/map-paint.ts +273 -0
- package/src/cdp/commands/measure/snap.ts +34 -7
- package/src/cdp/commands/page/exec.ts +1 -1
- package/src/cdp/commands/page/index.ts +2 -2
- package/src/cdp/commands/page/shot.ts +118 -6
- package/src/cdp/connection.ts +19 -12
- package/src/cdp/detect.ts +61 -20
- package/src/cdp/measure/collectors/geometry.ts +66 -9
- package/src/cdp/measure/collectors/pixels.ts +9 -1
- package/src/cdp/measure/collectors/states.ts +243 -4
- package/src/cdp/measure/explain.ts +123 -23
- package/src/cdp/measure/ink-source.ts +116 -0
- package/src/cdp/measure/ink.ts +180 -0
- package/src/cdp/measure/snapshot.ts +3 -0
- package/src/cdp/screenshot.ts +43 -11
- package/src/cdp/targets.ts +24 -0
- package/src/cdp/types.ts +15 -0
- package/src/contracts/index.ts +0 -2
- package/src/output/render.ts +1 -0
- package/src/output/selector.ts +238 -5
- package/src/session/commands.ts +47 -32
- package/src/command-help.ts +0 -29
- package/src/command-resolution.ts +0 -22
- package/src/command-walker.ts +0 -35
- package/src/contracts/results.ts +0 -208
- package/src/contracts/routes.ts +0 -140
- package/src/output/exact-raw.ts +0 -114
- package/src/registry.ts +0 -208
|
@@ -85,6 +85,11 @@
|
|
|
85
85
|
* === after) — a baseline row for the matrix, not a forced capture.
|
|
86
86
|
*/
|
|
87
87
|
|
|
88
|
+
import * as fs from 'node:fs';
|
|
89
|
+
import * as path from 'node:path';
|
|
90
|
+
import { PNG } from 'pngjs';
|
|
91
|
+
import pixelmatch from 'pixelmatch';
|
|
92
|
+
|
|
88
93
|
import type { CDPClient } from '../../client.js';
|
|
89
94
|
import type { Rect } from '../../coordinates.js';
|
|
90
95
|
import { forcePseudoStateForNode, type ForcedPseudoClass } from '../../domains.js';
|
|
@@ -101,6 +106,12 @@ const MAX_SELECTOR_MATCHES = 10;
|
|
|
101
106
|
const MAX_AUTO_ELEMENTS = 8;
|
|
102
107
|
/** Per-field length cap applied NODE-SIDE to page-controlled strings (text, aria-label). Passed to {@link sanitizeString} as `{ max }` — a tighter bound than the shared default. */
|
|
103
108
|
const MAX_STRING_LEN = 200;
|
|
109
|
+
/** Bounds forced-state evidence to the target subtree plus following sibling subtrees; omitted candidates are reported in `affected.truncated`. */
|
|
110
|
+
const MAX_AFFECTED_ELEMENTS = 100;
|
|
111
|
+
/** Default CSS-pixel inset/outset around the forced target's border rect included in its raster diff. */
|
|
112
|
+
const DEFAULT_STATE_DIFF_PADDING_CSS_PX = 8;
|
|
113
|
+
/** Two animation frames let style invalidation reach paint before the forced-state screenshot; this costs roughly 33ms at 60Hz per supported force. */
|
|
114
|
+
const STATE_CAPTURE_SETTLE_FRAMES = 2;
|
|
104
115
|
|
|
105
116
|
const CONCRETE_STATES = ['hover', 'focus', 'active', 'checked', 'open', 'disabled', 'invalid'] as const;
|
|
106
117
|
type ConcreteState = (typeof CONCRETE_STATES)[number];
|
|
@@ -207,6 +218,26 @@ interface HittestDelta {
|
|
|
207
218
|
readonly changed: boolean;
|
|
208
219
|
}
|
|
209
220
|
|
|
221
|
+
interface StateAffectedRecord {
|
|
222
|
+
readonly relation: 'target' | 'descendant' | 'peer-sibling';
|
|
223
|
+
readonly selector?: string;
|
|
224
|
+
readonly backendNodeId: number | null;
|
|
225
|
+
readonly identityUnresolved?: true;
|
|
226
|
+
readonly geometry: GeometryDelta;
|
|
227
|
+
readonly style: StyleDelta;
|
|
228
|
+
readonly hittest: HittestDelta;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
interface StateRasterArtifact {
|
|
232
|
+
readonly screenshot: string;
|
|
233
|
+
readonly diff?: string;
|
|
234
|
+
readonly viewportScale: { readonly available: boolean; readonly innerWidth: number; readonly innerHeight: number; readonly scaleX: number; readonly scaleY: number; readonly unavailableReason?: 'viewport-read-unavailable' };
|
|
235
|
+
readonly diffCrop: { readonly paddingCssPx: number; readonly borderRect: Rect };
|
|
236
|
+
readonly diffPixelCount?: number;
|
|
237
|
+
readonly changedPixelBboxCss?: Rect;
|
|
238
|
+
readonly diffUnavailableReason?: 'baseline-unavailable' | 'dimension-mismatch';
|
|
239
|
+
}
|
|
240
|
+
|
|
210
241
|
interface StateElementRecord {
|
|
211
242
|
readonly id: string;
|
|
212
243
|
readonly state: string;
|
|
@@ -229,6 +260,10 @@ interface StateElementRecord {
|
|
|
229
260
|
readonly geometry?: GeometryDelta;
|
|
230
261
|
readonly style?: StyleDelta;
|
|
231
262
|
readonly hittest?: HittestDelta;
|
|
263
|
+
/** Target, light-DOM descendants, and following sibling subtrees measured under this force. `truncated` names candidates omitted by the explicit cap. */
|
|
264
|
+
readonly affected?: { readonly elements: readonly StateAffectedRecord[]; readonly total: number; readonly kept: number; readonly truncated: boolean };
|
|
265
|
+
/** Rendered evidence captured while this exact force remained active. */
|
|
266
|
+
readonly raster?: StateRasterArtifact;
|
|
232
267
|
/** `true` when the pre- or post-force `evalFacts` read itself failed (evaluate threw, or resolved with no `value`) — distinguishes "could not read" from a genuine `{exists:false}`/"not found" measured outcome (I-5). Present only on that failure path. */
|
|
233
268
|
readonly factsUnavailable?: true;
|
|
234
269
|
readonly factsUnavailableReason?: FactsUnavailableReason;
|
|
@@ -684,8 +719,178 @@ function zeroDelta(before: Rect): GeometryDelta {
|
|
|
684
719
|
return { before, after: before, delta: { dx: 0, dy: 0, dwidth: 0, dheight: 0 }, changed: false };
|
|
685
720
|
}
|
|
686
721
|
|
|
722
|
+
interface DomNode {
|
|
723
|
+
readonly nodeId?: number;
|
|
724
|
+
readonly nodeType?: number;
|
|
725
|
+
readonly nodeName?: string;
|
|
726
|
+
readonly backendNodeId?: number;
|
|
727
|
+
readonly attributes?: readonly string[];
|
|
728
|
+
readonly children?: readonly DomNode[];
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
interface AffectedSubject {
|
|
732
|
+
readonly nodeId: number;
|
|
733
|
+
readonly relation: StateAffectedRecord['relation'];
|
|
734
|
+
readonly selector?: string;
|
|
735
|
+
readonly backendNodeId: number | null;
|
|
736
|
+
readonly identityUnresolved?: true;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function elementNodes(node: DomNode, out: DomNode[]): void {
|
|
740
|
+
if (node.nodeType === 1 && typeof node.nodeId === 'number') out.push(node);
|
|
741
|
+
for (const child of node.children ?? []) elementNodes(child, out);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function findNodeAndParent(node: DomNode, nodeId: number, parent?: DomNode): { node: DomNode; parent?: DomNode } | undefined {
|
|
745
|
+
if (node.nodeId === nodeId) return { node, parent };
|
|
746
|
+
for (const child of node.children ?? []) {
|
|
747
|
+
const found = findNodeAndParent(child, nodeId, node);
|
|
748
|
+
if (found) return found;
|
|
749
|
+
}
|
|
750
|
+
return undefined;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** Resolves a bounded subject population without re-requesting the document, because a new DOM root invalidates the frontend node id that CSS.forcePseudoState must restore. */
|
|
754
|
+
async function affectedSubjects(client: CDPClient, targetNodeId: number, target: AffectedSubject): Promise<{ subjects: AffectedSubject[]; total: number }> {
|
|
755
|
+
let targetObjectId: string | undefined;
|
|
756
|
+
let siblingsObjectId: string | undefined;
|
|
757
|
+
try {
|
|
758
|
+
const nodes: Array<{ node: DomNode; relation: StateAffectedRecord['relation'] }> = [];
|
|
759
|
+
const targetDescription = (await client.send('DOM.describeNode', { nodeId: targetNodeId, depth: -1, pierce: false })) as { node?: DomNode };
|
|
760
|
+
if (!targetDescription.node) return { subjects: [target], total: 1 };
|
|
761
|
+
const targetSubtree: DomNode[] = [];
|
|
762
|
+
elementNodes(targetDescription.node, targetSubtree);
|
|
763
|
+
for (const node of targetSubtree) nodes.push({ node, relation: node.nodeId === targetNodeId ? 'target' : 'descendant' });
|
|
764
|
+
const resolved = (await client.send('DOM.resolveNode', { nodeId: targetNodeId })) as { object?: { objectId?: string } };
|
|
765
|
+
targetObjectId = resolved.object?.objectId;
|
|
766
|
+
if (targetObjectId) {
|
|
767
|
+
const siblings = (await client.send('Runtime.callFunctionOn', { objectId: targetObjectId, functionDeclaration: 'function __captureStateFollowingPeers(){ const all = Array.from(this.parentElement ? this.parentElement.children : []); return all.slice(all.indexOf(this) + 1); }' })) as { result?: { objectId?: string } };
|
|
768
|
+
siblingsObjectId = siblings.result?.objectId;
|
|
769
|
+
if (siblingsObjectId) {
|
|
770
|
+
const properties = (await client.send('Runtime.getProperties', { objectId: siblingsObjectId, ownProperties: true })) as { result?: Array<{ name?: string; value?: { objectId?: string } }> };
|
|
771
|
+
for (const property of properties.result ?? []) {
|
|
772
|
+
if (!/^\d+$/.test(property.name ?? '') || !property.value?.objectId) continue;
|
|
773
|
+
const requested = (await client.send('DOM.requestNode', { objectId: property.value.objectId })) as { nodeId?: number };
|
|
774
|
+
if (!requested.nodeId) continue;
|
|
775
|
+
const siblingDescription = (await client.send('DOM.describeNode', { nodeId: requested.nodeId, depth: -1, pierce: false })) as { node?: DomNode };
|
|
776
|
+
if (!siblingDescription.node) continue;
|
|
777
|
+
const subtree: DomNode[] = [];
|
|
778
|
+
elementNodes(siblingDescription.node, subtree);
|
|
779
|
+
for (const node of subtree) nodes.push({ node, relation: 'peer-sibling' });
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
const subjects = nodes.slice(0, MAX_AFFECTED_ELEMENTS).map(({ node, relation }) => ({
|
|
784
|
+
nodeId: node.nodeId!, relation, selector: buildSelector(node.nodeName, node.attributes), backendNodeId: node.backendNodeId ?? null,
|
|
785
|
+
...(node.backendNodeId === undefined ? { identityUnresolved: true as const } : {}),
|
|
786
|
+
}));
|
|
787
|
+
return { subjects: subjects.length ? subjects : [target], total: nodes.length || 1 };
|
|
788
|
+
} catch {
|
|
789
|
+
return { subjects: [target], total: 1 };
|
|
790
|
+
} finally {
|
|
791
|
+
for (const objectId of [siblingsObjectId, targetObjectId]) if (objectId) {
|
|
792
|
+
try { await client.send('Runtime.releaseObject', { objectId }); } catch { /* best-effort object release */ }
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function nodeFactsFunction(): string {
|
|
798
|
+
return `function __captureStateNodeFacts() {
|
|
799
|
+
const el = this;
|
|
800
|
+
const rect = el.getBoundingClientRect();
|
|
801
|
+
const cs = getComputedStyle(el);
|
|
802
|
+
const style = {};
|
|
803
|
+
for (const p of ${JSON.stringify(STYLE_PROPS)}) style[p] = cs.getPropertyValue(p);
|
|
804
|
+
const cx = rect.left + rect.width / 2;
|
|
805
|
+
const cy = rect.top + rect.height / 2;
|
|
806
|
+
const inViewport = rect.width > 0 && rect.height > 0 && cx >= 0 && cy >= 0 && cx <= window.innerWidth && cy <= window.innerHeight;
|
|
807
|
+
const stack = inViewport ? document.elementsFromPoint(cx, cy) : [];
|
|
808
|
+
const top = stack[0] || null;
|
|
809
|
+
return { exists: true, tag: el.tagName, rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, style, hit: { isTarget: top === el, topTag: top ? top.tagName : null }, text: (el.textContent || '').trim(), axName: el.getAttribute('aria-label') };
|
|
810
|
+
}`;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
async function evalFactsForNode(client: CDPClient, nodeId: number, backendNodeId: number | null): Promise<ElementFacts> {
|
|
814
|
+
let objectId: string | undefined;
|
|
815
|
+
try {
|
|
816
|
+
// Child node ids in a depth-recursive DOM.describeNode response are not guaranteed to be frontend-bound; backend ids are the cross-call identity Chrome promises.
|
|
817
|
+
const resolved = (await client.send('DOM.resolveNode', backendNodeId === null ? { nodeId } : { backendNodeId })) as { object?: { objectId?: string } };
|
|
818
|
+
objectId = resolved.object?.objectId;
|
|
819
|
+
if (!objectId) return { exists: false, factsUnavailable: true, factsUnavailableReason: 'facts-evaluate-returned-no-value' };
|
|
820
|
+
const response = (await client.send('Runtime.callFunctionOn', { objectId, functionDeclaration: nodeFactsFunction(), returnByValue: true })) as { result?: { value?: ElementFacts } };
|
|
821
|
+
return response.result?.value ?? { exists: false, factsUnavailable: true, factsUnavailableReason: 'facts-evaluate-returned-no-value' };
|
|
822
|
+
} catch {
|
|
823
|
+
return { exists: false, factsUnavailable: true, factsUnavailableReason: 'facts-evaluate-threw' };
|
|
824
|
+
} finally {
|
|
825
|
+
if (objectId) {
|
|
826
|
+
try { await client.send('Runtime.releaseObject', { objectId }); } catch { /* object lifetime is browser-owned if release fails */ }
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function deltaForFacts(before: ElementFacts, after: ElementFacts): { geometry: GeometryDelta; style: StyleDelta; hittest: HittestDelta } | undefined {
|
|
832
|
+
if (!before.exists || !after.exists || before.factsUnavailable || after.factsUnavailable || !before.rect || !after.rect || !before.style || !after.style || !before.hit || !after.hit) return undefined;
|
|
833
|
+
const geometryChanged = before.rect.x !== after.rect.x || before.rect.y !== after.rect.y || before.rect.width !== after.rect.width || before.rect.height !== after.rect.height;
|
|
834
|
+
return {
|
|
835
|
+
geometry: { before: before.rect, after: after.rect, delta: { dx: after.rect.x - before.rect.x, dy: after.rect.y - before.rect.y, dwidth: after.rect.width - before.rect.width, dheight: after.rect.height - before.rect.height }, changed: geometryChanged },
|
|
836
|
+
style: { changed: STYLE_PROPS.filter((property) => before.style![property] !== after.style![property]), before: sanitizeStyleValues(before.style), after: sanitizeStyleValues(after.style) },
|
|
837
|
+
hittest: { before: before.hit, after: after.hit, changed: before.hit.isTarget !== after.hit.isTarget || before.hit.topTag !== after.hit.topTag },
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
async function settleForcedState(client: CDPClient): Promise<void> {
|
|
842
|
+
await client.send('Runtime.evaluate', { expression: `(async function __captureStatePaintSettle(){ for (let i = 0; i < ${STATE_CAPTURE_SETTLE_FRAMES}; i += 1) await new Promise(requestAnimationFrame); return true; })()`, awaitPromise: true, returnByValue: true });
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function round3(value: number): number { return Math.round(value * 1000) / 1000; }
|
|
846
|
+
|
|
847
|
+
/** Captures the active forced state and a target-border-plus-padding pixel diff against screenshot.png. */
|
|
848
|
+
async function captureStateRaster(ctx: import('../types.js').SnapshotContext, client: CDPClient, state: string, id: string, borderRect: Rect): Promise<StateRasterArtifact | undefined> {
|
|
849
|
+
const baselinePath = path.join(ctx.dir, 'screenshot.png');
|
|
850
|
+
if (!fs.existsSync(baselinePath)) return undefined;
|
|
851
|
+
const response = (await client.send('Page.captureScreenshot', { format: 'png' })) as { data: string };
|
|
852
|
+
const statePng = Buffer.from(response.data, 'base64');
|
|
853
|
+
const imageName = `states/${sanitizeToken(state) || 'state'}-${id}.png`;
|
|
854
|
+
ctx.write.binary(imageName, statePng);
|
|
855
|
+
const stateImage = PNG.sync.read(statePng);
|
|
856
|
+
const viewport = await client.send('Runtime.evaluate', { expression: '({w: window.innerWidth, h: window.innerHeight})', returnByValue: true }) as { result?: { value?: { w?: number; h?: number } } };
|
|
857
|
+
const w = viewport.result?.value?.w;
|
|
858
|
+
const h = viewport.result?.value?.h;
|
|
859
|
+
const available = typeof w === 'number' && w > 0 && typeof h === 'number' && h > 0;
|
|
860
|
+
const scaleX = available ? stateImage.width / w! : 1;
|
|
861
|
+
const scaleY = available ? stateImage.height / h! : 1;
|
|
862
|
+
const paddingCssPx = (ctx as import('../types.js').SnapshotContext & { stateDiffPaddingCssPx?: number }).stateDiffPaddingCssPx ?? DEFAULT_STATE_DIFF_PADDING_CSS_PX;
|
|
863
|
+
const raster: StateRasterArtifact = { screenshot: imageName, viewportScale: { available, innerWidth: available ? w! : 0, innerHeight: available ? h! : 0, scaleX: round3(scaleX), scaleY: round3(scaleY), ...(available ? {} : { unavailableReason: 'viewport-read-unavailable' as const }) }, diffCrop: { paddingCssPx, borderRect } };
|
|
864
|
+
const baseline = PNG.sync.read(fs.readFileSync(baselinePath));
|
|
865
|
+
if (baseline.width !== stateImage.width || baseline.height !== stateImage.height) return { ...raster, diffUnavailableReason: 'dimension-mismatch' };
|
|
866
|
+
const x0 = Math.max(0, Math.floor((borderRect.x - paddingCssPx) * scaleX));
|
|
867
|
+
const y0 = Math.max(0, Math.floor((borderRect.y - paddingCssPx) * scaleY));
|
|
868
|
+
const x1 = Math.min(stateImage.width, Math.ceil((borderRect.x + borderRect.width + paddingCssPx) * scaleX));
|
|
869
|
+
const y1 = Math.min(stateImage.height, Math.ceil((borderRect.y + borderRect.height + paddingCssPx) * scaleY));
|
|
870
|
+
const width = Math.max(1, x1 - x0);
|
|
871
|
+
const height = Math.max(1, y1 - y0);
|
|
872
|
+
const before = new PNG({ width, height });
|
|
873
|
+
const after = new PNG({ width, height });
|
|
874
|
+
PNG.bitblt(baseline, before, x0, y0, width, height, 0, 0);
|
|
875
|
+
PNG.bitblt(stateImage, after, x0, y0, width, height, 0, 0);
|
|
876
|
+
const diff = new PNG({ width, height });
|
|
877
|
+
const diffPixelCount = pixelmatch(before.data, after.data, diff.data, width, height, { threshold: 0.1, diffMask: true });
|
|
878
|
+
const diffName = `states/${sanitizeToken(state) || 'state'}-${id}-diff.png`;
|
|
879
|
+
ctx.write.binary(diffName, PNG.sync.write(diff));
|
|
880
|
+
let minX = width, minY = height, maxX = -1, maxY = -1;
|
|
881
|
+
for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) {
|
|
882
|
+
const offset = (y * width + x) * 4;
|
|
883
|
+
if (diff.data[offset] || diff.data[offset + 1] || diff.data[offset + 2] || diff.data[offset + 3]) { minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); }
|
|
884
|
+
}
|
|
885
|
+
const changedPixelBboxCss = maxX >= 0
|
|
886
|
+
? { x: round3((x0 + minX) / scaleX), y: round3((y0 + minY) / scaleY), width: round3((maxX - minX + 1) / scaleX), height: round3((maxY - minY + 1) / scaleY) }
|
|
887
|
+
: undefined;
|
|
888
|
+
return { ...raster, diff: diffName, diffPixelCount, ...(changedPixelBboxCss ? { changedPixelBboxCss } : {}) };
|
|
889
|
+
}
|
|
890
|
+
|
|
687
891
|
/** Captures one requested state on one already-resolved node: baseline facts, force (when not `normal`), post-force facts, delta, and — always, even on error — restoration. */
|
|
688
892
|
async function captureOneElement(
|
|
893
|
+
ctx: import('../types.js').SnapshotContext,
|
|
689
894
|
client: CDPClient,
|
|
690
895
|
selector: string,
|
|
691
896
|
index: number,
|
|
@@ -722,6 +927,8 @@ async function captureOneElement(
|
|
|
722
927
|
|
|
723
928
|
const axName = before.axName ? sanitizeString(before.axName, { max: MAX_STRING_LEN }) : undefined;
|
|
724
929
|
const text = before.text ? sanitizeString(before.text, { max: MAX_STRING_LEN }) : undefined;
|
|
930
|
+
const population = await affectedSubjects(client, nodeId, { nodeId, relation: 'target', selector: resolvedSelector, backendNodeId, ...(identityUnresolved ? { identityUnresolved } : {}) });
|
|
931
|
+
const affectedBefore = await Promise.all(population.subjects.map((subject) => subject.nodeId === nodeId ? Promise.resolve(before) : evalFactsForNode(client, subject.nodeId, subject.backendNodeId)));
|
|
725
932
|
|
|
726
933
|
if (state === 'normal') {
|
|
727
934
|
const rect = before.rect!;
|
|
@@ -739,6 +946,16 @@ async function captureOneElement(
|
|
|
739
946
|
geometry: zeroDelta(rect),
|
|
740
947
|
style: { changed: [], before: style, after: style },
|
|
741
948
|
hittest: { before: hit, after: hit, changed: false },
|
|
949
|
+
affected: {
|
|
950
|
+
elements: population.subjects.flatMap((subject, subjectIndex) => {
|
|
951
|
+
const facts = affectedBefore[subjectIndex]!;
|
|
952
|
+
const delta = deltaForFacts(facts, facts);
|
|
953
|
+
return delta ? [{ relation: subject.relation, selector: subject.selector, backendNodeId: subject.backendNodeId, ...(subject.identityUnresolved ? { identityUnresolved: true as const } : {}), ...delta }] : [];
|
|
954
|
+
}),
|
|
955
|
+
total: population.total,
|
|
956
|
+
kept: population.subjects.length,
|
|
957
|
+
truncated: population.total > population.subjects.length,
|
|
958
|
+
},
|
|
742
959
|
};
|
|
743
960
|
}
|
|
744
961
|
|
|
@@ -752,6 +969,8 @@ async function captureOneElement(
|
|
|
752
969
|
let restored = true;
|
|
753
970
|
let afterFactsUnavailableReason: FactsUnavailableReason | undefined;
|
|
754
971
|
let forceReadUnavailableReason: ForceReadUnavailableReason | undefined;
|
|
972
|
+
let affectedAfter: ElementFacts[] = [];
|
|
973
|
+
let raster: StateRasterArtifact | undefined;
|
|
755
974
|
|
|
756
975
|
try {
|
|
757
976
|
if (isPseudo) {
|
|
@@ -805,6 +1024,8 @@ async function captureOneElement(
|
|
|
805
1024
|
}
|
|
806
1025
|
|
|
807
1026
|
if (supported) {
|
|
1027
|
+
await settleForcedState(client);
|
|
1028
|
+
raster = await captureStateRaster(ctx, client, state, id, before.rect!);
|
|
808
1029
|
after = await evalFacts(client, selector, index);
|
|
809
1030
|
if (after.factsUnavailable) {
|
|
810
1031
|
supported = false;
|
|
@@ -813,9 +1034,8 @@ async function captureOneElement(
|
|
|
813
1034
|
} else if (!after.exists) {
|
|
814
1035
|
supported = false;
|
|
815
1036
|
reason = 'element no longer present after forcing state';
|
|
816
|
-
} else
|
|
817
|
-
|
|
818
|
-
reason = 'post-force facts no longer resolve to the original element (identity check failed)';
|
|
1037
|
+
} else {
|
|
1038
|
+
affectedAfter = await Promise.all(population.subjects.map((subject) => subject.nodeId === nodeId ? Promise.resolve(after!) : evalFactsForNode(client, subject.nodeId, subject.backendNodeId)));
|
|
819
1039
|
}
|
|
820
1040
|
}
|
|
821
1041
|
} catch (err) {
|
|
@@ -831,6 +1051,12 @@ async function captureOneElement(
|
|
|
831
1051
|
}
|
|
832
1052
|
}
|
|
833
1053
|
|
|
1054
|
+
// Re-resolving selector[index] can invalidate CSS's frontend node id, so it happens only after the `finally` restored the forced state.
|
|
1055
|
+
if (supported && !(await identityStillMatches(client, selector, index, identity.backendNodeId))) {
|
|
1056
|
+
supported = false;
|
|
1057
|
+
reason = 'post-force facts no longer resolve to the original element (identity check failed)';
|
|
1058
|
+
}
|
|
1059
|
+
|
|
834
1060
|
if (!supported) {
|
|
835
1061
|
return {
|
|
836
1062
|
id,
|
|
@@ -866,6 +1092,16 @@ async function captureOneElement(
|
|
|
866
1092
|
const changedStyleProps = STYLE_PROPS.filter((p) => rawBeforeStyle[p] !== rawAfterStyle[p]);
|
|
867
1093
|
const hitChanged = beforeHit.isTarget !== afterHit.isTarget || beforeHit.topTag !== afterHit.topTag;
|
|
868
1094
|
|
|
1095
|
+
const affected = {
|
|
1096
|
+
elements: population.subjects.flatMap((subject, subjectIndex) => {
|
|
1097
|
+
const delta = deltaForFacts(affectedBefore[subjectIndex]!, affectedAfter[subjectIndex]!);
|
|
1098
|
+
return delta ? [{ relation: subject.relation, selector: subject.selector, backendNodeId: subject.backendNodeId, ...(subject.identityUnresolved ? { identityUnresolved: true as const } : {}), ...delta }] : [];
|
|
1099
|
+
}),
|
|
1100
|
+
total: population.total,
|
|
1101
|
+
kept: population.subjects.length,
|
|
1102
|
+
truncated: population.total > population.subjects.length,
|
|
1103
|
+
};
|
|
1104
|
+
|
|
869
1105
|
return {
|
|
870
1106
|
id,
|
|
871
1107
|
state,
|
|
@@ -889,6 +1125,8 @@ async function captureOneElement(
|
|
|
889
1125
|
},
|
|
890
1126
|
style: { changed: changedStyleProps, before: beforeStyle, after: afterStyle },
|
|
891
1127
|
hittest: { before: beforeHit, after: afterHit, changed: hitChanged },
|
|
1128
|
+
affected,
|
|
1129
|
+
...(raster ? { raster } : {}),
|
|
892
1130
|
};
|
|
893
1131
|
}
|
|
894
1132
|
|
|
@@ -965,6 +1203,7 @@ export const collectStates: Collector = async (ctx) => {
|
|
|
965
1203
|
const nodeId = nodeIds[i];
|
|
966
1204
|
const identity = await describeNode(client, nodeId);
|
|
967
1205
|
const record = await captureOneElement(
|
|
1206
|
+
ctx,
|
|
968
1207
|
client,
|
|
969
1208
|
selector,
|
|
970
1209
|
i,
|
|
@@ -980,7 +1219,7 @@ export const collectStates: Collector = async (ctx) => {
|
|
|
980
1219
|
|
|
981
1220
|
ctx.write.json('states.json', {
|
|
982
1221
|
requested: ctx.state.map((s) => sanitizeString(s)),
|
|
983
|
-
scope: { root: 'top-document', shadowDom: 'light-only' },
|
|
1222
|
+
scope: { root: 'top-document', shadowDom: 'light-only', affected: { descendants: true, peerSiblings: 'following-sibling-subtrees', maxElements: MAX_AFFECTED_ELEMENTS } },
|
|
984
1223
|
elements,
|
|
985
1224
|
truncatedRequests,
|
|
986
1225
|
});
|
|
@@ -18,11 +18,15 @@ import {
|
|
|
18
18
|
} from '../../output/artifact.js';
|
|
19
19
|
import { parseSelectorInput, resolveSelectorInput, type ElementRecord, type SelectorInputKind } from '../../output/selector.js';
|
|
20
20
|
import { fact, line, text, type FactLine } from '../../output/render.js';
|
|
21
|
+
import { deriveInkBox, type InkEdges, type InkRect } from './ink.js';
|
|
22
|
+
import { baseInkSource, forcedStateInkSource, isInkSource, quadRect } from './ink-source.js';
|
|
21
23
|
|
|
22
24
|
export interface ExplainDetailOptions {
|
|
23
25
|
readonly size?: boolean;
|
|
24
26
|
readonly text?: boolean;
|
|
25
27
|
readonly form?: boolean;
|
|
28
|
+
/** One requested post-force state recorded by `measure snap --state`. */
|
|
29
|
+
readonly state?: string;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
interface ArtifactElement extends ElementRecord {
|
|
@@ -47,6 +51,8 @@ interface GeometryElement extends ArtifactElement {
|
|
|
47
51
|
readonly boxModel?: Record<string, unknown> | null;
|
|
48
52
|
readonly stackingContext?: { readonly creates?: boolean; readonly reasons?: readonly string[] };
|
|
49
53
|
readonly clipping?: { readonly clippedBy?: string; readonly clippedFraction?: number } | null;
|
|
54
|
+
/** Present on snapshots that recorded uncapped ink-relevant computed styles with geometry. */
|
|
55
|
+
readonly paint?: Record<string, string | null>;
|
|
50
56
|
readonly visibility?: { readonly visible?: boolean; readonly opacity?: number };
|
|
51
57
|
readonly layout?: Record<string, unknown>;
|
|
52
58
|
}
|
|
@@ -97,6 +103,8 @@ export interface ExplainSuccess {
|
|
|
97
103
|
export interface MissingSelectorRecovery {
|
|
98
104
|
readonly recordCount: number;
|
|
99
105
|
readonly kind: SelectorInputKind;
|
|
106
|
+
/** Present when a CSS attribute/quoted value was ranked as AX/text evidence before generated CSS paths. */
|
|
107
|
+
readonly interpretedValue?: string;
|
|
100
108
|
readonly candidates: readonly string[];
|
|
101
109
|
}
|
|
102
110
|
|
|
@@ -177,6 +185,21 @@ function selectorIdentifierDistance(requested: string, candidate: string): numbe
|
|
|
177
185
|
return requestedTokens.reduce((total, token) => total + Math.min(...candidateTokens.map((other) => editDistance(token, other) / Math.max(token.length, other.length))), 0);
|
|
178
186
|
}
|
|
179
187
|
|
|
188
|
+
function quotedSelectorValue(value: string): string | undefined {
|
|
189
|
+
const attribute = /\[[^\]]*?=\s*(?:"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|([^\]\s]+))\]/.exec(value);
|
|
190
|
+
const quoted = attribute?.[1] ?? attribute?.[2] ?? attribute?.[3] ?? /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'/.exec(value)?.slice(1).find(Boolean);
|
|
191
|
+
return quoted?.replace(/\\(.)/g, '$1');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function rankCandidates(candidates: string[], requested: string, valueOf: (candidate: string) => string): string[] {
|
|
195
|
+
return candidates.sort((a, b) => {
|
|
196
|
+
const aValue = valueOf(a).toLowerCase(), bValue = valueOf(b).toLowerCase();
|
|
197
|
+
const aIdentifierDistance = selectorIdentifierDistance(requested, aValue), bIdentifierDistance = selectorIdentifierDistance(requested, bValue);
|
|
198
|
+
const aDistance = editDistance(requested.toLowerCase(), aValue), bDistance = editDistance(requested.toLowerCase(), bValue);
|
|
199
|
+
return aIdentifierDistance - bIdentifierDistance || aDistance - bDistance || a.length - b.length || a.localeCompare(b);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
180
203
|
function recoveryForms(elements: readonly ElementRecord[], requested: string): MissingSelectorRecovery {
|
|
181
204
|
const input = parseSelectorInput(requested);
|
|
182
205
|
const formFor = (element: ElementRecord): string | undefined => {
|
|
@@ -189,14 +212,17 @@ function recoveryForms(elements: readonly ElementRecord[], requested: string): M
|
|
|
189
212
|
}
|
|
190
213
|
};
|
|
191
214
|
const valueOf = (candidate: string) => input.kind === 'css' ? candidate : candidate.slice(candidate.indexOf(':') + 1);
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
215
|
+
const cssCandidates = rankCandidates([...new Set(elements.map(formFor).filter((candidate): candidate is string => Boolean(candidate)))], input.value, valueOf);
|
|
216
|
+
const interpretedValue = input.kind === 'css' ? quotedSelectorValue(input.value) : undefined;
|
|
217
|
+
if (!interpretedValue) {
|
|
218
|
+
return { recordCount: elements.length, kind: input.kind, candidates: cssCandidates.slice(0, RECOVERY_CANDIDATE_LIMIT) };
|
|
219
|
+
}
|
|
220
|
+
const identityCandidates = rankCandidates([...new Set(elements.flatMap((element) => [
|
|
221
|
+
element.axName ? `ax:${element.axName}` : undefined,
|
|
222
|
+
element.text ? `text:${element.text}` : undefined,
|
|
223
|
+
]).filter((candidate): candidate is string => Boolean(candidate)))], interpretedValue, (candidate) => candidate.slice(candidate.indexOf(':') + 1));
|
|
224
|
+
const candidates = [...identityCandidates, ...cssCandidates.filter((candidate) => !identityCandidates.includes(candidate))];
|
|
225
|
+
return { recordCount: elements.length, kind: input.kind, interpretedValue, candidates: candidates.slice(0, RECOVERY_CANDIDATE_LIMIT) };
|
|
200
226
|
}
|
|
201
227
|
|
|
202
228
|
function sourceDescription(declaration: WinningDeclaration): string | undefined {
|
|
@@ -257,30 +283,85 @@ function cascadeSection(target: GeometryElement, styles: readonly StyleElement[]
|
|
|
257
283
|
return { kind: 'cascade', facts };
|
|
258
284
|
}
|
|
259
285
|
|
|
260
|
-
function
|
|
286
|
+
function formatBox(rect: InkRect): string {
|
|
287
|
+
return `x=${rect.x}…${rect.x + rect.width} y=${rect.y}…${rect.y + rect.height} (w=${rect.width} h=${rect.height})`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function overflowEdges(box: InkRect, clip: InkRect): InkEdges {
|
|
291
|
+
return {
|
|
292
|
+
top: Math.max(0, clip.y - box.y),
|
|
293
|
+
right: Math.max(0, box.x + box.width - (clip.x + clip.width)),
|
|
294
|
+
bottom: Math.max(0, box.y + box.height - (clip.y + clip.height)),
|
|
295
|
+
left: Math.max(0, clip.x - box.x),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function formatOverflow(edges: InkEdges): string {
|
|
300
|
+
const named = (Object.entries(edges) as Array<[keyof InkEdges, number]>).filter(([, value]) => value > 0).map(([edge, value]) => `${edge}=${value}px`);
|
|
301
|
+
return named.length ? named.join(' ') : 'none';
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function stackingClippingSection(target: GeometryElement, geometry: readonly GeometryElement[], states: unknown, state?: string): RawSection {
|
|
261
305
|
const facts: ExplainFact[] = [];
|
|
262
306
|
const ancestors = ancestorElements(geometry, target);
|
|
263
307
|
const stacking = ancestors.filter((element) => element.stackingContext?.creates);
|
|
264
308
|
const root = ancestors[0];
|
|
265
|
-
if (root) facts.push(factFor(root, fact`Stacking climb root: ${root.selector ?? root.tag ?? root.id}; z-index=${root.zIndex ?? 'auto'}.`));
|
|
309
|
+
if (root) facts.push(factFor(root, fact`Stacking climb root: ${root.selector ?? root.tag ?? root.id}; z-index=${root.zIndex ?? 'auto'} (base-state geometry record).`));
|
|
266
310
|
for (const ancestor of stacking) {
|
|
267
|
-
facts.push(factFor(ancestor, fact`Stacking ancestor ${ancestor.selector ?? ancestor.id}: z-index=${ancestor.zIndex ?? 'auto'}; creates context from ${(ancestor.stackingContext?.reasons ?? []).join(', ') || 'recorded context trigger'}.`));
|
|
311
|
+
facts.push(factFor(ancestor, fact`Stacking ancestor ${ancestor.selector ?? ancestor.id}: z-index=${ancestor.zIndex ?? 'auto'}; creates context from ${(ancestor.stackingContext?.reasons ?? []).join(', ') || 'recorded context trigger'} (base-state geometry record).`));
|
|
312
|
+
}
|
|
313
|
+
facts.push(factFor(target, fact`Target stacking record: z-index=${target.zIndex ?? 'auto'}; creates-context=${String(Boolean(target.stackingContext?.creates))}; reasons=${(target.stackingContext?.reasons ?? []).join(', ') || 'none recorded'} (base-state geometry record).`));
|
|
314
|
+
|
|
315
|
+
let source = baseInkSource(target);
|
|
316
|
+
if (state) {
|
|
317
|
+
const forced = forcedStateInkSource(states, target, state);
|
|
318
|
+
if (isInkSource(forced)) source = forced;
|
|
319
|
+
else facts.push(factFor(target, fact`Requested state "${state}" supplied no post-force ink source: ${forced.unavailable}; border/ink and clipping comparisons use the base-state geometry record.`));
|
|
320
|
+
}
|
|
321
|
+
const borderBox = isInkSource(source) ? source.rect : undefined;
|
|
322
|
+
const ink = isInkSource(source) ? deriveInkBox(source.rect, source.styles) : undefined;
|
|
323
|
+
if (ink?.inkBox && borderBox) {
|
|
324
|
+
const contributors = ink.contributors.length
|
|
325
|
+
? ink.contributors.map((contributor) => `${contributor.source}${contributor.index === undefined ? '' : ` ${contributor.index}`} (top=${contributor.edges.top}px right=${contributor.edges.right}px bottom=${contributor.edges.bottom}px left=${contributor.edges.left}px)`).join('; ')
|
|
326
|
+
: 'none';
|
|
327
|
+
facts.push(factFor(target, ink.nominal
|
|
328
|
+
? fact`Border box (${source.provenance}): ${formatBox(borderBox)}. Nominal ink box (blur-radius extent, ${source.provenance}): ${formatBox(ink.inkBox)}; contributors: ${contributors}.`
|
|
329
|
+
: fact`Border box (${source.provenance}): ${formatBox(borderBox)}. Ink box (${source.provenance}): ${formatBox(ink.inkBox)}; contributors: ${contributors}.`));
|
|
330
|
+
} else if (ink) {
|
|
331
|
+
const reason = ink.missingStyles.length
|
|
332
|
+
? `computed styles were not captured: ${ink.missingStyles.join(', ')}`
|
|
333
|
+
: `paint extents could not be resolved: ${ink.unresolved.join(', ')}`;
|
|
334
|
+
facts.push(factFor(target, fact`Ink box (${source.provenance}) was not calculated because ${reason}.`));
|
|
335
|
+
} else {
|
|
336
|
+
facts.push(factFor(target, text`Ink box was not calculated because ${source.unavailable}.`));
|
|
268
337
|
}
|
|
269
|
-
facts.push(factFor(target, fact`Target stacking record: z-index=${target.zIndex ?? 'auto'}; creates-context=${String(Boolean(target.stackingContext?.creates))}; reasons=${(target.stackingContext?.reasons ?? []).join(', ') || 'none recorded'}.`));
|
|
270
338
|
|
|
271
|
-
const
|
|
339
|
+
const overflowAncestors = ancestors.filter((element) => {
|
|
272
340
|
const layout = element.layout ?? {};
|
|
273
|
-
return [layout.overflowX, layout.overflowY].some((value) => typeof value === 'string' &&
|
|
341
|
+
return [layout.overflowX, layout.overflowY].some((value) => typeof value === 'string' && /\b(?:hidden|auto|scroll|clip)\b/.test(value));
|
|
342
|
+
});
|
|
343
|
+
const clipPathAncestors = ancestors.filter((element) => {
|
|
344
|
+
const clipPath = element.paint?.['clip-path'];
|
|
345
|
+
return typeof clipPath === 'string' && clipPath !== 'none';
|
|
274
346
|
});
|
|
275
|
-
for (const ancestor of
|
|
347
|
+
for (const ancestor of overflowAncestors) {
|
|
276
348
|
const layout = ancestor.layout ?? {};
|
|
277
|
-
const
|
|
278
|
-
|
|
349
|
+
const clip = quadRect((ancestor.boxModel ?? {}).padding);
|
|
350
|
+
const radius = ancestor.paint?.['border-radius'];
|
|
351
|
+
if (!clip) {
|
|
352
|
+
facts.push(factFor(ancestor, fact`Clipping ancestor ${ancestor.selector ?? ancestor.id}: overflow-x=${String(layout.overflowX ?? 'unknown')}; overflow-y=${String(layout.overflowY ?? 'unknown')} (base-state geometry record); padding-box clip was not captured, so border-box and ink-box clipping were not calculated.`));
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
const borderEdges = borderBox ? overflowEdges(borderBox, clip) : undefined;
|
|
356
|
+
const inkEdges = ink?.inkBox ? overflowEdges(ink.inkBox, clip) : undefined;
|
|
357
|
+
const targetSource = isInkSource(source) ? source.provenance : source.unavailable;
|
|
358
|
+
facts.push(factFor(ancestor, fact`Clipping ancestor ${ancestor.selector ?? ancestor.id}: overflow-x=${String(layout.overflowX ?? 'unknown')}; overflow-y=${String(layout.overflowY ?? 'unknown')}; rectangular padding clip (base-state geometry record) ${formatBox(clip)}; border-radius=${radius ?? 'not captured'}${radius && radius !== '0px' ? ' (rounded clip shape not resolved)' : ''}; target border/ink source=${targetSource}; border-box-overflows-padding-clip=${String(Boolean(borderEdges && formatOverflow(borderEdges) !== 'none'))}; border-box-overflow=${borderEdges ? formatOverflow(borderEdges) : 'not calculated'}; ink-box-overflows-padding-clip=${String(Boolean(inkEdges && formatOverflow(inkEdges) !== 'none'))}; ink-box-overflow=${inkEdges ? formatOverflow(inkEdges) : 'not calculated'}.`));
|
|
359
|
+
}
|
|
360
|
+
for (const ancestor of clipPathAncestors) {
|
|
361
|
+
facts.push(factFor(ancestor, fact`Clip-path ancestor ${ancestor.selector ?? ancestor.id}: clip-path=${ancestor.paint?.['clip-path']} (base-state geometry record); shape geometry was not captured, so border-box and ink-box clipping were not calculated for this source.`));
|
|
279
362
|
}
|
|
280
|
-
if (
|
|
281
|
-
facts.push(factFor(target,
|
|
282
|
-
} else if (!target.clipping) {
|
|
283
|
-
facts.push(factFor(target, text`No clipping source was recorded for the target.`));
|
|
363
|
+
if (!overflowAncestors.length && !clipPathAncestors.length) {
|
|
364
|
+
facts.push(factFor(target, text`No overflow or clip-path clipping ancestor was recorded for the target.`));
|
|
284
365
|
}
|
|
285
366
|
return { kind: 'stacking-clipping', facts };
|
|
286
367
|
}
|
|
@@ -361,7 +442,26 @@ function queriesStatesSection(target: GeometryElement, queries: Record<string, u
|
|
|
361
442
|
const records = (Array.isArray(states.elements) ? states.elements : [])
|
|
362
443
|
.filter((entry): entry is ArtifactElement & Record<string, unknown> => Boolean(entry && typeof entry === 'object'))
|
|
363
444
|
.filter((entry) => sameTarget(entry, target));
|
|
364
|
-
|
|
445
|
+
// A forced state records the element it forced plus the descendants and following peers whose
|
|
446
|
+
// style or geometry moved. Counting only the forced-target rows reports `matching records=0` for
|
|
447
|
+
// an element whose post-force values this same output just derived from an affected row.
|
|
448
|
+
const affectedRows = (Array.isArray(states.elements) ? states.elements : [])
|
|
449
|
+
.filter((entry): entry is ArtifactElement & Record<string, unknown> => Boolean(entry && typeof entry === 'object'))
|
|
450
|
+
.flatMap((entry) => {
|
|
451
|
+
const affected = entry.affected as Record<string, unknown> | undefined;
|
|
452
|
+
const rows = affected && typeof affected === 'object' && Array.isArray(affected.elements) ? affected.elements : [];
|
|
453
|
+
return rows
|
|
454
|
+
.filter((row): row is ArtifactElement & Record<string, unknown> => Boolean(row && typeof row === 'object'))
|
|
455
|
+
.filter((row) => String(row.relation ?? '') !== 'target' && sameTarget(row, target))
|
|
456
|
+
.map((row) => ({ state: String(entry.state ?? 'unknown'), forced: String(entry.selector ?? 'unknown'), row }));
|
|
457
|
+
});
|
|
458
|
+
facts.push(factFor(target, fact`State matrix context: requested=${requested.join(', ') || 'none'}; records forcing this element=${records.length}; records where it is an affected descendant or peer=${affectedRows.length}.`));
|
|
459
|
+
for (const { state, forced, row } of affectedRows) {
|
|
460
|
+
const changes = row.style && typeof row.style === 'object' && Array.isArray((row.style as Record<string, unknown>).changed)
|
|
461
|
+
? ((row.style as Record<string, unknown>).changed as unknown[]).map(String).join(', ')
|
|
462
|
+
: 'none recorded';
|
|
463
|
+
facts.push(factFor(target, fact`State ${state}: recorded as ${String(row.relation ?? 'unknown')} of forced target ${forced}; style changes=${changes}; geometry-changed=${String((row.geometry as Record<string, unknown> | undefined)?.changed ?? false)}; hittest-changed=${String((row.hittest as Record<string, unknown> | undefined)?.changed ?? false)}.`));
|
|
464
|
+
}
|
|
365
465
|
for (const record of records) {
|
|
366
466
|
const changes = record.style && typeof record.style === 'object' && Array.isArray((record.style as Record<string, unknown>).changed)
|
|
367
467
|
? ((record.style as Record<string, unknown>).changed as unknown[]).map(String).join(', ')
|
|
@@ -478,7 +578,7 @@ export function explainSnapshot(ref: SnapRef, selector: string, detailOpts: Expl
|
|
|
478
578
|
const rawSections: RawSection[] = [
|
|
479
579
|
elementSection(target, matches.length),
|
|
480
580
|
cascadeSection(target, styles),
|
|
481
|
-
stackingClippingSection(target, geometry),
|
|
581
|
+
stackingClippingSection(target, geometry, states, detailOpts.state),
|
|
482
582
|
focusScrollSection(target, focus, scroll),
|
|
483
583
|
queriesStatesSection(target, queries, states, styles),
|
|
484
584
|
];
|