From 2b3d48040605d684716b7a82be613faeaa040416 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 21:11:16 +0000 Subject: [PATCH 1/3] fix(uicheck): exit 1 when contrast fails AA; parse rgb/hsl/oklch and alpha `forge uicheck contrast '#777' '#fff'` printed FAILS AA and exited 0, so no script or CI step could gate on it. It also rejected every color that was not #rgb/#rrggbb, including oklch(), Tailwind v4's default palette syntax. - contrast (and the bare legacy `uicheck `) exit 1 when AA fails. - --large grades against the 3:1 large-text / UI bar; --json prints the full report (ratio, level, thresholds, the colors compared, notes). - parseColor() reads hex with an optional alpha pair, rgb()/rgba(), hsl()/hsla(), oklch(), oklab() and black/white/transparent, in legacy comma and modern `/ alpha` syntax. It throws on anything else. - A translucent foreground is composited over the background (and a translucent background over white) before measuring, quantized to the 8-bit color that is actually painted. Notes say when that happened. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UUhB8JaPayd43w37dxiXrW Signed-off-by: Claude --- CHANGELOG.md | 8 ++ docs/GUIDE.md | 19 +++- src/cli.js | 44 ++++++-- src/uicheck.js | 255 +++++++++++++++++++++++++++++++++++++++---- test/uicheck.test.js | 142 +++++++++++++++++++++++- 5 files changed, 434 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc97f4ec..d58d344b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed + +- **`forge uicheck contrast` exits 1 when a pair fails WCAG AA.** Before, it printed + `FAILS AA` and still exited 0. The bare `forge uicheck ` form gates the same way. + `--large` applies the 3:1 large-text / UI bar and `--json` prints the full report. Colors + may be `rgb()`, `hsl()`, `oklch()`, `oklab()` or hex with an alpha pair, not only + `#rrggbb`. A translucent foreground is composited over the background before measuring. + ## [1.3.0] - 2026-09-23 ### Added diff --git a/docs/GUIDE.md b/docs/GUIDE.md index b6d6aa9c..e9bf11a8 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -1140,12 +1140,25 @@ tree (your uncommitted changes wouldn't be in the run); commit/stash first or pa Five subcommands: three are static parsing — no LLM, no screenshots — and `visual` and `interact` optionally drive a real browser. -**`contrast `** — exact WCAG math, asserted, never guessed (bare -`forge uicheck ` still works): +**`contrast [--large] [--json]`** — exact WCAG math, asserted, never guessed +(bare `forge uicheck ` still works). It **exits 1 when the pair fails AA**, so a +script or CI step can gate on it. `--large` grades against the large-text / UI-component +bar (AA 3:1) instead of normal text (AA 4.5:1); `--json` prints the ratio, level, both +thresholds and the colors actually compared. Colors may be `#rgb`/`#rrggbb` with or +without an alpha digit pair, `rgb()`, `hsl()`, `oklch()` (Tailwind v4's default palette +syntax), `oklab()`, or `black`/`white`/`transparent`. A translucent foreground is +composited over the background before measuring (a 50% black text paints grey, not black); +a translucent background is composited over white, and the output says so. Quote colors +in the shell: an unquoted `#777` is a comment. ```console $ forge uicheck contrast "#777" "#fff" - contrast #777 on #fff: 4.48:1 → fail (FAILS AA) + contrast #777 on #fff: 4.48:1 → fail (FAILS AA — normal text needs 4.5:1) +$ echo $? +1 +$ forge uicheck contrast "rgb(0 0 0 / 50%)" "#fff" + contrast rgb(0 0 0 / 50%) on #fff: 3.95:1 → fail (FAILS AA — normal text needs 4.5:1) + note: foreground rgb(0 0 0 / 50%) has alpha 0.5 — composited over the background to #808080 ``` **`fingerprint [--mint]`** — the design feature vector of your UI files: diff --git a/src/cli.js b/src/cli.js index b03c8087..fb9e35ec 100755 --- a/src/cli.js +++ b/src/cli.js @@ -2827,24 +2827,46 @@ HANDLERS.uicheck = async (argv) => { if (fail) process.exitCode = 1; return; } - const { contrastRatio, wcagLevel, ASSERTABLE_CHECKS, ADVISORY_ONLY } = await import( - "./uicheck.js" - ); + const { contrastReport, ASSERTABLE_CHECKS, ADVISORY_ONLY } = await import("./uicheck.js"); // `uicheck contrast ` is the named form; bare `uicheck ` stays - // supported (it predates the subcommands and hooks already call it). - const [fg, bg] = sub === "contrast" ? [argv[2], argv[3]] : [argv[1], argv[2]]; - heading(`${BRAND.brand} uicheck — deterministic UI review\n`); + // supported (it predates the subcommands and hooks already call it). Both exit 1 + // when the pair fails AA — a failing contrast must fail the script that asked. + const args = argv.slice(sub === "contrast" ? 2 : 1); + const json = args.includes("--json"); + const large = args.includes("--large"); + const colors = args.filter((a) => !a.startsWith("--")); + if (sub === "contrast" && colors.length !== 2) { + console.error( + `usage: ${BRAND.cli} uicheck contrast [--large] [--json] (colors: #hex[alpha], rgb(), hsl(), oklch(), oklab())`, + ); + process.exitCode = 1; + return; + } + const [fg, bg] = colors; + /** @type {ReturnType|null} */ + let r = null; if (fg && bg) { try { - const g = wcagLevel(contrastRatio(fg, bg)); - console.log( - ` contrast ${fg} on ${bg}: ${g.ratio}:1 → ${g.level}${g.passesAA ? " (passes AA)" : " (FAILS AA)"}`, - ); + r = contrastReport(fg, bg, { large }); } catch (e) { - console.error(` ${e.message}`); + if (json) console.log(JSON.stringify({ error: e.message }, null, 2)); + else console.error(` ${e.message}`); process.exitCode = 1; return; } + if (!r.passesAA) process.exitCode = 1; + if (json) { + console.log(JSON.stringify(r, null, 2)); + return; + } + } + heading(`${BRAND.brand} uicheck — deterministic UI review\n`); + if (r) { + const kind = large ? "large text / UI" : "normal text"; + console.log( + ` contrast ${fg} on ${bg}: ${r.ratio}:1 → ${r.level}${r.passesAA ? ` (passes AA for ${kind})` : ` (FAILS AA — ${kind} needs ${r.required.aa}:1)`}`, + ); + for (const n of r.notes) console.log(` note: ${n}`); } console.log(`\n ASSERT (deterministic): ${ASSERTABLE_CHECKS.map((c) => c.id).join(", ")}`); console.log(` ADVISE (subjective, human-only): ${ADVISORY_ONLY.slice(0, 4).join(", ")} …`); diff --git a/src/uicheck.js b/src/uicheck.js index e73de258..1a4a3fa1 100644 --- a/src/uicheck.js +++ b/src/uicheck.js @@ -3,20 +3,181 @@ // verifier can state them without guessing. WCAG contrast is exact arithmetic — no LLM, no // false positives. The subjective calls stay ADVISORY (see the frontend-verifier calibration). -/** Parse #rgb / #rrggbb → {r,g,b} in 0..255. */ -function parseHex(hex) { - let h = String(hex).trim().replace(/^#/, ""); - if (h.length === 3) - h = h - .split("") - .map((c) => c + c) - .join(""); - if (!/^[0-9a-fA-F]{6}$/.test(h)) throw new Error(`bad hex color: ${hex}`); - return { - r: parseInt(h.slice(0, 2), 16), - g: parseInt(h.slice(2, 4), 16), - b: parseInt(h.slice(4, 6), 16), +// --------------------------------------------------------------------------- +// Color parsing — every CSS syntax a stylesheet (or Tailwind v4's oklch default +// palette) actually uses, normalized to sRGB {r,g,b} in 0..255 plus alpha a in 0..1. +// --------------------------------------------------------------------------- + +/** @typedef {{r:number, g:number, b:number, a:number}} Rgba */ + +const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x)); +const NUM_RE = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i; + +/** A bare CSS number (`none` is 0, per CSS Color 4); NaN when it isn't one. */ +function cssNumber(tok) { + if (tok === "none") return 0; + return NUM_RE.test(tok) ? Number(tok) : Number.NaN; +} + +/** `` or ``: a percentage maps onto `pctScale` (100% → pctScale). */ +function numOrPct(tok, pctScale) { + return tok.endsWith("%") ? (cssNumber(tok.slice(0, -1)) / 100) * pctScale : cssNumber(tok); +} + +/** A CSS `` in degrees (unitless = degrees; deg/grad/rad/turn accepted). */ +function cssHue(tok) { + const m = /^(.*?)(deg|grad|rad|turn)?$/i.exec(tok); + const n = cssNumber(m?.[1] ?? ""); + const unit = (m?.[2] ?? "deg").toLowerCase(); + const deg = + unit === "turn" + ? n * 360 + : unit === "rad" + ? (n * 180) / Math.PI + : unit === "grad" + ? n * 0.9 + : n; + return ((deg % 360) + 360) % 360; +} + +/** Alpha as `` or ``, clamped to 0..1 (absent → opaque). */ +function cssAlpha(tok) { + return tok === undefined ? 1 : clamp(numOrPct(tok, 1), 0, 1); +} + +/** + * The arguments of a color function, in either syntax: legacy commas + * (`rgba(0, 0, 0, .5)`) or modern space-separated with a `/ alpha` tail + * (`rgb(0 0 0 / 50%)`). Null when the shape is wrong. + * @param {string} inner + * @returns {{parts:string[], alpha:string|undefined}|null} + */ +function colorArgs(inner) { + let body = inner.trim(); + let alpha; + const slash = body.split("/"); + if (slash.length > 2) return null; + if (slash.length === 2) { + body = slash[0].trim(); + alpha = slash[1].trim(); + } + const parts = body.includes(",") ? body.split(",").map((p) => p.trim()) : body.split(/\s+/); + if (body.includes(",") && parts.length === 4 && alpha === undefined) alpha = parts.pop(); + if (parts.length !== 3 || parts.some((p) => !p)) return null; + if (alpha !== undefined && (!alpha || /\s/.test(alpha))) return null; + return { parts, alpha }; +} + +/** HSL (h degrees, s/l 0..1) → sRGB 0..255 (CSS Color 4 algorithm). */ +function hslToRgb(h, s, l) { + const f = (n) => { + const k = (n + h / 30) % 12; + const a = s * Math.min(l, 1 - l); + return (l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))) * 255; }; + return { r: f(0), g: f(8), b: f(4) }; +} + +/** OKLab → sRGB 0..255 (Ottosson's matrices; out-of-gamut channels are clipped). */ +function oklabToRgb(L, a, b) { + const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3; + const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3; + const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3; + const lin = [ + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s, + ]; + const [r, g, bl] = lin.map((c) => { + const x = clamp(c, 0, 1); + return (x <= 0.0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055) * 255; + }); + return { r, g, b: bl }; +} + +const NAMED = { + black: { r: 0, g: 0, b: 0, a: 1 }, + white: { r: 255, g: 255, b: 255, a: 1 }, + transparent: { r: 0, g: 0, b: 0, a: 0 }, +}; + +/** + * Parse one CSS color: #rgb / #rgba / #rrggbb / #rrggbbaa (the `#` optional — an + * unquoted `#777` is a shell comment), rgb()/rgba(), hsl()/hsla(), oklch(), oklab(), + * and black/white/transparent. Legacy comma and modern `/ alpha` syntaxes both work. + * Throws on anything else — a contrast verdict must never rest on a guessed color. + * @param {string} input + * @returns {Rgba} channels in 0..255 (floats, not rounded), alpha in 0..1 + */ +export function parseColor(input) { + const raw = String(input).trim(); + const s = raw.toLowerCase(); + const bad = () => + new Error(`bad color: ${input} (expected #hex, rgb(), hsl(), oklch() or oklab())`); + if (Object.hasOwn(NAMED, s)) return { ...NAMED[s] }; + const hex = /^#?([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(s); + if (hex) { + let h = hex[1]; + if (h.length <= 4) h = [...h].map((c) => c + c).join(""); + const byte = (i) => parseInt(h.slice(i, i + 2), 16); + return { r: byte(0), g: byte(2), b: byte(4), a: h.length === 8 ? byte(6) / 255 : 1 }; + } + const fn = /^(rgba?|hsla?|oklch|oklab)\(([^()]*)\)$/.exec(s); + if (!fn) throw bad(); + const args = colorArgs(fn[2]); + if (!args) throw bad(); + const [p0, p1, p2] = args.parts; + const a = cssAlpha(args.alpha); + let rgb; + if (fn[1].startsWith("rgb")) { + rgb = { r: numOrPct(p0, 255), g: numOrPct(p1, 255), b: numOrPct(p2, 255) }; + for (const k of ["r", "g", "b"]) rgb[k] = clamp(rgb[k], 0, 255); + } else if (fn[1].startsWith("hsl")) { + // Modern hsl() accepts bare numbers for s/l; they mean percentages. + const sat = clamp(numOrPct(p1, 100), 0, 100) / 100; + const light = clamp(numOrPct(p2, 100), 0, 100) / 100; + rgb = hslToRgb(cssHue(p0), sat, light); + } else if (fn[1] === "oklch") { + const L = numOrPct(p0, 1); + const C = Math.max(0, numOrPct(p1, 0.4)); // 100% chroma = 0.4 + const H = (cssHue(p2) * Math.PI) / 180; + rgb = oklabToRgb(L, C * Math.cos(H), C * Math.sin(H)); + } else { + rgb = oklabToRgb(numOrPct(p0, 1), numOrPct(p1, 0.4), numOrPct(p2, 0.4)); // 100% a/b = 0.4 + } + if ([rgb.r, rgb.g, rgb.b, a].some((x) => !Number.isFinite(x))) throw bad(); + return { r: rgb.r, g: rgb.g, b: rgb.b, a }; +} + +/** + * Source-over compositing of `top` onto an opaque `bottom` (in sRGB space, which is + * how browsers blend by default). A translucent text color is only as legible as the + * color it actually PAINTS, so contrast is measured on the composite. + * @param {Rgba} top @param {Rgba} bottom + * @returns {Rgba} opaque + */ +export function compositeOver(top, bottom) { + const mix = (t, b) => t * top.a + b * (1 - top.a); + return { r: mix(top.r, bottom.r), g: mix(top.g, bottom.g), b: mix(top.b, bottom.b), a: 1 }; +} + +const WHITE = { r: 255, g: 255, b: 255, a: 1 }; + +/** An opaque color: a translucent one is composited over white (the default canvas). */ +const opaque = (/** @type {Rgba} */ c) => (c.a < 1 ? compositeOver(c, WHITE) : c); + +const asRgba = (c) => (typeof c === "string" ? parseColor(c) : c); + +/** `#rrggbb` of an sRGB color (channels rounded, alpha dropped). */ +export function toHex(color) { + const c = asRgba(color); + return `#${[c.r, c.g, c.b] + .map((x) => + Math.round(clamp(x, 0, 255)) + .toString(16) + .padStart(2, "0"), + ) + .join("")}`; } // WCAG 2.1 sRGB → linear. @@ -25,16 +186,36 @@ const linear = (c) => { return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; }; -/** WCAG relative luminance of a color. */ -export function relativeLuminance(hex) { - const { r, g, b } = parseHex(hex); +/** + * WCAG relative luminance of a color (any syntax parseColor takes, or an Rgba). A + * translucent color is composited over white first. + * @param {string|Rgba} color + */ +export function relativeLuminance(color) { + const { r, g, b } = opaque(asRgba(color)); return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b); } -/** WCAG contrast ratio between two colors (1..21). */ +/** Round channels to the 8-bit values a display actually paints. */ +const quantize = (/** @type {Rgba} */ c) => ({ + r: Math.round(clamp(c.r, 0, 255)), + g: Math.round(clamp(c.g, 0, 255)), + b: Math.round(clamp(c.b, 0, 255)), + a: c.a, +}); + +/** + * WCAG contrast ratio between two colors (1..21). A translucent background is + * composited over white; a translucent foreground over that background. Both are + * measured as the 8-bit colors that get painted, so the ratio always matches the + * `#rrggbb` a report shows for them. + * @param {string|Rgba} fg @param {string|Rgba} bg + */ export function contrastRatio(fg, bg) { - const l1 = relativeLuminance(fg); - const l2 = relativeLuminance(bg); + const back = quantize(opaque(asRgba(bg))); + const front = quantize(compositeOver(asRgba(fg), back)); + const l1 = relativeLuminance(front); + const l2 = relativeLuminance(back); const [hi, lo] = l1 >= l2 ? [l1, l2] : [l2, l1]; return (hi + 0.05) / (lo + 0.05); } @@ -54,6 +235,42 @@ export function wcagLevel(ratio, { large = false } = {}) { }; } +/** + * The whole contrast verdict for one pair — what `uicheck contrast` prints (and emits + * under --json). `fgHex`/`bgHex` are the colors actually compared: the background made + * opaque (over white), the foreground composited onto it; `notes` says when either + * step changed a color, so a translucent input never passes silently. + * @param {string} fg @param {string} bg @param {{large?:boolean}} [opts] + * @returns {{fg:string, bg:string, fgHex:string, bgHex:string, ratio:number, + * level:string, passesAA:boolean, passesAAA:boolean, large:boolean, + * required:{aa:number, aaa:number}, notes:string[]}} + */ +export function contrastReport(fg, bg, { large = false } = {}) { + const f = parseColor(fg); + const b = parseColor(bg); + const back = opaque(b); + const front = compositeOver(f, back); + const notes = []; + if (b.a < 1) + notes.push( + `background ${bg} has alpha ${Math.round(b.a * 1000) / 1000} — composited over white to ${toHex(back)}; pass an opaque background for an exact ratio`, + ); + if (f.a < 1) + notes.push( + `foreground ${fg} has alpha ${Math.round(f.a * 1000) / 1000} — composited over the background to ${toHex(front)}`, + ); + return { + fg, + bg, + fgHex: toHex(front), + bgHex: toHex(back), + ...wcagLevel(contrastRatio(front, back), { large }), + large, + required: large ? { aa: 3, aaa: 4.5 } : { aa: 4.5, aaa: 7 }, + notes, + }; +} + // The deterministic checks a verifier may ASSERT (vs. the advisory, subjective ones). Kept as // data so the frontend-verifier and docs share one source of truth. export const ASSERTABLE_CHECKS = [ diff --git a/test/uicheck.test.js b/test/uicheck.test.js index 7614b820..174119ce 100644 --- a/test/uicheck.test.js +++ b/test/uicheck.test.js @@ -1,6 +1,27 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { test } from "node:test"; -import { ASSERTABLE_CHECKS, contrastRatio, relativeLuminance, wcagLevel } from "../src/uicheck.js"; +import { fileURLToPath } from "node:url"; +import { + ASSERTABLE_CHECKS, + compositeOver, + contrastRatio, + contrastReport, + parseColor, + relativeLuminance, + toHex, + wcagLevel, +} from "../src/uicheck.js"; + +const CLI = fileURLToPath(new URL("../src/cli.js", import.meta.url)); +const runCli = (args) => + spawnSync("node", [CLI, ...args], { + cwd: mkdtempSync(join(tmpdir(), "forge-uicheck-")), + encoding: "utf8", + }); test("relativeLuminance: black=0, white=1", () => { assert.equal(Math.round(relativeLuminance("#000000") * 1000), 0); @@ -32,3 +53,122 @@ test("the assertable checklist is exposed for the verifier + docs", () => { assert.ok(ASSERTABLE_CHECKS.some((c) => c.id === "contrast")); assert.ok(ASSERTABLE_CHECKS.some((c) => c.id === "focus-visible")); }); + +// Channel-wise closeness: oklch/hsl → sRGB is float math; ±1 of 255 is exact enough. +const near = (actual, [r, g, b], tol = 1) => + assert.ok( + Math.abs(actual.r - r) <= tol && Math.abs(actual.g - g) <= tol && Math.abs(actual.b - b) <= tol, + `${JSON.stringify(actual)} ≉ [${r}, ${g}, ${b}]`, + ); + +test("parseColor: hex in every length — #rgb, #rgba, #rrggbb, #rrggbbaa, and a bare (unquoted-shell) form", () => { + assert.deepEqual(parseColor("#777"), { r: 119, g: 119, b: 119, a: 1 }); + assert.deepEqual(parseColor("777"), { r: 119, g: 119, b: 119, a: 1 }, "`#` is a shell comment"); + assert.deepEqual(parseColor("#0f75bc"), { r: 15, g: 117, b: 188, a: 1 }); + assert.equal(parseColor("#fff8").a, 0x88 / 255); + assert.equal(parseColor("#00000080").a, 0x80 / 255); +}); + +test("parseColor: rgb()/hsl() in legacy comma and modern `/ alpha` syntax", () => { + assert.deepEqual(parseColor("rgb(0 0 0 / 50%)"), { r: 0, g: 0, b: 0, a: 0.5 }); + assert.deepEqual(parseColor("rgba(255, 0, 0, .25)"), { r: 255, g: 0, b: 0, a: 0.25 }); + near(parseColor("rgb(100% 0% 0%)"), [255, 0, 0]); + // hsl(210 21% 87%) is shadcn's `hsl(var(--border))` shape; #d7dee5 cross-checked + // against Python's colorsys.hls_to_rgb. + assert.equal(toHex(parseColor("hsl(210 21% 87%)")), "#d7dee5"); + assert.equal(toHex(parseColor("hsl(210deg, 21%, 87%)")), "#d7dee5"); + assert.equal(toHex(parseColor("hsl(0.5turn 50 50)")), "#40bfbf", "bare s/l numbers are %"); + assert.equal(parseColor("hsla(0, 0%, 0%, 0.3)").a, 0.3); +}); + +test("parseColor: oklch()/oklab() convert to sRGB (CSS Color 4 reference points)", () => { + // The CSS Color 4 spec's worked example: sRGB red is oklch(62.8% 0.2577 29.23). + near(parseColor("oklch(62.8% 0.2577 29.23)"), [255, 0, 0]); + near(parseColor("oklab(0.628 0.2249 0.1258)"), [255, 0, 0]); + near(parseColor("oklch(1 0 0)"), [255, 255, 255]); + near(parseColor("oklch(0 0 0)"), [0, 0, 0]); + // Tailwind v4's default blue-500 (its documented sRGB fallback is #2b7fff). + assert.equal(toHex(parseColor("oklch(0.623 0.214 259.815)")), "#2b7fff"); + assert.equal(parseColor("oklch(0.5 0.1 250 / 40%)").a, 0.4); +}); + +test("parseColor: named black/white/transparent; anything unrecognized throws, never guesses", () => { + assert.deepEqual(parseColor("White"), { r: 255, g: 255, b: 255, a: 1 }); + assert.equal(parseColor("transparent").a, 0); + for (const bad of ["nope", "#12345", "rgb(1 2)", "rgb(1,2,3,4,5)", "hsl(x y z)", "rgb(1 2 3 / )"]) + assert.throws(() => parseColor(bad), /bad color/, bad); +}); + +test("compositeOver: source-over in sRGB — 50% black on white paints mid-grey", () => { + const out = compositeOver(parseColor("rgb(0 0 0 / 50%)"), parseColor("#fff")); + assert.equal(out.a, 1); + assert.equal(toHex(out), "#808080"); +}); + +test("contrastRatio: a translucent foreground is measured on what it paints", () => { + // 50% black over white paints #808080 → 3.95:1, NOT black's 21:1. + const r = contrastRatio("rgb(0 0 0 / 50%)", "#ffffff"); + assert.equal(Math.round(r * 100) / 100, Math.round(contrastRatio("#808080", "#fff") * 100) / 100); + assert.ok(r < 4.5, "the opaque-black ratio would have hidden this AA failure"); + // A translucent background is composited over white first. + assert.equal(Math.round(contrastRatio("#000", "rgb(0 0 0 / 0%)")), 21); + assert.equal(Math.round(relativeLuminance("#ffffff80") * 1000), 1000, "over white"); +}); + +test("contrastReport: verdict, thresholds, composited hexes and notes in one object", () => { + const r = contrastReport("#777", "#fff"); + assert.equal(r.ratio, 4.48); + assert.equal(r.passesAA, false); + assert.equal(r.level, "fail"); + assert.deepEqual(r.required, { aa: 4.5, aaa: 7 }); + assert.deepEqual(r.notes, []); + const large = contrastReport("#777", "#fff", { large: true }); + assert.equal(large.passesAA, true, "3:1 is the large-text bar"); + assert.equal(large.level, "AA"); + assert.deepEqual(large.required, { aa: 3, aaa: 4.5 }); + const alpha = contrastReport("rgb(0 0 0 / 50%)", "rgb(255 255 255 / 50%)"); + assert.equal(alpha.bgHex, "#ffffff"); + assert.equal(alpha.fgHex, "#808080"); + assert.equal(alpha.notes.length, 2, "both compositing steps are disclosed"); +}); + +test("cli: `uicheck contrast` exits 1 when AA fails, 0 when it passes", () => { + const fail = runCli(["uicheck", "contrast", "#777", "#fff"]); + assert.equal(fail.status, 1, fail.stdout + fail.stderr); + assert.match(fail.stdout, /4\.48:1/); + assert.match(fail.stdout, /FAILS AA/); + const legacy = runCli(["uicheck", "#777", "#fff"]); + assert.equal(legacy.status, 1, "the bare legacy form gates too"); + const pass = runCli(["uicheck", "contrast", "#595959", "#fff"]); + assert.equal(pass.status, 0, pass.stdout + pass.stderr); +}); + +test("cli: `uicheck contrast --large` applies the 3:1 bar", () => { + const r = runCli(["uicheck", "contrast", "#777", "#fff", "--large"]); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.match(r.stdout, /passes AA for large text/); + const worse = runCli(["uicheck", "contrast", "--large", "#aaa", "#fff"]); + assert.equal(worse.status, 1, "flags may come first; #aaa is 2.32:1, under 3:1 too"); +}); + +test("cli: `uicheck contrast --json` emits the report; oklch/rgb-alpha inputs are accepted", () => { + const r = runCli(["uicheck", "contrast", "rgb(0 0 0 / 50%)", "#fff", "--json"]); + assert.equal(r.status, 1, "3.95:1 fails AA"); + const out = JSON.parse(r.stdout); + assert.equal(out.passesAA, false); + assert.equal(out.fgHex, "#808080"); + assert.equal(out.large, false); + assert.equal(out.notes.length, 1); + const ok = runCli(["uicheck", "contrast", "oklch(0.2 0.02 250)", "oklch(0.98 0 0)", "--json"]); + assert.equal(ok.status, 0, ok.stdout + ok.stderr); + assert.equal(JSON.parse(ok.stdout).passesAA, true); +}); + +test("cli: `uicheck contrast` usage and bad-color errors exit 1 (JSON error under --json)", () => { + const usage = runCli(["uicheck", "contrast", "#777"]); + assert.equal(usage.status, 1); + assert.match(usage.stderr, /usage: .*contrast \[--large\] \[--json\]/); + const bad = runCli(["uicheck", "contrast", "nope", "#fff", "--json"]); + assert.equal(bad.status, 1); + assert.match(JSON.parse(bad.stdout).error, /bad color: nope/); +}); From 2f8651fc4c2e31edcb1e9417b81ba21534aa2c33 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 21:11:25 +0000 Subject: [PATCH 2/3] fix(uicheck): resolve Tailwind theme tokens in design; empty input is insufficient-signal `forge uicheck design` returned PASS for token-based Tailwind because it saw nothing. On HostLelo's src/components/v2/primitives.tsx (rounded-card, shadow-lift, bg-brand-fill, ...) it counted 1 radius, 0 shadows and 0 colors. An empty `
` file also printed PASS. - fingerprint/design read theme tokens: Tailwind v4 `@theme { --color-* --radius-* --shadow-* }` (var(), hsl(var(--x)) and calc() resolved) and the colors/borderRadius/boxShadow objects of tailwind.config.* (parsed statically, never executed). - Theme sources are discovered under the working directory, skipping node_modules, build output and dot-directories, or named with the repeatable --theme . A named file that is missing is an error. - rounded-*, shadow-* and (bg|text|border|ring|fill|stroke|...)-* utilities resolve through those keys. A theme key that redefines a default wins. - Arbitrary values are parsed: rounded-[..], shadow-[..], p-/m-/gap-[..], text-[#..], bg-[oklch(..)]. oklch()/oklab() in plain CSS count as colors. - An empty feature vector gets the verdict `insufficient-signal` (pass:false, exit 1) in design and visual, never PASS. --json reports `verdict` and the theme summary. - --mint uses the same theme, so the minted home matches what design gates. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UUhB8JaPayd43w37dxiXrW Signed-off-by: Claude --- CHANGELOG.md | 13 + docs/GUIDE.md | 56 +- mintlify/cli/quality.mdx | 17 +- mintlify/concepts/verification-gates.mdx | 17 +- src/cli.js | 91 +++- src/commands.js | 33 +- src/uifingerprint.js | 653 +++++++++++++++++++++-- src/uivisual.js | 10 +- test/uifingerprint.test.js | 337 +++++++++++- test/uivisual.test.js | 28 +- 10 files changed, 1160 insertions(+), 95 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d58d344b..50330750 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,19 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `--large` applies the 3:1 large-text / UI bar and `--json` prints the full report. Colors may be `rgb()`, `hsl()`, `oklch()`, `oklab()` or hex with an alpha pair, not only `#rrggbb`. A translucent foreground is composited over the background before measuring. +- **`forge uicheck design` no longer passes token-based Tailwind by seeing nothing.** + `fingerprint` and `design` now read theme tokens: + - Tailwind v4 `@theme { --color-* --radius-* --shadow-* }` stylesheets and + `tailwind.config.*` files. Configs are parsed statically, never executed. + - Sources are discovered under the working directory, or named with `--theme `. + - `rounded-*`, `shadow-*` and `bg|text|border|ring|fill|stroke-*` utilities resolve + through those tokens. Arbitrary values (`rounded-[13px]`, `shadow-[…]`, `p-[…]`, + `text-[#…]`) are parsed too. + + A file with no measurable feature now gets the verdict `insufficient-signal` and exits 1. + Before, an empty `
` printed PASS. `uicheck visual` applies the same rule. Because + the vector now includes resolved tokens, re-mint the project fingerprint + (`forge uicheck fingerprint --mint`) if your UI uses theme tokens. ## [1.3.0] - 2026-09-23 diff --git a/docs/GUIDE.md b/docs/GUIDE.md index e9bf11a8..09dcbb67 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -1161,9 +1161,10 @@ $ forge uicheck contrast "rgb(0 0 0 / 50%)" "#fff" note: foreground rgb(0 0 0 / 50%) has alpha 0.5 — composited over the background to #808080 ``` -**`fingerprint [--mint]`** — the design feature vector of your UI files: -palette (hue histogram), spacing base + on-scale fraction, fonts, radius/shadow levels. -`--mint` stores it as a shared `fingerprint` ledger claim — the design gate's "home": +**`fingerprint [--theme ]... [--mint]`** — the design feature vector of +your UI files: palette (hue histogram), spacing base + on-scale fraction, fonts, +radius/shadow levels. `--mint` stores it as a shared `fingerprint` ledger claim — the +design gate's "home": ```console $ forge uicheck fingerprint src/components/*.jsx --mint @@ -1173,22 +1174,57 @@ Forge uicheck fingerprint — the design feature vector spacing: 4, 8, 16, 24 px — base 4, 96% on-scale type: Inter, ui-monospace shape: radii 6, 12 (2 level(s)) · 1 shadow level(s) + theme: src/app/globals.css (38 color · 3 radius · 3 shadow token(s)) minted fingerprint claim e7a90b12cd34 — the gate's "home" ``` -**`design `** — the two-sided gate for generated UI (exit 1 on fail): slop -distance to known generic templates must stay HIGH, conformance to your minted project -fingerprint must stay LOW, plus scale-conformance checks (spacing on base, level caps). -Failures are actionable per-feature edits, never a bare score. Honest limit: the -fingerprint doesn't resolve CSS `var()` indirection yet — fully tokenized palettes are -partially invisible to it. +**Token-based Tailwind.** A component written as `rounded-card shadow-lift bg-brand-fill` +carries its values in the _theme_, not in the file. `fingerprint` and `design` read the +project's theme tokens and resolve those utilities through them: + +- **Tailwind v4:** `--color-*`, `--radius-*` and `--shadow-*` declarations inside + `@theme { … }` blocks (`@theme inline` too). `var()` references are resolved through + every custom property in the theme stylesheets, including shadcn-style `hsl(var(--x))`. + `calc()` radii are evaluated. +- **Tailwind v3:** the `colors`, `borderRadius` and `boxShadow` objects of a + `tailwind.config.*`, nested families included (`brand: { DEFAULT, 500 }` becomes + `bg-brand` and `bg-brand-500`). The config is **parsed, never executed**. Spreads, + function calls and computed keys are skipped. +- **Matching:** `rounded-*` / `shadow-*` / `(bg|text|border|ring|fill|stroke|…)-*` + utilities match those keys (an `/opacity` modifier is ignored). A theme key that + redefines a default (`--color-blue-500`, `--radius-md`) wins over Tailwind's default. +- **Arbitrary values** are parsed in place: `rounded-[13px]`, `shadow-[0_1px_2px_#000]`, + `p-[13px]`, `text-[#abc]`, `bg-[oklch(0.6_0.1_250)]`. +- **Discovery:** theme sources are found under the working directory. That means every + `tailwind.config.*` and every stylesheet with `@theme`, `@tailwind` or + `@import "tailwindcss"`. `node_modules`, build output and dot-directories (`.git`, + `.next`, `.claude` worktrees) are skipped. `--theme ` (repeatable) names the + sources explicitly instead. The `theme:` line shows what was read. If you upgrade with a + token-based UI, re-mint the project fingerprint so it includes the resolved tokens. + +**`design [--theme ]... [--taste ] [--json]`** — the two-sided gate +for generated UI: slop distance to known generic templates must stay HIGH, conformance to +your minted project fingerprint must stay LOW, plus scale-conformance checks (spacing on +base, level caps). Failures are actionable per-feature edits, never a bare score. The +verdict (`verdict` under `--json`) is one of three: + +- `pass`: exit 0. +- `fail`: exit 1. +- `insufficient-signal`: exit 1. No color, spacing, font, radius or shadow was found, as + with a markup-only file or token utilities with no theme to resolve them. Nothing was + measured, so it is never reported as PASS. + +`var()` indirection resolves within the gated files and through the theme stylesheets' +custom properties. Values set only at runtime stay invisible to the static gate; use +`visual` for those. **`visual [--taste ] [--json] [--remote]`** — the Playwright visual loop: renders the page headless at two viewports (1280×800, 390×844), fingerprints the **computed** styles of every visible element — what the cascade, `var()` resolution, and runtime theming actually produced — and runs the exact same -design gate as `design` (exit 1 on fail). Screenshots land in `.forge/ui/` for human +design gate as `design` (exit 1 on fail, or on `insufficient-signal` when the page +paints nothing measurable). Screenshots land in `.forge/ui/` for human review. Playwright is an _optional tier_ (ADR-0005): `package.json` stays dependency-free; without a browser runtime the command prints a "skipped (no browser runtime)" note and exits 0 — enable it with `npm i -D playwright-core` or point diff --git a/mintlify/cli/quality.mdx b/mintlify/cli/quality.mdx index eac1fe4b..b163252b 100644 --- a/mintlify/cli/quality.mdx +++ b/mintlify/cli/quality.mdx @@ -62,12 +62,21 @@ Writes `DESIGN.md` and parameterizes the `uicheck design` gate thresholds. Deterministic UI checks. ```bash -forge uicheck contrast # WCAG contrast ratio -forge uicheck fingerprint # deterministic design fingerprint -forge uicheck design # slop-distance + conformance gate -forge uicheck visual # Playwright-rendered check (opt-in tier) +forge uicheck contrast [--large] [--json] # WCAG contrast ratio; exit 1 when AA fails +forge uicheck fingerprint [--theme ] # deterministic design fingerprint +forge uicheck design [--theme ] # slop-distance + conformance gate +forge uicheck visual # Playwright-rendered check (opt-in tier) ``` +`contrast` accepts hex (with optional alpha), `rgb()`, `hsl()`, `oklch()` and `oklab()`, +and composites a translucent foreground over the background before measuring. `--large` +applies the 3:1 large-text / UI-component bar. `fingerprint` and `design` resolve +token-based Tailwind utilities (`rounded-card`, `shadow-lift`, `bg-brand`) through the +project's theme: Tailwind v4 `@theme` stylesheets and `tailwind.config.*` files, found +automatically or named with `--theme`. Arbitrary values such as `rounded-[13px]` are +parsed too. `design` exits 1 on `fail` and on `insufficient-signal`: when the files carry +no measurable color, spacing, font, radius or shadow, the verdict is never PASS. + ## `forge harden` Wire security controls — gitleaks pre-commit + sandbox settings. diff --git a/mintlify/concepts/verification-gates.mdx b/mintlify/concepts/verification-gates.mdx index 159af180..d4d30e3b 100644 --- a/mintlify/concepts/verification-gates.mdx +++ b/mintlify/concepts/verification-gates.mdx @@ -127,11 +127,20 @@ requirement fires only when the session's diff actually changes code. Deterministic UI checks, no LLM and no screenshots for the first three lenses: ```bash -forge uicheck contrast # WCAG contrast ratio -forge uicheck fingerprint # deterministic design fingerprint -forge uicheck design # slop-distance + conformance gate -forge uicheck visual # Playwright-rendered check (opt-in tier) +forge uicheck contrast [--large] [--json] # WCAG contrast ratio; exit 1 when AA fails +forge uicheck fingerprint [--theme ] # deterministic design fingerprint +forge uicheck design [--theme ] # slop-distance + conformance gate +forge uicheck visual # Playwright-rendered check (opt-in tier) ``` +`contrast` accepts hex (with optional alpha), `rgb()`, `hsl()`, `oklch()` and `oklab()`, +and composites a translucent foreground over the background before measuring. `--large` +applies the 3:1 large-text / UI-component bar. `fingerprint` and `design` resolve +token-based Tailwind utilities (`rounded-card`, `shadow-lift`, `bg-brand`) through the +project's theme: Tailwind v4 `@theme` stylesheets and `tailwind.config.*` files, found +automatically or named with `--theme`. Arbitrary values such as `rounded-[13px]` are +parsed too. `design` exits 1 on `fail` and on `insufficient-signal`: when the files carry +no measurable color, spacing, font, radius or shadow, the verdict is never PASS. + Pair it with `forge taste` to pick one visual direction (brutalist, corporate, editorial, minimalist, playful) and parameterize the `design` gate thresholds. diff --git a/src/cli.js b/src/cli.js index fb9e35ec..03539d6f 100755 --- a/src/cli.js +++ b/src/cli.js @@ -2589,6 +2589,12 @@ HANDLERS.scope = async (argv) => { if (d.independentGroups === 1) console.log("\n all coupled — keep as one change."); return; }; +/** The last line of a `uicheck design|visual` run, per overall verdict. */ +const VERDICT_LABEL = { + pass: "✓ PASS", + fail: "✗ FAIL", + "insufficient-signal": "✗ INSUFFICIENT SIGNAL — nothing measurable, so this is not a PASS", +}; HANDLERS.uicheck = async (argv) => { const sub = argv[1]; if (sub === "visual") { @@ -2654,7 +2660,7 @@ HANDLERS.uicheck = async (argv) => { console.log( ` ${c.pass ? "✓" : "✗"} ${c.id}: ${c.detail}${c.pass || !c.hint ? "" : `\n fix: ${c.hint}`}`, ); - console.log(`\n ${r.fail ? "✗ FAIL" : "✓ PASS"}`); + console.log(`\n ${VERDICT_LABEL[r.verdict]}`); } if (r.fail) process.exitCode = 1; return; @@ -2726,22 +2732,55 @@ HANDLERS.uicheck = async (argv) => { const args = argv.slice(2); const tasteIdx = args.indexOf("--taste"); const tasteArg = tasteIdx >= 0 ? (args.splice(tasteIdx, 2)[1] ?? null) : null; + // `--theme ` (repeatable) names the Tailwind theme sources explicitly; + // without it they are discovered (@theme / @tailwind stylesheets, tailwind.config.*). + /** @type {string[]} */ + const themeArgs = []; + let themeMissing = false; + for (let i = args.indexOf("--theme"); i >= 0; i = args.indexOf("--theme")) { + const [, value] = args.splice(i, 2); + if (!value || value.startsWith("--")) themeMissing = true; + else themeArgs.push(value); + } const json = args.includes("--json"); const files = args.filter((a) => !a.startsWith("--")); - if (!files.length || (tasteIdx >= 0 && !tasteArg)) { + if (!files.length || (tasteIdx >= 0 && !tasteArg) || themeMissing) { console.error( - `usage: ${BRAND.cli} uicheck ${sub} [--json]${sub === "fingerprint" ? " [--mint]" : " [--taste ]"}`, + `usage: ${BRAND.cli} uicheck ${sub} [--theme ]... [--json]${sub === "fingerprint" ? " [--mint]" : " [--taste ]"}`, ); process.exitCode = 1; return; } - const fp = ui.fingerprintFiles(process.cwd(), files); + // An explicitly named theme that isn't there is an error, not a silent no-op. + const { existsSync } = await import("node:fs"); + const { resolve } = await import("node:path"); + const absent = themeArgs.filter((t) => !existsSync(resolve(process.cwd(), t))); + if (absent.length) { + console.error(`theme source not found: ${absent.join(", ")}`); + process.exitCode = 1; + return; + } + const theme = ui.loadThemeTokens( + process.cwd(), + ui.themeSourcesFor(process.cwd(), files, themeArgs), + ); + const themeLine = theme.sources.length + ? `${theme.sources.join(", ")} (${theme.colors.size} color · ${theme.radius.size} radius · ${theme.shadow.size} shadow token(s))` + : "(none found — token utilities like rounded-card / bg-brand stay unresolved; name one with --theme )"; + const themeSummary = { + sources: theme.sources, + colors: theme.colors.size, + radius: theme.radius.size, + shadow: theme.shadow.size, + }; + const fp = ui.fingerprintFiles(process.cwd(), files, { theme }); if (sub === "fingerprint") { let minted = null; if (argv.includes("--mint")) { const { epochDay } = await import("./util.js"); minted = ui.mintProjectFingerprint(process.cwd(), files, { t: epochDay(), + theme, }); } if (json) { @@ -2758,6 +2797,11 @@ HANDLERS.uicheck = async (argv) => { console.log( ` shape: radii ${fp.radii.join(", ") || "(none)"} (${fp.radiusLevels} level(s)) · ${fp.shadowLevels} shadow level(s)`, ); + console.log(` theme: ${themeLine}`); + if (!ui.hasDesignSignal(fp)) + console.log( + "\n ! no measurable design feature in these files — `design` reports insufficient-signal", + ); if (minted) { if (minted.ok) console.log( @@ -2789,17 +2833,21 @@ HANDLERS.uicheck = async (argv) => { const tauConform = profile?.gate?.tau_conform ?? ui.UI_GATE_DEFAULTS.tauConform; const gate = ui.uiGate(fp, { projectFp, tauSlop, tauConform }); const checks = [...ui.scaleChecks(fp), ...(profile ? ui.profileChecks(fp, profile) : [])]; - const fail = !gate.pass || checks.some((c) => !c.pass); + // insufficient-signal (an empty vector) exits non-zero like FAIL: nothing was + // measured, so nothing passed. + const verdict = ui.overallVerdict(gate, checks); if (json) { console.log( JSON.stringify( { ...gate, + verdict, checks, hasProjectFingerprint: !!projectFp, taste: profile ? tasteName : null, tauSlop, tauConform, + theme: themeSummary, }, null, 2, @@ -2808,23 +2856,28 @@ HANDLERS.uicheck = async (argv) => { } else { heading(`${BRAND.brand} uicheck design — slop distance + project conformance\n`); if (profile) console.log(` taste: ${tasteName} (thresholds from its profile)`); - console.log( - ` slop distance: ${gate.slop} (need ≥ ${tauSlop} — farther from generic is better)`, - ); - console.log( - projectFp - ? ` conformance: ${gate.conform} (need ≤ ${tauConform} — closer to the project system is better)` - : ` conformance: (no project fingerprint claim — slop-only; mint one: \`${BRAND.cli} uicheck fingerprint --mint\`)`, - ); - for (const v of gate.violations) console.log(`\n ✗ ${v.detail}\n fix: ${v.hint}`); - console.log(""); - for (const c of checks) + console.log(` theme: ${themeLine}`); + if (verdict !== "insufficient-signal") { console.log( - ` ${c.pass ? "✓" : "✗"} ${c.id}: ${c.detail}${c.pass || !c.hint ? "" : `\n fix: ${c.hint}`}`, + ` slop distance: ${gate.slop} (need ≥ ${tauSlop} — farther from generic is better)`, + ); + console.log( + projectFp + ? ` conformance: ${gate.conform} (need ≤ ${tauConform} — closer to the project system is better)` + : ` conformance: (no project fingerprint claim — slop-only; mint one: \`${BRAND.cli} uicheck fingerprint --mint\`)`, ); - console.log(`\n ${fail ? "✗ FAIL" : "✓ PASS"}`); + } + for (const v of gate.violations) console.log(`\n ✗ ${v.detail}\n fix: ${v.hint}`); + if (verdict !== "insufficient-signal") { + console.log(""); + for (const c of checks) + console.log( + ` ${c.pass ? "✓" : "✗"} ${c.id}: ${c.detail}${c.pass || !c.hint ? "" : `\n fix: ${c.hint}`}`, + ); + } + console.log(`\n ${VERDICT_LABEL[verdict]}`); } - if (fail) process.exitCode = 1; + if (verdict !== "pass") process.exitCode = 1; return; } const { contrastReport, ASSERTABLE_CHECKS, ADVISORY_ONLY } = await import("./uicheck.js"); diff --git a/src/commands.js b/src/commands.js index 6b00a072..29463f61 100644 --- a/src/commands.js +++ b/src/commands.js @@ -154,8 +154,37 @@ export const COMMANDS = { "doom-loop check — record a failure; 3× the same signature mints a diagnosis + escalation", imagine: "consequence simulation — predicted breaks + the minimal dry-run test suite for a task", lean: "scope-minimality (M5) — measure the diff's footprint vs what the task asked for", - uicheck: - "deterministic UI checks — contrast · fingerprint · design · visual ", + uicheck: { + summary: + "deterministic UI checks — contrast · fingerprint · design · visual ", + usage: + "forge uicheck contrast [--large] [--json] | fingerprint [--theme ]... [--mint] [--json] | design [--theme ]... [--taste ] [--json] | visual | interact ", + flags: [ + { + flag: "--large", + desc: "contrast: grade against the large-text / UI-component bar (AA 3:1) instead of normal text (AA 4.5:1); exit 1 when AA fails either way", + }, + { + flag: "--json", + desc: "machine-readable result (contrast: ratio, level, the composited hexes compared, notes; design: verdict pass | fail | insufficient-signal)", + }, + { + flag: "--theme ", + desc: "fingerprint/design: the Tailwind theme source(s) — a v4 @theme stylesheet or a tailwind.config — that resolve token utilities (rounded-card, shadow-lift, bg-brand); repeatable; replaces auto-discovery", + }, + { + flag: "--taste ", + desc: "design/visual: gate thresholds + checks from a taste profile (default: the style pinned by a forge-taste DESIGN.md)", + }, + { flag: "--mint", desc: "fingerprint: store the vector as the project's design claim" }, + ], + examples: [ + 'forge uicheck contrast "#777" "#fff"', + 'forge uicheck contrast "oklch(0.55 0.1 250)" "#fff" --large --json', + "forge uicheck design src/components/Card.tsx", + "forge uicheck design src/components/*.tsx --theme src/app/globals.css", + ], + }, dash: "live dashboard: ledger, metrics trends, radar, memory browser, timeline, blast radius", report: "emit a static, self-contained HTML snapshot of .forge/ — opens offline, no server", brand: "print the active brand token map", diff --git a/src/uifingerprint.js b/src/uifingerprint.js index e7a29442..e1a83e01 100644 --- a/src/uifingerprint.js +++ b/src/uifingerprint.js @@ -10,11 +10,12 @@ // Good output is far from generic and close to home; both are geometry once UI is a // feature vector. The subjective residue (beauty) stays with the human reviewer — // the gate's job is to stop the template from ever reaching them. -import { readFileSync } from "node:fs"; -import { isAbsolute, join } from "node:path"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { basename, isAbsolute, join, relative, resolve, sep } from "node:path"; import { BRAND } from "./brand.js"; import { mintClaim } from "./ledger.js"; import { loadClaims, putClaim, reindex, repoLedger } from "./ledger_store.js"; +import { parseColor } from "./uicheck.js"; import { gitAuthor } from "./util.js"; // --------------------------------------------------------------------------- @@ -80,8 +81,39 @@ const TW_COLOR_RE = new RegExp( "g", ); const TW_BW_RE = /\b(?:bg|text|border|from|via|to|ring|fill|stroke)-(white|black)\b/g; +// oklch()/oklab() — Tailwind v4's default palette syntax (and what browsers report +// for colors authored that way). `_` is a space inside Tailwind arbitrary values. +const OKLAB_FN_RE = /\boklch\([^()]*\)|\boklab\([^()]*\)/gi; -function parseColors(text) { +// A Tailwind utility's value: a theme key (`card`, `brand-fill`, `primary-500`) or +// an arbitrary `[...]` value. Shared by the rounded/shadow/color matchers below. +const TW_KEY = String.raw`(?:[\w.]+(?:-[\w.]+)*|\[[^\]\s]+\])`; +// Color utilities matched against theme keys (default families stay on TW_COLOR_RE); +// an optional `/opacity` modifier is accepted and ignored — hue identity only. +const TW_TOKEN_COLOR_RE = new RegExp( + String.raw`(? key.slice(1, -1).replace(/_/g, " "); + +const hslOf = (/** @type {{r:number,g:number,b:number}} */ c) => rgbToHsl(c.r, c.g, c.b); + +/** parseColor, but null instead of a throw — for scanning free text. */ +function tryHsl(value) { + try { + return hslOf(parseColor(value)); + } catch { + return null; + } +} + +/** + * @param {string} text + * @param {ThemeTokens|null} [theme] resolves `bg-brand`-style token utilities + */ +function parseColors(text, theme = null) { /** @type {{h:number,s:number,l:number}[]} */ const out = []; for (const [, hex] of text.matchAll(HEX_RE)) { @@ -100,14 +132,31 @@ function parseColors(text) { for (const [, r, g, b] of text.matchAll(RGB_RE)) out.push(rgbToHsl(+r, +g, +b)); for (const [, h, s, l] of text.matchAll(HSL_RE)) out.push({ h: Math.round(+h) % 360, s: Math.round(+s), l: Math.round(+l) }); + for (const [fn] of text.matchAll(OKLAB_FN_RE)) { + const c = tryHsl(fn.replace(/_/g, " ")); + if (c) out.push(c); + } for (const [, family, shade] of text.matchAll(TW_COLOR_RE)) { + // A theme that redefines a default family key wins — TW_TOKEN_COLOR_RE reads it. + if (theme?.colors.has(`${family}-${shade}`)) continue; const [h, s] = TW_FAMILY_HS[family]; // Lightness from the shade number: 50→95, 500→50, 950→5 — coarse but monotone, // and hue (what the slop signatures key on) is exact. out.push({ h, s, l: Math.min(96, Math.max(4, Math.round(100 - +shade / 10))) }); } - for (const [, bw] of text.matchAll(TW_BW_RE)) + for (const [, bw] of text.matchAll(TW_BW_RE)) { + if (theme?.colors.has(bw)) continue; out.push({ h: 0, s: 0, l: bw === "white" ? 100 : 0 }); + } + // Token + arbitrary color utilities: `bg-brand-fill`, `text-on-band/80` resolve + // through the theme; `text-[#abc]`, `bg-[oklch(0.6_0.1_250)]` parse in place. + // Anything else (`text-sm`, `border-t`, `text-[13px]`) is not a color — skipped. + for (const [, key] of text.matchAll(TW_TOKEN_COLOR_RE)) { + const c = key.startsWith("[") + ? tryHsl(arbitrary(key).replace(/^color:/, "")) + : (theme?.colors.get(key) ?? null); + if (c) out.push(c); + } return out; } @@ -124,35 +173,32 @@ const VAR_DECL_RE = /(--[\w-]+)\s*:\s*([^;}]+)/g; // (rgba(...), nested var(...)) — deeper nesting stays unmatched and thus untouched. const VAR_USE_RE = /var\(\s*(--[\w-]+)\s*(?:,\s*([^()]*(?:\([^()]*\)[^()]*)*))?\s*\)/g; +/** One substitution pass of `var(--name[, fallback])` against `decls`. */ +const substituteVars = (/** @type {string} */ s, /** @type {Map} */ decls) => + s.replace(VAR_USE_RE, (whole, name, fallback) => { + const v = decls.get(name); + if (v !== undefined) return v; + return fallback !== undefined ? String(fallback).trim() : whole; + }); + /** - * Substitute `var(--name[, fallback])` with the declared custom-property value - * (fallback when undeclared; left as-is when neither exists — the extractors ignore - * unresolved `var(` just as before). One level of nesting (a custom property whose - * value is itself a var()) resolves via a BOUNDED pass count, so declaration cycles - * terminate instead of recursing: cyclic values simply keep their `var(` text and - * stay invisible to the extractors. - * @param {string} text - * @returns {string} + * Every `--name: value` declaration in `text` (last wins), each resolved through the + * others. `seed` declarations (a theme stylesheet's) are visible too but lose to the + * text's own. + * @param {string} text @param {Map|null} [seed] + * @returns {Map} */ -export function resolveCssVars(text) { - const t = String(text); +function cssVarDecls(text, seed = null) { /** @type {Map} */ - const decls = new Map(); - for (const [, name, value] of t.matchAll(VAR_DECL_RE)) decls.set(name, value.trim()); - if (!decls.size && !t.includes("var(")) return t; - const substitute = (/** @type {string} */ s) => - s.replace(VAR_USE_RE, (whole, name, fallback) => { - const v = decls.get(name); - if (v !== undefined) return v; - return fallback !== undefined ? String(fallback).trim() : whole; - }); + const decls = new Map(seed ?? []); + for (const [, name, value] of String(text).matchAll(VAR_DECL_RE)) decls.set(name, value.trim()); // Resolve the declarations themselves first (--a: var(--b)); 4 passes covers the // sane nesting depths and bounds a --a↔--b cycle to a fixed cost. for (let i = 0; i < 4; i++) { let changed = false; for (const [name, value] of decls) { if (!value.includes("var(")) continue; - const next = substitute(value); + const next = substituteVars(value, decls); if (next !== value) { decls.set(name, next); changed = true; @@ -160,10 +206,34 @@ export function resolveCssVars(text) { } if (!changed) break; } - // Then the whole text; extra passes let a fallback that is itself a var() land. + return decls; +} + +/** + * Substitute `var(--name[, fallback])` with the declared custom-property value + * (fallback when undeclared; left as-is when neither exists — the extractors ignore + * unresolved `var(` just as before). One level of nesting (a custom property whose + * value is itself a var()) resolves via a BOUNDED pass count, so declaration cycles + * terminate instead of recursing: cyclic values simply keep their `var(` text and + * stay invisible to the extractors. + * @param {string} text + * @param {{vars?:Map|null}} [opts] `vars`: declarations from outside + * the text (the project theme) — a component's `var(--brand)` resolves through them + * without the theme's own values counting as the component's features. + * @returns {string} + */ +export function resolveCssVars(text, opts = {}) { + const t = String(text); + return substituteAll(t, cssVarDecls(t, opts.vars)); +} + +/** Substitute `decls` through the whole text; extra passes let a fallback that is + * itself a var() land. */ +function substituteAll(/** @type {string} */ t, /** @type {Map} */ decls) { + if (!decls.size && !t.includes("var(")) return t; let out = t; for (let i = 0; i < 3 && out.includes("var("); i++) { - const next = substitute(out); + const next = substituteVars(out, decls); if (next === out) break; out = next; } @@ -187,6 +257,87 @@ function parseLengths(value) { return out; } +const CALC_TOKEN_RE = /\s*(?:(infinity|\d*\.?\d+)(px|rem|em)?|([-+*/()]))/iy; + +/** + * Evaluate a `calc()` body over px/rem/em lengths and unitless numbers (+ − × ÷, + * parentheses, `infinity`). Null for anything else (%, vw, min()/max()/clamp()). + * @param {string} expr + * @returns {number|null} px + */ +function evalCalc(expr) { + /** @type {({n:number, len:boolean}|string)[]} */ + const toks = []; + CALC_TOKEN_RE.lastIndex = 0; + const src = expr.trim(); + while (CALC_TOKEN_RE.lastIndex < src.length) { + const m = CALC_TOKEN_RE.exec(src); + if (!m) return null; + if (m[3]) toks.push(m[3]); + else { + const n = m[1].toLowerCase() === "infinity" ? Number.POSITIVE_INFINITY : +m[1]; + toks.push({ n: m[2] && m[2].toLowerCase() !== "px" ? n * 16 : n, len: !!m[2] }); + } + } + let i = 0; + /** @returns {{n:number, len:boolean}|null} */ + const atom = () => { + const t = toks[i++]; + if (t === "(") { + const v = sum(); + return toks[i++] === ")" ? v : null; + } + if (t === "-") { + const v = atom(); + return v && { n: -v.n, len: v.len }; + } + return typeof t === "object" ? t : null; + }; + const product = () => { + let a = atom(); + while (a && (toks[i] === "*" || toks[i] === "/")) { + const op = toks[i++]; + const b = atom(); + if (!b || (a.len && b.len) || (op === "/" && b.len)) return null; + a = { n: op === "*" ? a.n * b.n : a.n / b.n, len: a.len || b.len }; + } + return a; + }; + function sum() { + let a = product(); + while (a && (toks[i] === "+" || toks[i] === "-")) { + const op = toks[i++]; + const b = product(); + if (!b || a.len !== b.len) return null; + a = { n: op === "+" ? a.n + b.n : a.n - b.n, len: a.len }; + } + return a; + } + const v = sum(); + return v && i === toks.length && v.len && !Number.isNaN(v.n) ? v.n : null; +} + +/** + * ONE length in px — a theme token or arbitrary value (`0.625rem`, `13px`, + * `calc(1rem - 2px)`, `calc(infinity * 1px)`). Null when it isn't exactly one + * absolute length; ≥999px pills normalize to 9999 like everywhere else. + * @param {string} value + * @returns {number|null} + */ +function lengthPx(value) { + const v = String(value).trim(); + const calc = /^calc\((.*)\)$/is.exec(v); + let px; + if (calc) px = evalCalc(calc[1]); + else if (/^0+(?:\.0+)?$/.test(v)) px = 0; + else { + const m = /^(\d*\.?\d+)(px|rem|em)$/i.exec(v); + px = m ? +m[1] * (m[2].toLowerCase() === "px" ? 1 : 16) : null; + } + if (px === null || !(px >= 0)) return null; + return px >= 999 ? 9999 : Math.round(px * 100) / 100; +} + const cssValues = (text, propRe) => [...text.matchAll(propRe)].map((m) => m[1]); // The leading class keeps `scroll-padding`, `--m-4` etc. from matching. @@ -201,12 +352,58 @@ const FONT_PROP_RE = /(?:^|[;{\s"'])font-family\s*:\s*([^;}]+)/gi; // lookbehind stops `top-4` matching as `p-4`. const TW_SPACE_RE = /(? v.trim().replace(/\s+/g, " "); + +/** + * px radius of one `rounded-*` utility, or null when it isn't measurable. + * @param {string|undefined} key @param {ThemeTokens|null} theme + */ +function twRadius(key, theme) { + if (key === undefined) return theme?.radius.get("") ?? 4; // bare `rounded` + if (key.startsWith("[")) return lengthPx(arbitrary(key)); + if (theme?.radius.has(key)) return theme.radius.get(key) ?? null; + return Object.hasOwn(TW_ROUNDED_PX, key) ? TW_ROUNDED_PX[key] : null; +} + +/** + * The elevation level one `shadow-*` utility names, or null (none / not a shadow — + * `shadow-brand` is a shadow COLOR). Theme and arbitrary shadows key on their value, + * so `shadow-lift` and a CSS `box-shadow` with the same value are ONE level. + * @param {string|undefined} key @param {ThemeTokens|null} theme + */ +function twShadow(key, theme) { + if (key === undefined) return theme?.shadow.get("") ?? "tw:base"; + if (key === "none") return null; + if (key.startsWith("[")) return normShadow(arbitrary(key)); + if (theme?.shadow.has(key)) return theme.shadow.get(key) ?? null; + return TW_SHADOW_KEYS.has(key) ? `tw:${key}` : null; +} const TW_FONT_RE = /(? [...new Set(arr)].sort(sortNum); /** * Extract the design fingerprint from raw CSS / JSX / Tailwind-class text. Pure and - * deterministic — the same text always yields the same vector (it becomes a - * content-addressed ledger claim, so this is a protocol requirement, not a nicety). + * deterministic — the same text (and theme) always yields the same vector (it becomes + * a content-addressed ledger claim, so this is a protocol requirement, not a nicety). * @param {string} text + * @param {{theme?:ThemeTokens|null}} [opts] `theme`: the project's Tailwind tokens + * (loadThemeTokens) — without it, token utilities like `rounded-card`, + * `shadow-lift` and `bg-brand` carry no measurable value and are skipped. * @returns {Fingerprint} */ -export function fingerprintText(text) { - const t = resolveCssVars(String(text)); +export function fingerprintText(text, opts = {}) { + const theme = opts.theme ?? null; + const t = resolveCssVars(String(text), { vars: theme?.vars }); const seen = new Set(); /** @type {Hsl[]} */ const palette = []; - for (const c of parseColors(t)) { + for (const c of parseColors(t, theme)) { const key = `${c.h},${c.s},${c.l}`; if (!seen.has(key)) { seen.add(key); @@ -287,6 +488,7 @@ export function fingerprintText(text) { ...cssValues(t, SPACING_PROP_RE).flatMap(parseLengths), ...cssValues(t, GAP_PROP_RE).flatMap(parseLengths), ...[...t.matchAll(TW_SPACE_RE)].map(([, n]) => (n === "px" ? 1 : +n * 4)).filter(Boolean), + ...[...t.matchAll(TW_SPACE_ARB_RE)].flatMap(([, v]) => parseLengths(v.replace(/_/g, " "))), ]; const spacing = uniqSorted(spacingRaw); const spacingBase = inferSpacingBase(spacing); @@ -313,17 +515,15 @@ export function fingerprintText(text) { .flatMap(parseLengths) .map((r) => (r >= 999 ? 9999 : r)), ...[...t.matchAll(TW_ROUNDED_RE)] - .map(([, size]) => (size === undefined ? 4 : TW_ROUNDED_PX[size])) - .filter((r) => r > 0), + .map(([, key]) => twRadius(key, theme)) + .filter((r) => typeof r === "number" && r > 0), ]); const shadows = new Set([ ...cssValues(t, SHADOW_PROP_RE) - .map((v) => v.trim().replace(/\s+/g, " ")) + .map(normShadow) .filter((v) => v !== "none"), - ...[...t.matchAll(TW_SHADOW_RE)] - .map(([, size]) => `tw:${size ?? "base"}`) - .filter((s) => s !== "tw:none"), + ...[...t.matchAll(TW_SHADOW_RE)].map(([, key]) => twShadow(key, theme)).filter(Boolean), ]); return { @@ -345,16 +545,335 @@ export function fingerprintText(text) { * whole surface, not any single file). Unreadable files are skipped; the file list * is sorted first so argument order can never change the vector. * @param {string} root @param {string[]} files + * @param {{theme?:ThemeTokens|null}} [opts] see fingerprintText * @returns {Fingerprint} */ -export function fingerprintFiles(root, files) { +export function fingerprintFiles(root, files, opts = {}) { const texts = []; for (const f of [...files].sort()) { try { texts.push(readFileSync(isAbsolute(f) ? f : join(root, f), "utf8")); } catch {} } - return fingerprintText(texts.join("\n")); + return fingerprintText(texts.join("\n"), opts); +} + +/** + * Does the vector carry ANY measurable design feature? An empty one (a markup-only + * file, or token utilities with no theme to resolve them) is not evidence of good + * design — the gate reports it as `insufficient-signal`, never PASS. + * @param {Fingerprint} fingerprint + */ +export function hasDesignSignal(fingerprint) { + const fp = asFp(fingerprint); + return ( + fp.paletteSize > 0 || + fp.spacing.length > 0 || + fp.fontFamilies.length > 0 || + fp.radii.length > 0 || + fp.shadowLevels > 0 + ); +} + +// --------------------------------------------------------------------------- +// Theme tokens — a token-based Tailwind UI (`rounded-card`, `shadow-lift`, +// `bg-brand-fill`) carries its values in the THEME, not in the component. Read +// them statically from Tailwind v4 `@theme { --radius-* --shadow-* --color-* }` +// stylesheets and v3 `tailwind.config.*` objects so the fingerprint sees what the +// utilities actually paint. The config is PARSED, never executed — it is project +// code, and a lint must not run it. +// --------------------------------------------------------------------------- + +/** + * @typedef {{colors:Map, radius:Map, shadow:Map, + * vars:Map, sources:string[]}} ThemeTokens + * Keys are utility suffixes (`brand-fill` for `bg-brand-fill`; "" for a DEFAULT); + * `vars` = the theme sources' custom properties, resolved. + */ + +/** @returns {ThemeTokens} */ +const emptyTheme = () => ({ + colors: new Map(), + radius: new Map(), + shadow: new Map(), + vars: new Map(), + sources: [], +}); + +/** @param {ThemeTokens} into @param {ThemeTokens} from */ +function mergeTheme(into, from) { + for (const [k, v] of from.colors) into.colors.set(k, v); + for (const [k, v] of from.radius) into.radius.set(k, v); + for (const [k, v] of from.shadow) into.shadow.set(k, v); + for (const [k, v] of from.vars) into.vars.set(k, v); + into.sources.push(...from.sources); + return into; +} + +/** File one token value under its namespace; unparseable values are skipped. */ +function addToken(theme, ns, key, rawValue) { + const value = String(rawValue).trim(); + if (ns === "color" || ns === "colors") { + const c = key ? tryHsl(value) : null; + if (c) theme.colors.set(key, c); + } else if (ns === "radius" || ns === "borderRadius") { + const px = lengthPx(value); + if (px !== null) theme.radius.set(key, px); + } else if (value && value !== "none") theme.shadow.set(key, normShadow(value)); +} + +const THEME_BLOCK_RE = /@theme\b[^{};]*\{/g; +const THEME_DECL_RE = /--(color|radius|shadow)-([\w-]+)\s*:\s*([^;}]+)/g; + +/** The bodies of every `@theme [inline|static|…] { … }` block (brace-matched). */ +function atThemeBodies(text) { + const out = []; + for (const m of text.matchAll(THEME_BLOCK_RE)) { + const start = (m.index ?? 0) + m[0].length; + let depth = 1; + let i = start; + for (; i < text.length && depth; i++) { + if (text[i] === "{") depth++; + else if (text[i] === "}") depth--; + } + out.push(text.slice(start, depth ? i : i - 1)); + } + return out; +} + +/** + * Theme tokens from Tailwind v4 CSS: `@theme { --color-brand: …; --radius-card: …; + * --shadow-lift: … }`, with var() resolved through every custom property in the + * text (so `--color-fg: var(--hl-fg)` or shadcn's `hsl(var(--border))` land). + * @param {string} text one or more stylesheets, concatenated + * @returns {ThemeTokens} + */ +export function themeFromCss(text) { + const t = String(text); + const theme = emptyTheme(); + theme.vars = cssVarDecls(t); + for (const body of atThemeBodies(substituteAll(t, theme.vars))) + for (const [, ns, key, value] of body.matchAll(THEME_DECL_RE)) addToken(theme, ns, key, value); + return theme; +} + +/** + * The string-valued leaves of ONE JS object literal starting at `src[open] === "{"`, + * as [keyPath, value]. Spreads, calls, references and computed keys are skipped — + * a static read, never an evaluation. + * @param {string} src @param {number} open + * @returns {[string[], string][]} + */ +function objectLiteralLeaves(src, open) { + /** @type {[string[], string][]} */ + const leaves = []; + let i = open; + const ws = () => { + for (;;) { + while (i < src.length && /\s/.test(src[i])) i++; + if (src.startsWith("//", i)) { + const nl = src.indexOf("\n", i); + i = nl < 0 ? src.length : nl + 1; + } else if (src.startsWith("/*", i)) { + const end = src.indexOf("*/", i + 2); + i = end < 0 ? src.length : end + 2; + } else return; + } + }; + // A quoted string at src[i]; null for a template literal with ${} (dynamic). + const str = () => { + const q = src[i++]; + let out = ""; + while (i < src.length && src[i] !== q) { + if (src[i] === "\\") { + out += src[i + 1] ?? ""; + i += 2; + } else out += src[i++]; + } + i++; + return q === "`" && out.includes("${") ? null : out; + }; + // Skip an unreadable value: up to the next `,` or `}` at depth 0. + const skip = () => { + let depth = 0; + while (i < src.length) { + const c = src[i]; + if (c === '"' || c === "'" || c === "`") { + str(); + continue; + } + if (c === "(" || c === "[" || c === "{") depth++; + else if (c === ")" || c === "]" || c === "}") { + if (depth === 0) { + if (c !== "}") i = src.length; // unbalanced — stop reading + return; + } + depth--; + } else if (c === "," && depth === 0) return; + i++; + } + }; + const obj = (/** @type {string[]} */ path) => { + i++; // past "{" + while (i < src.length) { + ws(); + if (src[i] === "}") { + i++; + return; + } + if (src[i] === ",") { + i++; + continue; + } + let key = null; + if (src[i] === '"' || src[i] === "'") key = str(); + else { + const m = /^[\w$]+/.exec(src.slice(i, i + 256)); + if (m) { + key = m[0]; + i += key.length; + } + } + ws(); + if (key === null || src[i] !== ":") { + const at = i; + skip(); + if (i === at && src[i] !== "}" && src[i] !== ",") return; + continue; + } + i++; + ws(); + const c = src[i]; + if (c === "{") obj([...path, key]); + else if (c === '"' || c === "'" || c === "`") { + const v = str(); + if (v !== null) leaves.push([[...path, key], v]); + } else skip(); + } + }; + obj([]); + return leaves; +} + +const CONFIG_KEY_RE = /\b(colors|borderRadius|boxShadow)\s*:\s*\{/g; + +/** + * Theme tokens from a Tailwind v3 `tailwind.config.*` (`theme` or `theme.extend` + * `colors` / `borderRadius` / `boxShadow` objects). Nested color families flatten + * to utility keys (`brand: { DEFAULT, 500 }` → `brand`, `brand-500`); values may + * use var() when `vars` (the stylesheets' custom properties) resolve them. + * @param {string} text @param {{vars?:Map|null}} [opts] + * @returns {ThemeTokens} + */ +export function themeFromTailwindConfig(text, opts = {}) { + const src = String(text); + const theme = emptyTheme(); + for (const m of src.matchAll(CONFIG_KEY_RE)) { + const open = (m.index ?? 0) + m[0].length - 1; + for (const [path, value] of objectLiteralLeaves(src, open)) { + const key = path.filter((p) => p !== "DEFAULT").join("-"); + addToken(theme, m[1], key, resolveCssVars(value, { vars: opts.vars })); + } + } + return theme; +} + +const THEME_CONFIG_RE = /^tailwind\.config\.[cm]?[jt]s$/; +const THEME_CSS_MARK_RE = /@theme\b|@tailwind\b|@import\s+(?:url\(\s*)?["']tailwindcss/; +const THEME_SKIP_DIRS = new Set(["node_modules", "dist", "build", "out", "coverage", "vendor"]); +const MAX_THEME_CSS_BYTES = 2 * 1024 * 1024; + +/** Is this file a Tailwind theme source (a config, or a stylesheet with Tailwind directives)? */ +function isThemeSource(path) { + if (THEME_CONFIG_RE.test(basename(path))) return true; + if (!path.endsWith(".css")) return false; + try { + if (statSync(path).size > MAX_THEME_CSS_BYTES) return false; + return THEME_CSS_MARK_RE.test(readFileSync(path, "utf8")); + } catch { + return false; + } +} + +/** + * Discover the project's Tailwind theme sources under `root`: every + * `tailwind.config.*` and every stylesheet carrying `@theme` / `@tailwind` / + * `@import "tailwindcss"`. Bounded walk (depth, entry cap); dot-directories + * (.git, .next, .claude worktrees …), node_modules and build output are skipped; + * symlinks are not followed. Sorted, root-relative. + * @param {string} root @param {{maxDepth?:number, maxEntries?:number}} [opts] + * @returns {string[]} + */ +export function findThemeSources(root, { maxDepth = 5, maxEntries = 20000 } = {}) { + /** @type {string[]} */ + const found = []; + let seen = 0; + const walk = (/** @type {string} */ dir, /** @type {string} */ rel, depth) => { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + for (const e of entries) { + if (++seen > maxEntries) return; + const r = rel ? `${rel}/${e.name}` : e.name; + if (e.isDirectory()) { + if (depth < maxDepth && !e.name.startsWith(".") && !THEME_SKIP_DIRS.has(e.name)) + walk(join(dir, e.name), r, depth + 1); + } else if (e.isFile() && isThemeSource(join(dir, e.name))) found.push(r); + } + }; + walk(resolve(root), "", 0); + return found.sort(); +} + +/** + * The theme sources a `uicheck fingerprint|design` run should read: the explicit + * `--theme` list when given, else the discovered ones — plus any INPUT file that is + * itself a theme source (gating `globals.css` alongside the components). Root-relative, + * deduplicated, sorted. + * @param {string} root @param {string[]} files @param {string[]} [explicit] + * @returns {string[]} + */ +export function themeSourcesFor(root, files, explicit = []) { + // Forward slashes on every OS: these are shown to the user and compared in tests. + const rel = (/** @type {string} */ f) => + (relative(resolve(root), resolve(root, f)) || f).split(sep).join("/"); + const base = explicit.length ? explicit : findThemeSources(root); + const fromInputs = files.filter((f) => isThemeSource(resolve(root, f))); + return [...new Set([...base, ...fromInputs].map(rel))].sort(); +} + +/** + * Read theme tokens from the given sources (root-relative or absolute). Stylesheets + * are read together (a `@theme` may consume `:root` vars declared in another file); + * `tailwind.config.*` keys load first so a v4 `@theme` wins on conflict. Unreadable + * paths are skipped and left out of `sources`. + * @param {string} root @param {string[]} paths + * @returns {ThemeTokens} + */ +export function loadThemeTokens(root, paths) { + const css = []; + const configs = []; + const sources = []; + for (const p of [...new Set(paths)].sort()) { + let text; + try { + text = readFileSync(isAbsolute(p) ? p : join(root, p), "utf8"); + } catch { + continue; + } + sources.push(p); + (THEME_CONFIG_RE.test(basename(p)) ? configs : css).push(text); + } + const fromCss = themeFromCss(css.join("\n")); + const theme = emptyTheme(); + for (const c of configs) mergeTheme(theme, themeFromTailwindConfig(c, { vars: fromCss.vars })); + mergeTheme(theme, fromCss); + theme.sources = sources; + return theme; } // --------------------------------------------------------------------------- @@ -596,14 +1115,31 @@ const CONFORM_HINTS = { * fingerprint exists) conform ≤ tauConform. Violations name the driving feature and * a concrete edit; because each per-feature distance is in [0,1], a failing mean * always has at least one failing feature — a FAIL can never arrive hint-less. + * An EMPTY vector is neither: nothing was measured, so the verdict is + * `insufficient-signal` (pass:false) — silence is not evidence of good design. * @param {Fingerprint} fingerprint * @param {{projectFp?:Fingerprint|null, tauSlop?:number, tauConform?:number}} [opts] - * @returns {{pass:boolean, slop:number, conform:number|null, - * violations:{feature:string, detail:string, hint:string}[]}} + * @returns {{pass:boolean, verdict:"pass"|"fail"|"insufficient-signal", slop:number, + * conform:number|null, violations:{feature:string, detail:string, hint:string}[]}} */ export function uiGate(fingerprint, opts = {}) { const { projectFp = null, tauSlop, tauConform } = { ...UI_GATE_DEFAULTS, ...opts }; const fp = asFp(fingerprint); + if (!hasDesignSignal(fp)) + return { + pass: false, + verdict: "insufficient-signal", + slop: slopDistance(fp), + conform: null, + violations: [ + { + feature: "signal", + detail: + "no measurable design feature — no color, spacing, font, radius or shadow was found, so there is nothing to gate", + hint: "gate the files that carry the UI's styles or classes (markup alone has nothing to measure); Tailwind token utilities (rounded-card, bg-brand) resolve through the project's @theme stylesheet or tailwind.config — name it with --theme if discovery misses it", + }, + ], + }; const violations = []; const near = nearestGeneric(fp); const slop = near?.distance ?? 1; @@ -631,7 +1167,20 @@ export function uiGate(fingerprint, opts = {}) { }); } } - return { pass: violations.length === 0, slop, conform, violations }; + const pass = violations.length === 0; + return { pass, verdict: pass ? "pass" : "fail", slop, conform, violations }; +} + +/** + * The run's overall verdict: the gate's, except a gate PASS with a failing check + * (scale / taste) is a FAIL. `insufficient-signal` always survives — it is not a + * PASS no matter what the (vacuous) checks say. + * @param {{pass:boolean, verdict?:string}} gate @param {{pass:boolean}[]} checks + * @returns {"pass"|"fail"|"insufficient-signal"} + */ +export function overallVerdict(gate, checks) { + if (gate.verdict === "insufficient-signal") return "insufficient-signal"; + return gate.pass && checks.every((c) => c.pass) ? "pass" : "fail"; } // --------------------------------------------------------------------------- @@ -827,11 +1376,13 @@ export function profileChecks(fingerprint, profile) { * Extract the project fingerprint from `files` and store it as a `fingerprint` * claim. Content-addressed: the same UI surface mints the same id on every machine, * so teammates converge on one claim instead of duplicating. - * @param {string} root @param {string[]} files @param {{t?:number}} [opts] + * @param {string} root @param {string[]} files + * @param {{t?:number, theme?:ThemeTokens|null}} [opts] `theme`: see fingerprintText — + * mint with the same theme `design` gates with, or token utilities won't match. * @returns {{ok:true, id:string, existed:boolean, fingerprint:Fingerprint}|{ok:false, reason:string}} */ -export function mintProjectFingerprint(root, files, { t = 0 } = {}) { - const fingerprint = fingerprintFiles(root, files); +export function mintProjectFingerprint(root, files, { t = 0, theme = null } = {}) { + const fingerprint = fingerprintFiles(root, files, { theme }); const minted = mintClaim({ kind: "fingerprint", body: fingerprint, diff --git a/src/uivisual.js b/src/uivisual.js index b87d0ec9..3d32b5e5 100644 --- a/src/uivisual.js +++ b/src/uivisual.js @@ -24,6 +24,7 @@ import { fingerprintText, loadProjectFingerprint, loadTasteProfile, + overallVerdict, profileChecks, scaleChecks, UI_GATE_DEFAULTS, @@ -310,8 +311,8 @@ export async function renderedFingerprint(target, opts = {}) { * `taste` is the EXPLICIT profile name (unknown → error, like `design --taste`); * when omitted, a `forge taste`-managed DESIGN.md style is picked up automatically. * @returns {Promise<{ok:false, skipped?:boolean, reason:string}|{ok:true, fail:boolean, - * pass:boolean, slop:number, conform:number|null, violations:object[], checks:object[], - * fingerprint:object, screenshots:string[], elements:number, url:string, + * pass:boolean, verdict:"pass"|"fail"|"insufficient-signal", slop:number, + * conform:number|null, violations:object[], checks:object[], fingerprint:object, screenshots:string[], elements:number, url:string, * hasProjectFingerprint:boolean, taste:string|null, tauSlop:number, tauConform:number}>} */ export async function visualGate(target, opts = {}) { @@ -333,10 +334,13 @@ export async function visualGate(target, opts = {}) { ...scaleChecks(r.fingerprint), ...(profile ? profileChecks(r.fingerprint, profile) : []), ]; + // An empty rendered vector is insufficient-signal, never a PASS (same as `design`). + const verdict = overallVerdict(gate, checks); return { ok: true, - fail: !gate.pass || checks.some((c) => !c.pass), + fail: verdict !== "pass", ...gate, + verdict, checks, fingerprint: r.fingerprint, screenshots: r.screenshots, diff --git a/test/uifingerprint.test.js b/test/uifingerprint.test.js index c11a893f..cb1f16c7 100644 --- a/test/uifingerprint.test.js +++ b/test/uifingerprint.test.js @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; @@ -10,19 +10,27 @@ import { ASSERTABLE_CHECKS } from "../src/uicheck.js"; import { activeTasteStyle, conformance, + findThemeSources, fingerprintFiles, fingerprintText, GENERIC_SIGNATURES, + hasDesignSignal, inferSpacingBase, loadProjectFingerprint, loadTasteProfile, + loadThemeTokens, mintProjectFingerprint, nearestGeneric, onScaleFraction, + overallVerdict, profileChecks, resolveCssVars, + rgbToHsl, scaleChecks, slopDistance, + themeFromCss, + themeFromTailwindConfig, + themeSourcesFor, UI_GATE_DEFAULTS, uiGate, } from "../src/uifingerprint.js"; @@ -397,3 +405,330 @@ test("cli: fingerprint --mint stores the project claim; design then gates agains assert.equal(gate.hasProjectFingerprint, true); assert.equal(gate.conform, 0); }); + +// --------------------------------------------------------------------------- +// Token-based Tailwind: theme tokens, arbitrary values, insufficient signal. +// --------------------------------------------------------------------------- + +// A Tailwind v4 stylesheet in the shape real projects ship: semantic tokens in +// `@theme inline` pointing at `:root` vars, shadcn's `hsl(var(--x))`, an oklch +// accent, a redefined default key, calc() radii and a `--color-*` reset line. +const V4_THEME_CSS = `@import "tailwindcss"; +:root { + --hl-brand: #0b6aac; --hl-fg: #0d1624; --radius: 1rem; --border: 210 21% 87%; + --hl-shadow-2: 0 1px 2px rgb(13 22 36 / 0.06), 0 10px 28px -10px rgb(15 60 110 / 0.18); + --color-outside: #123456; +} +@theme inline { + --color-brand: var(--hl-brand); + --color-fg: var(--hl-fg); + --color-rule: hsl(var(--border)); + --color-accent: oklch(0.65 0.2 30); + --color-blue-500: #0f75bc; + --shadow-lift: var(--hl-shadow-2); +} +@theme { + --color-*: initial; + --font-display: "Fraunces", serif; + --radius-control: 0.625rem; + --radius-card: 1rem; + --radius-md: calc(var(--radius) - 2px); + --radius-pill: calc(infinity * 1px); +} +`; + +// Token utilities ONLY — before this fix the fingerprint saw nothing here and the +// gate printed PASS. +const TOKEN_ONLY_TSX = `export const Card = () => ( +
+);`; + +const TOKEN_TSX = `export const Card = () => ( +
+ +
+);`; + +const hex = (h) => + rgbToHsl(parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)); + +test("themeFromCss: @theme --color-*/--radius-*/--shadow-* land with var(), hsl(var()), oklch and calc() resolved", () => { + const theme = themeFromCss(V4_THEME_CSS); + assert.deepEqual(theme.colors.get("brand"), hex("#0b6aac")); + assert.deepEqual(theme.colors.get("fg"), hex("#0d1624")); + assert.deepEqual(theme.colors.get("rule"), hex("#d7dee5"), "shadcn hsl(var(--border))"); + assert.ok(theme.colors.get("accent"), "oklch token parsed"); + assert.deepEqual(theme.colors.get("blue-500"), hex("#0f75bc")); + assert.equal(theme.colors.has("outside"), false, "only @theme declarations are tokens"); + assert.equal(theme.colors.has("*"), false, "the --color-* reset line is not a key"); + assert.equal(theme.radius.get("control"), 10); + assert.equal(theme.radius.get("card"), 16); + assert.equal(theme.radius.get("md"), 14, "calc(1rem - 2px)"); + assert.equal(theme.radius.get("pill"), 9999, "calc(infinity * 1px) is a pill"); + assert.equal( + theme.shadow.get("lift"), + "0 1px 2px rgb(13 22 36 / 0.06), 0 10px 28px -10px rgb(15 60 110 / 0.18)", + "whitespace-normalized value", + ); + assert.equal(theme.vars.get("--radius"), "1rem", "the sources' custom properties ride along"); +}); + +test("fingerprintText: token utilities resolve through the theme (rounded-*/shadow-*/bg|text|border|ring-*)", () => { + const theme = themeFromCss(V4_THEME_CSS); + const fp = fingerprintText(TOKEN_TSX, { theme }); + assert.deepEqual( + fp.radii, + [10, 14, 16], + "rounded-control, rounded-t-md (theme md), rounded-card", + ); + assert.equal(fp.shadowLevels, 1, "shadow-lift"); + for (const c of ["#0b6aac", "#0d1624", "#d7dee5", "#0f75bc", "#ffffff"]) + assert.ok( + fp.palette.some((p) => JSON.stringify(p) === JSON.stringify(hex(c))), + `${c} in palette`, + ); + assert.ok( + !fp.palette.some((p) => p.h === 217 && p.s === 91), + "a theme-redefined blue-500 replaces the default-family approximation", + ); + assert.equal(fp.paletteSize, 6, "brand, fg, rule, accent, blue-500, #fff"); + // The same markup without the theme only sees the default-key utilities. + const bare = fingerprintText(TOKEN_TSX); + assert.deepEqual(bare.radii, [6], "only rounded-t-md, at Tailwind's default 6px"); + assert.equal(bare.shadowLevels, 0); +}); + +test("hasDesignSignal: token-only markup is empty without its theme, measurable with it", () => { + assert.equal(hasDesignSignal(fingerprintText("")), false, "an empty file"); + assert.equal(hasDesignSignal(fingerprintText("export const C = () =>
;")), false); + assert.equal(hasDesignSignal(fingerprintText(TOKEN_ONLY_TSX)), false, "no theme → nothing"); + const fp = fingerprintText(TOKEN_ONLY_TSX, { theme: themeFromCss(V4_THEME_CSS) }); + assert.equal(hasDesignSignal(fp), true); + assert.deepEqual(fp.radii, [16]); + assert.equal(fp.shadowLevels, 1); + assert.equal(fp.paletteSize, 2, "bg-brand/40 (opacity modifier ignored) + text-fg"); +}); + +test("fingerprintText: arbitrary values — rounded-[..], shadow-[..], p-[..], text-[#..], bg-[oklch(..)]", () => { + const fp = fingerprintText( + `
`, + ); + assert.deepEqual(fp.radii, [8, 13]); + assert.equal(fp.shadowLevels, 2); + assert.deepEqual(fp.spacing, [3, 5, 7, 13]); + assert.ok(fp.palette.some((p) => JSON.stringify(p) === JSON.stringify(hex("#abcdef")))); + assert.ok(fp.palette.some((p) => JSON.stringify(p) === JSON.stringify(hex("#0a141e")))); + // #abcdef, oklch, rgb(10 20 30), and #000 / #0003 inside the arbitrary shadows; + // text-[13px] (a size) and bg-[url()] are not colors. + assert.equal(fp.paletteSize, 4, JSON.stringify(fp.palette)); +}); + +test("fingerprintText: oklch() in plain CSS counts as a color (Tailwind v4's default syntax)", () => { + const fp = fingerprintText(".a { color: oklch(62.8% 0.2577 29.23); }"); + assert.equal(fp.paletteSize, 1); + assert.equal(fp.palette[0].h, 0, "sRGB red"); +}); + +test("resolveCssVars: `vars` seeds outside declarations; the text's own still win", () => { + const vars = new Map([["--brand", "#123456"]]); + assert.match(resolveCssVars(".a { color: var(--brand); }", { vars }), /color: #123456/); + assert.match( + resolveCssVars(":root { --brand: #abcdef; } .a { color: var(--brand); }", { vars }), + /color: #abcdef/, + ); +}); + +const V3_CONFIG = `/** @type {import('tailwindcss').Config} */ +const colors = require("tailwindcss/colors"); +module.exports = { + content: ["./src/**/*.{ts,tsx}"], + theme: { + extend: { + colors: { + ...colors, + brand: { DEFAULT: "#0f75bc", soft: 'hsl(var(--brand-soft))', 900: "#00355b" }, + "on-brand": "#ffffff", // trailing comment + /* block comment */ dynamic: \`\${"x"}\`, + computed: someFn("#000", { nested: true }), + [key]: "#111111", + }, + borderRadius: { DEFAULT: "6px", card: "1rem", huge: "calc(infinity * 1px)", half: "50%" }, + boxShadow: { lift: "0 10px 28px -10px rgba(15, 60, 110, 0.18)" }, + }, + }, + plugins: [require("@tailwindcss/forms")], +}; +`; + +test("themeFromTailwindConfig: v3 objects read statically — nested families, DEFAULT, var() via stylesheet vars", () => { + const vars = new Map([["--brand-soft", "205 90% 95%"]]); + const theme = themeFromTailwindConfig(V3_CONFIG, { vars }); + assert.deepEqual([...theme.colors.keys()].sort(), [ + "brand", + "brand-900", + "brand-soft", + "on-brand", + ]); + assert.deepEqual(theme.colors.get("brand"), hex("#0f75bc")); + assert.ok(theme.colors.get("brand-soft"), "hsl(var(--brand-soft)) resolved through vars"); + assert.equal(theme.radius.get(""), 6, "DEFAULT → the bare `rounded`"); + assert.equal(theme.radius.get("card"), 16); + assert.equal(theme.radius.get("huge"), 9999); + assert.equal(theme.radius.has("half"), false, "a % radius is not an absolute length"); + assert.equal(theme.shadow.get("lift"), "0 10px 28px -10px rgba(15, 60, 110, 0.18)"); + // Spreads, template literals with ${}, calls and computed keys are skipped, not run. + assert.equal(theme.colors.has("dynamic"), false); + assert.equal(theme.colors.has("computed"), false); + const fp = fingerprintText(``, { theme }); + assert.deepEqual(fp.radii, [6], "bare rounded uses the configured DEFAULT"); +}); + +/** A little Next.js-shaped project with a v4 theme and decoys discovery must skip. */ +function themeRepo() { + const root = tmp(); + mkdirSync(join(root, "src", "app"), { recursive: true }); + mkdirSync(join(root, "src", "components"), { recursive: true }); + mkdirSync(join(root, "node_modules", "pkg"), { recursive: true }); + mkdirSync(join(root, ".claude", "worktrees", "old", "src"), { recursive: true }); + writeFileSync(join(root, "src", "app", "globals.css"), V4_THEME_CSS); + writeFileSync(join(root, "src", "app", "plain.css"), ".x { color: red; }"); + writeFileSync(join(root, "src", "components", "Card.tsx"), TOKEN_TSX); + writeFileSync(join(root, "src", "components", "Token.tsx"), TOKEN_ONLY_TSX); + writeFileSync(join(root, "node_modules", "pkg", "theme.css"), "@theme { --radius-x: 1px; }"); + writeFileSync( + join(root, ".claude", "worktrees", "old", "src", "globals.css"), + "@theme { --radius-card: 99px; }", + ); + return root; +} + +test("findThemeSources: @theme/@tailwind stylesheets + tailwind.config; node_modules and dot-dirs skipped", () => { + const root = themeRepo(); + assert.deepEqual(findThemeSources(root), ["src/app/globals.css"]); + writeFileSync(join(root, "tailwind.config.ts"), V3_CONFIG); + writeFileSync(join(root, "src", "app", "legacy.css"), "@tailwind base;\n@tailwind utilities;"); + assert.deepEqual(findThemeSources(root), [ + "src/app/globals.css", + "src/app/legacy.css", + "tailwind.config.ts", + ]); + assert.deepEqual(findThemeSources(root, { maxDepth: 0 }), ["tailwind.config.ts"]); +}); + +test("themeSourcesFor: explicit --theme replaces discovery; a theme-bearing input always counts", () => { + const root = themeRepo(); + writeFileSync(join(root, "tokens.css"), "@theme { --radius-card: 2px; }"); + assert.deepEqual(themeSourcesFor(root, ["src/components/Card.tsx"]), [ + "src/app/globals.css", + "tokens.css", + ]); + assert.deepEqual(themeSourcesFor(root, ["src/components/Card.tsx"], ["./tokens.css"]), [ + "tokens.css", + ]); + assert.deepEqual( + themeSourcesFor(root, ["./src/app/globals.css", "src/components/Card.tsx"], ["tokens.css"]), + ["src/app/globals.css", "tokens.css"], + "normalized + deduplicated", + ); +}); + +test("loadThemeTokens: stylesheets read together, config merged under them, unreadable paths dropped", () => { + const root = themeRepo(); + writeFileSync(join(root, "tailwind.config.js"), V3_CONFIG); + // brand is #0f75bc in the config but var(--hl-brand) = #0b6aac in the v4 @theme. + const theme = loadThemeTokens(root, ["tailwind.config.js", "src/app/globals.css", "ghost.css"]); + assert.deepEqual(theme.sources, ["src/app/globals.css", "tailwind.config.js"]); + assert.deepEqual(theme.colors.get("brand"), hex("#0b6aac"), "@theme wins on conflict"); + assert.deepEqual(theme.colors.get("brand-900"), hex("#00355b"), "config-only keys survive"); + assert.equal(theme.radius.get("card"), 16); + assert.equal(theme.shadow.has("lift"), true); +}); + +test("uiGate: an empty vector is insufficient-signal — not PASS — with a named fix", () => { + const gate = uiGate(fingerprintText("export const C = () =>
;")); + assert.equal(gate.pass, false); + assert.equal(gate.verdict, "insufficient-signal"); + assert.equal(gate.violations[0].feature, "signal"); + assert.match(gate.violations[0].hint, /--theme/); + // Even against a project fingerprint, nothing measured is not "conforming". + const vsProject = uiGate(fingerprintText(""), { projectFp: fingerprintText(CUSTOM_CSS) }); + assert.equal(vsProject.verdict, "insufficient-signal"); + assert.equal(uiGate(fingerprintText(CUSTOM_CSS)).verdict, "pass"); + assert.equal(uiGate(fingerprintText(GENERIC_CSS)).verdict, "fail"); +}); + +test("overallVerdict: failing checks turn a gate PASS into FAIL; insufficient-signal always survives", () => { + const ok = [{ pass: true }]; + const bad = [{ pass: false }]; + assert.equal(overallVerdict({ pass: true, verdict: "pass" }, ok), "pass"); + assert.equal(overallVerdict({ pass: true, verdict: "pass" }, bad), "fail"); + assert.equal(overallVerdict({ pass: false, verdict: "fail" }, ok), "fail"); + assert.equal( + overallVerdict({ pass: false, verdict: "insufficient-signal" }, ok), + "insufficient-signal", + ); +}); + +test("mintProjectFingerprint: the minted vector includes theme-resolved tokens", () => { + const root = themeRepo(); + const theme = loadThemeTokens(root, ["src/app/globals.css"]); + const m = mintProjectFingerprint(root, ["src/components/Card.tsx"], { t: 1, theme }); + assert.equal(m.ok, true); + assert.ok(m.ok); + assert.deepEqual(m.fingerprint.radii, [10, 14, 16]); + assert.deepEqual(loadProjectFingerprint(root).radii, [10, 14, 16]); +}); + +test("cli: `uicheck design` on an empty file is INSUFFICIENT SIGNAL with a non-zero exit", () => { + const cwd = tmp(); + writeFileSync(join(cwd, "empty.tsx"), ""); + const r = runCli(["uicheck", "design", "empty.tsx"], cwd); + assert.equal(r.status, 1, r.stdout + r.stderr); + assert.match(r.stdout, /INSUFFICIENT SIGNAL/); + assert.doesNotMatch(r.stdout, /✓ PASS/); + const j = runCli(["uicheck", "design", "empty.tsx", "--json"], cwd); + assert.equal(j.status, 1); + const out = JSON.parse(j.stdout); + assert.equal(out.verdict, "insufficient-signal"); + assert.equal(out.pass, false); +}); + +test("cli: `uicheck design|fingerprint` discover a Tailwind v4 @theme and resolve token utilities", () => { + const cwd = themeRepo(); + const fp = runCli(["uicheck", "fingerprint", "src/components/Card.tsx", "--json"], cwd); + assert.equal(fp.status, 0, fp.stderr); + const vec = JSON.parse(fp.stdout); + assert.deepEqual(vec.radii, [10, 14, 16]); + assert.equal(vec.shadowLevels, 1); + const text = runCli(["uicheck", "fingerprint", "src/components/Card.tsx"], cwd); + assert.match(text.stdout, /theme: +src\/app\/globals\.css \(\d+ color · 4 radius · 1 shadow/); + // The token-only component: PASS-by-emptiness before; measured (and gated) now. + const d = runCli(["uicheck", "design", "src/components/Token.tsx", "--json"], cwd); + const out = JSON.parse(d.stdout); + assert.notEqual(out.verdict, "insufficient-signal"); + assert.deepEqual(out.theme.sources, ["src/app/globals.css"]); + assert.equal(out.theme.radius, 4); +}); + +test("cli: `--theme ` names the theme explicitly; a valueless --theme is a usage error", () => { + const cwd = tmp(); + mkdirSync(join(cwd, ".design")); + // Hidden from discovery (dot-dir) — only the explicit flag can find it. + writeFileSync(join(cwd, ".design", "theme.css"), V4_THEME_CSS); + writeFileSync(join(cwd, "Token.tsx"), TOKEN_ONLY_TSX); + const none = runCli(["uicheck", "design", "Token.tsx", "--json"], cwd); + assert.equal(JSON.parse(none.stdout).verdict, "insufficient-signal"); + const named = runCli( + ["uicheck", "design", "Token.tsx", "--theme", ".design/theme.css", "--json"], + cwd, + ); + const out = JSON.parse(named.stdout); + assert.notEqual(out.verdict, "insufficient-signal"); + assert.deepEqual(out.theme.sources, [".design/theme.css"]); + const bad = runCli(["uicheck", "design", "Token.tsx", "--theme"], cwd); + assert.equal(bad.status, 1); + assert.match(bad.stderr, /usage: .*--theme /); + const ghost = runCli(["uicheck", "design", "Token.tsx", "--theme", "nope.css"], cwd); + assert.equal(ghost.status, 1, "a named theme that isn't there is an error, not a no-op"); + assert.match(ghost.stderr, /theme source not found: nope\.css/); +}); diff --git a/test/uivisual.test.js b/test/uivisual.test.js index 61ae25ee..96a4ebf6 100644 --- a/test/uivisual.test.js +++ b/test/uivisual.test.js @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { existsSync, mkdtempSync } from "node:fs"; +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, sep } from "node:path"; import { test } from "node:test"; @@ -187,6 +187,32 @@ test("visualGate: unresolvable target is a plain error, not a skip", async () => }); }); +// A stand-in playwright whose page paints `records` — the gate logic without a browser. +const fakePw = (records) => ({ + chromium: { + launch: async () => ({ + newPage: async () => ({ + goto: async () => {}, + evaluate: async () => records, + screenshot: async () => {}, + close: async () => {}, + }), + close: async () => {}, + }), + }, +}); + +test("visualGate: a page that paints nothing measurable is insufficient-signal, never PASS", async () => { + const root = tmp(); + const page = join(root, "blank.html"); + writeFileSync(page, "x"); + const r = await visualGate(page, { root, pw: fakePw([]) }); + assert.equal(r.ok, true, r.ok ? "" : r.reason); + assert.ok(r.ok); // narrow + assert.equal(r.verdict, "insufficient-signal"); + assert.equal(r.fail, true, "the CLI exits non-zero on it"); +}); + // --------------------------------------------------------------------------- // The live loop — auto-skips unless a playwright runtime resolves (point // FORGE_PLAYWRIGHT at an install, e.g. .../node_modules/playwright-core, to run it). From 15f0666799fa0b7c3fa7a6ecbb53fd19a11a8add Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 22:28:28 +0000 Subject: [PATCH 3/3] fix(uicheck): measure the default theme; refuse to mint an empty fingerprint Follow-ups from the independent review of the two previous commits. Each one was reproduced on 2f8651f before the fix. - Dark-mode overrides no longer win var() resolution. `.dark {}`, `[data-theme=dark] {}` and `@media (prefers-color-scheme: dark) {}` declarations are applied only to properties with no default. Negations such as `:root:not(.dark)` count as light scope. On HostLelo's globals.css, bg-canvas resolved to the dark #080f17; it now resolves to the default #f1f5f9. - contrastReport quantizes the background before compositing the foreground, exactly as contrastRatio does. For rgba(0,0,0,.5) on rgba(255,0,0,.3) it reported #805959 at 3.54 while contrastRatio gave 3.50. Both now give #805a5a at 3.50. - shadow-[#123456] and shadow-[color:...] are shadow colors: they count in the palette, not as an elevation level. Fully transparent colors (transparent, alpha 0 in hex/rgb/hsl/oklch, transparent theme tokens) are no longer palette entries counted as black. - calc() evaluation and the tailwind.config object reader cap their nesting depth. About 5000 nested parens, or 20000 nested config objects, threw RangeError and crashed `uicheck design`. - mintProjectFingerprint refuses an empty vector (ok:false, insufficient-signal) and writes nothing. `fingerprint --mint` then exits 1. Before, it stored an empty "home" that failed every later `design` run. CHANGELOG gains a "Changed" entry for the three new non-zero exits (contrast AA failure, insufficient-signal, empty --mint). GUIDE, the Mintlify pages and the --mint help text describe the new rules. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UUhB8JaPayd43w37dxiXrW Signed-off-by: Claude --- CHANGELOG.md | 12 ++ docs/GUIDE.md | 12 +- mintlify/cli/quality.mdx | 1 + mintlify/concepts/verification-gates.mdx | 1 + src/commands.js | 5 +- src/uicheck.js | 6 +- src/uifingerprint.js | 158 +++++++++++++++++++---- test/uicheck.test.js | 14 ++ test/uifingerprint.test.js | 113 ++++++++++++++++ 9 files changed, 293 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50330750..dcab2767 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed + +- **`forge uicheck` now exits 1 in three cases that used to exit 0.** A script or CI step + that calls it should expect a non-zero exit when: + - `contrast` (or the bare `uicheck `) grades a pair below WCAG AA. + - `design` or `visual` finds nothing measurable (verdict `insufficient-signal`). + - `fingerprint --mint` is given files with no measurable feature. Nothing is stored, + because an empty project fingerprint would fail every later `design` run. + ### Fixed - **`forge uicheck contrast` exits 1 when a pair fails WCAG AA.** Before, it printed @@ -21,6 +30,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `rounded-*`, `shadow-*` and `bg|text|border|ring|fill|stroke-*` utilities resolve through those tokens. Arbitrary values (`rounded-[13px]`, `shadow-[…]`, `p-[…]`, `text-[#…]`) are parsed too. + - The default theme is measured: a custom property redeclared for dark mode (`.dark`, + `[data-theme="dark"]`, `prefers-color-scheme: dark`) no longer overrides its default + value. Fully transparent colors no longer count as black. A file with no measurable feature now gets the verdict `insufficient-signal` and exits 1. Before, an empty `
` printed PASS. `uicheck visual` applies the same rule. Because diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 09dcbb67..04098d89 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -1164,7 +1164,8 @@ $ forge uicheck contrast "rgb(0 0 0 / 50%)" "#fff" **`fingerprint [--theme ]... [--mint]`** — the design feature vector of your UI files: palette (hue histogram), spacing base + on-scale fraction, fonts, radius/shadow levels. `--mint` stores it as a shared `fingerprint` ledger claim — the -design gate's "home": +design gate's "home". It refuses an empty vector (exit 1, nothing stored): a "home" with +no features would fail every later `design` run. ```console $ forge uicheck fingerprint src/components/*.jsx --mint @@ -1195,7 +1196,14 @@ project's theme tokens and resolve those utilities through them: utilities match those keys (an `/opacity` modifier is ignored). A theme key that redefines a default (`--color-blue-500`, `--radius-md`) wins over Tailwind's default. - **Arbitrary values** are parsed in place: `rounded-[13px]`, `shadow-[0_1px_2px_#000]`, - `p-[13px]`, `text-[#abc]`, `bg-[oklch(0.6_0.1_250)]`. + `p-[13px]`, `text-[#abc]`, `bg-[oklch(0.6_0.1_250)]`. `shadow-[#123456]` and + `shadow-[color:…]` set a shadow _color_, so they count in the palette, not as an + elevation level. Fully transparent colors (`transparent`, alpha 0) are not palette + entries. +- **Light/dark themes:** the default theme is what gets measured. A custom property + redeclared inside a dark-mode block (`.dark { … }`, `[data-theme="dark"] { … }`, + `@media (prefers-color-scheme: dark) { … }`) never overrides its default value; one + declared only for dark mode still resolves. - **Discovery:** theme sources are found under the working directory. That means every `tailwind.config.*` and every stylesheet with `@theme`, `@tailwind` or `@import "tailwindcss"`. `node_modules`, build output and dot-directories (`.git`, diff --git a/mintlify/cli/quality.mdx b/mintlify/cli/quality.mdx index b163252b..1fd0c914 100644 --- a/mintlify/cli/quality.mdx +++ b/mintlify/cli/quality.mdx @@ -76,6 +76,7 @@ project's theme: Tailwind v4 `@theme` stylesheets and `tailwind.config.*` files, automatically or named with `--theme`. Arbitrary values such as `rounded-[13px]` are parsed too. `design` exits 1 on `fail` and on `insufficient-signal`: when the files carry no measurable color, spacing, font, radius or shadow, the verdict is never PASS. +`fingerprint --mint` exits 1 on such files instead of storing an empty project fingerprint. ## `forge harden` diff --git a/mintlify/concepts/verification-gates.mdx b/mintlify/concepts/verification-gates.mdx index d4d30e3b..13557879 100644 --- a/mintlify/concepts/verification-gates.mdx +++ b/mintlify/concepts/verification-gates.mdx @@ -141,6 +141,7 @@ project's theme: Tailwind v4 `@theme` stylesheets and `tailwind.config.*` files, automatically or named with `--theme`. Arbitrary values such as `rounded-[13px]` are parsed too. `design` exits 1 on `fail` and on `insufficient-signal`: when the files carry no measurable color, spacing, font, radius or shadow, the verdict is never PASS. +`fingerprint --mint` exits 1 on such files instead of storing an empty project fingerprint. Pair it with `forge taste` to pick one visual direction (brutalist, corporate, editorial, minimalist, playful) and parameterize the `design` gate thresholds. diff --git a/src/commands.js b/src/commands.js index 29463f61..2e046c75 100644 --- a/src/commands.js +++ b/src/commands.js @@ -176,7 +176,10 @@ export const COMMANDS = { flag: "--taste ", desc: "design/visual: gate thresholds + checks from a taste profile (default: the style pinned by a forge-taste DESIGN.md)", }, - { flag: "--mint", desc: "fingerprint: store the vector as the project's design claim" }, + { + flag: "--mint", + desc: "fingerprint: store the vector as the project's design claim (refused, exit 1, when it is empty)", + }, ], examples: [ 'forge uicheck contrast "#777" "#fff"', diff --git a/src/uicheck.js b/src/uicheck.js index 1a4a3fa1..9b3950cf 100644 --- a/src/uicheck.js +++ b/src/uicheck.js @@ -248,8 +248,10 @@ export function wcagLevel(ratio, { large = false } = {}) { export function contrastReport(fg, bg, { large = false } = {}) { const f = parseColor(fg); const b = parseColor(bg); - const back = opaque(b); - const front = compositeOver(f, back); + // Quantized exactly as contrastRatio does, so the ratio, the hexes and + // contrastRatio(fg, bg) always agree (even when BOTH colors are translucent). + const back = quantize(opaque(b)); + const front = quantize(compositeOver(f, back)); const notes = []; if (b.a < 1) notes.push( diff --git a/src/uifingerprint.js b/src/uifingerprint.js index e1a83e01..1a633ef1 100644 --- a/src/uifingerprint.js +++ b/src/uifingerprint.js @@ -74,8 +74,20 @@ const TW_FAMILY_HS = { }; const HEX_RE = /#([0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{4}|[0-9a-f]{3})\b/gi; -const RGB_RE = /rgba?\(\s*(\d{1,3})[,\s]+(\d{1,3})[,\s]+(\d{1,3})/gi; -const HSL_RE = /hsla?\(\s*([\d.]+)(?:deg)?[,\s]+([\d.]+)%[,\s]+([\d.]+)%/gi; +// The optional last group is the alpha (`, 0` or `/ 0%`): only used to skip a fully +// transparent color, which paints nothing. +const ALPHA_TAIL = String.raw`(?:\s*[,/]\s*([\d.]+%?))?`; +const RGB_RE = new RegExp( + String.raw`rgba?\(\s*(\d{1,3})[,\s]+(\d{1,3})[,\s]+(\d{1,3})${ALPHA_TAIL}`, + "gi", +); +const HSL_RE = new RegExp( + String.raw`hsla?\(\s*([\d.]+)(?:deg)?[,\s]+([\d.]+)%[,\s]+([\d.]+)%${ALPHA_TAIL}`, + "gi", +); +/** Is a captured alpha (`0`, `0.0`, `0%`) zero? Undefined (no alpha) is opaque. */ +const zeroAlpha = (/** @type {string|undefined} */ a) => + a !== undefined && +a.replace("%", "") === 0; const TW_COLOR_RE = new RegExp( `\\b(?:bg|text|border|from|via|to|ring|outline|fill|stroke|accent|caret|decoration|divide|shadow)-(${Object.keys(TW_FAMILY_HS).join("|")})-(50|100|200|300|400|500|600|700|800|900|950)\\b`, "g", @@ -100,15 +112,28 @@ const arbitrary = (key) => key.slice(1, -1).replace(/_/g, " "); const hslOf = (/** @type {{r:number,g:number,b:number}} */ c) => rgbToHsl(c.r, c.g, c.b); -/** parseColor, but null instead of a throw — for scanning free text. */ +/** parseColor, but null instead of a throw — for scanning free text. A fully + * transparent color (`transparent`, alpha 0) is null too: it paints nothing, so it is + * not a palette entry (and must not count as black). */ function tryHsl(value) { try { - return hslOf(parseColor(value)); + const c = parseColor(value); + return c.a === 0 ? null : hslOf(c); } catch { return null; } } +/** Does the whole string parse as one color? */ +function isColor(value) { + try { + parseColor(value); + return true; + } catch { + return false; + } +} + /** * @param {string} text * @param {ThemeTokens|null} [theme] resolves `bg-brand`-style token utilities @@ -118,7 +143,9 @@ function parseColors(text, theme = null) { const out = []; for (const [, hex] of text.matchAll(HEX_RE)) { // 3/4-digit shorthand expands per CSS; a trailing alpha channel is ignored — the - // fingerprint cares about hue identity, not opacity. + // fingerprint cares about hue identity, not opacity — except alpha 0 (#0000, + // #rrggbb00), which paints nothing. + if (/^(?:[0-9a-f]{3}0|[0-9a-f]{6}00)$/i.test(hex)) continue; const full = hex.length <= 4 ? [...hex.slice(0, 3)].map((c) => c + c).join("") : hex.slice(0, 6); out.push( @@ -129,9 +156,10 @@ function parseColors(text, theme = null) { ), ); } - for (const [, r, g, b] of text.matchAll(RGB_RE)) out.push(rgbToHsl(+r, +g, +b)); - for (const [, h, s, l] of text.matchAll(HSL_RE)) - out.push({ h: Math.round(+h) % 360, s: Math.round(+s), l: Math.round(+l) }); + for (const [, r, g, b, a] of text.matchAll(RGB_RE)) + if (!zeroAlpha(a)) out.push(rgbToHsl(+r, +g, +b)); + for (const [, h, s, l, a] of text.matchAll(HSL_RE)) + if (!zeroAlpha(a)) out.push({ h: Math.round(+h) % 360, s: Math.round(+s), l: Math.round(+l) }); for (const [fn] of text.matchAll(OKLAB_FN_RE)) { const c = tryHsl(fn.replace(/_/g, " ")); if (c) out.push(c); @@ -181,17 +209,72 @@ const substituteVars = (/** @type {string} */ s, /** @type {Map} return fallback !== undefined ? String(fallback).trim() : whole; }); +// A block scoped to dark mode: a `.dark` class (Tailwind / shadcn), a +// `[data-theme="dark"]`-style attribute (next-themes), `prefers-color-scheme: dark`, +// or Tailwind's `@variant dark`. Negations (`:root:not(.dark)`, `@media not (…)`) are +// removed first: they scope a block to LIGHT mode. +const DARK_SCOPE_RE = + /\.dark(?![\w-])|\[\s*data-[\w-]+\s*[~|^$*]?=\s*["']?dark["']?\s*[is]?\s*\]|prefers-color-scheme\s*:\s*dark\b|@(?:custom-)?variant\s+dark\b/i; +const NEGATION_RE = /\bnot\s*\([^()]*\)/gi; +const isDarkPrelude = (/** @type {string} */ p) => DARK_SCOPE_RE.test(p.replace(NEGATION_RE, "")); + +/** + * Split CSS into the text OUTSIDE dark-mode-scoped blocks and the bodies INSIDE them + * (brace-matched, nesting included). Comments are blanked for the scan, so a brace or + * a `.dark` inside one never scopes a block. + * @param {string} text + * @returns {{base:string, dark:string}} + */ +function splitDarkScoped(text) { + const scan = text.replace(/\/\*[\s\S]*?\*\//g, (c) => " ".repeat(c.length)); + let base = ""; + let dark = ""; + let from = 0; // start of the pending `base` slice + let prelude = 0; // start of the current selector / at-rule prelude + for (let i = 0; i < scan.length; i++) { + const ch = scan[i]; + if (ch === "}" || ch === ";") prelude = i + 1; + else if (ch === "{") { + if (!isDarkPrelude(scan.slice(prelude, i))) { + prelude = i + 1; + continue; + } + let depth = 1; + let j = i + 1; + for (; j < scan.length && depth; j++) { + if (scan[j] === "{") depth++; + else if (scan[j] === "}") depth--; + } + base += text.slice(from, prelude); + dark += `${text.slice(i + 1, depth ? j : j - 1)}\n`; + from = j; + prelude = j; + i = j - 1; + } + } + return { base: base + text.slice(from), dark }; +} + /** * Every `--name: value` declaration in `text` (last wins), each resolved through the * others. `seed` declarations (a theme stylesheet's) are visible too but lose to the - * text's own. + * text's own. Declarations inside dark-mode-scoped blocks (`.dark {}`, + * `[data-theme=dark] {}`, `@media (prefers-color-scheme: dark) {}`) never override a + * default one — the default theme is what the fingerprint measures; a property + * declared ONLY for dark mode still resolves through its dark value. * @param {string} text @param {Map|null} [seed] * @returns {Map} */ function cssVarDecls(text, seed = null) { /** @type {Map} */ const decls = new Map(seed ?? []); - for (const [, name, value] of String(text).matchAll(VAR_DECL_RE)) decls.set(name, value.trim()); + const { base, dark } = splitDarkScoped(String(text)); + for (const [, name, value] of base.matchAll(VAR_DECL_RE)) decls.set(name, value.trim()); + /** @type {Map} */ + const darkOnly = new Map(); + for (const [, name, value] of dark.matchAll(VAR_DECL_RE)) + if (!decls.has(name)) darkOnly.set(name, value.trim()); + for (const [name, value] of darkOnly) decls.set(name, value); // Resolve the declarations themselves first (--a: var(--b)); 4 passes covers the // sane nesting depths and bounds a --a↔--b cycle to a fixed cost. for (let i = 0; i < 4; i++) { @@ -258,6 +341,8 @@ function parseLengths(value) { } const CALC_TOKEN_RE = /\s*(?:(infinity|\d*\.?\d+)(px|rem|em)?|([-+*/()]))/iy; +// Real calc() radii nest a few levels; anything deeper is not a design token. +const MAX_CALC_DEPTH = 32; /** * Evaluate a `calc()` body over px/rem/em lengths and unitless numbers (+ − × ÷, @@ -280,18 +365,25 @@ function evalCalc(expr) { } } let i = 0; + // Recursive descent: bound the nesting (parens + unary minus) so a pathological + // value is "not a length" (null) instead of a stack overflow that kills the run. + let depth = 0; /** @returns {{n:number, len:boolean}|null} */ const atom = () => { + if (depth >= MAX_CALC_DEPTH) return null; + depth++; const t = toks[i++]; + /** @type {{n:number, len:boolean}|null} */ + let v = null; if (t === "(") { - const v = sum(); - return toks[i++] === ")" ? v : null; - } - if (t === "-") { - const v = atom(); - return v && { n: -v.n, len: v.len }; - } - return typeof t === "object" ? t : null; + const inner = sum(); + v = toks[i++] === ")" ? inner : null; + } else if (t === "-") { + const inner = atom(); + v = inner && { n: -inner.n, len: inner.len }; + } else if (typeof t === "object") v = t; + depth--; + return v; }; const product = () => { let a = atom(); @@ -393,14 +485,18 @@ function twRadius(key, theme) { /** * The elevation level one `shadow-*` utility names, or null (none / not a shadow — - * `shadow-brand` is a shadow COLOR). Theme and arbitrary shadows key on their value, - * so `shadow-lift` and a CSS `box-shadow` with the same value are ONE level. + * `shadow-brand`, `shadow-[#123456]` and `shadow-[color:…]` set a shadow COLOR). Theme + * and arbitrary shadows key on their value, so `shadow-lift` and a CSS `box-shadow` + * with the same value are ONE level. * @param {string|undefined} key @param {ThemeTokens|null} theme */ function twShadow(key, theme) { if (key === undefined) return theme?.shadow.get("") ?? "tw:base"; if (key === "none") return null; - if (key.startsWith("[")) return normShadow(arbitrary(key)); + if (key.startsWith("[")) { + const value = arbitrary(key); + return value.startsWith("color:") || isColor(value) ? null : normShadow(value); + } if (theme?.shadow.has(key)) return theme.shadow.get(key) ?? null; return TW_SHADOW_KEYS.has(key) ? `tw:${key}` : null; } @@ -744,8 +840,12 @@ function objectLiteralLeaves(src, open) { i++; ws(); const c = src[i]; - if (c === "{") obj([...path, key]); - else if (c === '"' || c === "'" || c === "`") { + // Past MAX_CONFIG_DEPTH a nested object is skipped (iteratively), never recursed + // into: a pathological config must not overflow the stack. + if (c === "{") { + if (path.length < MAX_CONFIG_DEPTH) obj([...path, key]); + else skip(); + } else if (c === '"' || c === "'" || c === "`") { const v = str(); if (v !== null) leaves.push([[...path, key], v]); } else skip(); @@ -756,6 +856,8 @@ function objectLiteralLeaves(src, open) { } const CONFIG_KEY_RE = /\b(colors|borderRadius|boxShadow)\s*:\s*\{/g; +// Color families nest 1–2 levels (`brand: { DEFAULT, 500 }`); 16 is far past any real one. +const MAX_CONFIG_DEPTH = 16; /** * Theme tokens from a Tailwind v3 `tailwind.config.*` (`theme` or `theme.extend` @@ -1375,7 +1477,9 @@ export function profileChecks(fingerprint, profile) { /** * Extract the project fingerprint from `files` and store it as a `fingerprint` * claim. Content-addressed: the same UI surface mints the same id on every machine, - * so teammates converge on one claim instead of duplicating. + * so teammates converge on one claim instead of duplicating. An EMPTY vector is + * refused (nothing is written): stored as "home", it would fail every later `design` + * run against a design system with no features. * @param {string} root @param {string[]} files * @param {{t?:number, theme?:ThemeTokens|null}} [opts] `theme`: see fingerprintText — * mint with the same theme `design` gates with, or token utilities won't match. @@ -1383,6 +1487,12 @@ export function profileChecks(fingerprint, profile) { */ export function mintProjectFingerprint(root, files, { t = 0, theme = null } = {}) { const fingerprint = fingerprintFiles(root, files, { theme }); + if (!hasDesignSignal(fingerprint)) + return { + ok: false, + reason: + "insufficient-signal: nothing stored — an empty vector is not a design system; mint from the files that carry the styles, and name the theme with --theme if discovery misses it", + }; const minted = mintClaim({ kind: "fingerprint", body: fingerprint, diff --git a/test/uicheck.test.js b/test/uicheck.test.js index 174119ce..9b4b1877 100644 --- a/test/uicheck.test.js +++ b/test/uicheck.test.js @@ -132,6 +132,20 @@ test("contrastReport: verdict, thresholds, composited hexes and notes in one obj assert.equal(alpha.notes.length, 2, "both compositing steps are disclosed"); }); +test("contrastReport: with BOTH colors translucent, report, hexes and contrastRatio agree", () => { + // 30% red over white quantizes to #ffb3b3; 50% black composited onto THAT (not onto + // the unrounded 178.5 channels) paints #805a5a. Measuring the unrounded background + // reported #805959 at 3.54 while contrastRatio said 3.50. + const fg = "rgba(0,0,0,.5)"; + const bg = "rgba(255,0,0,.3)"; + const r = contrastReport(fg, bg); + assert.equal(r.bgHex, "#ffb3b3"); + assert.equal(r.fgHex, "#805a5a"); + assert.equal(r.ratio, Math.round(contrastRatio(fg, bg) * 100) / 100); + assert.equal(r.ratio, Math.round(contrastRatio(r.fgHex, r.bgHex) * 100) / 100); + assert.equal(r.ratio, 3.5); +}); + test("cli: `uicheck contrast` exits 1 when AA fails, 0 when it passes", () => { const fail = runCli(["uicheck", "contrast", "#777", "#fff"]); assert.equal(fail.status, 1, fail.stdout + fail.stderr); diff --git a/test/uifingerprint.test.js b/test/uifingerprint.test.js index cb1f16c7..edfdd7db 100644 --- a/test/uifingerprint.test.js +++ b/test/uifingerprint.test.js @@ -732,3 +732,116 @@ test("cli: `--theme ` names the theme explicitly; a valueless --theme is a assert.equal(ghost.status, 1, "a named theme that isn't there is an error, not a no-op"); assert.match(ghost.stderr, /theme source not found: nope\.css/); }); + +// --------------------------------------------------------------------------- +// Review follow-ups: dark-mode scoping, shadow colors, transparency, pathological +// nesting, and refusing to mint an empty vector. +// --------------------------------------------------------------------------- + +// The shadcn / next-themes layout: light defaults on :root, dark overrides AFTER them. +const LIGHT_DARK_CSS = ` +/* .dark { --hl-fg: #ff0000 } in a comment scopes nothing */ +:root { --hl-fg: #111111; --hl-canvas: #f1f5f9; --hl-edge: transparent; } +.dark { --hl-fg: #eeeeee; } +:root[data-theme='dark'] { --hl-canvas: #080f17; --hl-edge: #253140; --hl-glow: #336699; } +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) { --hl-fg: #dddddd; } +} +@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *)); +@theme inline { + --color-fg: var(--hl-fg); + --color-canvas: var(--hl-canvas); + --color-edge: var(--hl-edge); + --color-glow: var(--hl-glow); +} +`; + +test("themeFromCss: dark-scoped overrides (.dark, [data-theme=dark], prefers-color-scheme) never beat the default", () => { + const theme = themeFromCss(LIGHT_DARK_CSS); + assert.deepEqual(theme.colors.get("fg"), hex("#111111"), ".dark and @media dark lose"); + assert.deepEqual(theme.colors.get("canvas"), hex("#f1f5f9"), "[data-theme='dark'] loses"); + assert.equal(theme.vars.get("--hl-fg"), "#111111"); + assert.equal(theme.colors.has("edge"), false, "the default is transparent — not a color"); + assert.deepEqual( + theme.colors.get("glow"), + hex("#336699"), + "declared only for dark: still resolves", + ); + // Order does not matter: a dark block BEFORE the default still loses. + const reversed = themeFromCss( + ".dark { --x: #eeeeee; } :root { --x: #111111; } @theme { --color-x: var(--x); }", + ); + assert.deepEqual(reversed.colors.get("x"), hex("#111111")); + // Same rule for a component's own declarations (fingerprintText → resolveCssVars). + assert.match( + resolveCssVars(":root { --a: #111111; } .dark { --a: #eeeeee; } .c { color: var(--a); }"), + /color: #111111/, + ); + // An unscoped later declaration still wins (last-wins is unchanged outside dark scope). + assert.match( + resolveCssVars(":root { --a: #111111; } .brand { --a: #222222; } .c { color: var(--a); }"), + /color: #222222/, + ); + // A negation scopes to LIGHT mode: `:root:not(.dark)` is a default, not an override. + assert.match( + resolveCssVars( + ":root:not(.dark) { --a: #111111; } .dark { --a: #eeeeee; } .c { color: var(--a); }", + ), + /color: #111111/, + ); +}); + +test("fingerprintText: shadow-[#hex] / shadow-[color:…] are shadow COLORS, not elevation levels", () => { + const fp = fingerprintText( + `
`, + ); + assert.equal(fp.shadowLevels, 1, "only the real shadow value is a level"); + for (const c of ["#123456", "#654321"]) + assert.ok( + fp.palette.some((p) => JSON.stringify(p) === JSON.stringify(hex(c))), + `${c} still counts as a color`, + ); +}); + +test("fingerprintText: fully transparent colors are not palette entries (never counted as black)", () => { + const clear = fingerprintText( + `
+ .a { color: rgba(0, 0, 0, 0); background: hsl(210 40% 50% / 0%); border-color: #0000; }`, + ); + assert.equal(clear.paletteSize, 0, JSON.stringify(clear.palette)); + // Translucent but visible colors still count. + const faint = fingerprintText(".a { color: rgba(0, 0, 0, 0.5); background: #12345680; }"); + assert.equal(faint.paletteSize, 2); + // A transparent theme token is skipped the same way. + const theme = themeFromCss("@theme { --color-clear: transparent; --color-ink: #111111; }"); + assert.deepEqual([...theme.colors.keys()], ["ink"]); + assert.equal(fingerprintText(`

`, { theme }).paletteSize, 0); +}); + +test("fingerprintText / themeFromTailwindConfig: pathological nesting is skipped, never a stack overflow", () => { + const deep = `

`; + assert.deepEqual(fingerprintText(deep).radii, [6], "only the sane calc() is a radius"); + const theme = themeFromCss( + `@theme { --radius-deep: calc(${"(".repeat(5000)}1px${")".repeat(5000)}); --radius-ok: 4px; }`, + ); + assert.deepEqual([...theme.radius], [["ok", 4]]); + const config = themeFromTailwindConfig( + `module.exports = { theme: { colors: { ${"a: {".repeat(20000)} b: "#ffffff" ${"}".repeat(20000)}, ink: "#111111" } } };`, + ); + assert.deepEqual([...config.colors.keys()], ["ink"]); +}); + +test("mintProjectFingerprint: an empty vector is refused and nothing is written", () => { + const root = tmp(); + writeFileSync(join(root, "empty.tsx"), "export const C = () =>
;"); + const m = mintProjectFingerprint(root, ["empty.tsx"], { t: 1 }); + assert.equal(m.ok, false); + if (!m.ok) assert.match(m.reason, /^insufficient-signal/); + assert.equal(loadProjectFingerprint(root), null, "no empty 'home' for design to gate against"); + const cli = runCli(["uicheck", "fingerprint", "empty.tsx", "--mint", "--json"], root); + assert.equal(cli.status, 1, cli.stdout + cli.stderr); + const out = JSON.parse(cli.stdout); + assert.equal(out.minted.ok, false); + assert.match(out.minted.reason, /^insufficient-signal/); + assert.equal(loadProjectFingerprint(root), null); +});