styleproof 1.7.1 โ†’ 1.8.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,7 +7,39 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ## [1.7.1]
10
+ ## [1.8.0]
11
+
12
+ The crop now shows you where to look โ€” without painting over the UI.
13
+
14
+ ### Added
15
+
16
+ - **Annotated crop, alongside the clean one.** Each crop shows the clean
17
+ before|after composite by default (the real UI), with an annotated twin one
18
+ click away under a `๐Ÿ” Highlight what changed` toggle โ€” a thin magenta outline
19
+ around each changed element on both sides, so the eye lands on exactly what the
20
+ bullet named. Outline only (never filled), and the clean image right there
21
+ proves the box isn't part of the design, so the marker can be confident without
22
+ reading as a change.
23
+
24
+ ### Changed
25
+
26
+ - **Tighter, more focused crops.** Default crop padding drops from 24px to 12px
27
+ (the change fills more of the frame), and up to 8 crop regions per surface
28
+ (was 6) before collapsing โ€” so distinct changes get their own focused frame
29
+ instead of one wide merged one. Both are still tunable (`pad`, `maxCrops`).
30
+ - The action now commits the annotated crops alongside the composites.
31
+
32
+ ## [1.7.2]
33
+
34
+ ### Fixed
35
+
36
+ - **Responsive variants of one change no longer show as duplicate sections.** A
37
+ grid whose `grid-template-columns/rows` computes to different pixels per width
38
+ (`282px ร—2` vs `282px 228px`) was given a different signature per width, so the
39
+ same change rendered once per breakpoint. The signature now keys grid tracks by
40
+ their COUNT, so responsive widths collapse into one grouped section.
41
+ - The `e.g. โ€ฆ` fold line no longer names the `+N more` overflow marker as if it
42
+ were a change.
11
43
 
12
44
  ### Fixed
13
45
 
@@ -25,6 +25,8 @@ export declare function toHex(v: string): string;
25
25
  /** Reverse-index a token map (`--red-200` โ†’ `rgb(...)`) to value โ†’ token name,
26
26
  * preferring a scale step (`red-200`) over an alias, then the shorter name. */
27
27
  export declare function tokenIndex(tokens?: Record<string, string>): Map<string, string>;
28
+ /** Count grid tracks in a `grid-template-*` value, honouring the `Npx ร—K` form. */
29
+ export declare function trackCount(v: string): number;
28
30
  /** Token reverse-indexes for each side, so colour rules can name `red-200`. */
