Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
The report branch previously received only `report.md` and crop PNGs even when
a byte-capped Markdown report directed reviewers to `report.json`. Publication
now includes the generated JSON alongside the Markdown and crops.
- **Report-only path correspondence surfaces real before→after deltas across path
churn.** When a DOM-removed base path and a DOM-added head path share a unique
privacy-safe signature (tag + rect x/y/width + ownTextLength) under a meaningful
shared structural prefix, presentation rewrites the before path onto the head
path and re-runs the existing differ for the report only. Unchanged wrapper
moves collapse; true paired property deltas (e.g. font-size, height geometry)
show as restyles; ambiguous duplicate signatures stay unpaired. Raw findings,
`rawCounts` / `reviewableCounts`, exit codes, and approval gates stay on the
concrete-path certification differ.

- **Report taxonomy no longer bills added-node style inventories as restyles.** A
brand-new element still emits its full resting/state inventory (raw findings and
exit codes unchanged), but presentation counts and copy reserve
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,14 @@ tables labelled _Style inventory (head-side — no baseline)_), but those rows a
not billed as before→after restyles — so a wrapper insert or path churn reads as
DOM structure, not a cascade of restyles.

When a removed base path and an added head path share a **unique** privacy-safe
signature (tag + box x/y/width + own-text length) under a shared structural
prefix, the report **corresponds** them for presentation only: pure path moves
with unchanged styles collapse, and real paired property deltas (font-size,
height geometry, …) render as ordinary before→after restyles on the head path.
Ambiguous duplicates stay unpaired — no invented provenance. Certification still
diffs concrete paths; raw counts, exit codes, and approval gates are unchanged.

Tiny changes also receive a magnified crop. Structural matching avoids painting
an unchanged shifted subtree as changed, while ambiguous duplicate elements stay
explicit rather than receiving invented provenance.
Expand Down
1 change: 1 addition & 0 deletions docs/what-it-catches.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ On every **captured surface**, base vs head:
| A `:hover` / `:focus` / `:active` variant dropped or changed | `state` finding | pr-surfacing ✓ |
| A `::before` / `::after` style differs | `style` finding, pseudo tagged | pr-surfacing ✓ |
| An element is added or removed | `dom` finding (added / removed); report shows head-side style **inventory** (value-only, no baseline) — not a restyle | pr-surfacing ✓ |
| Path churn with unique stable geometry (wrapper insert, …) | report-only correspondence: pure moves collapse; true property deltas pair as restyles — raw path diff/gates unchanged | unit + report tests |
| An element is retagged (`button` → `a`) | removed + added at that position | pr-surfacing ✓ |
| A nav item / route disappears | inventory guard, named, **gates** | pr-surfacing ✓ |
| A surface exists on only one side | reported as a new / removed surface | pr-surfacing ✓ |
Expand Down
150 changes: 150 additions & 0 deletions src/path-correspondence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import type { ElementEntry, StyleMap } from './capture.js';
import { diffStyleMaps, type Finding } from './diff.js';

/**
* Report-only structural correspondence between base and head captures.
*
* Certification still diffs by concrete structural path. Presentation may
* remap a one-sided before path onto a uniquely corresponding after path so
* path churn (wrapper insert, nth-child shift with stable geometry) collapses
* into real paired property deltas — or disappears when nothing changed —
* without touching raw findings, exit codes, or approval gates.
*
* Matching is intentionally conservative:
* - candidates are only DOM-removed (before) vs DOM-added (after) paths
* - signature is privacy-safe: tag + rect x/y/width + ownTextLength (height
* omitted so pure size changes still pair)
* - signature must be unique on both sides
* - paths must share a meaningful structural prefix
* - ambiguous or incomplete signatures stay unmatched
*/

/** Privacy-safe correspondence key, or null when the entry cannot be paired. */
export function correspondenceSignature(entry: ElementEntry | undefined): string | null {
if (!entry?.rect) return null;
const [x, y, width] = entry.rect;
// ownTextLength is always safe (length only). Legacy maps without it stay
// unpaired: geometry alone is not enough evidence to claim correspondence.
if (entry.ownTextLength === undefined) return null;
const textLen = entry.ownTextLength;
return JSON.stringify([entry.tag, x, y, width, textLen]);
}

/** Longest exact leading path-segment prefix (`a > b` style). */
export function sharedPathPrefix(beforePath: string, afterPath: string): string {
const beforeSegs = beforePath.split(' > ');
const afterSegs = afterPath.split(' > ');
const shared: string[] = [];
const limit = Math.min(beforeSegs.length, afterSegs.length);
for (let i = 0; i < limit && beforeSegs[i] === afterSegs[i]; i++) shared.push(beforeSegs[i]!);
return shared.join(' > ');
}

