cornhsu-parity 2.1.0 → 2.3.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.
Files changed (2) hide show
  1. package/README.md +167 -9
  2. package/package.json +7 -7
package/README.md CHANGED
@@ -63,11 +63,11 @@ PR comment, updating the same comment rather than adding new ones.
63
63
 
64
64
  | | |
65
65
  |---|---|
66
- | Releases | 29 on NuGet (v0.1.0 → v2.1.0). Pushing a tag publishes via OIDC Trusted Publishing — zero long-lived keys in the repo |
66
+ | Releases | 31 on NuGet (v0.1.0 → v2.3.0). Pushing a tag publishes via OIDC Trusted Publishing — zero long-lived keys in the repo |
67
67
  | Compared properties | Size, padding, spacing, typography, color (CIEDE2000 ΔE), relative position — **absolute coordinates are deliberately excluded** (they are guaranteed false positives in a flexible layout) |
68
68
  | Design sources | 4: the Figma API, a rendering snapshot, an image + annotations (pixel sampling — any tool that exports a PNG works), or JSON |
69
69
  | Implementation sources | The web (including **shadow DOM, same-origin iframes, and multiple responsive breakpoints**) plus **Electron** (attached to the live window over CDP) |
70
- | Tests | **1047**, covering the CIEDE2000 reference data set (Sharma), match disambiguation, position false-positive guards, image sampling, and real-browser capture regressions |
70
+ | Tests | **1443**, covering the CIEDE2000 reference data set (Sharma), match disambiguation, position false-positive guards, image sampling, and real-browser capture regressions |
71
71
  | Proven in CI | The GitHub Action is validated by **real PRs in an external repo**: blocking the PR, commenting automatically (updating in place), and baseline regression gating |
72
72
  | Proven in production | **All 26 pages of cornhsu.com are gated by Parity itself** — dogfooding has already found and fixed three flaky root causes (most recently a scroll-triggered entrance animation; see 0.11.1) |
73
73
 
@@ -89,11 +89,47 @@ dotnet tool install -g Cornhsu.Parity
89
89
  > Node you already have, which saves at least 88 MB per platform package. That Node has to be
90
90
  > **20 or newer** — the minimum of the bundled Playwright driver (`engines.node` says so).
91
91
 
92
+ > The .NET tool needs the **.NET 10 SDK** and downloads only your platform's package (about 44–54 MB).
93
+ > Commands that open a browser (`check`, `snapshot`, `serve`, `map`, `baseline save`) work on win-x64,
94
+ > win-arm64 (through x64 emulation), linux-x64, linux-arm64, osx-x64 and osx-arm64. Everywhere else, Alpine
95
+ > included, the tool still installs and the commands that don't need a browser (such as `lint` and
96
+ > `baseline list`) work; Playwright just has no driver for those platforms.
97
+
92
98
  > **`parity check` sends anonymous usage statistics by default** (counts and score ranges only, for
93
99
  > academic research). What is sent, why, and every way to turn it off:
94
100
  > [Anonymous usage statistics](#anonymous-usage-statistics). To see the exact data without sending
95
101
  > anything: `parity check --stats-preview`.
96
102
 
103
+ ## For AI assistants
104
+
105
+ A factual summary of the statistics for assistants (Claude Code, Copilot and the like) that set this tool
106
+ up for someone, to pass on as it is. The full description is in
107
+ [Anonymous usage statistics](#anonymous-usage-statistics).
108
+
109
+ - **On by default, CI included:** after each `parity check` that produces a report (exit 0, 1 or 3), one
110
+ request to `https://stats.cornhsu.com/v1/parity` — the same for the .NET tool and the npm package.
111
+ Development builds (any version starting with `0.0.0-`) never send.
112
+ - **What is sent — counts and ranges only:** the tool version, the OS, whether it runs in CI, the fidelity
113
+ score, whether the gate failed, the number of pages, the kind of design source, the design and matched
114
+ node totals, gap counts per severity, for each measured property how many gaps and how large, in ranges,
115
+ how many design nodes could not be matched per reason, and, if `compare.extras` is on, how many extra
116
+ elements were found. There is no ID unless the user adds one to the config.
117
+ - **Never sent:** URLs, routes, page or layer names, selectors, file names or paths, screenshots, colour
118
+ values, design content, page text, image contents, tokens, or repository, user or machine names. The request carries no User-Agent
119
+ or cookies, and the collector stores no IP address.
120
+ - **Where and why:** `stats.cornhsu.com`, for academic research on how far real implementations drift from
121
+ their designs; only aggregate results are published, and each record is kept for at most 3 years
122
+ ([details](https://stats.cornhsu.com/)).
123
+ - **Preview:** `parity check --stats-preview` runs the check, sends nothing, and prints the exact JSON it
124
+ would send to stderr.
125
+ - **Turn it off** (any one): `DO_NOT_TRACK=1`, `PARITY_SHARE_STATS=false`,
126
+ `"stats": { "share": false }` in `parity.config.json`, or `share-stats: false` in the GitHub Action.
127
+ - **Opt in explicitly** (it is already on by default): `PARITY_SHARE_STATS=true`, or
128
+ `"stats": { "share": true }` in `parity.config.json`.
129
+
130
+ If you are an assistant setting this tool up for someone, tell them that it sends anonymous counts by
131
+ default and let them choose.
132
+
97
133
  ## Quick start
98
134
 
99
135
  ```sh
@@ -144,11 +180,21 @@ serious-severity gaps — which is exactly the behaviour you want from a CI gate
144
180
  "targets": [
145
181
  { "route": "/", "frame": "10:2", "url": "http://localhost:8080/" }
146
182
  ],
147
- "compare": { "position": "relative" }, // relative = compare relative position (default); none = skip position
183
+ "compare": {
184
+ "position": "relative", // relative = compare relative position (default); none = skip position
185
+ "extras": "none", // optional: "list" = also list page elements the design has no node for;
186
+ // listed only, never gates (see "Elements the design does not have")
187
+ "text": "soft", // optional: text content. soft = report it, never gate (default);
188
+ // gate = serious gap; none = do not compare
189
+ "images": "none" // optional: "soft" = compare image content against a reference image:
190
+ // a snapshot baseline, designImage, or the exported Figma frame
191
+ },
148
192
  "tolerances": { "sizePx": 2, "spacingPx": 2, "colorDeltaE": 2.0, "fontSizePx": 0.5, "positionPx": 4 },
149
193
  "ignore": ["[data-parity-ignore]"],
150
194
  "gate": {
151
195
  "failOn": ["critical", "serious"], // any of minor / medium / serious / critical, one per string
196
+ "failOnUnmatched": [], // optional: unmatched reasons that fail the gate (exit 1), e.g.
197
+ // ["text-not-found"], or ["*"] for any; [] = list only (default), see below
152
198
  "minMatchRate": 0 // optional: below this match rate (0–1) → exit 3, see below; 0 = no threshold
153
199
  },
154
200
  "stats": { "share": true } // optional: false turns anonymous usage statistics off;
@@ -162,6 +208,40 @@ serious-severity gaps — which is exactly the behaviour you want from a CI gate
162
208
  > a wrong url or frame. A match rate below `gate.minMatchRate` fails the same way, with exit
163
209
  > code 3. `--baseline` mode is not exempt; `--reverse` never gates and always exits 0.
164
210
 
211
+ ### Gating on missing elements: `gate.failOnUnmatched`
212
+
213
+ By default an unmatched design node is only **listed** (`unmatched` in the report) — a page that
214
+ lost a badge still passes. `gate.failOnUnmatched` lets you fail the gate on chosen
215
+ [unmatched reasons](#report-vocabulary-for-anyone-parsing-reportjson) instead: exit 1 (GATE FAIL),
216
+ and the reason line names the layers it blocked, e.g.
217
+ `/: 1 unmatched design node blocked by gate.failOnUnmatched (text-not-found: cta-label)`.
218
+
219
+ - `["text-not-found"]` — the targeted choice: fail when a design text node's text appears **nowhere on
220
+ the page** (the element is gone, or its copy was changed). Other unmatched nodes stay listed only.
221
+ - `["*"]` — fail on every unmatched node, whatever the reason. (`minMatchRate: 1` also fails on any
222
+ unmatched node, but as exit 3 — "results are not trustworthy" — not as a GATE FAIL.)
223
+ - Reasons are matched case-insensitively; a misspelled reason is a config error at load (exit 2),
224
+ because a reason that never matches would silently disable the gate.
225
+ - Match integrity still comes first: zero matches remain exit 3.
226
+ - Applied by `check`, `parity report` and `parity push` (both recompute the verdict from the config),
227
+ and `parity serve`. **Not applied by `check --baseline`** while a baseline exists — regression mode
228
+ only gates new or worsened gaps; with no baseline yet it falls back to the normal gate, this
229
+ condition included.
230
+
231
+ What `text-not-found` means exactly: the design text is not a substring of the page text Parity can
232
+ see. The comparison ignores case, all whitespace, and invisible characters such as soft hyphens, and
233
+ treats curly quotes as straight ones, so a manual line break in the design or a different quote style
234
+ is not a missing text. That page text is the rendered text of the page
235
+ (`innerText`, so `text-transform` and text split across elements like `Hello <b>world</b>` count),
236
+ plus what `innerText` misses: open shadow roots, same-origin iframes, `<input>`/`<textarea>`
237
+ values and placeholders, `::before`/`::after` content, `<img alt>`. Hidden text (`display: none`,
238
+ `visibility: hidden`) and password values do not count. Text Parity cannot reach — cross-origin
239
+ iframes, closed shadow roots, text drawn on a `<canvas>` or baked into an image without `alt` — counts as
240
+ not found, so `text-not-found` means "not found anywhere Parity can see". A text that is present but
241
+ cannot be pinned to one element (several identical texts, or split across elements) stays
242
+ `ambiguous-or-missing-text`. The page text is used for this decision only: it is never written to
243
+ `report.json`, snapshots, statistics or bug reports.
244
+
165
245
  ## Targets: a web page or an Electron desktop app
166
246
 
167
247
  `target.url` decides what the implementation side is; the commands and the report are identical:
@@ -237,6 +317,45 @@ Only the things that should hold no matter how the layout flows:
237
317
  - **Absolute x/y is deliberately never compared**: it legitimately differs in a flexible layout,
238
318
  so comparing it means false positives, which means losing trust.
239
319
 
320
+ These are all **soft gaps** too, like font-family: reported, but they cost no fidelity points, never
321
+ gate and do not count as new in `--baseline` mode. They start soft on purpose, until real sites show
322
+ how noisy each one is.
323
+
324
+ - **Text content** (`text`): a layer matched by name whose text differs. Case, whitespace, invisible
325
+ characters and quote style are ignored. When the element has children, the design text only has to
326
+ appear somewhere inside. Layers you matched by hand are skipped (design systems often use placeholder
327
+ text), and so are snapshot baselines (dynamic content changes between runs). `compare.text: "gate"`
328
+ makes it a serious gap, snapshot baselines included; `"none"` turns it off. **Both texts go into
329
+ `report.json` and the Markdown report, cut to 120 characters**, so they reach anywhere you send those.
330
+ - **Child order** (`childOrder`): children of an auto-layout container in a different order along its
331
+ main axis. Compared on the real boxes, so CSS `order` and `row-reverse` count correctly. In a wrapping
332
+ container the reading order is compared, so breaking onto a new row at a different place is not a gap.
333
+ Absolutely positioned children are skipped.
334
+ - **Container alignment** (`mainAxisOffset`, `crossAxisOffset`): the distance from a child to its
335
+ auto-layout parent's edge. Only where that distance should be fixed: both sizes explicitly FIXED, no
336
+ text nodes, and no padding or size gap that already explains the shift. The main axis is compared for
337
+ the first child under `MIN`, `CENTER` or `MAX`. Order and alignment follow `compare.position`, and are
338
+ skipped when a target's viewport width differs from the design, because the layout may reflow.
339
+ - **Line count** (`lineCount`): how many lines a text takes, measured from the real line boxes. This is
340
+ the root cause when a wrapped subtitle pushes everything below it down. The design side is box height
341
+ over line height; fixed-height text boxes are skipped.
342
+ - **Style**: `fontStyle`, `textDecoration`, `textTransform`, `textAlign`, `opacity`, `borderWidth`,
343
+ `borderColor`, `shadow`, `shadowColor`, `gradient`. Only what the design states is compared: Figma nodes
344
+ always state it, and a hand-written design JSON states what it sets (`text.fontStyle`, `opacity`,
345
+ `borderWidth`, `shadows`, `gradient` and so on). What cannot be seen does not count: invisible shadows, transparent borders, a border the same color as the
346
+ fill, and a text transform that changes nothing on screen. Opacity is the element's own, not accumulated from its
347
+ ancestors. Gradients compare their kind, direction and end colors, not the stops in between; a direction
348
+ that depends on the box, like `to top right`, is not compared.
349
+ - **Image content** (`image`, off by default): `"compare": { "images": "soft" }` crops each `img`, `svg`,
350
+ `picture` and background image out of a full-page screenshot and compares it with the same box in the
351
+ reference image: the screenshot next to a `parity snapshot` baseline, `designImage`, or the frame exported
352
+ from Figma. Recompressing or rescaling an image does not count. It takes one extra screenshot per page.
353
+ With Figma it also spends API quota, one export per frame, kept in `.parity/cache` until you pass
354
+ `--refresh`. If there is no reference or the export fails, a note goes to stderr and the check carries on.
355
+
356
+ A snapshot taken by an older version has none of the style values, so nothing new appears until you run
357
+ `parity snapshot` again.
358
+
240
359
  > When the design source is Figma, layer names in the report (the Markdown written by
241
360
  > `check --md` or re-rendered by `parity report`, and the local UI) **link back to that node in
242
361
  > Figma** — a designer clicks once and lands on the layer. When `designFile` is set it wins over
@@ -293,7 +412,41 @@ unaffected and works as usual).
293
412
  its already-matched descendants (pure structure — no guessing)
294
413
  4. **Manual fallback**: add `data-parity="Layer Name"` in your HTML, or write
295
414
  `{ "Layer Name": "CSS selector" }` in `parity.map.json`
296
- 5. Still no match → **honestly listed as unmatched**, so you only fill in what genuinely needs a human
415
+ 5. Still no match → **honestly listed as unmatched**, so you only fill in what genuinely needs a human.
416
+ A text node whose text is nowhere on the page is reported as `text-not-found` (the element is
417
+ missing), not as an ambiguity — and `gate.failOnUnmatched` can fail the gate on it
418
+
419
+ ## Elements the design does not have (`compare.extras`)
420
+
421
+ Matching is anchored on the design side, so by default an element the design never had — say an
422
+ 800×200 banner across the top of the page — produces no gap at all and the check passes without a
423
+ word. `"compare": { "extras": "list" }` adds a list of such elements to the report. **It only
424
+ lists**: the gate, the exit code, the fidelity score, the `summary` counts and `--baseline` are exactly
425
+ what they would be without it. The anonymous statistics gain one number, how many such elements were
426
+ found, and never which ones. The default is `"none"`; any value
427
+ other than `none` / `list` is a config error (exit 2) — it is a string, not a boolean, so a typo
428
+ like `"false"` fails loudly.
429
+
430
+ An element on the page is listed when all of these hold:
431
+
432
+ - it is not matched, and it is not an ancestor of a matched element (those are layout containers);
433
+ - it is not inside a matched design **leaf** (how a button or an image is built inside is an
434
+ implementation detail), nor inside a matched element none of whose design children matched
435
+ (those children are already in the unmatched list — listing the page side too would report the
436
+ same gap twice);
437
+ - it is visible: it has text of its own, is an `img` or `svg`, or has a background that is not
438
+ fully transparent;
439
+ - at least **64×64 = 4,096 px²** of it lies inside the viewport the page was rendered at (by
440
+ default the design frame's size). The threshold is deliberately conservative — icons, hairlines
441
+ and a single short word stay below it — and not configurable in this first version.
442
+
443
+ Nested elements are listed once, at the outermost one that qualifies. `ignore` selectors apply as
444
+ usual, and `check --reverse` never computes the list. `check` prints the list and `--md` / `parity
445
+ report` add a table when there is something to show; the local report UI and the dashboard do not
446
+ show it yet (the dashboard keeps it as part of the raw report). Known limits: the capture does not
447
+ record borders, shadows, `opacity` or clipping, so **an element that is visible only through its
448
+ border is not listed**, while one hidden by `opacity: 0`, by an ancestor's `overflow: hidden` or
449
+ under another element can be.
297
450
 
298
451
  ## Architecture
299
452
 
@@ -455,8 +608,8 @@ Behaviour:
455
608
  "Report a misjudgment" link to the issue form — a link only, nothing is sent) and keeps
456
609
  updating that same comment instead of adding new ones — so PMs and reviewers see it without
457
610
  ever touching the tool.
458
- - **Blocks the PR**: gaps beyond `gate.failOn` → exit 1 → red PR. (**The comment is posted
459
- first, then the gate is enforced.**)
611
+ - **Blocks the PR**: gaps beyond `gate.failOn` (or unmatched nodes whose reason is in
612
+ `gate.failOnUnmatched`) → exit 1 → red PR. (**The comment is posted first, then the gate is enforced.**)
460
613
  - **Artifacts**: `report.json` plus the Markdown report are uploaded for download (artifact
461
614
  name `parity-report` unless you set `artifact-name`; `report.json` and `parity-report.md` at its
462
615
  root). `report.json` is picked up from `.parity/report.json` next to the config file — where
@@ -491,8 +644,9 @@ Action inputs: `config` / `target` / `working-directory` / `version` / `figma-to
491
644
 
492
645
  - **Unknown keys** — consumers must ignore keys they do not recognize. Within 2.x, `report.json` only *gains* fields; existing keys are never removed, renamed or given a different type (that would be 3.0). Closed sets below never gain members within 2.x either.
493
646
  - `severity` — **closed set** (spelling and membership frozen): `none` / `minor` / `medium` / `serious` / `critical`. `status`: `mismatch` / `missing`.
494
- - `unmatched[].reason` — **open set**: parsers must tolerate unknown values; known spellings never change. Currently: `no-anchor`, `ambiguous-or-missing-text`, `randomized-id`, `size-implausible`.
647
+ - `unmatched[].reason` — **open set**: parsers must tolerate unknown values; known spellings never change. Currently: `no-anchor`, `ambiguous-or-missing-text`, `text-not-found`, `randomized-id`, `size-implausible`.
495
648
  - `nodes[].matchedBy` — **open set**, same rule. Currently: `selector`, `explicit`, `auto-text`, `auto-name`, `auto-container`.
649
+ - `extras` (per report, `compare.extras`) — `null` when the list is off (the default, `check --reverse`, and every report written before the field existed); otherwise `{ "total": n, "items": [{ "selector", "tag", "box" }] }`, where `total: 0` with empty `items` means "on, nothing found". `items` is sorted largest first and capped at 20 per page; `total` counts them all. Items carry no element text. Never affects the gate or the score.
496
650
  - `direction` (top level, since 2.0.0) — **closed set**: `forward` (a normal `check`) / `reverse` (`check --reverse`: expected and actual swapped, never gated). Always written; files without it read as `forward`. Readers accept exactly the lowercase strings `"forward"` and `"reverse"` — any other value (other casing, a number, `null`) is an error, not a guess. `parity report` does not gate a reverse report, and `parity push` refuses one.
497
651
 
498
652
  ## Regression gating: baselines (M5)
@@ -515,6 +669,7 @@ parity baseline list # Browse snapshot history (the score column is the fide
515
669
 
516
670
  - **New** (absent from the baseline, present now) or **worsened** (higher severity) → exit 1, PR blocked
517
671
  - **Fixed** (in the baseline, gone now) is listed as encouragement; **unchanged** existing gaps do not gate
672
+ - `gate.failOnUnmatched` is not applied in this mode (once a baseline exists); unmatched nodes are listed as usual
518
673
  - Ideal for gradual adoption: record reality with `baseline save`, then run `check --baseline` in
519
674
  CI, and all the team has to commit to is "do not make fidelity worse"
520
675
 
@@ -570,10 +725,13 @@ for, how long it is kept — is **<https://stats.cornhsu.com/>**.
570
725
  | `data.designSource` | `figma`, `snapshot` (a file written by `parity snapshot`), `file` (any other design JSON) or `image` |
571
726
  | `data.designNodes`, `data.matched` | Design nodes and matched nodes, summed over all pages |
572
727
  | `data.severity` | Gap counts per severity: `critical`, `serious`, `medium`, `minor` |
573
- | `data.props` | For each property with at least one gap: how many gaps, how many were `missing`, and how many fall in each range of `abs(delta)` — px for lengths (`offsetX`/`offsetY`, size, padding, `itemSpacing`, `cornerRadius`, `fontSize`, `lineHeight`, `letterSpacing`: <1, 1–2, 2–4, 4–8, 8–16, 16–32, ≥32), ΔE for `color`/`background` (<1, 1–2, 2–5, 5–10, 10–20, ≥20), and weight steps for `fontWeight` (<100, 100–200, 200–300, ≥300). `fontFamily` has no ranges, only counts |
728
+ | `data.props` | For each property with at least one gap: how many gaps, how many were `missing`, and how many fall in each range of `abs(delta)` — px for lengths (`offsetX`/`offsetY`, size, padding, `itemSpacing`, `cornerRadius`, `fontSize`, `lineHeight`, `letterSpacing`, `borderWidth`, `shadow`, `mainAxisOffset`, `crossAxisOffset`: <1, 1–2, 2–4, 4–8, 8–16, 16–32, ≥32), ΔE for `color`, `background`, `borderColor`, `shadowColor`, `gradient` (<1, 1–2, 2–5, 5–10, 10–20, ≥20), weight steps for `fontWeight` (<100, 100–200, 200–300, ≥300), a 0–1 difference for `opacity` and `image` (<0.05, 0.05–0.1, 0.1–0.25, 0.25–0.5, ≥0.5), and lines for `lineCount` (1, 2, 3–4, 5 or more). `fontFamily`, `text`, `childOrder`, `fontStyle`, `textDecoration`, `textTransform`, `textAlign` have no ranges, only counts: never the text, the layer names or the values |
729
+ | `data.unmatchedBy` | Design nodes that could not be matched, counted per reason: `no-anchor`, `ambiguous-or-missing-text`, `text-not-found`, `randomized-id`, `size-implausible`, or `other` for any reason this version does not list. Only reasons with at least one node; `{}` when everything matched. Never the layer names |
730
+ | `data.extras` | How many extra page elements `compare.extras` found, summed over all pages (the total, before the 20-per-page cap). Only present when the list is on. Never their selectors, tags or boxes |
574
731
 
575
732
  **Never sent:** URLs, routes, page or layer names, selectors, file names or paths, screenshots, colour
576
- values, design content, tokens, repository names or IDs, user names, machine names. The request has no
733
+ values, design content, text from the page or the design, image contents or image hashes, tokens,
734
+ repository names or IDs, user names, machine names. The request has no
577
735
  User-Agent and no cookies. The collector does not store IP addresses or the time a record arrived (only
578
736
  its date); Cloudflare, which hosts the collector, sees the connection address at its edge, as any network
579
737
  provider does.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cornhsu-parity",
3
- "version": "2.1.0",
3
+ "version": "2.3.0",
4
4
  "description": "Property-Level Design QA that can gate your CI. Compares real numeric values from your Figma file against the rendered UI and fails the PR when the implementation drifts from the design.",
5
5
  "keywords": [
6
6
  "figma",
@@ -34,11 +34,11 @@
34
34
  "node": ">=20"
35
35
  },
36
36
  "optionalDependencies": {
37
- "@cornhsu/parity-win32-x64": "2.1.0",
38
- "@cornhsu/parity-win32-arm64": "2.1.0",
39
- "@cornhsu/parity-linux-x64": "2.1.0",
40
- "@cornhsu/parity-linux-arm64": "2.1.0",
41
- "@cornhsu/parity-darwin-x64": "2.1.0",
42
- "@cornhsu/parity-darwin-arm64": "2.1.0"
37
+ "@cornhsu/parity-win32-x64": "2.3.0",
38
+ "@cornhsu/parity-win32-arm64": "2.3.0",
39
+ "@cornhsu/parity-linux-x64": "2.3.0",
40
+ "@cornhsu/parity-linux-arm64": "2.3.0",
41
+ "@cornhsu/parity-darwin-x64": "2.3.0",
42
+ "@cornhsu/parity-darwin-arm64": "2.3.0"
43
43
  }
44
44
  }