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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

### Fixed

- **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
"computed-style difference(s)" / "state-delta difference(s)" / "restyled" for
matched-path before→after changes. Added-node tables are labelled
**Style inventory (head-side — no baseline)** with value-only rows, so wrapper
/ path-churn inserts read as DOM adds and removals — never as a pile of
restyles. `report.json` `counts` follows the presentation taxonomy;
`rawCounts` / `reviewableCounts` keep the full certification tallies.
- **Dependency audit is green again.** Updated the transitive `fast-uri` development
dependency from 3.1.4 to 3.1.5, closing GHSA-7p8r-x3mc-p8w7 without changing
the shipped runtime package.
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,13 @@ New pages, states, and surfaces appear before element-level changes. Existing
surfaces render one distinct change per section, with aligned crops, truthful
annotations, a one-line summary, and exact properties under a toggle.

Headline counts distinguish **matched-path restyles** ("N computed-style
difference(s)") from **one-sided DOM adds/removes**. A brand-new node's full
resting and interaction-state inventory still appears under the element (value-only
tables labelled _Style inventory (head-side — no baseline)_), but those rows are
not billed as before→after restyles — so a wrapper insert or path churn reads as
DOM structure, not a cascade of restyles.

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
14 changes: 10 additions & 4 deletions bin/styleproof-diff.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -431,16 +431,21 @@ printBaselineSurfaceFailureCallout();

// One finding's lines: a heading, then its summarised property deltas (the same
// dedupe the report shows). Returns [] for a DOM finding (handled separately) or a
// finding whose props all summarised away.
function findingLines(f) {
// finding whose props all summarised away. `inventory` = one-sided added path —
// head-side values with no baseline, never printed as before → after restyles.
function findingLines(f, inventory = false) {
if (f.kind === 'dom') return [];
const rows = summarizeProps(f.props);
if (!rows.length) return [];
const head =
f.kind === 'state'
? ` [:${f.state}] ${findingLabel(f.path, f.cls)}${f.sub !== f.path ? ` ⇒ ${f.sub}` : ''}`
: ` ${findingLabel(f.path, f.cls)}${f.pseudo || ''}`;
return [head, ...rows.map((p) => ` ${p.prop}: ${p.before} → ${p.after}`)];
const note = inventory ? ' (head-side inventory — no baseline)' : '';
return [
head + note,
...rows.map((p) => (inventory ? ` ${p.prop}: ${p.after}` : ` ${p.prop}: ${p.before} → ${p.after}`)),
];
}

// A DOM finding's one-line heading (added/removed/retagged).
Expand All @@ -457,7 +462,8 @@ function elementLines(findings) {
for (const group of groupByPath(findings)) {
const dom = group.find((f) => f.kind === 'dom');
if (dom) lines.push(domLine(dom));
for (const f of group) lines.push(...findingLines(f));
const inventory = dom?.change === 'added';
for (const f of group) lines.push(...findingLines(f, inventory));
}
return lines;
}
Expand Down
12 changes: 6 additions & 6 deletions docs/demo/report.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

🆕 **1 new surface(s)** captured with no baseline to compare: `pricing @ 900`. Approve them before they become the baseline.

**5 DOM change(s) · 8 computed-style difference(s)** across 3 distinct change(s) in 3 changed surface bases with an existing baseline.
**5 DOM change(s) · 4 computed-style difference(s)** across 3 distinct change(s) in 3 changed surface bases with an existing baseline.
_**Surface base** = one product UI state; capture keys with `@width` or live-state/popup variants are width or state captures of that base._

## 🆕 New pages, states, or surfaces — review first
Expand Down Expand Up @@ -34,11 +34,11 @@ _duplicate-insertion @ 900_
- **1** element added

<details>
<summary>Show the property change</summary>
<summary>Show the head-side style inventory</summary>

**Added** `button.duplicate-control`

Style:
Style inventory (head-side — no baseline):

| Property | Value |
| --- | --- |
Expand Down Expand Up @@ -144,23 +144,23 @@ Style:

**Added** `button.filter`

Style:
Style inventory (head-side — no baseline):

| Property | Value |
| --- | --- |
| `color` | `#ffffff` |

**Added** `div.grid`

Style:
Style inventory (head-side — no baseline):

| Property | Value |
| --- | --- |
| `background-color` | `#e5e7eb` |

**Added** `article.card`

Style:
Style inventory (head-side — no baseline):

| Property | Value |
| --- | --- |
Expand Down
4 changes: 2 additions & 2 deletions docs/what-it-catches.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ On every **captured surface**, base vs head:

| Change | Surfaced as | Pinned by |
| ------------------------------------------------------------ | ----------------------------------- | -------------- |
| A computed style differs (resting) | `style` finding, property named | pr-surfacing ✓ |
| A computed style differs on a matched path (resting) | `style` finding; headline "computed-style difference(s)" — not used for added-node inventory | pr-surfacing ✓ |
| 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) | 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 ✓ |
| 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
4 changes: 3 additions & 1 deletion src/describe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,9 @@ function restyleLines(els: ElementChange[], ctx: DescribeCtx): string[] {
*/
export function describeChange(els: ElementChange[], ctx: DescribeCtx = {}, maxBullets = 6): string[] {
const lines = [...domVerbLines(els), ...restyleLines(els, ctx)];
const states = [...new Set(els.flatMap((e) => e.states ?? []))];
// Interaction-state *changes* only on matched-path elements. Added/removed nodes
// carry head- or base-side state inventory (no baseline pair) — the DOM verb covers them.
const states = [...new Set(els.filter((e) => !e.added && !e.removed).flatMap((e) => e.states ?? []))];
if (states.length) lines.push(`interaction states changed: ${states.map((s) => `\`:${s}\``).join(', ')}`);
if (lines.length <= maxBullets) return lines;
return [...lines.slice(0, maxBullets - 1), `…and ${lines.length - (maxBullets - 1)} more change(s)`];
Expand Down
46 changes: 39 additions & 7 deletions src/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,9 @@ function annotationPaths(
return { before: innermost([...beforePaths]), after: innermost([...afterPaths]) };
}

/** Headline counts with the zeros dropped — `0 state-delta difference(s)` is noise. */
/** Headline counts with the zeros dropped — `0 state-delta difference(s)` is noise.
* `style`/`state` here are matched-path restyles only (see {@link countShownChanges});
* one-sided added-node inventories are billed under DOM, never as differences. */
function changeCountLabel(shown: DiffCounts): string {
const parts: string[] = [];
if (shown.dom) parts.push(`${shown.dom} DOM change(s)`);
Expand All @@ -444,6 +446,16 @@ function changeCountLabel(shown: DiffCounts): string {
return parts.join(' · ');
}

/** Paths that are one-sided DOM adds/removes — their style/state rows are full
* head- or base-side inventories, not before→after restyles on a matched path. */
function oneSidedDomPaths(findings: Finding[]): Set<string> {
const paths = new Set<string>();
for (const f of findings) {
if (f.kind === 'dom' && (f.change === 'added' || f.change === 'removed')) paths.add(f.path);
}
return paths;
}

type Group = { paths: string[]; before: Box | null; after: Box | null };

function groupRegions(paths: string[], a: StyleMap, b: StyleMap, padBy: number): Group[] {
Expand Down Expand Up @@ -740,6 +752,13 @@ function valueTable(rows: PropChange[]): string[] {
return ['| Property | Value |', '| --- | --- |', ...rows.map((r) => `| ${codeValue(r.prop)} | ${cell(r.after)} |`)];
}

/** Heading for an added node's style block — inventory, never a before→after restyle. */
function addedStyleHeading(pseudo: string | null): string {
return pseudo
? `On \`${pseudo}\` (head-side inventory — no baseline):`
: 'Style inventory (head-side — no baseline):';
}

/** `Button (variant=primary, size=sm)` — the React component + sanitized props
* the element captured (advisory; present only with captureComponent). */
function renderComponent(c: { name: string; props?: Record<string, string> }): string {
Expand All @@ -749,15 +768,15 @@ function renderComponent(c: { name: string; props?: Record<string, string> }): s
}

/** One element's heading + body lines (no leading blank, no ×N suffix). */
// Base/pseudo style rows. Added elements render value-only (no meaningful before).
// Base/pseudo style rows. Added elements render value-only inventory (no baseline).
function styleSection(styles: Extract<Finding, { kind: 'style' }>[], added: boolean): string[] {
const out: string[] = [];
for (const s of styles) {
const rows = summarizeProps(s.props);
if (rows.length)
out.push(
'',
s.pseudo ? `On \`${s.pseudo}\`:` : 'Style:',
added ? addedStyleHeading(s.pseudo) : s.pseudo ? `On \`${s.pseudo}\`:` : 'Style:',
'',
...(added ? valueTable(rows) : beforeAfterTable(rows)),
);
Expand Down Expand Up @@ -857,10 +876,17 @@ function renderElements(findings: Finding[], maxElements = 40): string[] {
}

/** Plain-text `<summary>` affordance — GitHub renders markdown inside `<summary>`
* literally, so no backticks or bold here. */
* literally, so no backticks or bold here. Added-node-only inventories say
* "inventory", not "property change", so reviewers don't read them as restyles. */
function foldSummary(findings: Finding[]): string {
const n = findings.flatMap((f) => (f.kind === 'dom' ? [] : summarizeProps(f.props))).length;
const oneSided = oneSidedDomPaths(findings);
const propFindings = findings.filter((f) => f.kind !== 'dom');
const n = propFindings.flatMap((f) => summarizeProps(f.props)).length;
if (!n) return 'Show details';
const allInventory = propFindings.length > 0 && propFindings.every((f) => oneSided.has(f.path));
if (allInventory) {
return n === 1 ? 'Show the head-side style inventory' : `Show all ${n} head-side inventory properties`;
}
return n === 1 ? 'Show the property change' : `Show all ${n} property changes`;
}

Expand Down Expand Up @@ -1258,15 +1284,21 @@ function groupBySignature(prepared: PreparedSurface[], beforeDir: string, afterD
}

// Counts reflect the GROUPED view: each distinct change counts once, not once per
// surface it appears on (after shorthand/dedupe collapsing).
// surface it appears on (after shorthand/dedupe collapsing). Style/state tallies
// are matched-path restyles only — props on a one-sided added/removed path are
// head- or base-side inventories and already covered by the DOM count; billing
// them as "computed-style difference(s)" mislabels path churn as restyles.
function countShownChanges(changeGroups: ChangeGroup[]): DiffCounts {
const shown: DiffCounts = { dom: 0, style: 0, state: 0 };
for (const cg of changeGroups)
for (const cg of changeGroups) {
const oneSided = oneSidedDomPaths(cg.findings);
for (const f of cg.findings) {
if (f.kind === 'dom') shown.dom++;
else if (oneSided.has(f.path)) continue;
else if (f.kind === 'style') shown.style += summarizeProps(f.props).length;
else shown.state += summarizeProps(f.props).length;
}
}
return shown;
}

Expand Down
Loading
Loading