styleproof 3.16.0 → 3.17.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,34 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.17.0] - 2026-07-05
11
+
12
+ ### Changed
13
+
14
+ - **`report.md` is bounded so GitHub can always render it.** A large redesign used to
15
+ produce a `report.md` too big for GitHub's markdown viewer (it refuses to render past
16
+ ~512 KB) — the reviewer clicked through and got _"we can't show files this big"_, so the
17
+ report was useless exactly when the change was biggest. The generator now holds
18
+ `report.md` to a byte budget (`maxReportBytes`, default ~400 KB): it emits full property
19
+ tables greedily, then lists any remaining changed surfaces as one-liners (name · change
20
+ count · crop link) under an announced banner. The exhaustive per-row detail is always in
21
+ `report.json` and every crop in `crops/`, so nothing is dropped from the certification —
22
+ only the inline detail is capped. A report a reviewer can't open isn't a source of truth.
23
+
24
+ ### Added
25
+
26
+ - **`affectedSurfaces` — selective-remap core (opt-in, advisory).** Given the files a
27
+ change touched, a declared surface→entry map, and a module graph (any tool's output
28
+ in `{ from, to }` shape — dependency-cruiser maps directly), returns exactly the
29
+ surfaces that could have rendered differently, or the sentinel `'all'`. Sound by
30
+ construction: it over-approximates and resolves every uncertainty to `'all'` — global
31
+ stylesheets/tokens, vanilla (unscoped) stylesheets, `createGlobalStyle`, design-system
32
+ configs, unbounded `import(x)`, and unplaceable files all force a full re-capture;
33
+ computed `import(`../dir/${x}`)` is recovered as a bundler context module (directory-
34
+ level, never a miss). Ships with `classifyStyleChange`. Purely additive, adds no
35
+ dependency, and never touches the default gate — the gate still captures every surface
36
+ and lets the map be the oracle. New export; existing specs unaffected.
37
+
10
38
  ## [3.16.0] - 2026-07-05
11
39
 
12
40
  ### Added