/**
* Require a non-empty shared ancestor segment so unrelated subtrees that
* happen to share geometry cannot pair across the whole document.
*/
export function hasMeaningfulSharedPrefix(beforePath: string, afterPath: string): boolean {
return sharedPathPrefix(beforePath, afterPath).length > 0;
}

function indexBySignature(map: StyleMap, paths: string[]): Map<string, string[]> {
const bySig = new Map<string, string[]>();
for (const elementPath of paths) {
const sig = correspondenceSignature(map.elements[elementPath]);
if (sig === null) continue;
const list = bySig.get(sig);
if (list) list.push(elementPath);
else bySig.set(sig, [elementPath]);
}
return bySig;
}

/**
* Conservative before→after path map among one-sided elements only.
* Values are after (head) paths; keys are before (base) paths.
*/
export function correspondElementPaths(before: StyleMap, after: StyleMap): Map<string, string> {
const removed = Object.keys(before.elements).filter((p) => !(p in after.elements));
const added = Object.keys(after.elements).filter((p) => !(p in before.elements));
if (removed.length === 0 || added.length === 0) return new Map();

const removedBySig = indexBySignature(before, removed);
const addedBySig = indexBySignature(after, added);
const mapping = new Map<string, string>();

for (const [sig, beforePaths] of removedBySig) {
if (beforePaths.length !== 1) continue; // ambiguous on before side
const afterPaths = addedBySig.get(sig);
if (!afterPaths || afterPaths.length !== 1) continue; // missing or ambiguous on after
const beforePath = beforePaths[0]!;
const afterPath = afterPaths[0]!;
if (!hasMeaningfulSharedPrefix(beforePath, afterPath)) continue;
mapping.set(beforePath, afterPath);
}

return mapping;
}

/** Rewrite an element path, preserving a `::pseudo` suffix when present. */
export function remapPath(elementPath: string, beforeToAfter: Map<string, string>): string {
const pseudoAt = elementPath.indexOf('::');
if (pseudoAt === -1) return beforeToAfter.get(elementPath) ?? elementPath;
const base = elementPath.slice(0, pseudoAt);
const pseudo = elementPath.slice(pseudoAt);
return (beforeToAfter.get(base) ?? base) + pseudo;
}

/**
* Clone a before map with matched element (and safe forced-state) paths rewritten
* onto their corresponding after paths. Unmatched paths stay put.
*/
export function remapBeforeStyleMap(before: StyleMap, beforeToAfter: Map<string, string>): StyleMap {
if (beforeToAfter.size === 0) return before;

const elements: StyleMap['elements'] = {};
for (const [elementPath, entry] of Object.entries(before.elements)) {
elements[remapPath(elementPath, beforeToAfter)] = entry;
}

const states: NonNullable<StyleMap['states']> = {};
for (const [ownerPath, byState] of Object.entries(before.states ?? {})) {
const newOwner = remapPath(ownerPath, beforeToAfter);
const mappedByState: (typeof states)[string] = {};
for (const [stateName, targets] of Object.entries(byState)) {
const mappedTargets: typeof targets = {};
for (const [targetPath, props] of Object.entries(targets)) {
// Owner and target paths remap independently when each end corresponded;
// a target that did not correspond keeps its concrete before path and
// will surface as a one-sided state inventory against the real after map
// — safer than inventing a head path.
mappedTargets[remapPath(targetPath, beforeToAfter)] = props;
}
mappedByState[stateName] = mappedTargets;
}
states[newOwner] = mappedByState;
}

return {
...before,
elements,
states,
volatile: before.volatile?.map((p) => remapPath(p, beforeToAfter)),
liveCandidates: before.liveCandidates?.map((c) => ({ ...c, path: remapPath(c.path, beforeToAfter) })),
overlays: before.overlays?.map((o) => ({ ...o, path: remapPath(o.path, beforeToAfter) })),
};
}

/** Before map rewritten for presentation lookup/diff against the real after map. */
export function presentationBeforeMap(before: StyleMap, after: StyleMap): StyleMap {
return remapBeforeStyleMap(before, correspondElementPaths(before, after));
}

