Gate 23 gets teeth: one accent number, measured two ways

color.md's 3%-vs-5% contradiction resolves to 5% everywhere. The gate
moves from judged-only to M/R:

- static half (sloplint.mjs check23, WARN-grade): flags accent tokens
  painting viewport-scale rules (body/section/hero-class selectors, or
  rules sized >= 40dvh / 320px / inset 0) and display-size accent text.
  Posture-aware via the stamp comment (Drenched skips; Committed and
  dark papers are structurally exempt because fields and papers are
  never accent tokens); alpha-thinned washes and atmospheric radial
  blooms pass
- render half (sloplint-render.mjs, 1280x800 only, undeclared-posture
  pages): resolves the accent tokens to sRGB and sums painted area on a
  16px union grid (no double-counting of nested rects); > 8% FAIL,
  > 5% WARN, atmospheric thresholds 30/20. Along the way the render
  tier gains a real colour parser: modern Chrome serializes computed
  values and canvas fillStyle in their authored space, so oklch is now
  converted with the same math sloplint uses (the old rgb-only regex
  would have silently skipped every oklch page)

Verified in the browser: a full-bleed accent fixture measures 94.2%
painted (FAIL) and the live coral landing measures 0% (pass); the static
half flags the fixture's three sites and stays quiet on all canonical
examples. Also: garden-01's pre-existing zero-chroma mask-matte token
gains 0.005 chroma so the full example sweep is 0-FAIL again.
This commit is contained in:
Youssef
2026-07-30 16:35:35 +01:00
parent b34d96b722
commit de3a01dd84
5 changed files with 149 additions and 8 deletions
+1 -1
View File
@@ -123,7 +123,7 @@
--honey-autumn: oklch(60% 0.14 60); /* darkest pour */
--shadow-jar: 0 18px 24px oklch(24% 0.05 150 / 0.18);
--shadow-vial: 0 6px 10px oklch(24% 0.05 150 / 0.16);
--mask-matte: oklch(0% 0 0); /* mask alpha only — never painted */
--mask-matte: oklch(0% 0.005 145); /* mask alpha only — never painted */
/* ── Dark band (the standing-jar CTA on --color-ink) ───────── */
--ink-paper: oklch(88% 0.012 110); /* light body text on dark ink */
+1 -1
View File
@@ -5,7 +5,7 @@ Most AI-generated UI fails on colour. It picks blue. It uses pure black. It draw
## Principles
- **OKLCH only.** Perceptually uniform; predictable lightness; consistent hue across tints. `hsl()` and `rgb()` lie about brightness.
- **One accent.** Maximum two. Everything else is neutral. The accent should occupy **3% or less** of any given viewport.
- **One accent.** Maximum two. Everything else is neutral. The accent should occupy **5% or less** of any given viewport (the one number; gate 23 measures it).
- **No pure extremes.** No `#000`, no `#fff`. Always tint with a trace of chroma toward the palette's anchor hue.
- **Tint the greys.** If your anchor hue is orange, your neutrals lean warm. If it's blue, they lean cool. A page with a warm accent and cool grey body copy looks wrong and most people can't name why.
+1 -1
View File
@@ -86,7 +86,7 @@ Record the six scores in a one-line stamp comment directly below the macrostruct
## Implementation gates
22. **[M]** Does any neutral / surface colour have `oklch(... 0 ...)` (zero chroma)? Pure greys read as flat. Tint every neutral toward the anchor hue — minimum 0.005 chroma. *Genre note: modern-minimal allows zero-chroma neutrals (the monochrome Stripe / ElevenLabs school).*
23. **[J]** Does the accent colour cover more than ~5 % of any single viewport (count by area: solid fills, large headings in accent, full-bleed accent backgrounds)? If yes, retreat — accent is for emphasis, not for filling. *Genre note: atmospheric allows accent-tinted radial blooms covering up to ~20 % of the canvas, since the bloom is the design.* *Posture note: a colour serving as a declared surface under a stated colour posture (`--color-paper*`, `--color-field` on Committed / Drenched custom runs, and every dark theme already) is not accent footprint; the accent token proper stays <= 5%, contrast gates 40-41 bind unchanged on the coloured surface, and undeclared accent sprawl still fails. See [`color.md`](color.md) § Colour postures.*
23. **[M/R]** Does the accent colour cover more than ~5 % of any single viewport (count by area: solid fills, large headings in accent, full-bleed accent backgrounds)? If yes, retreat — accent is for emphasis, not for filling. *Genre note: atmospheric allows accent-tinted radial blooms covering up to ~20 % of the canvas, since the bloom is the design.* *Posture note: a colour serving as a declared surface under a stated colour posture (`--color-paper*`, `--color-field` on Committed / Drenched custom runs, and every dark theme already) is not accent footprint; the accent token proper stays <= 5%, contrast gates 40-41 bind unchanged on the coloured surface, and undeclared accent sprawl still fails. See [`color.md`](color.md) § Colour postures.* *Mechanics: sloplint's static half WARNs on accent tokens painting viewport-scale rules or display-size text (posture-aware via the stamp); `--render` measures the painted area on the 1280x800 fold and FAILs past 8% (atmospheric: 30%).*
24. **[M]** Is any padding / gap / margin a value that isn't on the named spacing scale (`--space-3xs``--space-5xl`, multiples of 4 px)? Arbitrary `padding: 17px` is a tell.
25. **[M]** Is any prose container's `max-width` outside the 4575 ch range? Measure must read; under 45 ch is choppy, over 75 ch loses the eye.
26. **[M]** Does any interactive element lack `:focus-visible`, `:active`, OR `:disabled` styling? (Eight states is the rule. Default + hover is two; you need at least default + hover + focus-visible + active + disabled present in code.)
+95 -3
View File
@@ -7,6 +7,7 @@
* 49 two-line clickable affordances (a, button, [role=button], nav a)
* 44 hero fold at 1280x800: first h1 + nearest CTA fully inside the fold
* 40/41 computed-style contrast for visible text
* 23 painted accent area at 1280x800 vs the 5% budget (posture-aware)
* 56 two position:sticky top:0 elements actually overlapping
*
* Requires puppeteer-core (optional) plus a Chrome executable at the standard
@@ -21,7 +22,8 @@ import { existsSync } from 'node:fs';
const WIDTHS = [320, 375, 414, 768, 1280, 1920];
const MAC_CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
export async function renderCheck(htmlFiles) {
export async function renderCheck(htmlFiles, metaIn) {
const meta = { genre: null, postureByFile: {}, ...(metaIn || {}) };
let puppeteer;
try {
puppeteer = (await import('puppeteer-core')).default;
@@ -42,10 +44,11 @@ export async function renderCheck(htmlFiles) {
const page = await browser.newPage();
try {
await page.goto('file://' + file, { waitUntil: 'networkidle0', timeout: 20000 }).catch(() => {});
const pageMeta = { genre: meta.genre, posture: meta.postureByFile[file] || 'restrained' };
for (const width of WIDTHS) {
await page.setViewport({ width, height: 800 });
await new Promise((r) => setTimeout(r, 120));
const res = await page.evaluate(observe, width === 1280);
const res = await page.evaluate(observe, width === 1280, pageMeta);
for (const f of res) findings.push({ ...f, file, line: 1, evidence: `[${width}px] ${f.evidence}` });
}
} catch (e) {
@@ -68,7 +71,8 @@ export async function renderCheck(htmlFiles) {
}
/* Runs inside the page. checkFold is true only at 1280x800. */
function observe(checkFold) {
function observe(checkFold, meta) {
meta = meta || { genre: null, posture: 'restrained' };
const out = [];
const push = (gate, grade, evidence, fix) => out.push({ gate: String(gate), grade, evidence, fix });
const describe = (el) => {
@@ -184,6 +188,94 @@ function observe(checkFold) {
}
}
/* gate 23: painted accent area at 1280x800. Runs only when no colour
posture is declared (a stamped posture is the gate's own carve-out and
stays judged); dark papers can never match the accent RGB, so the
dark-theme exception is automatic. Union grid = no double-counting. */
if (checkFold && meta.posture === 'restrained') {
const cs0 = getComputedStyle(document.documentElement);
/* Modern Chrome keeps oklch() in computed values AND in canvas fillStyle
serialization, so normalize by parsing: hex, rgb/rgba, color(srgb),
and oklch via the same math sloplint.mjs uses. */
const oklchToRgb = (L, C, H) => {
const hr = (H * Math.PI) / 180;
const a = C * Math.cos(hr), b = C * Math.sin(hr);
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const l = l_ ** 3, mm = m_ ** 3, ss = s_ ** 3;
return [
4.0767416621 * l - 3.3077115913 * mm + 0.2309699292 * ss,
-1.2684380046 * l + 2.6097574011 * mm - 0.3413193965 * ss,
-0.0041960863 * l - 0.7034186147 * mm + 1.7076147010 * ss,
].map((c) => {
const cl = Math.min(1, Math.max(0, c));
const g = cl <= 0.0031308 ? 12.92 * cl : 1.055 * cl ** (1 / 2.4) - 0.055;
return Math.round(g * 255);
});
};
const toRgb = (str) => {
if (!str) return null;
str = str.trim();
let m;
if ((m = /^#([0-9a-f]{6})([0-9a-f]{2})?$/i.exec(str))) {
const h = m[1];
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), m[2] ? parseInt(m[2], 16) / 255 : 1];
}
if ((m = /^rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)$/.exec(str))) {
return [+m[1], +m[2], +m[3], m[4] == null ? 1 : +m[4]];
}
if ((m = /^color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.]+))?\)$/.exec(str))) {
return [Math.round(+m[1] * 255), Math.round(+m[2] * 255), Math.round(+m[3] * 255), m[4] == null ? 1 : +m[4]];
}
if ((m = /^oklch\(\s*([\d.]+%?)\s+([\d.]+)\s+([\d.]+)(?:deg)?\s*(?:\/\s*([\d.]+%?))?\s*\)$/.exec(str))) {
const L = m[1].endsWith('%') ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const alpha = m[4] == null ? 1 : (m[4].endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4]));
return [...oklchToRgb(L, parseFloat(m[2]), parseFloat(m[3])), alpha];
}
return null;
};
const targets = ['--color-accent', '--color-accent-2']
.map((t) => toRgb(cs0.getPropertyValue(t).trim())).filter(Boolean);
if (targets.length) {
const near = (str) => {
const p = toRgb(str);
if (!p || p[3] < 0.5) return false; // alpha-thinned tints don't count
return targets.some((t) => Math.abs(t[0] - p[0]) + Math.abs(t[1] - p[1]) + Math.abs(t[2] - p[2]) <= 30);
};
const CW = 16, COLS = Math.ceil(innerWidth / CW), ROWS = Math.ceil(800 / CW);
const grid = new Uint8Array(COLS * ROWS);
let walked = 0;
for (const el of document.querySelectorAll('body, body *')) {
if (++walked > 4000) break; // perf bound
if (el !== document.body && !visible(el)) continue;
const cs = getComputedStyle(el);
const isBg = near(cs.backgroundColor);
const isTxt = near(cs.color) && parseFloat(cs.fontSize) >= 32 &&
[...el.childNodes].some((nd) => nd.nodeType === 3 && nd.textContent.trim());
if (!isBg && !isTxt) continue;
const r = el.getBoundingClientRect();
const x0 = Math.max(0, Math.floor(r.left / CW));
const x1 = Math.min(COLS - 1, Math.floor((Math.min(r.right, innerWidth) - 1) / CW));
const y0 = Math.max(0, Math.floor(r.top / CW));
const y1 = Math.min(ROWS - 1, Math.floor((Math.min(r.bottom, 800) - 1) / CW));
for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) grid[y * COLS + x] = 1;
}
let filled = 0;
for (const c of grid) filled += c;
const pct = (100 * filled) / (COLS * ROWS);
const warnAt = meta.genre === 'atmospheric' ? 20 : 5;
const failAt = meta.genre === 'atmospheric' ? 30 : 8;
if (pct > failAt) {
push(23, 'FAIL', `accent paints ~${pct.toFixed(1)}% of the 1280x800 viewport (> ${failAt}%)`,
'Retreat the accent to <= 5%; large colour belongs to paper/field tokens or a declared posture');
} else if (pct > warnAt) {
push(23, 'WARN', `accent paints ~${pct.toFixed(1)}% of the 1280x800 viewport`,
'Confirm this is a declared posture surface, else retreat to <= 5%');
}
}
}
/* gate 56: double sticky at top 0 */
if (checkFold) {
const sticky = [];
+51 -2
View File
@@ -49,6 +49,9 @@
* 11 uniform hover-scale across multiple selectors
* 17 tooltip focus delay equals hover delay
* 18 infinite auto-rotating animation without pause-on-hover
* 23 accent token on viewport-scale backgrounds / display-size accent text
* (WARN; posture-aware via the stamp; --render measures painted area
* against the 5% budget and can FAIL)
* 25 prose measure outside 45-75ch
* 28 video/LCP hygiene (autoplay w/o muted, no poster, lazy hero media)
* 30 two or more icon libraries mixed - the WARN half
@@ -847,6 +850,46 @@ function check22(ctx) {
}
}
/* gate 23: accent-footprint heuristic (WARN) - static half; --render measures the real area */
function check23(ctx) {
const heads = [
...ctx.cssFiles.map((f) => f.raw),
...ctx.docs.flatMap((d) => d.styles.map((st) => st.css)),
].map((t) => t.split('\n').slice(0, 40).join('\n')).join('\n');
const posture = (/posture:\s*(restrained|committed|full-palette|drenched)/i.exec(heads)?.[1] || 'restrained').toLowerCase();
if (posture === 'drenched') return; // the surface IS the colour, by declaration
const ACC = /var\(\s*--color-accent(?:-2)?\s*[,)]/;
const SURFACE = /(^|[\s,>~+])(body|html|main|section|header|footer|aside)(\b|$)|\bhero\b|__hero|banner|\bstrip\b|\bband\b/i;
const big = (r) => r.decls.some((d) =>
(/^(min-)?height$/.test(d.prop) && (parseFloat((/([\d.]+)\s*(vh|dvh|svh)/.exec(d.value) || [])[1] || 0) >= 40 || (pxOf(d.value) ?? 0) >= 320)) ||
(d.prop === 'inset' && /^0(px)?(\s|$)/.test(d.value.trim())));
for (const r of ctx.rules) {
if (isTokenRule(r) || r.keyframes) continue;
for (const d of r.decls) {
if (!/^background(-color|-image)?$/.test(d.prop) || !ACC.test(d.value)) continue;
if (/\/\s*0?\.[0-2]\d*/.test(d.value)) continue; // alpha-thinned tint or wash, not a fill
if (ctx.genre === 'atmospheric' && /radial-gradient/i.test(d.value)) continue; // the bloom licence; area judged at the gate
if (SURFACE.test(r.selector) || big(r)) {
report(23, 'WARN', r.file, d.line,
`accent background on viewport-scale rule "${trunc(r.selector, 44)}"`,
'Accent stays <= 5% of a viewport; large fills belong to paper/field tokens or a declared posture in the stamp');
}
}
const col = r.decls.find((d) => d.prop === 'color' && ACC.test(d.value));
const fsDecl = r.decls.find((d) => d.prop === 'font-size');
if (col && fsDecl) {
const rv = resolveVars(fsDecl.value, ctx.tokens);
const clampMax = /,\s*([^,()]+)\)\s*$/.exec(rv);
const maxPx = pxOf(rv) ?? (clampMax ? pxOf(clampMax[1].trim()) : null);
if ((maxPx != null && maxPx >= 48) || /--text-display\b/.test(fsDecl.value)) {
report(23, 'WARN', r.file, col.line,
`display-size text set in accent on "${trunc(r.selector, 44)}"`,
'Accent marks emphasis; set display type in ink and keep accent for small signals');
}
}
}
}
/* gate 24: off-scale px spacing */
function check24(ctx) {
const props = /^(padding|margin|gap|row-gap|column-gap)(-[a-z]+)?$/;
@@ -1567,7 +1610,7 @@ function checkCopyTell(ctx) {
/* -------------------------------------------------------------- runner --- */
const CHECKS = [check1, check2, check3, check4, check5, check7, check10, check11,
check12, check14, check15, check17, check18, check19, check20, check22, check24,
check12, check14, check15, check17, check18, check19, check20, check22, check23, check24,
check25, check26, check27, check28, check30, check33, check34, check37, check38a,
check39, check40_41, check42, check43, check46, check47, check48, check49,
check50, check51, check52, check53, check54, check55, check56, checkCopyTell];
@@ -1617,7 +1660,13 @@ if (opts.render) {
const htmlFiles = files.filter((f) => extname(f).toLowerCase() === '.html');
try {
const mod = await import(new URL('./sloplint-render.mjs', import.meta.url).href);
const extra = await mod.renderCheck(htmlFiles);
const postureByFile = {};
for (const f of htmlFiles) {
let head = '';
try { head = readFileSync(f, 'utf8').split('\n').slice(0, 60).join('\n'); } catch { /* unreadable */ }
postureByFile[f] = (/posture:\s*(restrained|committed|full-palette|drenched)/i.exec(head)?.[1] || 'restrained').toLowerCase();
}
const extra = await mod.renderCheck(htmlFiles, { genre: opts.genre, postureByFile });
for (const f of extra) {
findings.push({ ...f, file: rel(f.file), evidence: trunc(String(f.evidence)) });
}