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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

## [Unreleased]

## [3.17.0] - 2026-07-05

### Changed

- **`report.md` is bounded so GitHub can always render it.** A large redesign used to
produce a `report.md` too big for GitHub's markdown viewer (it refuses to render past
~512 KB) — the reviewer clicked through and got _"we can't show files this big"_, so the
report was useless exactly when the change was biggest. The generator now holds
`report.md` to a byte budget (`maxReportBytes`, default ~400 KB): it emits full property
tables greedily, then lists any remaining changed surfaces as one-liners (name · change
count · crop link) under an announced banner. The exhaustive per-row detail is always in
`report.json` and every crop in `crops/`, so nothing is dropped from the certification —
only the inline detail is capped. A report a reviewer can't open isn't a source of truth.

### Added

- **`affectedSurfaces` — selective-remap core (opt-in, advisory).** Given the files a
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "styleproof",
"version": "3.16.0",
"version": "3.17.0",
"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.",
"keywords": [
"playwright",
Expand Down
61 changes: 59 additions & 2 deletions src/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,15 @@ export type ReportOptions = {
* cause) for the reviewer's eye.
*/
includeContent?: boolean;
/**
* Byte ceiling for report.md so GitHub can always render it (its markdown viewer
* refuses to render files past ~512 KB). Once the accumulated report would exceed
* this, the remaining changed surfaces are listed as one-liners (name · change
* count · crop link) instead of full property tables — the exhaustive per-row
* detail is always kept in report.json and every crop in crops/, so nothing is
* lost, just relocated. Default 400_000 (~0.4 MB). Set to Infinity to never cap.
*/
maxReportBytes?: number;
};