/**
* Presentation findings: same differ as certification, but on a before map whose
* uniquely corresponded paths have been rewritten to the head path. Raw
* `diffStyleMaps(before, after)` is unchanged for gates.
*/
export function presentationDiffStyleMaps(before: StyleMap, after: StyleMap): Finding[] {
return diffStyleMaps(presentationBeforeMap(before, after), after);
}
48 changes: 36 additions & 12 deletions src/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
type PropChange,
type SurfaceDiff,
} from './diff.js';
import { presentationBeforeMap, presentationDiffStyleMaps } from './path-correspondence.js';
import { describeChange, tokenIndex, toHex, type ElementChange, type DescribeCtx } from './describe.js';
import {
auditCoverage,
Expand Down Expand Up @@ -1238,8 +1239,11 @@ type RepresentativeScore = { hasExposedChange: boolean; hasActiveModal: boolean;
* ordinary page over a popup state that can leave shared chrome in the background,
* then the widest width. */
function representativeScore(candidate: PreparedSurface, beforeDir: string, afterDir: string): RepresentativeScore {
const beforeMap = loadStyleMap(findCapture(beforeDir, candidate.sd.surface));
const rawBefore = loadStyleMap(findCapture(beforeDir, candidate.sd.surface));
const afterMap = loadStyleMap(findCapture(afterDir, candidate.sd.surface));
// Presentation findings may sit on corresponded head paths — score against the
// remapped before map so those paths resolve on both sides.
const beforeMap = presentationBeforeMap(rawBefore, afterMap);
const changedPaths = [...new Set(candidate.findings.map((finding) => finding.path))];
const hasExposedChange = hasExposedChangedEntry(beforeMap, afterMap, changedPaths);
const hasActiveModal = [...(beforeMap.overlays ?? []), ...(afterMap.overlays ?? [])].some(
Expand Down Expand Up @@ -1661,8 +1665,11 @@ function renderChangeGroup(
cropSeq: number,
): { md: string[]; json: Record<string, unknown>; findingCount: number; cropSeq: number } {
const { sd, findings: surfaceFindings } = cg.rep;
const mapA = loadStyleMap(findCapture(ctx.beforeDir, sd.surface));
const rawBefore = loadStyleMap(findCapture(ctx.beforeDir, sd.surface));
const mapB = loadStyleMap(findCapture(ctx.afterDir, sd.surface));
// Same correspondence rewrite as prepareReportSurfaces so crops/annotations
// resolve corresponded head paths on the before side too.
const mapA = presentationBeforeMap(rawBefore, mapB);
// Theme-token reverse-indexes so colour changes can name `red-200` per side.
const describeCtx: DescribeCtx = { tokensBefore: tokenIndex(mapA.tokens), tokensAfter: tokenIndex(mapB.tokens) };
const changedPaths = outermost([...new Set(surfaceFindings.map((f) => f.path))]);
Expand Down Expand Up @@ -1825,20 +1832,37 @@ function comparisonForReport(
};
}

/** Focus each surface on styling intent unless layout noise is requested. A
* surface whose ONLY changes are derived longhands keeps them
* (cleanFindingsForDisplay): those findings still gate, and a report that
* renders nothing for a gating change asks a reviewer to approve evidence
* that doesn't exist. */
/**
* Focus each surface on styling intent unless layout noise is requested.
*
* Presentation findings run through report-only path correspondence first:
* uniquely paired removed→added elements are rewritten onto the head path so
* `diffStyleMaps` can emit real before→after property deltas (or collapse a
* pure path move). Raw `sd.findings`, `rawCounts`, exit codes, and approval
* gates stay on the concrete-path certification differ.
*
* A surface whose ONLY changes are derived longhands keeps them
* (cleanFindingsForDisplay): those findings still gate, and a report that
* renders nothing for a gating change asks a reviewer to approve evidence
* that doesn't exist.
*/
function prepareReportSurfaces(
surfaces: ReturnType<typeof diffStyleMapDirs>['surfaces'],
includeNoise: boolean,
beforeDir: string,
afterDir: string,
): PreparedSurface[] {
return surfaces
.map((sd) => ({
sd,
findings: sd.missing || includeNoise ? sd.findings : cleanFindingsForDisplay(sd.findings),
}))
.map((sd) => {
if (sd.missing) return { sd, findings: sd.findings };
const beforeMap = loadStyleMap(findCapture(beforeDir, sd.surface));
const afterMap = loadStyleMap(findCapture(afterDir, sd.surface));
const corresponded = presentationDiffStyleMaps(beforeMap, afterMap);
return {
sd,
findings: includeNoise ? corresponded : cleanFindingsForDisplay(corresponded),
};
})
.filter((p) => p.sd.missing || p.findings.length > 0);
}

Expand Down Expand Up @@ -1908,7 +1932,7 @@ export function generateStyleMapReport(opts: ReportOptions): ReportResult {
// forced-state echoes of base changes, and remove non-value noise (see
// cleanFindings), unless includeLayoutNoise is set. Surfaces left with no real
// change are dropped.
const prepared = prepareReportSurfaces(surfaces, includeNoise);
const prepared = prepareReportSurfaces(surfaces, includeNoise, beforeDir, afterDir);

const missing = prepared.filter((p) => p.sd.missing);
const changeGroups = groupBySignature(prepared, beforeDir, afterDir);
Expand Down
Loading
Loading