mirror of
https://github.com/Nutlope/hallmark.git
synced 2026-08-14 12:35:33 +02:00
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:
@@ -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 */
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 45–75 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.)
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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)) });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user