export type ReportResult = {
Expand Down Expand Up @@ -1495,6 +1504,33 @@ function renderNewSurface(
return { md, json, cropSeq };
}

/** The one-time banner where report.md switches from full detail to one-line
* summaries, so the reader knows nothing is missing — only relocated to report.json. */
function cappedNoticeLines(budget: number): string[] {
return [
'',
'## … more changed surfaces (summarized to keep this report renderable)',
'',
`_This report reached its ~${Math.round(budget / 1000)} KB display budget (GitHub does not render ` +
`markdown past ~512 KB), so the surfaces below are listed as one-liners. Their full property ` +
`tables are in \`report.json\` and their crops in \`crops/\` — the certification above covers every ` +
`surface; only the inline detail is capped._`,
'',
];
}

/** One-line summary for a changed surface whose full detail was budget-capped: its
* name (and how many surfaces share the identical change) · change count · a crop
* link so the reviewer can still see it without opening report.json. */
function compactChangeSummary(cg: ChangeGroup, json: Record<string, unknown>, img: (rel: string) => string): string {
const surface = cg.rep.sd.surface;
const more = cg.surfaces.length > 1 ? ` (+${cg.surfaces.length - 1} more)` : '';
const regions = (json.regions as Array<{ images?: { composite?: string } }> | undefined) ?? [];
const composite = regions[0]?.images?.composite;
const link = composite ? ` — [crop](${img(composite)})` : '';
return `- \`${surface}\`${more} · ${cg.rep.findings.length} change(s)${link}`;
}

export function generateStyleMapReport(opts: ReportOptions): ReportResult {
const {
beforeDir,
Expand All @@ -1512,6 +1548,7 @@ export function generateStyleMapReport(opts: ReportOptions): ReportResult {
// own focused frame rather than one wide merged one.
maxCrops = 8,
foldDetailsAt = 0,
maxReportBytes = 400_000,
} = opts;

const includeNoise = opts.includeLayoutNoise ?? false;
Expand Down Expand Up @@ -1579,18 +1616,38 @@ export function generateStyleMapReport(opts: ReportOptions): ReportResult {

let totalFindings = 0;
let cropSeq = 0;
// report.md must stay renderable — GitHub refuses to render markdown past ~512 KB.
// Emit full detail greedily until the byte budget is reached, then list any remaining
// surfaces as one-liners. The exhaustive per-row detail is always in report.json and
// every crop in crops/, so the cap changes what's shown inline, never what's certified.
let reportBytes = md.join('\n').length;
let capped = false;
const emitDetail = (detail: string[], summary: string): void => {
const cost = detail.join('\n').length + 1;
if (!capped && reportBytes + cost <= maxReportBytes) {
md.push(...detail);
reportBytes += cost;
return;
}
if (!capped) {
md.push(...cappedNoticeLines(maxReportBytes));
capped = true;
}
md.push(summary);
reportBytes += summary.length + 1;
};
for (const cg of changeGroups) {
const r = renderChangeGroup(cg, ctx, maxCrops, cropSeq);
md.push(...r.md);
json.push(r.json);
totalFindings += r.findingCount;
cropSeq = r.cropSeq;
emitDetail(r.md, compactChangeSummary(cg, r.json, img));
}
for (const p of missing) {
const r = renderNewSurface(p, ctx, cropSeq);
md.push(...r.md);
json.push(r.json);
cropSeq = r.cropSeq;
emitDetail(r.md, `- \`${p.sd.surface}\` · new surface`);
}

md.push(...contentSection.md);
Expand Down
58 changes: 58 additions & 0 deletions test/report.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1038,3 +1038,61 @@ test('end-to-end: annotation boxes the changed CHILD, not its changed container'
assert.ok(hilite < 1500, `highlight footprint ${hilite} should be child-sized, not container-sized`);
rmTmp(root);
});

// ---------------------------------------- bounded report (always GitHub-renderable)

test('report.md stays under its byte budget (GitHub-renderable); report.json keeps every surface', () => {
const { beforeDir, afterDir, outDir, root } = tmpDirs();
const N = 40;
const M = 15;
// Each surface carries a DISTINCT change (values keyed by surface index) so they
// don't collapse into one "identical across N surfaces" group — N real detail blocks.
const surfaceMap = (s, shift) =>
makeMap({
elements: Object.fromEntries(
Array.from({ length: M }, (_, k) => [
`body > div:nth-child(${k + 1})`,
{
tag: 'div',
cls: `s${s}-c${k}`,
rect: [10, 20 + k * 30, 200, 24],
style: {
color: `rgb(${(shift + s) % 256}, ${k}, 0)`,
'padding-top': `${10 + shift + s}px`,
'font-size': `${12 + k}px`,
'margin-top': `${4 + shift}px`,
},
},
]),
),
});
for (let s = 0; s < N; s++) {
const surface = `surface-${s}@1280`;
writeCapture(beforeDir, surface, surfaceMap(s, 0), solidPng(1280, 800));
writeCapture(afterDir, surface, surfaceMap(s, 5), solidPng(1280, 800));
}

const budget = 15_000;
const res = generateStyleMapReport({ beforeDir, afterDir, outDir, maxReportBytes: budget });
const md = fs.readFileSync(res.reportMdPath, 'utf8');
const json = JSON.parse(fs.readFileSync(res.reportJsonPath, 'utf8'));

assert.ok(md.length < budget * 2, `report.md must stay bounded near the budget (was ${md.length})`);
assert.match(md, /summarized to keep this report renderable/, 'the cap is announced, not silent');
assert.match(md, /· \d+ change\(s\)/, 'capped surfaces appear as one-line summaries');
assert.equal(json.surfaces.length, N, 'report.json keeps every surface — the cap relocates detail, never drops it');

// The cap must actually shrink a large report (uncapped is far bigger).
const full = generateStyleMapReport({
beforeDir,
afterDir,
outDir: path.join(root, 'out2'),
maxReportBytes: Infinity,
});
const mdFull = fs.readFileSync(full.reportMdPath, 'utf8');
assert.ok(
mdFull.length > md.length * 2,
`uncapped report should be far larger (capped ${md.length}, full ${mdFull.length})`,
);
rmTmp(root);
});
Loading