styleproof 1.9.4 → 2.0.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,86 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.0.0] - 2026-06-22
11
+
12
+ ### Changed
13
+
14
+ - **Single approval box is the only review-gate UI.** The report comment carries
15
+ one **Approve all changes** checkbox — one tick signs off every change. The
16
+ per-change boxes (and the `approve-all` input that opted into the single box) are
17
+ gone. **Breaking:** a review-gate consumer on the old per-change
18
+ `styleproof-approve` workflow must replace it with the updated
19
+ `example/styleproof-approve.yml` — the old one counts `Approve this change` boxes
20
+ that no longer exist, so it would never turn the status green.
21
+ - **Lean PR comment; the committed report is the complete source of truth.** The
22
+ comment is now a summary + the approval box + a link to the side-by-side report.
23
+ Before/after crops and per-element property tables live only in the report, so
24
+ the comment and report can't drift and the comment renders identically on public
25
+ and private repos. **Breaking:** the `inline-images` input is removed (the comment
26
+ no longer embeds images).
27
+
28
+ ### Added
29
+
30
+ - **Approver attribution.** When a reviewer ticks **Approve all changes**, the
31
+ comment shows _approved by @them_ inline and the commit-status description reads
32
+ `Approved by @them`. The status is the source of truth, so a later report re-run
33
+ (e.g. to clear a blocking check) reconstructs the attribution instead of losing it.
34
+ - **`styleproof.config.json` policy file + `blocking`.** An optional repo-root file
35
+ for gate _policy_, separate from the Action's workflow-_plumbing_ inputs.
36
+ `"blocking": true` makes review-gate mode also **fail the job** on unapproved
37
+ visual changes, so the check is red even without a branch-protection rule
38
+ requiring the status — the blocking option for free private repos. Asynchronous by
39
+ design: tick **Approve all changes**, then re-run the job; the re-run reads the
40
+ sign-off from the commit status and passes instead of clobbering it.
41
+
42
+ ## [1.10.0] - 2026-06-22
43
+
44
+ ### Added
45
+
46
+ - **`approve-all` input.** In review-gate mode, render a single **Approve all
47
+ changes** checkbox at the top of the report instead of one box per change, so a
48
+ reviewer signs off every change with one tick. Off by default (per-change boxes
49
+ are unchanged); the `styleproof-approve` workflow accepts either shape.
50
+ - **Fork and Dependabot support.** The Action now resolves the PR number and head
51
+ SHA in an event-aware way, so it can be driven from a `workflow_run` as well as
52
+ from `pull_request`. New example workflows `example/styleproof-capture.yml`
53
+ (read-only `pull_request` capture + artifact upload) and
54
+ `example/styleproof-report.yml` (`workflow_run` report under a write token, never
55
+ running the PR's code) let fork and Dependabot PRs gate without handing a write
56
+ token to untrusted code — the secure alternative to `pull_request_target`. PR
57
+ identity is taken only from the trusted `workflow_run` event (`head_sha`, the
58
+ event's `pull_requests`, and a commit→PR lookup against that same head SHA for
59
+ forks) — never from the capture artifact — so an untrusted PR cannot redirect the
60
+ privileged comment or status at a victim PR or commit.
61
+
62
+ ### Fixed
63
+
64
+ - **Real focus no longer makes `:focus` capture nondeterministic.** Capture froze
65
+ motion (`FREEZE_CSS`) and the clock but never neutralised real DOM focus, so
66
+ whatever element happened to hold focus at capture time (autofocus, late
67
+ hydration, a stray prior action) contaminated the capture across runs: it baked
68
+ a focus ring into the resting `elements` map, and it cancelled the forced-state
69
+ `:focus` delta — forcing `:focus` on an already-focused element changes nothing,
70
+ so the ring showed up as a delta on some runs but not others, surfacing as a
71
+ self-check `outline-color: … → (state does not change it)` failure on a no-op
72
+ PR. `captureStyleMap` now blurs the active element before any read, mirroring
73
+ the motion freeze; `:hover`/`:focus`/`:active` are still certified
74
+ deterministically via CDP `forcePseudoState`.
75
+ - **SSE streams no longer read as phantom diffs under replay.** A long-lived
76
+ stream can't round-trip through a HAR, so `routeFromHAR`'s url glob (default
77
+ `**/api/**`) would intercept an app's `EventSource` endpoint and, on the replay
78
+ run, abort it — dropping the app to its no-stream fallback (a different but
79
+ _stable_ render that settle/volatile can't catch). That surfaced as a
80
+ computed-style change on every diff even when no CSS changed. Capture now lets
81
+ `Accept: text/event-stream` requests bypass record/replay and reach the live
82
+ server on both runs, so both sides see the same streamed state. Stream-pushed
83
+ data must be deterministic at capture time (fixtures/frozen clock), as the
84
+ README already requires of live regions.
85
+ - The README CI recipe pointed `baseline-dir` / `fresh-dir` at the bare `base` /
86
+ `head` labels, but captures land under `baseDir` (`__stylemaps__/base`,
87
+ `__stylemaps__/head`), so `styleproof-diff` failed with `no capture at base`.
88
+ The recipe now uses the full `__stylemaps__/<label>` paths.
89
+
10
90
  ## [1.9.4]
11
91
 
12
92
  ### Changed
package/README.md CHANGED
@@ -14,9 +14,9 @@ Pixel-snapshot tools miss most CSS regressions: they can't force `:hover` / `:fo
14
14
 
15
15
  On every PR, StyleProof captures a `StyleMap` from the HEAD and from the base branch, diffs them, and posts a Markdown comment:
16
16
 
17
- - A summary line, then **one section per distinct change**, with a side-by-side before/after cropped screenshot (both sides cropped from the same rectangle, so they line up exactly) and **plain-English bullets that tell you what to look for** (`columns: 2 → 3`, `recoloured cyan → amber`) above the exact property changes, folded under a toggle.
18
- - An **approval checkbox per change**, driving a `StyleProof` commit status: red until every change is signed off, green when there are none.
19
- - **New surfaces don't block.** A surface that exists only on the PR head (no baseline to diff — e.g. the bootstrap PR that first adds the capture spec, or a brand-new page) is shown with its screenshot under a `🆕 new surface` heading and an _optional_ approval box, but it never holds the status red. It becomes part of the baseline once merged.
17
+ - A **lean summary comment** linking to a committed side-by-side report — the report is the complete source of truth (**one section per distinct change**, with a before/after cropped screenshot cropped from the same rectangle so the two sides line up exactly, **plain-English bullets that tell you what to look for** `columns: 2 → 3`, `recoloured cyan → amber` and the exact property changes). The comment never duplicates the report, so the two can't drift, and it renders identically on public and private repos.
18
+ - A single **Approve all changes** checkbox in the comment, driving a `StyleProof` commit status: red until one tick signs off every change, green when there are none. The reviewer who ticks it is recorded inline (_approved by @them_), sourced from the commit status so it survives a report re-run.
19
+ - **New surfaces don't block.** A surface that exists only on the PR head (no baseline to diff — e.g. the bootstrap PR that first adds the capture spec, or a brand-new page) is shown in the report under a `🆕 new surface` heading but never holds the status red and needs no sign-off. It becomes part of the baseline once merged.
20
20
  - No committed baseline to maintain — the diff is HEAD-vs-base, so the report is _exactly what this PR changes_.
21
21
 
22
22
  ## What a report looks like
@@ -111,10 +111,10 @@ jobs:
111
111
  - run: STYLEMAP_DIR=head STYLEPROOF_REPLAY_FROM=__stylemaps__/base npx playwright test e2e/styleproof.spec.ts
112
112
 
113
113
  # report + gate
114
- - uses: BenSheridanEdwards/StyleProof@v1
114
+ - uses: BenSheridanEdwards/StyleProof@v2
115
115
  with:
116
- baseline-dir: base
117
- fresh-dir: head
116
+ baseline-dir: __stylemaps__/base # captures land under baseDir (default __stylemaps__)
117
+ fresh-dir: __stylemaps__/head
118
118
  require-approval: true # review-gate mode (omit / use fail-on-diff: true to certify)
119
119
  ```
120
120
 
@@ -122,6 +122,21 @@ jobs:
122
122
 
123
123
  **4. Require the `StyleProof` status** in branch protection. Now an unsigned visual change can't merge.
124
124
 
125
+ ## Forks and Dependabot
126
+
127
+ The single-workflow setup above runs the whole gate in one `pull_request` job — which needs a **write** token to push the report branch, post the comment, and set the `StyleProof` status. That's fine for same-repo PRs, but **fork and Dependabot PRs run with a read-only `GITHUB_TOKEN`** (GitHub's security default for untrusted PRs). So the job can't post the status — and a required `StyleProof` check then sits `pending` forever, blocking the PR even though a dependency or fork change usually touches no UI at all.
128
+
129
+ Fix it by splitting capture from reporting, the way the approve workflow is already split out:
130
+
131
+ - **[`example/styleproof-capture.yml`](example/styleproof-capture.yml)** runs `on: pull_request` with a **read-only** token and no secrets — safe to run untrusted PR code. It only builds, captures the style maps, and uploads them as an artifact.
132
+ - **[`example/styleproof-report.yml`](example/styleproof-report.yml)** runs `on: workflow_run` (after capture finishes) from your **default branch** with a write token. It downloads the artifact and does the diff, comment, and status — but **never checks out or runs the PR's code**, only the trusted style-map data.
133
+
134
+ That last point is why this works where `pull_request_target` does not: StyleProof builds and serves the PR's head, so running it under `pull_request_target` would hand a write token (and your secrets) to untrusted code — the exact supply-chain risk StyleProof exists to help you catch. The `workflow_run` split keeps the privileged half away from PR code entirely.
135
+
136
+ **Where the PR identity comes from.** The report stage comments on the PR and sets the `StyleProof` status against a specific PR number and head commit, so those values have to be trustworthy. It takes them from the trusted `workflow_run` event — `head_sha`, then the event's `pull_requests`, with a commit→PR lookup against that **same trusted head SHA** for fork PRs (whose association the event doesn't carry directly) — and **never** from the downloaded artifact. The artifact is produced by the untrusted capture job, so treating anything in it as identity would let a malicious PR point the privileged comment and status at a victim PR or an arbitrary commit (a confused-deputy attack). The artifact therefore carries only the style-map captures, consumed purely as diff input.
137
+
138
+ Copy both `capture` and `report` files to `.github/workflows/` (the `report` one must be on your default branch, like `styleproof-approve.yml`), then require the `StyleProof` status as in step 4. The single-job `styleproof.yml` above remains fine for repos that never see fork or bot PRs.
139
+
125
140
  **Deterministic by default — no fixtures required.** A style diff only means something if both sides saw the same inputs; otherwise live-data drift (a backend blip, a `5m ago` timestamp, a status chip that flips) reads as a style change on a PR that touched no CSS. StyleProof handles this for you:
126
141
 
127
142
  - **Record / replay.** The base capture records each surface's data responses (anything matching `**/api/**`) to a HAR; the head capture replays them, so the head renders _its_ code against the _base's_ data — the app's own JS/CSS still load live. Backend down during a run? Both sides replay the same recording, so there's no phantom diff. Point the head capture at the base's recording with `STYLEPROOF_REPLAY_FROM=<base dir>` (see the CI step above); tune the data boundary with `STYLEPROOF_REPLAY_URL` / `replayUrl` if your API isn't under `/api`.
@@ -133,9 +148,29 @@ jobs:
133
148
 
134
149
  **Live pages just work.** Before each capture, StyleProof settles the page — it waits until the computed-style map stops changing, so async content (a fetch, an SSE/WebSocket stream backfilling a grid) is captured loaded, not mid-load. Anything still moving on its own after that is detected as a live region and excluded from the diff, so a stream or ticker never reads as a change — no manual `ignore` needed. Disable or tune with `captureStyleMap(page, { stabilize: false })` / `{ stabilize: { quietFor, timeout } }`.
135
150
 
151
+ ## Optional: content layer (advisory)
152
+
153
+ StyleProof is **computed-styles first**, and stays that way: a CSS-only refactor that also rewrites text is still certified identical, and live text (a clock, "2m ago") never reads as a change. But a pure-style diff is blind to copy, and copy isn't always cosmetic: **new or longer text can overflow or clip its box, silently breaking the layout.** A visual-confidence tool that can't see that isn't quite complete. So the content layer exists as an explicit **opt-in**, off by default, and **advisory** — it never feeds the certification or the gate.
154
+
155
+ Turn it on in two places:
156
+
157
+ ```ts
158
+ // styleproof.spec.ts — record each element's own text alongside its computed style
159
+ defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR, captureText: true });
160
+ ```
161
+
162
+ ```bash
163
+ # render the advisory content section (each change with a before/after crop)
164
+ styleproof-report before after --out report --include-content
165
+ ```
166
+
167
+ The report then carries a separate **📝 Content changes (advisory)** section: every element whose own text changed, with the before/after strings and a side-by-side crop, so a silent copy edit (and any overflow it causes) is visible in review. It does **not** affect `changed`, the `StyleProof` status, or the diff exit code, by design. With capture left at its default (`captureText` off), there's no text in the maps and the section is always empty, so existing setups are completely unaffected.
168
+
169
+ Notes: only an element's _own_ text is recorded (so a parent and child never double-report the same string); text churn in a live region is auto-excluded by the same settle pass that guards styles; and the certification CLI (`styleproof-diff`) is deliberately left content-blind.
170
+
136
171
  ## Reference
137
172
 
138
- **Action `BenSheridanEdwards/StyleProof@v1`** — key inputs:
173
+ **Action `BenSheridanEdwards/StyleProof@v2`** — key inputs:
139
174
 
140
175
  | Input | Default | Purpose |
141
176
  | ------------------ | ------------ | -------------------------------------------------------------------------- |
@@ -145,7 +180,23 @@ jobs:
145
180
  | `fail-on-diff` | `true` | Certify mode: fail on any diff. Ignored when `require-approval` is true. |
146
181
  | `status-context` | `StyleProof` | Commit-status name. Must match the approve workflow and branch protection. |
147
182
 
148
- Outputs: `changed` (`"true"` when anything changed), `report-url`. Other inputs (`report-branch`, `inline-images`, `github-token`) have sensible defaults — see [`action.yml`](https://github.com/BenSheridanEdwards/StyleProof/blob/main/action.yml).
183
+ Outputs: `changed` (`"true"` when anything changed), `report-url`. Other inputs (`report-branch`, `github-token`) have sensible defaults — see [`action.yml`](https://github.com/BenSheridanEdwards/StyleProof/blob/main/action.yml).
184
+
185
+ **Policy file `styleproof.config.json`** (optional, at the repo root) — gate policy that isn't workflow plumbing:
186
+
187
+ | Key | Default | Purpose |
188
+ | ---------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
189
+ | `blocking` | `false` | Review-gate mode only: on **unapproved** visual changes, also **fail the job** (red ✗), so the check blocks even without a branch-protection rule requiring the status. See below. |
190
+
191
+ ### Blocking without branch protection
192
+
193
+ A commit status only _blocks a merge_ where a branch-protection rule requires it — which needs GitHub Pro or a public repo. On a free private repo the `StyleProof` status is advisory. Set `"blocking": true` in `styleproof.config.json` to also fail the report job on unapproved changes, so the PR shows a red check regardless:
194
+
195
+ ```json
196
+ { "blocking": true }
197
+ ```
198
+
199
+ It's **asynchronous by design**: approval is a checkbox tick handled by a separate workflow, so to clear the red you tick **Approve all changes**, then **re-run the StyleProof job** — the re-run sees the sign-off on the commit status and passes. (A new push that changes styles re-opens it.)
149
200
 
150
201
  **Capture spec `defineStyleMapCapture({ surfaces, … })`** — determinism is on by default; you rarely set more than `surfaces` and `dir`:
151
202
 
@@ -176,7 +227,7 @@ Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<styl
176
227
 
177
228
  - `styleproof-init` — scaffold the capture spec (and a starter `playwright.config.ts` if none exists).
178
229
  - `styleproof-diff <beforeDir> <afterDir>` — the certify gate; exits `1` on any difference.
179
- - `styleproof-report <beforeDir> <afterDir> --out <dir>` — render the diff to a Markdown report with before/after crops.
230
+ - `styleproof-report <beforeDir> <afterDir> --out <dir>` — render the diff to a Markdown report with before/after crops. Add `--include-content` for the opt-in, advisory content section (see above).
180
231
 
181
232
  A programmatic API (`captureStyleMap`, `diffStyleMaps`, `generateStyleMapReport`, …) is also exported. For the capture internals, the approve-workflow trust model, and how to contribute, see [CONTRIBUTING](https://github.com/BenSheridanEdwards/StyleProof/blob/main/CONTRIBUTING.md) and the [`example/`](https://github.com/BenSheridanEdwards/StyleProof/tree/main/example) workflows.
182
233
 
@@ -28,6 +28,10 @@ options:
28
28
  --include-layout-noise keep size/position-derived longhands (height, width,
29
29
  transform-origin, top…) that a reflow changes up the
30
30
  whole ancestor chain (off by default)
31
+ --include-content render the opt-in content layer: an advisory section
32
+ of elements whose text changed, each with a
33
+ before/after crop. Needs captures taken with
34
+ captureText:true; never affects the check (off by default)
31
35
  -h, --help show this help
32
36
 
33
37
  exit: 0 no changes, 1 report generated, 2 usage error.
@@ -42,6 +46,7 @@ let foldDetailsAt;
42
46
  let minWidth;
43
47
  let minHeight;
44
48
  let includeLayoutNoise = false;
49
+ let includeContent = false;
45
50
  for (let i = 0; i < argv.length; i++) {
46
51
  const a = argv[i];
47
52
  if (a === '-h' || a === '--help') {
@@ -63,6 +68,8 @@ for (let i = 0; i < argv.length; i++) {
63
68
  else if (a.startsWith('--min-height=')) minHeight = Number(a.slice(13));
64
69
  else if (a === '--include-layout-noise') includeLayoutNoise = true;
65
70
  else if (a.startsWith('--include-layout-noise=')) includeLayoutNoise = a.slice(23) !== 'false';
71
+ else if (a === '--include-content') includeContent = true;
72
+ else if (a.startsWith('--include-content=')) includeContent = a.slice(18) !== 'false';
66
73
  else if (a.startsWith('--')) {
67
74
  console.error(`unknown flag: ${a}`);
68
75
  process.exit(2);
@@ -102,6 +109,7 @@ try {
102
109
  minWidth,
103
110
  minHeight,
104
111
  includeLayoutNoise,
112
+ includeContent,
105
113
  });
106
114
  } catch (e) {
107
115
  console.error(e.message);
@@ -117,4 +125,7 @@ console.log(
117
125
  : `✗ ${result.changedSurfaces} changed surface(s), ${result.totalFindings} finding(s)${newNote}`,
118
126
  );
119
127
  console.log(`report: ${result.reportMdPath}`);
128
+ if (includeContent && result.contentChanges > 0) {
129
+ console.log(`📝 ${result.contentChanges} advisory content change(s) — does not affect the exit code`);
130
+ }
120
131
  process.exit(result.changedSurfaces === 0 && result.newSurfaces === 0 ? 0 : 1);
package/dist/capture.d.ts CHANGED
@@ -33,6 +33,15 @@ export type ElementEntry = {
33
33
  rect?: Rect;
34
34
  style: Props;
35
35
  pseudo?: Record<string, Props>;
36
+ /**
37
+ * The element's OWN rendered text (direct text-node children only, whitespace
38
+ * collapsed) — present only when capture ran with `captureText: true` (the
39
+ * opt-in content layer, off by default). Own-text, not subtree text, so each
40
+ * change is attributed to the single element that owns it. Diffed by
41
+ * {@link diffContentMaps}, never by the certification diff — content is
42
+ * advisory, not a computed-style outcome. See README "Optional: content layer".
43
+ */
44
+ text?: string;
36
45
  };
37
46
  export type StyleMap = {
38
47
  defaults: Record<string, Props>;
@@ -107,6 +116,19 @@ export type CaptureOptions = {
107
116
  * minutes; raise it deliberately if you need full coverage of a huge page.
108
117
  */
109
118
  maxInteractive?: number;
119
+ /**
120
+ * Opt-in content layer (default OFF). When true, each element's own rendered
121
+ * text is recorded on `ElementEntry.text` so {@link diffContentMaps} can
122
+ * surface copy changes a pure-style diff is blind to — most importantly the
123
+ * silent ones where new or longer text overflows or clips its box. This is
124
+ * ADVISORY: it never feeds the certification diff or its blocking counts, and
125
+ * StyleProof stays computed-styles-first. Off by default keeps the core
126
+ * promise (a CSS-only refactor that rewrites text is still certified
127
+ * identical) intact for anyone who doesn't opt in. Text churn (clocks,
128
+ * "2m ago") is auto-excluded by the same live-region settle pass that already
129
+ * guards styles, since text now participates in change detection.
130
+ */
131
+ captureText?: boolean;
110
132
  };
111
133
  /** True if `path` is one of `roots` or a structural descendant of one. Shared by
112
134
  * the capture (excluding live regions) and the diff (skipping them). */
package/dist/capture.js CHANGED
@@ -31,7 +31,10 @@ export function isUnder(path, roots) {
31
31
  return roots.some((r) => path === r || path.startsWith(r + ' > '));
32
32
  }
33
33
  // Serialized into the browser by page.evaluate; cannot call module helpers.
34
- function capturePage({ ignore, motionOnly }) {
34
+ // Pre-existing, grandfathered in the health baseline; the content layer adds
35
+ // one small captureText block, not new structure.
36
+ // fallow-ignore-next-line complexity
37
+ function capturePage({ ignore, motionOnly, captureText }) {
35
38
  const MOTION = /^(transition|animation)/;
36
39
  const PSEUDOS = ['::before', '::after', '::marker', '::placeholder'];
37
40
  const skipSel = ignore.length ? ignore.map((s) => `${s}, ${s} *`).join(', ') : '';
@@ -131,6 +134,19 @@ function capturePage({ ignore, motionOnly }) {
131
134
  Math.round(r.width),
132
135
  Math.round(r.height),
133
136
  ];
137
+ if (captureText) {
138
+ // Own text only (direct text-node children, whitespace collapsed), so a
139
+ // parent and child never both report the same string — each change is
140
+ // attributed to the element that actually owns the text.
141
+ let t = '';
142
+ for (const node of Array.prototype.slice.call(el.childNodes)) {
143
+ if (node.nodeType === 3 /* TEXT_NODE */)
144
+ t += node.textContent ?? '';
145
+ }
146
+ t = t.replace(/\s+/g, ' ').trim();
147
+ if (t)
148
+ entry.text = t;
149
+ }
134
150
  }
135
151
  for (const ps of PSEUDOS) {
136
152
  if (ps === '::marker' && getComputedStyle(el).display !== 'list-item')
@@ -209,8 +225,14 @@ const STATE_SETS = {
209
225
  focus: ['focus', 'focus-visible'],
210
226
  active: ['active'],
211
227
  };
212
- /** Structural paths for every selector match, index-aligned with CDP's DOM.querySelectorAll. */
213
- function pathsForSelector({ selector, skipSel }) {
228
+ const STATE_ID_ATTR = 'data-styleproof-state-id';
229
+ /**
230
+ * Mark interactive elements once, then address that same element from both CDP
231
+ * and page.evaluate. Positional CDP nodeId ↔ querySelectorAll index alignment is
232
+ * flaky on hydrated apps: two non-atomic DOM snapshots can have the same count
233
+ * but different ordering, which produces phantom forced-state diffs.
234
+ */
235
+ function markInteractiveElements({ selector, skipSel, attr }) {
214
236
  const pathOf = (el) => {
215
237
  if (el === document.documentElement)
216
238
  return 'html';
@@ -227,7 +249,18 @@ function pathsForSelector({ selector, skipSel }) {
227
249
  }
228
250
  return 'body > ' + parts.join(' > ');
229
251
  };
230
- return [...document.querySelectorAll(selector)].map((el) => (skipSel && el.matches(skipSel) ? null : pathOf(el)));
252
+ let i = 0;
253
+ return [...document.querySelectorAll(selector)].flatMap((el) => {
254
+ if (skipSel && el.matches(skipSel))
255
+ return [];
256
+ const id = `sp-${i++}`;
257
+ el.setAttribute(attr, id);
258
+ return [{ id, path: pathOf(el) }];
259
+ });
260
+ }
261
+ function clearInteractiveMarks(attr) {
262
+ for (const el of document.querySelectorAll(`[${attr}]`))
263
+ el.removeAttribute(attr);
231
264
  }
232
265
  // Forced pseudo-class states on interactive elements, via CDP so no real
233
266
  // mouse or focus is involved and parent-state descendant rules still apply.
@@ -236,49 +269,39 @@ async function captureForcedStates(page, ignore, maxInteractive, skipPaths = [])
236
269
  await client.send('DOM.enable');
237
270
  await client.send('CSS.enable');
238
271
  const { root } = await client.send('DOM.getDocument');
239
- const { nodeIds } = await client.send('DOM.querySelectorAll', { nodeId: root.nodeId, selector: INTERACTIVE });
240
272
  const skipSel = ignore.length ? ignore.map((s) => `${s}, ${s} *`).join(', ') : '';
241
- // Null out forced-state work for live (volatile) paths too, so they're not
242
- // probed and can't reintroduce the churn the settle pass just excluded.
243
- const paths = (await page.evaluate(pathsForSelector, { selector: INTERACTIVE, skipSel })).map((p) => p && skipPaths.length && isUnder(p, skipPaths) ? null : p);
244
- // The CDP DOM snapshot and the live querySelectorAll are two separate,
245
- // non-atomic reads. They can legitimately disagree — display:contents,
246
- // nodes detached or injected between the two calls (next-route-announcer,
247
- // Playwright internals, late hydration adding [tabindex]). A mismatch is NOT
248
- // a fatal error: positional nodeId↔path alignment is only valid when the
249
- // counts agree, so on skew we warn and skip the forced-state layer for this
250
- // surface rather than aborting the whole capture (base + pseudo layers,
251
- // the load-bearing certification, still succeed).
252
- if (paths.length !== nodeIds.length) {
253
- // eslint-disable-next-line no-console
254
- console.warn(`styleproof: interactive-element count skew (CDP saw ${nodeIds.length}, page saw ${paths.length}); ` +
255
- 'skipping forced :hover/:focus/:active capture for this surface. This is usually a benign, ' +
256
- 'transient DOM difference (display:contents, injected/detached nodes). Re-run if it persists.');
257
- await client.detach();
258
- return { states: {}, skipped: true };
259
- }
260
- const limit = Math.min(nodeIds.length, maxInteractive);
261
- if (nodeIds.length > maxInteractive) {
273
+ const marked = (await page.evaluate(markInteractiveElements, { selector: INTERACTIVE, skipSel, attr: STATE_ID_ATTR })).filter((m) => !(skipPaths.length && isUnder(m.path, skipPaths)));
274
+ const limit = Math.min(marked.length, maxInteractive);
275
+ if (marked.length > maxInteractive) {
262
276
  // eslint-disable-next-line no-console
263
- console.warn(`styleproof: ${nodeIds.length} interactive elements exceeds maxInteractive=${maxInteractive}; ` +
277
+ console.warn(`styleproof: ${marked.length} interactive elements exceeds maxInteractive=${maxInteractive}; ` +
264
278
  `forced-state capture truncated to the first ${maxInteractive}. Raise maxInteractive to cover them all.`);
265
279
  }
266
280
  const states = {};
267
- for (let i = 0; i < limit; i++) {
268
- const p = paths[i];
269
- if (!p)
270
- continue;
271
- const baseSnap = await page.evaluate(snapSubtree, { selector: INTERACTIVE, index: i });
272
- for (const [stateName, forcedPseudoClasses] of Object.entries(STATE_SETS)) {
273
- await client.send('CSS.forcePseudoState', { nodeId: nodeIds[i], forcedPseudoClasses });
274
- const forcedSnap = await page.evaluate(snapSubtree, { selector: INTERACTIVE, index: i });
275
- await client.send('CSS.forcePseudoState', { nodeId: nodeIds[i], forcedPseudoClasses: [] });
276
- const delta = deltaBetween(baseSnap, forcedSnap);
277
- if (Object.keys(delta).length)
278
- (states[p] ??= {})[stateName] = delta;
281
+ try {
282
+ for (const { id, path: p } of marked.slice(0, limit)) {
283
+ const selector = `[${STATE_ID_ATTR}="${id}"]`;
284
+ const { nodeId } = await client.send('DOM.querySelector', { nodeId: root.nodeId, selector });
285
+ if (!nodeId) {
286
+ // eslint-disable-next-line no-console
287
+ console.warn(`styleproof: interactive element ${id} detached before forced-state capture; skipping it.`);
288
+ continue;
289
+ }
290
+ const baseSnap = await page.evaluate(snapSubtree, { selector, index: 0 });
291
+ for (const [stateName, forcedPseudoClasses] of Object.entries(STATE_SETS)) {
292
+ await client.send('CSS.forcePseudoState', { nodeId, forcedPseudoClasses });
293
+ const forcedSnap = await page.evaluate(snapSubtree, { selector, index: 0 });
294
+ await client.send('CSS.forcePseudoState', { nodeId, forcedPseudoClasses: [] });
295
+ const delta = deltaBetween(baseSnap, forcedSnap);
296
+ if (Object.keys(delta).length)
297
+ (states[p] ??= {})[stateName] = delta;
298
+ }
279
299
  }
280
300
  }
281
- await client.detach();
301
+ finally {
302
+ await page.evaluate(clearInteractiveMarks, STATE_ID_ATTR).catch(() => undefined);
303
+ await client.detach();
304
+ }
282
305
  return { states, skipped: false };
283
306
  }
284
307
  /** Element paths that differ between two captures (added, removed, or restyled). */
@@ -299,8 +322,11 @@ function changedElementPaths(a, b) {
299
322
  * Reuses `capturePage` (motion already frozen by the caller), so only
300
323
  * content/layout churn — not an animation frame — keeps it from settling.
301
324
  */
302
- async function stabilizePage(page, ignore, interval, quietFor, timeout) {
303
- const snap = async () => (await page.evaluate(capturePage, { ignore, motionOnly: false })).elements;
325
+ async function stabilizePage(page, ignore, interval, quietFor, timeout, captureText) {
326
+ // captureText is threaded in so text churn participates in settle detection:
327
+ // a clock/ticker whose text changes (with no style change) keeps the map from
328
+ // settling and is excluded as a live region, exactly as style churn already is.
329
+ const snap = async () => (await page.evaluate(capturePage, { ignore, motionOnly: false, captureText })).elements;
304
330
  const start = Date.now();
305
331
  let prev = await snap();
306
332
  let lastChangeAt = start;
@@ -349,11 +375,11 @@ function capturePageTokens() {
349
375
  return tokens;
350
376
  }
351
377
  /** Settle the page and return the paths of live regions to exclude. */
352
- async function detectVolatile(page, ignore, stabilize) {
378
+ async function detectVolatile(page, ignore, stabilize, captureText) {
353
379
  if (stabilize === false)
354
380
  return [];
355
381
  const opt = typeof stabilize === 'object' ? stabilize : {};
356
- const volatile = await stabilizePage(page, ignore, opt.interval || 150, opt.quietFor || 600, opt.timeout || 5000);
382
+ const volatile = await stabilizePage(page, ignore, opt.interval || 150, opt.quietFor || 600, opt.timeout || 5000, captureText);
357
383
  if (volatile.length) {
358
384
  // eslint-disable-next-line no-console
359
385
  console.warn(`styleproof: ${volatile.length} live region(s) kept changing on their own and were excluded from ` +
@@ -406,15 +432,27 @@ export async function captureStyleMap(page, options = {}) {
406
432
  const captureStates = options.captureStates ?? true;
407
433
  const maxInteractive = options.maxInteractive ?? 800;
408
434
  const stabilize = options.stabilize ?? true;
435
+ const captureText = options.captureText ?? false;
436
+ // Neutralise real focus the same way FREEZE_CSS neutralises motion: blur
437
+ // whatever element holds focus so every read below is the no-interaction
438
+ // resting state. Real :focus is nondeterministic across runs (autofocus, late
439
+ // hydration, a stray prior action) and contaminates BOTH layers — it bakes a
440
+ // focus ring into the resting map, AND it cancels the forced-state :focus delta
441
+ // (forcing :focus on an already-focused element changes nothing, so the ring
442
+ // shows up as a delta on some runs but not others — a self-check "non-
443
+ // deterministic" failure). Interaction states are certified deterministically
444
+ // via CDP forcePseudoState below, never via whatever happened to be focused.
445
+ await page.evaluate(() => document.activeElement?.blur?.());
409
446
  // Motion longhands first (FREEZE_CSS would null them), then everything else.
410
- const motion = await page.evaluate(capturePage, { ignore, motionOnly: true });
447
+ // Motion never carries text it's a settled end state of declared motion.
448
+ const motion = await page.evaluate(capturePage, { ignore, motionOnly: true, captureText: false });
411
449
  await page.addStyleTag({ content: FREEZE_CSS });
412
450
  // Settle: wait for async content to finish painting so base and head capture
413
451
  // the same loaded state, and collect any region still changing on its own
414
452
  // (a live stream/ticker) to exclude — animations are frozen above, so only
415
453
  // real content/layout churn lands here.
416
- const volatile = await detectVolatile(page, ignore, stabilize);
417
- const base = await page.evaluate(capturePage, { ignore, motionOnly: false });
454
+ const volatile = await detectVolatile(page, ignore, stabilize, captureText);
455
+ const base = await page.evaluate(capturePage, { ignore, motionOnly: false, captureText });
418
456
  dropVolatile(base.elements, volatile);
419
457
  warnUntraversed(base.shadowHosts, base.sameOriginFrames);
420
458
  mergeMotion(base.elements, motion.elements);
package/dist/diff.d.ts CHANGED
@@ -40,6 +40,15 @@ export type DiffCounts = {
40
40
  style: number;
41
41
  state: number;
42
42
  };
43
+ /** A change to an element's own rendered text (opt-in content layer). Kept out
44
+ * of `Finding`/`DiffCounts` on purpose: content is advisory, never part of the
45
+ * computed-style certification or its blocking counts. */
46
+ export type ContentChange = {
47
+ path: string;
48
+ cls: string;
49
+ before: string;
50
+ after: string;
51
+ };
43
52
  /** Diff two style maps of the same surface. */
44
53
  export declare function diffStyleMaps(a: StyleMap, b: StyleMap): Finding[];
45
54
  /** Diff every same-named capture between two directories. `volatile` is the
@@ -49,5 +58,17 @@ export declare function diffStyleMapDirs(dirA: string, dirB: string): {
49
58
  counts: DiffCounts;
50
59
  volatile: number;
51
60
  };
61
+ export declare function diffContentMaps(a: StyleMap, b: StyleMap): ContentChange[];
62
+ /** Per-surface content diff across two capture dirs (opt-in layer). Mirrors
63
+ * {@link diffStyleMapDirs} but content-only and non-gating; surfaces present on
64
+ * just one side have no baseline and are skipped (the style diff reports those
65
+ * as new surfaces). */
66
+ export declare function diffContentDirs(dirA: string, dirB: string): {
67
+ surfaces: {
68
+ surface: string;
69
+ changes: ContentChange[];
70
+ }[];
71
+ count: number;
72
+ };
52
73
  /** Human label: structural path plus a truncated class hint. */
53
74
  export declare function findingLabel(path: string, cls: string): string;
package/dist/diff.js CHANGED
@@ -13,14 +13,22 @@ function diffProps(propsA, propsB, fallbackA, fallbackB, unsetA, unsetB) {
13
13
  }
14
14
  return changed;
15
15
  }
16
+ /** Union of both captures' live-region paths — skipped by every diff layer so a
17
+ * region volatile on either side never reads as a change. */
18
+ function volatilePaths(a, b) {
19
+ return [...new Set([...(a.volatile ?? []), ...(b.volatile ?? [])])];
20
+ }
16
21
  /** Diff two style maps of the same surface. */
22
+ // Pre-existing, grandfathered in the health baseline; the content layer only
23
+ // extracted volatilePaths out of this, it is not newly complex.
24
+ // fallow-ignore-next-line complexity
17
25
  export function diffStyleMaps(a, b) {
18
26
  const findings = [];
19
27
  // Live regions either capture flagged as nondeterministic (a stream, ticker,
20
28
  // late-loading content): never diff them — their values move with no code
21
29
  // change. Union both sides so a region volatile on only one capture is still
22
30
  // skipped, in every layer (element, pseudo, forced-state).
23
- const volatile = [...new Set([...(a.volatile ?? []), ...(b.volatile ?? [])])];
31
+ const volatile = volatilePaths(a, b);
24
32
  for (const p of [...new Set([...Object.keys(a.elements), ...Object.keys(b.elements)])].sort()) {
25
33
  if (volatile.length && isUnder(p, volatile))
26
34
  continue;
@@ -133,6 +141,58 @@ export function diffStyleMapDirs(dirA, dirB) {
133
141
  }
134
142
  return { surfaces, counts, volatile };
135
143
  }
144
+ /**
145
+ * Diff the OPT-IN content layer: elements whose own rendered text changed.
146
+ * Separate from {@link diffStyleMaps} by design — content never enters the
147
+ * certification or its counts. Yields nothing unless capture ran with
148
+ * `captureText: true` (no `text` on either side → nothing to compare), so it's a
149
+ * no-op for anyone who hasn't opted in. Add/remove of an element is left to the
150
+ * style diff (it surfaces there as a DOM change); this reports text that changed
151
+ * on an element present in BOTH captures. Volatile (live) regions are skipped,
152
+ * same as the style diff.
153
+ */
154
+ /** Normalise captured text (absent → empty) so undefined and '' compare equal. */
155
+ const ownText = (t) => t ?? '';
156
+ export function diffContentMaps(a, b) {
157
+ const volatile = volatilePaths(a, b);
158
+ // Only paths present in BOTH captures: an add/remove is a DOM change the style
159
+ // diff already owns, so iterating the intersection both skips them and keeps
160
+ // this loop branch-light. Equal text (incl. both absent → '' === '') drops out.
161
+ const common = Object.keys(a.elements)
162
+ .filter((p) => p in b.elements)
163
+ .sort();
164
+ const out = [];
165
+ for (const p of common) {
166
+ if (isUnder(p, volatile))
167
+ continue;
168
+ const before = ownText(a.elements[p].text);
169
+ const after = ownText(b.elements[p].text);
170
+ if (before !== after)
171
+ out.push({ path: p, cls: a.elements[p].cls, before, after });
172
+ }
173
+ return out;
174
+ }
175
+ /** Per-surface content diff across two capture dirs (opt-in layer). Mirrors
176
+ * {@link diffStyleMapDirs} but content-only and non-gating; surfaces present on
177
+ * just one side have no baseline and are skipped (the style diff reports those
178
+ * as new surfaces). */
179
+ export function diffContentDirs(dirA, dirB) {
180
+ const indexA = indexDir(dirA);
181
+ const indexB = indexDir(dirB);
182
+ const both = Object.keys(indexA)
183
+ .filter((s) => s in indexB)
184
+ .sort();
185
+ const surfaces = [];
186
+ let count = 0;
187
+ for (const surface of both) {
188
+ const changes = diffContentMaps(loadStyleMap(indexA[surface]), loadStyleMap(indexB[surface]));
189
+ if (changes.length) {
190
+ surfaces.push({ surface, changes });
191
+ count += changes.length;
192
+ }
193
+ }
194
+ return { surfaces, count };
195
+ }
136
196
  /** Human label: structural path plus a truncated class hint. */
137
197
  export function findingLabel(path, cls) {
138
198
  if (!cls)
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { captureStyleMap, saveStyleMap, loadStyleMap } from './capture.js';
2
2
  export type { StyleMap, CaptureOptions, ElementEntry, Rect } from './capture.js';
3
3
  export { defineStyleMapCapture } from './runner.js';
4
4
  export type { Surface, DefineOptions } from './runner.js';
5
- export { diffStyleMaps, diffStyleMapDirs, findingLabel } from './diff.js';
6
- export type { Finding, PropChange, SurfaceDiff, DiffCounts } from './diff.js';
5
+ export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
6
+ export type { Finding, PropChange, SurfaceDiff, DiffCounts, ContentChange } from './diff.js';
7
7
  export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
8
8
  export type { ReportOptions, ReportResult } from './report.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { captureStyleMap, saveStyleMap, loadStyleMap } from './capture.js';
2
2
  export { defineStyleMapCapture } from './runner.js';
3
- export { diffStyleMaps, diffStyleMapDirs, findingLabel } from './diff.js';
3
+ export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
4
4
  export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
package/dist/report.d.ts CHANGED
@@ -40,6 +40,16 @@ export type ReportOptions = {
40
40
  * (`styleproof-diff`) always keeps them.
41
41
  */
42
42
  includeLayoutNoise?: boolean;
43
+ /**
44
+ * Render the opt-in content layer (default OFF): a separate, ADVISORY section
45
+ * listing elements whose own text changed, each with a before/after crop.
46
+ * Requires captures taken with `captureText: true`; otherwise there's no text
47
+ * to diff and the section is empty. Never affects `changedSurfaces`,
48
+ * `totalFindings`, or the exit code — StyleProof stays computed-styles-first;
49
+ * this only surfaces copy changes (and any silent overflow/clipping they
50
+ * cause) for the reviewer's eye.
51
+ */
52
+ includeContent?: boolean;
43
53
  };
44
54
  export type ReportResult = {
45
55
  /** Surfaces carrying a reviewable change (excludes new, one-sided surfaces). */
@@ -47,6 +57,8 @@ export type ReportResult = {
47
57
  /** New surfaces present on only one side, with no baseline to compare. */
48
58
  newSurfaces: number;
49
59
  totalFindings: number;
60
+ /** Advisory content-layer changes rendered (0 unless includeContent + captured text). Never gates. */
61
+ contentChanges: number;
50
62
  reportMdPath: string;
51
63
  reportJsonPath: string;
52
64
  };
package/dist/report.js CHANGED
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { PNG } from 'pngjs';
4
4
  import { loadStyleMap } from './capture.js';
5
- import { diffStyleMapDirs } from './diff.js';
5
+ import { diffStyleMapDirs, diffContentDirs, } from './diff.js';
6
6
  import { describeChange, tokenIndex, toHex, trackCount } from './describe.js';
7
7
  // Re-export the plain-English summariser so consumers (and tests) reach it
8
8
  // through the package's report module rather than a deep path.
@@ -620,6 +620,92 @@ function buildElementChanges(findings) {
620
620
  }
621
621
  return els;
622
622
  }
623
+ /** One-line, backtick-safe display text, clipped so the report stays scannable. */
624
+ function clipText(s, max = 200) {
625
+ const t = s.replace(/\s+/g, ' ').replace(/`/g, "'").trim();
626
+ return t.length > max ? t.slice(0, max - 1) + '…' : t;
627
+ }
628
+ /** An element's padded box on one side, or null when it has no visible rect. */
629
+ function paddedRect(entry, padBy) {
630
+ if (!entry?.rect)
631
+ return null;
632
+ const b = pad(rectToBox(entry.rect), padBy);
633
+ return visible(b) ? b : null;
634
+ }
635
+ /** Crop box for a content change: the union of where the element sits on each
636
+ * side (so the pair lines up), or null if it's not visible anywhere. */
637
+ function contentBox(mapA, mapB, p, padBy) {
638
+ const ba = paddedRect(mapA.elements[p], padBy);
639
+ const bb = paddedRect(mapB.elements[p], padBy);
640
+ if (ba && bb)
641
+ return union(ba, bb);
642
+ return bb ?? ba;
643
+ }
644
+ /** before|after crop lines for one content change, or [] when there's no box or
645
+ * no screenshots. Writes the composite PNG as a side effect. */
646
+ function contentCropLines(ctx, surface, c, mapA, mapB, pngA, pngB, seq) {
647
+ const box = contentBox(mapA, mapB, c.path, ctx.padBy);
648
+ if (!box || !pngA || !pngB)
649
+ return [];
650
+ const w = Math.max(ctx.minWidth, box.w);
651
+ const h = Math.min(ctx.maxHeight, Math.max(ctx.minHeight, box.h));
652
+ const composite = compositePair(cropPng(pngA, box, w, h).png, cropPng(pngB, box, w, h).png);
653
+ const stem = `crops/${surface.replace(/[^a-z0-9-]/gi, '-')}-content-${seq}`;
654
+ writePng(path.join(ctx.outDir, `${stem}-composite.png`), composite);
655
+ return [
656
+ '',
657
+ `![before ◀ │ ▶ after](${ctx.img(`${stem}-composite.png`)})`,
658
+ '',
659
+ `<sub>◀ before · after ▶ — ${surface}</sub>`,
660
+ ];
661
+ }
662
+ /** One surface's content block: heading, then per change the before/after text
663
+ * and its crop. Returns the markdown plus the advanced crop counter. */
664
+ function renderContentSurface(ctx, surface, changes, seq) {
665
+ const mapA = loadStyleMap(findCapture(ctx.beforeDir, surface));
666
+ const mapB = loadStyleMap(findCapture(ctx.afterDir, surface));
667
+ const pngA = readPng(path.join(ctx.beforeDir, `${surface}.png`));
668
+ const pngB = readPng(path.join(ctx.afterDir, `${surface}.png`));
669
+ const md = ['', `### \`${surface}\` · ${changes.length} content change(s)`];
670
+ for (const c of changes) {
671
+ seq++;
672
+ md.push('', `**\`${prettyLabel(c.path, c.cls)}\`**`, '', `- before: \`${clipText(c.before) || '(empty)'}\``, `- after: \`${clipText(c.after) || '(empty)'}\``, ...contentCropLines(ctx, surface, c, mapA, mapB, pngA, pngB, seq));
673
+ }
674
+ return { md, seq };
675
+ }
676
+ /**
677
+ * The opt-in content layer, rendered as its own ADVISORY section. Reuses the
678
+ * style report's crop/composite machinery so every copy change gets a
679
+ * before/after screenshot — the whole point being to make a silent text change,
680
+ * and any overflow or clipping it triggers, visible in review. Returns the
681
+ * markdown plus a count; the caller keeps both out of the gate (counts/exit live
682
+ * on the computed-style path).
683
+ */
684
+ function renderContentSection(ctx) {
685
+ const { surfaces, count } = diffContentDirs(ctx.beforeDir, ctx.afterDir);
686
+ if (!count)
687
+ return { md: [], count: 0 };
688
+ const md = [
689
+ '',
690
+ '---',
691
+ '',
692
+ '## 📝 Content changes (advisory)',
693
+ '',
694
+ `_${count} element(s) changed their own text. **Advisory only** — content is not part of the computed-style ` +
695
+ `certification and does not affect the check. Surfaced so a copy change the style diff can't see (and any ` +
696
+ `overflow or clipping it causes) is visible in review._`,
697
+ ];
698
+ let seq = 0;
699
+ for (const { surface, changes } of surfaces) {
700
+ const out = renderContentSurface(ctx, surface, changes, seq);
701
+ md.push(...out.md);
702
+ seq = out.seq;
703
+ }
704
+ return { md, count };
705
+ }
706
+ // Pre-existing, grandfathered in the health baseline; the content layer is
707
+ // rendered by extracted helpers, this only gained a call + headline branch.
708
+ // fallow-ignore-next-line complexity
623
709
  export function generateStyleMapReport(opts) {
624
710
  const { beforeDir, afterDir, outDir, imageBaseUrl = '',
625
711
  // Tighter than before (was 24) so the change fills the frame — the annotation
@@ -629,6 +715,7 @@ export function generateStyleMapReport(opts) {
629
715
  // own focused frame rather than one wide merged one.
630
716
  maxCrops = 8, foldDetailsAt = 0, } = opts;
631
717
  const includeNoise = opts.includeLayoutNoise ?? false;
718
+ const includeContent = opts.includeContent ?? false;
632
719
  const { surfaces, volatile: volatileCount } = diffStyleMapDirs(beforeDir, afterDir);
633
720
  fs.mkdirSync(path.join(outDir, 'crops'), { recursive: true });
634
721
  // Focus each surface on styling intent: drop reflow-casualty props, suppress
@@ -680,9 +767,16 @@ export function generateStyleMapReport(opts) {
680
767
  const md = [];
681
768
  const json = [];
682
769
  const img = (rel) => (imageBaseUrl ? `${imageBaseUrl.replace(/\/$/, '')}/${rel}` : rel);
770
+ // Opt-in, advisory: computed here so its count can colour the headline, but its
771
+ // markdown is appended at the very end and it NEVER feeds the gate below.
772
+ const contentSection = includeContent
773
+ ? renderContentSection({ beforeDir, afterDir, outDir, img, padBy, minWidth, minHeight, maxHeight })
774
+ : { md: [], count: 0 };
683
775
  md.push('## 🗺️ StyleProof report', '');
684
776
  if (changeGroups.length === 0 && missing.length === 0) {
685
- md.push('✓ All surfaces identical: every computed style, pseudo-element, and hover/focus/active state matches.');
777
+ md.push(contentSection.count > 0
778
+ ? '✓ Computed styles identical: every longhand, pseudo-element, and hover/focus/active state matches. See the advisory content changes below.'
779
+ : '✓ All surfaces identical: every computed style, pseudo-element, and hover/focus/active state matches.');
686
780
  }
687
781
  else {
688
782
  if (changeGroups.length > 0) {
@@ -698,6 +792,9 @@ export function generateStyleMapReport(opts) {
698
792
  if (volatileCount > 0) {
699
793
  md.push('', `_${volatileCount} live region(s) auto-excluded as nondeterministic (a stream, ticker, or late-loading content) — they don't affect the check._`);
700
794
  }
795
+ if (contentSection.count > 0 && (changeGroups.length > 0 || missing.length > 0)) {
796
+ md.push('', `📝 _${contentSection.count} advisory content change(s) below — they don't affect the check._`);
797
+ }
701
798
  let totalFindings = 0;
702
799
  let cropSeq = 0;
703
800
  for (const cg of changeGroups) {
@@ -808,6 +905,7 @@ export function generateStyleMapReport(opts) {
808
905
  md.push('', `_No baseline to compare against — this surface is new, so it doesn't block the check. It becomes part of the baseline once this merges._`);
809
906
  json.push(surfaceJson);
810
907
  }
908
+ md.push(...contentSection.md);
811
909
  const reportMdPath = path.join(outDir, 'report.md');
812
910
  const reportJsonPath = path.join(outDir, 'report.json');
813
911
  fs.writeFileSync(reportMdPath, md.join('\n') + '\n');
@@ -816,6 +914,7 @@ export function generateStyleMapReport(opts) {
816
914
  changedSurfaces: prepared.length - missing.length,
817
915
  newSurfaces: missing.length,
818
916
  totalFindings,
917
+ contentChanges: contentSection.count,
819
918
  reportMdPath,
820
919
  reportJsonPath,
821
920
  };
package/dist/runner.d.ts CHANGED
@@ -64,7 +64,29 @@ export type DefineOptions = {
64
64
  * as a phantom change on an unrelated diff. Default from STYLEPROOF_SELFCHECK=1.
65
65
  */
66
66
  selfCheck?: boolean;
67
+ /**
68
+ * Opt-in content layer (default OFF). Record each element's own rendered text
69
+ * so the report's optional content section can surface copy changes (run
70
+ * `styleproof-report --include-content`). Advisory only — never gates. See
71
+ * `CaptureOptions.captureText` and the README's "Optional: content layer".
72
+ */
73
+ captureText?: boolean;
67
74
  };
75
+ /**
76
+ * Let SSE (EventSource) requests bypass HAR record/replay and reach the live
77
+ * server. A long-lived stream can't round-trip through a HAR entry: recording
78
+ * captures at most a truncated body, and on replay the connection aborts, so the
79
+ * app drops to its no-stream fallback — a DIFFERENT but STABLE state that
80
+ * settle/volatile can't catch (it isn't moving, so it reads as a real change).
81
+ * Passing the stream through on BOTH record and replay keeps both sides in the
82
+ * same streamed state; the data it pushes must be deterministic at capture time
83
+ * (fixtures/frozen clock), same as any live region. Detected by the
84
+ * `Accept: text/event-stream` header EventSource always sends.
85
+ *
86
+ * Registered AFTER routeFromHAR so it matches first (Playwright runs the most
87
+ * recently added route first); non-stream requests `fallback()` to the HAR.
88
+ */
89
+ export declare function passLiveStreams(page: Page, url: string): Promise<void>;
68
90
  /**
69
91
  * Generate one Playwright test per surface × width that captures the style
70
92
  * map to `<baseDir>/<dir>/<key>@<width>.json.gz`. Captures are made
@@ -78,4 +100,4 @@ export type DefineOptions = {
78
100
  * defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
79
101
  * ```
80
102
  */
81
- export declare function defineStyleMapCapture({ surfaces, dir, baseDir, screenshots, replayFrom, replayUrl, freezeClock, clockTime, selfCheck, }: DefineOptions): void;
103
+ export declare function defineStyleMapCapture({ surfaces, dir, baseDir, screenshots, replayFrom, replayUrl, freezeClock, clockTime, selfCheck, captureText, }: DefineOptions): void;
package/dist/runner.js CHANGED
@@ -10,18 +10,42 @@ function driftDesc(f) {
10
10
  const p = f.props[0];
11
11
  return p ? `${f.path} ${p.prop}: ${p.before} → ${p.after}` : f.path;
12
12
  }
13
+ /**
14
+ * Let SSE (EventSource) requests bypass HAR record/replay and reach the live
15
+ * server. A long-lived stream can't round-trip through a HAR entry: recording
16
+ * captures at most a truncated body, and on replay the connection aborts, so the
17
+ * app drops to its no-stream fallback — a DIFFERENT but STABLE state that
18
+ * settle/volatile can't catch (it isn't moving, so it reads as a real change).
19
+ * Passing the stream through on BOTH record and replay keeps both sides in the
20
+ * same streamed state; the data it pushes must be deterministic at capture time
21
+ * (fixtures/frozen clock), same as any live region. Detected by the
22
+ * `Accept: text/event-stream` header EventSource always sends.
23
+ *
24
+ * Registered AFTER routeFromHAR so it matches first (Playwright runs the most
25
+ * recently added route first); non-stream requests `fallback()` to the HAR.
26
+ */
27
+ export async function passLiveStreams(page, url) {
28
+ await page.route(url, async (route) => {
29
+ if ((route.request().headers()['accept'] ?? '').includes('text/event-stream'))
30
+ await route.continue();
31
+ else
32
+ await route.fallback();
33
+ });
34
+ }
13
35
  /**
14
36
  * Pin the surface's inputs: replay the baseline's recorded data (or record ours),
15
37
  * scoped to the data URLs so the app's own JS/CSS still load live, then freeze the
16
38
  * clock so time-derived styling is stable.
17
39
  */
18
40
  async function pinInputs(page, harName, s) {
41
+ let intercepting = false;
19
42
  if (s.replayFrom) {
20
43
  const har = path.join(s.replayFrom, harName);
21
44
  if (fs.existsSync(har)) {
22
45
  // notFound:'abort' — a request the baseline never recorded fails
23
46
  // deterministically rather than silently hitting the live backend.
24
47
  await page.routeFromHAR(har, { url: s.replayUrl, update: false, notFound: 'abort' });
48
+ intercepting = true;
25
49
  }
26
50
  else {
27
51
  // eslint-disable-next-line no-console
@@ -34,14 +58,20 @@ async function pinInputs(page, harName, s) {
34
58
  update: true,
35
59
  updateContent: 'embed', // single portable file, no sidecar resources
36
60
  });
61
+ intercepting = true;
37
62
  }
63
+ // Streams can't be HAR'd; let them reach the live server so replay doesn't
64
+ // abort them into the app's no-stream fallback (a phantom diff). Only when a
65
+ // HAR route is active — otherwise everything is already live.
66
+ if (intercepting)
67
+ await passLiveStreams(page, s.replayUrl);
38
68
  if (s.freezeClock)
39
69
  await page.clock.setFixedTime(new Date(s.clockTime));
40
70
  }
41
71
  /** Capture the surface again and throw if the computed styles drifted from `first`. */
42
- async function assertDeterministic(page, surface, first) {
72
+ async function assertDeterministic(page, surface, first, captureText) {
43
73
  await surface.go(page);
44
- const again = await captureStyleMap(page, { ignore: surface.ignore ?? [] });
74
+ const again = await captureStyleMap(page, { ignore: surface.ignore ?? [], captureText });
45
75
  const drift = diffStyleMaps(first, again);
46
76
  if (drift.length) {
47
77
  throw new Error(`styleproof self-check failed: ${surface.key} is non-deterministic — ` +
@@ -57,9 +87,9 @@ async function captureSurface(page, surface, width, s) {
57
87
  const height = typeof surface.height === 'function' ? surface.height(width) : (surface.height ?? 800);
58
88
  await page.setViewportSize({ width, height });
59
89
  await surface.go(page);
60
- const map = await captureStyleMap(page, { ignore: surface.ignore ?? [] });
90
+ const map = await captureStyleMap(page, { ignore: surface.ignore ?? [], captureText: s.captureText });
61
91
  if (s.selfCheck)
62
- await assertDeterministic(page, surface, map);
92
+ await assertDeterministic(page, surface, map, s.captureText);
63
93
  const stem = path.join(s.baseDir, s.dir, `${surface.key}@${width}`);
64
94
  saveStyleMap(`${stem}.json.gz`, map);
65
95
  if (s.screenshots) {
@@ -81,7 +111,7 @@ async function captureSurface(page, surface, width, s) {
81
111
  * defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
82
112
  * ```
83
113
  */
84
- export function defineStyleMapCapture({ surfaces, dir, baseDir = '__stylemaps__', screenshots = true, replayFrom = process.env.STYLEPROOF_REPLAY_FROM, replayUrl = process.env.STYLEPROOF_REPLAY_URL ?? '**/api/**', freezeClock = true, clockTime = '2025-01-01T00:00:00Z', selfCheck = process.env.STYLEPROOF_SELFCHECK === '1', }) {
114
+ export function defineStyleMapCapture({ surfaces, dir, baseDir = '__stylemaps__', screenshots = true, replayFrom = process.env.STYLEPROOF_REPLAY_FROM, replayUrl = process.env.STYLEPROOF_REPLAY_URL ?? '**/api/**', freezeClock = true, clockTime = '2025-01-01T00:00:00Z', selfCheck = process.env.STYLEPROOF_SELFCHECK === '1', captureText = false, }) {
85
115
  test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
86
116
  const settings = {
87
117
  dir: dir,
@@ -92,6 +122,7 @@ export function defineStyleMapCapture({ surfaces, dir, baseDir = '__stylemaps__'
92
122
  freezeClock,
93
123
  clockTime,
94
124
  selfCheck,
125
+ captureText,
95
126
  };
96
127
  test.describe('styleproof capture', () => {
97
128
  for (const surface of surfaces) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "1.9.4",
3
+ "version": "2.0.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",