diff --git a/site/examples/garden-01/tokens.css b/site/examples/garden-01/tokens.css index 8f30bde..94c3497 100644 --- a/site/examples/garden-01/tokens.css +++ b/site/examples/garden-01/tokens.css @@ -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 */ diff --git a/skills/hallmark/references/color.md b/skills/hallmark/references/color.md index a2466a1..c50d7cd 100644 --- a/skills/hallmark/references/color.md +++ b/skills/hallmark/references/color.md @@ -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. diff --git a/skills/hallmark/references/slop-test.md b/skills/hallmark/references/slop-test.md index 36a9737..59d733e 100644 --- a/skills/hallmark/references/slop-test.md +++ b/skills/hallmark/references/slop-test.md @@ -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.) diff --git a/skills/hallmark/scripts/sloplint-render.mjs b/skills/hallmark/scripts/sloplint-render.mjs index 59b4d53..020fe09 100644 --- a/skills/hallmark/scripts/sloplint-render.mjs +++ b/skills/hallmark/scripts/sloplint-render.mjs @@ -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 = []; diff --git a/skills/hallmark/scripts/sloplint.mjs b/skills/hallmark/scripts/sloplint.mjs index 6a34610..024f9e3 100644 --- a/skills/hallmark/scripts/sloplint.mjs +++ b/skills/hallmark/scripts/sloplint.mjs @@ -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)) }); }