mirror of
https://github.com/zanwei/design-dna.git
synced 2026-09-14 19:59:56 +08:00
Add deterministic color measurement and a verify loop
Perceived colors drift toward familiar palette defaults (a brand pink like #f476b8 reads as #ec4899, dE ~17; a near-black #16151b reads as pure #000000). This adds two optional zero-config scripts and wires them into the skill: - scripts/measure-colors.mjs: deterministic k-means (farthest-point init) over the actual pixels with perceptual dE merging of anti-aliasing/JPEG noise; outputs exact hexes with coverage and background/text/accent roles for design_system.color - scripts/verify.mjs: re-measures the generated implementation and reports per-color dE + coverage drift with PASS/FAIL thresholds (mean dE <= 5, max dE <= 20, drift <= 0.35), so the agent can self-correct instead of asking the user to judge by eye - scripts/color-math.mjs: shared sRGB->Lab / dE76 math - SKILL.md: Analyze uses measured hexes verbatim when the reference is an image; Generate ends with a verify step - references/schema.md: optional measured_palette field for traceability - README: Deterministic Measurement section with a before/after example (docs/example-deterministic-measurement.png): rebuilding bun.sh's hero from perceived style fails verify at mean dE 9.54, while the measured rebuild passes at mean dE 0.87 No API keys; the only dependency is sharp (Node >=18.17), installed inside scripts/ and gitignored. package-lock.json is ignored to avoid lockfile churn in a skill repo. Translated READMEs are not updated here; happy to sync them during review.
This commit is contained in:
@@ -5,3 +5,5 @@ Thumbs.db
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
scripts/node_modules/
|
||||
scripts/package-lock.json
|
||||
|
||||
@@ -98,6 +98,26 @@ The DNA JSON is the key artifact. Once extracted, it can be **committed to versi
|
||||
>
|
||||
> **Prompt:** **Against the reference, audit hierarchy, ornamentation, typographic rhythm, motion, materiality, and overall UI—then merge your conclusions back into the current implementation.**
|
||||
|
||||
## Deterministic Measurement (optional)
|
||||
|
||||
LLM color perception drifts toward familiar palette defaults — a brand pink like `#ff90e8` gets "seen" as `#ec4899` (ΔE ≈ 29). Two optional scripts make the Analyze and Generate phases measurable:
|
||||
|
||||
```bash
|
||||
cd scripts && npm install && cd ..
|
||||
|
||||
# Analyze: measure the exact palette from a reference screenshot
|
||||
node scripts/measure-colors.mjs reference.png > measured-colors.json
|
||||
|
||||
# Generate: score the implementation screenshot against the reference
|
||||
node scripts/verify.mjs implementation.png measured-colors.json
|
||||
```
|
||||
|
||||
`measure-colors.mjs` runs deterministic k-means clustering over the actual pixels (with perceptual ΔE merging of anti-aliasing noise) and outputs exact hexes with coverage percentages and background/text/accent roles. `verify.mjs` re-measures the generated output and reports per-color ΔE and coverage drift with PASS/FAIL thresholds, giving the agent a self-correction loop instead of relying on the user's eye. The skill instructs agents to use both automatically when references are image files; no API keys required.
|
||||
|
||||
Same reference (bun.sh's hero), same agent — perceived rebuild vs measured rebuild:
|
||||
|
||||

|
||||
|
||||
## Compatibility
|
||||
|
||||
Follows the [Agent Skills specification](https://agentskills.io). Installable via [`skills` CLI](https://github.com/vercel-labs/skills) to all [supported agents](https://github.com/vercel-labs/skills#supported-agents) including Cursor, Claude Code, Codex, GitHub Copilot, and [39 more](https://github.com/vercel-labs/skills#supported-agents).
|
||||
|
||||
@@ -42,7 +42,7 @@ When the user provides images, screenshots, or links representing a target desig
|
||||
|
||||
1. Read [references/schema.md](references/schema.md) for the full field list
|
||||
2. For each reference provided:
|
||||
- If image/screenshot: analyze visual properties directly
|
||||
- If image/screenshot: **first run the deterministic color measurement** (see below), then analyze the remaining visual properties directly
|
||||
- If URL: fetch and analyze the page's visual design
|
||||
3. For every field in the schema, extract or infer a value from the references
|
||||
4. When multiple references conflict, note the dominant pattern and mention variants
|
||||
@@ -52,7 +52,12 @@ When the user provides images, screenshots, or links representing a target desig
|
||||
**Analysis approach per dimension:**
|
||||
|
||||
#### Dimension 1: design_system
|
||||
- **color**: Extract dominant palette via visual sampling. Primary by area dominance, secondary by supporting role, accent by CTA usage. Map neutral scale from lightest background to darkest text.
|
||||
- **color**: Do not estimate hex values by eye — perceived colors drift toward familiar palette defaults (often by a ΔE of 10+). When the reference is an image file, measure instead:
|
||||
```bash
|
||||
cd scripts && npm install --silent && cd ..
|
||||
node scripts/measure-colors.mjs reference.png > measured-colors.json
|
||||
```
|
||||
Use the measured hexes verbatim in the DNA JSON: map the `background` role to `surface.background`, `text` to the darkest neutral, `accent` to `accent.hex`, and keep the full measured palette (with coverage percentages) in `design_system.color.measured_palette` for traceability. Only fall back to visual sampling when a measurement is impossible (e.g. URL-only references you cannot screenshot). Primary by area dominance, secondary by supporting role, accent by CTA usage. Map neutral scale from lightest background to darkest text.
|
||||
- **typography**: Identify font families by visual characteristics (geometric, humanist, serif class). Estimate scale ratios from heading/body size relationships.
|
||||
- **spacing**: Assess density by element proximity. Measure rhythm by section gap consistency.
|
||||
- **layout**: Identify grid by content alignment patterns. Note max-width, column count, asymmetry.
|
||||
@@ -87,6 +92,11 @@ When the user provides DNA JSON + content to design:
|
||||
- Heavy effects → Three.js, custom GLSL shaders, Pixi.js
|
||||
7. Generate the design output (default: self-contained HTML with inline CSS/JS)
|
||||
8. Run quality checks from the generation guide
|
||||
9. **Verify (when the reference was an image)**: screenshot the generated output, then score it against the measured reference palette:
|
||||
```bash
|
||||
node scripts/verify.mjs implementation.png measured-colors.json
|
||||
```
|
||||
The report gives per-color ΔE and coverage drift with PASS/FAIL thresholds. If it fails, fix the offending colors and re-verify instead of asking the user to judge fidelity by eye.
|
||||
|
||||
**If the user provides only content without DNA JSON**, ask whether to:
|
||||
- Analyze a reference first (go to Phase 2)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 554 KiB |
@@ -36,6 +36,7 @@ The structural and measurable layer.
|
||||
- `surface.card`
|
||||
- `surface.elevated`
|
||||
- `contrast_strategy`
|
||||
- `measured_palette` *(optional)* — when the reference was an image measured with `scripts/measure-colors.mjs`, the full measured palette (`hex`, `coverage`, `role` per entry) for traceability
|
||||
|
||||
#### `design_system.typography`
|
||||
- `type_scale.display.size`
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Shared color math for the measurement scripts.
|
||||
|
||||
export function srgbToLab([r, g, b]) {
|
||||
const f = (v) => {
|
||||
v /= 255;
|
||||
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
const [R, G, B] = [f(r), f(g), f(b)];
|
||||
let x = (R * 0.4124 + G * 0.3576 + B * 0.1805) / 0.95047;
|
||||
let y = R * 0.2126 + G * 0.7152 + B * 0.0722;
|
||||
let z = (R * 0.0193 + G * 0.1192 + B * 0.9505) / 1.08883;
|
||||
const t = (v) => (v > 0.008856 ? Math.cbrt(v) : 7.787 * v + 16 / 116);
|
||||
[x, y, z] = [t(x), t(y), t(z)];
|
||||
return [116 * y - 16, 500 * (x - y), 200 * (y - z)];
|
||||
}
|
||||
|
||||
export function deltaE(a, b) {
|
||||
const [L1, A1, B1] = srgbToLab(a);
|
||||
const [L2, A2, B2] = srgbToLab(b);
|
||||
return Math.hypot(L1 - L2, A1 - A2, B1 - B2);
|
||||
}
|
||||
|
||||
export function hex([r, g, b]) {
|
||||
return (
|
||||
"#" + [r, g, b].map((v) => Math.round(v).toString(16).padStart(2, "0")).join("")
|
||||
);
|
||||
}
|
||||
|
||||
export function parseHex(s) {
|
||||
return [1, 3, 5].map((i) => parseInt(s.slice(i, i + 2), 16));
|
||||
}
|
||||
|
||||
// Hue (degrees), HSV saturation, and HSL lightness. HSV saturation is used for
|
||||
// accent detection because HSL saturation blows up for near-white colors.
|
||||
export function hsv([r, g, b]) {
|
||||
(r /= 255), (g /= 255), (b /= 255);
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
const d = max - min;
|
||||
const s = max === 0 ? 0 : d / max;
|
||||
const l = (max + min) / 2;
|
||||
let h = 0;
|
||||
if (d !== 0) {
|
||||
if (max === r) h = 60 * (((g - b) / d) % 6);
|
||||
else if (max === g) h = 60 * ((b - r) / d + 2);
|
||||
else h = 60 * ((r - g) / d + 4);
|
||||
}
|
||||
return [(h + 360) % 360, s, l];
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
// Deterministic color measurement for the design-dna skill.
|
||||
//
|
||||
// Instead of perceiving colors from a screenshot (which drifts toward familiar
|
||||
// palette defaults), this measures them: k-means clustering over the actual
|
||||
// pixels, perceptual (CIE ΔE) merging of near-duplicate clusters, and coverage
|
||||
// percentages. The output is meant to be merged into `design_system.color` of
|
||||
// a Design DNA JSON — exact hexes with evidence, not guesses.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/measure-colors.mjs <screenshot.(png|jpg|webp)> [--k 8]
|
||||
//
|
||||
// Output (stdout): JSON
|
||||
// {
|
||||
// "source": { "file", "width", "height" },
|
||||
// "palette": [ { "hex", "coverage", "role" } ... ],
|
||||
// "measured": true
|
||||
// }
|
||||
|
||||
import sharp from "sharp";
|
||||
import { basename, extname } from "node:path";
|
||||
import { deltaE, hex, hsv } from "./color-math.mjs";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const file = args.find((a) => !a.startsWith("--"));
|
||||
if (!file) {
|
||||
console.error("usage: node scripts/measure-colors.mjs <image> [--k 8]");
|
||||
process.exit(1);
|
||||
}
|
||||
const kIdx = args.indexOf("--k");
|
||||
const K = kIdx >= 0 ? Math.max(2, Math.min(16, Number(args[kIdx + 1]) || 8)) : 8;
|
||||
|
||||
// ---------- k-means ----------
|
||||
|
||||
function kmeans(pixels, k, iters = 24) {
|
||||
// deterministic farthest-point init: start from the darkest pixel, then
|
||||
// repeatedly add the pixel farthest from its nearest existing center, so
|
||||
// small but distinct color regions get their own cluster
|
||||
const sorted = [...pixels].sort(
|
||||
(a, b) => a[0] * 3 + a[1] * 6 + a[2] - (b[0] * 3 + b[1] * 6 + b[2])
|
||||
);
|
||||
const centers = [[...sorted[0]]];
|
||||
const nearest = new Array(pixels.length).fill(Infinity);
|
||||
while (centers.length < k) {
|
||||
const last = centers[centers.length - 1];
|
||||
let far = 0, fd = -1;
|
||||
for (let p = 0; p < pixels.length; p++) {
|
||||
const dx = pixels[p][0] - last[0];
|
||||
const dy = pixels[p][1] - last[1];
|
||||
const dz = pixels[p][2] - last[2];
|
||||
const d = dx * dx + dy * dy + dz * dz;
|
||||
if (d < nearest[p]) nearest[p] = d;
|
||||
if (nearest[p] > fd) (fd = nearest[p]), (far = p);
|
||||
}
|
||||
if (fd <= 0) break; // fewer distinct colors than k
|
||||
centers.push([...pixels[far]]);
|
||||
}
|
||||
k = centers.length;
|
||||
const assign = new Array(pixels.length).fill(0);
|
||||
for (let it = 0; it < iters; it++) {
|
||||
let moved = false;
|
||||
for (let p = 0; p < pixels.length; p++) {
|
||||
let best = 0, bd = Infinity;
|
||||
for (let c = 0; c < k; c++) {
|
||||
const dx = pixels[p][0] - centers[c][0];
|
||||
const dy = pixels[p][1] - centers[c][1];
|
||||
const dz = pixels[p][2] - centers[c][2];
|
||||
const d = dx * dx + dy * dy + dz * dz;
|
||||
if (d < bd) (bd = d), (best = c);
|
||||
}
|
||||
if (assign[p] !== best) (assign[p] = best), (moved = true);
|
||||
}
|
||||
const sums = Array.from({ length: k }, () => [0, 0, 0, 0]);
|
||||
for (let p = 0; p < pixels.length; p++) {
|
||||
const s = sums[assign[p]];
|
||||
s[0] += pixels[p][0]; s[1] += pixels[p][1]; s[2] += pixels[p][2]; s[3]++;
|
||||
}
|
||||
for (let c = 0; c < k; c++) {
|
||||
if (sums[c][3] > 0) {
|
||||
centers[c] = [sums[c][0] / sums[c][3], sums[c][1] / sums[c][3], sums[c][2] / sums[c][3]];
|
||||
}
|
||||
}
|
||||
if (!moved) break;
|
||||
}
|
||||
const counts = new Array(k).fill(0);
|
||||
for (const a of assign) counts[a]++;
|
||||
return centers
|
||||
.map((center, i) => ({ center, share: counts[i] / pixels.length }))
|
||||
.filter((c) => c.share > 0)
|
||||
.sort((a, b) => b.share - a.share);
|
||||
}
|
||||
|
||||
// merge perceptually-identical clusters (anti-aliasing / jpeg noise)
|
||||
function mergeSimilar(clusters, maxDE = 2.5) {
|
||||
const merged = [];
|
||||
for (const c of clusters) {
|
||||
const near = merged.find((m) => deltaE(m.center, c.center) <= maxDE);
|
||||
if (near) {
|
||||
const total = near.share + c.share;
|
||||
near.center = near.center.map(
|
||||
(v, i) => (v * near.share + c.center[i] * c.share) / total
|
||||
);
|
||||
near.share = total;
|
||||
} else {
|
||||
merged.push({ center: [...c.center], share: c.share });
|
||||
}
|
||||
}
|
||||
return merged.sort((a, b) => b.share - a.share);
|
||||
}
|
||||
|
||||
// ---------- role assignment ----------
|
||||
|
||||
function assignRoles(clusters) {
|
||||
const entries = clusters.map(({ center, share }) => {
|
||||
const [h, s, l] = hsv(center);
|
||||
return { center, share, h, s, l, role: "unassigned" };
|
||||
});
|
||||
const taken = new Set();
|
||||
// background: largest coverage
|
||||
entries[0].role = "background";
|
||||
taken.add(0);
|
||||
const bgL = entries[0].l;
|
||||
// text: strongest lightness contrast vs background with meaningful coverage
|
||||
let text = -1, bestC = 0;
|
||||
entries.forEach((e, i) => {
|
||||
if (taken.has(i)) return;
|
||||
const c = Math.abs(e.l - bgL);
|
||||
if (e.share >= 0.005 && c > bestC) (bestC = c), (text = i);
|
||||
});
|
||||
if (text >= 0 && bestC > 0.25) {
|
||||
entries[text].role = "text";
|
||||
taken.add(text);
|
||||
}
|
||||
// accent: most saturated remaining color with ≥0.2% coverage; near-white and
|
||||
// near-black clusters are excluded — they are surfaces/ink, not accents
|
||||
const accents = entries
|
||||
.map((e, i) => ({ e, i }))
|
||||
.filter(
|
||||
({ e, i }) =>
|
||||
!taken.has(i) && e.s >= 0.25 && e.share >= 0.002 && e.l >= 0.08 && e.l <= 0.92
|
||||
)
|
||||
.sort((a, b) => b.e.s * Math.sqrt(b.e.share) - a.e.s * Math.sqrt(a.e.share));
|
||||
if (accents.length > 0) {
|
||||
entries[accents[0].i].role = "accent";
|
||||
taken.add(accents[0].i);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ---------- main ----------
|
||||
|
||||
const img = sharp(file).flatten({ background: "#ffffff" }).toColourspace("srgb");
|
||||
let width, height;
|
||||
try {
|
||||
({ width, height } = await img.metadata());
|
||||
} catch (err) {
|
||||
console.error(`error: cannot read ${file}: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const MAX = 400; // downsample for clustering speed; colors are unaffected
|
||||
const scale = Math.min(1, MAX / Math.max(width, height));
|
||||
const w = Math.max(1, Math.round(width * scale));
|
||||
const h = Math.max(1, Math.round(height * scale));
|
||||
const raw = await img.resize(w, h, { kernel: "nearest" }).raw().toBuffer();
|
||||
|
||||
const pixels = [];
|
||||
for (let i = 0; i + 2 < raw.length; i += 3) {
|
||||
pixels.push([raw[i], raw[i + 1], raw[i + 2]]);
|
||||
}
|
||||
|
||||
// JPEG compression spreads flat colors into wider noise bands than PNG/WebP
|
||||
const isJpeg = [".jpg", ".jpeg"].includes(extname(file).toLowerCase());
|
||||
const palette = assignRoles(mergeSimilar(kmeans(pixels, K), isJpeg ? 5 : 2.5));
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
source: { file: basename(file), width, height },
|
||||
palette: palette.map((p) => ({
|
||||
hex: hex(p.center),
|
||||
coverage: Number(p.share.toFixed(4)),
|
||||
role: p.role,
|
||||
})),
|
||||
measured: true,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "design-dna-scripts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
},
|
||||
"description": "Optional deterministic measurement scripts for the design-dna skill",
|
||||
"dependencies": {
|
||||
"sharp": "^0.33.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env node
|
||||
// Verify loop for the design-dna skill.
|
||||
//
|
||||
// After generating an implementation from a Design DNA JSON, screenshot the
|
||||
// result and score it against the reference measurement. This turns "does it
|
||||
// look right?" into a number the agent can iterate on.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/measure-colors.mjs reference.png > measured.json
|
||||
// node scripts/verify.mjs implementation.png measured.json
|
||||
//
|
||||
// Output (stdout): JSON report with per-color ΔE and coverage drift, plus
|
||||
// PASS/FAIL on stderr. Exit code 0 = pass, 2 = fail.
|
||||
//
|
||||
// Thresholds: mean ΔE ≤ 5, max ΔE ≤ 20, coverage drift ≤ 0.35.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { deltaE, parseHex } from "./color-math.mjs";
|
||||
|
||||
const [imgFile, specFile] = process.argv.slice(2);
|
||||
if (!imgFile || !specFile) {
|
||||
console.error("usage: node scripts/verify.mjs <implementation.png> <measured.json>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const spec = JSON.parse(readFileSync(specFile, "utf8"));
|
||||
const specPalette = spec.palette ?? spec.design_system?.color?.measured_palette;
|
||||
if (!Array.isArray(specPalette)) {
|
||||
console.error("measured.json must contain a `palette` array (from measure-colors.mjs)");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// re-measure the implementation with the same deterministic pipeline
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const out = execFileSync(
|
||||
process.execPath,
|
||||
[join(here, "measure-colors.mjs"), imgFile, "--k", String(Math.min(16, Math.max(specPalette.length + 4, 8)))],
|
||||
{ encoding: "utf8" }
|
||||
);
|
||||
const impl = JSON.parse(out);
|
||||
|
||||
const dE = (a, b) => deltaE(parseHex(a), parseHex(b));
|
||||
|
||||
// Partition the implementation's clusters by their nearest spec color, so a
|
||||
// spec color that re-measures as several nearby clusters is credited with
|
||||
// their combined coverage instead of a single nearest match.
|
||||
const assigned = specPalette.map(() => []);
|
||||
for (const c of impl.palette) {
|
||||
let best = 0, bd = Infinity;
|
||||
specPalette.forEach((s, i) => {
|
||||
const d = dE(s.hex, c.hex);
|
||||
if (d < bd) (bd = d), (best = i);
|
||||
});
|
||||
assigned[best].push({ ...c, deltaE: bd });
|
||||
}
|
||||
|
||||
const entries = specPalette.map((s, i) => {
|
||||
const group = assigned[i];
|
||||
const cov = group.reduce((t, g) => t + g.coverage, 0);
|
||||
let de, nearest;
|
||||
if (group.length > 0 && cov > 0) {
|
||||
de = group.reduce((t, g) => t + g.deltaE * g.coverage, 0) / cov;
|
||||
nearest = group.sort((a, b) => a.deltaE - b.deltaE)[0].hex;
|
||||
} else {
|
||||
let bd = Infinity;
|
||||
for (const c of impl.palette) {
|
||||
const d = dE(s.hex, c.hex);
|
||||
if (d < bd) (bd = d), (nearest = c.hex);
|
||||
}
|
||||
de = bd;
|
||||
}
|
||||
return {
|
||||
specHex: s.hex,
|
||||
role: s.role,
|
||||
nearestImageHex: nearest,
|
||||
deltaE: Number(de.toFixed(2)),
|
||||
specCoverage: s.coverage,
|
||||
imageCoverage: Number(cov.toFixed(4)),
|
||||
};
|
||||
});
|
||||
|
||||
// coverage-weighted mean ΔE + coverage drift
|
||||
let meanDE = 0, drift = 0, wsum = 0;
|
||||
for (const e of entries) {
|
||||
meanDE += e.deltaE * e.specCoverage;
|
||||
drift += Math.abs(e.specCoverage - e.imageCoverage);
|
||||
wsum += e.specCoverage;
|
||||
}
|
||||
meanDE = wsum > 0 ? meanDE / wsum : 0;
|
||||
// max ΔE considers only colors with meaningful coverage (≥0.5%) so a stray
|
||||
// sub-percent cluster can't fail an otherwise faithful implementation
|
||||
const significant = entries.filter((e) => e.specCoverage >= 0.005);
|
||||
const maxDE = Math.max(...(significant.length ? significant : entries).map((e) => e.deltaE));
|
||||
|
||||
const pass = meanDE <= 5 && maxDE <= 20 && drift <= 0.35;
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
implementation: imgFile,
|
||||
reference: specFile,
|
||||
entries,
|
||||
meanDeltaE: Number(meanDE.toFixed(2)),
|
||||
maxDeltaE: Number(maxDE.toFixed(2)),
|
||||
coverageDrift: Number(drift.toFixed(2)),
|
||||
thresholds: { meanDeltaE: 5, maxDeltaE: 20, coverageDrift: 0.35 },
|
||||
pass,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
console.error(
|
||||
`${pass ? "PASS" : "FAIL"} — mean ΔE ${meanDE.toFixed(2)}, max ΔE ${maxDE.toFixed(2)}, coverage drift ${drift.toFixed(2)}`
|
||||
);
|
||||
process.exit(pass ? 0 : 2);
|
||||
Reference in New Issue
Block a user