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:
minjishang4566
2026-08-12 01:59:29 +00:00
parent 9d9d79568d
commit a8a08d45ee
9 changed files with 402 additions and 2 deletions
+2
View File
@@ -5,3 +5,5 @@ Thumbs.db
.vscode/
*.swp
*.swo
scripts/node_modules/
scripts/package-lock.json
+20
View File
@@ -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:
![Example: rebuilding the bun.sh hero from perceived style vs measured tokens. The measured rebuild reproduces every token (verify PASS, mean ΔE 0.87); the perceived rebuild drifts the near-black background to #000000 and the brand pink to the familiar #ec4899 (FAIL, mean ΔE 9.54).](docs/example-deterministic-measurement.png)
## 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).
+12 -2
View File
@@ -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

+1
View File
@@ -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`
+48
View File
@@ -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];
}
+189
View File
@@ -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
)
);
+12
View File
@@ -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"
}
}
+118
View File
@@ -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);