29
31
  export type DescribeCtx = {
30
32
  tokensBefore?: Map<string, string>;
package/dist/describe.js CHANGED
@@ -117,7 +117,7 @@ const shift = (before, after, idxB, idxA) => {
117
117
  };
118
118
  // --- track counting for grid columns/rows ------------------------------------
119
119
  /** Count grid tracks in a `grid-template-*` value, honouring the `Npx ร—K` form. */
120
- function trackCount(v) {
120
+ export function trackCount(v) {
121
121
  if (v === 'none' || !v)
122
122
  return 0;
123
123
  const rep = v.match(/ร—(\d+)/); // summarizeProps collapses "8px 8px 8px" โ†’ "8px ร—3"
@@ -318,10 +318,13 @@ function foldedLine(group, ctx) {
318
318
  const common = lists[0].filter((p) => lists.every((l) => l.includes(p)));
319
319
  if (common.length)
320
320
  return `${common.join(', ')} _(details vary)_`;
321
+ // Rank the most common REAL changes โ€” the `+N more` overflow marker isn't a
322
+ // change and shouldn't be named.
321
323
  const freq = new Map();
322
324
  for (const l of lists)
323
325
  for (const p of new Set(l))
324
- freq.set(p, (freq.get(p) ?? 0) + 1);
326
+ if (!/^\+\d+ more$/.test(p))
327
+ freq.set(p, (freq.get(p) ?? 0) + 1);
325
328
  const top = [...freq.entries()]
326
329
  .sort((a, b) => b[1] - a[1])
327
330
  .slice(0, 3)
package/dist/report.d.ts CHANGED
@@ -17,14 +17,14 @@ export type ReportOptions = {
17
17
  outDir: string;
18
18
  /** Prefix for image URLs in report.md (default: relative paths). */
19
19
  imageBaseUrl?: string;
20
- /** Padding around the union of changed rects (default 24px). */
20
+ /** Padding around the union of changed rects (default 12px). */
21
21
  pad?: number;
22
22
  /** Minimum crop size, for context around tiny changes (default 320ร—180). */
23
23
  minWidth?: number;
24
24
  minHeight?: number;
25
25
  /** Crops taller than this are clamped (default 1600px). */
26
26
  maxHeight?: number;
27
- /** Max crop regions per surface before collapsing into one union crop (default 6). */
27
+ /** Max crop regions per surface before collapsing into one union crop (default 8). */
28
28
  maxCrops?: number;
29
29
  /**
30
30
  * Row count at which a crop's property tables fold under a `<details>` toggle
package/dist/report.js CHANGED
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { PNG } from 'pngjs';
4
4
  import { loadStyleMap } from './capture.js';
5
5
  import { diffStyleMapDirs } from './diff.js';
6
- import { describeChange, tokenIndex, toHex } from './describe.js';
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.
9
9
  export { describeChange, colorName, tokenIndex, toHex } from './describe.js';
@@ -80,13 +80,13 @@ function cropPng(src, box, w, h) {
80
80
  // Center the fixed-size crop on the box, clamped to the image.
81
81
  const cx = box.x + box.w / 2;
82
82
  const cy = box.y + box.h / 2;
83
- const x = Math.max(0, Math.min(Math.round(cx - w / 2), src.width - w));
84
- const y = Math.max(0, Math.min(Math.round(cy - h / 2), src.height - h));
83
+ const ox = Math.max(0, Math.min(Math.round(cx - w / 2), src.width - w));
84
+ const oy = Math.max(0, Math.min(Math.round(cy - h / 2), src.height - h));
85
85
  const cw = Math.min(w, src.width);
86
86
  const ch = Math.min(h, src.height);
87
87
  const out = new PNG({ width: cw, height: ch });
88
- PNG.bitblt(src, out, Math.max(0, x), Math.max(0, y), cw, ch, 0, 0);
89
- return out;
88
+ PNG.bitblt(src, out, Math.max(0, ox), Math.max(0, oy), cw, ch, 0, 0);
89
+ return { png: out, ox, oy };
90
90
  }
91
91
  // Lossless but lean: drop the alpha channel (every crop/composite is opaque),
92
92
  // max deflate, adaptive per-row filtering. ~15% smaller than the default, and
@@ -108,6 +108,29 @@ function fillRect(png, x, y, w, h, [r, g, b]) {
108
108
  }
109
109
  }
110
110
  }
111
+ // The annotation hue: a magenta no real UI palette tends to use, so an outline
112
+ // reads as a marker, not content. Drawn as a hollow rectangle (never filled) so
113
+ // the UI underneath stays visible โ€” and the clean image alongside proves the box
114
+ // isn't part of the design.
115
+ const HILITE = [255, 0, 200];
116
+ function strokeRect(png, x, y, w, h, t = 2, color = HILITE) {
117
+ fillRect(png, x, y, w, t, color); // top
118
+ fillRect(png, x, y + h - t, w, t, color); // bottom
119
+ fillRect(png, x, y, t, h, color); // left
120
+ fillRect(png, x + w - t, y, t, h, color); // right
121
+ }
122
+ /** Clone a crop and outline each changed element's box (page coords mapped into
123
+ * the crop via its origin), so the eye lands on exactly what the bullet named. */
124
+ function annotateCrop(crop, rects) {
125
+ const out = new PNG({ width: crop.png.width, height: crop.png.height });
126
+ PNG.bitblt(crop.png, out, 0, 0, crop.png.width, crop.png.height, 0, 0);
127
+ for (const [rx, ry, rw, rh] of rects) {
128
+ if (rw <= 0 || rh <= 0)
129
+ continue;
130
+ strokeRect(out, rx - crop.ox, ry - crop.oy, rw, rh);
131
+ }
132
+ return out;
133
+ }
111
134
  /**
112
135
  * One before|after image: the two equal-size crops on a dark canvas with a
113
136
  * neutral divider between them. Left is always before; before/after is labelled
@@ -339,6 +362,16 @@ function formatSurfaceList(surfaces) {
339
362
  })
340
363
  .join(' ยท ');
341
364
  }
365
+ // Grid-track longhands compute to width-dependent pixels (`282px ร—2` at one width,
366
+ // `282px 228px` at another), so the SAME responsive change would otherwise get a
367
+ // different signature per width. Key them by track COUNT โ€” what actually
368
+ // identifies the change โ€” so responsive variants group into one section.
369
+ function sigValue(c) {
370
+ if (c.prop === 'grid-template-columns' || c.prop === 'grid-template-rows') {
371
+ return `${c.prop}=${trackCount(c.before)}t>${trackCount(c.after)}t`;
372
+ }
373
+ return `${c.prop}=${c.before}>${c.after}`;
374
+ }
342
375
  /** Canonical signature of a surface's findings: surfaces that changed in the
343
376
  * same way collapse into one section + one image (the rects differ per width;
344
377
  * the change itself does not). */
@@ -348,11 +381,7 @@ function signatureOf(findings) {
348
381
  p: f.path,
349
382
  k: f.kind,
350
383
  t: f.kind === 'dom' ? f.change : f.kind === 'state' ? f.state : (f.pseudo ?? ''),
351
- v: f.kind === 'dom'
352
- ? ''
353
- : summarizeProps(f.props)
354
- .map((c) => `${c.prop}=${c.before}>${c.after}`)
355
- .join('|'),
384
+ v: f.kind === 'dom' ? '' : summarizeProps(f.props).map(sigValue).join('|'),
356
385
  }))
357
386
  .sort((a, b) => `${a.p}|${a.k}|${a.t}`.localeCompare(`${b.p}|${b.k}|${b.t}`)));
358
387
  }
@@ -585,7 +614,13 @@ function buildElementChanges(findings) {
585
614
  return els;
586
615
  }
587
616
  export function generateStyleMapReport(opts) {
588
- const { beforeDir, afterDir, outDir, imageBaseUrl = '', pad: padBy = 24, minWidth = 320, minHeight = 180, maxHeight = 1600, maxCrops = 6, foldDetailsAt = 0, } = opts;
617
+ const { beforeDir, afterDir, outDir, imageBaseUrl = '',
618
+ // Tighter than before (was 24) so the change fills the frame โ€” the annotation
619
+ // box keeps enough context legible.
620
+ pad: padBy = 12, minWidth = 320, minHeight = 180, maxHeight = 1600,
621
+ // More, smaller crops before collapsing (was 6), so distinct changes get their
622
+ // own focused frame rather than one wide merged one.
623
+ maxCrops = 8, foldDetailsAt = 0, } = opts;
589
624
  const includeNoise = opts.includeLayoutNoise ?? false;
590
625
  const { surfaces, volatile: volatileCount } = diffStyleMapDirs(beforeDir, afterDir);
591
626
  fs.mkdirSync(path.join(outDir, 'crops'), { recursive: true });
@@ -711,15 +746,19 @@ export function generateStyleMapReport(opts) {
711
746
  const stem = `crops/${sd.surface.replace(/[^a-z0-9-]/gi, '-')}-${cropSeq}`;
712
747
  const before = cropPng(pngA, cropBox, w, h);
713
748
  const after = cropPng(pngB, cropBox, w, h);
714
- const composite = compositePair(before, after);
715
- writePng(path.join(outDir, `${stem}-before.png`), before);
716
- writePng(path.join(outDir, `${stem}-after.png`), after);
749
+ const composite = compositePair(before.png, after.png);
717
750
  writePng(path.join(outDir, `${stem}-composite.png`), composite);
718
- images = { before: `${stem}-before.png`, after: `${stem}-after.png`, composite: `${stem}-composite.png` };
719
- // One side-by-side image per crop, left as a PLAIN image (not wrapped in a
720
- // link) so GitHub's built-in lightbox opens it in a zoomable popup on click โ€”
721
- // a link wrapper would navigate to the file instead.
722
- md.push('', `![before โ—€ โ”‚ โ–ถ after](${img(images.composite)})`, '', `<sub>โ—€ before ยท after โ–ถ โ€” ${sd.surface} ยท click to zoom</sub>`);
751
+ // Annotated twin: outline each changed element on both sides, so the eye
752
+ // lands on what the bullet named. The clean image stays the default.
753
+ const rectsA = g.paths.map((p) => mapA.elements[p]?.rect).filter((r) => !!r);
754
+ const rectsB = g.paths.map((p) => mapB.elements[p]?.rect).filter((r) => !!r);
755
+ const annotated = compositePair(annotateCrop(before, rectsA), annotateCrop(after, rectsB));
756
+ writePng(path.join(outDir, `${stem}-annotated.png`), annotated);
757
+ images = { composite: `${stem}-composite.png`, annotated: `${stem}-annotated.png` };
758
+ // Clean before|after shown by default (the real UI); the annotated twin โ€”
759
+ // boxes marking each change โ€” sits one click away under a toggle. Plain
760
+ // images (no link wrap) so a click opens the full-resolution file.
761
+ md.push('', `![before โ—€ โ”‚ โ–ถ after](${img(images.composite)})`, '', `<sub>โ—€ before ยท after โ–ถ โ€” ${sd.surface}</sub>`, '', '<details>', '<summary>๐Ÿ” Highlight what changed</summary>', '', `![annotated before โ—€ โ”‚ โ–ถ after](${img(images.annotated)})`, '', `<sub>magenta boxes mark each change โ€” ${sd.surface}</sub>`, '', '</details>');
723
762
  }
724
763
  else if (!region) {
725
764
  md.push('', '_Changed element is not visible in this state (zero-size box) โ€” see the property list._');
@@ -747,7 +786,7 @@ export function generateStyleMapReport(opts) {
747
786
  if (png) {
748
787
  cropSeq++;
749
788
  const h = Math.min(maxHeight, png.height);
750
- const crop = cropPng(png, { x: 0, y: 0, w: png.width, h }, png.width, h);
789
+ const crop = cropPng(png, { x: 0, y: 0, w: png.width, h }, png.width, h).png;
751
790
  const stem = `crops/${p.sd.surface.replace(/[^a-z0-9-]/gi, '-')}-${cropSeq}-new`;
752
791
  writePng(path.join(outDir, `${stem}.png`), crop);
753
792
  md.push('', `![new surface โ€” ${side}](${img(`${stem}.png`)})`, '', `<sub>${side} ยท ${p.sd.surface}${png.height > h ? ' (top of page)' : ''}</sub>`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "1.7.1",
3
+ "version": "1.8.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",