styleproof 3.11.0 → 3.13.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/CHANGELOG.md CHANGED
@@ -7,6 +7,41 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.13.0] - 2026-07-05
11
+
12
+ ### Added
13
+
14
+ - **`styleproof-diff --json` now carries the inventory verdict.** The structured output
15
+ gained an `inventory` field alongside `coverage` and `determinism` — so all three
16
+ source-of-truth axes are machine-readable, matching the report's certification block.
17
+ Shape: `{ removed, added, unacknowledged, staleAcknowledgements }` (arrays of keys),
18
+ or `null` when no capture carried inventory. Previously the inventory removals were
19
+ printed to stdout but absent from `--json`, forcing a CI that wanted to hard-gate on a
20
+ dropped nav item to grep human prose. Now it can read `inventory.unacknowledged.length`.
21
+
22
+ ## [3.12.0] - 2026-07-05
23
+
24
+ ### Changed
25
+
26
+ - **Inventory guard: target-based keying.** Source-of-truth step 4 (keying honesty).
27
+ Tabs / menu items / nav buttons now key by the most stable identity the element
28
+ exposes — a developer-authored `data-testid`, else a non-generated `id` /
29
+ `aria-controls` — falling back to the label slug only when there's none. Previously
30
+ they always keyed by `slug(accessible-name)`, so a wobble in the **label** (a live
31
+ count badge like "COMMAND 5" → "COMMAND 3", a re-word) faked a `removed` + `added`
32
+ pair. Links were already immune (they key by href); this extends the same
33
+ target-based principle to the rest.
34
+
35
+ Framework-generated ids (React `useId` `:r0:`, Headless UI `headlessui-…`, Radix,
36
+ hashes) are rejected so keying on them can't _add_ churn. The design keeps the
37
+ guard's safe failure direction: the label-slug fallback turns a wobble into a
38
+ **surfaced** removed+added (a red you see and acknowledge), never a hidden real
39
+ removal. Give a nav item a `data-testid` to key it immune to its own text.
40
+
41
+ Note: an id-bearing affordance's key changes from `<role>:<slug>` to
42
+ `<role>:#<id>`, so existing `styleproof.inventory.json` acknowledgements for such
43
+ items need re-keying once (the guard names the new key).
44
+
10
45
  ## [3.11.0] - 2026-07-06
11
46
 
12
47
  ### Added
@@ -306,7 +306,26 @@ const determinismFails = printDeterminismVerdict(determinismVerdict);
306
306
  if (jsonOut)
307
307
  fs.writeFileSync(
308
308
  jsonOut,
309
- JSON.stringify({ counts, surfaces, compared, coverage: coverageVerdict, determinism: determinismVerdict }, null, 2),
309
+ JSON.stringify(
310
+ {
311
+ counts,
312
+ surfaces,
313
+ compared,
314
+ coverage: coverageVerdict,
315
+ determinism: determinismVerdict,
316
+ // The inventory verdict, machine-readable — parallel to coverage/determinism and
317
+ // to the report's certification block. `null` when no capture carried inventory.
318
+ // `unacknowledged` is the gating set: a CI can hard-fail on `unacknowledged.length`.
319
+ inventory: inventoryAudit && {
320
+ removed: inventoryAudit.delta.removed.map((i) => i.key),
321
+ added: inventoryAudit.delta.added.map((i) => i.key),
322
+ unacknowledged: inventoryAudit.unexplained.map((i) => i.key),
323
+ staleAcknowledgements: inventoryAudit.staleAllowances,
324
+ },
325
+ },
326
+ null,
327
+ 2,
328
+ ),
310
329
  );
311
330
 
312
331
  const total = counts.dom + counts.style + counts.state;
@@ -35,9 +35,23 @@ export type RawAffordance = {
35
35
  name: string;
36
36
  /** pathname+search for a same-origin `<a href>`; null otherwise. Resolved in-page. */
37
37
  internalPath: string | null;
38
+ /** `data-testid` — developer-authored, trusted as a stable identity when present. */
39
+ testId: string | null;
40
+ /** `id` — used as a stable identity only when it doesn't look framework-generated. */
41
+ domId: string | null;
42
+ /** `aria-controls` (a tab's panel) — a stable identity when not framework-generated. */
43
+ controls: string | null;
38
44
  };
39
45
  /** In-page: collect visible navigable affordances. No classification. */
40
46
  export declare function collectNavAffordances(): RawAffordance[];
47
+ /**
48
+ * Whether an `id` / `aria-controls` value is a STABLE identity worth keying on, vs a
49
+ * framework-generated one (React useId `:r0:`, Headless UI `headlessui-tabs-tab-3`,
50
+ * Radix `radix-:r1:`, Emotion hashes) that wobbles across renders/builds. Keying by a
51
+ * generated id would ADD churn, so those fall back to the label slug. (`data-testid` is
52
+ * developer-authored by definition, so it's trusted without this check.)
53
+ */
54
+ export declare function isStableId(id: string | null | undefined): id is string;
41
55
  /** Pure: turn raw affordances into keyed, deduped, sorted navigable items. */