package/README.md CHANGED
@@ -27,6 +27,7 @@ report. Intentional visual changes get approved; unexpected ones block or fail.
27
27
  - [Forks and Dependabot](#forks-and-dependabot)
28
28
  - [Optional: content layer](#optional-content-layer-advisory)
29
29
  - [Optional: React component layer](#optional-react-component-layer-advisory)
30
+ - [Optional: selective remap](#optional-selective-remap-advisory)
30
31
  - [Newly-added elements show their full style](#newly-added-elements-show-their-full-style)
31
32
  - [Reference](#reference)
32
33
  - [Blocking without branch protection](#blocking-without-branch-protection)
@@ -679,6 +680,37 @@ Capture reads the React fiber in-page (`__reactFiber$*`/`__reactProps$*` on Reac
679
680
 
680
681
  Like the content layer it is **advisory**: never fed to the certification diff or the gate, so captures stay deterministic. Component names are mangled in minified production builds, so it's most useful against a dev / non-minified target; on a non-React page the fiber keys are absent and the field is simply omitted.
681
682
 
683
+ ## Optional: selective remap (advisory)
684
+
685
+ On a large app, capturing every surface on every PR is the slow part. `affectedSurfaces` answers the question that lets you skip most of it: **given the files a change touched, which declared surfaces could have rendered differently?** Everything it doesn't return can reuse its committed base map.
686
+
687
+ It is **opt-in and never part of the default gate** — the gate still captures every surface and lets the map be the oracle. This is a helper for wiring a faster pre-push/CI path yourself, and it is built to be wrong only in the safe direction: when it cannot _prove_ a surface is unaffected, it returns the sentinel `'all'` (re-capture everything). A global stylesheet or token, a vanilla (unscoped) stylesheet, a `createGlobalStyle`, a design-system config, an unbounded `import(x)`, or a file it can't place — all resolve to `'all'`.
688
+
689
+ The module graph is an **input**, so StyleProof stays framework-agnostic and adds no dependency. Produce it with any tool whose output you can shape into `{ from, to }` edges — [dependency-cruiser](https://www.npmjs.com/package/dependency-cruiser)'s `modules[].dependencies[]` maps directly:
690
+
691
+ ```ts
692
+ import { affectedSurfaces } from 'styleproof';
693
+ import { readFileSync } from 'node:fs';
694
+
695
+ // A dependency-cruiser run: `depcruise src --no-config --output-type json`
696
+ const cruise = JSON.parse(readFileSync('dc.json', 'utf8'));
697
+ const graph = cruise.modules.flatMap((m) =>
698
+ (m.dependencies ?? []).map((d) => ({ from: m.source, to: d.resolved, dynamic: d.dynamic })),
699
+ );
700
+
701
+ const result = affectedSurfaces({
702
+ changedFiles: ['src/components/PriceTable.tsx'], // e.g. `git diff --name-only origin/main`
703
+ surfaces: { home: 'src/pages/Home.tsx', pricing: 'src/pages/Pricing.tsx' },
704
+ graph,
705
+ files: cruise.modules.map((m) => m.source),
706
+ readFile: (p) => readFileSync(p, 'utf8'),
707
+ });
708
+ // → Set { 'pricing' } (capture only these; reuse the base map for the rest)
709
+ // → 'all' (some change couldn't be bounded — capture everything)
710
+ ```
711
+
712
+ Two honest limits, both resolving to `'all'`: a computed `import(`../dir/${x}`)` is treated as a bundler **context module** (every file under that dir is a possible target, so precision there is directory-level, never a miss); and the CSS-in-JS global list (`createGlobalStyle`, `injectGlobal`, `globalStyle`, …) must match the libraries you use — an unrecognized global API is the one way a scoped verdict could be unsound, so treat an unsupported styling system as a reason to skip selective remap. Because a PR-time miss would be silent, always let `main` (or a scheduled run) capture **all** surfaces as the trust-but-verify net.
713
+
682
714
  ## Newly-added elements show their full style
683
715
 
684
716
  When a PR **adds** an element, StyleProof now reports its **full resting computed style** (background, padding, font, radius, …), value-only, in addition to any interaction-state deltas — previously an added element surfaced only its `:hover`/`:focus` changes. The new element already gates via its `added` finding; this only enriches what you see, in both the report and the `styleproof-diff` CLI.
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Selective remap: given the files a change touched, which declared surfaces
3
+ * could have rendered differently?
4
+ *
5
+ * This is the sound core behind "capture only what a PR can affect, reuse the
6
+ * committed base map for the rest" — an OPT-IN speed-up, never the default gate.
7
+ * The default gate captures every surface and lets the map be the oracle; this
8
+ * function only decides which surfaces a caller *may* skip, and it is built to
9
+ * be wrong only in the safe direction.
10
+ *
11
+ * The hard constraint: in the committed-map model a wrong "unaffected" is silent
12
+ * and fatal — a stale committed map matches the base, the diff is empty, and the
13
+ * regression ships green. So this function OVER-APPROXIMATES. When it cannot
14
+ * prove a surface is unaffected, it returns the sentinel `'all'`, meaning
15
+ * "re-capture everything." Every uncertainty resolves to `'all'`:
16
+ *
17
+ * - a global style change (a reset, `:root`/theme token, `@tailwind`, a
18
+ * `createGlobalStyle`, a design-system config) cascades everywhere → `'all'`;
19
+ * - a vanilla (non-module) stylesheet has a global class namespace the import
20
+ * graph cannot bound → `'all'`;
21
+ * - a computed dynamic `import(x)` with no static prefix could load anything
22
+ * → `'all'`; with a static prefix (`import(`../dir/${x}`)`) it is treated as
23
+ * a bundler context module — every file under that dir is a possible target;
24
+ * - a changed file the graph cannot place at all → `'all'`.
25
+ *
26
+ * The module graph is an INPUT, not a dependency: pass any tool's output in the
27
+ * {@link ModuleEdge} shape (dependency-cruiser's `modules[].dependencies[]` maps
28
+ * directly). StyleProof stays framework-agnostic and adds no dependency; the
29
+ * caller owns graph production, which is where framework-specific resolution
30
+ * lives.
31
+ *
32
+ * Pure and side-effect-free (I/O is injected via `readFile`) so it is fully
33
+ * unit-testable and deterministic.
34
+ */
35
+ /** One resolved import edge: `from` imports `to`. Mirrors a dependency-cruiser
36
+ * `modules[].dependencies[]` entry (use `module.source` as `from`, dependency
37
+ * `resolved` as `to`). `dynamic` is informational; resolution already happened. */
38
+ export type ModuleEdge = {
39
+ from: string;
40
+ to: string;
41
+ dynamic?: boolean;
42
+ };
43
+ export type AffectedSurfacesInput = {
44
+ /** Repo-relative paths the change touched (as they appear in the graph). */
45
+ changedFiles: Iterable<string>;
46
+ /** Declared surfaces: capture key → the surface's entry module path. */
47
+ surfaces: Record<string, string>;
48
+ /** Resolved import edges for the source tree (node_modules edges are ignored). */
49
+ graph: Iterable<ModuleEdge>;
50
+ /** Every candidate source file path — the universe a context-module glob can
51
+ * resolve within. Typically the graph's node set. */
52
+ files: Iterable<string>;
53
+ /** Read a source file's text (for style classification and dynamic-import
54
+ * recovery). Throwing/returning nothing is treated as "unknown" → `'all'`. */
55
+ readFile: (path: string) => string;
56
+ };
57
+ /** `'all'` means "re-capture everything" (some change could not be bounded). A
58
+ * `Set` of surface keys means exactly those surfaces can be affected; any not
59
+ * listed are provably unaffected and may reuse their committed base map. */
60
+ export type AffectedSurfaces = Set<string> | 'all';
61
+ /**
62
+ * Decide whether a single changed file's style scope is bounded to the files
63
+ * that import it (`'scope'` → follow the import graph) or escapes them
64
+ * (`'all'` → re-capture everything). Sound by construction: `'scope'` is
65
+ * returned only for provably-scoped changes (a CSS Module without escapes, or
66
+ * colocated CSS-in-JS with no global API); everything else, including anything
67
+ * unrecognized, is `'all'`.
68
+ */
69
+ export declare function classifyStyleChange(file: string, readFile: (p: string) => string): 'scope' | 'all';
70
+ /**
71
+ * Compute the set of declared surfaces a change could have altered, or `'all'`.
72
+ * See the module doc for the soundness contract. Any not in the returned set are
73
+ * provably unaffected and may reuse their committed base map.
74
+ */
75
+ export declare function affectedSurfaces(input: AffectedSurfacesInput): AffectedSurfaces;
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Selective remap: given the files a change touched, which declared surfaces
3
+ * could have rendered differently?
4
+ *
5
+ * This is the sound core behind "capture only what a PR can affect, reuse the
6
+ * committed base map for the rest" — an OPT-IN speed-up, never the default gate.
7
+ * The default gate captures every surface and lets the map be the oracle; this
8
+ * function only decides which surfaces a caller *may* skip, and it is built to
9
+ * be wrong only in the safe direction.
10
+ *
11
+ * The hard constraint: in the committed-map model a wrong "unaffected" is silent
12
+ * and fatal — a stale committed map matches the base, the diff is empty, and the
13
+ * regression ships green. So this function OVER-APPROXIMATES. When it cannot
14
+ * prove a surface is unaffected, it returns the sentinel `'all'`, meaning
15
+ * "re-capture everything." Every uncertainty resolves to `'all'`:
16
+ *
17
+ * - a global style change (a reset, `:root`/theme token, `@tailwind`, a
18
+ * `createGlobalStyle`, a design-system config) cascades everywhere → `'all'`;
19
+ * - a vanilla (non-module) stylesheet has a global class namespace the import
20
+ * graph cannot bound → `'all'`;
21
+ * - a computed dynamic `import(x)` with no static prefix could load anything
22
+ * → `'all'`; with a static prefix (`import(`../dir/${x}`)`) it is treated as
23
+ * a bundler context module — every file under that dir is a possible target;
24
+ * - a changed file the graph cannot place at all → `'all'`.
25
+ *
26
+ * The module graph is an INPUT, not a dependency: pass any tool's output in the
27
+ * {@link ModuleEdge} shape (dependency-cruiser's `modules[].dependencies[]` maps
28
+ * directly). StyleProof stays framework-agnostic and adds no dependency; the
29
+ * caller owns graph production, which is where framework-specific resolution
30
+ * lives.
31
+ *
32
+ * Pure and side-effect-free (I/O is injected via `readFile`) so it is fully
33
+ * unit-testable and deterministic.
34
+ */
35
+ // A stylesheet whose scope escapes the file that imports it. Any of these means
36
+ // a change cascades beyond the import graph's reach.
37
+ const GLOBAL_CSS = /(^|\})\s*(:root|html|body|\*)[\s,{]|@tailwind\b|@layer\s+base\b|@theme\b|@font-face\b/;
38
+ // CSS-in-JS global APIs. NOTE: soundness depends on this list being complete for
39
+ // the libraries in use — an unlisted global API in a .tsx would be misread as a
40
+ // scoped (local) change. Extend deliberately; when unsure, the caller should
41
+ // treat the styling system as unsupported and skip selective remap.
42
+ const CSSJS_GLOBAL = /\b(createGlobalStyle|injectGlobal|globalStyle|globalCss|createGlobalTheme)\b/;
43
+ // `:global(...)` escape hatch or cross-module composition pulls in outside scope.
44
+ const MODULE_ESCAPES = /:global\b|\bcompose[sd]?\b[^;]*\bfrom\b/;
45
+ const isConfig = (f) => /(?:^|\/)(?:tailwind|postcss|theme|tokens?|panda|uno)\.config\.[cm]?[jt]s$/.test(f) ||
46
+ /(?:^|\/)theme\.[cm]?[jt]s$/.test(f);
47
+ const isStyleSheet = (f) => /\.(css|scss|sass|less|styl)$/.test(f);
48
+ const isCssModule = (f) => /\.module\.(css|scss|sass|less|styl)$/.test(f);
49
+ const isCode = (f) => /\.[cm]?[jt]sx?$/.test(f);
50
+ /**
51
+ * Decide whether a single changed file's style scope is bounded to the files
52
+ * that import it (`'scope'` → follow the import graph) or escapes them
53
+ * (`'all'` → re-capture everything). Sound by construction: `'scope'` is
54
+ * returned only for provably-scoped changes (a CSS Module without escapes, or
55
+ * colocated CSS-in-JS with no global API); everything else, including anything
56
+ * unrecognized, is `'all'`.
57
+ */
58
+ export function classifyStyleChange(file, readFile) {
59
+ if (isConfig(file))
60
+ return 'all'; // design-system config cascades to every surface
61
+ let src;
62
+ try {
63
+ src = readFile(file);
64
+ }
65
+ catch {
66
+ return 'all'; // cannot read → cannot prove local
67
+ }
68
+ if (src == null)
69
+ return 'all';
70
+ if (isCode(file))
71
+ return CSSJS_GLOBAL.test(src) ? 'all' : 'scope'; // .tsx: global CSS-in-JS or colocated scope
72
+ if (isStyleSheet(file)) {
73
+ if (isCssModule(file)) {
74
+ // Hashed per-file scope — but `:global`, cross-module `composes … from`, and
75
+ // genuinely global selectors (`:root`, `html`, `@font-face`, …) all escape it.
76
+ return MODULE_ESCAPES.test(src) || GLOBAL_CSS.test(src) ? 'all' : 'scope';
77
+ }
78
+ return 'all'; // vanilla stylesheet: global class namespace, import graph can't bound it
79
+ }
80
+ return 'all'; // unknown file kind → fail closed
81
+ }
82
+ // `import(` with a non-string-literal argument, capturing the argument text.
83
+ const DYNAMIC_IMPORT = /import\(\s*([^)]+?)\s*\)/g;
84
+ const dirOf = (p) => (p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '');
85
+ const isSource = (p) => !p.includes('node_modules');
86
+ function link(rev, to, from) {
87
+ let s = rev.get(to);
88
+ if (!s)
89
+ rev.set(to, (s = new Set()));
90
+ s.add(from);
91
+ }
92
+ /** Read a source file, mapping any throw/nullish result to `undefined`. */
93
+ function safeRead(readFile, path) {
94
+ try {
95
+ return readFile(path) ?? undefined;
96
+ }
97
+ catch {
98
+ return undefined;
99
+ }
100
+ }
101
+ /**
102
+ * Resolve one computed `import()` argument to the directory its bundler context
103
+ * module is rooted at: `null` for a plain string literal (the resolver already
104
+ * captured it), `'unbounded'` when there is no static directory prefix (target
105
+ * could be any file), else the normalized directory.
106
+ */
107
+ function contextDir(arg, fromDir) {
108
+ if (/^['"]/.test(arg))
109
+ return null; // plain string literal
110
+ const prefix = arg.match(/^`([^$`]*)/)?.[1]; // static head of a template literal
111
+ if (!prefix || !prefix.includes('/'))
112
+ return 'unbounded'; // e.g. import(name)
113
+ return normalizeDir(fromDir, prefix);
114
+ }
115
+ /**
116
+ * Recover computed `import()`s the graph resolver dropped, as bundler context
117
+ * modules. Returns extra edges to add, or `'unbounded'` if any dynamic import
118
+ * has no static directory prefix (its target could be any file → the whole
119
+ * reachability is untrustworthy → caller must return `'all'`).
120
+ */
121
+ function recoverContextEdges(from, src, files) {
122
+ const edges = [];
123
+ const fromDir = dirOf(from);
124
+ for (const m of src.matchAll(DYNAMIC_IMPORT)) {
125
+ const base = contextDir(m[1], fromDir);
126
+ if (base === 'unbounded')
127
+ return 'unbounded';
128
+ if (base === null)
129
+ continue;
130
+ for (const f of files)
131
+ if (dirOf(f) === base && isCode(f))
132
+ edges.push({ from, to: f, dynamic: true });
133
+ }
134
+ return edges;
135
+ }
136
+ // Resolve a `../a/b/` style prefix against a source file's directory, without fs.
137
+ function normalizeDir(fromDir, prefix) {
138
+ const parts = (fromDir ? fromDir.split('/') : []).concat(prefix.split('/'));
139
+ const out = [];
140
+ for (const p of parts) {
141
+ if (p === '' || p === '.')
142
+ continue;
143
+ if (p === '..')
144
+ out.pop();
145
+ else
146
+ out.push(p);
147
+ }
148
+ return out.join('/');
149
+ }
150
+ /** True if any changed file is a style change that escapes its importers. */
151
+ function anyGlobalStyleChange(changed, readFile) {
152
+ return changed.some((f) => (isStyleSheet(f) || isCode(f) || isConfig(f)) && classifyStyleChange(f, readFile) === 'all');
153
+ }
154
+ /**
155
+ * Build the reverse-import adjacency (`imported → importers`) from the graph plus
156
+ * recovered context-module edges. `'all'` when an unbounded dynamic import makes
157
+ * the whole reachability untrustworthy.
158
+ */
159
+ function buildReverseGraph(input, files) {
160
+ const rev = new Map();
161
+ for (const e of input.graph)
162
+ if (isSource(e.from) && isSource(e.to))
163
+ link(rev, e.to, e.from);
164
+ for (const f of files) {
165
+ const src = isCode(f) ? safeRead(input.readFile, f) : undefined;
166
+ if (src === undefined)
167
+ continue;
168
+ const extra = recoverContextEdges(f, src, files);
169
+ if (extra === 'unbounded')
170
+ return 'all';
171
+ for (const e of extra)
172
+ link(rev, e.to, e.from);
173
+ }
174
+ return rev;
175
+ }
176
+ /**
177
+ * Compute the set of declared surfaces a change could have altered, or `'all'`.
178
+ * See the module doc for the soundness contract. Any not in the returned set are
179
+ * provably unaffected and may reuse their committed base map.
180
+ */
181
+ export function affectedSurfaces(input) {
182
+ const changed = [...input.changedFiles];
183
+ const files = [...input.files];
184
+ // 1. A style change that escapes its importers forces a full re-capture.
185
+ if (anyGlobalStyleChange(changed, input.readFile))
186
+ return 'all';
187
+ // 2. Reverse reachability (+ recovered context edges; unbounded dynamic → all).
188
+ const rev = buildReverseGraph(input, files);
189
+ if (rev === 'all')
190
+ return 'all';
191
+ // 3. Map each changed file to the surfaces that transitively import it. A file
192
+ // the graph can't place (no importers, not a surface entry) → 'all'.
193
+ const entryToKey = new Map(Object.entries(input.surfaces).map(([k, f]) => [f, k]));
194
+ const hit = new Set();
195
+ for (const f of changed) {
196
+ const reach = reverseReach(f, rev);
197
+ if (reach.size === 0 && !entryToKey.has(f))
198
+ return 'all';
199
+ for (const src of [f, ...reach]) {
200
+ const key = entryToKey.get(src);
201
+ if (key)
202
+ hit.add(key);
203
+ }
204
+ }
205
+ return hit;
206
+ }
207
+ function reverseReach(file, rev) {
208
+ const seen = new Set();
209
+ const stack = [file];
210
+ while (stack.length) {
211
+ const cur = stack.pop();
212
+ for (const importer of rev.get(cur) ?? []) {
213
+ if (!seen.has(importer)) {
214
+ seen.add(importer);
215
+ stack.push(importer);
216
+ }
217
+ }
218
+ }
219
+ return seen;
220
+ }
package/dist/index.d.ts CHANGED
@@ -23,3 +23,5 @@ export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, find
23
23
  export type { Finding, PropChange, SurfaceDiff, DiffCounts, ContentChange } from './diff.js';
24
24
  export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
25
25
  export type { ReportOptions, ReportResult } from './report.js';
26
+ export { affectedSurfaces, classifyStyleChange } from './affected-surfaces.js';
27
+ export type { ModuleEdge, AffectedSurfacesInput, AffectedSurfaces } from './affected-surfaces.js';
package/dist/index.js CHANGED
@@ -12,3 +12,4 @@ export { selectCrawlLinks, defaultLinkKey } from './crawl.js';
12
12
  export { harvestStyleVariants } from './variant-crawler.js';
13
13
  export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
14
14
  export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
15
+ export { affectedSurfaces, classifyStyleChange } from './affected-surfaces.js';
package/dist/report.d.ts CHANGED
@@ -56,6 +56,15 @@ export type ReportOptions = {
56
56
  * cause) for the reviewer's eye.
57
57
  */
58
58
  includeContent?: boolean;
59
+ /**
60
+ * Byte ceiling for report.md so GitHub can always render it (its markdown viewer
61
+ * refuses to render files past ~512 KB). Once the accumulated report would exceed
62
+ * this, the remaining changed surfaces are listed as one-liners (name · change
63
+ * count · crop link) instead of full property tables — the exhaustive per-row
64
+ * detail is always kept in report.json and every crop in crops/, so nothing is
65
+ * lost, just relocated. Default 400_000 (~0.4 MB). Set to Infinity to never cap.
66
+ */
67
+ maxReportBytes?: number;
59
68
  };
60
69
  export type ReportResult = {
61
70
  /** Surfaces carrying a reviewable change (excludes new, one-sided surfaces). */
package/dist/report.js CHANGED
@@ -1196,6 +1196,31 @@ function renderNewSurface(p, ctx, cropSeq) {
1196
1196
  md.push('', `_No baseline to compare against — this surface is new. Review and approve it before it becomes part of the baseline._`);
1197
1197
  return { md, json, cropSeq };
1198
1198
  }
1199
+ /** The one-time banner where report.md switches from full detail to one-line
1200
+ * summaries, so the reader knows nothing is missing — only relocated to report.json. */
1201
+ function cappedNoticeLines(budget) {
1202
+ return [
1203
+ '',
1204
+ '## … more changed surfaces (summarized to keep this report renderable)',
1205
+ '',
1206
+ `_This report reached its ~${Math.round(budget / 1000)} KB display budget (GitHub does not render ` +
1207
+ `markdown past ~512 KB), so the surfaces below are listed as one-liners. Their full property ` +
1208
+ `tables are in \`report.json\` and their crops in \`crops/\` — the certification above covers every ` +
1209
+ `surface; only the inline detail is capped._`,
1210
+ '',
1211
+ ];
1212
+ }
1213
+ /** One-line summary for a changed surface whose full detail was budget-capped: its
1214
+ * name (and how many surfaces share the identical change) · change count · a crop
1215
+ * link so the reviewer can still see it without opening report.json. */
1216
+ function compactChangeSummary(cg, json, img) {
1217
+ const surface = cg.rep.sd.surface;
1218
+ const more = cg.surfaces.length > 1 ? ` (+${cg.surfaces.length - 1} more)` : '';
1219
+ const regions = json.regions ?? [];
1220
+ const composite = regions[0]?.images?.composite;
1221
+ const link = composite ? ` — [crop](${img(composite)})` : '';
1222
+ return `- \`${surface}\`${more} · ${cg.rep.findings.length} change(s)${link}`;
1223
+ }
1199
1224
  export function generateStyleMapReport(opts) {
1200
1225
  const { beforeDir, afterDir, outDir, imageBaseUrl = '',
1201
1226
  // Tighter than before (was 24) so the change fills the frame — the annotation
@@ -1203,7 +1228,7 @@ export function generateStyleMapReport(opts) {
1203
1228
  pad: padBy = 12, minWidth = 320, minHeight = 180, maxHeight = 1600, zoomBelow = 64,
1204
1229
  // More, smaller crops before collapsing (was 6), so distinct changes get their
1205
1230
  // own focused frame rather than one wide merged one.
1206
- maxCrops = 8, foldDetailsAt = 0, } = opts;
1231
+ maxCrops = 8, foldDetailsAt = 0, maxReportBytes = 400_000, } = opts;
1207
1232
  const includeNoise = opts.includeLayoutNoise ?? false;
1208
1233
  const includeContent = opts.includeContent ?? false;
1209
1234
  const { surfaces, volatile: volatileCount } = diffStyleMapDirs(beforeDir, afterDir);
@@ -1261,18 +1286,38 @@ export function generateStyleMapReport(opts) {
1261
1286
  }));
1262
1287
  let totalFindings = 0;
1263
1288
  let cropSeq = 0;
1289
+ // report.md must stay renderable — GitHub refuses to render markdown past ~512 KB.
1290
+ // Emit full detail greedily until the byte budget is reached, then list any remaining
1291
+ // surfaces as one-liners. The exhaustive per-row detail is always in report.json and
1292
+ // every crop in crops/, so the cap changes what's shown inline, never what's certified.
1293
+ let reportBytes = md.join('\n').length;
1294
+ let capped = false;
1295
+ const emitDetail = (detail, summary) => {
1296
+ const cost = detail.join('\n').length + 1;
1297
+ if (!capped && reportBytes + cost <= maxReportBytes) {
1298
+ md.push(...detail);
1299
+ reportBytes += cost;
1300
+ return;
1301
+ }
1302
+ if (!capped) {
1303
+ md.push(...cappedNoticeLines(maxReportBytes));
1304
+ capped = true;
1305
+ }
1306
+ md.push(summary);
1307
+ reportBytes += summary.length + 1;
1308
+ };
1264
1309
  for (const cg of changeGroups) {
1265
1310
  const r = renderChangeGroup(cg, ctx, maxCrops, cropSeq);
1266
- md.push(...r.md);
1267
1311
  json.push(r.json);
1268
1312
  totalFindings += r.findingCount;
1269
1313
  cropSeq = r.cropSeq;
1314
+ emitDetail(r.md, compactChangeSummary(cg, r.json, img));
1270
1315
  }
1271
1316
  for (const p of missing) {
1272
1317
  const r = renderNewSurface(p, ctx, cropSeq);
1273
- md.push(...r.md);
1274
1318
  json.push(r.json);
1275
1319
  cropSeq = r.cropSeq;
1320
+ emitDetail(r.md, `- \`${p.sd.surface}\` · new surface`);
1276
1321
  }
1277
1322
  md.push(...contentSection.md);
1278
1323
  const reportMdPath = path.join(outDir, 'report.md');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "3.16.0",
3
+ "version": "3.17.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",