42
56
  export declare function classifyInventory(raw: RawAffordance[]): NavigableItem[];
43
57
  /** Harvest a page's inventory: thin in-page collect + pure classify. */
package/dist/inventory.js CHANGED
@@ -75,6 +75,9 @@ export function collectNavAffordances() {
75
75
  role: (el.getAttribute('role') || '').toLowerCase(),
76
76
  name: nameOf(el),
77
77
  internalPath: el.tagName === 'A' ? internalPath(el) : null,
78
+ testId: el.getAttribute('data-testid'),
79
+ domId: el.getAttribute('id'),
80
+ controls: el.getAttribute('aria-controls'),
78
81
  }));
79
82
  }
80
83
  const slug = (s) => s
@@ -82,6 +85,48 @@ const slug = (s) => s
82
85
  .replace(/[^a-z0-9]+/g, '-')
83
86
  .replace(/^-+|-+$/g, '')
84
87
  .slice(0, 60);
88
+ /**
89
+ * Whether an `id` / `aria-controls` value is a STABLE identity worth keying on, vs a
90
+ * framework-generated one (React useId `:r0:`, Headless UI `headlessui-tabs-tab-3`,
91
+ * Radix `radix-:r1:`, Emotion hashes) that wobbles across renders/builds. Keying by a
92
+ * generated id would ADD churn, so those fall back to the label slug. (`data-testid` is
93
+ * developer-authored by definition, so it's trusted without this check.)
94
+ */
95
+ export function isStableId(id) {
96
+ if (!id)
97
+ return false;
98
+ const v = id.trim();
99
+ if (!v || v.length > 80)
100
+ return false;
101
+ if (v.includes(':'))
102
+ return false; // React useId / Radix scoped ids
103
+ if (/^(headlessui|radix|mui|chakra|reach|react-aria|floating-ui|downshift)-/i.test(v))
104
+ return false;
105
+ if (/^[0-9a-f]{8,}$/i.test(v))
106
+ return false; // hash-like
107
+ return true;
108
+ }
109
+ // A tab/menuitem/button's stable identity, if it exposes one: a data-testid (trusted),
110
+ // else a non-generated id or aria-controls. Preferred over the label so a count badge
111
+ // or re-label in the text doesn't move the key. (Links already key by href, the most
112
+ // stable target of all.) When absent, we fall back to the label slug — the wobble is a
113
+ // false removed+added, which the guard SURFACES; it never hides a real removal.
114
+ function stableIdOf(c) {
115
+ if (c.testId && c.testId.trim())
116
+ return c.testId.trim();
117
+ if (isStableId(c.domId))
118
+ return c.domId.trim();
119
+ if (isStableId(c.controls))
120
+ return c.controls.trim();
121
+ return null;
122
+ }
123
+ // `<role>:#<stable-id>` when the affordance exposes a stable identity, else
124
+ // `<role>:<slug(name)>`. The `#` marker keeps id-keys from colliding with slug-keys
125
+ // (a slug never contains `#`).
126
+ function affordanceKey(role, c) {
127
+ const id = stableIdOf(c);
128
+ return id ? `${role}:#${id}` : `${role}:${slug(c.name)}`;
129
+ }
85
130
  /** Pure: turn raw affordances into keyed, deduped, sorted navigable items. */
86
131
  export function classifyInventory(raw) {
87
132
  const items = new Map();
@@ -94,13 +139,13 @@ export function classifyInventory(raw) {
94
139
  add(`route:${c.internalPath}`, 'link', c.name || c.internalPath, c.internalPath);
95
140
  }
96
141
  else if (c.name && c.role === 'tab') {
97
- add(`tab:${slug(c.name)}`, 'tab', c.name);
142
+ add(affordanceKey('tab', c), 'tab', c.name);
98
143
  }
99
144
  else if (c.name && c.role.startsWith('menuitem')) {
100
- add(`menuitem:${slug(c.name)}`, 'menuitem', c.name);
145
+ add(affordanceKey('menuitem', c), 'menuitem', c.name);
101
146
  }
102
147
  else if (c.name && c.tag === 'button') {
103
- add(`nav-button:${slug(c.name)}`, 'nav-button', c.name);
148
+ add(affordanceKey('nav-button', c), 'nav-button', c.name);
104
149
  }
105
150
  }
106
151
  return Array.from(items.values()).sort((a, b) => a.key.localeCompare(b.key));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "3.11.0",
3
+ "version": "3.13.0",
4
4
  "description": "Catch every CSS change before it ships — review PRs and certify refactors by the browser's computed styles, not pixels. Works with any styling system.",
5
5
  "keywords": [
6
6
  "playwright",