Hallmark Makes the UIs a coding assistant generates look made, not generated: a hard floor against the defaults every model was trained into, a dealt direction so consecutive builds do not converge, one idea per screen. Above the floor the page is yours: you are a capable designer. Powered by Together AI. The Floor Binds every output in every harness, none of it waivable, even when nothing else is read. 1. Every colour and every font goes through a token declared at (or a declared theme block). No literal colour or past the token block. (48) 2. Never . Never animate , , or . (10a) 3. Never on…

&& s[j + 1] === '{') {\n parts.push(cutGlued(seg, after, true));\n const e = braceEnd(s, j + 1);\n parts.push(clsLits(s.slice(j + 2, e), level + 1).join(' '));\n j = e + 1; seg = ''; after = true;\n continue;\n }\n seg += s[j]; j++;\n }\n parts.push(cutGlued(seg, after, false));\n i = j + 1;\n continue;\n }\n i++;\n }\n return parts.join(' ').split(/\\s+/).filter(Boolean);\n}\n\nconst JS_EXPR_KW = new Set(['return', 'yield', 'await', 'case', 'default', 'else', 'do', 'throw', 'typeof', 'void', 'delete', 'in', 'of', 'new', 'instanceof']);\nconst CLASS_FNS = new Set(['cn', 'clsx', 'classNames', 'classnames', 'twMerge', 'twJoin', 'cva', 'cx', 'tv']);\nfunction jsxPseudo(src) {\n const n = src.length;\n const out = new Array(n);\n for (let k = 0; k \u003c n; k++) out[k] = src[k] === '\\n' ? '\\n' : ' ';\n const classes = [];\n const calls = [];\n let hi = 0, fails = 0, inClass = 0, depth = 0;\n const blank = (a, b) => { for (let k = a; k \u003c b && k \u003c n; k++) out[k] = src[k] === '\\n' ? '\\n' : ' '; };\n const paint = (a, b) => { for (let k = a; k \u003c b && k \u003c n; k++) out[k] = src[k]; if (b > hi) hi = b; };\n const write = (a, b, text) => {\n blank(a, b);\n let k = a;\n for (const ch of text) { while (k \u003c b && src[k] === '\\n') k++; if (k >= b) break; out[k++] = ch; }\n if (b > hi) hi = b;\n };\n const ws = (i) => { while (i \u003c n && /\\s/.test(src[i])) i++; return i; };\n const gap = (i) => {\n for (;;) {\n i = ws(i);\n if (src[i] === '/' && src[i + 1] === '*') { const e = src.indexOf('*/', i + 2); if (e \u003c 0) return n; i = e + 2; continue; }\n if (src[i] === '/' && src[i + 1] === '/') { const e = src.indexOf('\\n', i); if (e \u003c 0) return n; i = e; continue; }\n return i;\n }\n };\n const strEnd = (i) => {\n const q = src[i];\n let j = i + 1;\n while (j \u003c n && src[j] !== q && src[j] !== '\\n') { if (src[j] === '\\\\') j++; j++; }\n return Math.min(j + 1, n);\n };\n const tplEnd = (i) => {\n let j = i + 1;\n while (j \u003c n && src[j] !== '`') {\n if (src[j] === '\\\\') { j += 2; continue; }\n if (src[j] === '

Hallmark Makes the UIs a coding assistant generates look made, not generated: a hard floor against the defaults every model was trained into, a dealt direction so consecutive builds do not converge, one idea per screen. Above the floor the page is yours: you are a capable designer. Powered by Together AI. The Floor Binds every output in every harness, none of it waivable, even when nothing else is read. 1. Every colour and every font goes through a token declared at (or a declared theme block). No literal colour or past the token block. (48) 2. Never . Never animate , , or . (10a) 3. Never on…

&& src[j + 1] === '{') { j = js(j + 2, true) + 1; continue; }\n j++;\n }\n return Math.min(j + 1, n);\n };\n const reEnd = (i) => {\n let j = i + 1, set = false;\n while (j \u003c n && src[j] !== '\\n') {\n const c = src[j];\n if (c === '\\\\') { j += 2; continue; }\n if (c === '[') set = true;\n else if (c === ']') set = false;\n else if (c === '/' && !set) { j++; while (j \u003c n && /[a-z]/i.test(src[j])) j++; return j; }\n j++;\n }\n return -1;\n };\n function js(i, stop) {\n if (depth > 200) return stop ? Math.min(braceEnd(src, i - 1), n) : n;\n depth++;\n const r = jsScan(i, stop);\n depth--;\n return r;\n }\n function jsScan(i, stop) {\n let level = 0, val = false;\n while (i \u003c n) {\n const c = src[i];\n if (c === ' ' || c === '\\n' || c === '\\t' || c === '\\r') { i++; continue; }\n if (c === '/' && src[i + 1] === '/') { const e = src.indexOf('\\n', i); i = e \u003c 0 ? n : e; continue; }\n if (c === '/' && src[i + 1] === '*') { const e = src.indexOf('*/', i + 2); i = e \u003c 0 ? n : e + 2; continue; }\n if (c === '\"' || c === \"'\") { i = strEnd(i); val = true; continue; }\n if (c === '`') { i = tplEnd(i); val = true; continue; }\n if (c === '{') { level++; i++; val = false; continue; }\n if (c === '}') { if (stop && level === 0) return i; level--; i++; val = true; continue; }\n if (c === ')' || c === ']') { i++; val = true; continue; }\n if (c === '\u003c' && !val && fails \u003c 500 && /[A-Za-z_$>]/.test(src[i + 1] || '')) {\n const e = el(i);\n if (e > 0) { i = e; val = true; continue; }\n fails++;\n }\n if (c === '/' && !val) { const e = reEnd(i); if (e > 0) { i = e; val = true; continue; } }\n if (/[A-Za-z_$]/.test(c)) {\n let j = i + 1;\n while (j \u003c n && /[\\w$]/.test(src[j])) j++;\n const w = src.slice(i, j);\n val = !JS_EXPR_KW.has(w);\n if (!inClass && CLASS_FNS.has(w) && src[i - 1] !== '.' && src[ws(j)] === '(') calls.push(i);\n i = j;\n continue;\n }\n if (c >= '0' && c \u003c= '9') { let j = i + 1; while (j \u003c n && /[\\w.]/.test(src[j])) j++; i = j; val = true; continue; }\n i++;\n val = false;\n }\n return n;\n }\n function el(start) {\n if (depth > 200) return -1;\n const mc = classes.length, mk = calls.length;\n depth++;\n const e = tag(start);\n depth--;\n if (e \u003c 0) { blank(start, Math.max(hi, start + 1)); hi = start; classes.length = mc; calls.length = mk; }\n return e;\n }\n function tag(start) {\n let i = start + 1;\n if (src[i] === '>') return kids(i + 1, '');\n let j = i;\n while (j \u003c n && /[\\w$.:-]/.test(src[j])) j++;\n const name = src.slice(i, j);\n paint(start, j);\n i = j;\n let cls = null;\n for (;;) {\n i = gap(i);\n if (i >= n) return -1;\n const c = src[i];\n if (c === '/' && src[i + 1] === '>') { paint(i, i + 2); if (cls) classes.push([start, cls]); return i + 2; }\n if (c === '>') { paint(i, i + 1); i++; break; }\n if (c === '{') { const e = js(i + 1, true); if (e >= n) return -1; blank(i, e + 1); i = e + 1; continue; }\n if (!/[A-Za-z_$]/.test(c)) return -1;\n let k = i + 1;\n while (k \u003c n && /[\\w$:.-]/.test(src[k])) k++;\n const attr = src.slice(i, k);\n let v = ws(k);\n if (src[v] !== '=') { paint(i, k); i = k; continue; }\n v = ws(v + 1);\n const q = src[v];\n const isCls = attr === 'className' || attr === 'class';\n let e;\n if (q === '\"' || q === \"'\") {\n e = src.indexOf(q, v + 1);\n if (e \u003c 0) return -1;\n if (isCls) cls = (cls || []).concat(src.slice(v + 1, e).split(/\\s+/).filter(Boolean));\n else paint(i, e + 1);\n } else if (q === '{') {\n if (isCls) inClass++;\n e = js(v + 1, true);\n if (isCls) inClass--;\n if (e >= n) return -1;\n if (isCls) cls = (cls || []).concat(clsLits(src.slice(v + 1, e)));\n else { paint(i, v); write(v, e + 1, '\"' + src.slice(v + 1, e).replace(/[^\\w$.\\-/:#@]+/g, ' ') + '\"'); }\n } else if (q === '\u003c') {\n e = el(v) - 1;\n if (e \u003c 0) return -1;\n blank(i, e + 1);\n } else return -1;\n if (isCls && cls) write(i, e + 1, 'class=\"' + cls.join(' ').replace(/\"/g, \"'\").replace(/[\u003c>]/g, ' ') + '\"');\n i = e + 1;\n }\n if (cls) classes.push([start, cls]);\n return kids(i, name);\n }\n function kids(i, name) {\n while (i \u003c n) {\n const c = src[i];\n if (c === '{') { const e = js(i + 1, true); if (e >= n) return -1; i = e + 1; continue; }\n if (c === '\u003c') {\n if (src[i + 1] === '/') {\n const k = ws(i + 2);\n let j = k;\n while (j \u003c n && /[\\w$.:-]/.test(src[j])) j++;\n const e = ws(j);\n if (src[e] !== '>' || src.slice(k, j) !== name) return -1;\n if (name) paint(i, e + 1);\n return e + 1;\n }\n if (/[A-Za-z_$>]/.test(src[i + 1] || '')) { const e = el(i); if (e > 0) { i = e; continue; } }\n i++;\n continue;\n }\n out[i] = c;\n if (i >= hi) hi = i + 1;\n i++;\n }\n return -1;\n }\n js(0, false);\n return { pseudo: out.join(''), classAt: new Map(classes), calls };\n}\n\nfunction classCalls(src, sites) {\n const out = [];\n let last = -1;\n for (const pos of [...sites].sort((a, b) => a - b)) {\n if (pos \u003c last) continue;\n const open = src.indexOf('(', pos);\n if (open \u003c 0) continue;\n let depth = 0, i = open;\n for (; i \u003c src.length; i++) {\n const c = src[i];\n if (c === '/' && src[i + 1] === '/') { const e = src.indexOf('\\n', i); i = e \u003c 0 ? src.length : e; continue; }\n if (c === '/' && src[i + 1] === '*') { const e = src.indexOf('*/', i + 2); i = e \u003c 0 ? src.length : e + 1; continue; }\n if (c === '\"' || c === \"'\" || c === '`') { i++; while (i \u003c src.length && src[i] !== c) { if (src[i] === '\\\\') i++; i++; } continue; }\n if (c === '(') depth++;\n else if (c === ')' && --depth === 0) break;\n }\n last = i;\n const toks = clsLits(src.slice(open + 1, i));\n if (toks.length) out.push({ pos, name: /^[\\w$]+/.exec(src.slice(pos))[0], toks });\n }\n return out;\n}\n\nfunction scriptSites(src, kind) {\n const ranges = [];\n for (const m of src.matchAll(/\u003cscript\\b[^>]*>([\\s\\S]*?)\u003c\\/script>/gi)) ranges.push([m.index + m[0].length - m[1].length - 9, m.index + m[0].length - 9]);\n const fm = kind === 'astro' ? FRONTMATTER.exec(src) : null;\n if (fm) ranges.push([0, fm[0].length]);\n const sites = [];\n for (const [a, b] of ranges) {\n for (const m of src.slice(a, b).matchAll(/(?\u003c![\\w$.])(?:cn|clsx|classNames|classnames|twMerge|twJoin|cva|cx|tv)\\s*\\(/g)) sites.push(a + m.index);\n }\n return sites;\n}\n\nfunction parseDoc(file, raw) {\n const ext = extname(file).toLowerCase();\n if (ext === '.jsx' || ext === '.tsx') {\n const j = jsxPseudo(raw);\n const doc = parseHtml(file, j.pseudo, 'jsx');\n doc.styles = [];\n doc.scripts = raw;\n doc.classAt = j.classAt;\n doc.virtuals = classCalls(raw, j.calls);\n doc.raw = raw;\n return doc;\n }\n const kind = ext === '.html' ? 'html' : ext.slice(1);\n const doc = parseHtml(file, templateMask(raw, kind), kind);\n doc.raw = raw;\n doc.virtuals = kind === 'html' ? [] : classCalls(raw, scriptSites(raw, kind));\n return doc;\n}\n\nfunction getAttr(attrStr, name) {\n const re = new RegExp(`(?:^|\\\\s)${name}\\\\s*=\\\\s*(\"([^\"]*)\"|'([^']*)'|([^\\\\s>/]+))`, 'i');\n const m = re.exec(attrStr);\n if (!m) return null;\n return m[2] ?? m[3] ?? m[4] ?? '';\n}\nfunction hasAttr(attrStr, name) {\n return new RegExp(`(?:^|\\\\s)${name}(?=[\\\\s=/]|$)`, 'i').test(attrStr);\n}\n\nfunction directChildren(doc, idx) {\n const open = doc.tags[idx];\n if (open.close || open.selfClosing) return [];\n const children = [];\n let depth = 1;\n let childStart = -1, childOpen = null, childIdx = -1;\n for (let i = idx + 1; i \u003c doc.tags.length; i++) {\n const t = doc.tags[i];\n if (t.close) {\n depth--;\n if (depth === 0) break;\n if (depth === 1 && childOpen) {\n children.push({ name: childOpen.name, open: doc.src.slice(childOpen.pos, childOpen.end), html: doc.src.slice(childStart, t.end), idx: childIdx });\n childOpen = null;\n }\n continue;\n }\n if (depth === 1) {\n if (t.selfClosing) {\n children.push({ name: t.name, open: doc.src.slice(t.pos, t.end), html: doc.src.slice(t.pos, t.end), idx: i });\n } else {\n childStart = t.pos; childOpen = t; childIdx = i;\n }\n }\n if (!t.selfClosing) depth++;\n }\n return children;\n}\n\nfunction ancestorMap(doc) {\n if (doc._anc) return doc._anc;\n const map = new Map();\n const stack = [];\n for (let i = 0; i \u003c doc.tags.length; i++) {\n const t = doc.tags[i];\n if (t.close) { stack.pop(); continue; }\n map.set(i, stack.slice());\n if (!t.selfClosing) stack.push(t.name);\n }\n doc._anc = map;\n return map;\n}\n\nfunction plainText(html) {\n return html.replace(/\u003c[^>]*>/g, ' ')\n .replace(/·|�?183;|�?b7;/gi, '·').replace(/•|•|•/gi, '•').replace(/—|—|—/gi, '\\u2014').replace(/–|–|–/gi, '\\u2013')\n .replace(/→|→|→|⟶|⟶/gi, '\\u2192').replace(/»|»|»/gi, '\\u00bb').replace(/›|›|›/gi, '\\u203a')\n .replace(/ | /g, ' ').replace(/&/g, '&').replace(/</g, '\u003c').replace(/>/g, '>')\n .replace(/’|‘|’|‘|’|‘/gi, '\\u2019').replace(/”|“|“|”/gi, '\"')\n .replace(/&#\\d+;|&[a-z]+;/gi, ' ')\n .replace(/\\s+/g, ' ').trim();\n}\n\nfunction innerOf(doc, idx) {\n const open = doc.tags[idx];\n if (open.close || open.selfClosing) return null;\n let depth = 1;\n for (let i = idx + 1; i \u003c doc.tags.length; i++) {\n const t = doc.tags[i];\n if (t.selfClosing) continue;\n if (t.close) { depth--; if (depth === 0) return { inner: doc.src.slice(open.end, t.pos), endPos: t.pos }; }\n else depth++;\n }\n return { inner: doc.src.slice(open.end), endPos: doc.src.length };\n}\n\nfunction parseNum(s) { const v = parseFloat(s); return Number.isNaN(v) ? null : v; }\n\nfunction parseColor(raw) {\n if (!raw) return null;\n const v = raw.trim().toLowerCase();\n if (v === 'white') return { r: 1, g: 1, b: 1, alpha: 1 };\n if (v === 'black') return { r: 0, g: 0, b: 0, alpha: 1 };\n if (v === 'transparent') return { r: 0, g: 0, b: 0, alpha: 0 };\n let m;\n if ((m = /^#([0-9a-f]{3,8})$/.exec(v))) {\n const h = m[1];\n if (h.length === 3 || h.length === 4) {\n const r = parseInt(h[0] + h[0], 16) / 255, g = parseInt(h[1] + h[1], 16) / 255, b = parseInt(h[2] + h[2], 16) / 255;\n const a = h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1;\n return { r, g, b, alpha: a };\n }\n if (h.length === 6 || h.length === 8) {\n const r = parseInt(h.slice(0, 2), 16) / 255, g = parseInt(h.slice(2, 4), 16) / 255, b = parseInt(h.slice(4, 6), 16) / 255;\n const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;\n return { r, g, b, alpha: a };\n }\n return null;\n }\n if ((m = /^rgba?\\(([^)]+)\\)$/.exec(v))) {\n const parts = m[1].split('/');\n const nums = parts[0].split(/[\\s,]+/).filter(Boolean);\n if (nums.length \u003c 3) return null;\n const chan = (s) => s.endsWith('%') ? parseNum(s) / 100 : parseNum(s) / 255;\n let alpha = 1;\n if (parts[1] != null) alpha = parts[1].trim().endsWith('%') ? parseNum(parts[1]) / 100 : parseNum(parts[1]);\n else if (nums.length === 4) alpha = nums[3].endsWith('%') ? parseNum(nums[3]) / 100 : parseNum(nums[3]);\n const [r, g, b] = nums.slice(0, 3).map(chan);\n if ([r, g, b].some((x) => x == null)) return null;\n return { r, g, b, alpha: alpha ?? 1 };\n }\n if ((m = /^hsla?\\(([^)]+)\\)$/.exec(v))) {\n const parts = m[1].split('/');\n const nums = parts[0].split(/[\\s,]+/).filter(Boolean);\n if (nums.length \u003c 3) return null;\n const h = parseNum(nums[0]);\n const s = parseNum(nums[1]) / 100, l = parseNum(nums[2]) / 100;\n let alpha = 1;\n if (parts[1] != null) alpha = parts[1].trim().endsWith('%') ? parseNum(parts[1]) / 100 : parseNum(parts[1]);\n else if (nums.length === 4) alpha = nums[3].endsWith('%') ? parseNum(nums[3]) / 100 : parseNum(nums[3]);\n if ([h, s, l].some((x) => x == null)) return null;\n const c = (1 - Math.abs(2 * l - 1)) * s;\n const hp = ((h % 360) + 360) % 360 / 60;\n const x = c * (1 - Math.abs((hp % 2) - 1));\n let rgb = [0, 0, 0];\n if (hp \u003c 1) rgb = [c, x, 0]; else if (hp \u003c 2) rgb = [x, c, 0];\n else if (hp \u003c 3) rgb = [0, c, x]; else if (hp \u003c 4) rgb = [0, x, c];\n else if (hp \u003c 5) rgb = [x, 0, c]; else rgb = [c, 0, x];\n const mAdd = l - c / 2;\n return { r: rgb[0] + mAdd, g: rgb[1] + mAdd, b: rgb[2] + mAdd, alpha: alpha ?? 1 };\n }\n if ((m = /^oklch\\(([^)]+)\\)$/.exec(v))) {\n const parts = m[1].split('/');\n const nums = parts[0].split(/\\s+/).filter(Boolean);\n if (nums.length \u003c 3) return null;\n let L = parseNum(nums[0]);\n if (nums[0].endsWith('%')) L = L / 100;\n const C = parseNum(nums[1]);\n let H = nums[2] === 'none' ? 0 : parseNum(nums[2]);\n let alpha = 1;\n if (parts[1] != null) alpha = parts[1].trim().endsWith('%') ? parseNum(parts[1]) / 100 : parseNum(parts[1]);\n if (L == null || C == null || H == null) return null;\n const rgb = oklchToSrgb(L, C, H);\n return { ...rgb, alpha: alpha ?? 1, okl: { L, C, H } };\n }\n return null;\n}\n\nfunction oklchToSrgb(L, C, H) {\n const hr = (H * Math.PI) / 180;\n const a = C * Math.cos(hr), b = C * Math.sin(hr);\n const l_ = L + 0.3963377774 * a + 0.2158037573 * b;\n const m_ = L - 0.1055613458 * a - 0.0638541728 * b;\n const s_ = L - 0.0894841775 * a - 1.2914855480 * b;\n const l = l_ ** 3, mm = m_ ** 3, s = s_ ** 3;\n const lin = [\n 4.0767416621 * l - 3.3077115913 * mm + 0.2309699292 * s,\n -1.2684380046 * l + 2.6097574011 * mm - 0.3413193965 * s,\n -0.0041960863 * l - 0.7034186147 * mm + 1.7076147010 * s,\n ].map((c) => Math.min(1, Math.max(0, c)));\n const enc = (c) => (c \u003c= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);\n return { r: enc(lin[0]), g: enc(lin[1]), b: enc(lin[2]) };\n}\n\nfunction srgbToOklch(col) {\n const dec = (c) => (c \u003c= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);\n const r = dec(col.r), g = dec(col.g), b = dec(col.b);\n const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);\n const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);\n const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);\n const L = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s;\n const a = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s;\n const b2 = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s;\n const C = Math.sqrt(a * a + b2 * b2);\n let H = (Math.atan2(b2, a) * 180) / Math.PI;\n if (H \u003c 0) H += 360;\n return { L, C, H };\n}\n\nfunction luminance(col) {\n const dec = (c) => (c \u003c= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);\n return 0.2126 * dec(col.r) + 0.7152 * dec(col.g) + 0.0722 * dec(col.b);\n}\nfunction contrastRatio(a, b) {\n const la = luminance(a), lb = luminance(b);\n const hi = Math.max(la, lb), lo = Math.min(la, lb);\n return (hi + 0.05) / (lo + 0.05);\n}\n\nconst TOKEN_SEL = /^(?:html|:root)?(?:\\[data-(?:theme|mode|scheme)[^\\]]*\\]|:root|:not\\([^)]*\\))*$/i;\nfunction isTokenRule(rule) {\n if (rule.selector === '@font-face' || rule.selector === '@theme' || basename(rule.file).toLowerCase() === 'tokens.css') return true;\n return rule.selector.split(',').every((p) => { const t = p.trim(); return t && TOKEN_SEL.test(t); });\n}\n\nfunction buildTokens(rules) {\n const tokens = {};\n for (const r of rules) {\n if (!isTokenRule(r)) continue;\n for (const d of r.decls) if (d.prop.startsWith('--')) tokens[d.prop] = d.value;\n }\n return tokens;\n}\n\nfunction resolveVars(value, tokens, local = null, depth = 0) {\n if (!value || depth > 5 || !value.includes('var(')) return value;\n const out = value.replace(/var\\(\\s*(--[\\w-]+)\\s*(?:,\\s*([^()]*(?:\\([^()]*\\)[^()]*)*))?\\)/g, (m0, name, fb) => {\n const v = (local && local[name] != null) ? local[name] : tokens[name];\n if (v != null) return v;\n if (fb != null) return fb.trim();\n return m0;\n });\n if (out === value) return out;\n return resolveVars(out, tokens, local, depth + 1);\n}\n\n// A family's identity ignores spaces, hyphens and underscores, so \"GeistSans\" and \"Geist Sans\" are one face\nconst famKey = (f) => String(f || '').replace(/[\\s_-]+/g, '');\nfunction firstFamily(value) {\n const first = value.split(',')[0].trim().replace(/^['\"]|['\"]$/g, '').toLowerCase();\n return first;\n}\n\nfunction pxOf(value) {\n const m = /^(-?\\d+(?:\\.\\d+)?)(px|rem|em)$/.exec(value.trim());\n if (!m) return null;\n const n = parseFloat(m[1]);\n return m[2] === 'px' ? n : n * 16;\n}\n\nconst rel = (f) => f.startsWith(process.cwd()) ? f.slice(process.cwd().length + 1) : f;\nconst trunc = (s, n = 72) => (s.length > n ? s.slice(0, n - 1) + '…' : s);\n\nconst PAGE_ONLY = new Set(['3', '8', '9', '25', '28', '32', '34', '42', '44', '46b', '47b', '47c', '49', '50', '54', '55', '56', '58', '59', '65', '66', '68', '69', '71', '77', '78', '79', '80', '81', '82', '83', 'F7', 'F8', 'F15', 'F16', 'F20']);\nconst gateNumber = (id) => (/^(\\d{1,2})/.exec(String(id)) || [])[1] || null;\nconst pageOnly = (id) => PAGE_ONLY.has(String(id)) || PAGE_ONLY.has(gateNumber(id));\nconst APP_SKIP = new Set(['1', '6', '8', '9', '32', '42', '44', '46b', '58', '59', '66', '68', '77', '78', '79', '81', '82', '83', '85', 'F8', 'F15', 'F20']);\nconst appSkip = (id) => APP_SKIP.has(String(id)) || APP_SKIP.has(gateNumber(id));\n\nconst TIERS = new Map(Object.entries({\n '2': 'floor', '4': 'floor', '10': 'floor', '15': 'floor', '17': 'floor', '18': 'floor', '19': 'floor',\n '20': 'floor', '26': 'floor', '27': 'floor', '28': 'floor', '30': 'floor', '33': 'floor', '34': 'floor',\n '39': 'floor', '40': 'floor', '41': 'floor', '46': 'floor', '48': 'floor', '49': 'floor', '50': 'floor',\n '53': 'floor', '54': 'floor', '56': 'floor', '58': 'floor', '62': 'floor', '63': 'floor', '67': 'floor', '70': 'floor',\n '1': 'reflex', '3': 'reflex', '6': 'reflex', '7': 'reflex', '8': 'reflex', '9': 'reflex', '11': 'reflex',\n '12': 'reflex', '16': 'reflex', '22': 'reflex', '23': 'reflex', '24': 'reflex', '25': 'reflex', '29': 'reflex',\n '37': 'reflex', '38': 'reflex', '42': 'reflex', '44': 'reflex', '47': 'reflex', '55': 'reflex', '59': 'reflex',\n '64': 'reflex', '65': 'reflex', '66': 'reflex', '68': 'reflex', '69': 'reflex',\n '32': 'ledger',\n '2a': 'floor', '2b': 'reflex', '6a': 'floor', '6b': 'reflex', '7a': 'floor', '7b': 'reflex',\n '10a': 'floor', '10b': 'reflex', '24a': 'floor', '24b': 'reflex', '26a': 'floor', '26b': 'reflex',\n '28a': 'floor', '28b': 'reflex', '30a': 'floor', '30b': 'reflex', '38i': 'floor', '38ii': 'reflex', '38iii': 'reflex',\n '39a': 'floor', '39b': 'reflex', '44a': 'reflex', '44b': 'floor', '44c': 'reflex', '44d': 'floor',\n '46a': 'floor', '46b': 'reflex', '47a': 'floor', '47b': 'reflex', '47c': 'reflex', '49a': 'floor', '49b': 'reflex',\n '55a': 'floor', '55b': 'reflex', '71': 'reflex', '72': 'reflex', '73': 'floor', '74': 'reflex', '75': 'reflex', '76': 'floor',\n '77': 'reflex', '78': 'reflex', '79': 'reflex', '80': 'reflex', '81': 'reflex', '82': 'reflex', '83': 'reflex', '84': 'reflex', '85': 'reflex',\n}));\n\nconst HERO_SKIP = new Set(['8', '9', '32', '59', '76', '77', '78', 'F8', 'F15']);\nfunction baseGate(id) {\n const s = String(id).trim();\n if (/^F\\d+$/i.test(s)) return s.toUpperCase();\n const m = /^(\\d{1,2}(?:i{1,3}|[a-z])?)/.exec(s);\n return m ? m[1] : s;\n}\nfunction tierOf(gate) {\n const exact = TIERS.get(String(gate).trim());\n if (exact) return exact;\n const b = baseGate(gate);\n if (/^F\\d+$/.test(b)) return 'finish';\n return TIERS.get(b) || 'floor';\n}\n\nfunction fontFamilyCount(ctx) {\n const fams = new Set();\n for (const [prop, val] of Object.entries(ctx.tokens || {})) {\n if (/^--font-/.test(prop)) fams.add(famKey(firstFamily(resolveVars(val, ctx.tokens))));\n }\n return fams.size;\n}\nconst GUARDS = {\n '1': (ctx) => fontFamilyCount(ctx) >= 2 || 'fewer than two font families are declared',\n '2b': (ctx) => {\n for (const { r, grad } of gradientTextRules(ctx)) {\n if (!grad) return `${r.selector} clips text to a background that is not a gradient ramp`;\n if (/#[0-9a-f]{3,8}\\b|oklch\\(|rgba?\\(|hsla?\\(/i.test(grad.value)) return `${r.selector}: the ramp stops are literals, not tokens`;\n const hues = gradientHues(grad.value, ctx);\n if (hues.length >= 2 && Math.max(...hues) - Math.min(...hues) > 40) return `${r.selector}: ramp spans ${Math.round(Math.max(...hues) - Math.min(...hues))} degrees of hue`;\n }\n for (const it of twGroup(ctx).items) {\n const g = twGradient(ctx, it.info);\n if (!g || !g.text) continue;\n if (it.info.stops.some((s) => s.kind !== 'token')) return `${twWhere(it)}: the ramp stops are palette or literal classes, not tokens`;\n const hues = it.info.stops.map((s) => s.hue).filter((h) => h != null);\n if (hues.length >= 2 && Math.max(...hues) - Math.min(...hues) > 40) return `${twWhere(it)}: ramp spans ${Math.round(Math.max(...hues) - Math.min(...hues))} degrees of hue`;\n }\n return true;\n },\n '7': (ctx) => /surface:\\s*absolute/i.test(ctx.stampText) || 'the stamp does not declare surface: absolute',\n '23': (ctx) => /posture:\\s*(restrained|committed|full-palette|drenched)/i.test(ctx.stampText)\n || 'the stamp declares no colour posture',\n '37': (ctx) => fontFamilyCount(ctx) \u003c= 4 || 'five or more families is past the backstop',\n '26b': (ctx) => !ctx.docs.some((d) => d.tags.some((t) => !t.close && /aria-disabled/i.test(t.attrs))) || 'aria-disabled appears in the markup',\n '38': () => 'name the clause: 38ii (italic display) or 38iii (accented word)',\n '38ii': (ctx) => /font-synthesis\\s*:\\s*none/i.test(ctx.allCss) || 'font-synthesis: none is not declared',\n '38iii': (ctx) => accentedWordInHero(ctx),\n '42': (ctx) => (/(^|[·*])\\s*nav:\\s*\\S/im.test(ctx.stampText) && /(^|[·*])\\s*footer:\\s*\\S/im.test(ctx.stampText)) || 'the stamp has no nav: and footer: fields naming the chrome',\n '64': (ctx) => monoIsMaterial(ctx) || 'none of --font-display, --font-body or --font-outlier is a monospace face',\n '65': (ctx) => /by request|pinned|user named/i.test(ctx.stampText) || 'the stamp does not say the theme was asked for by name',\n '44': (ctx) => /(^|[·*])\\s*hero:\\s*\\S/im.test(ctx.stampText) || 'the stamp names no hero: posture',\n};\n\nconst WAIVER_MAX = 3;\n\nconst WAIVER_FILLER = /\\b(a|an|the|is|it|this|that|was|are|and|but|so|very|just|really|here|quite|more|most)\\b/gi;\nconst WAIVER_BOILERPLATE_PHRASES = [\n 'design choice', 'design decision', 'intentionally', 'intentional', 'by design',\n 'looks better', 'for the aesthetic', 'aesthetic', 'necessary here', 'necessary',\n 'the prompt requires it', 'prompt requires', 'the brief requires it', 'brief requires', 'on purpose', 'deliberate', 'deliberately',\n 'it works', 'works better', 'needed', 'required', 'fine', 'ok', 'okay',\n];\nfunction reasonIsBoilerplate(reason) {\n let r = reason.toLowerCase();\n for (const p of WAIVER_BOILERPLATE_PHRASES) r = r.split(p).join(' ');\n r = r.replace(WAIVER_FILLER, ' ').replace(/[^a-z0-9]+/g, ' ').trim();\n return r.length \u003c 12;\n}\n\nlet WAIVED = new Set();\nlet ANSWERED = new Set();\nlet DISMISSED = new Set();\nlet VERBATIM = false;\nlet APPSCREEN = false;\nlet LAYOUT_KEPT = false;\nconst STRUCTURAL = new Set(['6b', '8', '9', '32', '42', '44a', '44c', '58', '77', '79']);\nconst SPACING = new Set(['24b', '59', '78']);\nconst COPY_GATES = new Set(['19', '58', '69']);\n\nconst findings = [];\nfunction report(gate, grade, file, line, evidence, fix) {\n const g = String(gate);\n if (opts.before && g === '20') return;\n if (opts.scope === 'component' && pageOnly(g)) return;\n if ((opts.scope === 'app' || APPSCREEN) && appSkip(g)) return;\n if (opts.stage === 'hero' && HERO_SKIP.has(String(g))) return;\n const tier = tierOf(g);\n const b = baseGate(g);\n const waived = WAIVED.has(b) || WAIVED.has(gateNumber(b));\n if (VERBATIM && COPY_GATES.has(gateNumber(b)) && (grade === 'FAIL' || grade === 'WARN')) grade = 'NOTE';\n const owned = LAYOUT_KEPT === 'full' ? gateNumber(b) === '58' : STRUCTURAL.has(b) || STRUCTURAL.has(gateNumber(b)) || (LAYOUT_KEPT === 'polish' && SPACING.has(b));\n if (LAYOUT_KEPT && owned && grade !== 'NOTE') grade = 'NOTE';\n if (grade === 'FAIL') {\n if (tier === 'ledger') grade = 'NOTE';\n else if (tier === 'reflex') grade = waived ? 'WAIVED' : 'REFLEX';\n }\n if (tier === 'finish' && grade !== 'FAIL' && ANSWERED.has(b)) grade = 'ANSWERED';\n if (grade === 'WARN' && (DISMISSED.has(b) || DISMISSED.has(gateNumber(b)))) grade = 'DISMISSED';\n if (grade === 'WARN' && tier === 'reflex' && waived) grade = 'WAIVED';\n findings.push({ gate: g, grade, tier, file: rel(file), line: line || 1, evidence: trunc(String(evidence), g === 'kept' ? 400 : 120), fix });\n}\n\nfunction applyWaivers(ctx, dir) {\n WAIVED = new Set(); ANSWERED = new Set(); DISMISSED = new Set();\n VERBATIM = /\\bcopy:\\s*verbatim\\b/i.test(ctx.stampText);\n APPSCREEN = /\\bshape:\\s*app screen\\b/i.test(ctx.stampText);\n LAYOUT_KEPT = (/\\bredesign:\\s*(polish|restyle|full)\\b/i.exec(ctx.stampText) || [])[1]?.toLowerCase() || false;\n const re = /\\/\\*\\s*Hallmark\\s*·\\s*waive\\s+(\\d{1,2}[a-z]{0,3}|F\\d+)\\s*·\\s*([^·*]+?)\\s*·\\s*([^*]+?)\\s*\\*\\//gi;\n const claims = [...ctx.stampText.matchAll(re)].map((m) => ({\n id: m[1], evidence: m[2].trim(), reason: m[3].trim(), raw: m[0],\n }));\n\n const wellFormed = new Set(claims.map((c) => c.raw));\n const malformed = [...ctx.stampText.matchAll(/\\/\\*[^*]*Hallmark[^*]*·\\s*waive\\b[^*]*\\*\\//gi)]\n .map((m) => m[0]).filter((raw) => !wellFormed.has(raw));\n const reasons = new Map();\n for (const c of claims) {\n const key = c.reason.toLowerCase();\n reasons.set(key, (reasons.get(key) || 0) + 1);\n }\n\n const pageWords = new Set(\n (ctx.stampText.replace(/\\/\\*\\s*Hallmark\\s*·\\s*(?:waive|answered|dismissed)\\b[^*]*\\*\\//gi, ' ') + ' ' + ctx.docs.map((d) => d.text).join(' '))\n .toLowerCase().match(/[a-z]{4,}/g) || []\n );\n const file = ctx.cssFiles[0]?.file || ctx.docs[0]?.file || dir;\n const rejectAt = (c, why) =>\n findings.push({\n gate: baseGate(c.id), grade: 'WAIVER', tier: tierOf(c.id), file: rel(file), line: 1,\n evidence: trunc(`waiver on ${c.id} REJECTED (${why})`),\n fix: 'Fix the gate, or satisfy the guard and say why in the page\\'s own words',\n });\n\n for (const raw of malformed) {\n findings.push({\n gate: 'waive', grade: 'WAIVER', tier: 'reflex', file: rel(file), line: 1,\n evidence: trunc(`malformed waiver, ignored: ${raw.replace(/\\s+/g, ' ')}`),\n fix: 'Three · -separated fields: /* Hallmark · waive \u003cgate> · \u003cguard evidence> · \u003creason> */',\n });\n }\n for (const c of claims) {\n const b = baseGate(c.id);\n const tier = tierOf(c.id);\n if (tier !== 'reflex') {\n rejectAt(c, tier === 'ledger' ? 'a Ledger note is bookkeeping, nothing to waive'\n : tier === 'finish' ? 'Finish findings are answered, not waived'\n : 'Floor is never waivable');\n continue;\n }\n if (/^\\d+$/.test(c.id) && [...TIERS.keys()].some((k) => k !== c.id && gateNumber(k) === c.id)) { rejectAt(c, `name the clause (${[...TIERS.keys()].filter((k) => k !== c.id && gateNumber(k) === c.id).join(' or ')})`); continue; }\n if (WAIVED.has(b)) { rejectAt(c, 'one waiver per gate'); continue; }\n if (c.reason.length \u003c 24) { rejectAt(c, 'reason under 24 characters'); continue; }\n if (reasonIsBoilerplate(c.reason)) { rejectAt(c, 'boilerplate reason'); continue; }\n if (reasons.get(c.reason.toLowerCase()) > 1) { rejectAt(c, 'reason reused on another waiver'); continue; }\n const words = c.reason.toLowerCase().match(/[a-z]{4,}/g) || [];\n if (!words.some((w) => pageWords.has(w))) { rejectAt(c, 'reason shares no word with the page'); continue; }\n const guard = GUARDS[b] || GUARDS[gateNumber(b)];\n const verdict = guard ? guard(ctx) : true;\n if (verdict !== true) { rejectAt(c, `guard: ${verdict}`); continue; }\n WAIVED.add(b);\n }\n\n if (WAIVED.size > WAIVER_MAX) {\n findings.push({\n gate: 'waive', grade: 'NOTE', tier: 'ledger', file: rel(file), line: 1,\n evidence: trunc(`${WAIVED.size} reflex gates waived (${[...WAIVED].join(', ')}): that is a house style, not a set of exceptions`),\n fix: 'Lock it into a design.md so later pages inherit it instead of re-arguing it',\n });\n }\n\n const ansRe = /\\/\\*\\s*Hallmark\\s*·\\s*answered\\s+(\\d{1,2}[a-z]{0,3}|F\\d+)\\s*·\\s*([^*]+?)\\s*\\*\\//gi;\n for (const m of ctx.stampText.matchAll(ansRe)) {\n const id = baseGate(m[1]);\n const reason = m[2].trim();\n const bad = tierOf(id) !== 'finish' ? 'only Finish findings are answered; Reflex is waived and Floor is fixed'\n : reason.length \u003c 24 ? 'reason under 24 characters'\n : reasonIsBoilerplate(reason) ? 'boilerplate reason' : null;\n if (bad) {\n findings.push({\n gate: id, grade: 'WAIVER', tier: tierOf(id), file: rel(file), line: 1,\n evidence: trunc(`answer on ${m[1]} REJECTED (${bad})`),\n fix: 'Two · -separated fields: /* Hallmark · answered \u003ccheck> · \u003creason> */',\n });\n continue;\n }\n ANSWERED.add(id);\n }\n\n const disRe = /\\/\\*\\s*Hallmark\\s*·\\s*dismissed\\s+(\\d{1,2}[a-z]{0,3}|F\\d+)\\s*·\\s*([^*]+?)\\s*\\*\\//gi;\n for (const m of ctx.stampText.matchAll(disRe)) {\n const reason = m[2].trim();\n const bad = reason.length \u003c 24 ? 'reason under 24 characters' : reasonIsBoilerplate(reason) ? 'boilerplate reason' : null;\n if (bad) {\n findings.push({ gate: baseGate(m[1]), grade: 'WAIVER', tier: tierOf(m[1]), file: rel(file), line: 1, evidence: trunc(`dismissal of ${m[1]} REJECTED (${bad})`), fix: 'Two · -separated fields: /* Hallmark · dismissed \u003cgate> · \u003cwhat you checked and why the warning is wrong here> */' });\n continue;\n }\n DISMISSED.add(baseGate(m[1]));\n }\n}\n\nconst GRADE_RANK = { FAIL: 0, REFLEX: 1, WAIVER: 2, WARN: 3, WAIVED: 4, ANSWERED: 5, DISMISSED: 6, NOTE: 7 };\n\nfunction gateSortKey(g) {\n const f = /^F(\\d+)$/.exec(g);\n if (f) return 900 + Number(f[1]);\n const n = parseFloat(g);\n return Number.isNaN(n) ? 999 : n;\n}\n\nfunction selectorIsInteractive(sel) {\n return /(^|[\\s,>+~(])(a|button|input|select|textarea|summary)\\b|\\.(btn|button|cta|link|tab|chip|control)/i.test(sel);\n}\nconst HEADING_SEL = /(^|[\\s,>+~(])h[1-6]\\b|__title|__display|__headline|hero__(title|display|headline|h\\d)|wordmark|masthead|display/i;\nconst GENERIC_FAMILIES = new Set(['serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui', 'ui-monospace', 'ui-serif', 'ui-sans-serif', 'ui-rounded', 'math', 'emoji', 'fangsong', 'inherit', 'initial', 'unset']);\n\nfunction mergedDeclsBySelector(ctx) {\n if (ctx._merged) return ctx._merged;\n const map = new Map();\n for (const r of ctx.rules) {\n if (r.keyframes || r.selector === '@font-face') continue;\n const key = r.selector + '@@' + (r.media || '');\n if (!map.has(key)) map.set(key, { selector: r.selector, media: r.media, decls: [], file: r.file, line: r.line });\n map.get(key).decls.push(...r.decls);\n }\n ctx._merged = [...map.values()];\n return ctx._merged;\n}\n\nfunction styleIndex(ctx) {\n if (ctx._styleIdx) return ctx._styleIdx;\n const entries = [];\n for (const r of ctx.rules) {\n if (r.keyframes || r.selector === '@font-face') continue;\n if (r.media && /max-width/i.test(r.media)) continue;\n for (const part of r.selector.split(',')) {\n const compound = part.trim().split(/[\\s>+~]+/).filter(Boolean).pop();\n if (!compound) continue;\n const cleaned = compound.replace(/::?[a-z-]+(\\([^)]*\\))?/gi, '').replace(/\\[[^\\]]*\\]/g, '');\n const classes = (cleaned.match(/\\.[a-zA-Z_][\\w-]*/g) || []).map((c) => c.slice(1));\n const tagM = /^([a-zA-Z][\\w-]*)/.exec(cleaned);\n const tag = tagM ? tagM[1].toLowerCase() : null;\n if (!classes.length && !tag) continue;\n entries.push({ classes, tag, decls: r.decls, state: /:(hover|active|focus|focus-visible|focus-within|disabled|checked|visited|target)\\b|::?(before|after|marker|placeholder|selection|backdrop)\\b/i.test(compound) });\n }\n }\n ctx._styleIdx = entries;\n return entries;\n}\n\nfunction styleFor(ctx, classAttr, tagName, restingOnly = false) {\n const have = new Set(String(classAttr || '').split(/\\s+/).filter(Boolean));\n const decls = [];\n for (const e of styleIndex(ctx)) {\n if (restingOnly && e.state) continue;\n if (e.tag && e.tag !== tagName) continue;\n if (!e.classes.every((c) => have.has(c))) continue;\n decls.push(...e.decls);\n }\n return decls;\n}\n\nfunction declValue(ctx, decls, prop) {\n let v = null;\n for (const d of decls) if (d.prop === prop) v = d.value;\n return v == null ? null : resolveVars(v, ctx.tokens);\n}\n\nfunction fontPxOf(ctx, decls) {\n const v = declValue(ctx, decls, 'font-size');\n if (!v) return null;\n const clamped = /clamp\\(\\s*([^,]+),/.exec(v);\n return pxOf(clamped ? clamped[1].trim() : v);\n}\n\nfunction trackingEmOf(ctx, decls, fontPx) {\n const v = declValue(ctx, decls, 'letter-spacing');\n if (!v) return null;\n const m = /^(-?\\d*\\.?\\d+)em$/.exec(v.trim());\n if (m) return parseFloat(m[1]);\n const px = pxOf(v);\n if (px == null || !fontPx) return null;\n return px / fontPx;\n}\n\nfunction check1(ctx) {\n const banned = new Set(['inter', 'roboto', 'open sans', 'poppins', 'lato', 'system-ui', '-apple-system', 'arial', 'helvetica', 'helvetica neue', 'segoe ui']);\n const seen = new Set();\n const flag = (fam, file, line, where) => {\n if ([...banned].some((b) => famKey(b) === famKey(fam)) && !seen.has(fam + where)) {\n seen.add(fam + where);\n report(1, 'FAIL', file, line, `display font \"${fam}\" (${where})`, 'Pick a display face from the typography catalog');\n }\n };\n for (const [prop, val] of Object.entries(ctx.tokens)) {\n if (/^--font-(display|heading|title|hero)/.test(prop)) {\n const tr = ctx.rules.find((r) => isTokenRule(r) && r.decls.some((d) => d.prop === prop));\n flag(firstFamily(resolveVars(val, ctx.tokens)), tr ? tr.file : ctx.rules[0]?.file || '?', tr ? tr.decls.find((d) => d.prop === prop)?.line : 1, prop);\n }\n }\n for (const r of ctx.rules) {\n if (isTokenRule(r) || !HEADING_SEL.test(r.selector)) continue;\n for (const d of r.decls) {\n if (d.prop === 'font-family') flag(firstFamily(resolveVars(d.value, ctx.tokens)), r.file, d.line, r.selector);\n }\n }\n}\n\nconst hueIn = (h, lo, hi) => (lo \u003c= hi ? h >= lo && h \u003c= hi : h >= lo || h \u003c= hi);\nfunction gradientHues(value, ctx) {\n const resolved = resolveVars(value, ctx.tokens);\n const stops = [];\n const colRe = /#[0-9a-fA-F]{3,8}\\b|oklch\\([^)]*\\)|rgba?\\([^)]*\\)|hsla?\\([^)]*\\)/g;\n let cm;\n while ((cm = colRe.exec(resolved))) {\n const c = parseColor(cm[0]);\n if (c && c.alpha > 0.05) {\n const ok = c.okl || srgbToOklch(c);\n if (ok.C > 0.04) stops.push(ok.H);\n }\n }\n return stops;\n}\nfunction paperIsDark(ctx) {\n const p = parseColor(resolveVars(ctx.tokens['--color-paper'] || '', ctx.tokens));\n return !!p && (p.okl || srgbToOklch(p)).L \u003c 0.4;\n}\nfunction bannedGradient(stops, ctx) {\n const purple = stops.some((h) => hueIn(h, 265, 330));\n const blue = stops.some((h) => hueIn(h, 200, 264));\n const pink = stops.some((h) => hueIn(h, 331, 25));\n const cyan = stops.some((h) => hueIn(h, 170, 230));\n if (purple && blue) return 'purple-to-blue';\n if (purple && pink) return 'purple-to-pink';\n if (cyan && paperIsDark(ctx)) return 'cyan-on-dark';\n return null;\n}\nfunction gradientTextRules(ctx) {\n const out = [];\n for (const r of mergedDeclsBySelector(ctx)) {\n const clip = r.decls.find((d) => /background-clip/.test(d.prop) && /\\btext\\b/i.test(d.value));\n if (!clip) continue;\n const grad = r.decls.find((d) => /^background(-image)?$/.test(d.prop) && /gradient\\(/i.test(d.value));\n const transparent = r.decls.find((d) => (d.prop === 'color' || d.prop === '-webkit-text-fill-color') && /transparent/i.test(d.value));\n if (grad || transparent) out.push({ r, clip, grad });\n }\n return out;\n}\nfunction withoutPatterns(value) {\n let out = '', i = 0;\n const re = /repeating-(?:linear|radial|conic)-gradient\\(/gi;\n let m;\n while ((m = re.exec(value))) {\n out += value.slice(i, m.index);\n let depth = 1, k = m.index + m[0].length;\n while (k \u003c value.length && depth > 0) { if (value[k] === '(') depth++; else if (value[k] === ')') depth--; k++; }\n i = k; re.lastIndex = k;\n }\n return out + value.slice(i);\n}\n\nfunction check2(ctx) {\n const textRules = new Set();\n for (const { r, clip, grad } of gradientTextRules(ctx)) {\n textRules.add(r.selector);\n const banned = grad ? bannedGradient(gradientHues(grad.value, ctx), ctx) : null;\n if (banned) report('2a', 'FAIL', r.file, clip.line, `${banned} gradient text on ${r.selector}`, 'One anchor hue, solid ink');\n else report('2b', 'FAIL', r.file, clip.line, `gradient text on ${r.selector}`, 'Solid ink; weight or a display face. Guard: a single-hue ramp from two tokens with 40 and 41 passing');\n }\n for (const r of ctx.rules) {\n if (textRules.has(r.selector)) continue;\n for (const d of r.decls) {\n if (!/gradient\\(/i.test(d.value)) continue;\n const washes = withoutPatterns(d.value);\n if (!/gradient\\(/i.test(washes)) continue;\n const banned = bannedGradient(gradientHues(washes, ctx), ctx);\n if (banned) report('2a', 'FAIL', r.file, d.line, `${banned} gradient on ${r.selector}`, 'One anchor hue; drop the gradient');\n }\n }\n}\n\nfunction check3(ctx) {\n for (const r of ctx.rules) {\n for (const d of r.decls) {\n if (d.prop !== 'grid-template-columns') continue;\n const v = resolveVars(d.value, ctx.tokens).trim();\n if (/^repeat\\(\\s*3\\s*,\\s*(minmax\\(\\s*0(px)?\\s*,\\s*1fr\\s*\\)|1fr)\\s*\\)$/.test(v)\n || /^(1fr|minmax\\(\\s*0(px)?\\s*,\\s*1fr\\s*\\))(\\s+(1fr|minmax\\(\\s*0(px)?\\s*,\\s*1fr\\s*\\))){2}$/.test(v)) {\n\n const classes = (r.selector.match(/\\.[a-zA-Z][\\w-]*/g) || []).map((c) => c.slice(1));\n let iconTiles = !ctx.docs.length && !classes.length;\n for (const doc of ctx.docs) {\n for (let i = 0; i \u003c doc.tags.length; i++) {\n const t = doc.tags[i];\n if (t.close || t.selfClosing) continue;\n const cls = getAttr(t.attrs, 'class') || '';\n if (!classes.some((c) => cls.split(/\\s+/).includes(c))) continue;\n const inner = innerOf(doc, i);\n if (inner && (/class\\s*=\\s*[\"'][^\"']*icon/i.test(inner.inner) || /\u003csvg\\b/i.test(inner.inner)) && /\u003ch[2-6]\\b/i.test(inner.inner)) {\n iconTiles = true;\n }\n }\n }\n if (iconTiles) {\n report(3, 'WARN', r.file, d.line, `3 equal icon-tile columns on ${r.selector}`, 'Vary column widths or drop the icon tiles');\n }\n }\n }\n }\n}\n\nfunction check4(ctx) {\n\n const cardWord = /(^|-)card(--[\\w-]+)?$/i;\n const containerish = /(grid|list|rail|row|wrap|deck|group|stack|container|section|scroller|track)/i;\n for (const doc of ctx.docs) {\n const stack = [];\n for (const t of doc.tags) {\n if (t.close) {\n for (let i = stack.length - 1; i >= 0; i--) {\n if (stack[i].name === t.name) { stack.splice(i); break; }\n }\n continue;\n }\n if (t.selfClosing) continue;\n const cls = getAttr(t.attrs, 'class') || '';\n const isCard = cls.split(/\\s+/).some((c) => { const base = c.split('--')[0]; return base && cardWord.test(base) && !containerish.test(base); });\n if (isCard && stack.some((s) => s.isCard)) {\n report(4, 'FAIL', doc.file, t.line, `card nested in card: class=\"${trunc(cls, 40)}\"`, 'Keep one containment layer');\n }\n stack.push({ name: t.name, isCard });\n }\n }\n}\n\nfunction check3Stripe(ctx) {\n for (const r of ctx.rules) {\n for (const d of r.decls) {\n if (!/^border-(left|right|inline-start|inline-end)$/.test(d.prop)) continue;\n const m = /(\\d+(?:\\.\\d+)?)px/.exec(d.value);\n if (m && parseFloat(m[1]) >= 3 && !/transparent|none/i.test(d.value)) {\n report(3, 'WARN', r.file, d.line, `side stripe ${d.prop}: ${trunc(d.value, 32)} on ${r.selector}`, 'Hairline all round, none, or an accent square');\n }\n }\n }\n}\n\nfunction heroClasses(ctx) {\n const out = new Set();\n for (const doc of ctx.docs) {\n const h1 = doc.tags.findIndex((t) => !t.close && t.name === 'h1');\n if (h1 \u003c 0) continue;\n for (let i = h1 - 1; i >= 0; i--) {\n const t = doc.tags[i];\n if (t.close || !/^(section|header)$/.test(t.name)) continue;\n for (const c of (getAttr(t.attrs, 'class') || '').split(/\\s+/)) if (c) out.add(c);\n break;\n }\n }\n return out;\n}\nfunction check6a(ctx) {\n const heroCls = heroClasses(ctx);\n const isHero = (sel) => /hero/i.test(sel) || [...heroCls].some((c) => sel.includes('.' + c));\n const seen = new Set();\n for (const r of ctx.rules) {\n if (r.keyframes || isTokenRule(r) || !isHero(r.selector)) continue;\n for (const d of r.decls) {\n if (!/^(min-)?height$/.test(d.prop)) continue;\n const v = resolveVars(d.value, ctx.tokens);\n if (!/\\d\\s*vh\\b/i.test(v) || seen.has(r.selector)) continue;\n seen.add(r.selector);\n report('6a', 'FAIL', r.file, d.line, `hero sized in vh: ${d.prop}: ${trunc(v, 24)} on ${r.selector}`, 'dvh or svh');\n }\n }\n}\n\nfunction check7(ctx) {\n const props = /^(color|background|background-color|border-color|outline-color)$|^--color-/;\n for (const r of ctx.rules) {\n if (r.selector === '@font-face') continue;\n for (const d of r.decls) {\n if (!props.test(d.prop)) continue;\n const resolved = resolveVars(d.value, ctx.tokens).trim();\n const c = parseColor(resolved);\n if (!c || c.alpha \u003c 1) continue;\n const isWhite = c.r > 0.999 && c.g > 0.999 && c.b > 0.999;\n const isBlack = c.r \u003c 0.001 && c.g \u003c 0.001 && c.b \u003c 0.001;\n if (isWhite && ctx.genre === 'modern-minimal') continue;\n if (isWhite || isBlack) {\n\n const viaToken = d.prop.startsWith('--') || /var\\(/.test(d.value);\n report(viaToken ? '7b' : '7a', 'FAIL', r.file, d.line, `pure ${isBlack ? '#000' : '#fff'}: ${d.prop}: ${trunc(d.value, 28)} (${r.selector})`, 'Tint the base toward the anchor hue');\n }\n }\n }\n}\n\nfunction check10(ctx) {\n for (const r of ctx.rules) {\n for (const d of r.decls) {\n if (d.prop === 'transition' && /(^|,)\\s*all[\\s,]|^all$|^all\\s/.test(d.value.trim())) {\n report('10a', 'FAIL', r.file, d.line, `transition: all on ${r.selector}`, 'List the transitioned properties explicitly');\n }\n if (d.prop === 'transition-property' && /\\ball\\b/.test(d.value)) {\n report('10a', 'FAIL', r.file, d.line, `transition-property: all on ${r.selector}`, 'List the transitioned properties explicitly');\n }\n }\n }\n}\n\nfunction check11(ctx) {\n const byScale = new Map();\n for (const r of ctx.rules) {\n if (!/:hover/.test(r.selector)) continue;\n for (const d of r.decls) {\n if (d.prop !== 'transform') continue;\n const m = /scale\\(\\s*([\\d.]+)\\s*\\)/.exec(d.value);\n if (m && parseFloat(m[1]) !== 1) {\n const k = m[1];\n if (!byScale.has(k)) byScale.set(k, []);\n byScale.get(k).push({ sel: r.selector, file: r.file, line: d.line });\n }\n }\n }\n for (const [k, list] of byScale) {\n const distinct = new Set(list.map((x) => x.sel));\n if (distinct.size >= 2) {\n report(11, 'WARN', list[0].file, list[0].line, `hover scale(${k}) on ${distinct.size} selectors`, 'One hover signal per element');\n }\n }\n for (const r of ctx.rules) {\n if (!/:hover/.test(r.selector) || !/(^|[\\s>+~,])(img|picture|figure|video)\\b|__(img|image|photo|thumb|cover)\\b|\\.(img|image|photo|thumb|cover)\\b/i.test(r.selector)) continue;\n const t = r.decls.find((d) => d.prop === 'transform' && /scale|rotate/.test(d.value));\n if (t) report(11, 'WARN', r.file, t.line, `image transforms on hover (${r.selector})`, 'Imagery sits still; give the container the feedback');\n }\n}\n\nfunction check12(ctx) {\n const uiSel = /:hover|:active|:focus|\\b(btn|button|modal|dialog|tooltip|dropdown|menu|popover|toast|tab|input|select|switch|toggle|accordion)\\b/i;\n const playful = ctx.genre === 'playful' || /genre:\\s*playful/.test(ctx.stampText || '');\n const declared = playful && /--ease-spring/.test(Object.keys(ctx.tokens).join(' '));\n const springHome = (r) => /primary|cta|press|character|mascot|hero__btn|btn--solid|btn--main/i.test(r.selector);\n const springSels = new Set();\n for (const r of ctx.rules) {\n if (!uiSel.test(r.selector)) continue;\n for (const d of r.decls) {\n if (!/transition|animation/.test(d.prop)) continue;\n if (declared && /var\\(--ease-spring\\)/.test(d.value) && springHome(r) && (springSels.add(baseSelector(r.selector).split(':')[0]), springSels.size \u003c= 2)) continue;\n const re = /cubic-bezier\\(\\s*([-\\d.]+)\\s*,\\s*([-\\d.]+)\\s*,\\s*([-\\d.]+)\\s*,\\s*([-\\d.]+)\\s*\\)/g;\n let m;\n while ((m = re.exec(resolveVars(d.value, ctx.tokens)))) {\n const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);\n if (y1 > 1.05 || y2 > 1.05 || y1 \u003c -0.05 || y2 \u003c -0.05) {\n report(12, 'FAIL', r.file, d.line, `overshoot easing ${m[0]} on ${r.selector}`, 'Use exponential ease-out on UI state changes');\n }\n }\n }\n }\n}\n\nfunction check10Motion(ctx) {\n const hardRe = /(?\u003c![\\w-])(width|height|top|left|right|bottom|inset(?:-[a-z-]+)?|flex-basis|grid-template-columns)(?![\\w-])/;\n const softRe = /(?\u003c![\\w-])(margin(?:-[a-z]+)?|padding(?:-[a-z]+)?)(?![\\w-])/;\n const a11ySel = /skip|sr-only|visually-hidden/i;\n for (const r of ctx.rules) {\n if (a11ySel.test(r.selector)) continue;\n if (r.keyframes) {\n for (const d of r.decls) {\n if (/^(min|max)-/.test(d.prop)) continue;\n if (hardRe.test(d.prop)) {\n report('10a', 'FAIL', r.file, d.line, `@keyframes ${r.keyframes} animates ${d.prop}`, 'Animate transform or opacity instead');\n } else if (softRe.test(d.prop)) {\n report('10b', 'WARN', r.file, d.line, `@keyframes ${r.keyframes} animates ${d.prop}`, 'Animate transform or opacity instead');\n }\n }\n continue;\n }\n for (const d of r.decls) {\n if (d.prop === 'transition' || d.prop === 'transition-property') {\n const hard = hardRe.exec(d.value);\n const soft = softRe.exec(d.value);\n if (hard) {\n report('10a', 'FAIL', r.file, d.line, `transitioning ${hard[1]} on ${r.selector}`, 'Animate transform or opacity instead');\n } else if (soft) {\n report('10b', 'WARN', r.file, d.line, `transitioning ${soft[1]} on ${r.selector}`, 'Animate transform or opacity instead');\n }\n }\n }\n }\n}\n\nfunction check15(ctx) {\n for (const r of ctx.rules) {\n if (!/:focus(-visible|-within)?\\b/.test(r.selector)) continue;\n const withinOnly = !/:focus(-visible)?(?![\\w-])/.test(r.selector);\n for (const d of r.decls) {\n if (d.prop === 'transition' && (withinOnly ? /outline|box-shadow|\\ball\\b/ : /outline|box-shadow|opacity|\\ball\\b/).test(d.value)) {\n report(15, 'FAIL', r.file, d.line, `focus ring transition on ${r.selector}`, 'Remove the transition; rings appear instantly');\n }\n }\n }\n}\n\nfunction check17(ctx) {\n const tips = ctx.rules.filter((r) => /tooltip|tip\\b/i.test(r.selector));\n const delayed = (r) => r.decls.find((d) => /^transition(-delay)?$/.test(d.prop) && /(\\d{3,})ms|([1-9][\\d.]*)s\\b/.test(d.value));\n const hover = tips.filter((r) => /:hover/.test(r.selector)).map(delayed).find(Boolean);\n const focus = tips.filter((r) => /:focus/.test(r.selector)).map(delayed).find(Boolean);\n if (hover && focus) {\n report(17, 'WARN', tips[0].file, focus.line, 'tooltip focus delay matches hover delay', 'Hover delay 800-1000ms; focus delay 0');\n }\n}\n\nfunction check18(ctx) {\n const autoish = /carousel|marquee|ticker|rotate|slider|scroll|auto/i;\n const paused = ctx.rules.some((r) => /:hover|:focus|\\[data-loop\\]/.test(r.selector) && r.decls.some((d) => d.prop === 'animation-play-state' && /paused/.test(d.value)));\n for (const r of ctx.rules) {\n if (r.keyframes || !autoish.test(r.selector)) continue;\n for (const d of r.decls) {\n if (/^animation(-iteration-count)?$/.test(d.prop) && /\\binfinite\\b/.test(d.value) && !paused) {\n report(18, 'WARN', r.file, d.line, `infinite animation on ${r.selector} with no pause state`, 'Pause on hover and focus');\n }\n }\n }\n}\n\nfunction check19(ctx) {\n for (const doc of ctx.docs) {\n const t = maskedProse(doc);\n const patterns = [\n [/\\b(jane doe|john smith|john doe|example user)\\b/i, 'placeholder name'],\n [/\\bAcme\\b/, 'startup cliche name'],\n [/\\bNexus\\b/, 'startup cliche name'],\n ];\n const title = (/\u003ctitle\\b[^>]*>([^\u003c]*)\u003c\\/title>/i.exec(doc.src) || [])[1] || '';\n for (const [re, label] of patterns) {\n const m = re.exec(t);\n if (!m) continue;\n if (label === 'startup cliche name' && (re.test(title) || (t.match(new RegExp(re.source, 'g')) || []).length >= 3)) continue;\n report(19, 'FAIL', doc.file, doc.lineOf(m.index), `${label}: \"${m[0]}\"`, 'Use plausible audience-fitting names');\n }\n }\n}\n\nfunction check20(ctx) {\n if (ctx.tokensOnly) return;\n const stamped = (src) => {\n const lines = src.split('\\n').map((l) => l.trim()).filter(Boolean);\n let i = 0;\n while (i \u003c lines.length && /^@charset\\b/i.test(lines[i])) i++;\n return i \u003c lines.length && /^\\/\\*\\s*Hallmark/.test(lines[i]);\n };\n let ok = false;\n const cssSources = [];\n for (const f of ctx.cssFiles) cssSources.push(f.raw);\n for (const doc of ctx.docs) for (const s of doc.styles) cssSources.push(s.css);\n for (const s of cssSources) if (stamped(s)) { ok = true; break; }\n const pageCss = ctx.cssFiles.length || ctx.docs.some((d) => d.kind === 'html' && d.styles.length);\n if (!ok && cssSources.length && pageCss) {\n const where = ctx.cssFiles[0]?.file || ctx.docs[0]?.file;\n report(20, 'FAIL', where, 1, 'no Hallmark stamp on the first non-empty line of any CSS', 'The stamp comment is line 1 of the CSS; only @charset may precede it');\n }\n}\n\nfunction check22(ctx) {\n if (ctx.genre === 'modern-minimal') return;\n const MASK_PROPS = /(^|-)mask(-image|-composite|-mode)?$|^filter$|^backdrop-filter$/;\n for (const r of ctx.rules) {\n for (const d of r.decls) {\n if (MASK_PROPS.test(d.prop)) continue;\n const re = /oklch\\(\\s*([\\d.]+%?)\\s+([\\d.]+)\\s+[\\d.]+(?:deg)?\\s*(?:\\/\\s*([\\d.]+%?))?\\s*\\)/g;\n let m;\n while ((m = re.exec(d.value))) {\n const chroma = parseFloat(m[2]);\n const alpha = m[3] ? (m[3].endsWith('%') ? parseFloat(m[3]) / 100 : parseFloat(m[3])) : 1;\n if (chroma === 0 && alpha >= 1) {\n report(22, 'FAIL', r.file, d.line, `zero-chroma ${m[0]} (${d.prop} on ${r.selector})`, 'Give neutrals at least 0.005 chroma');\n }\n }\n }\n }\n}\n\nfunction check23(ctx) {\n if (ctx.posture === 'drenched') return;\n const ACC = /var\\(\\s*--color-accent(?:-2)?\\s*[,)]/;\n const SURFACE = /(^|[\\s,>~+])(body|html|main|section|header|footer|aside)(\\b|$)|\\bhero\\b|__hero|banner|\\bstrip\\b|\\bband\\b/i;\n const big = (r) => r.decls.some((d) =>\n (/^(min-)?height$/.test(d.prop) && (parseFloat((/([\\d.]+)\\s*(vh|dvh|svh)/.exec(d.value) || [])[1] || 0) >= 40 || (pxOf(d.value) ?? 0) >= 320)) ||\n (d.prop === 'inset' && /^0(px)?(\\s|$)/.test(d.value.trim())));\n for (const r of ctx.rules) {\n if (isTokenRule(r) || r.keyframes) continue;\n for (const d of r.decls) {\n if (!/^background(-color|-image)?$/.test(d.prop) || !ACC.test(d.value)) continue;\n if (/\\/\\s*0?\\.[0-2]\\d*/.test(d.value)) continue;\n if (ctx.genre === 'atmospheric' && /radial-gradient/i.test(d.value)) continue;\n if (SURFACE.test(r.selector) || big(r)) {\n report(23, 'WARN', r.file, d.line,\n `accent background on viewport-scale rule \"${trunc(r.selector, 44)}\"`,\n 'Accent stays \u003c= 5% of a viewport; large fills belong to paper/field tokens or a declared posture in the stamp');\n }\n }\n const col = r.decls.find((d) => d.prop === 'color' && ACC.test(d.value));\n const fsDecl = r.decls.find((d) => d.prop === 'font-size');\n if (col && fsDecl) {\n const rv = resolveVars(fsDecl.value, ctx.tokens);\n const clampMax = /,\\s*([^,()]+)\\)\\s*$/.exec(rv);\n const maxPx = pxOf(rv) ?? (clampMax ? pxOf(clampMax[1].trim()) : null);\n if ((maxPx != null && maxPx >= 48) || /--text-display\\b/.test(fsDecl.value)) {\n report(23, 'WARN', r.file, col.line,\n `display-size text set in accent on \"${trunc(r.selector, 44)}\"`,\n 'Accent marks emphasis; set display type in ink and keep accent for small signals');\n }\n }\n }\n}\n\nfunction check24(ctx) {\n const props = /^(padding|margin|gap|row-gap|column-gap)(-[a-z]+)?$/;\n for (const r of ctx.rules) {\n for (const d of r.decls) {\n if (!props.test(d.prop)) continue;\n const re = /(-?\\d+(?:\\.\\d+)?)px/g;\n let m;\n while ((m = re.exec(d.value))) {\n const v = Math.abs(parseFloat(m[1]));\n if (v > 2 && (v % 4 !== 0 || !Number.isInteger(v))) {\n report('24b', 'FAIL', r.file, d.line, `${d.prop}: ${trunc(d.value, 28)} (off the 4px scale) on ${r.selector}`, 'Use the 4px spacing scale');\n }\n }\n }\n }\n}\n\nfunction check24a(ctx) {\n const spaceVals = new Map();\n for (const [k, v] of Object.entries(ctx.tokens)) {\n if (!/^--space-/.test(k)) continue;\n const px = pxOf(resolveVars(v, ctx.tokens));\n if (px != null && px > 0) spaceVals.set(px, k);\n }\n if (!spaceVals.size) return;\n const props = /^(padding|margin|gap|row-gap|column-gap)(-[a-z]+)?$/;\n const counts = new Map();\n for (const r of ctx.rules) {\n if (r.keyframes || isTokenRule(r)) continue;\n for (const d of r.decls) {\n if (!props.test(d.prop)) continue;\n const raw = d.value.replace(/var\\([^)]*\\)/g, ' ');\n for (const m of raw.matchAll(/(?\u003c![\\w.-])(-?\\d+(?:\\.\\d+)?)(px|rem)\\b/g)) {\n const px = Math.abs(pxOf(m[1] + m[2]) ?? 0);\n if (!px || !spaceVals.has(px)) continue;\n const n = (counts.get(r.file) || 0) + 1;\n counts.set(r.file, n);\n if (n \u003c= 8) report('24a', 'FAIL', r.file, d.line, `${d.prop}: ${trunc(d.value, 28)} on ${trunc(r.selector, 32)} where ${spaceVals.get(px)} exists`, `Use var(${spaceVals.get(px)})`);\n break;\n }\n }\n }\n for (const [file, n] of counts) if (n > 8) report('24a', 'FAIL', file, 1, `+${n - 8} more raw spacing lengths with a matching --space-* token in this file`, 'Use the tokens');\n}\n\nfunction shortProse(ctx, sel) {\n const cls = [...sel.matchAll(/\\.([\\w-]+)/g)].map((m) => m[1]).pop();\n if (!cls) return false;\n const texts = [];\n for (const doc of ctx.docs) for (const m of doc.src.matchAll(new RegExp(`\u003c(\\\\w+)[^>]*class=\"[^\"]*\\\\b${cls}\\\\b[^\"]*\"[^>]*>([\\\\s\\\\S]*?)\u003c/\\\\1>`, 'g'))) {\n for (const p of (/^p$/i.test(m[1]) ? [m[2]] : m[2].match(/\u003cp\\b[^>]*>[\\s\\S]*?\u003c\\/p>/g) || [])) texts.push(p.replace(/\u003c[^>]+>/g, ' ').replace(/\\s+/g, ' ').trim());\n }\n return texts.length > 0 && texts.every((t) => t.length \u003c= 200 && (t.match(/[.!?](?:\\s|$)/g) || []).length \u003c= 2);\n}\n\nfunction check25(ctx) {\n const proseish = /(^|[\\s,>+~(])p\\b|lede|prose|copy|__body|__text|__desc|paragraph/i;\n for (const r of ctx.rules) {\n if (!proseish.test(r.selector) || HEADING_SEL.test(r.selector)) continue;\n for (const d of r.decls) {\n if (d.prop !== 'max-width') continue;\n const m = /^([\\d.]+)ch$/.exec(resolveVars(d.value, ctx.tokens).trim());\n if (m) {\n const ch = parseFloat(m[1]);\n const floor = /lede|hero|caption|figcaption|__line|__sub|subhead|tagline/i.test(r.selector) || shortProse(ctx, r.selector) ? 28 : 45;\n if (ch \u003c floor || ch > 75) {\n report(25, 'WARN', r.file, d.line, `measure ${ch}ch on ${r.selector} (${floor}-75ch range)`, 'Set prose measure between 45 and 75ch; a one-sentence lede or caption from 28ch');\n }\n }\n }\n }\n}\n\nfunction check26(ctx) {\n const hasInteractive = ctx.docs.some((doc) => /\u003c(a|button|input|select|textarea)\\b|role\\s*=\\s*[\"']button/i.test(doc.src))\n || ctx.rules.some((r) => selectorIsInteractive(r.selector));\n if (!hasInteractive) return;\n const has = (pseudo) => ctx.rules.some((r) => r.selector.includes(pseudo));\n const tv = twGroup(ctx).variants;\n const shared = ctx.shared || {};\n const once = (key) => !shared.once || (!shared.once.has(key) && !!shared.once.add(key));\n const file = ctx.cssFiles[0]?.file || ctx.docs[0]?.file || '?';\n if (!has(':focus-visible') && !has(':focus') && !shared.focus && ![...tv].some((v) => /^focus(?:-visible|-within)?$/.test(v)) && once('26a')) {\n report('26a', 'FAIL', file, 1, 'no :focus-visible styling anywhere', 'A focus ring at 3:1 or better, instant');\n }\n if (!has(':active') && !shared.active && !tv.has('active') && once('26b:active')) {\n report('26b', 'FAIL', file, 1, 'no :active styling anywhere', 'All eight states; guard: not a form control, no aria-disabled anywhere');\n }\n const hasFormish = ctx.docs.some((doc) => /\u003c(button|input|select|textarea)\\b/i.test(doc.src));\n if (hasFormish && !has(':disabled') && !shared.disabled && !tv.has('disabled') && !tv.has('aria-disabled') && !ctx.rules.some((r) => /\\[disabled\\]|aria-disabled/.test(r.selector)) && once('26b:disabled')) {\n report('26b', 'FAIL', file, 1, 'no :disabled styling anywhere', 'All eight states; guard: not a form control, no aria-disabled anywhere');\n }\n}\n\nfunction check27(ctx) {\n const tw = twGroup(ctx);\n const moves = (r) => r.decls.some((d) => /^(transform|translate|scale|rotate)$/.test(d.prop) && !/^none\\b/.test(d.value.trim()));\n const hasMotion = ctx.rules.some((r) => r.keyframes)\n || ctx.rules.some((r) => r.decls.some((d) => /^transition/.test(d.prop) && /transform/.test(d.value)))\n || (ctx.rules.some((r) => r.decls.some((d) => /^transition/.test(d.prop) && /(^|[\\s,])all\\b/.test(d.value))) && ctx.rules.some((r) => /:(hover|active|focus)/.test(r.selector) && moves(r)));\n if (!hasMotion && !tw.motion) return;\n const hasPRM = ctx.rules.some((r) => r.media && /prefers-reduced-motion/i.test(r.media))\n || ctx.cssFiles.some((f) => /prefers-reduced-motion/i.test(stripComments(f.raw)))\n || ctx.docs.some((doc) => doc.styles.some((s) => /prefers-reduced-motion/i.test(stripComments(s.css))))\n || tw.variants.has('motion-reduce') || tw.variants.has('motion-safe') || !!ctx.shared?.prm;\n if (!hasPRM && ctx.shared) {\n if (ctx.shared.once.has('27')) return;\n ctx.shared.once.add('27');\n }\n if (!hasPRM && !hasMotion) {\n const m = tw.motion;\n report(27, 'FAIL', m.doc.file, m.line, `motion utilities (${m.info.animate || 'a transition on a transform'}) with no motion-reduce: or motion-safe: variant anywhere`, 'Add motion-reduce: fallbacks, or put the motion behind motion-safe:');\n } else if (!hasPRM) {\n const kf = ctx.rules.find((r) => r.keyframes);\n const file = kf ? kf.file : (ctx.cssFiles[0]?.file || ctx.docs[0]?.file || '?');\n report(27, 'FAIL', file, kf ? kf.line : 1, 'motion present, no prefers-reduced-motion block', 'Add a prefers-reduced-motion fallback');\n }\n}\n\nfunction check28(ctx) {\n for (const doc of ctx.docs) {\n let firstMedia = true, seenH1 = false, heroPassed = false;\n for (const t of doc.tags) {\n if (t.close) { if (seenH1 && (t.name === 'section' || t.name === 'header')) heroPassed = true; continue; }\n if (t.name === 'h1') seenH1 = true;\n if (t.name === 'video') {\n if (hasAttr(t.attrs, 'autoplay') && !hasAttr(t.attrs, 'muted')) {\n report('28a', 'FAIL', doc.file, t.line, 'autoplay video without muted', 'autoplay muted loop playsinline, with poster');\n }\n if (!getAttr(t.attrs, 'poster')) {\n report('28b', 'FAIL', doc.file, t.line, 'video without poster', 'Add a poster frame; guard: below the fold and not the LCP');\n }\n if ((getAttr(t.attrs, 'loading') || '').toLowerCase() === 'lazy' && firstMedia) {\n report('28a', 'WARN', doc.file, t.line, 'lazy-loaded hero video', 'Never lazy on the LCP video; preload its poster with fetchpriority=high');\n }\n firstMedia = false;\n }\n if (t.name === 'img') {\n if (firstMedia && !heroPassed && (getAttr(t.attrs, 'loading') || '').toLowerCase() === 'lazy') {\n report('28a', 'WARN', doc.file, t.line, 'lazy-loaded first image (likely LCP)', 'fetchpriority=high on the LCP element');\n }\n firstMedia = false;\n }\n }\n }\n}\n\nfunction check30(ctx) {\n const EMOJI = /(?![©®™‼⁉←-⇿])\\p{Extended_Pictographic}/u;\n const GLYPH = /(?![©®™‼⁉←-⇿✓-✖])(?:\\p{Emoji_Presentation}|\\p{Extended_Pictographic}️)/u;\n for (const doc of ctx.docs) {\n const lines = new Set();\n let n = 0;\n const flag = (line, what) => { if (lines.has(line) || n >= 8) return; lines.add(line); n++; report('30a', 'FAIL', doc.file, line, what, 'One icon library, a custom SVG, or the word alone'); };\n for (let i = 0; i \u003c doc.tags.length; i++) {\n const t = doc.tags[i];\n if (t.close || t.selfClosing) continue;\n const cls = getAttr(t.attrs, 'class') || '';\n const iconish = /icon|card|step|tier|feature|value|prop|badge/i.test(cls);\n const inner = innerOf(doc, i);\n if (!inner) continue;\n const content = inner.inner.replace(/\u003c[^>]*>/g, '').trim();\n if (content && content.length \u003c= 4 && EMOJI.test(content) && (iconish || !/\u003c[a-z]/.test(inner.inner))) {\n lines.add(doc.lineOf(t.end + inner.inner.search(EMOJI)));\n report('30a', 'FAIL', doc.file, t.line, `emoji icon \"${content}\" in \u003c${t.name} class=\"${trunc(cls, 28)}\">`, 'One icon library or custom SVG');\n continue;\n }\n if (!/^(h[1-6]|button|a|label|th|summary|option|legend|dt)$/.test(t.name) || /\u003c(p|div|ul|ol|table|section)\\b/i.test(inner.inner)) continue;\n const text = plainText(inner.inner);\n const at = inner.inner.search(GLYPH);\n if (!text || at \u003c 0) continue;\n const line = doc.lineOf(t.end + at);\n flag(line, `emoji in \u003c${t.name}> \"${trunc(text, 32)}\"`);\n }\n const raw = doc.raw || doc.src;\n const body = doc.kind === 'html' ? doc.masked : raw;\n const lineAt = (text, pos) => text.slice(0, pos).split('\\n').length;\n for (const m of body.matchAll(/>\\s*((?:\\p{Emoji_Presentation}|\\p{Extended_Pictographic}️)[^\u003c>{}\\n]{0,40})/gu)) flag(lineAt(body, m.index + m[0].length - m[1].length), `text opens with an emoji: \"${trunc(m[1].trim(), 32)}\"`);\n for (const m of raw.matchAll(/placeholder\\s*=\\s*[\"']([^\"']*)[\"']/g)) if (GLYPH.test(m[1])) flag(lineAt(raw, m.index), `emoji in a placeholder \"${trunc(m[1], 28)}\"`);\n if (doc.kind !== 'html') for (const m of raw.matchAll(/([\"'`])\\s*((?:(?:\\p{Emoji_Presentation}|\\p{Extended_Pictographic}️?)‍?){1,3})\\s*\\1/gu)) flag(lineAt(raw, m.index), `emoji passed as an icon: ${m[2]}`);\n }\n const libs = new Map([\n ['material', /material-icons|material-symbols/i],\n ['heroicons', /heroicon/i],\n ['lucide', /lucide/i],\n ['phosphor', /phosphor|\\bph-(?:bold|fill|light|duotone|[a-z]+-[a-z]+)/i],\n ['font-awesome', /font-?awesome|\\bfa-(solid|regular|brands|light)\\b/i],\n ['feather', /feather-icons|\\bfeather\\b/i],\n ['tabler', /tabler-icons/i],\n ['bootstrap-icons', /bootstrap-icons|\\bbi-[a-z]/i],\n ]);\n const found = new Set();\n const hay = ctx.docs.map((d) => d.src).join('\\n') + ctx.cssFiles.map((f) => f.raw).join('\\n');\n for (const [name, re] of libs) if (re.test(hay)) found.add(name);\n if (found.size >= 2) {\n report('30b', 'WARN', ctx.docs[0]?.file || ctx.cssFiles[0]?.file, 1, `icon libraries mixed: ${[...found].join(' + ')}`, 'One icon library per page; guard: the second is a brand mark in at most two slots');\n }\n}\n\nfunction check33(ctx) {\n const labeledAttrs = (a) => hasAttr(a, 'aria-hidden') || getAttr(a, 'aria-label') != null\n || getAttr(a, 'aria-labelledby') != null\n || ['img', 'presentation', 'none'].includes((getAttr(a, 'role') || '').toLowerCase());\n for (const doc of ctx.docs) {\n const stack = [];\n for (const t of doc.tags) {\n if (t.close) {\n for (let i = stack.length - 1; i >= 0; i--) {\n if (stack[i].name === t.name) { stack.splice(i); break; }\n }\n continue;\n }\n if (t.name === 'svg' || t.name === 'canvas') {\n const ancestorLabeled = stack.some((s) => s.labeled);\n const titled = t.name === 'svg' && !t.selfClosing && /\u003ctitle\\b[^>]*>\\s*\\S/i.test(innerOf(doc, doc.tags.indexOf(t))?.inner || '');\n if (!labeledAttrs(t.attrs) && !ancestorLabeled && !titled) {\n report(33, 'FAIL', doc.file, t.line, `\u003c${t.name}> without aria-hidden or aria-label`, 'Add aria-hidden or an aria-label');\n }\n }\n if (!t.selfClosing) stack.push({ name: t.name, labeled: labeledAttrs(t.attrs) });\n }\n }\n}\n\nfunction check34(ctx) {\n const clipped = new Set();\n let hidden = null;\n for (const r of ctx.rules) {\n if (!/(^|[\\s,])(html|body)\\b/.test(r.selector)) continue;\n const targets = r.selector.split(',').map((p) => (/^\\s*(html|body)\\s*$/.exec(p) || [])[1]).filter(Boolean);\n for (const d of r.decls) {\n if (d.prop === 'overflow-x' || d.prop === 'overflow') {\n if (/\\bclip\\b/.test(d.value)) for (const t of targets) clipped.add(t);\n else if (/\\bhidden\\b/.test(d.value)) hidden = { file: r.file, line: d.line, sel: r.selector };\n }\n }\n }\n const hiddenUtil = [];\n for (const it of twGroup(ctx).items) {\n if (it.idx \u003c 0 || !/^(html|body)$/.test(it.name)) continue;\n if (it.info.oxClip) clipped.add(it.name);\n if (it.info.oxHidden) hiddenUtil.push(it);\n }\n for (const it of hiddenUtil) report(34, 'FAIL', it.doc.file, it.line, `${it.info.oxHidden} on \u003c${it.name}> (breaks sticky)`, 'overflow-x-clip on both html and body, never hidden');\n const shell = (d) => d.kind === 'html' && !d.styles.length && /\u003cscript\\b[^>]*type\\s*=\\s*[\"']module[\"'][^>]*\\bsrc\\s*=\\s*[\"'][^\"']+\\.(?:tsx?|jsx?|mjs|vue|svelte)[\"']/i.test(d.raw || d.src) && ![...(d.raw || d.src).matchAll(/\u003clink\\b[^>]*>/gi)].some((m) => /rel\\s*=\\s*[\"']stylesheet/i.test(m[0]) && !/href\\s*=\\s*[\"']https?:/i.test(m[0]));\n const pageish = ctx.docs.some((d) => (d.kind === 'html' && !shell(d)) || (d.kind !== 'html' && d.tags.some((t) => !t.close && (t.name === 'html' || t.name === 'body'))));\n const file = ctx.cssFiles[0]?.file || ctx.docs[0]?.file;\n const both = clipped.has('html') && clipped.has('body');\n if (!both && hidden) {\n report(34, 'FAIL', hidden.file, hidden.line, `overflow-x: hidden on ${hidden.sel} (breaks sticky)`, 'Use overflow-x: clip on both html and body, never hidden');\n } else if (!both && file && ctx.docs.length && pageish && !hiddenUtil.length) {\n const where = clipped.size ? `overflow-x: clip on ${[...clipped][0]} only` : 'no overflow-x: clip on html and body';\n report(34, 'FAIL', file, 1, where, 'overflow-x: clip on both html and body');\n }\n for (const r of ctx.rules) {\n for (const d of r.decls) {\n if (/^(width|min-width)$/.test(d.prop) && /(^|[^\\w.-])100vw\\b/.test(d.value)) {\n report(34, 'FAIL', r.file, d.line, `${d.prop}: ${trunc(d.value, 28)} on ${r.selector} (scrollbar overflow)`, 'width: 100% with container padding');\n }\n }\n }\n}\n\nfunction check37(ctx) {\n const families = new Map();\n const generic = GENERIC_FAMILIES;\n for (const r of ctx.rules) {\n if (r.selector === '@font-face') continue;\n for (const d of r.decls) {\n if (d.prop !== 'font-family' && !/^--font-/.test(d.prop)) continue;\n if (/^--font-/.test(d.prop) && !isTokenRule(r)) continue;\n const fam = firstFamily(resolveVars(d.value, ctx.tokens));\n if (!fam || generic.has(fam) || fam.startsWith('var(')) continue;\n if (![...families.keys()].some((k) => famKey(k) === famKey(fam))) families.set(fam, { file: r.file, line: d.line });\n }\n }\n if (families.size > 3) {\n const list = [...families.keys()].join(', ');\n const first = [...families.values()][3];\n report(37, 'FAIL', first.file, first.line, `${families.size} font families: ${trunc(list, 56)}`, 'Drop the extra family; three is the ceiling');\n }\n}\n\nfunction check38a(ctx) {\n for (const r of ctx.rules) {\n if (!HEADING_SEL.test(r.selector)) continue;\n for (const d of r.decls) {\n if (d.prop === 'font-style' && /italic|oblique/.test(d.value)) {\n report('38ii', 'FAIL', r.file, d.line, `italic display type on ${r.selector}`, 'Roman headers; emphasis via weight or accent');\n }\n }\n }\n\n const normSel = ctx.rules.filter((r) => /(^|[\\s,>+~])(em|i)\\b/.test(r.selector)\n && r.decls.some((d) => d.prop === 'font-style' && /normal/.test(d.value))).flatMap((r) => r.selector.split(',').map((x) => x.trim()));\n const normalised = (level, classes) => normSel.some((sel) => /^(em|i)$/.test(sel) || new RegExp(`(^|[\\\\s,>+~])h${level}\\\\b`).test(sel) || classes.some((c) => sel.includes('.' + c)));\n for (const doc of ctx.docs) {\n const re = /\u003ch([1-6])\\b([^>]*)>([\\s\\S]*?)\u003c\\/h\\1>/gi;\n let m;\n while ((m = re.exec(doc.src))) {\n const classes = ((/class\\s*=\\s*[\"']([^\"']*)[\"']/i.exec(m[2]) || [])[1] || '').split(/\\s+/).filter(Boolean);\n if (/\u003c(em|i)\\b/i.test(m[3]) && !normalised(m[1], classes)) {\n report('38i', 'FAIL', doc.file, doc.lineOf(m.index), `\u003cem>/\u003ci> inside \u003ch${m[1]}>`, 'Roman headers; emphasis via weight or accent');\n }\n }\n }\n}\n\nfunction check39(ctx) {\n const hasInputs = ctx.docs.some((doc) => /\u003c(input|textarea|select)\\b/i.test(doc.src))\n || ctx.rules.some((r) => /(^|[\\s,>+~])(input|textarea|select)\\b/.test(r.selector));\n if (!hasInputs) return;\n const inputSel = /(^|[\\s,>+~])(input|textarea|select)\\b|__input|__field|\\.(input|field)\\b/;\n const widthOf = (v) => { const m = /(\\d+(?:\\.\\d+)?)px/.exec(v); return m ? parseFloat(m[1]) : null; };\n let baseW = null, baseWhere = null;\n for (const r of ctx.rules) {\n if (!inputSel.test(r.selector) || /:hover|:focus|:active|:disabled|\\[|:checked/.test(r.selector)) {\n continue;\n }\n for (const d of r.decls) {\n if (d.prop === 'border' || d.prop === 'border-width') {\n const w = widthOf(d.value);\n if (w != null && baseW == null) { baseW = w; baseWhere = { file: r.file, line: d.line }; }\n }\n }\n }\n for (const r of ctx.rules) {\n if (!inputSel.test(r.selector)) continue;\n const stateful = /:hover|:focus|\\.is-error|\\[aria-invalid/.test(r.selector);\n if (!stateful) continue;\n for (const d of r.decls) {\n if (d.prop === 'border' || d.prop === 'border-width') {\n const w = widthOf(d.value);\n if (w != null && baseW != null && w !== baseW) {\n report('39a', 'FAIL', r.file, d.line, `border-width ${baseW}px -> ${w}px on ${r.selector} (layout shift)`, 'Keep border-width constant; move state to outline');\n }\n }\n }\n if (/:focus/.test(r.selector)) {\n const setsBorder = r.decls.some((d) => /^border(-color)?$/.test(d.prop));\n const setsOutline = r.decls.some((d) => /^outline/.test(d.prop));\n if (setsBorder && !setsOutline) {\n report('39a', 'FAIL', r.file, r.line, `focus state via border on ${r.selector}`, 'Focus ring via outline, not border');\n }\n }\n }\n for (const r of ctx.rules) {\n if (!/:disabled|\\[disabled\\]/.test(r.selector)) continue;\n const hasOpacity = r.decls.some((d) => d.prop === 'opacity');\n const hasCursor = r.decls.some((d) => d.prop === 'cursor' && /not-allowed/.test(d.value));\n if (hasOpacity && !hasCursor) {\n report('39a', 'FAIL', r.file, r.line, `disabled via opacity alone on ${r.selector}`, 'Three disabled channels: fill, ink and cursor, plus the disabled attribute');\n }\n }\n}\n\nfunction check40_41(ctx) {\n const bySel = mergedDeclsBySelector(ctx);\n const resolveColor = (value, local) => {\n const resolved = resolveVars(value, ctx.tokens, local).trim();\n return parseColor(resolved);\n };\n for (const r of bySel) {\n const local = {};\n for (const d of r.decls) if (d.prop.startsWith('--')) local[d.prop] = d.value;\n const colorD = r.decls.find((d) => d.prop === 'color');\n const bgD = r.decls.find((d) => d.prop === 'background-color')\n || r.decls.find((d) => d.prop === 'background' && !/gradient|url\\(/i.test(d.value));\n if (colorD && bgD && !/::selection|::placeholder|::-moz/.test(r.selector)) {\n const fg = resolveColor(colorD.value, local);\n const bg = resolveColor(bgD.value, local);\n if (fg && bg && fg.alpha >= 1 && bg.alpha >= 1) {\n const sizeD = r.decls.find((d) => d.prop === 'font-size');\n const weightD = r.decls.find((d) => d.prop === 'font-weight');\n const sizePx = sizeD ? pxOf(resolveVars(sizeD.value, ctx.tokens, local)) : null;\n const weight = weightD ? parseInt(weightD.value, 10) || 400 : 400;\n const large = (sizePx != null && sizePx >= 24) || (sizePx != null && sizePx >= 18 && weight >= 700);\n const threshold = large ? 3.0 : 4.5;\n const ratio = contrastRatio(fg, bg);\n if (ratio \u003c threshold - 0.05) {\n report(40, 'WARN', r.file, colorD.line, `${ratio.toFixed(2)}:1 \u003c ${threshold}:1 on ${r.selector}`, 'Raise the contrast to threshold');\n }\n const okFg = srgbToOklch(fg), okBg = srgbToOklch(bg);\n const btnish = /btn|button|cta|pill|chip/i.test(r.selector);\n if (btnish && Math.abs(okFg.L - okBg.L) \u003c 0.05 && Math.abs(okFg.C - okBg.C) \u003c 0.05) {\n report(41, 'WARN', r.file, colorD.line, `button text ~ button fill on ${r.selector} (dL ${Math.abs(okFg.L - okBg.L).toFixed(3)})`, 'Use accent-ink or paper for the label');\n }\n }\n }\n const faceProp = Object.keys(local).find((k) => /(face|fill|bg)$/.test(k));\n const inkProp = Object.keys(local).find((k) => /(ink|text)$/.test(k));\n if (faceProp && inkProp) {\n const face = resolveColor(local[faceProp], local);\n const ink = resolveColor(local[inkProp], local);\n if (face && ink && face.alpha >= 1 && ink.alpha >= 1) {\n const a = srgbToOklch(face), b = srgbToOklch(ink);\n if (Math.abs(a.L - b.L) \u003c 0.05 && Math.abs(a.C - b.C) \u003c 0.05) {\n report(41, 'WARN', r.file, r.line, `${inkProp} ~ ${faceProp} on ${r.selector}`, 'Use accent-ink or paper for the label');\n }\n }\n }\n }\n if (ctx.tokens['--color-accent'] && !ctx.tokens['--color-accent-ink']) {\n const used = ctx.rules.some((r) => !isTokenRule(r) && r.decls.some((d) =>\n /^background(-color)?$/.test(d.prop) && /var\\(\\s*--color-accent\\s*[),]/.test(d.value)));\n if (used) {\n const file = ctx.cssFiles[0]?.file || ctx.docs[0]?.file;\n report(41, 'WARN', file, 1, 'accent fills a surface but --color-accent-ink is undefined', 'Define and apply --color-accent-ink');\n }\n } else if (ctx.tokens['--color-accent'] && ctx.tokens['--color-accent-ink']) {\n const a = parseColor(resolveVars(ctx.tokens['--color-accent'], ctx.tokens));\n const i = parseColor(resolveVars(ctx.tokens['--color-accent-ink'], ctx.tokens));\n if (a && i && contrastRatio(a, i) \u003c 4.45) {\n const file = ctx.cssFiles[0]?.file || ctx.docs[0]?.file;\n report(41, 'WARN', file, 1, `accent-ink vs accent only ${contrastRatio(a, i).toFixed(2)}:1`, 'Accent-ink needs 4.5:1 against accent');\n }\n }\n const sectionish = /section|panel|hero|footer|band|block|col\\b|__col|card|cta|dark|invert/i;\n for (const r of bySel) {\n if (!sectionish.test(r.selector) || /:hover|:focus|:active|::|@font-face/.test(r.selector)) continue;\n const local = {};\n for (const d of r.decls) if (d.prop.startsWith('--')) local[d.prop] = d.value;\n const bgD = r.decls.find((d) => d.prop === 'background-color')\n || r.decls.find((d) => d.prop === 'background' && !/gradient|url\\(/i.test(d.value));\n if (!bgD) continue;\n const bg = resolveColor(bgD.value, local);\n if (!bg || bg.alpha \u003c 1) continue;\n const okl = srgbToOklch(bg);\n if (okl.L \u003c 0.5) {\n const hasColor = r.decls.some((d) => d.prop === 'color');\n if (!hasColor) {\n report(41, 'WARN', r.file, bgD.line, `dark surface (L ${(okl.L * 100).toFixed(0)}%) on ${r.selector} sets no text colour`, 'Swap text to paper in the same rule');\n }\n }\n }\n}\n\nfunction check42(ctx) {\n for (const doc of ctx.docs) {\n const navIdx = doc.tags.findIndex((t) => !t.close && (t.name === 'nav' || (t.name === 'header' && /banner|nav/i.test(t.attrs))));\n if (navIdx === -1) continue;\n const inner = innerOf(doc, navIdx);\n if (!inner) continue;\n const links = (inner.inner.match(/\u003ca\\b/gi) || []).length;\n const navTag = doc.tags[navIdx];\n const navCls = (getAttr(navTag.attrs, 'class') || '').split(/\\s+/)[0];\n const hairline = ctx.rules.some((r) => (navCls && r.selector.includes(navCls) || /(^|[\\s,])(nav|header)\\b/.test(r.selector))\n && r.decls.some((d) => /^border(-bottom)?$/.test(d.prop) && /\\b1px\\b/.test(d.value)));\n const hasCta = /btn|cta|button/i.test(inner.inner);\n if (links >= 4 && links \u003c= 6 && hairline && hasCta) {\n report(42, 'WARN', doc.file, navTag.line, `default nav shape: ${links} links + CTA + 1px hairline`, 'Give the nav a shape of its own (layout.md, navs)');\n }\n }\n}\n\nfunction check42Footer(ctx) {\n for (const doc of ctx.docs) {\n const idx = doc.tags.findIndex((t) => !t.close && t.name === 'footer');\n if (idx === -1) continue;\n const inner = innerOf(doc, idx);\n if (!inner) continue;\n const uls = (inner.inner.match(/\u003cul\\b/gi) || []).length;\n\n const fourEqualCol = ctx.rules.some((r) => /footer/i.test(r.selector) && r.decls.some((d) =>\n d.prop === 'grid-template-columns'\n && (/repeat\\(\\s*4\\s*,/.test(d.value)\n || /^(1fr|minmax\\([^)]*\\))(\\s+(1fr|minmax\\([^)]*\\))){3,}$/.test(d.value.trim()))));\n\n let linkGroups = 0;\n const colRe = /\u003c(ul|div|p|nav)\\b[^>]*class\\s*=\\s*\"[^\"]*(col|column|links|group)[^\"]*\"[^>]*>([\\s\\S]*?)\u003c\\/\\1>/gi;\n let cm;\n while ((cm = colRe.exec(inner.inner))) {\n if ((cm[3].match(/\u003ca\\b/gi) || []).length >= 3) linkGroups++;\n }\n for (const um of inner.inner.matchAll(/\u003cul\\b[^>]*>([\\s\\S]*?)\u003c\\/ul>/gi)) {\n if ((um[1].match(/\u003ca\\b/gi) || []).length >= 3) linkGroups++;\n }\n if (uls >= 4 || (fourEqualCol && linkGroups >= 3)) {\n report(42, 'WARN', doc.file, doc.tags[idx].line, `default footer shape: ${uls >= 4 ? uls + ' link columns' : '4 equal columns of links'}`, 'Decide the footer and name it in the stamp, or build another shape');\n }\n }\n}\n\nfunction check46(ctx) {\n const shapes = [\n /\\b\\d+(\\.\\d+)?\\s*[x×]\\s*faster\\b/i,\n /trusted by [\\d,.]+\\s*\\+?/i,\n /\\b99\\.9+\\s*%/,\n /\\b[\\d,]{4,}\\s*\\+\\s*(teams|users|customers|developers|companies|creators)\\b/i,\n /\\b\\d+(\\.\\d+)?\\s*%\\s*(faster|uptime|conversion|accuracy|retention|growth|fewer|less|more)\\b/i,\n /\\+\\s*\\d+(\\.\\d+)?\\s*%\\s*(conversion|growth|revenue|retention)\\b/i,\n /\\bsaves?\\s+\\d+\\s+hours?\\b/i,\n ];\n for (const doc of ctx.docs) {\n const prose = maskedProse(doc);\n const lines = new Set();\n for (const re of shapes) {\n const m = re.exec(prose);\n if (!m) continue;\n const line = doc.lineOf(m.index);\n if (lines.has(line)) continue;\n lines.add(line);\n report('46a', 'WARN', doc.file, line, `metric \"${m[0].trim()}\": verify the user supplied this number`, 'Confirm the metric or drop the slot');\n }\n }\n}\n\nconst CHROME_47 = [\n ['47a', /(browser|window|mac|osx)[-_]?(bar|chrome|dots|controls)|traffic[-_]?light|title[-_]?bar|window[-_]?(dots|buttons)|url[-_]?(bar|pill)/i],\n ['47b', /phone[-_]?(frame|mock(up)?)|device[-_]?frame|\\bnotch\\b|terminal[-_]?(chrome|frame|bar|dots|header)|(ide|editor)[-_]?(chrome|frame)|(browser|window)[-_]?frame/i],\n];\nfunction check47(ctx) {\n for (const [gate, re] of CHROME_47) {\n for (const doc of ctx.docs) {\n for (const t of doc.tags) {\n if (t.close) continue;\n const cls = getAttr(t.attrs, 'class');\n const m = cls ? re.exec(cls) : null;\n if (m) { report(gate, 'FAIL', doc.file, t.line, `re-drawn chrome class \"${m[0]}\" (class=\"${trunc(cls, 32)}\")`, 'Use a real screenshot in a figure'); break; }\n }\n }\n for (const r of ctx.rules) {\n const m = re.exec(r.selector);\n if (m) {\n report(gate, 'FAIL', r.file, r.line, `re-drawn chrome selector ${r.selector}`, 'Use a real screenshot in a figure');\n break;\n }\n }\n }\n}\n\nfunction check48(ctx) {\n const colorLit = /#[0-9a-fA-F]{3,8}\\b|(?\u003c![\\w-])rgba?\\(|(?\u003c![\\w-])hsla?\\(|(?\u003c![\\w-])oklch\\(/;\n for (const [prop, val] of Object.entries(ctx.tokens)) {\n if (/^--(font|color)-/.test(prop) && /FILL ME/i.test(val)) {\n const tr = ctx.rules.find((r) => isTokenRule(r) && r.decls.some((d) => d.prop === prop));\n report(48, 'FAIL', tr ? tr.file : ctx.cssFiles[0]?.file || ctx.docs[0]?.file, tr ? tr.decls.find((d) => d.prop === prop)?.line : 1, `${prop} is still the scaffold placeholder \"FILL ME\"`, /^--font-/.test(prop) ? 'Fill the face from the theme tokens (scaffold) or fonts.md on a custom run before building' : 'Fill the colour from the theme tokens (scaffold) or direction.md on a custom run before building');\n }\n }\n for (const r of ctx.rules) {\n if (isTokenRule(r)) continue;\n for (const d of r.decls) {\n if (d.prop === 'font-family') {\n const v = d.value;\n if (/FILL ME/i.test(resolveVars(v, ctx.tokens))) {\n report(48, 'FAIL', r.file, d.line, `font-family on ${r.selector} resolves to the placeholder \"FILL ME\"`, 'Fill the face from the theme tokens (scaffold) or fonts.md on a custom run before building');\n continue;\n }\n if (!v.includes('var(')) {\n const fam = firstFamily(v);\n if (!GENERIC_FAMILIES.has(fam)) {\n report(48, 'FAIL', r.file, d.line, `font-family literal \"${trunc(v, 36)}\" on ${r.selector}`, 'Lift the value into the token block');\n }\n }\n continue;\n }\n if (!colorLit.test(d.value)) continue;\n const re = /#[0-9a-fA-F]{3,8}\\b|oklch\\([^)]*\\)|rgba?\\([^)]*\\)|hsla?\\([^)]*\\)/g;\n let m;\n while ((m = re.exec(d.value))) {\n const c = parseColor(m[0]);\n if (!c) continue;\n if (c.alpha === 0) continue;\n const isOklch = m[0].toLowerCase().startsWith('oklch');\n const ev = `${isOklch ? 'oklch' : 'colour'} literal ${trunc(m[0], 30)} outside tokens (${d.prop} on ${trunc(r.selector, 30)})`;\n report(48, 'FAIL', r.file, d.line, ev, 'Lift the value into the token block');\n }\n }\n }\n for (const doc of ctx.docs) {\n const re = /style\\s*=\\s*\"([^\"]*)\"/gi;\n let m;\n while ((m = re.exec(doc.src))) {\n const lit = /#[0-9a-fA-F]{3,8}\\b|oklch\\([^)]*\\)|rgba?\\([^)]*\\)|hsla?\\([^)]*\\)/.exec(m[1]);\n if (lit && parseColor(lit[0])) {\n const isOklch = lit[0].toLowerCase().startsWith('oklch');\n const ev = `inline-style colour literal ${trunc(lit[0], 30)}`;\n report(48, 'FAIL', doc.file, doc.lineOf(m.index), ev, 'Lift the value into the token block');\n }\n }\n }\n}\n\nfunction check49(ctx) {\n const nowrapSelectors = ctx.rules.filter((r) => r.decls.some((d) => d.prop === 'white-space' && /nowrap/.test(d.value)));\n const covered = (cls, tag) => nowrapSelectors.some((r) =>\n (cls && cls.split(/\\s+/).some((c) => c && r.selector.includes('.' + c))) || new RegExp(`(^|[\\\\s,>+~])${tag}\\\\b`).test(r.selector));\n for (const doc of ctx.docs) {\n for (let i = 0; i \u003c doc.tags.length; i++) {\n const t = doc.tags[i];\n if (t.close || t.selfClosing) continue;\n const cls = getAttr(t.attrs, 'class') || '';\n const clickable = t.name === 'button' || (t.name === 'a' && /btn|cta|button|nav/i.test(cls))\n || (/(^|\\s)(btn|button)(\\s|$|__|--)/i.test(cls) && !/^(section|header|footer|main|article|aside|nav|form|ul|ol)$/.test(t.name));\n if (!clickable) continue;\n const inner = innerOf(doc, i);\n if (!inner) continue;\n const label = inner.inner.replace(/\u003c[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim();\n if (label.length > 24 && !covered(cls, t.name)) {\n report(49, 'WARN', doc.file, t.line, `long clickable label \"${trunc(label, 36)}\" without nowrap`, 'Shorten the label or set nowrap');\n }\n }\n }\n}\n\nfunction check50(ctx) {\n for (const r of ctx.rules) {\n for (const d of r.decls) {\n if (d.prop !== 'grid-template-columns') continue;\n let flat = resolveVars(d.value, ctx.tokens);\n for (let k = 0; k \u003c 4 && /\\((?:[^()]*)\\)/.test(flat); k++) flat = flat.replace(/(calc|min|max|clamp|var|fit-content)\\(([^()]*)\\)/g, 'X');\n flat = flat.replace(/minmax\\([^()]*\\)/g, 'MM');\n const bare = /(?\u003c![\\w.-])\\d*\\.?\\d+fr\\b/.exec(flat);\n if (!bare) continue;\n const classes = (r.selector.match(/\\.[a-zA-Z][\\w-]*/g) || []).map((c) => c.slice(1));\n let hit = null;\n for (const doc of ctx.docs) {\n for (let i = 0; i \u003c doc.tags.length; i++) {\n const t = doc.tags[i];\n if (t.close || t.selfClosing) continue;\n const cls = getAttr(t.attrs, 'class') || '';\n if (!classes.some((c) => cls.split(/\\s+/).includes(c))) continue;\n const inner = innerOf(doc, i);\n if (inner && /\u003c(img|picture|video)\\b/i.test(inner.inner)) { hit = { doc, t }; break; }\n }\n if (hit) break;\n }\n if (hit) {\n report(50, 'FAIL', r.file, d.line, `bare ${bare[0]} track holds images (${r.selector})`, 'Use minmax(0, 1fr) for image tracks');\n }\n }\n }\n}\n\nfunction check50Wrap(ctx) {\n const wrapCovered = (cls, tag) => ctx.rules.some((r) => {\n const has = r.decls.some((d) =>\n (d.prop === 'overflow-wrap' && /anywhere|break-word/.test(d.value))\n || (d.prop === 'word-break' && /break-word|break-all/.test(d.value))\n || (d.prop === 'hyphens' && /auto/.test(d.value)));\n if (!has) return false;\n if (/^\\*|:where\\(|html|body/.test(r.selector)) return true;\n return (cls && cls.split(/\\s+/).some((c) => c && r.selector.includes('.' + c)))\n || new RegExp(`(^|[\\\\s,>+~])${tag}\\\\b`).test(r.selector);\n });\n for (const doc of ctx.docs) {\n for (let i = 0; i \u003c doc.tags.length; i++) {\n const t = doc.tags[i];\n if (t.close || t.selfClosing) continue;\n const cls = getAttr(t.attrs, 'class') || '';\n const displayish = /^h[12]$/.test(t.name) || /hero__(title|display)|section__title|__display/.test(cls);\n if (!displayish) continue;\n const inner = innerOf(doc, i);\n if (!inner) continue;\n const textContent = inner.inner.replace(/\u003c[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim();\n let risky = null;\n for (const word of textContent.split(' ')) {\n for (const seg of word.split('-')) {\n if (seg.replace(/[^\\w]/g, '').length >= 16) { risky = word; break; }\n }\n if (risky) break;\n }\n if (risky && !wrapCovered(cls, t.name)) {\n report(50, 'FAIL', doc.file, t.line, `display header word \"${trunc(risky, 30)}\" with no overflow-wrap`, 'Add overflow-wrap: break-word and min-width: 0');\n }\n }\n }\n}\n\nfunction check53(ctx) {\n const radios = ctx.rules.filter((r) => /input\\[type=[\"']?radio[\"']?\\]|\\.(radio|tab-input|tab-radio)\\b/i.test(r.selector));\n for (const r of radios) {\n const abs = r.decls.some((d) => d.prop === 'position' && /absolute|fixed/.test(d.value));\n const topZero = r.decls.some((d) => d.prop === 'top' && /^0(px|rem)?$/.test(d.value.trim()));\n if (abs && topZero) {\n const guarded = ctx.docs.some((doc) => /preventScroll/.test(doc.scripts));\n if (!guarded) {\n report(53, 'FAIL', r.file, r.line, `radio tabs at position:absolute top:0 (${r.selector}), no JS guard`, 'Keep radios in flow or add a JS guard');\n }\n }\n }\n}\n\nconst EYEBROW_CONTAINER = /^(div|header|footer|section|article|aside|hgroup|li|main|figure|a|body|blockquote)$/;\n\nconst EYEBROW_SKIP_TAGS = /^(figcaption|label|legend|caption|th|dt|summary|option|code|kbd|button|select|nav|time)$/;\nconst EYEBROW_ANCESTOR_SKIP = /^(form|fieldset|table|thead|tbody|nav|menu|dl|select|figcaption|label|button)$/;\n\nconst EYEBROW_BLOCKY = /\u003c(p|ul|ol|hr|h[1-6]|img|table|figure|section|article|blockquote|form|dl)\\b/i;\nconst EYEBROW_CLASS = /\\b(eyebrow|kicker|overline|superhead|suphead|rubric|strap(line)?|pre-?(title|head(ing)?)|section-?label|num-?label|chapter|ordinal|topic|category|dept)\\b|__(num|label|tag|kicker|chip|badge|meta|index|caps|micro)\\b|\\b(tag|chip|badge|meta|caps|micro|label|index)\\b/i;\nconst EYEBROW_ANNOUNCE = /^(features?|benefits?|introducing|new|how it works?|the (process|tour)|our (process|story|work|team|approach|values?)|why (us|choose|we)|pricing|plans?|faq|questions?|testimonials?|reviews?|trusted by|as seen in|what we do|about( us)?|services?|solutions?|use cases?|get started|contact|team|work|projects?|gallery|blog|news|resources?|examples?|overview)$/i;\n\nfunction capsRatio(s) {\n const letters = s.replace(/[^A-Za-z]/g, '');\n if (!letters) return 0;\n return (letters.match(/[A-Z]/g) || []).length / letters.length;\n}\n\nfunction eyebrowHeadingBearing(ctx, B, aPx) {\n if (/^h[1-6]$/.test(B.name)) return true;\n if (/\u003ch[1-6][\\s>]/i.test(B.html)) return true;\n\n const cls = getAttr(B.open, 'class') || '';\n if (!/__(title|display|head(line)?|h\\d)\\b|hero__/i.test(cls)) return false;\n const bPx = fontPxOf(ctx, styleFor(ctx, cls, B.name));\n return bPx != null && aPx != null && bPx >= 1.5 * aPx;\n}\n\nfunction eyebrowHit(ctx, A, B, inOrderedList) {\n if (EYEBROW_SKIP_TAGS.test(A.name)) return null;\n if (/(^|\\s)for\\s*=/i.test(A.open)) return null;\n if (/role\\s*=\\s*[\"']status|aria-live|aria-current|aria-hidden\\s*=\\s*[\"']true/i.test(A.open)) return null;\n const inner = A.html.slice(A.open.length);\n if (EYEBROW_BLOCKY.test(inner) || /\u003cbr\\b/i.test(inner)) return null;\n if (/\u003ctime\\b[^>]*\\sdatetime\\s*=/i.test(A.html)) return null;\n if (/\u003cimg\\b[^>]*\\salt\\s*=\\s*[\"'][^\"']+/i.test(A.html)) return null;\n\n const ownHref = A.name === 'a' ? getAttr(A.open, 'href') : null;\n if (ownHref && !/^#?$/.test(ownHref.trim())) return null;\n const innerHref = /\u003ca\\b[^>]*\\shref\\s*=\\s*[\"']([^\"']*)[\"']/i.exec(inner);\n if (innerHref && !/^#?$/.test(innerHref[1].trim())) return null;\n\n const text = plainText(A.html);\n if (!text) return null;\n const words = text.split(/[\\s·/|\\u2014\\u2013]+/).filter(Boolean);\n if (words.length > 7 || text.length > 56) return null;\n if (/[\\w/-]+\\.[a-z]{1,4}$/i.test(text)) return null;\n\n if (/^[\\d.,]+\\s*(?:[%°+]|[a-z]{1,3})$/i.test(text)) return null;\n const stripped = text.replace(/^\\s*(?:no\\.?\\s*)?\\d{1,3}(?:\\s*[·./\\u2014\\u2013-]\\s*|\\s+|$)/i, '').trim();\n if ((stripped.match(/\\d/g) || []).length >= 2) return null;\n\n if (inOrderedList && stripped && !EYEBROW_ANNOUNCE.test(stripped)) return null;\n\n const cls = getAttr(A.open, 'class') || '';\n const aDecls = styleFor(ctx, cls, A.name);\n\n const pos = declValue(ctx, aDecls, 'position');\n if (pos && /absolute|fixed/.test(pos)) return null;\n const aPx = fontPxOf(ctx, aDecls);\n if (!eyebrowHeadingBearing(ctx, B, aPx)) return null;\n\n if (EYEBROW_CLASS.test(cls)) return { text };\n let signals = 0;\n const tt = declValue(ctx, aDecls, 'text-transform');\n if ((tt && /uppercase/.test(tt)) || (capsRatio(text) >= 0.6 && /[A-Z]/.test(text))) signals++;\n const tr = trackingEmOf(ctx, aDecls, aPx || 16);\n if (tr != null && tr >= 0.04) signals++;\n if (aPx != null && aPx \u003c= 15) signals++;\n const ff = declValue(ctx, aDecls, 'font-family');\n if (ff && /mono|--font-label/i.test(ff)) signals++;\n const fv = declValue(ctx, aDecls, 'font-variant-caps');\n if (fv && /small-caps/.test(fv)) signals++;\n if (signals >= 2) return { text };\n if (EYEBROW_ANNOUNCE.test(stripped)) return { text };\n return null;\n}\n\nfunction check54(ctx) {\n for (const doc of ctx.docs) {\n const anc = ancestorMap(doc);\n for (let i = 0; i \u003c doc.tags.length; i++) {\n const t = doc.tags[i];\n if (t.close || t.selfClosing || !EYEBROW_CONTAINER.test(t.name)) continue;\n const ancestors = anc.get(i) || [];\n if (ancestors.some((n) => EYEBROW_ANCESTOR_SKIP.test(n))) continue;\n const inOl = ancestors.includes('ol');\n const kids = directChildren(doc, i);\n for (let k = 0; k \u003c kids.length - 1; k++) {\n const A = kids[k];\n let B = null;\n for (let j = k + 1; j \u003c Math.min(kids.length, k + 3); j++) {\n const c = kids[j];\n if (c.name === 'hr' || /aria-hidden\\s*=\\s*[\"']true/i.test(c.open)) continue;\n B = c; break;\n }\n if (!B) continue;\n const hit = eyebrowHit(ctx, A, B, inOl);\n if (hit) {\n report(54, 'FAIL', doc.file, doc.tags[A.idx]?.line || t.line,\n `eyebrow \"${trunc(hit.text, 32)}\" before \u003c${B.name}>`,\n 'Cut it; the heading already says this (craft.md § 7 has twelve other openings)');\n break;\n }\n }\n }\n }\n check54Unheaded(ctx);\n}\n\nfunction check54Unheaded(ctx) {\n for (const doc of ctx.docs) {\n for (let i = 0; i \u003c doc.tags.length; i++) {\n const t = doc.tags[i];\n if (t.close || t.selfClosing || !/^(section|article)$/.test(t.name)) continue;\n const labelledBy = getAttr(t.attrs, 'aria-labelledby');\n if (!labelledBy) continue;\n const body = innerOf(doc, i);\n if (!body || /\u003ch[1-6][\\s>]/i.test(body.inner)) continue;\n const idRe = new RegExp(`\u003c([a-z][\\\\w-]*)\\\\b[^>]*\\\\sid\\\\s*=\\\\s*[\"']${labelledBy.split(/\\s+/)[0]}[\"'][^>]*>`, 'i');\n const m = idRe.exec(body.inner);\n if (!m || /^h[1-6]$/i.test(m[1])) continue;\n report(54, 'WARN', doc.file, doc.lineOf(body.endPos - body.inner.length + m.index),\n `section labelled by \u003c${m[1].toLowerCase()}> with no heading element`,\n 'Promote it to \u003ch2>; a label doing heading duty should be a heading');\n }\n }\n}\n\nfunction check55(ctx) {\n const bySel = mergedDeclsBySelector(ctx);\n for (const r of bySel) {\n const upper = r.decls.find((d) => d.prop === 'text-transform' && /uppercase/.test(d.value));\n if (!upper) continue;\n const displayish = HEADING_SEL.test(r.selector);\n const lhD = r.decls.find((d) => d.prop === 'line-height');\n if (!lhD) continue;\n const lh = parseFloat(resolveVars(lhD.value, ctx.tokens));\n if (Number.isNaN(lh) || lh >= 1.02) continue;\n const bigType = r.decls.some((d) => d.prop === 'font-size' && (pxOf(resolveVars(d.value, ctx.tokens)) ?? 0) >= 28);\n if (!displayish && !bigType) continue;\n const nowrap = r.decls.some((d) => d.prop === 'white-space' && /nowrap/.test(d.value));\n if (!nowrap && lh >= 1.0) continue;\n report(nowrap ? '55b' : '55a', 'FAIL', r.file, lhD.line, `uppercase + line-height ${lh} on ${r.selector} (cap collision)`, nowrap ? 'A single-line caps lockup holds 1.02 or more' : 'Line-height 1.0 minimum for caps heads that can wrap');\n }\n}\n\nfunction check56(ctx) {\n const bySel = mergedDeclsBySelector(ctx);\n const sticky = [];\n for (const r of bySel) {\n const isSticky = r.decls.some((d) => d.prop === 'position' && /sticky/.test(d.value));\n const topZero = r.decls.some((d) => d.prop === 'top' && /^(0|0px|0rem|-1px)$/.test(resolveVars(d.value, ctx.tokens).trim()));\n if (isSticky && topZero) sticky.push(r);\n }\n if (sticky.length >= 2) {\n const sels = sticky.map((r) => r.selector).join(' + ');\n report(56, 'FAIL', sticky[1].file, sticky[1].line, `two sticky top:0 elements: ${trunc(sels, 52)}`, 'Offset the second sticky by banner height');\n }\n}\n\nfunction checkCopyTell(ctx) {\n for (const doc of ctx.docs) {\n const prose = maskedProse(doc);\n const m = /\\w\\s--\\s\\w|[\\u2014\\u2013]|—|–|—|–/.exec(prose);\n if (m) report(19, 'FAIL', doc.file, doc.lineOf(m.index), `dash \"${m[0]}\" in copy`, 'A comma, a colon, a full stop or parentheses; never a dash');\n const seenBuzz = new Set();\n for (const buzz of prose.matchAll(new RegExp(/\\b(seamless(ly)?|robust|empower(s|ing)?|supercharge[sd]?|best[- ]in[- ]class|industry[- ]leading|mission[- ]critical|future[- ]proof(ed)?|unleash(es|ed|ing)?|elevate[sd]?|effortless(ly)?|unlock(s|ing)?|next[- ]generation|world[- ]class|cutting[- ]edge|streamline[sd]?|revolutioni[sz]e[sd]?|game[- ]chang(er|ing)|enterprise[- ]grade|quietly (trusted|in use)|built for the modern|in today's)\\b/.source, 'gi'))) {\n const word = buzz[0].toLowerCase();\n if (seenBuzz.has(word) || seenBuzz.size >= 8) continue;\n seenBuzz.add(word);\n report(19, 'FAIL', doc.file, doc.lineOf(buzz.index), `buzzword \"${buzz[0]}\"`, 'Say what the product does, in its own nouns');\n }\n const cadence = prose.match(/(^|[.!?]\\s+)(not|no)\\s+[a-z][^.!?\\n]{0,40}[.]\\s+(just|only|a|an)?\\s*[a-z][^.!?\\n]{0,40}[.]/gi) || [];\n if (cadence.length >= 3) report(19, 'FAIL', doc.file, doc.lineOf(prose.indexOf(cadence[0].trim()) || 0), `rebuttal cadence repeated ${cadence.length} times (\"${trunc(cadence[0].trim(), 40)}\")`, 'Once is voice; three times is a tic');\n }\n}\n\nfunction monoIsMaterial(ctx) {\n const mono = (v) => v && /mono|code|courier|silkscreen|jetbrains|plex mono|geist mono|commit|fira code|space mono|dm mono|roboto mono|source code|ibm plex mono|press start|vt323|share tech/i.test(firstFamily(resolveVars(v, ctx.tokens)));\n return mono(ctx.tokens['--font-display']) || mono(ctx.tokens['--font-body']) || mono(ctx.tokens['--font-outlier']);\n}\n\nconst HIDE_OK = /tooltip|popover|menu|dropdown|modal|dialog|toast|overlay|backdrop|sheet|drawer|panel|:hover|:focus|:checked|:target|\\[open|\\[aria-(?:expanded|selected|current|pressed)|::before|::after|\\.is-|\\[hidden|\\[aria-hidden|sr-only|visually-hidden|skip|placeholder|::marker|\\[data-slot/i;\nfunction startingStyleText(css) {\n let out = '';\n for (const m of css.matchAll(/@starting-style\\s*\\{/gi)) {\n let depth = 1, k = m.index + m[0].length;\n while (k \u003c css.length && depth > 0) { if (css[k] === '{') depth++; else if (css[k] === '}') depth--; k++; }\n out += css.slice(m.index, k) + ' ';\n const open = css.lastIndexOf('{', m.index - 1), close = css.lastIndexOf('}', m.index - 1);\n if (open > close) out += css.slice(Math.max(close + 1, 0), open) + ' ';\n }\n return out;\n}\nfunction check62(ctx) {\n const starting = startingStyleText(ctx.allCss);\n const jsScoped = (sel) => /(^|[\\s,])(html|body)?\\.js\\b|\\[data-js|\\.no-js|html\\.has-js/i.test(sel);\n const animatedNames = new Map();\n for (const r of ctx.rules) if (r.keyframes) {\n const arr = animatedNames.get(r.keyframes) || [];\n arr.push(r); animatedNames.set(r.keyframes, arr);\n }\n const animatesVisible = (name) => (animatedNames.get(name) || []).some((r) => /to|100%/.test(r.selector) && r.decls.some((d) => d.prop === 'opacity' && parseFloat(d.value) === 1));\n const seen = new Set();\n for (const r of ctx.rules) {\n if (r.keyframes || (r.media && /\\bprint\\b/i.test(r.media)) || r.selector === '@font-face') continue;\n const hidden = r.decls.find((d) => (d.prop === 'opacity' && parseFloat(resolveVars(d.value, ctx.tokens)) === 0) || (d.prop === 'visibility' && /hidden/.test(d.value)));\n if (!hidden) continue;\n if (HIDE_OK.test(r.selector.replace(/:not\\([^)]*\\)/g, '')) || jsScoped(r.selector) || (starting && starting.includes(r.selector))) continue;\n const revealish = /reveal|fade|animate|aos|in-view|inview|appear|enter|rise|slide|stagger|observe|lazy/i.test(r.selector) || r.decls.some((d) => d.prop === 'transform' && /translate|scale/.test(d.value));\n if (!revealish) continue;\n const anim = r.decls.find((d) => d.prop === 'animation' || d.prop === 'animation-name');\n const animName = anim ? anim.value.split(/\\s+/).find((w) => animatedNames.has(w)) : null;\n if (animName && animatesVisible(animName)) continue;\n const key = r.selector;\n if (seen.has(key)) continue; seen.add(key);\n report(62, 'FAIL', r.file, hidden.line, `${r.selector} is hidden at rest (${hidden.prop}: ${hidden.value}) and depends on a script to appear`, 'Visible by default: scope the hidden state under a .js class the script adds, or animate from visible with @starting-style');\n }\n}\n\nconst TEXT_TAGS = new Set(['p', 'span', 'li', 'small', 'a', 'div', 'figcaption', 'td', 'th', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'time', 'dd', 'dt', 'cite', 'strong', 'em', 'b', 'label', 'button', 'summary']);\nfunction check63(ctx) {\n for (const doc of ctx.docs) {\n const dotLines = new Set(); const arrowLines = new Set(); let dotCount = 0;\n const tickerRanges = [];\n for (let i = 0; i \u003c doc.tags.length; i++) {\n const t = doc.tags[i];\n if (t.close || t.selfClosing || !/marquee|ticker|tape|crawl/i.test(getAttr(t.attrs, 'class') || '')) continue;\n const inner = innerOf(doc, i);\n if (inner) tickerRanges.push([t.pos, inner.endPos]);\n }\n for (let i = 0; i \u003c doc.tags.length; i++) {\n const t = doc.tags[i];\n if (t.close || t.selfClosing || !TEXT_TAGS.has(t.name)) continue;\n const cls = getAttr(t.attrs, 'class') || '';\n if (/marquee|ticker|tape|crawl/i.test(cls) || tickerRanges.some(([a, b]) => t.pos > a && t.pos \u003c b)) continue;\n const inner = innerOf(doc, i);\n if (!inner) continue;\n if (/\u003c(p|div|li|section|article|ul|ol|table)\\b/i.test(inner.inner)) continue;\n const text = plainText(inner.inner);\n if (!text) continue;\n const dots = (text.match(/\\s[·•]\\s/g) || []).length;\n if (dots >= 2 && !dotLines.has(t.line)) { dotLines.add(t.line); report(63, 'FAIL', doc.file, t.line, `middle-dot meta string \"${trunc(text, 50)}\"`, 'Line breaks, columns or hairlines; one dot in a byline at most'); }\n if ((t.name === 'a' || t.name === 'button') && /(\\u2192|->|\\u203a|\\u00bb|\\u27f6)\\s*$/.test(text) && !arrowLines.has(t.line)) { arrowLines.add(t.line); report(63, 'FAIL', doc.file, t.line, `arrow glued to the label \"${trunc(text, 40)}\"`, 'A verb as the label; the link already says it goes somewhere'); }\n if (/^[A-Z]{3,}\\s+-\\s+\\S/.test(text) && text.length \u003c 60) report(63, 'FAIL', doc.file, t.line, `WORD - fragment label \"${trunc(text, 40)}\"`, 'Say the thing as a sentence or a heading');\n }\n for (let i = 0; i \u003c doc.tags.length; i++) {\n const t = doc.tags[i];\n if (t.close || !/^(span|i|b|em|s)$/.test(t.name)) continue;\n const cls = getAttr(t.attrs, 'class') || '';\n if (!/(^|\\s)(dot|status-dot|indicator|bullet|pip|blip)(\\s|$|__|-)/i.test(cls)) continue;\n if (/aria-live|role\\s*=\\s*\"status\"/i.test(t.attrs)) continue;\n const inner = innerOf(doc, i);\n if (inner && plainText(inner.inner)) continue;\n dotCount++;\n if (dotCount === 3) report(63, 'FAIL', doc.file, t.line, `decorative status dots (${cls.trim()}) repeated across the page`, 'Delete them; a dot means live state, and only where it is one');\n }\n }\n}\n\nfunction check64(ctx) {\n if (monoIsMaterial(ctx)) return;\n const monoFam = (v) => /mono|courier|jetbrains|plex mono|geist mono|commit|fira code|space mono|dm mono|roboto mono|source code|silkscreen/i.test(firstFamily(resolveVars(v, ctx.tokens)));\n const LABELISH = /label|caption|meta|eyebrow|kicker|tag\\b|badge|chip|small|folio|footnote|legend|\\bth\\b|thead|figcaption|overline|dateline|byline|credit|note\\b/i;\n const DATAISH = /code|pre\\b|kbd|samp|num|figure|stat|price|value|data|tabular|counter|timer|clock|coord|amount|total|score|version|hash|\\bid\\b|sku|time|date|hour|utc|eta|lot\\b|edition|ref\\b|dimension|measure|reading|plate-?no|tag-?no|url|uri\\b|path|endpoint|command|cmd|snippet|install/i;\n const seen = new Set();\n for (const r of ctx.rules) {\n if (r.keyframes || r.selector === '@font-face' || isTokenRule(r)) continue;\n if (!LABELISH.test(r.selector) || DATAISH.test(r.selector)) continue;\n const fam = r.decls.find((d) => d.prop === 'font-family' || (d.prop === 'font' && /var\\(--font|mono/i.test(d.value)));\n if (!fam) continue;\n const raw = fam.prop === 'font' ? (/var\\(--font-[\\w-]+\\)/.exec(fam.value) || [fam.value])[0] : fam.value;\n if (!monoFam(raw)) continue;\n const key = baseSelector(r.selector);\n if (seen.has(key)) continue; seen.add(key);\n report(64, 'FAIL', r.file, fam.line, `monospace label voice on ${r.selector}`, 'Labels in the body face, a step smaller, sentence case; mono only for code and figures');\n }\n}\n\nfunction check65(ctx) {\n const paper = parseColor(resolveVars(ctx.tokens['--color-paper'] || '', ctx.tokens));\n const accent = parseColor(resolveVars(ctx.tokens['--color-accent'] || '', ctx.tokens));\n if (!paper || !accent) return;\n const p = paper.okl || srgbToOklch(paper); const a = accent.okl || srgbToOklch(accent);\n const cream = p.L >= 0.9 && p.C >= 0.006 && p.H >= 55 && p.H \u003c= 105;\n const clay = a.C >= 0.08 && a.H >= 15 && a.H \u003c= 55;\n const disp = firstFamily(resolveVars(ctx.tokens['--font-display'] || '', ctx.tokens));\n const hcSerif = /bodoni|didot|didone|playfair|fraunces|instrument serif|cormorant|dm serif|libre caslon display|abril|prata|gloock|newsreader|editorial new|gt sectra|canela|freight|yeseva|rozha|boska|melodrama/i.test(disp);\n const catalogTheme = /theme:\\s*(?!custom\\b)[A-Z][\\w-]*\\s*\\((?:deal|by request)/.test(ctx.stampText);\n if (cream && clay && hcSerif && !catalogTheme) report(65, 'FAIL', ctx.cssFiles[0]?.file || ctx.docs[0]?.file, 1, `the named look: cream paper (L ${Math.round(p.L * 100)}%, hue ${Math.round(p.H)}) + ${disp} + a clay accent (hue ${Math.round(a.H)})`, 'Move one axis, the accent first; or stamp the theme as asked for by name');\n}\n\nfunction checkF15(ctx) {\n const widths = new Map();\n for (const r of ctx.rules) {\n if (r.keyframes || r.media || !/container|wrap|inner|shell|content|section|page|main/i.test(r.selector)) continue;\n for (const d of r.decls) if (d.prop === 'max-width') { const px = maxPxIn(resolveVars(d.value, ctx.tokens)); if (px && px >= 900) widths.set(px, r.selector); }\n }\n if (widths.size > 1) report('F15', 'WARN', ctx.cssFiles[0]?.file || ctx.docs[0]?.file, 1, `${widths.size} container widths on one page (${[...widths.keys()].join(', ')}px)`, 'One container width, applied by the same two-element pattern in every section');\n const gaps = new Map();\n for (const r of ctx.rules) {\n if (r.keyframes || r.media || /hero|masthead|fold|\\b(?:app|ui|demo|sample|screen|product|mock|device|console|editor|window)(?:\\b|__|--)|\\[data-slot/i.test(r.selector)) continue;\n const cols = r.decls.find((d) => d.prop === 'grid-template-columns' && trackCount(d.value) > 1 && !/^repeat\\(\\s*auto/.test(d.value.trim()));\n if (!cols) continue;\n const gap = r.decls.find((d) => /^(gap|column-gap)$/.test(d.prop));\n if (gap) { const v = resolveVars(gap.value, ctx.tokens).split(/\\s+/).pop(); const px = maxPxIn(v); if (px != null && px >= 24) gaps.set(v, r.selector); }\n }\n if (gaps.size > 2) report('F15', 'WARN', ctx.cssFiles[0]?.file || ctx.docs[0]?.file, 1, `${gaps.size} different column gaps across multi-column sections (${[...gaps.keys()].join(', ')})`, 'One column gap for every multi-column section');\n}\n\nfunction trackCount(v) {\n const t = v.replace(/\\[[^\\]]*\\]/g, ' ').trim();\n const rep = /^repeat\\(\\s*(\\d+)/.exec(t);\n if (rep) return Number(rep[1]);\n let depth = 0, n = 0, inTok = false;\n for (const ch of t) {\n if (ch === '(') depth++;\n else if (ch === ')') depth--;\n if (depth === 0 && /\\s/.test(ch)) inTok = false;\n else if (!inTok) { inTok = true; n++; }\n }\n return n;\n}\n\nfunction checkF16(ctx) {\n if (ctx.tokensOnly || !ctx.tokens['--color-accent']) return;\n const tw = twGroup(ctx);\n const missing = [];\n if (!/::selection/.test(ctx.allCss) && !tw.variants.has('selection')) missing.push('::selection');\n if (!/scrollbar-color|::-webkit-scrollbar/.test(ctx.allCss) && !tw.scrollbar) missing.push('scrollbar-color');\n const hasInputs = ctx.docs.some((d) => /\u003c(input|textarea)\\b/i.test(d.src));\n if (hasInputs && !/caret-color/.test(ctx.allCss) && !tw.caret) missing.push('caret-color');\n if (missing.length) report('F16', 'WARN', ctx.cssFiles[0]?.file || ctx.docs[0]?.file, 1, `browser surfaces unthemed: ${missing.join(', ')}`, 'Theme the parts you did not draw from the palette');\n}\n\nconst proseRun = (run, pad) => {\n let r = run;\n for (let k = 0; k \u003c 3 && /\\{[^{}]*\\}/.test(r); k++) r = r.replace(/\\{[^{}]*\\}/g, pad);\n return /[;=(){}]|\\breturn\\b/.test(r) ? pad(r) : r;\n};\nfunction maskedProse(doc) {\n if (doc._prose == null) {\n const pad = (m) => m.replace(/[^\\n]/g, ' ');\n const src = doc.kind && doc.kind !== 'html' ? doc.src.replace(/(^|>)([^\u003c>]*)(?=\u003c|$)/g, (m0, a, run) => a + proseRun(run, pad)) : doc.src;\n doc._prose = src\n .replace(/\u003c(script|style|pre|code|kbd|samp|textarea)\\b[^>]*>[\\s\\S]*?\u003c\\/\\1>/gi, pad)\n .replace(/\u003c!--[\\s\\S]*?-->/g, pad)\n .replace(/\u003c[^>]*>/g, pad);\n }\n return doc._prose;\n}\n\nfunction maxPxIn(value) {\n let best = null;\n for (const m of String(value).matchAll(/(-?\\d+(?:\\.\\d+)?)(px|rem|em)/g)) {\n const px = m[2] === 'px' ? parseFloat(m[1]) : parseFloat(m[1]) * 16;\n if (best == null || px > best) best = px;\n }\n return best;\n}\n\nfunction baseSelector(sel) {\n return sel.replace(/::?[a-z-]+(\\([^)]*\\))?/gi, '').replace(/\\s+/g, ' ').trim();\n}\n\nfunction checkF1(ctx) {\n const dq = /(?:\"|"|�?34;)(?=[A-Za-z])|[A-Za-z](?:\"|"|�?34;)/g;\n const ap = /[A-Za-z](?:'|�?39;|')(?=[A-Za-z])/g;\n for (const doc of ctx.docs) {\n const prose = maskedProse(doc);\n const hits = [...prose.matchAll(dq), ...prose.matchAll(ap)].sort((a, b) => a.index - b.index);\n if (!hits.length) continue;\n const around = prose.slice(Math.max(0, hits[0].index - 16), hits[0].index + 16).replace(/\\s+/g, ' ').trim();\n report('F1', 'WARN', doc.file, doc.lineOf(hits[0].index),\n `straight quote in prose x${hits.length} (first: ${trunc(around, 40)})`, 'Use typographic quotes: \\u201C \\u201D \\u2019');\n }\n}\n\nfunction checkF2(ctx) {\n for (const doc of ctx.docs) {\n const prose = maskedProse(doc);\n const hits = [...prose.matchAll(/\\.{3,}/g)];\n if (!hits.length) continue;\n report('F2', 'WARN', doc.file, doc.lineOf(hits[0].index),\n `three-period ellipsis in prose x${hits.length}`, 'Use a true ellipsis character (\\u2026)');\n }\n}\n\nfunction checkF3(ctx) {\n const H12 = /(^|[\\s,>+~(])h[12]\\b/i;\n const targets = [];\n let satisfied = false;\n for (const r of mergedDeclsBySelector(ctx)) {\n const isH12 = H12.test(r.selector);\n const isDisplayClass = HEADING_SEL.test(r.selector)\n && r.decls.some((d) => d.prop === 'font-size' && (maxPxIn(resolveVars(d.value, ctx.tokens)) ?? 0) >= 40);\n if (!isH12 && !isDisplayClass) continue;\n targets.push(r);\n if (r.decls.some((d) => /^text-wrap(-style)?$/.test(d.prop) && /balance/.test(d.value))) satisfied = true;\n }\n if (targets.length && !satisfied) {\n report('F3', 'WARN', targets[0].file, targets[0].line,\n `no text-wrap: balance on any display head (${targets.length} head rule(s), e.g. ${trunc(targets[0].selector, 32)})`,\n 'Add text-wrap: balance to h1/h2-level heads');\n }\n}\n\nfunction checkF4(ctx) {\n const BTN = /\\b(btn|button|cta)\\b|__(btn|button|cta)\\b/i;\n const filled = new Map();\n for (const r of mergedDeclsBySelector(ctx)) {\n if (isTokenRule(r) || /:hover|:active|:focus|:disabled|\\.is-|\\[disabled/i.test(r.selector)) continue;\n const first = baseSelector(r.selector.split(',')[0]);\n if (!BTN.test(first)) continue;\n for (const d of r.decls) {\n if (!/^background(-color)?$/.test(d.prop)) continue;\n const rv = resolveVars(d.value, ctx.tokens);\n if (/transparent|none/i.test(rv)) continue;\n if (/var\\(\\s*--color-(?!paper|rule|muted|focus)[\\w-]+/.test(d.value) && !filled.has(first)) filled.set(first, { r, d });\n }\n }\n if (filled.size >= 2) {\n const sels = [...filled.keys()];\n const second = filled.get(sels[1]);\n report('F4', 'WARN', second.r.file, second.d.line,\n `${filled.size} filled primary-button recipes: ${trunc(sels.join(' + '), 52)}`,\n 'One filled primary per page; demote the rest to outline/quiet variants');\n }\n}\n\nfunction checkF5(ctx) {\n for (const r of mergedDeclsBySelector(ctx)) {\n if (isTokenRule(r) || !/icon|\\bico\\b/i.test(r.selector)) continue;\n const radius = r.decls.find((d) => d.prop === 'border-radius');\n if (!radius) continue;\n const rv = resolveVars(radius.value, ctx.tokens);\n const pct = /(\\d+(?:\\.\\d+)?)%/.exec(rv);\n const round = (pct && parseFloat(pct[1]) >= 50) || ((maxPxIn(rv) ?? 0) >= 999);\n if (!round) continue;\n const bg = r.decls.find((d) => /^background(-color)?$/.test(d.prop)\n && !/^(none|transparent)$/i.test(resolveVars(d.value, ctx.tokens).trim()));\n if (!bg) continue;\n const small = r.decls.some((d) => (d.prop === 'width' || d.prop === 'height')\n && (pxOf(resolveVars(d.value, ctx.tokens)) ?? 999) \u003c= 64);\n if (small) {\n report('F5', 'WARN', r.file, bg.line, `icon-in-coloured-circle coin: ${trunc(r.selector, 40)}`,\n 'Drop the tinted coin; set the icon at text size in currentColor');\n }\n }\n}\n\nfunction checkF6(ctx) {\n const NUM_SEL = /\\b(stat(?!ement)|price|table|figure|count|num)[\\w-]*/i;\n const hasTnum = (r) => r.decls.some((d) => d.prop === 'font-variant-numeric'\n || (d.prop === 'font-feature-settings' && /tnum/.test(d.value)));\n if (ctx.rules.some((r) => /^(html|body|:root)\\b/i.test(r.selector.trim()) && hasTnum(r))) return;\n const bySel = mergedDeclsBySelector(ctx);\n const covered = new Set(bySel.filter(hasTnum).map((r) => baseSelector(r.selector)));\n const warned = new Set();\n for (const r of bySel) {\n if (isTokenRule(r) || !NUM_SEL.test(r.selector)) continue;\n const base = baseSelector(r.selector);\n if (warned.has(base) || covered.has(base)) continue;\n const fs2 = r.decls.find((d) => d.prop === 'font-size');\n if (!fs2 || (maxPxIn(resolveVars(fs2.value, ctx.tokens)) ?? 0) \u003c 24) continue;\n warned.add(base);\n report('F6', 'WARN', r.file, fs2.line,\n `numeric selector ${trunc(base, 36)} at display size without tabular figures`,\n 'Add font-variant-numeric: tabular-nums');\n }\n}\n\nfunction checkF7(ctx) {\n const byKey = new Map();\n for (const r of mergedDeclsBySelector(ctx)) {\n if (isTokenRule(r) || /::(before|after)/.test(r.selector)) continue;\n const get = (re) => {\n const d = r.decls.filter((x) => re.test(x.prop)).pop();\n return d ? resolveVars(d.value, ctx.tokens).replace(/\\s+/g, ' ').trim() : '';\n };\n const border = get(/^border$/)\n || [get(/^border-width$/), get(/^border-style$/), get(/^border-color$/)].filter(Boolean).join(' ');\n const radius = get(/^border-radius$/);\n const surface = get(/^background(-color)?$/) || '';\n const shadow = get(/^box-shadow$/);\n if (!radius || (!border && !surface && !shadow)) continue;\n const key = [border, radius, surface, shadow].join(' | ');\n if (!byKey.has(key)) byKey.set(key, { sels: new Set(), r });\n for (const part of r.selector.split(',')) byKey.get(key).sels.add(baseSelector(part));\n }\n for (const v of byKey.values()) {\n if (v.sels.size >= 6) {\n report('F7', 'WARN', v.r.file, v.r.line,\n `same container recipe on ${v.sels.size} selectors (${trunc([...v.sels].slice(0, 4).join(', '), 48)}...)`,\n 'Vary weight, radius, or fill by role; not every box is the same box');\n }\n }\n}\n\nfunction checkF8(ctx) {\n const famOf = (sel) => {\n const m = /\\.([a-z][\\w-]*)/i.exec(sel) || /(^|[\\s>+~])([a-z][\\w-]*)/i.exec(sel);\n const name = m ? (m[1] || m[2]) : sel;\n return name.split('__')[0].split('--')[0].toLowerCase();\n };\n const NAVISH = /^(nav|header|topbar|masthead|banner)$/;\n const timeline = new Map(), sticky = new Map(), animated = new Set();\n for (const r of ctx.rules) {\n if (r.keyframes || isTokenRule(r)) continue;\n for (const d of r.decls) {\n if (d.prop === 'animation-timeline' && !/^(none|auto)$/i.test(d.value.trim())\n && !timeline.has(famOf(r.selector))) timeline.set(famOf(r.selector), { r, d });\n if (d.prop === 'animation' || d.prop === 'animation-name') animated.add(famOf(r.selector));\n if (d.prop === 'position' && /sticky/i.test(d.value)) sticky.set(famOf(r.selector), { r, d });\n }\n }\n const families = new Map(timeline);\n for (const [fam, v] of sticky) {\n if (!families.has(fam) && animated.has(fam) && !NAVISH.test(fam)) families.set(fam, v);\n }\n if (families.size > 1) {\n const names = [...families.keys()];\n const second = families.get(names[1]);\n report('F8', 'WARN', second.r.file, second.d.line,\n `${families.size} scroll-choreography patterns on one page (${trunc(names.join(', '), 44)})`,\n 'Budget is one scroll pattern per page (scroll-choreography.md); cut the rest');\n }\n}\n\nfunction checkF9(ctx) {\n const FAKE = /(i\\.pravatar\\.cc|pravatar\\.cc|api\\.dicebear\\.com|dicebear\\.com|randomuser\\.me|thispersondoesnotexist\\.com|ui-avatars\\.com)/i;\n const UNSPLASH = /https?:\\/\\/[^\"'()\\s]*unsplash\\.com[^\"'()\\s]*/gi;\n const PERSONY = /person|face|portrait|people|headshot/i;\n const scan = (text, file, lineAt) => {\n const f = FAKE.exec(text);\n if (f) report('F9', 'FAIL', file, lineAt(f.index), `fake-human avatar service ${f[1]}`,\n 'Drop the face, or use the real person\\'s own photo; a generated stranger is fabricated social proof');\n for (const m of text.matchAll(UNSPLASH)) {\n if (PERSONY.test(m[0])) report('F9', 'WARN', file, lineAt(m.index),\n `stock person-keyword URL ${trunc(m[0], 44)}`,\n 'Faces as social proof need real people; a stock face is a stranger');\n }\n };\n for (const doc of ctx.docs) scan(doc.src, doc.file, (i) => doc.lineOf(i));\n for (const c of ctx.cssFiles) scan(c.raw, c.file, (i) => c.raw.slice(0, i).split('\\n').length);\n}\n\nfunction fontSizePxOf(r, ctx) {\n const fs = r.decls.find((d) => d.prop === 'font-size');\n if (fs) return pxOf(resolveVars(fs.value, ctx.tokens));\n const f = r.decls.find((d) => d.prop === 'font');\n if (!f) return null;\n const m = /(?:^|\\s)(\\d+(?:\\.\\d+)?(?:px|rem|em))(?:\\/[\\d.]+(?:px|rem|em)?)?\\s/.exec(resolveVars(f.value, ctx.tokens) + ' ');\n return m ? pxOf(m[1]) : null;\n}\n\nconst LABEL67 = /label|caption|meta|eyebrow|kicker|tag\\b|badge|chip|small|folio|footnote|legend|\\bth\\b|thead|figcaption|overline|dateline|byline|credit|note\\b|\\bnav\\b|menu|link|pill|status|unit|smallcaps|count|placard/i;\nconst CONTROL67 = /btn|button|cta|\\.sign\\b|plate|control/i;\nconst EXEMPT67_NAME = /^(wordmark|marquee|ticker|logo|brand|abbr|logo-?(mark|type|text)|brand-?(mark|name|type))$/i;\nconst EXEMPT67_PART = /(^|[-_])(wordmark|marquee|ticker|logotype|logo|brand)([-_]|$)|(^|[-_])(slab|nav|site|head|header|masthead|bar)[-_]+mark([-_]|$)/i;\nconst exemptName67 = (n) => !!n && (EXEMPT67_NAME.test(n.split('--')[0].split('__').pop()) || EXEMPT67_PART.test(n));\nconst SCROLLER67 = /(^|[-_])(marquee|ticker)([-_]|$)/i;\nfunction exempt67(sel) {\n return sel.split(',').some((part) => {\n const comps = baseSelector(part).split(/[\\s>+~]+/).filter(Boolean);\n const comp = comps[comps.length - 1] || '';\n const names = (comp.match(/\\.[a-zA-Z_][\\w-]*/g) || []).map((c) => c.slice(1));\n names.push((/^[a-zA-Z][\\w-]*/.exec(comp) || [''])[0]);\n if (names.some(exemptName67)) return true;\n return comps.slice(0, -1).some((c) => (c.match(/\\.[a-zA-Z_][\\w-]*/g) || []).some((x) => SCROLLER67.test(x.slice(1))));\n });\n}\nfunction inherited67(bySel, sel, prop) {\n const base = baseSelector(sel);\n let best = null, bestLen = 0;\n for (const o of bySel) {\n if (/:hover|:focus|:active/.test(o.selector) || (o.media && /max-width/i.test(o.media))) continue;\n for (const part of o.selector.split(',')) {\n const ob = baseSelector(part);\n if (!ob || ob === base || ob.length \u003c= bestLen || !base.startsWith(ob) || !/[\\s>+~]/.test(base[ob.length] || '')) continue;\n const d = o.decls.filter((x) => x.prop === prop).pop();\n if (d) { best = d.value; bestLen = ob.length; }\n }\n }\n return best;\n}\nconst isSmallCaps67 = (prop, v) => (/^font-variant(-caps)?$/.test(prop) && /\\b(all-)?(small|petite)-caps\\b/.test(v))\n || (prop === 'font-feature-settings' && /\\b(smcp|c2sc)\\b/.test(v));\nfunction check67(ctx) {\n const bySel = mergedDeclsBySelector(ctx);\n const displayish = (r) => HEADING_SEL.test(r.selector) || /display|title|headline|bill|poster|banner/i.test(r.selector) || r.decls.some((d) => (d.prop === 'font-family' || d.prop === 'font') && /--font-display/.test(d.value)) || (fontSizePxOf(r, ctx) || 0) >= 28;\n const upperDisplay = bySel.some((r) => displayish(r) && !LABEL67.test(r.selector) && r.decls.some((d) => d.prop === 'text-transform' && /uppercase/.test(d.value)));\n const isUpper = (v) => v != null && /uppercase/.test(resolveVars(v, ctx.tokens));\n const seen = new Set();\n for (const r of bySel) {\n if (isTokenRule(r) || exempt67(r.selector) || /::first-letter\\s*(,|$)/.test(r.selector)) continue;\n const ownTT = r.decls.filter((d) => d.prop === 'text-transform').pop();\n const ownUpper = ownTT ? isUpper(ownTT.value) : null;\n const ownSmallCaps = r.decls.some((d) => isSmallCaps67(d.prop, resolveVars(d.value, ctx.tokens)));\n const px = fontSizePxOf(r, ctx);\n const ownEm = trackingEmOf(ctx, r.decls, px || 16);\n const ownSignal = ownUpper === true || ownSmallCaps || (ownEm != null && ownEm > 0.03);\n if (!ownSignal) continue;\n const upper = ownUpper != null ? ownUpper : isUpper(inherited67(bySel, r.selector, 'text-transform'));\n const smallCaps = ownSmallCaps || ['font-variant', 'font-variant-caps', 'font-feature-settings'].some((prop) => {\n const v = inherited67(bySel, r.selector, prop);\n return v != null && isSmallCaps67(prop, resolveVars(v, ctx.tokens));\n });\n let em = ownEm;\n if (em == null) {\n const v = inherited67(bySel, r.selector, 'letter-spacing');\n if (v != null) em = trackingEmOf(ctx, [{ prop: 'letter-spacing', value: v }], px || 16);\n }\n const tracked = em != null && em > 0.03;\n const subject = r.selector.split(',').map((p) => baseSelector(p).split(/[\\s>+~]+/).filter(Boolean).pop() || '');\n const bigToken = r.decls.some((d) => d.prop === 'font-size' && /--text-(display|section|title)|clamp\\(\\s*(?:[2-9]|\\d{2,})(?:\\.\\d+)?rem/.test(d.value));\n const labelish = subject.some((s) => LABEL67.test(s)) ? !((px != null && px >= 28) || bigToken)\n : LABEL67.test(r.selector) && !subject.every((s) => /^h[1-6]\\b/i.test(s)) && (px == null || px \u003c 20);\n const control = CONTROL67.test(r.selector);\n const heading = HEADING_SEL.test(r.selector) && !labelish;\n if (heading && !control) continue;\n const key = baseSelector(r.selector);\n if (seen.has(key)) continue;\n const small = px != null && px \u003c= 15;\n if (control && !labelish) {\n if (!upper && !smallCaps) { if (!(tracked && small)) continue; }\n else if (upperDisplay && (em == null || em \u003c= 0.045)) continue;\n seen.add(key);\n report(67, 'FAIL', r.file, r.line, upperDisplay ? `${r.selector}: uppercase control tracked ${em}em` : `${r.selector}: uppercase control on a page whose display is not uppercase`, 'Controls follow the display voice: sentence case here, or tracking 0.04em at most where the display itself is uppercase');\n continue;\n }\n const caps = upper || smallCaps;\n if ((caps && (labelish || smallCaps || tracked || small)) || (tracked && (labelish || small))) {\n seen.add(key);\n const what = smallCaps ? 'small caps' : upper ? 'uppercase' : 'letter-spaced';\n report(67, 'FAIL', r.file, r.line, `${r.selector}: ${what}${tracked ? ` tracked ${em}em` : ''}${px != null ? ` at ${Math.round(px)}px` : ''} in a label role`, 'Labels, captions, table heads, nav links and meta are the body face (or the theme\\'s label face), a step smaller, sentence case, untracked');\n }\n }\n check67Html(ctx);\n}\n\nconst CAPS_TAGS = /^(p|span|small|a|li|div|figcaption|td|th|dt|dd|label|time|cite|strong|b|em|button|summary)$/;\nfunction check67Html(ctx) {\n for (const doc of ctx.docs) {\n const anc = ancestorMap(doc);\n const seen = new Set();\n for (let i = 0; i \u003c doc.tags.length; i++) {\n const t = doc.tags[i];\n if (t.close || t.selfClosing || !CAPS_TAGS.test(t.name)) continue;\n const cls = getAttr(t.attrs, 'class') || '';\n if (cls.split(/\\s+/).some(exemptName67)) continue;\n if ((anc.get(i) || []).some((n) => /^(svg|pre|code|abbr|kbd|samp)$/.test(n))) continue;\n const inner = innerOf(doc, i);\n if (!inner || /\u003c(p|div|ul|ol|h[1-6]|table|section|article|svg|img|abbr|code|kbd)\\b/i.test(inner.inner)) continue;\n const text = plainText(inner.inner);\n const letters = text.replace(/[^A-Za-z]/g, '');\n if (letters.length \u003c 5 || capsRatio(text) \u003c 0.95 || /^[A-Z]{2,5}$/.test(text) || /^\\(?\\s*(CC0|CC BY(-SA|-NC|-ND)*( [0-9.]+)?|PD|GFDL)\\b/.test(text)) continue;\n const decls = styleFor(ctx, cls, t.name);\n const px = fontPxOf(ctx, decls);\n const em = trackingEmOf(ctx, decls, px || 16);\n const tracked = em != null && em > 0.03;\n if (!((px != null && px \u003c= 15) || tracked)) continue;\n const key = cls || t.name;\n if (seen.has(key)) continue;\n seen.add(key);\n report(67, 'FAIL', doc.file, t.line, `\"${trunc(text, 32)}\" typed in capitals${px != null ? ` at ${Math.round(px)}px` : ''}${tracked ? ` tracked ${em}em` : ''} (\u003c${t.name}${cls ? ` class=\"${trunc(cls, 24)}\"` : ''}>)`, 'Sentence case in the body face; capitals survive only as a wordmark or as the display voice');\n }\n }\n}\n\nconst ACCENT38 = /(?:^|[\\s,])((?:h[1-3]|\\.display|\\.headline|\\.title|[.\\w-]*__(?:title|display|headline))[^\\s,>+~]*)\\s*(?:>|\\s)\\s*(em|i|b|strong|span|mark|\\.[\\w-]+)(?::nth-child\\([^)]*\\))?(?:::?[\\w-]+)?\\s*$/i;\nconst INHERITED38 = /^(inherit|currentcolor|unset|initial|normal)$/i;\nfunction accentedHeads(ctx) {\n if (ctx._acc38) return ctx._acc38;\n const out = [];\n const seen = new Set();\n for (const r of ctx.rules) {\n if (r.keyframes || isTokenRule(r)) continue;\n const m = ACCENT38.exec(r.selector);\n if (!m) continue;\n const marked = r.decls.some((d) =>\n (/^(color|background|background-color|-webkit-text-stroke|font-weight|text-decoration|text-decoration-color|border-bottom|box-shadow)$/.test(d.prop) && !INHERITED38.test(d.value.trim()))\n || (d.prop === 'font-style' && !INHERITED38.test(d.value.trim())));\n if (!marked || seen.has(r.selector)) continue;\n seen.add(r.selector);\n out.push({ r, head: m[1] });\n }\n const marks = (r) => r.decls.some((d) => (/^(color|background|background-color|-webkit-text-stroke|font-weight|text-decoration|text-decoration-color|border-bottom|box-shadow|font-style)$/.test(d.prop) && !INHERITED38.test(d.value.trim())));\n for (const doc of ctx.docs) {\n for (let i = 0; i \u003c doc.tags.length; i++) {\n const t = doc.tags[i];\n if (t.close || !/^h[1-3]$/.test(t.name)) continue;\n const inner = innerOf(doc, i);\n if (!inner) continue;\n const headLen = plainText(inner.inner).replace(/\\s+/g, '').length || 1;\n const spans = [...inner.inner.matchAll(/\u003c(span|em|strong|b|i|mark)\\b[^>]*\\bclass\\s*=\\s*[\"']([^\"']+)[\"'][^>]*>([\\s\\S]*?)\u003c\\/\\1>/gi)];\n const cover = (cls) => spans.filter((x) => x[2].split(/\\s+/).includes(cls)).reduce((n, x) => n + plainText(x[3]).replace(/\\s+/g, '').length, 0) / headLen;\n for (const m of spans) {\n if (!/[A-Za-z0-9]/.test(plainText(m[3]))) continue;\n for (const cls of m[2].split(/\\s+/).filter(Boolean)) {\n if (cover(cls) >= 0.8) continue;\n for (const r of ctx.rules) {\n if (r.keyframes || isTokenRule(r) || seen.has(r.selector)) continue;\n if (!r.selector.split(',').some((part) => !/::?(before|after)\\b/.test(part) && baseSelector(part).trim() === `.${cls}`)) continue;\n if (r.decls.some((d) => (d.prop === 'display' && /^(block|flex|grid|table)/.test(d.value.trim())) || (d.prop === 'position' && /absolute|fixed/.test(d.value)))) continue;\n if (!marks(r)) continue;\n const headDecls = styleFor(ctx, getAttr(t.attrs, 'class'), t.name, true);\n const norm = (v) => String(v || '').replace(/\\s+/g, ' ').trim().toLowerCase();\n const own = (prop) => declValue(ctx, headDecls, prop) ?? (prop === 'color' ? resolveVars('var(--color-ink)', ctx.tokens) : null);\n const marking = r.decls.filter((d) => /^(color|background|background-color|-webkit-text-stroke|font-weight|text-decoration|text-decoration-color|border-bottom|box-shadow|font-style)$/.test(d.prop) && !INHERITED38.test(d.value.trim()));\n if (marking.length && marking.every((d) => /^(color|font-weight)$/.test(d.prop) && own(d.prop) != null && norm(resolveVars(d.value, ctx.tokens)) === norm(own(d.prop)))) continue;\n seen.add(r.selector);\n out.push({ r, head: t.name });\n }\n }\n }\n }\n }\n ctx._acc38 = out;\n return out;\n}\nfunction accentedWordInHero(ctx) {\n const heads = accentedHeads(ctx);\n if (!heads.length) return true;\n const sels = new Set(heads.map((h) => baseSelector(h.r.selector)));\n if (sels.size > 1) return `the accented word sits on ${sels.size} selectors (${trunc([...sels].join(', '), 48)}); the signature appears once`;\n const { r, head } = heads[0];\n if (/hero/i.test(r.selector) || /^h1\\b/i.test(head)) return true;\n const cls = (/\\.([\\w-]+)/.exec(head) || [])[1];\n if (!cls || !ctx.docs.length) return true;\n let count = 0, aboveFirstH2 = false;\n for (const doc of ctx.docs) {\n let h2Seen = false;\n for (const t of doc.tags) {\n if (t.close) continue;\n if (t.name === 'h2') h2Seen = true;\n if ((getAttr(t.attrs, 'class') || '').split(/\\s+/).includes(cls)) { count++; if (!h2Seen) aboveFirstH2 = true; }\n }\n }\n if (count === 1 && aboveFirstH2) return true;\n return count !== 1 ? `.${cls} appears ${count} times in the markup; the signature appears once, in the hero` : `.${cls} sits below the first h2, outside the hero`;\n}\nfunction check38c(ctx) {\n for (const { r } of accentedHeads(ctx)) {\n report('38iii', 'FAIL', r.file, r.line, `accented word inside a heading (${r.selector})`, 'Set the whole head one way: a line break, a second line at a second size, or the whole head in the accent. Guard: the theme names the coloured word as its signature and it appears once on the page, in the hero');\n }\n}\n\nfunction heroIndexOf(doc, h1Idx) {\n for (let i = h1Idx - 1; i >= 0; i--) {\n const t = doc.tags[i];\n if (t.close || !/^(section|header)$/.test(t.name)) continue;\n const inner = innerOf(doc, i);\n if (inner && inner.endPos > doc.tags[h1Idx].pos) return i;\n }\n return -1;\n}\nconst wordsOf = (t) => plainText(t).split(/\\s+/).filter((w) => /[A-Za-z0-9]/.test(w));\n\nconst TELLS19 = [\n [/\\bwhether you'?re\\b/i, 'filler opener'],\n [/\\bwe'?ve got you covered\\b/i, 'cliche'],\n [/\\bsay goodbye to\\b/i, 'cliche'],\n [/\\bno credit card required\\b/i, 'template line'],\n [/\\beverything you need to\\b/i, 'cliche'],\n [/\\ball in one place\\b/i, 'cliche'],\n [/\\blorem ipsum\\b/i, 'placeholder text'],\n [/\\ball rights reserved\\b/i, 'template footer line'],\n [/\\bmade with\\s*(?:♥|❤|\\u{1f9e1}|\\u{1f49b}|\\u{1f49a}|\\u{1f499}|\\u{1f49c}|\\u{1f5a4}|\\u{1f90d}|♥|♥|❤|\u003c3)/iu, 'made with a heart'],\n];\nfunction check19Tells(ctx) {\n for (const doc of ctx.docs) {\n const prose = maskedProse(doc);\n const lines = new Set();\n const tell = (idx, what, text) => {\n const line = doc.lineOf(idx);\n if (lines.has(line)) return;\n lines.add(line);\n report(19, what === 'template footer line' ? 'WARN' : 'FAIL', doc.file, line, `${what}: \"${trunc(text, 40)}\"`, what === 'template footer line' ? 'A stock line with no legal effect; cut it unless the owner wants it' : 'Say what the product does, in its own nouns');\n };\n for (const [re, what] of TELLS19) {\n const m = re.exec(prose);\n if (m) tell(m.index, what, m[0]);\n }\n const stock = [];\n for (let i = 0; i \u003c doc.tags.length; i++) {\n const t = doc.tags[i];\n if (t.close || t.name !== 'a') continue;\n const label = plainText(innerOf(doc, i)?.inner || '').replace(/[\\u2192\\u203a\\u00bb]|->/g, '').trim();\n if (/^(learn more|read more|find out more|click here|more info|see more)$/i.test(label)) stock.push({ t, label });\n }\n if (stock.length >= 2) report(19, 'WARN', doc.file, stock[0].t.line, `stock link label \"${stock[0].label}\" x${stock.length}`, 'Say where each link goes: the thing it opens, in its own noun');\n for (let i = 0; i \u003c doc.tags.length; i++) {\n const t = doc.tags[i];\n if (t.close || !/^h[1-6]$/.test(t.name)) continue;\n const text = plainText(innerOf(doc, i)?.inner || '');\n if (/^Meet\\s+[A-Z][\\w-]*[.!]?$/.test(text)) tell(t.pos, 'the \"Meet \u003cName>\" heading', text);\n else if (/^[A-Z][a-z]+\\.\\s+[A-Z][a-z]+\\.\\s+[A-Z][a-z]+\\.$/.test(text)) tell(t.pos, 'the three-word triad heading', text);\n }\n }\n}\n\nfunction check58Static(ctx) {\n for (const doc of ctx.docs) {\n const h1 = doc.tags.findIndex((t) => !t.close && t.name === 'h1');\n if (h1 >= 0) {\n const words = wordsOf(innerOf(doc, h1)?.inner || '');\n if (words.length > 7) report(58, 'WARN', doc.file, doc.tags[h1].line, `h1 runs ${words.length} words (\"${trunc(words.join(' '), 40)}\")`, 'Seven words or fewer, 50 characters or fewer');\n const heroIdx = heroIndexOf(doc, h1);\n const hero = heroIdx >= 0 ? innerOf(doc, heroIdx) : null;\n if (hero) {\n for (let i = h1 + 1; i \u003c doc.tags.length && doc.tags[i].pos \u003c hero.endPos; i++) {\n const t = doc.tags[i];\n if (t.close || t.name !== 'p') continue;\n const pw = wordsOf(innerOf(doc, i)?.inner || '');\n if (pw.length > 12) report(58, 'WARN', doc.file, t.line, `hero paragraph runs ${pw.length} words`, 'One sentence, twelve words or fewer');\n break;\n }\n }\n }\n const sections = doc.tags.filter((t) => !t.close && t.name === 'section').length;\n const fullPage = sections >= 5 || doc.tags.some((t) => !t.close && t.name === 'footer');\n if (!fullPage || doc.kind !== 'html') continue;\n const countWords = (t) => t.split(/\\s+/).filter((w) => /[A-Za-z0-9]/.test(w)).length;\n let hiddenWords = 0, hiddenEnd = -1;\n for (let i = 0; i \u003c doc.tags.length; i++) {\n const t = doc.tags[i];\n if (t.close || t.selfClosing || t.pos \u003c hiddenEnd) continue;\n const cls = getAttr(t.attrs, 'class') || '';\n if (!(/(^|\\s)(sr-only|visually-hidden|screen-reader-text|skip|skip-link)(\\s|$)/i.test(cls) || /aria-hidden\\s*=\\s*[\"']?true/i.test(t.attrs) || /(^|\\s)hidden(\\s|=|$)/i.test(t.attrs) || /(^|\\s)data-credits(\\s|=|$)/i.test(t.attrs))) continue;\n const inner = innerOf(doc, i);\n if (!inner) continue;\n hiddenWords += countWords(plainText(inner.inner));\n hiddenEnd = inner.endPos;\n }\n const total = countWords(doc.text) - hiddenWords;\n if (total > 450) report(58, 'WARN', doc.file, 1, `${total} visible words on the page (450 at most)`, 'Cut copy: one idea per screen, sections become objects');\n }\n}\n\nconst SECOND_CTA = /^(learn more|see how it works|watch (the )?demo|get started|book a demo)$/i;\nfunction check42TwoButton(ctx) {\n for (const doc of ctx.docs) {\n const h1 = doc.tags.findIndex((t) => !t.close && t.name === 'h1');\n if (h1 \u003c 0) continue;\n const heroIdx = heroIndexOf(doc, h1);\n const hero = heroIdx >= 0 ? innerOf(doc, heroIdx) : null;\n if (!hero) continue;\n const anc = ancestorMap(doc);\n const controls = [];\n for (let i = heroIdx + 1; i \u003c doc.tags.length && doc.tags[i].pos \u003c hero.endPos; i++) {\n const t = doc.tags[i];\n if (t.close || t.selfClosing || !(t.name === 'button' || t.name === 'a')) continue;\n const cls = getAttr(t.attrs, 'class') || '';\n if (t.name === 'a' && !/btn|cta|button/i.test(cls)) continue;\n if ((anc.get(i) || []).includes('nav')) continue;\n controls.push({ t, cls, label: plainText(innerOf(doc, i)?.inner || '').trim() });\n }\n if (controls.length \u003c 2) continue;\n const fillOf = (c) => { const decls = styleFor(ctx, c.cls, c.t.name, true); let raw = null; for (const d of decls) if (d.prop === 'background-color' || d.prop === 'background') raw = d.value; if (!raw || /--color-paper\\b/.test(raw)) return false; const bg = resolveVars(raw, ctx.tokens); return !/^(none|transparent|inherit|initial|unset)$/i.test(bg.trim()) && !/transparent/i.test(bg) && !/var\\(--color-paper/.test(raw); };\n const firstFilled = fillOf(controls[0]);\n if (firstFilled && fillOf(controls[1])) { report('F4', 'WARN', doc.file, controls[1].t.line, `two filled buttons side by side in the hero: \"${trunc(controls[0].label, 20)}\" and \"${trunc(controls[1].label, 20)}\"`, 'One filled primary per screen; the second action is outlined or a typographic link'); continue; }\n if (!SECOND_CTA.test(controls[1].label) || !firstFilled) continue;\n report(19, 'WARN', doc.file, controls[1].t.line, `the stock second action in the hero: \"${trunc(controls[0].label, 20)}\" then \"${controls[1].label}\"`, 'One action in the hero; the second button is the template speaking');\n }\n}\n\nconst PLACEHOLDER_IMG = /placehold\\.co|via\\.placeholder\\.com|source\\.unsplash\\.com\\/random|dummyimage\\.com/i;\nfunction check68(ctx) {\n for (const doc of ctx.docs) {\n if (doc.kind !== 'html') continue;\n const slots = [];\n for (let i = 0; i \u003c doc.tags.length; i++) {\n const t = doc.tags[i];\n if (t.close || !/\\bdata-slot\\s*=/.test(t.attrs)) continue;\n const inner = t.selfClosing ? null : innerOf(doc, i);\n if (inner && /\u003cimg\\b[^>]*\\bsrc\\s*=\\s*[\"'][^\"']+[\"']/i.test(inner.inner)) continue;\n if (inner && (inner.inner.match(/\u003c[a-z][\\w-]*\\b[^>]*>\\s*[^\u003c\\s]/gi) || []).length >= 6) continue;\n let hiddenUp = false;\n for (let k = i - 1, d = 0; k >= 0 && !hiddenUp; k--) { const u = doc.tags[k]; if (u.selfClosing) continue; if (u.close) { d++; continue; } if (d > 0) { d--; continue; } if (/(?:^|\\s)hidden(?:\\s|=|$)/.test(u.attrs)) hiddenUp = true; }\n if (hiddenUp) continue;\n slots.push({ i, t });\n }\n if (slots.length && !ctx.docs.some((d) => d.kind !== 'html') && !/hallmarkSlots/.test(doc.src) && !ctx.docs.some((d) => d !== doc && /hallmarkSlots/.test(d.src))) report('F18', 'WARN', doc.file, slots[0].t.line, `${slots.length} slot${slots.length > 1 ? 's' : ''} and no slot script`, 'Paste the hallmarkSlots script the scaffold printed before \u003c/body>, so the user\\'s images drop in (imagery.md § 3)');\n if (slots.length > 5) report(68, 'FAIL', doc.file, slots[5].t.line, `${slots.length} empty slots on one page`, 'Two to four slots carry a page; set the rest in type, names and facts in a list or table');\n const byRun = new Map();\n for (const sl of slots) {\n let depth = 0, parentIdx = -1;\n for (let k = sl.i - 1; k >= 0; k--) { const t = doc.tags[k]; if (t.selfClosing) continue; if (t.close) depth++; else if (depth === 0) { parentIdx = k; break; } else depth--; }\n const up = (from) => { let d = 0; for (let k = from - 1; k >= 0; k--) { const t = doc.tags[k]; if (t.selfClosing) continue; if (t.close) d++; else if (d === 0) return k; else d--; } return -1; };\n const gp = parentIdx >= 0 ? up(parentIdx) : -1;\n const ggp = gp >= 0 ? up(gp) : -1;\n for (const key of new Set([parentIdx, gp, ggp])) { if (key \u003c 0) continue; byRun.set(key, (byRun.get(key) || 0) + 1); }\n }\n for (const [key, n] of byRun) if (n >= 4) { report(68, 'FAIL', doc.file, doc.tags[key].line, `${n} empty slots in one row or grid`, 'A set the page has no pictures of is set in type with one slot at most'); break; }\n }\n}\n\nconst FACT69 = /(?:[£$€¥]\\s?\\d[\\d,.]*|\\d[\\d,.]*\\s?(?:pm|am|MAD|kg|g|ml|km|mi|miles|%)\\b|\\b\\d{1,2}:\\d{2}\\b)/gi;\nconst REGION69 = /^(section|header|footer|article|aside|nav|form|dialog)$/;\nfunction check69(ctx) {\n for (const doc of ctx.docs) {\n if (doc.kind !== 'html') continue;\n const bodyAt = doc.tags.findIndex((t) => !t.close && t.name === 'body');\n const stack = [];\n const seen = new Map();\n let uid = 0;\n const regionOf = () => {\n const r = stack.find((e) => REGION69.test(e.name));\n if (r) return r.id;\n const band = stack.find((e, i) => i > 0 && (stack[i - 1].name === 'body' || stack[i - 1].name === 'main') && e.name !== 'main');\n return band ? band.id : 'root';\n };\n for (let i = Math.max(bodyAt, 0); i \u003c doc.tags.length; i++) {\n const t = doc.tags[i];\n if (t.close) { const at = stack.map((e) => e.name).lastIndexOf(t.name); if (at >= 0) stack.length = at; }\n else if (!t.selfClosing) stack.push({ name: t.name, id: uid++ });\n const next = doc.tags[i + 1];\n if (/^(script|style|template|noscript)$/.test(t.name) && !t.close) continue;\n const chunk = doc.masked.slice(t.end, next ? next.pos : doc.masked.length)\n .replace(/£/g, '£').replace(/€/g, '€').replace(/ | /g, ' ');\n if (!chunk.trim()) continue;\n const region = regionOf();\n for (const f of chunk.match(FACT69) || []) {\n const k = f.replace(/\\s+/g, '').toLowerCase();\n if (!seen.has(k)) seen.set(k, new Set());\n seen.get(k).add(region);\n }\n }\n const loud = [...seen].map(([k, s]) => [k, s.size]).filter(([, n]) => n >= 4).sort((a, b) => b[1] - a[1]);\n if (loud.length) report(69, 'WARN', doc.file, 1, `the same fact printed in ${loud[0][1]} sections: ${loud.slice(0, 3).map(([k, n]) => `${k} (${n})`).join(', ')}`, 'Say each fact once, where it does work; cut the restatements');\n }\n}\n\nfunction check70(ctx) {\n const SUCCESS = /\\b(sent|thanks|thank you|reserved|booked|on the list|subscribed|in touch|received|confirmed|ordered|chosen|on its way|signed up|all set|you're in|you are in|done)\\b/i;\n const NEG = /\\b(not|nothing|no|never|isn't|wasn't|didn't|cannot|can't|failed|try again|soon)\\b/i;\n for (const doc of ctx.docs) {\n const js = (doc.scripts && typeof doc.scripts === 'string') ? doc.scripts : (doc.src.match(/\u003cscript\\b[^>]*>([\\s\\S]*?)\u003c\\/script>/gi) || []).join('\\n');\n if (!/\u003cform\\b|type\\s*=\\s*[\"']submit/i.test(doc.src) || !js) continue;\n let m = null;\n for (const x of js.matchAll(/([\"'`])((?:(?!\\1)[^\\\\\\n]|\\\\.){4,140})\\1/g)) {\n const str = x[2];\n const head = str.trim().split(/\\s+/).slice(0, 4).join(' ');\n if (str.trim().split(/\\s+/).length >= 2 && SUCCESS.test(head) && !NEG.test(str)) { m = [x[0]]; break; }\n }\n if (!m) continue;\n if (/\\bfetch\\s*\\(|XMLHttpRequest|navigator\\.sendBeacon|\\.submit\\s*\\(\\s*\\)|axios|\\$\\.ajax/.test(js)) continue;\n report(70, 'WARN', doc.file, doc.lineOf ? doc.lineOf(doc.src.indexOf(m[0]) >= 0 ? doc.src.indexOf(m[0]) : 0) : 1, `a script prints success (\"${trunc(m[0].replace(/[\"'`]/g, ''), 30)}\") with no request behind it`, 'No success state until the form posts somewhere; an empty data-endpoint and a handoff note');\n }\n}\n\nfunction check47Placeholders(ctx) {\n for (const doc of ctx.docs) {\n const m = PLACEHOLDER_IMG.exec(doc.src);\n if (m) report('47c', 'WARN', doc.file, doc.lineOf(m.index), `placeholder image service ${m[0]}`, 'A labelled data-slot in the theme colours, or a real asset');\n }\n for (const c of ctx.cssFiles) {\n const m = PLACEHOLDER_IMG.exec(c.raw);\n if (m) report('47c', 'WARN', c.file, c.raw.slice(0, m.index).split('\\n').length, `placeholder image service ${m[0]}`, 'A labelled data-slot in the theme colours, or a real asset');\n }\n}\n\nconst TAILWIND_HEX = /#(0f172a|1e293b|6366f1|8b5cf6|3b82f6|f8fafc|64748b)\\b/i;\nfunction check65Tailwind(ctx) {\n for (const r of ctx.rules) {\n if (!isTokenRule(r) || r.selector === '@font-face') continue;\n for (const d of r.decls) {\n const m = TAILWIND_HEX.exec(d.value);\n if (m) report(74, 'WARN', r.file, d.line, `default Tailwind palette ${m[0]} on ${d.prop}`, 'Mix the colour for this page in OKLCH; a stock swatch is the AI look');\n }\n }\n}\n\nfunction splitTop(v) {\n const out = [];\n let depth = 0, cur = '';\n for (const ch of v) {\n if (ch === '(') depth++;\n if (ch === ')') depth--;\n if (ch === ',' && depth === 0) { out.push(cur); cur = ''; } else cur += ch;\n }\n if (cur.trim()) out.push(cur);\n return out;\n}\nconst COLOR_RE = /#[0-9a-fA-F]{3,8}\\b|oklch\\([^)]*\\)|rgba?\\([^)]*\\)|hsla?\\([^)]*\\)|color-mix\\((?:[^()]|\\([^()]*\\))*\\)|\\b(white|black|transparent)\\b/i;\nconst lengthPx = (x) => (x === '0' ? 0 : pxOf(x));\nfunction check29Glow(ctx) {\n const seen = new Set();\n const once = (key, file, line, ev, fix) => { if (seen.has(key)) return; seen.add(key); report(29, 'WARN', file, line, ev, fix); };\n for (const r of mergedDeclsBySelector(ctx)) {\n if (isTokenRule(r)) continue;\n const local = {};\n for (const d of r.decls) if (d.prop.startsWith('--')) local[d.prop] = d.value;\n const bf = r.decls.find((d) => /^(-webkit-)?backdrop-filter$/.test(d.prop) && /blur\\(/i.test(d.value));\n if (bf) {\n const bgD = r.decls.filter((d) => /^background(-color)?$/.test(d.prop)).pop();\n const rv = bgD ? resolveVars(bgD.value, ctx.tokens, local).trim() : '';\n const c = parseColor(rv);\n const mixed = /color-mix\\([^)]*\\b(white|black|--color-(paper|ink))\\b[^)]*\\btransparent\\b/i.test(rv);\n const nearWhite = c && c.r > 0.85 && c.g > 0.85 && c.b > 0.85;\n const nearBlack = c && c.r \u003c 0.15 && c.g \u003c 0.15 && c.b \u003c 0.15;\n if ((c && c.alpha \u003c 0.95 && (nearWhite || nearBlack)) || (!c && mixed)) {\n once('glass:' + r.selector, r.file, bf.line, `glass panel: backdrop-filter blur over a translucent ${nearBlack ? 'black' : 'white'} on ${trunc(r.selector, 36)}`, 'Solid or grained surface; elevation by lightness');\n }\n }\n for (const d of r.decls) {\n if (d.prop !== 'box-shadow') continue;\n for (const part of splitTop(resolveVars(d.value, ctx.tokens, local))) {\n const cm = COLOR_RE.exec(part);\n if (!cm) continue;\n const c = parseColor(cm[0]);\n if (!c || c.alpha \u003c= 0.05) continue;\n const ok = c.okl || srgbToOklch(c);\n if (ok.C \u003c= 0.04) continue;\n const lens = part.replace(cm[0], ' ').replace(/\\binset\\b/, ' ').trim().split(/\\s+/).filter(Boolean).map(lengthPx);\n if (lens.length >= 3 && lens[0] === 0 && lens[1] === 0 && lens[2] != null && lens[2] > 20) {\n once('glow:' + r.selector, r.file, d.line, `chromatic zero-offset glow (blur ${Math.round(lens[2])}px) on ${trunc(r.selector, 36)}`, 'Shadows carry offset and blur in a neutral; colour is not depth');\n break;\n }\n }\n }\n if (/::(before|after)/.test(r.selector) && r.decls.some((d) => d.prop === 'position' && /absolute|fixed/.test(d.value))) {\n const f = r.decls.find((d) => d.prop === 'filter' && /blur\\(\\s*(\\d+(?:\\.\\d+)?)px/i.test(d.value));\n const n = f ? parseFloat(/blur\\(\\s*(\\d+(?:\\.\\d+)?)px/i.exec(f.value)[1]) : 0;\n if (n > 40) once('orb:' + r.selector, r.file, f.line, `blurred orb: filter blur(${n}px) on the positioned ${trunc(r.selector, 36)}`, 'Delete the wash; a surface is paper or ground, not a light show');\n }\n }\n}\n\nfunction check11Lift(ctx) {\n const lifts = new Map();\n for (const r of mergedDeclsBySelector(ctx)) {\n if (!/:hover/.test(r.selector)) continue;\n const t = r.decls.find((d) => (d.prop === 'transform' && /translateY\\(\\s*-\\s*\\d/.test(resolveVars(d.value, ctx.tokens))) || (d.prop === 'translate' && /^0(px)?\\s+-\\d/.test(resolveVars(d.value, ctx.tokens))));\n const sh = r.decls.find((d) => d.prop === 'box-shadow');\n if (t && sh) for (const part of r.selector.split(',')) lifts.set(baseSelector(part), { r, t });\n }\n if (lifts.size \u003c 3) return;\n const third = [...lifts.values()][2];\n report(11, 'WARN', third.r.file, third.t.line, `the hover lift (translateY plus shadow) on ${lifts.size} selectors (${trunc([...lifts.keys()].slice(0, 3).join(', '), 44)})`, 'One signal per element; the same lift on every card is the template');\n}\n\nfunction check15Fade(ctx) {\n const focusProps = new Map();\n for (const r of ctx.rules) {\n if (!/:focus-visible/.test(r.selector)) continue;\n for (const part of r.selector.split(',')) {\n if (!/:focus-visible/.test(part)) continue;\n const base = baseSelector(part).replace(/^\\*$/, '');\n const set = focusProps.get(base) || new Set();\n for (const d of r.decls) {\n if (d.prop === 'box-shadow') set.add('box-shadow');\n if (/^outline/.test(d.prop)) set.add('outline');\n }\n focusProps.set(base, set);\n }\n }\n if (!focusProps.size) return;\n const global = focusProps.get('') || new Set();\n for (const r of ctx.rules) {\n if (r.keyframes || /:focus|:hover|:active/.test(r.selector)) continue;\n for (const d of r.decls) {\n if (d.prop !== 'transition' && d.prop !== 'transition-property') continue;\n for (const prop of ['box-shadow', 'outline']) {\n if (!new RegExp(`(^|[\\\\s,])${prop}(-[a-z]+)?\\\\b`).test(d.value)) continue;\n const own = r.selector.split(',').some((p) => (focusProps.get(baseSelector(p)) || new Set()).has(prop));\n if (global.has(prop) || own) {\n report(15, 'FAIL', r.file, d.line, `${trunc(r.selector, 32)} transitions ${prop} while :focus-visible sets it (the ring fades in)`, 'Rings appear instantly: transition the hover property only, or draw the ring with a property that is not transitioned');\n break;\n }\n }\n }\n }\n}\n\nfunction checkF7Pills(ctx) {\n const CONTROLISH = /btn|button|cta|input|select|textarea|switch|toggle|control|chip|pill|tag|badge|status/i;\n const sels = new Set();\n let where = null;\n for (const r of mergedDeclsBySelector(ctx)) {\n if (isTokenRule(r)) continue;\n const d = r.decls.filter((x) => x.prop === 'border-radius').pop();\n if (!d) continue;\n if (!/^(9999px|999px|50rem)$/.test(resolveVars(d.value, ctx.tokens).trim())) continue;\n for (const part of r.selector.split(',')) {\n const b = baseSelector(part);\n if (!b || CONTROLISH.test(b)) continue;\n sels.add(b);\n where = where || { r, d };\n }\n }\n if (sels.size >= 5) report('F7', 'WARN', where.r.file, where.d.line, `the pill radius on ${sels.size} non-control selectors (${trunc([...sels].slice(0, 4).join(', '), 48)})`, 'Pills are for controls; boxes and media take the card radius');\n}\n\nconst TW_SIZE = { xs: 12, sm: 14, base: 16, lg: 18, xl: 20, '2xl': 24, '3xl': 30, '4xl': 36, '5xl': 48, '6xl': 60, '7xl': 72, '8xl': 96, '9xl': 128 };\nconst TW_TRACK = { tighter: -0.05, tight: -0.025, normal: 0, wide: 0.025, wider: 0.05, widest: 0.1 };\nconst TW_HUE = { red: 25, orange: 47, amber: 70, yellow: 86, lime: 131, green: 150, emerald: 163, teal: 183, cyan: 215, sky: 237, blue: 260, indigo: 277, violet: 293, purple: 304, fuchsia: 322, pink: 354, rose: 16 };\nconst TW_COLOURS = 'slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose';\nconst TW_PAL = new RegExp(`^(?:bg|text|border(?:-[xytrblse])?|ring(?:-offset)?|outline|decoration|divide|from|via|to|fill|stroke|shadow|accent|caret|placeholder|inset-shadow|inset-ring|drop-shadow)-(?:${TW_COLOURS})-(?:50|[1-9]00|950)(?:\\\\/(?:\\\\d{1,3}|\\\\[[^\\\\]]*\\\\]))? hallmark — Skillopedia );\nconst TW_SHADE = new RegExp(`^(${TW_COLOURS})-(?:50|[1-9]00|950)(?:\\\\/\\\\S*)? hallmark — Skillopedia );\nconst TW_ACCENT = /^text-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:500|600|700)(?:\\/\\S*)?$/;\nconst TW_PURPLE = /^(?:purple|violet|fuchsia|indigo)$/;\nconst TW_BLUE = /^(?:blue|sky|cyan|indigo)$/;\nconst TW_PINK = /^(?:pink|rose)$/;\nconst TW_GREY = /^(?:slate|gray|zinc|neutral|stone)$/;\nconst TW_NONFILL = /^bg-(?:none|transparent|inherit|current|fixed|local|scroll|cover|contain|auto|center|top|bottom|left|right|no-repeat|repeat(?:-[\\w-]+)?|(?:left|right|top|bottom)-(?:top|bottom|left|right)|clip-[\\w-]+|origin-[\\w-]+|blend-[\\w-]+|gradient-to-[\\w-]+|(?:linear|radial|conic)(?:-.*)?|size-.*|position-.*|\\[(?:url|length|position|size|image|(?:repeating-)?(?:linear|radial|conic)-gradient)[^\\]]*\\].*)$/;\nconst TW_GLASS = /^bg-(?:white|black|(?:slate|gray|zinc|neutral|stone)-(?:50|100|900|950)|paper|ink|background|foreground)\\/(\\d{1,2}|\\[(?:0?\\.\\d+|\\d{1,2}%)\\])$/;\nconst TW_MONO = /mono|courier|jetbrains|plex mono|geist mono|commit|fira code|space mono|dm mono|roboto mono|source code|silkscreen/i;\nconst TW_LOGO = /logo|brand|wordmark/i;\nconst TW_ROOT = { size: null, em: null, upper: false, caps: false, noLabel: false, code: false, eyeSkip: false, inOl: false, logo: false, logoSelf: false };\nconst TW_PARSED = new Map();\n\nfunction twParse(tok) {\n let p = TW_PARSED.get(tok);\n if (p) return p;\n const variants = [];\n let depth = 0, cur = '';\n for (const ch of tok) {\n if (ch === '[' || ch === '(') depth++;\n else if ((ch === ']' || ch === ')') && depth > 0) depth--;\n if (ch === ':' && depth === 0) { variants.push(cur); cur = ''; } else cur += ch;\n }\n let base = cur;\n if (base.startsWith('!')) base = base.slice(1);\n if (base.endsWith('!')) base = base.slice(0, -1);\n const neg = base.length > 1 && base.startsWith('-');\n if (neg) base = base.slice(1);\n p = { variants: variants.map((v) => v.replace(/^(?:group|peer)-/, '').replace(/\\/[\\w-]+$/, '')), base, neg };\n TW_PARSED.set(tok, p);\n return p;\n}\n\nconst twTokens = (ctx) => ctx.twTok || ctx.tokens;\nconst twValue = (tok, v) => resolveVars(v, tok).trim().replace(/^(-?)\\./, '$10.');\n\nfunction twSize(ctx, b) {\n const tok = twTokens(ctx);\n let m = /^text-(xs|sm|base|lg|[2-9]?xl)(?:\\/\\S+)?$/.exec(b);\n if (m) return TW_SIZE[m[1]];\n m = /^text-(?:\\[(?:length:)?([^\\]]+)\\]|\\((?:length:)?(--[\\w-]+)\\))(?:\\/\\S+)?$/.exec(b);\n let v = m ? (m[1] ? m[1].replace(/_/g, ' ') : `var(${m[2]})`) : null;\n if (!m) {\n m = /^text-([a-z][\\w-]*?)(?:\\/\\S+)?$/.exec(b);\n v = m && tok[`--text-${m[1]}`];\n }\n if (!v) return null;\n const r = twValue(tok, v);\n const cl = /^clamp\\(\\s*([^,]+),/.exec(r);\n return pxOf(cl ? twValue(tok, cl[1]) : r);\n}\n\nfunction twTrack(ctx, b, neg) {\n const tok = twTokens(ctx);\n let m = /^tracking-(tighter|tight|normal|wide|wider|widest)$/.exec(b);\n let v = null;\n if (m) v = `${TW_TRACK[m[1]]}em`;\n else if ((m = /^tracking-(?:\\[([^\\]]+)\\]|\\((--[\\w-]+)\\))$/.exec(b))) v = m[1] ? m[1].replace(/_/g, ' ') : `var(${m[2]})`;\n else if ((m = /^tracking-([a-z][\\w-]*)$/.exec(b)) && tok[`--tracking-${m[1]}`]) v = tok[`--tracking-${m[1]}`];\n if (v == null) return null;\n const r = twValue(tok, v);\n const e = /^(-?\\d*\\.?\\d+)em$/.exec(r);\n const px = e ? null : pxOf(r);\n if (!e && px == null) return null;\n const out = e ? parseFloat(e[1]) : px;\n return e ? { em: neg ? -out : out } : { px: neg ? -out : out };\n}\n\nconst twHue = (c) => {\n if (!c || c.alpha \u003c= 0.05) return null;\n const ok = c.okl || srgbToOklch(c);\n return ok.C > 0.04 ? ok.H : null;\n};\nfunction twStop(ctx, v, tok) {\n if (/^\\[?-?\\d*\\.?\\d+%\\]?$/.test(v)) return null;\n const tokens = twTokens(ctx);\n const pm = TW_SHADE.exec(v);\n if (pm) return { tok, kind: 'palette', pal: pm[1], hue: TW_GREY.test(pm[1]) ? null : TW_HUE[pm[1]] };\n if (/^(?:white|black|transparent|current|inherit)(?:\\/\\S*)?$/.test(v)) return { tok, kind: 'plain', hue: null };\n if (v.startsWith('(')) return { tok, kind: 'token', hue: twHue(parseColor(twValue(tokens, `var${v.replace(/\\/\\S*$/, '').replace(/^\\((?:color:)?/, '(')}`))) };\n if (v.startsWith('[')) {\n const inner = v.slice(1, v.lastIndexOf(']')).replace(/_/g, ' ');\n return { tok, kind: /var\\(/.test(inner) ? 'token' : 'literal', hue: twHue(parseColor(twValue(tokens, inner))) };\n }\n const tv = tokens[`--color-${v.replace(/\\/\\S*$/, '')}`];\n return { tok, kind: 'token', hue: tv ? twHue(parseColor(twValue(tokens, tv))) : null };\n}\n\nfunction twFamily(v) {\n if (/var\\(/.test(v)) return null;\n const named = v.replace(/^family-name:/, '');\n if (named === v && !/^['\"]/.test(v) && /^(?:\\d|weight:|number:)/.test(v)) return null;\n const fam = firstFamily(named.replace(/_/g, ' '));\n return !fam || GENERIC_FAMILIES.has(fam) ? null : fam;\n}\n\nfunction twLiterals(b, tok, f) {\n for (const m of b.matchAll(/\\[([^\\]]+)\\]/g)) {\n const v = m[1].replace(/url\\([^)]*\\)/gi, ' ').replace(/_/g, ' ');\n const fam = /^font-family:(.+)$/.exec(v);\n if (fam && !/var\\(/.test(fam[1]) && !GENERIC_FAMILIES.has(firstFamily(fam[1]))) f.font = f.font || tok;\n for (const c of v.matchAll(TW_COLOR_LIT)) {\n const col = parseColor(c[0]);\n if (col && col.alpha > 0) f.colours.push({ tok, oklch: /^oklch/i.test(c[0]) });\n }\n }\n}\nconst TW_COLOR_LIT = /#[0-9a-fA-F]{3,8}\\b|oklch\\([^)]*\\)|rgba?\\([^)]*\\)|hsla?\\([^)]*\\)/g;\n\nfunction twInfo(ctx, toks, tw) {\n const f = {\n upper: false, normalCase: false, caps: false, size: null, em: null, mono: false, accent: null,\n tAll: null, bounce: null, overshoot: null, hScale: null, hRotate: null, grad: false, stops: [], clip: false, clear: false,\n bblur: null, glass: null, blur: 0, blurTok: null, pos: null, fill: false, shadows: [], shadowCol: null, vh: null,\n oxHidden: null, oxClip: false, colours: [], font: null, pal: [], rounded: false, cols3: false, animate: null,\n transform: false, transition: false, caret: false, scrollbar: false, hero: false, padded: false,\n };\n let sizeV = null;\n const tracks = [];\n for (const tok of toks) {\n if (tok.includes('__')) continue;\n const p = twParse(tok);\n const b = p.base;\n if (!b) continue;\n const plain = !p.variants.length;\n for (const v of p.variants) tw.variants.add(v);\n const hover = p.variants.includes('hover');\n if (TW_PAL.test(b)) f.pal.push(b.replace(/\\/.*$/, ''));\n if (b.includes('[')) twLiterals(b, tok, f);\n if (/hero/i.test(b)) f.hero = true;\n if (b === 'uppercase') { f.upper = true; continue; }\n if (b === 'normal-case') { if (plain) f.normalCase = true; continue; }\n if (b === 'small-caps' || /^\\[font-variant(?:-caps)?:[^\\]]*(?:small|petite)-caps/.test(b) || /^\\[font-feature-settings:[^\\]]*(?:smcp|c2sc)/.test(b)) { f.caps = true; continue; }\n if (b.startsWith('text-')) {\n const px = twSize(ctx, b);\n if (px != null) {\n if (plain) f.size = px; else sizeV = sizeV == null ? px : Math.min(sizeV, px);\n continue;\n }\n if (b === 'text-transparent') { f.clear = true; continue; }\n if (TW_ACCENT.test(b)) f.accent = f.accent || tok;\n continue;\n }\n if (b.startsWith('tracking-')) {\n const r = twTrack(ctx, b, p.neg);\n if (r) tracks.push({ r, plain });\n continue;\n }\n if (b === 'font-mono') { f.mono = true; continue; }\n if (b.startsWith('font-')) {\n const arb = /^font-\\[(.+)\\]$/.exec(b);\n const fam = arb ? twFamily(arb[1]) : null;\n if (fam) { f.font = f.font || tok; if (TW_MONO.test(fam)) f.mono = true; }\n const named = !arb && /^font-([a-z][\\w-]*)$/.exec(b);\n const tv = named && twTokens(ctx)[`--font-${named[1]}`];\n if (tv && /mono|courier|code/i.test(firstFamily(resolveVars(tv, twTokens(ctx))))) f.mono = true;\n continue;\n }\n if (b === 'transition-all' || /^transition-\\[[^\\]]*\\b(?:all|width|height|top|left|right|bottom|inset)\\b/.test(b) || /^\\[transition(?:-property)?:[^\\]]*\\b(?:all|width|height|top|left)\\b/.test(b)) {\n f.tAll = f.tAll || tok; f.transition = true;\n continue;\n }\n if (/^transition(?:-transform)?$/.test(b)) { f.transition = true; if (b !== 'transition') f.transform = true; continue; }\n if (b.startsWith('animate-')) {\n if (b !== 'animate-none') f.animate = f.animate || tok;\n if (b === 'animate-bounce') f.bounce = f.bounce || tok;\n continue;\n }\n const ez = /^ease-\\[cubic-bezier\\(([^)]*)\\)\\]$/.exec(b);\n if (ez) {\n const n = ez[1].split(/[,_\\s]+/).filter(Boolean).map(Number);\n if (n.length === 4 && [n[1], n[3]].some((y) => y > 1.05 || y \u003c -0.05)) f.overshoot = f.overshoot || tok;\n continue;\n }\n if (/^(?:scale|translate|rotate|skew)-/.test(b)) {\n f.transform = true;\n if (hover && /^scale-/.test(b) && !/^scale-(?:[xy]-)?100$/.test(b)) f.hScale = f.hScale || tok;\n if (hover && /^rotate-/.test(b) && b !== 'rotate-0') f.hRotate = f.hRotate || tok;\n continue;\n }\n if (/^bg-(?:gradient-to-|linear-|radial|conic)|^bg-\\[(?:repeating-)?(?:linear|radial|conic)-gradient/.test(b) || /^\\[background(?:-image)?:[^\\]]*gradient\\(/.test(b)) f.grad = true;\n const st = /^(?:from|via|to)-(.+)$/.exec(b);\n if (st) { const s = twStop(ctx, st[1], tok); if (s) f.stops.push(s); continue; }\n if (b === 'bg-clip-text' || /^\\[(?:-webkit-)?background-clip:text\\]$/.test(b)) { f.clip = true; continue; }\n if (b === '[-webkit-text-fill-color:transparent]') { f.clear = true; continue; }\n if (/^backdrop-blur(?:-|$)/.test(b) || /^\\[(?:-webkit-)?backdrop-filter:[^\\]]*blur/.test(b)) { f.bblur = f.bblur || tok; continue; }\n const bl = /^blur-(?:(2xl|3xl)|\\[(\\d*\\.?\\d+)(px|rem)\\])$/.exec(b);\n if (bl) {\n const px = bl[1] ? (bl[1] === '2xl' ? 40 : 64) : parseFloat(bl[2]) * (bl[3] === 'rem' ? 16 : 1);\n if (px > f.blur) { f.blur = px; f.blurTok = tok; }\n continue;\n }\n if (b === 'absolute' || b === 'fixed') { f.pos = f.pos || b; continue; }\n if (b.startsWith('bg-')) {\n const gm = TW_GLASS.exec(b);\n if (gm) {\n const v = gm[1].replace(/^\\[|\\]$/g, '');\n const a = v.endsWith('%') ? parseFloat(v) / 100 : v.includes('.') ? parseFloat(v) : parseFloat(v) / 100;\n if (a \u003c 0.95) f.glass = f.glass || tok;\n }\n if (!TW_NONFILL.test(b)) f.fill = true;\n continue;\n }\n if (b.startsWith('shadow-')) {\n const sm = /^shadow-\\[(.+)\\]$/.exec(b);\n if (sm) f.shadows.push({ v: sm[1], tok });\n else if (!/^shadow-(?:2xs|xs|sm|md|lg|xl|2xl|inner|none)$/.test(b)) f.shadowCol = f.shadowCol || b.slice(7);\n continue;\n }\n if (/^(?:min-)?h-(?:screen|\\[[^\\]]*\\d\\s*vh[^\\]]*\\])$/.test(b)) { f.vh = f.vh || tok; continue; }\n if (b === 'overflow-x-hidden') { f.oxHidden = f.oxHidden || tok; continue; }\n if (b === 'overflow-x-clip' || b === 'overflow-clip') { f.oxClip = true; continue; }\n if (/^rounded(?:-|$)/.test(b)) { if (b !== 'rounded-none') f.rounded = true; continue; }\n if (b === 'grid-cols-3') { f.cols3 = true; continue; }\n if (/^p[xytrblse]?-/.test(b)) { f.padded = true; continue; }\n if (b.startsWith('caret-')) { f.caret = true; continue; }\n if (b.startsWith('scrollbar') || b.startsWith('[scrollbar-')) f.scrollbar = true;\n }\n if (f.size == null) f.size = sizeV;\n let emV = null;\n for (const { r, plain } of tracks) {\n const em = r.em != null ? r.em : r.px / (f.size || 16);\n if (plain) f.em = em; else emV = emV == null ? em : Math.max(emV, em);\n }\n if (f.em == null) f.em = emV;\n return f;\n}\n\nconst TW_CLASS_ATTR = /(?:^|\\s)(class|className|:class|v-bind:class|x-bind:class|class:list)\\s*=\\s*/g;\nfunction twInterp(val) {\n const parts = [];\n let i = 0, seg = '', after = false;\n while (i \u003c val.length) {\n if (val[i] === '{') {\n parts.push(cutGlued(seg, after, true));\n const e = braceEnd(val, i);\n parts.push(clsLits(val.slice(i + 1, e)).join(' '));\n i = e + 1; seg = ''; after = true;\n continue;\n }\n seg += val[i++];\n }\n parts.push(cutGlued(seg, after, false));\n return parts.join(' ').split(/\\s+/).filter(Boolean);\n}\nfunction twTokensOf(doc, t) {\n if (doc.classAt) return doc.classAt.get(t.pos) || [];\n const tag = (doc.raw || doc.src).slice(t.pos, t.end);\n if (!tag.includes('class')) return [];\n const out = [];\n for (const m of tag.matchAll(TW_CLASS_ATTR)) {\n const i = m.index + m[0].length;\n const q = tag[i];\n let val, dyn = m[1] !== 'class' && m[1] !== 'className';\n if (q === '\"' || q === \"'\") { const e = tag.indexOf(q, i + 1); val = tag.slice(i + 1, e \u003c 0 ? tag.length : e); }\n else if (q === '{') { val = tag.slice(i + 1, braceEnd(tag, i)); dyn = true; }\n else val = (/^[^\\s>]*/.exec(tag.slice(i)) || [''])[0];\n if (dyn) out.push(...clsLits(val));\n else if (val.includes('{')) out.push(...twInterp(val));\n else out.push(...val.split(/\\s+/));\n }\n for (const d of tag.matchAll(/(?:^|\\s)class:(?!list\\b)([\\w-]+)/g)) out.push(d[1]);\n return out.filter(Boolean);\n}\n\nfunction twFrame(parent, t, f, toks) {\n const logo = toks.some((x) => TW_LOGO.test(x)) || TW_LOGO.test(getAttr(t.attrs, 'aria-label') || '');\n return {\n size: f && f.size != null ? f.size : parent.size,\n em: f && f.em != null ? f.em : parent.em,\n upper: f ? f.upper || (!f.normalCase && parent.upper) : parent.upper,\n caps: (f && f.caps) || parent.caps,\n noLabel: parent.noLabel || /^(?:svg|pre|code|abbr|kbd|samp)$/.test(t.name),\n code: parent.code || /^(?:pre|code|kbd|samp)$/.test(t.name),\n eyeSkip: parent.eyeSkip || EYEBROW_ANCESTOR_SKIP.test(t.name),\n inOl: parent.inOl || t.name === 'ol',\n logo: parent.logo || (t.name === 'a' && logo),\n logoSelf: logo,\n };\n}\n\nfunction twGroup(ctx) {\n if (ctx._tw) return ctx._tw;\n const tw = { items: [], variants: new Set(), caret: false, scrollbar: false, motion: null, upperDisplay: false };\n ctx._tw = tw;\n const add = (it) => {\n const f = it.info;\n tw.items.push(it);\n if (f.caret) tw.caret = true;\n if (f.scrollbar) tw.scrollbar = true;\n if (!tw.motion && (f.animate || (f.transition && f.transform))) tw.motion = it;\n if (/^h[12]$/.test(it.name) && f.upper) tw.upperDisplay = true;\n };\n for (const doc of ctx.docs) {\n const stack = [];\n for (let i = 0; i \u003c doc.tags.length; i++) {\n const t = doc.tags[i];\n if (t.close) { stack.pop(); continue; }\n const parent = stack.length ? stack[stack.length - 1] : TW_ROOT;\n const toks = twTokensOf(doc, t);\n const info = toks.length ? twInfo(ctx, toks, tw) : null;\n const frame = twFrame(parent, t, info, toks);\n if (info) add({ doc, idx: i, name: t.name, attrs: t.attrs, line: t.line, toks, info, frame });\n if (!t.selfClosing) stack.push(frame);\n }\n for (let c = 0; c \u003c doc.comps.length; c++) {\n const t = doc.comps[c];\n if (t.close) continue;\n const toks = twTokensOf(doc, t);\n if (!toks.length) continue;\n const info = twInfo(ctx, toks, tw);\n add({ doc, idx: -1, comp: c, name: t.name, attrs: t.attrs, line: t.line, toks, info, frame: twFrame(TW_ROOT, t, info, toks) });\n }\n for (const v of doc.virtuals || []) {\n const info = twInfo(ctx, v.toks, tw);\n add({ doc, idx: -1, virtual: true, name: v.name, attrs: '', line: doc.lineOf(v.pos), toks: v.toks, info, frame: twFrame(TW_ROOT, { name: v.name, attrs: '' }, info, v.toks) });\n }\n }\n return tw;\n}\n\nfunction twWhere(it) {\n return it.virtual ? `${it.name}() class string` : `\u003c${it.name} class=\"${trunc(it.toks.join(' '), 32)}\">`;\n}\n\nfunction compInner(doc, c) {\n const open = doc.comps[c];\n if (open.selfClosing) return '';\n let depth = 1;\n for (let i = c + 1; i \u003c doc.comps.length; i++) {\n const t = doc.comps[i];\n if (t.name !== open.name || t.selfClosing) continue;\n if (!t.close) depth++;\n else if (--depth === 0) return doc.src.slice(open.end, t.pos);\n }\n return '';\n}\n\nfunction twPlain(doc, html) {\n let s = html;\n if (doc.kind !== 'html' && doc.kind !== 'jsx') for (let k = 0; k \u003c 3 && /\\{[^{}]*\\}/.test(s); k++) s = s.replace(/\\{[^{}]*\\}/g, ' ');\n return plainText(s);\n}\n\nfunction twText(it) {\n if (it.text == null) {\n let inner = '';\n if (it.idx >= 0) inner = innerOf(it.doc, it.idx)?.inner || '';\n else if (it.comp != null) inner = compInner(it.doc, it.comp);\n it.text = twPlain(it.doc, inner);\n }\n return it.text;\n}\n\nfunction closeIndex(doc, idx) {\n if (doc.tags[idx].selfClosing) return idx;\n let depth = 1;\n for (let i = idx + 1; i \u003c doc.tags.length; i++) {\n const t = doc.tags[i];\n if (t.selfClosing) continue;\n if (!t.close) depth++;\n else if (--depth === 0) return i;\n }\n return doc.tags.length - 1;\n}\n\nfunction twGradient(ctx, f) {\n if (!f.stops.length || (!f.grad && f.stops.length \u003c 2)) return null;\n const names = f.stops.filter((s) => s.pal).map((s) => s.pal);\n let banned = null;\n for (const a of names) {\n if (!TW_PURPLE.test(a)) continue;\n if (names.some((x) => x !== a && TW_BLUE.test(x))) { banned = 'purple-to-blue'; break; }\n if (names.some((x) => x !== a && TW_PINK.test(x))) { banned = 'purple-to-pink'; break; }\n }\n if (!banned) banned = bannedGradient(f.stops.map((s) => s.hue).filter((h) => h != null), { tokens: twTokens(ctx) });\n return { banned, text: f.clip && f.clear, list: f.stops.map((s) => s.tok).join(' ') };\n}\n\nfunction twGlow(ctx, f) {\n const tokens = twTokens(ctx);\n for (const s of f.shadows) {\n for (const part of splitTop(resolveVars(s.v.replace(/_/g, ' '), tokens))) {\n const cm = COLOR_RE.exec(part);\n const lens = (cm ? part.replace(cm[0], ' ') : part).replace(/\\binset\\b/, ' ').trim().split(/\\s+/).filter(Boolean).map(lengthPx);\n if (lens.length \u003c 3 || lens[0] !== 0 || lens[1] !== 0 || lens[2] == null || lens[2] \u003c 8) continue;\n let chroma = 0;\n if (cm) {\n const c = parseColor(cm[0]);\n if (c && c.alpha > 0.05) chroma = (c.okl || srgbToOklch(c)).C;\n } else if (f.shadowCol) {\n const name = f.shadowCol.replace(/\\/\\S*$/, '');\n const pm = TW_SHADE.exec(name);\n const tv = tokens[`--color-${name}`];\n chroma = pm ? (TW_GREY.test(pm[1]) ? 0 : 0.2) : tv ? (twHue(parseColor(resolveVars(tv, tokens).trim())) != null ? 0.2 : 0) : 0;\n }\n if (chroma > 0.04) return `glow: ${s.tok}${cm ? '' : ` with shadow-${f.shadowCol}`}`;\n }\n }\n return null;\n}\n\nfunction twDataText(s) {\n const t = s.trim();\n const letters = (t.match(/[A-Za-z]/g) || []).length;\n const digits = (t.match(/\\d/g) || []).length;\n if (digits >= letters) return true;\n if (/^v?\\d+(?:\\.\\d+)+/.test(t) || /[⌘⌥⇧⌃⎋↵]|^(?:ctrl|cmd|alt|shift|option)\\s*[+-]/i.test(t)) return true;\n if (/[{}();=\u003c>]|^\\$\\s|^(?:npm|npx|pnpm|yarn|bun|git|curl|cd|ls|brew|pip|docker|node) |^--?[a-z]|[\\w-]+\\.[a-z][a-z0-9]{0,3}$|^[#.]?[a-z]+(?:[-_][a-z0-9]+)+$|^[a-z]+[A-Z]\\w*$|^\\/[\\w/.-]*$|^https?:/.test(t)) return true;\n return digits > 0 && /^(?:\\d{1,2}:\\d{2}|(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\\.?\\s+\\d)/i.test(t);\n}\n\nfunction tw67(it, tw) {\n const f = it.info, fr = it.frame;\n if (!(f.upper || f.caps || (f.em != null && f.em > 0.03))) return null;\n if (fr.logo || fr.logoSelf || fr.noLabel) return null;\n const size = fr.size, em = fr.em;\n if ((size != null && size >= 24) || (size == null && /^h[1-6]$/.test(it.name))) return null;\n const caps = fr.upper || fr.caps;\n const small = size != null && size \u003c= 15;\n const tracked = em != null && em > 0.03;\n const control = /^(?:button|input)$/.test(it.name) || /(?:button|btn|cta)$/i.test(it.name) || (it.name === 'a' && f.fill && f.padded) || /role\\s*=\\s*[\"']?button/i.test(it.attrs);\n if (tw.upperDisplay && control && (em == null || em \u003c= 0.04)) return null;\n if (!((caps && (small || tracked)) || (tracked && small))) return null;\n return { what: fr.caps ? 'small caps' : fr.upper ? 'uppercase' : 'letter-spaced', size, em: tracked ? Math.round(em * 1000) / 1000 : null };\n}\n\nfunction tw54(it) {\n const f = it.info, fr = it.frame, doc = it.doc;\n if (it.idx \u003c 0 || !/^(?:p|span|div|small)$/.test(it.name) || fr.eyeSkip) return null;\n if (!(f.upper || (f.em != null && f.em > 0.03) || f.accent)) return null;\n if (fr.size == null || fr.size > 14) return null;\n const t = doc.tags[it.idx];\n if (/role\\s*=\\s*[\"']?status|aria-live|aria-current|aria-hidden\\s*=\\s*[\"']?true/i.test(t.attrs)) return null;\n const c = closeIndex(doc, it.idx);\n const nx = doc.tags[c + 1];\n if (!nx || nx.close || !/^h[1-3]$/.test(nx.name)) return null;\n const inner = c > it.idx ? doc.src.slice(t.end, doc.tags[c].pos) : '';\n if (EYEBROW_BLOCKY.test(inner) || /\u003cbr\\b|\u003ctime\\b/i.test(inner)) return null;\n const href = /\u003ca\\b[^>]*\\shref\\s*=\\s*[\"']([^\"']*)[\"']/i.exec(inner);\n if (href && !/^#?$/.test(href[1].trim())) return null;\n const text = twPlain(doc, inner);\n const signal =[f.upper ? 'uppercase' : null, f.em != null && f.em > 0.03 ? `tracked ${Math.round(f.em * 1000) / 1000}em` : null, f.accent].filter(Boolean).join(', ');\n if (!text) return doc.kind !== 'html' && c > it.idx && (doc.raw || '').slice(t.end, doc.tags[c].pos).includes('{') ? { text: '', next: nx.name, signal } : null;\n const words = text.split(/[\\s·/|\\u2014\\u2013]+/).filter(Boolean);\n if (words.length > 7 || text.length > 56 || /[\\w/-]+\\.[a-z]{1,4}$/i.test(text)) return null;\n const stripped = text.replace(/^\\s*(?:no\\.?\\s*)?\\d{1,3}(?:\\s*[·./\\u2014\\u2013-]\\s*|\\s+|$)/i, '').trim();\n if ((stripped.match(/\\d/g) || []).length >= 2) return null;\n if (fr.inOl && stripped && !EYEBROW_ANNOUNCE.test(stripped)) return null;\n return { text, next: nx.name, signal };\n}\n\nfunction twTiles(it, byIdx) {\n const doc = it.doc;\n let tiles = 0;\n for (const k of directChildren(doc, it.idx)) {\n if (!/\u003ch3\\b/i.test(k.html)) continue;\n const end = closeIndex(doc, k.idx);\n for (let j = k.idx + 1; j \u003c end; j++) {\n const t = doc.tags[j];\n if (t.close) continue;\n if (/^h[1-6]$/.test(t.name)) break;\n const ti = byIdx.get(j);\n if (!ti || !ti.info.rounded || !ti.info.fill) continue;\n const inner = innerOf(doc, j);\n if (inner && /\u003csvg\\b|\u003c[A-Z][\\w.]*Icon\\b|\u003cIcon\\b|\u003ci\\b[^>]*class/.test(inner.inner) && !twPlain(doc, inner.inner)) { tiles++; break; }\n }\n }\n return tiles;\n}\n\nfunction checkTailwind(ctx) {\n const tw = twGroup(ctx);\n if (!tw.items.length) return;\n const had = (gate, doc, line) => findings.some((x) => x.gate === gate && x.file === rel(doc.file) && x.line === line);\n const monoOk = monoIsMaterial({ tokens: twTokens(ctx) });\n const byDoc = new Map();\n for (const it of tw.items) {\n if (!byDoc.has(it.doc)) byDoc.set(it.doc, []);\n byDoc.get(it.doc).push(it);\n const { doc, info: f } = it;\n const at = (gate, grade, ev, fix) => report(gate, grade, doc.file, it.line, ev, fix);\n const where = twWhere(it);\n if (f.tAll) at('10a', 'FAIL', `${f.tAll} on ${where}`, 'Name the properties: transition-colors, transition-opacity or transition-transform');\n if (f.bounce || f.overshoot) at(12, 'FAIL', `${f.bounce ? 'bounce' : 'overshoot easing'} ${f.bounce || f.overshoot} on ${where}`, 'Exponential ease-out on UI state; no bounce or overshoot');\n const g = twGradient(ctx, f);\n if (g && g.banned) at('2a', 'FAIL', `${g.banned} gradient${g.text ? ' text' : ''} (${g.list}) on ${where}`, 'One anchor hue, solid ink');\n else if (g && g.text) at('2b', 'FAIL', `gradient text (${g.list}) on ${where}`, 'Solid ink; weight or a display face. Guard: a single-hue ramp from two tokens with 40 and 41 passing');\n const g29 = f.bblur && f.glass ? `glass: ${f.bblur} over ${f.glass}`\n : f.blur >= 40 && f.pos && (f.fill || f.grad || f.stops.length) ? `blurred orb: ${f.blurTok} on a filled ${f.pos} element`\n : twGlow(ctx, f);\n if (g29) at(29, 'WARN', `${g29} on ${where}`, 'Solid or grained surface; elevation by lightness, shadows with offset and blur');\n if (f.colours.length || f.font) {\n const what = [f.colours.length ? `colour literal ${f.colours[0].tok}` : null, f.font ? `font-family literal ${f.font}` : null].filter(Boolean).join(' and ');\n at(48, f.font || f.colours.some((c) => !c.oklch) ? 'FAIL' : 'WARN', `${what} in a class on ${where}`, 'Lift the value into the token block (@theme or :root) and use its utility name');\n }\n if ((f.hScale || f.hRotate) && /^(?:img|picture|image)$/i.test(it.name)) at(11, 'WARN', `${f.hScale || f.hRotate} on ${where}: the image moves on hover`, 'Imagery sits still; give the container the feedback');\n const t67 = tw67(it, tw);\n if (t67 && !had('67', doc, it.line)) {\n const text = it.virtual ? '' : twText(it);\n at(67, 'FAIL', `${text ? `\"${trunc(text, 24)}\" ` : ''}${t67.what}${t67.size != null ? ` at ${t67.size}px` : ''}${t67.em != null ? ` tracked ${t67.em}em` : ''} on ${where}`, 'Sentence case in the body face, a step smaller, untracked: drop uppercase and the tracking-* class');\n }\n const e54 = tw54(it);\n if (e54 && !had('54', doc, it.line)) {\n at(54, 'FAIL', `eyebrow ${e54.text ? `\"${trunc(e54.text, 24)}\" ` : ''}before \u003c${e54.next}> (${e54.signal} at ${it.frame.size}px)`, 'Cut it; open the section another way (craft.md, Opening a section)');\n }\n if (!monoOk && f.mono && !it.virtual && !it.frame.code && !/^(?:code|pre|kbd|samp|time|var)$/.test(it.name) && it.frame.size != null && it.frame.size \u003c= 14) {\n const text = twText(it);\n if (/[A-Za-z]/.test(text) && !twDataText(text)) at(64, 'FAIL', `font-mono on \"${trunc(text, 24)}\" at ${it.frame.size}px (${where})`, 'Labels in the body face at a smaller size and normal case; font-mono only for code and figures');\n }\n }\n for (const [doc, list] of byDoc) {\n const pal = new Map();\n let palAt = null;\n for (const it of list) for (const p of it.info.pal) if (!pal.has(p)) { pal.set(p, it); if (pal.size === 5) palAt = it; }\n if (palAt) report(74, 'WARN', doc.file, palAt.line, `default palette instead of theme tokens: ${pal.size} distinct palette classes (${[...pal.keys()].slice(0, 4).join(' ')})`, 'Extend the Tailwind theme with the Hallmark tokens and use those names');\n const scaled = list.filter((it) => it.info.hScale);\n if (scaled.length >= 3) report(11, 'WARN', doc.file, scaled[2].line, `hover scale on ${scaled.length} elements in this file (${scaled.slice(0, 3).map((it) => it.info.hScale).join(', ')})`, 'One hover signal per element; not every card grows');\n const first = new Set(['section', 'header'].map((n) => doc.tags.findIndex((t) => !t.close && t.name === n)).filter((x) => x >= 0));\n const byIdx = new Map();\n for (const it of list) if (it.idx >= 0) byIdx.set(it.idx, it);\n for (const it of list) {\n if (it.info.vh && !it.virtual && (first.has(it.idx) || it.info.hero)) report('6a', 'FAIL', doc.file, it.line, `hero sized in vh: ${it.info.vh} on ${twWhere(it)}`, 'min-h-dvh or min-h-svh');\n if (it.info.cols3 && it.idx >= 0) {\n const n = twTiles(it, byIdx);\n if (n >= 3) report(3, 'WARN', doc.file, it.line, `icon-tile row: ${n} children of ${twWhere(it)} open on an icon in a rounded filled tile above an h3`, 'Vary column widths or drop the icon tiles');\n }\n }\n }\n}\n\nconst EMOJI_ICON = /(?![©®™‼⁉←-⇿])\\p{Extended_Pictographic}/u;\nfunction checkComponentText(ctx) {\n for (const doc of ctx.docs) {\n for (let c = 0; c \u003c doc.comps.length; c++) {\n const t = doc.comps[c];\n if (t.close || t.selfClosing) continue;\n const inner = compInner(doc, c);\n if (!inner || /\u003c(?:p|div|li|section|article|ul|ol|table)\\b/i.test(inner)) continue;\n const text = twPlain(doc, inner);\n if (!text) continue;\n if (/(?:link|button|btn|cta)$/i.test(t.name) && /(→|->|›|»|⟶)\\s*$/.test(text)) report(63, 'FAIL', doc.file, t.line, `arrow glued to the label \"${trunc(text, 40)}\" (\u003c${t.name}>)`, 'A verb as the label; the link already says it goes somewhere');\n if ((text.match(/\\s[·•]\\s/g) || []).length >= 2) report(63, 'FAIL', doc.file, t.line, `middle-dot meta string \"${trunc(text, 50)}\"`, 'Line breaks, columns or hairlines; one dot in a byline at most');\n if (text.length \u003c= 4 && EMOJI_ICON.test(text)) report('30a', 'FAIL', doc.file, t.line, `emoji icon \"${text}\" in \u003c${t.name}>`, 'One icon library or custom SVG');\n }\n }\n}\n\nfunction checkF21(ctx) {\n for (const doc of ctx.docs) {\n if (doc.kind !== 'html') continue;\n let prev = 0;\n for (const m of doc.src.matchAll(/\u003ch([1-6])\\b[^>]*>([\\s\\S]*?)\u003c\\/h\\1>/gi)) {\n const level = Number(m[1]);\n if (prev && level > prev + 1) {\n report('F21', 'WARN', doc.file, doc.lineOf(m.index), `h${prev} is followed by h${level} (\"${trunc(m[2].replace(/\u003c[^>]+>/g, ' ').replace(/\\s+/g, ' ').trim(), 32)}\")`, 'Step heading levels one at a time; size a head with CSS, never with its level');\n break;\n }\n prev = level;\n }\n }\n}\n\nfunction checkF22(ctx) {\n for (const r of ctx.rules) {\n const j = r.decls.find((d) => d.prop === 'text-align' && /^justify\\b/.test(d.value.trim()));\n if (j && !r.decls.some((d) => d.prop === 'hyphens' && /auto/.test(d.value))) { report('F22', 'WARN', r.file, j.line, `justified text without hyphenation on ${r.selector}`, 'Set it left, or add hyphens: auto'); return; }\n }\n for (const doc of ctx.docs) {\n const m = /class(?:Name)?\\s*=\\s*[\"'][^\"']*(?\u003c![\\w-])text-justify(?![\\w-])/.exec(doc.src);\n if (m && !/(?\u003c![\\w-])hyphens-auto(?![\\w-])/.test(doc.src)) { report('F22', 'WARN', doc.file, doc.lineOf(m.index), 'justified text without hyphenation (text-justify)', 'Set it left, or add hyphens-auto'); return; }\n }\n}\n\nfunction checkF23(ctx) {\n for (const doc of ctx.docs) {\n if (doc.kind !== 'html' || !/\u003chtml\\b/i.test(doc.src)) continue;\n const missing = [];\n const html = /\u003chtml\\b([^>]*)>/i.exec(doc.src);\n if (!html || !getAttr(html[1], 'lang')) missing.push('lang on \u003chtml>');\n if (!/\u003ctitle\\b[^>]*>\\s*[^\u003c\\s][^\u003c]*\u003c\\/title>/i.test(doc.src)) missing.push('a \u003ctitle>');\n if (!/\u003cmeta\\b[^>]*\\bname\\s*=\\s*[\"']description[\"'][^>]*>/i.test(doc.src)) missing.push('a meta description');\n if (missing.length) report('F23', 'WARN', doc.file, 1, `the document lacks ${missing.join(', ')}`, 'Name the language, title the page in its own words, and describe it in one sentence for link previews');\n }\n}\n\nfunction checkF24(ctx) {\n const wide = (value) => resolveVars(value, ctx.tokens).split(/,(?![^(]*\\))/).some((layer) => {\n if (/\\binset\\b/.test(layer)) return false;\n const len = [...layer.replace(/(?:oklch|oklab|rgba?|hsla?|color|color-mix)\\([^)]*\\)/g, '').matchAll(/(-?\\d*\\.?\\d+)(px|rem|em)?(?=\\s|$)/g)].map((m) => parseFloat(m[1]) * (m[2] === 'rem' || m[2] === 'em' ? 16 : 1));\n return len.length >= 3 && len[2] + Math.min(0, len[3] || 0) >= 24;\n });\n for (const r of ctx.rules) {\n const sh = r.decls.find((d) => d.prop === 'box-shadow' && d.value.trim() !== 'none');\n if (!sh || !wide(sh.value)) continue;\n const edge = r.decls.find((d) => /^border(?:-(?:top|right|bottom|left|inline|block)(?:-(?:start|end))?)?$/.test(d.prop) && /(?:^|\\s)(?:1px|0\\.5px|thin|var\\(--rule-hair\\))(?:\\s|$)/.test(d.value) && !/\\b(?:none|transparent)\\b/.test(d.value));\n if (edge) { report('F24', 'WARN', r.file, sh.line, `a hairline border under a wide soft shadow on ${r.selector}`, 'Commit to one: a defined edge, or elevation by shadow'); return; }\n }\n for (const doc of ctx.docs) {\n const m = /class(?:Name)?\\s*=\\s*[\"'][^\"']*(?\u003c![\\w:-])border(?![\\w-])[^\"']*(?\u003c![\\w:-])shadow-(?:xl|2xl)(?![\\w-])|class(?:Name)?\\s*=\\s*[\"'][^\"']*(?\u003c![\\w:-])shadow-(?:xl|2xl)(?![\\w-])[^\"']*(?\u003c![\\w:-])border(?![\\w-])/.exec(doc.raw || doc.src);\n if (m) { report('F24', 'WARN', doc.file, doc.lineOf ? doc.lineOf(m.index) : 1, 'a hairline border under a wide soft shadow (border with shadow-xl or shadow-2xl)', 'Commit to one: a defined edge, or elevation by shadow'); return; }\n }\n}\n\nlet LIBRARY = null;\nfunction library() {\n if (LIBRARY) return LIBRARY;\n LIBRARY = new Map();\n try { for (const p of JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'photos.json'), 'utf8')).photos) { LIBRARY.set(p.file.split('/').pop(), p); LIBRARY.set(savedName(p.id), p); } } catch { /* no library */ }\n return LIBRARY;\n}\n// theme.mjs photos --save names a file after its title; match that name back to the library entry.\nconst savedName = (id) => id.replace(/\\.[a-z]+$/i, '').normalize('NFKD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || `photo-${createHash('sha1').update(id).digest('hex').slice(0, 10)}`;\nconst SAVED = /([a-z0-9-]+)-(?:960|1280|1920)\\.jpg/g;\nconst COMMONS = /upload\\.wikimedia\\.org\\/wikipedia\\/commons\\/(?:thumb\\/)?[0-9a-f]\\/[0-9a-f]{2}\\/([^/\"'\\s)?]+)/g;\nconst STOCK = /(?:images|plus)\\.unsplash\\.com\\/(premium_photo|photo)-[\\w-]+|images\\.pexels\\.com\\/photos\\/\\d+|cdn\\.pixabay\\.com\\/photo\\//g;\nfunction check76(ctx) {\n const lib = library();\n const text = ctx.docs.filter((d) => d.kind === 'html').map((d) => d.src).join('\\n');\n const credited = (p) => /^(CC0|Public domain|PD)/i.test(p.license) || text.includes(p.page) || text.includes(p.page.replace(/^https:/, '')) || text.includes(p.author);\n const seen = new Set();\n for (const doc of ctx.docs) {\n let stock = 0, plus = 0;\n for (const m of doc.src.matchAll(STOCK)) {\n if (m[1] === 'premium_photo') { if (!plus++) report('76', 'FAIL', doc.file, doc.lineOf(m.index), 'an Unsplash+ photo, which needs a paid licence', 'Use a library photograph (theme.mjs photos) or the user\\'s own'); }\n else if (!stock++) report('76', 'WARN', doc.file, doc.lineOf(m.index), 'a photo hotlinked from a stock site, whose terms bar images picked by an AI tool', 'Use a library photograph (theme.mjs photos) or the user\\'s own; dismiss only if the user supplied this one');\n }\n for (const m of doc.src.matchAll(COMMONS)) {\n const name = m[1].replace(/^\\d+px-/, '');\n if (seen.has(name)) continue;\n seen.add(name);\n const p = lib.get(name);\n if (!p) { if (lib.size) report('76', 'WARN', doc.file, doc.lineOf(m.index), `a Commons file that is not in the library (${trunc(decodeURIComponent(name), 40)})`, 'Pick photographs with theme.mjs photos, or check its licence and credit it by hand'); }\n else if (!credited(p)) report('76', 'FAIL', doc.file, doc.lineOf(m.index), `a library photo with no credit on the page (${trunc(p.author, 30)}, ${p.license})`, 'Add its printed credit to the footer\\'s Photographs line (imagery.md § 2)');\n }\n for (const m of doc.src.matchAll(SAVED)) {\n const p = lib.get(m[1]);\n if (!p || seen.has(m[1])) continue;\n seen.add(m[1]);\n if (!credited(p)) report('76', 'FAIL', doc.file, doc.lineOf(m.index), `a library photo with no credit on the page (${trunc(p.author, 30)}, ${p.license})`, 'Add its printed credit to the footer\\'s Photographs line (imagery.md § 2)');\n }\n if (doc.kind !== 'html') continue;\n for (const m of doc.src.matchAll(/\u003cimg\\b([^>]*)>/gi)) {\n const src = getAttr(m[1], 'src');\n if ((src == null || !src.trim() || src.trim() === '#') && !getAttr(m[1], 'srcset') && !hasAttr(m[1], 'data-src')) { report('76', 'FAIL', doc.file, doc.lineOf(m.index), 'an \u003cimg> with no source', 'Give it the real image, make it a data-slot placeholder, or remove it'); break; }\n }\n }\n}\n\nconst CHECKS = [check1, check2, check3, check3Stripe, check4, check6a, check7, check10, check10Motion, check11,\n check12, check15, check17, check18, check19, check20, check22, check23, check24, check24a,\n check25, check26, check27, check28, check30, check33, check34, check37, check38a,\n check39, check40_41, check42, check42Footer, check46, check47, check48, check49,\n check50, check50Wrap, check53, check54, check55, check56, checkCopyTell, check62, check63, check64, check65, check67, check38c,\n check19Tells, check58Static, check68, check69, check70, check42TwoButton, check47Placeholders, check65Tailwind, check29Glow, check11Lift, check15Fade,\n checkF1, checkF2, checkF3, checkF4, checkF5, checkF6, checkF7, checkF7Pills, checkF8, checkF9, checkF15, checkF16, checkF21, checkF22, checkF23, checkF24, check76,\n checkTailwind, checkComponentText];\n\nconst files = opts.paths.length ? collectFiles(opts.paths) : [];\nif (!files.length && !opts.urls.length) { console.error('sloplint: no .html, .css, .jsx, .tsx, .vue, .svelte or .astro files found'); process.exit(2); }\n\nconst groups = new Map();\nfor (const f of files) {\n const dir = dirname(f);\n if (!groups.has(dir)) groups.set(dir, { css: [], html: [] });\n const g = groups.get(dir);\n if (extname(f).toLowerCase() === '.css') g.css.push(f);\n else g.html.push(f);\n}\n\nconst localRef = (href) => href && !/^(?:[a-z]+:|\\/\\/)/i.test(href) ? href.split(/[?#]/)[0] : null;\nfunction importsOf(css, from) {\n const out = [];\n for (const m of stripComments(css).matchAll(/@import\\s+(?:url\\(\\s*)?[\"']?([^\"')\\s;]+)[\"']?\\s*\\)?/gi)) {\n const ref = localRef(m[1]);\n if (ref) out.push(resolve(from, ref));\n }\n return out;\n}\nfunction linksOf(doc, from) {\n const out = [];\n for (const t of doc.tags) {\n if (t.close || t.name !== 'link' || !/stylesheet/i.test(getAttr(t.attrs, 'rel') || '')) continue;\n const ref = localRef(getAttr(t.attrs, 'href'));\n if (ref) out.push(resolve(from, ref));\n }\n return out;\n}\n\nconst projectRoots = new Map();\nfunction projectRoot(dir) {\n if (projectRoots.has(dir)) return projectRoots.get(dir);\n const up = dirname(dir);\n const root = existsSync(join(dir, 'package.json')) ? dir : up === dir ? null : projectRoot(up);\n projectRoots.set(dir, root);\n return root;\n}\nfunction shareProjects(contexts) {\n const byRoot = new Map();\n for (const [dir, ctx] of contexts) {\n const root = projectRoot(dir);\n if (!root) continue;\n if (!byRoot.has(root)) byRoot.set(root, []);\n byRoot.get(root).push(ctx);\n }\n for (const list of byRoot.values()) {\n const parts = list.filter((c) => !c.cssFiles.length && c.docs.length && c.docs.every((d) => d.kind !== 'html'));\n if (!parts.length || parts.length === list.length) continue;\n const tokens = {};\n const shared = { focus: false, active: false, disabled: false, prm: false, once: new Set() };\n for (const c of list) {\n Object.assign(tokens, c.tokens);\n const tv = twGroup(c).variants;\n const sel = (re) => c.rules.some((r) => re.test(r.selector));\n shared.focus = shared.focus || sel(/:focus/) || [...tv].some((v) => /^focus/.test(v));\n shared.active = shared.active || sel(/:active/) || tv.has('active');\n shared.disabled = shared.disabled || sel(/:disabled|\\[disabled\\]|aria-disabled/) || tv.has('disabled') || tv.has('aria-disabled');\n shared.prm = shared.prm || /prefers-reduced-motion/i.test(stripComments(c.allCss)) || tv.has('motion-reduce') || tv.has('motion-safe');\n }\n for (const c of list) c.shared = shared;\n const stamped = list.find((c) => !parts.includes(c) && /Hallmark\\s*·/.test(c.stampText));\n for (const c of parts) {\n if (stamped && !/Hallmark\\s*·/.test(c.stampText)) c.stampText = `${c.stampText}\\n${stamped.stampText}`;\n c.twTok = { ...tokens, ...c.tokens };\n c._tw = null;\n }\n }\n}\n\nconst waiversByKey = new Map();\nconst postureByFile = {};\nconst heroPostureByFile = {};\nconst photoShapeByFile = {};\nconst jobByFile = {};\nconst contexts = [];\nconst parsedDocs = new Map();\nconst docOf = (f) => {\n if (parsedDocs.has(f)) return parsedDocs.get(f);\n let doc = null;\n try { doc = parseDoc(f, readFileSync(f, 'utf8')); } catch (e) { console.error(`sloplint: skipped ${rel(f)}, it could not be parsed (${e.message})`); }\n parsedDocs.set(f, doc);\n return doc;\n};\nfunction buildContext(key, cssList, docList) {\n const cssFiles = [];\n const rules = [];\n const docs = [];\n const inGroup = new Set([...cssList, ...docList.map((d) => d.file)]);\n const pending = [];\n for (const f of cssList) {\n const raw = readFileSync(f, 'utf8');\n cssFiles.push({ file: f, raw });\n rules.push(...parseCss(raw, f));\n pending.push(...importsOf(raw, dirname(f)));\n }\n for (const doc of docList) {\n docs.push(doc);\n for (const st of doc.styles) {\n rules.push(...parseCss(st.css, doc.file, st.lineOffset));\n pending.push(...importsOf(st.css, dirname(doc.file)));\n }\n pending.push(...linksOf(doc, dirname(doc.file)));\n }\n const reached = new Set();\n while (pending.length) {\n const p = pending.shift();\n if (inGroup.has(p) || extname(p).toLowerCase() !== '.css' || !existsSync(p)) continue;\n inGroup.add(p);\n reached.add(p);\n let raw;\n try { raw = readFileSync(p, 'utf8'); } catch { continue; }\n cssFiles.push({ file: p, raw, attached: true });\n rules.push(...parseCss(raw, p));\n pending.push(...importsOf(raw, dirname(p)));\n }\n const head40 = (t) => t.split('\\n').slice(0, 40).join('\\n');\n const stampText = [\n ...cssFiles.map((c) => head40(c.raw)),\n ...docs.flatMap((d) => d.styles.map((st) => head40(st.css))),\n ].join('\\n');\n const posture = (/posture:\\s*(restrained|committed|full-palette|drenched)/i.exec(stampText)?.[1] || 'restrained').toLowerCase();\n // a custom page designs its own hero: 44a and 44c read it as free\n const heroPosture = /theme:\\s*custom\\b/i.test(stampText) ? 'custom' : (/hero:\\s*(settled|banner|poster|ledge|corridor|stage|counter|index)\\b/i.exec(stampText)?.[1] || '').toLowerCase();\n const photoShape = /shape:\\s*(photographic|portfolio)/i.test(stampText);\n const job = ((/\\bjob:\\s*([a-z][a-z /-]*)/i.exec(stampText) || [])[1] || '').trim().toLowerCase();\n for (const d of docs) { postureByFile[d.file] = posture; heroPostureByFile[d.file] = heroPosture; photoShapeByFile[d.file] = photoShape; jobByFile[d.file] = job; }\n const allCss = cssFiles.map((c) => c.raw).join('\\n') + '\\n'\n + docs.map((d) => d.styles.map((st) => st.css).join('\\n')).join('\\n');\n const tokensOnly = !docs.length && rules.length > 0 && rules.every(isTokenRule);\n contexts.push([key, { rules, tokens: buildTokens(rules), docs, cssFiles, genre: opts.genre, stampText, allCss, posture, tokensOnly }]);\n return reached;\n}\nfor (const [dir, g] of groups) {\n const docs = g.html.map(docOf).filter(Boolean);\n const pages = docs.filter((d) => d.kind === 'html');\n const parts = docs.filter((d) => d.kind !== 'html');\n if (!pages.length) { buildContext(dir, g.css, parts); continue; }\n const used = new Set();\n for (const page of pages) {\n const linked = new Set();\n const queue = [...linksOf(page, dirname(page.file)), ...page.styles.flatMap((st) => importsOf(st.css, dirname(page.file)))];\n while (queue.length) {\n const q = queue.shift();\n if (linked.has(q) || !existsSync(q) || extname(q).toLowerCase() !== '.css') continue;\n linked.add(q);\n try { queue.push(...importsOf(readFileSync(q, 'utf8'), dirname(q))); } catch {}\n }\n const own = pages.length === 1 ? g.css : g.css.filter((c) => linked.has(c));\n own.forEach((c) => used.add(c));\n for (const c of linked) used.add(c);\n buildContext(page.file, own, [page]);\n }\n const rest = g.css.filter((c) => !used.has(c));\n if (rest.length || parts.length) buildContext(dir, rest, parts);\n}\nshareProjects(contexts);\nfor (const [key, ctx] of contexts) {\n applyWaivers(ctx, key);\n waiversByKey.set(key, { WAIVED, ANSWERED, DISMISSED, VERBATIM, APPSCREEN, LAYOUT_KEPT });\n for (const check of CHECKS) {\n try { check(ctx); } catch (e) {\n console.error(`sloplint: internal error in ${check.name} for ${rel(key)}: ${e.message}`);\n }\n }\n}\n\nlet renderNotRun = null;\nlet domInventories = {};\nif (opts.render) {\n const isShell = (f) => { try { const x = readFileSync(f, 'utf8'); return /\u003cscript\\b[^>]*type\\s*=\\s*[\"']module[\"'][^>]*\\bsrc\\s*=\\s*[\"'][^\"']+\\.(?:tsx?|jsx?|mjs|vue|svelte)[\"']/i.test(x); } catch { return false; } };\n const htmlFiles = files.filter((f) => extname(f).toLowerCase() === '.html' && !(opts.urls.length && isShell(f)));\n if (!htmlFiles.length && !opts.urls.length) renderNotRun = 'no HTML page to render; pass --url for a running dev server';\n else try {\n const mod = await import(new URL('./sloplint-render.mjs', import.meta.url).href);\n const extra = await mod.renderCheck([...htmlFiles, ...opts.urls], { genre: opts.genre, postureByFile, heroPostureByFile, photoShapeByFile, jobByFile, stage: opts.stage, selectors: opts.before || opts.kept ? Object.fromEntries([...new Set(contexts.flatMap(([, c]) => c.docs))].filter((d) => d.kind === 'html').map((d) => [d.file, scriptSelectors(d)])) : {}, scope: opts.scope === 'app' || [...waiversByKey.values()].some((x) => x.APPSCREEN) ? 'app' : opts.scope, before: opts.before });\n domInventories = extra.inventories || {};\n const allWaivers = { WAIVED: new Set([...waiversByKey.values()].flatMap((x) => [...x.WAIVED])), ANSWERED: new Set([...waiversByKey.values()].flatMap((x) => [...x.ANSWERED])), DISMISSED: new Set([...waiversByKey.values()].flatMap((x) => [...(x.DISMISSED || [])])), VERBATIM: [...waiversByKey.values()].some((x) => x.VERBATIM), APPSCREEN: [...waiversByKey.values()].some((x) => x.APPSCREEN), LAYOUT_KEPT: [...waiversByKey.values()].map((x) => x.LAYOUT_KEPT).find(Boolean) || false };\n for (const f of extra) {\n const w = /^https?:/.test(f.file) ? allWaivers : (waiversByKey.get(f.file) || waiversByKey.get(dirname(f.file)));\n WAIVED = w ? w.WAIVED : new Set();\n ANSWERED = w ? w.ANSWERED : new Set();\n DISMISSED = w ? (w.DISMISSED || new Set()) : new Set();\n VERBATIM = !!(w && w.VERBATIM);\n APPSCREEN = !!(w && w.APPSCREEN);\n LAYOUT_KEPT = (w && w.LAYOUT_KEPT) || false;\n report(f.gate, f.grade === 'REFLEX' ? 'FAIL' : f.grade, f.file, f.line, f.evidence, f.fix);\n }\n for (const x of extra.failed || []) { WAIVED = new Set(); ANSWERED = new Set(); DISMISSED = new Set(); VERBATIM = false; APPSCREEN = false; LAYOUT_KEPT = false; report('render', 'FAIL', x.file, 1, `the page did not render (${trunc(x.message, 60)}); its render gates did not run`, 'Fix the page or the URL and rerun; an unrendered page is never a pass'); }\n if (extra.notRun) renderNotRun = extra.notRun;\n } catch (e) {\n renderNotRun = e.message;\n }\n}\n\nconst SNAP = join(process.cwd(), '.hallmark', 'before.json');\nconst squash = (t) => t.replace(/\\s+/g, ' ').trim();\nconst escRe = (t) => t.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\{head-tags}');\nfunction wiringOf(src) {\n const body = src.replace(/\u003c!--[\\s\\S]*?-->/g, '');\n const w = { handlers: [], links: [], fields: [], hooks: [], ids: [], scripts: [] };\n const val = String.raw`(\"[^\"]*\"|'[^']*'|\\{(?:[^{}]|\\{(?:[^{}]|\\{[^{}]*\\})*\\})*\\})`;\n for (const m of body.matchAll(new RegExp(String.raw`(?\u003c![\\w-])(on[A-Z]\\w*|on:[\\w|]+|@[a-z][\\w.:-]*|v-on:[\\w.:-]+|on(?:click|submit|change|input|keydown|keyup|blur|focus))\\s*=\\s*` + val, 'g'))) w.handlers.push(`${m[1]}=${squash(m[2])}`);\n for (const m of body.matchAll(new RegExp(String.raw`(?\u003c![\\w:-])(?:href|to|action)\\s*=\\s*` + val, 'g'))) {\n const tag = (/\u003c([A-Za-z][\\w.-]*)[^\u003c]*$/.exec(body.slice(Math.max(0, m.index - 600), m.index)) || [])[1] || '';\n if (/^(link|base|use|image|script|style)$/.test(tag)) continue;\n const v = squash(m[1]).replace(/^[\"']|[\"']$/g, '').replace(/^\\.\\//, '');\n if (v && v !== '#') w.links.push(v);\n }\n for (const m of body.matchAll(/(?\u003c![\\w-])name\\s*=\\s*[\"']([^\"']+)[\"']/g)) {\n const tag = (/\u003c([A-Za-z][\\w.-]*)[^\u003c]*$/.exec(body.slice(Math.max(0, m.index - 600), m.index)) || [])[1] || '';\n if (!/^(meta|slot|iframe|param|a|map|object|link)$/i.test(tag)) w.fields.push(m[1]);\n }\n for (const m of body.matchAll(/\\bregister\\(\\s*[\"']([\\w.-]+)[\"']/g)) w.fields.push(m[1]);\n for (const m of body.matchAll(new RegExp(String.raw`\\bdata-(?:testid|test|cy|qa|track[\\w-]*|analytics[\\w-]*|event[\\w-]*|gtm[\\w-]*)\\s*=\\s*` + val, 'g'))) w.hooks.push(squash(m[1]).replace(/^[\"']|[\"']$/g, ''));\n for (const m of body.matchAll(/(?\u003c![\\w-])id\\s*=\\s*[\"']([^\"'{}\\s]+)[\"']/g)) w.ids.push(m[1]);\n for (const m of body.matchAll(/\u003cscript\\b[^>]*\\bsrc\\s*=\\s*[\"']([^\"']+)[\"']/gi)) w.scripts.push(m[1].split('?')[0].replace(/^\\.\\//, ''));\n return w;\n}\nfunction scriptText(doc) {\n const raw = doc.raw || doc.src;\n let js = [...raw.matchAll(/\u003cscript\\b(?![^>]*\\bsrc\\s*=)(?![^>]*application\\/(?:ld\\+)?json)[^>]*>([\\s\\S]*?)\u003c\\/script>/gi)].map((m) => m[1]).join('\\n');\n for (const m of raw.matchAll(/\u003cscript\\b[^>]*\\bsrc\\s*=\\s*[\"']([^\"']+)[\"']/gi)) {\n if (/^(?:https?:)?\\/\\//.test(m[1])) continue;\n try { js += '\\n' + readFileSync(resolve(dirname(doc.file), m[1].split('?')[0]), 'utf8'); } catch {}\n }\n return js;\n}\nfunction scriptSelectors(doc) {\n const js = scriptText(doc);\n const sels = new Set();\n for (const m of js.matchAll(/getElementById\\(\\s*[\"'`]([\\w-]+)[\"'`]/g)) sels.add('#' + m[1]);\n for (const m of js.matchAll(/(?:querySelector(?:All)?|closest|matches)\\(\\s*([\"'`])((?:(?!\\1)[^\\\\\\n])+)\\1/g)) if (!m[2].includes('${')) sels.add(m[2].trim());\n return [...sels].sort();\n}\nfunction scriptHooks(doc) {\n const markup = (doc.raw || doc.src).replace(/\u003cscript\\b[^>]*>[\\s\\S]*?\u003c\\/script>/gi, '');\n const has = (tok) => tok[0] === '#' ? new RegExp(`(?\u003c![\\\\w-])id\\\\s*=\\\\s*[\"']${escRe(tok.slice(1))}[\"']`).test(markup)\n : tok[0] === '.' ? new RegExp(`(?\u003c![\\\\w-])class\\\\s*=\\\\s*[\"'][^\"']*(?\u003c![\\\\w-])${escRe(tok.slice(1))}(?![\\\\w-])`).test(markup)\n : markup.includes(tok.slice(1));\n const queried = scriptSelectors(doc);\n const tokens = (sel) => [...sel.matchAll(/#[\\w-]+|\\.[A-Za-z_][\\w-]*|\\[data-[\\w-]+/g)].map((t) => t[0]);\n return { queried, hooked: queried.filter((sel) => { const t = tokens(sel); return t.length > 0 && t.every(has); }) };\n}\nfunction takeInventory() {\n const docs = [...new Set(contexts.flatMap(([, c]) => c.docs))];\n const byFile = {};\n for (const d of docs) { const w = wiringOf(d.raw || d.src); byFile[rel(d.file)] = Object.fromEntries(Object.entries(w).map(([k, v]) => [k, [...new Set(v)].sort()])); }\n const pages = {};\n for (const [file, inv] of Object.entries(domInventories)) if (inv) pages[/^https?:/.test(file) ? file : rel(file)] = inv;\n const scripts = {};\n for (const d of docs) if (d.kind === 'html') {\n const h = scriptHooks(d);\n const dom = domInventories[d.file];\n if (dom && Array.isArray(dom.hooked)) h.dom = dom.hooked;\n h.hides = /\\.hidden\\s*=|[\"'`]hidden[\"'`]/.test(scriptText(d));\n if (h.queried.length || h.hides) scripts[rel(d.file)] = h;\n }\n return { files: byFile, wiring: unionWiring(Object.values(byFile)), scripts, pages };\n}\nfunction unionWiring(list) {\n const u = { handlers: new Set(), links: new Set(), fields: new Set(), hooks: new Set(), ids: new Set() };\n for (const w of list) for (const k of Object.keys(u)) for (const v of w[k] || []) u[k].add(v);\n return Object.fromEntries(Object.entries(u).map(([k, v]) => [k, [...v].sort()]));\n}\nlet keptLine = null;\nif (opts.kept) {\n let was = null;\n try { was = JSON.parse(readFileSync(SNAP, 'utf8')); } catch {}\n WAIVED = new Set(); ANSWERED = new Set(); DISMISSED = new Set(); VERBATIM = false; APPSCREEN = false; LAYOUT_KEPT = false;\n if (!was) report('kept', 'FAIL', SNAP, 1, 'no before record here; --kept has nothing to compare against', 'Run sloplint with --before from this folder before the redesign starts');\n else {\n const now = takeInventory();\n const outside = Object.keys(was.files || {}).filter((f) => !(f in now.files) && existsSync(resolve(f)));\n if (was.files) was.wiring = unionWiring(Object.entries(was.files).filter(([f]) => !outside.includes(f)).map(([, w]) => w));\n const stamps = contexts.map(([, c]) => c.stampText).join('\\n');\n for (const m of stamps.matchAll(/\\/\\*\\s*Hallmark\\s*·\\s*(dropped|reworded)\\s+([^·*]+?)\\s*·\\s*([^*]+?)\\s*\\*\\//gi)) if (m[2].trim() && m[3].trim().length \u003c 12) report('kept', 'WARN', SNAP, 1, `${m[1]} ${trunc(m[2].trim(), 40)} is not on the record: its reason is under 12 characters`, 'Say why in a clause');\n const allowed = [...stamps.matchAll(/\\/\\*\\s*Hallmark\\s*·\\s*(dropped|reworded)\\s+([^·*]+?)\\s*·\\s*([^*]+?)\\s*\\*\\//gi)].filter((m) => m[3].trim().length >= 12).flatMap((m) => (m[1].toLowerCase() === 'reworded' ? [...m[2].matchAll(/\"([^\"]+)\"/g)].map((q) => q[1]) : m[2].split(',')).map((t) => t.trim().replace(/^[`\"']|[`\"']$/g, '').toLowerCase()).filter(Boolean));\n const acked = {};\n const gone = (a, b, cat) => (a || []).filter((x) => {\n if ((b || []).includes(x)) return false;\n if (allowed.some((t) => x.toLowerCase() === t || (t.length >= 4 && x.toLowerCase().includes(t)))) { if (cat) (acked[cat] ||= new Set()).add(x); return false; }\n return true;\n });\n const fieldsGone = (b, a, where) => {\n const named = (b || []).filter((x) => !x.startsWith('unnamed '));\n const spare = (a || []).length - named.filter((x) => (a || []).includes(x)).length;\n const short = Math.max(0, (b || []).length - named.length - spare);\n return [...gone(named, a, 'fields'), ...(short ? [`${short} field${short > 1 ? 's' : ''} with no name${where}`] : [])];\n };\n const hard = { links: new Set(), fields: new Set(), forms: new Set(), handlers: new Set(), 'test hooks': new Set(), scripts: new Set() };\n const soft = { headings: new Set(), figures: new Set(), images: new Set(), ids: new Set() };\n const total = { links: new Set(was.wiring.links), fields: new Set(was.wiring.fields), handlers: new Set(was.wiring.handlers), 'test hooks': new Set(was.wiring.hooks), headings: new Set(), figures: new Set() };\n for (const x of gone(was.wiring.links, now.wiring.links, 'links')) hard.links.add(x);\n for (const x of gone(was.wiring.fields, now.wiring.fields, 'fields')) hard.fields.add(x);\n for (const x of gone(was.wiring.handlers, now.wiring.handlers, 'handlers')) hard.handlers.add(x);\n for (const x of gone(was.wiring.hooks, now.wiring.hooks, 'test hooks')) hard['test hooks'].add(x);\n for (const x of gone(was.wiring.ids, now.wiring.ids)) soft.ids.add(x);\n for (const [f, w] of Object.entries(was.files || {})) if (now.files[f]) for (const x of gone(w.scripts, now.files[f].scripts)) hard.scripts.add(`${x} on ${f}`);\n const skipped = [];\n let wordsWas = 0, wordsNow = 0;\n for (const [k, b] of Object.entries(was.pages || {})) {\n const a = now.pages[k];\n if (!a) { skipped.push(k); continue; }\n for (const x of b.links || []) total.links.add(x);\n for (const x of b.fields || []) total.fields.add(x);\n for (const x of b.headings || []) total.headings.add(x);\n for (const x of b.figures || []) total.figures.add(x);\n for (const x of gone(b.links, [...a.links, ...now.wiring.links], 'links')) hard.links.add(x);\n for (const x of fieldsGone(b.fields, a.fields, ` on ${k}`)) hard.fields.add(x);\n for (const x of gone(b.actions, a.actions)) hard.forms.add(x);\n for (const x of gone(b.headings, a.headings, 'headings')) soft.headings.add(x);\n for (const x of gone(b.figures, a.figures, 'figures')) soft.figures.add(x);\n for (const x of gone(b.images, a.images)) soft.images.add(x);\n wordsWas += b.words || 0; wordsNow += a.words || 0;\n }\n const list = (set, n = 8) => { const a = [...set]; return a.slice(0, n).map((x) => trunc(x, 48)).join(', ') + (a.length > n ? ` and ${a.length - n} more` : ''); };\n const fix = 'Restore each one, or record it in the stamp: /* Hallmark · dropped \u003citem>, \u003citem> · \u003cthe reason> */';\n total['script hooks'] = new Set(); hard['script hooks'] = new Set();\n for (const [f, h] of Object.entries(was.scripts || {})) {\n if (outside.includes(f)) continue;\n const n = now.scripts[f];\n const both = n && h.dom && n.dom;\n const had = both ? h.dom : h.hooked;\n for (const x of had) total['script hooks'].add(`${f} ${x}`);\n const unhooked = n ? had.filter((x) => n.queried.includes(x) && !(both ? n.dom : n.hooked).includes(x)) : [];\n for (const x of unhooked) hard['script hooks'].add(`${f} ${x}`);\n }\n const guard = contexts.some(([, c]) => c.rules.some((r) => /\\[hidden\\]/.test(r.selector) && r.decls.some((d) => d.prop === 'display' && /none/.test(d.value))));\n if (!guard && Object.values(now.scripts).some((h) => h.hides)) report('kept', 'FAIL', SNAP, 1, 'a script hides elements with the hidden attribute and no rule pins it, so any display rule shows them again', 'Add [hidden] { display: none !important; } to the stylesheet');\n for (const [k, b] of Object.entries(was.pages || {})) {\n const a = now.pages[k];\n if (!a) continue;\n for (const bf of b.forms || []) {\n const af = (a.forms || []).find((x) => x.key === bf.key) || (a.forms || []).find((x) => x.attrs.join() === bf.attrs.join());\n if (!af) { if (!allowed.includes(bf.key.toLowerCase())) hard.forms.add(`the form ${bf.key} on ${k}`); continue; }\n for (const x of fieldsGone(bf.fields, af.fields, '')) hard.fields.add(`${x} in ${bf.key} on ${k}`);\n for (const x of bf.hidden) if (!af.hidden.includes(x)) hard.fields.add(`hidden ${x} in ${bf.key} on ${k}`);\n for (const x of bf.attrs) if (!af.attrs.includes(x)) hard.forms.add(`${x} on ${bf.key} (${k})`);\n if (bf.submits && !af.submits) hard.forms.add(`a submit control in ${bf.key} (${k})`);\n for (const x of bf.controls || []) if (!(af.controls || []).includes(x) && !(!x.includes('[name=') && (af.controls || []).some((y) => y.startsWith(`${x}[name=`))) && !allowed.some((t) => x.toLowerCase().includes(t) && t.length >= 4)) hard.forms.add(`${x} in ${bf.key} (${k})`);\n }\n }\n for (const x of [...hard.fields]) if ([...hard.fields].some((y) => y.startsWith(`${x} in `))) hard.fields.delete(x);\n for (const [k, set] of Object.entries(hard)) if (set.size) report('kept', 'FAIL', SNAP, 1, k === 'script hooks' ? `a script still queries what its page no longer has: ${list(set)}` : k === 'forms' ? `a form lost what it had before: ${list(set)}` : `${k} the page had before and no longer has: ${list(set)}`, k === 'script hooks' ? 'Keep the ids, classes and attributes scripts query, or change the script with the markup' : fix);\n for (const [k, set] of Object.entries(soft)) if (set.size) report('kept', 'WARN', SNAP, 1, `${k} gone or reworded since the before record: ${list(set)}`, 'Restore it, or record it in the stamp: /* Hallmark · reworded \"\u003cold>\" to \"\u003cnew>\" · \u003cthe gate that named it> */ or dropped \u003citem> · \u003cthe reason>');\n if (wordsWas > 80 && wordsNow \u003c wordsWas * 0.6) report('kept', 'WARN', SNAP, 1, `the pages hold ${wordsNow} words where they held ${wordsWas}`, 'Check that no section of content was dropped');\n if (skipped.length) report('kept', 'WARN', SNAP, 1, `not compared, because this run did not render them: ${list(skipped, 4)}`, 'Render the same pages or URLs the before record holds');\n if (outside.length) report('kept', 'WARN', SNAP, 1, `not compared, because this run did not read them: ${list(outside, 4)}`, 'Run --kept on the same target as --before');\n const lostOf = (k) => (hard[k] || soft[k]).size;\n keptLine = { parts: ['links', 'fields', 'handlers', 'test hooks', 'script hooks', 'headings', 'figures'].filter((k) => total[k].size).map((k) => { const d = acked[k] ? acked[k].size : 0; return `${k} ${Math.max(0, total[k].size - lostOf(k) - d)}/${total[k].size}${d ? ` (${d} dropped on record)` : ''}`; }), was: was.summary || null };\n }\n}\n\nconst seen = new Set();\nconst tierOk = (f) => !opts.tier || (opts.tier === 'floor' ? (f.tier === 'floor' && f.grade === 'FAIL') : f.tier === opts.tier);\nconst unique = findings.filter((f) => {\n if (!tierOk(f)) return false;\n const k = `${f.gate}|${f.grade}|${f.file}|${f.line}|${f.evidence}`;\n if (seen.has(k)) return false;\n seen.add(k);\n return true;\n});\n\nunique.sort((a, b) => {\n const ra = GRADE_RANK[a.grade] ?? 9, rb = GRADE_RANK[b.grade] ?? 9;\n if (ra !== rb) return ra - rb;\n const ga = gateSortKey(a.gate), gb = gateSortKey(b.gate);\n if (ga !== gb) return ga - gb;\n if (a.file !== b.file) return a.file \u003c b.file ? -1 : 1;\n return a.line - b.line;\n});\n\nconst count = (g) => unique.filter((f) => f.grade === g).length;\nconst fails = count('FAIL');\nconst reflex = count('REFLEX');\nconst warns = count('WARN');\nconst waived = count('WAIVED');\nconst rejected = count('WAIVER');\nconst answered = count('ANSWERED');\nconst dismissed = count('DISMISSED');\nconst notes = count('NOTE');\n\nconst tally = [`${fails} FAIL`];\nif (reflex) tally.push(`${reflex} REFLEX`);\ntally.push(`${warns} WARN`);\nif (waived) tally.push(`${waived} WAIVED`);\nif (rejected) tally.push(`${rejected} REJECTED`);\nif (answered) tally.push(`${answered} ANSWERED`);\nif (dismissed) tally.push(`${dismissed} DISMISSED`);\nif (notes) tally.push(`${notes} NOTE`);\n\nlet beforeLine = null;\nif (opts.before) {\n const inv = takeInventory();\n mkdirSync(dirname(SNAP), { recursive: true });\n writeFileSync(SNAP, JSON.stringify({ v: 1, at: new Date().toISOString(), scope: opts.scope, summary: { fails, reflex, warns }, ...inv }, null, 1) + '\\n');\n beforeLine = `before: recorded ${new Set([...inv.wiring.links, ...Object.values(inv.pages).flatMap((p) => p.links)]).size} links, ${new Set([...inv.wiring.fields, ...Object.values(inv.pages).flatMap((p) => p.fields)]).size} fields, ${inv.wiring.handlers.length} handlers, ${inv.wiring.hooks.length} test hooks, ${new Set(Object.values(inv.scripts).flatMap((h) => h.hooked)).size} script hooks${Object.keys(inv.pages).length ? ` and ${Object.keys(inv.pages).length} rendered page(s)` : ''} in ${rel(SNAP)}; --kept checks a redesign against it`;\n}\nconst keptNote = keptLine ? `kept: ${keptLine.parts.join(', ') || 'nothing recorded'}${keptLine.was ? `; before ${keptLine.was.fails} FAIL, ${keptLine.was.reflex} REFLEX, now ${fails} FAIL, ${reflex} REFLEX` : ''}` : null;\nlet compareNote = null;\nif (opts.kept && opts.render && !renderNotRun) {\n try {\n const mod = await import(new URL('./sloplint-render.mjs', import.meta.url).href);\n const made = mod.writeCompare([...files.filter((f) => extname(f).toLowerCase() === '.html'), ...opts.urls], keptNote ? keptNote.replace(/^kept: /, 'Kept: ').replace(/; before (\\d+) FAIL, (\\d+) REFLEX, now (\\d+) FAIL, (\\d+) REFLEX/, '. Floor findings: $1 before, $3 now. Reflex findings: $2 before, $4 now') + '.' : null);\n if (made.length) compareNote = `compare: ${made.map(rel).join(', ')} shows before and after side by side`;\n } catch {}\n}\nconst scopeNote = `${files.length} file(s)${opts.urls.length ? ` and ${opts.urls.length} URL(s)` : ''}${opts.stage === 'hero' ? ' (hero stage)' : ''}`;\nconst renderNote = opts.render ? (renderNotRun ? `render tier not run: ${renderNotRun}` : null) : null;\nif (opts.json) {\n console.log(JSON.stringify({\n summary: { fails, reflex, warns, waived, rejected, answered, dismissed, notes, files: files.length, render: opts.render ? (renderNotRun ? `not run: ${renderNotRun}` : 'ran') : 'off', ...(keptNote ? { kept: keptNote } : {}), ...(beforeLine ? { before: beforeLine } : {}) },\n findings: unique,\n }, null, 2));\n} else {\n if (!unique.length) console.log(`sloplint: clean. 0 FAIL, 0 REFLEX, 0 WARN across ${scopeNote}.`);\n else if (opts.before) {\n const by = new Map();\n for (const f of unique) { const k = `${f.grade} ${f.gate}`; if (!by.has(k)) by.set(k, []); by.get(k).push(f); }\n for (const [k, list] of by) console.log(` ${k}${list.length > 1 ? ` x${list.length}` : ''} ${list.slice(0, 2).map((f) => `${f.file}:${f.line} ${trunc(f.evidence, 64)}`).join('; ')} | fix: ${list[0].fix}`);\n const every = join(dirname(SNAP), 'before.txt');\n mkdirSync(dirname(SNAP), { recursive: true });\n const ignore = join(dirname(SNAP), '.gitignore');\n if (!existsSync(ignore)) writeFileSync(ignore, 'shots/\\nbefore/\\nbefore.json\\nbefore.txt\\ncompare.html\\n');\n writeFileSync(every, unique.map((f) => `${f.grade} ${f.gate} ${f.file}:${f.line} ${f.evidence} | fix: ${f.fix}`).join('\\n') + '\\n');\n console.log(`sloplint: ${tally.join(', ')} across ${scopeNote}, grouped by gate; every line is in ${rel(every)}.`);\n } else {\n const byFile = new Map();\n for (const f of unique) { if (!byFile.has(f.file)) byFile.set(f.file, []); byFile.get(f.file).push(f); }\n const told = new Set();\n for (const [file, list] of byFile) {\n console.log(file);\n for (const f of list) {\n const key = `${f.gate}|${f.fix}`;\n const fix = f.grade !== 'WAIVED' && f.grade !== 'ANSWERED' && f.grade !== 'DISMISSED' && !told.has(key) ? ` | fix: ${f.fix}` : '';\n told.add(key);\n console.log(` ${f.grade} ${f.gate} :${f.line} ${f.evidence}${fix}`);\n }\n }\n console.log(`sloplint: ${tally.join(', ')} across ${scopeNote}.`);\n }\n if (renderNote) console.log(renderNote);\n if (keptNote) console.log(keptNote);\n if (compareNote) console.log(compareNote);\n if (beforeLine) console.log(beforeLine);\n}\nconst exitCode = !opts.before && (fails > 0 || reflex > 0) ? 1 : 0;\nif (process.stdout.writableLength) process.stdout.once('drain', () => process.exit(exitCode)); else process.exit(exitCode);\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":231704,"content_sha256":"530b8d5a5ad0bb19b3e06a74600523bef3f3397de47e43de045e7862d73ca11e"},{"filename":"scripts/theme.mjs","content":"#!/usr/bin/env node\nimport { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, renameSync } from 'node:fs';\nimport { join, dirname, basename, resolve } from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport { createHash, randomBytes } from 'node:crypto';\n\nconst HERE = dirname(fileURLToPath(import.meta.url));\nconst THEMES = join(HERE, '..', 'references', 'themes');\nconst INDEX = join(THEMES, 'index.json');\nconst VERSION = '2.0.0';\nconst PHOTOS = join(HERE, 'photos.json');\nconst args = process.argv.slice(2);\nconst cmd = args[0];\nconst flag = (k, d = null) => { const i = args.indexOf('--' + k); const v = i >= 0 ? args[i + 1] : undefined; return v !== undefined && !String(v).startsWith('--') ? v : d; };\nconst has = (k) => args.includes('--' + k);\nconst VALUED = new Set(['genre', 'prompt', 'brief', 'log', 'n', 'reroll', 'dealt', 'dir', 'axes', 'face', 'hue', 'entry', 'redesign', 'theme', 'orient', 'save', 'avoid', 'material', 'seed', 'job']);\nconst positional = () => { const out = []; for (let i = 1; i \u003c args.length; i++) { const a = args[i]; if (a.startsWith('--')) { if (VALUED.has(a.slice(2))) i++; continue; } out.push(a); } return out; };\nconst GENRES = ['editorial', 'modern-minimal', 'atmospheric', 'playful'];\n// A theme slug names a file in references/themes, so it is a plain name, never a path.\nconst isSlug = (s) => /^[a-z0-9][a-z0-9-]*$/.test(s);\n\nfunction usage() {\n console.error(`usage:\n node theme.mjs deal --genre \u003cg> --prompt \"\u003cprompt>\" [--log .hallmark/log.json] [--n 3] [--reroll 0] [--json]\n node theme.mjs scaffold \u003cslug> [--dealt \"\u003cn>/3, seed \u003cseed>\"] [--redesign polish|restyle|full] [--scope app] [--dir \u003cbeside the stylesheet>] [--force]\n node theme.mjs scaffold --custom | --brand [--redesign polish|restyle|full] [--scope app] [--dir \u003cbeside the stylesheet>] [--force]\n node theme.mjs check [--axes \"\u003cpaper>/\u003cdisplay>/\u003caccent>\"] [--face \"\u003cdisplay face>\"] [--hue \u003caccent hue>] [--log .hallmark/log.json]\n paper: light | mid | dark · accent: warm | cool | neutral | chromatic-\u003chue name>\n display: roman-serif | high-contrast-serif | slab-serif | grotesk-sans | grotesk-heavy | geometric-sans | rounded-sans | display-condensed | display-heavy | mono | pixel | handwritten | blackletter\n node theme.mjs draw --material photo|product|words --job \u003csubscribe|book|visit|attend|buy|try|sign up|hire|join|listen> [--theme \u003cslug>] [--log .hallmark/log.json] [--seed \u003chex>]\n node theme.mjs photos \"\u003csubject>\" [\"\u003csubject>\" ...] [--theme \u003cslug>] [--n 3] [--orient landscape|portrait|square] [--log .hallmark/log.json] [--avoid \u003cfile naming photos>] [--save \u003cdir>] [--json]\n node theme.mjs log --entry '\u003cone JSON object>' [--log .hallmark/log.json]\n node theme.mjs index\n node theme.mjs list\n node theme.mjs validate \u003cfiles...>`);\n}\n\nfunction parseHeader(src, file) {\n const m = /^---\\r?\\n([\\s\\S]*?)\\r?\\n---/.exec(src);\n if (!m) throw new Error(`${file}: no header block`);\n const h = {};\n for (const line of m[1].split('\\n')) {\n const mm = /^([a-z]+):\\s*(.*)$/.exec(line.trim());\n if (mm) h[mm[1]] = mm[2].trim();\n }\n const need = ['name', 'slug', 'genre', 'paper', 'display', 'accent', 'posture', 'keywords', 'material'];\n for (const k of need) if (!h[k]) throw new Error(`${file}: header is missing \"${k}\"`);\n const [pband, pL, phue] = h.paper.split(/\\s+/);\n const [dclass, ...dface] = h.display.split(/\\s+/);\n const [aband, ahue] = h.accent.split(/\\s+/);\n return {\n name: h.name, slug: h.slug, genre: h.genre, posture: h.posture,\n paper: { band: pband, L: Number(pL), hue: Number(phue) },\n display: { class: dclass, face: dface.join(' ') },\n accent: { band: aband, hue: Number(ahue) },\n keywords: h.keywords.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),\n material: h.material, file: `references/themes/${basename(file)}`,\n ...(h.photo ? { photo: h.photo } : {}),\n ...(h.pictured ? { pictured: h.pictured } : {}),\n ...(existsSync(join(THEMES, basename(file).replace(/\\.md$/, '.jpg'))) ? { picture: `references/themes/${basename(file).replace(/\\.md$/, '.jpg')}` } : {}),\n };\n}\n\nfunction buildIndex() {\n const files = readdirSync(THEMES).filter((f) => f.endsWith('.md') && f !== 'README.md').sort();\n const entries = files.map((f) => parseHeader(readFileSync(join(THEMES, f), 'utf8'), f));\n const slugs = new Set();\n for (const e of entries) {\n if (slugs.has(e.slug)) throw new Error(`duplicate slug ${e.slug}`);\n slugs.add(e.slug);\n }\n writeFileSync(INDEX, JSON.stringify({ themes: entries }, null, 1) + '\\n');\n return entries;\n}\n\nfunction loadIndex() {\n if (!existsSync(INDEX)) return buildIndex();\n return JSON.parse(readFileSync(INDEX, 'utf8')).themes;\n}\n\nfunction addLog() {\n const file = resolve(flag('log', '.hallmark/log.json'));\n let entry;\n try { entry = JSON.parse(flag('entry', '')); } catch { entry = null; }\n if (!entry || Array.isArray(entry) || typeof entry !== 'object') { console.error('--entry takes one JSON object, the build\\'s log entry'); process.exit(2); }\n if (!entry.date) entry.date = new Date().toISOString().slice(0, 10);\n if (!entry.mode) entry.mode = 'build';\n if (!['build', 'redesign'].includes(entry.mode)) { console.error('mode is build or redesign'); process.exit(2); }\n if (entry.mode === 'redesign' && !['polish', 'restyle', 'full'].includes(entry.depth)) { console.error('a redesign entry names its depth: polish, restyle or full'); process.exit(2); }\n const composed = entry.scope !== 'app' && (entry.mode === 'build' || entry.depth === 'full');\n if (!entry.imagery && entry.enrichment) entry.imagery = entry.enrichment;\n if (!entry.prompt && entry.brief) { entry.prompt = entry.brief; delete entry.brief; }\n const missing = [...(composed ? ['shape', 'axes', 'hero', 'nav', 'footer', 'imagery', 'entry', 'fingerprint', 'sections', 'signature'] : []), ...(entry.depth === 'polish' ? [] : ['theme']), 'prompt'].filter((k) => !entry[k]);\n if (entry.photos && !Array.isArray(entry.photos)) { console.error('photos is the list of photo ids the page used'); process.exit(2); }\n if (existsSync(file)) { try { JSON.parse(readFileSync(file, 'utf8')); } catch { console.error(`${file} is not valid JSON; fix it or move it aside, nothing was written`); process.exit(1); } }\n const next = [entry, ...readLog(file)].slice(0, 20);\n mkdirSync(dirname(file), { recursive: true });\n const tmp = `${file}.${process.pid}.tmp`;\n writeFileSync(tmp, JSON.stringify(next, null, 1) + '\\n');\n renameSync(tmp, file);\n console.log(`logged ${entry.mode === 'redesign' ? `a ${entry.depth} redesign` : entry.shape || '?'} in ${entry.theme || '?'} at the top of ${file} (${next.length} of 20)${missing.length ? `; missing ${missing.join(', ')}, which Rotation reads, so log them` : ''}`);\n}\n\nfunction readLog(p) {\n if (!p || !existsSync(p)) return [];\n try { const j = JSON.parse(readFileSync(p, 'utf8')); return Array.isArray(j) ? j : (j.runs || []); } catch { return []; }\n}\n\nfunction contrastOf(f, b) {\n const rgb = ([L, C, H]) => { const h = H * Math.PI / 180, x = C * Math.cos(h), y = C * Math.sin(h); const l = (L + 0.3963377774 * x + 0.2158037573 * y) ** 3, m = (L - 0.1055613458 * x - 0.0638541728 * y) ** 3, s2 = (L - 0.0894841775 * x - 1.2914855480 * y) ** 3; return [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s2, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s2, -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s2].map((c) => Math.min(1, Math.max(0, c))); };\n const Y = (c) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];\n const a = Y(rgb(f)), c = Y(rgb(b));\n return (Math.max(a, c) + 0.05) / (Math.min(a, c) + 0.05);\n}\n\nconst triple = (t) => `${t.paper.band}/${t.display.class}/${t.accent.band}`;\nconst words = (s) => (s || '').toLowerCase().replace(/[^a-z0-9\\s-]/g, ' ').split(/\\s+/).filter((w) => w.length > 2);\nconst ASK = new Set(['build', 'make', 'create', 'design', 'need', 'want', 'please', 'page', 'site', 'website', 'landing', 'called', 'for', 'and', 'the', 'with', 'that', 'this', 'our', 'your']);\nconst promptWords = (s) => words(s).filter((w) => !ASK.has(w));\nconst stem = (w) => { let x = w.replace(/(?:ing|ed)$/, ''); if (x.length \u003c 3) x = w; return x.endsWith('ss') ? x : x.replace(/s$/, ''); };\nconst hueGap = (a, b) => { const d = Math.abs(((a - b) % 360 + 360) % 360); return Math.min(d, 360 - d); };\n\nfunction seededRand(key) {\n let h = createHash('sha256').update(key).digest();\n let i = 0;\n return () => { if (i >= h.length - 4) { h = createHash('sha256').update(h).digest(); i = 0; } const v = h.readUInt32BE(i); i += 4; return v / 0xffffffff; };\n}\n\nfunction pickFrom(pool, n, exclude) {\n const avail = pool.filter((s) => !exclude.has(s.t.slug));\n const picked = [];\n const tripleOf = (s) => triple(s.t);\n if (!avail.length) return picked;\n picked.push(avail[0]);\n const rest = () => avail.filter((s) => !picked.includes(s));\n if (n >= 2 && rest().length) {\n const best = rest()[0];\n const other = rest().find((s) => s.t.paper.band !== picked[0].t.paper.band && s.fit >= best.fit - 2 && tripleOf(s) !== tripleOf(picked[0]));\n picked.push(other || rest().find((s) => tripleOf(s) !== tripleOf(picked[0])) || best);\n }\n while (picked.length \u003c n && rest().length) {\n const cand = rest().find((s) => picked.every((p) => tripleOf(p) !== tripleOf(s))) || rest()[0];\n picked.push(cand);\n }\n return picked;\n}\n\n// A project's own salt, so two projects dealing the same prompt on the same day get different hands;\n// one project still gets the same deal back (reroll moves it on).\nfunction projectSalt() {\n const f = resolve(dirname(resolve(flag('log', '.hallmark/log.json'))), 'salt');\n try { if (existsSync(f)) return readFileSync(f, 'utf8').trim(); mkdirSync(dirname(f), { recursive: true }); const salt = randomBytes(4).toString('hex'); writeFileSync(f, salt + '\\n'); return salt; } catch { return 'nosalt'; }\n}\n\n// The draw: the structural decisions a model converges on when left alone, dealt fresh for every build\n// against the log and the theme's own picture, so two builds of one prompt in one theme come out as\n// different pages. The model takes it unless the prompt's material refuses a pick, and says so.\nconst DRAW = {\n shapes: {\n photo: ['Photographic', 'Marquee Hero', 'Split Studio', 'Catalogue', 'Narrative Workflow', 'Bento Grid', 'Conversational FAQ', 'Letter', 'Feature Stack'],\n product: ['Workbench', 'Feature Stack', 'Bento Grid', 'Narrative Workflow', 'Marquee Hero', 'Split Studio', 'Conversational FAQ', 'Map / Diagram', 'Stat-Led'],\n words: ['Long Document', 'Manifesto', 'Letter', 'Index-First', 'Conversational FAQ', 'Marquee Hero', 'Narrative Workflow'],\n },\n heroes: { photo: ['Poster', 'Stage', 'Ledge', 'Settled', 'Poster', 'Stage'], product: ['Stage', 'Settled', 'Banner', 'Corridor', 'Stage'], words: ['Corridor', 'Ledge', 'Banner', 'Settled'] },\n navs: ['edge-aligned', 'floating pill', 'masthead', 'slab', 'search-led bar', 'banner over a retracting bar'],\n footers: ['mast', 'statement', 'colophon block', 'newsletter-first', 'letter close', 'index columns'],\n openers: ['Split', 'Showcase', 'Grid', 'Bento', 'Rows', 'Table', 'Steps', 'Statement', 'Rail', 'Pinned', 'Switcher', 'Index', 'Schedule', 'Offer'],\n jobs: {\n subscribe: { sections: ['an example issue set as it arrives (a sample)', 'the archive as an Index of past issues (sample titles)', 'who writes it, with a picture slot', 'the contents of one issue, as rows'], close: ['a second subscribe field under the archive', 'the archive itself, ending on the latest issue'], open: ['Index', 'Rail', 'Statement', 'Split'], heroes: ['Counter', 'Corridor', 'Index', 'Banner'], action: ['the subscribe field in the hero, beside the latest issue', 'the subscribe field in the hero and once more under the issues'] },\n book: { sections: ['the menu as a Switcher by lunch and dinner (sample dishes, no prices unless given)', 'the room as a full-bleed photograph with no head', 'hours and the address as a section of their own', 'the next free times as buttons'], close: ['hours, the address and the booking again', 'the booking beside one last photograph'], open: ['Switcher', 'Index', 'Schedule', 'Split', 'Showcase'], heroes: ['Counter', 'Poster', 'Stage', 'Index'], action: ['a sticky booking bar', 'the booking times in the hero', 'the booking beside the menu or the rooms'] },\n visit: { sections: ['what is on now as the lead picture', 'what is coming as dated rows', 'the shelves or the rooms as a Rail', 'getting there, with the hours'], close: ['visit information and the address', 'what is on next'], open: ['Showcase', 'Schedule', 'Rail', 'Split'], heroes: ['Poster', 'Ledge', 'Stage'], action: ['hours and the address in the hero', 'hours, the map and the way in at the close', 'a sticky bar with today\\'s hours'] },\n attend: { sections: ['the days as a Schedule', 'the lineup as an Index, set large', 'ticket tiers as rows', 'the place and how to get there', 'questions as rows'], close: ['tickets again', 'the questions', 'the mailing list for the next edition'], open: ['Schedule', 'Index', 'Rail', 'Showcase'], heroes: ['Index', 'Poster', 'Counter', 'Ledge'], action: ['tickets in the hero', 'tickets beside the lineup', 'a sticky ticket bar'] },\n buy: { sections: ['the range as an Offer or a Rail (sample names; no prices unless given)', 'the thing in use, close and large', 'how it is made, as a Pinned run', 'delivery and care as plain facts'], close: ['the offer again', 'delivery and the guarantee as plain facts'], open: ['Offer', 'Rail', 'Showcase', 'Switcher'], heroes: ['Counter', 'Stage', 'Poster'], action: ['the offer as the hero', 'a sticky buy bar', 'the offer after the product close up'] },\n try: { sections: ['features as named moments in screens (Pinned or Switcher)', 'a before and after', 'the platforms and what it needs'], close: ['the download beside one last screen'], open: ['Pinned', 'Switcher', 'Showcase', 'Rail'], heroes: ['Counter', 'Stage', 'Settled'], action: ['the download beside the screen in the hero', 'the download in the hero and at the close'] },\n 'sign up': { sections: ['the product at work as a Pinned run', 'a code sample or the integration', 'pricing as a table with its period switch', 'questions as rows'], close: ['the hero\\'s action in a short band', 'the code sample'], open: ['Pinned', 'Switcher', 'Showcase', 'Bento', 'Table'], heroes: ['Stage', 'Settled', 'Banner', 'Counter'], action: ['sign up in the hero', 'sign up in the hero and beside the price'] },\n hire: { sections: ['the work as an Index of projects with pictures', 'one project opened as a case', 'what the studio does, as a short list', 'where it is'], close: ['contact details per office', 'a plain email and phone'], open: ['Index', 'Rail', 'Showcase', 'Statement'], heroes: ['Index', 'Poster', 'Corridor', 'Ledge'], action: ['the enquiry after the work', 'a contact line in the nav and at the close'] },\n join: { sections: ['what a session is like, shown', 'when and where as a Schedule', 'membership options as rows', 'first-visit guidance'], close: ['the first visit', 'the membership options'], open: ['Schedule', 'Grid', 'Split', 'Showcase'], heroes: ['Counter', 'Poster', 'Corridor'], action: ['join in the hero', 'join beside the schedule'] },\n listen: { sections: ['the record with its tracks as an Index (sample titles unless given)', 'dates as rows with ticket links', 'earlier work as covers in a Rail', 'a live photograph or the video'], close: ['the dates', 'more of the catalogue', 'the mailing list'], open: ['Index', 'Rail', 'Showcase', 'Schedule'], heroes: ['Poster', 'Index', 'Counter', 'Ledge'], action: ['the player or the record in the hero', 'listen in the hero and the dates beside it'] },\n },\n leads: { photo: ['the place', 'the thing itself, close', 'the making', 'the offer and its price', 'the season or the date', 'the people at work'], product: ['the job it does', 'the screen at work', 'the speed or the limit', 'the material it handles', 'the price', 'the before and after'], words: ['the claim', 'the question', 'the first line of the argument', 'the date and the place', 'the list itself'] },\n};\nfunction draw() {\n const material = flag('material', 'photo');\n if (!DRAW.shapes[material]) { console.error('--material is photo, product or words'); process.exit(2); }\n const seed = flag('seed', '') || randomBytes(3).toString('hex');\n const rand = seededRand(`draw·${seed}`);\n const log = readLog(flag('log', '.hallmark/log.json'));\n const low = (x) => String(x || '').toLowerCase();\n let pictured = [];\n const slug = flag('theme', '');\n if (slug && isSlug(slug) && existsSync(join(THEMES, `${slug}.md`))) pictured = low(parseHeader(readFileSync(join(THEMES, `${slug}.md`), 'utf8'), slug).pictured).split(/,\\s*/).map((x) => x.replace(/ hero$/, ''));\n const recent = (k, n) => log.slice(0, n).map((r) => low(r[k]));\n const near = (opts, n, k, extra = []) => { const bad = new Set([...recent(k, n), ...extra]); const ok = opts.filter((o) => ![...bad].some((b) => b && b.includes(low(o)))); return ok.length ? ok : opts; };\n const take = (opts, m = 1) => { const pool = [...new Set(opts)]; const out = []; while (out.length \u003c m && pool.length) out.push(pool.splice(Math.floor(rand() * pool.length), 1)[0]); return out; };\n const shapes = take(near(DRAW.shapes[material], 3, 'shape', pictured), 1);\n const jobName = low(flag('job', '')).replace(/download|app/, 'try').replace(/software|saas/, 'sign up').replace(/newsletter|waitlist/, 'subscribe').replace(/watch|read/, 'listen');\n const job = DRAW.jobs[jobName] || null;\n const [hero] = take(near(job ? [...job.heroes, ...job.heroes, ...DRAW.heroes[material].filter((h) => !/settled|stage/i.test(h) || job.heroes.includes(h))] : DRAW.heroes[material], 1, 'hero', pictured));\n const [nav] = take(near(DRAW.navs, 3, 'nav'));\n const [footer] = take(near(DRAW.footers.filter((f) => f !== 'newsletter-first' || /subscribe/.test(jobName)), 3, 'footer'));\n const [action] = job ? take(job.action) : [null];\n const native = job ? take(job.sections, 3) : [];\n const [close] = job ? take(job.close) : [null];\n const lastOpen = low((log[0] && (Array.isArray(log[0].sections) ? log[0].sections[0] : String(log[0].sections || '').split(/[,>·]/)[0])) || '');\n const [opener] = take((job ? job.open : DRAW.openers).filter((o) => !lastOpen.includes(low(o))));\n const skip = take(['Split', 'Grid', 'Band', 'Statement', 'Steps', 'Figures'].filter((o) => o !== opener), 2);\n const [lead] = take(DRAW.leads[material]);\n if (has('json')) { console.log(JSON.stringify({ seed, material, job: jobName || null, shapes, hero, nav, footer, opener, skip, lead, action, native, close })); return; }\n console.log(`draw ${seed} (fresh each build; --seed ${seed} repeats it)${pictured.length ? ` · clear of the theme picture's ${pictured.join(' and ')}` : ''}`);\n console.log(`shape: ${shapes[0]} · hero: ${hero} · nav: ${nav} · footer: ${footer}`);\n console.log(`${job ? '' : `first section after the hero: ${opener} · `}leave out this time: ${skip.join(' and ')} · the hero leads with ${lead}, if the prompt has it`);\n console.log(job ? `job: ${jobName} · the action: ${action}\\nbuild these, in the order the shape suits (the first right after the hero): ${native.join('; ')} · close on: ${close} (anatomy.md § ${jobName})` : 'no --job given: name the page\\'s job (subscribe, book, visit, attend, buy, try, sign up, hire, join, listen) and draw again with --job');\n console.log(`photos: pass --seed ${seed} to every theme.mjs photos call, so this build's picks are its own, and take its first fitting pick for the hero rather than hunting for the library's single best frame`);\n console.log('take the draw unless the prompt\\'s material refuses a pick; name any swap in the Picks block, and stamp draw: ' + seed);\n}\n\nfunction deal() {\n const genre = flag('genre', 'editorial');\n if (!GENRES.includes(genre)) { console.error(`unknown genre \"${genre}\" (valid: ${GENRES.join(', ')})`); process.exit(2); }\n const prompt = flag('prompt', flag('brief', '')); // --brief is the older name\n const n = Number(flag('n', 3));\n const reroll = Number(flag('reroll', 0));\n const log = readLog(flag('log', '.hallmark/log.json'));\n const themes = loadIndex();\n const recent = log.slice(0, 3);\n const recentNames = new Set(recent.map((r) => String(r.theme || '').toLowerCase()));\n const lastAxes = recent[0] && recent[0].axes ? String(recent[0].axes) : null;\n const occupiedAxes = new Set(recent.map((r) => r.axes).filter(Boolean));\n const bw = new Set(promptWords(prompt).map(stem));\n const slug = `${promptWords(prompt).slice(0, 4).join('-') || 'prompt'}-${createHash('sha1').update(prompt.trim().toLowerCase()).digest('hex').slice(0, 4)}`;\n const seedBase = `${slug}·${projectSalt()}·${new Date().toISOString().slice(0, 10)}`;\n const seedKey = `${seedBase}·r${reroll}`;\n const rand = seededRand(seedBase);\n\n const df = new Map();\n for (const t of themes) for (const k of new Set(t.keywords.map((k) => k.toLowerCase()))) df.set(k, (df.get(k) || 0) + 1);\n const scored = themes.map((t) => {\n let fit = 0; const hits = [];\n for (const k of t.keywords) { const kw = k.toLowerCase().split(/\\s+/).map(stem); if (kw.every((w) => bw.has(w))) { const d = df.get(k.toLowerCase()); fit += d \u003c= 2 ? 3 : d \u003c= 4 ? 2 : 1; hits.push(k); } }\n if (t.genre === genre) fit += 2; else fit -= 1;\n const blocked = recentNames.has(t.name.toLowerCase()) || (lastAxes && lastAxes === triple(t));\n const sibling = occupiedAxes.has(triple(t));\n if (sibling && !blocked) fit -= 1;\n return { t, fit, hits, blocked, sibling, r: rand() };\n });\n const pool = scored.filter((s) => !s.blocked).sort((a, b) => b.fit - a.fit || a.r - b.r);\n const previously = new Set();\n for (let r = 0; r \u003c reroll; r++) for (const p of pickFrom(pool, n, previously)) previously.add(p.t.slug);\n const picked = pickFrom(pool, n, previously);\n if (!picked.length) { console.error(`nothing left to deal at reroll ${reroll}; use a lower --reroll`); process.exit(1); }\n\n if (has('json')) { console.log(JSON.stringify({ seed: seedKey, genre, deal: picked.map((s) => ({ ...s.t, fit: s.fit, hits: s.hits })), avoided: [...recentNames] })); return; }\n console.log(`deal: ${seedKey} · genre ${genre}${reroll ? ` · reroll ${reroll}` : ''}`);\n picked.forEach((s, i) => {\n const why = s.hits.length ? `fits: ${s.hits.slice(0, 4).join(', ')}` : (s.t.genre === genre ? 'genre fit' : 'adjacent genre');\n console.log(`${i + 1}. ${s.t.name} (${s.t.slug}) · ${triple(s.t)} · ${s.t.material} · ${why}${s.t.picture ? ` · ${s.t.picture}` : ''}${s.t.pictured ? `, pictured as ${s.t.pictured}` : ''}`);\n });\n if (recent.length) console.log(`avoid: ${recent.map((r) => `${r.theme || '?'} (${r.axes || 'no axes'})`).join(' · ')}`);\n console.log(`next: look at the pictures, pick the material this prompt wants, read references/themes/\u003cslug>.md, then theme.mjs scaffold \u003cslug> --dealt \"\u003cn>/${picked.length}, seed ${seedKey}\"`);\n}\n\nconst PAPER_BANDS = new Set(['light', 'mid', 'dark']);\nconst DISPLAY_CLASSES = new Set(['roman-serif', 'high-contrast-serif', 'slab-serif', 'grotesk-sans', 'grotesk-heavy', 'geometric-sans', 'rounded-sans', 'display-condensed', 'display-heavy', 'mono', 'pixel', 'handwritten', 'blackletter']);\nconst ACCENT_BAND = /^(warm|cool|neutral|chromatic-[a-z-]+)$/;\nconst listOf = (set) => [...set].join(', ');\n\nfunction check() {\n const axes = flag('axes');\n if (!axes && !flag('face') && flag('hue') == null) { usage(); process.exit(2); }\n const [pb, dc, ab] = (axes || '//').split('/').map((s) => s.trim());\n const unknown = !axes ? null : !PAPER_BANDS.has(pb) ? `paper band \"${pb}\" (valid: ${listOf(PAPER_BANDS)})`\n : !DISPLAY_CLASSES.has(dc) ? `display class \"${dc}\" (valid: ${listOf(DISPLAY_CLASSES)})`\n : !ACCENT_BAND.test(ab || '') ? `accent band \"${ab}\" (valid: warm, cool, neutral, chromatic-\u003chue name>)` : null;\n if (unknown) { console.error(`unknown ${unknown}`); process.exit(2); }\n const face = (flag('face', '') || '').toLowerCase().trim();\n const hue = flag('hue') != null ? Number(flag('hue')) : null;\n const themes = loadIndex();\n const log = readLog(flag('log', '.hallmark/log.json'));\n let bad = 0, near = 0;\n const named = new Set();\n const look = (t) => `look at references/themes/${t.slug}.jpg beside your plan and move an axis only if the two would read alike`;\n if (axes) for (const t of themes) {\n if (t.paper.band === pb && t.display.class === dc && t.accent.band === ab) { near++; named.add(t.slug); console.log(`near: ${t.name} sits on ${triple(t)} · ${look(t)}`); }\n }\n const toks = (x) => x.split(/[\\s-]+/).filter(Boolean);\n const sameFamily = (a, b) => { const A = toks(a), B = toks(b), n = Math.min(A.length, B.length); return n > 0 && A.slice(0, n).join(' ') === B.slice(0, n).join(' '); };\n if (face) for (const t of themes) {\n const tf = t.display.face.toLowerCase();\n if (sameFamily(tf, face)) { bad++; console.log(`collision: ${t.name} already sets its display in ${t.display.face} · pick a face no theme uses (fonts.md)`); }\n }\n if (hue != null) for (const t of themes) {\n // with axes: themes on the same paper and display class within 20 degrees; hue alone: any theme within 10\n if (named.has(t.slug)) continue;\n if (axes ? t.paper.band === pb && t.display.class === dc && hueGap(t.accent.hue, hue) \u003c= 20 : hueGap(t.accent.hue, hue) \u003c= 10) { near++; console.log(`near: ${t.name} is ${triple(t)} at hue ${t.accent.hue} · ${look(t)}`); }\n }\n const recent = axes ? log.slice(0, 3).filter((r) => r.axes === axes) : [];\n for (const r of recent) { near++; console.log(`recent: ${r.theme || 'a build'} on ${r.date || '?'} used ${axes} · vary another decision (the shape, the pictures, the type scale)`); }\n if (!bad && !near) { console.log(axes ? `clear: ${axes}${face ? ` in ${face}` : ''}${hue != null ? ` at hue ${hue}` : ''} lands on no catalog theme and no recent build` : [face && `clear: no catalog theme sets its display in ${flag('face')}`, hue != null && `clear: no catalog theme's accent sits within 10 degrees of hue ${hue}`].filter(Boolean).join('\\n')); return; }\n if (bad) process.exitCode = 1;\n}\n\nfunction list() {\n for (const t of loadIndex()) console.log(`${t.name.padEnd(12)} ${triple(t).padEnd(38)} ${t.genre.padEnd(15)} ${t.material}`);\n}\n\nconst TREATMENTS = { natural: {}, warm: { sepia: 0.28, saturate: 0.94 }, muted: { saturate: 0.65, contrast: 0.97 }, mono: { grayscale: 1, contrast: 1.18 } };\nconst ADJUST = { con: ['contrast', (n) => 1 + n / 100], sat: ['saturate', (n) => Math.max(0, 1 + n / 100)], vib: ['saturate', (n) => 1 + n / 200], exp: ['brightness', (n) => 1 + n / 100], high: ['brightness', (n) => 1 + n / 300], shad: ['brightness', (n) => 1 + n / 400], sepia: ['sepia', (n) => n / 100], blur: ['blur', (n) => n / 20] };\nconst r2 = (n) => Math.round(n * 100) / 100;\n\n// A theme's photo: line as CSS on every picture in a slot: filter functions, and for duotone or tint a\n// multiply plate under the photo and a lighten plate over it, both in the theme's own tokens.\nfunction treatmentOf(spec, tokens) {\n const parts = String(spec || 'natural').trim().split(/\\s+/);\n const f = new Map(), bad = [];\n let duo = null;\n const tok = (n) => { if (!tokens[`--color-${n}`]) bad.push(`no --color-${n} token`); return `var(--color-${n})`; };\n for (let i = 0; i \u003c parts.length; i++) {\n const p = parts[i];\n if (p in TREATMENTS) for (const [k, v] of Object.entries(TREATMENTS[p])) f.set(k, v);\n else if (p === 'duotone') { duo = [tok(parts[++i]), tok(parts[++i])]; f.set('grayscale', 1); if (!f.has('contrast')) f.set('contrast', 1.08); }\n else if (p === 'tint') { const t = tok(parts[++i]); const dark = (tokens['--color-paper'] || [1])[0] \u003c 0.4; duo = dark ? ['var(--color-paper)', t] : [`color-mix(in oklab, ${t}, black 55%)`, `color-mix(in oklab, ${t}, white 82%)`]; f.set('grayscale', 1); }\n else if (/^[a-z]+=-?\\d+$/.test(p) && p.split('=')[0] in ADJUST) { const [k, n] = p.split('='); const [fn, v] = ADJUST[k]; f.set(fn, k === 'vib' && f.has(fn) ? f.get(fn) * v(Number(n)) : v(Number(n))); }\n else bad.push(`\"${p}\" is not a treatment (natural, warm, muted, mono, duotone \u003cdark> \u003clight>, tint \u003ctoken>, or con|sat|vib|exp|high|shad|sepia|blur=\u003cn>)`);\n }\n const filter = [...f].map(([k, v]) => `${k}(${k === 'blur' ? `${r2(v)}px` : r2(v)})`).join(' ');\n const css = !filter && !duo ? '' : duo\n ? `[data-slot]:has(> img) { isolation: isolate; background: ${duo[1]}; outline: 1px solid color-mix(in oklab, var(--color-ink) 8%, transparent); outline-offset: -1px; }\\n[data-slot] > img { z-index: 0; filter: ${filter}; mix-blend-mode: multiply; }\\n[data-slot]:has(> img)::before { content: \"\"; position: absolute; inset: 0; z-index: 1; background: ${duo[0]}; mix-blend-mode: lighten; pointer-events: none; }`\n : `[data-slot] > img { filter: ${filter}; }`;\n return { css, bad };\n}\n\nfunction tokensOf(src, file) {\n const tok = {};\n for (const m of tokenBlockOf(src, file).matchAll(/(--color-[\\w-]+):\\s*oklch\\(\\s*([\\d.]+)%\\s+([\\d.]+)\\s+([\\d.]+)\\s*\\)/g)) tok[m[1]] = [Number(m[2]) / 100, Number(m[3]), Number(m[4])];\n return tok;\n}\n\nconst SYNONYMS = { cafe: ['coffee'], coffee: ['cafe', 'espresso'], restaurant: ['dining', 'dish'], bakery: ['bread', 'pastry'], bread: ['bakery', 'loaf'], wine: ['vineyard', 'grape'], cider: ['apple', 'orchard'], gin: ['botanical', 'spirit', 'distillery'], beer: ['brewery'], ski: ['snow', 'slope'], snow: ['winter'], sea: ['coast', 'ocean'], beach: ['coast', 'sea'], hotel: ['room', 'lobby'], office: ['desk', 'workspace'], studio: ['workshop'], workshop: ['studio', 'craft'], pottery: ['ceramic', 'clay'], ceramic: ['pottery', 'clay'], weaving: ['loom', 'textile'], textile: ['fabric'], fabric: ['textile'], tailor: ['sewing', 'fabric'], fashion: ['clothing'], garden: ['plant', 'flower'], farm: ['field', 'farmland'], city: ['street', 'urban'], train: ['railway', 'station'], concert: ['music', 'stage'], music: ['concert', 'record'], book: ['library', 'reading'], print: ['printing', 'press'], skate: ['skateboard', 'skatepark'], running: ['runner', 'marathon'], gym: ['fitness'], yoga: ['wellness'], lab: ['science', 'laboratory'], doctor: ['clinic', 'medical'], kid: ['child', 'children'], dog: ['pet'], cat: ['pet'] };\n\nasync function photos() {\n if (!existsSync(PHOTOS)) { console.error(`no photo library at ${PHOTOS}`); process.exit(1); }\n const lib = JSON.parse(readFileSync(PHOTOS, 'utf8')).photos;\n const subjects = positional();\n if (!subjects.length) { usage(); process.exit(2); }\n const n = Math.max(1, Number(flag('n', 3)) || 3);\n const orient = flag('orient', '');\n const slug = flag('theme', '');\n let header = null, treat = { css: '', bad: [] };\n if (slug) {\n const file = join(THEMES, `${slug}.md`);\n if (!isSlug(slug) || !existsSync(file)) { console.error(`no theme at ${file}`); process.exit(1); }\n const src = readFileSync(file, 'utf8');\n header = parseHeader(src, file);\n treat = treatmentOf(header.photo, tokensOf(src, file));\n if (treat.bad.length) console.error(`${slug}'s photo: line: ${treat.bad.join('; ')}`);\n }\n const recent = new Set(readLog(flag('log', '.hallmark/log.json')).slice(0, 5).flatMap((r) => Array.isArray(r.photos) ? r.photos : []));\n const avoidText = flag('avoid', '') && existsSync(flag('avoid', '')) ? readFileSync(flag('avoid', ''), 'utf8') : '';\n const avoid = { has: (id) => !!avoidText && (avoidText.includes(id) || avoidText.includes(id.replace(/ /g, '_'))) };\n const shape = (p) => { const r = p.w / p.h; return r > 1.18 ? 'landscape' : r \u003c 0.85 ? 'portrait' : 'square'; };\n const PHOTO_STOP = new Set(['seen', 'from', 'with', 'into', 'onto', 'over', 'under', 'behind', 'above', 'below', 'beside', 'near', 'close', 'view', 'shot', 'photo', 'image', 'picture', 'some', 'very', 'one', 'two', 'three', 'each', 'every', 'its', 'their', 'his', 'her', 'who', 'where', 'while', 'up', 'down', 'out']);\n const docs = lib.map((p) => { const t = new Set(); for (const x of p.tags) for (const w of words(x)) t.add(stem(w)); for (const w of words(p.subject)) t.add(stem(w)); t.add(stem(p.cat)); return t; });\n const df = new Map(); for (const d of docs) for (const w of d) df.set(w, (df.get(w) || 0) + 1);\n const idf = (w) => Math.log((lib.length + 1) / ((df.get(w) || 0) + 1)) + 0.3;\n // Close scores rotate from build to build, so two pages on one subject rarely share their photographs; --seed repeats a pick.\n const jitter = seededRand(`photos·${flag('seed', '') || randomBytes(3).toString('hex')}`);\n const used = new Set();\n const result = [];\n for (const subject of subjects) {\n const q = [...new Set(promptWords(subject).map(stem))].filter((w) => !PHOTO_STOP.has(w));\n const total = q.reduce((a, w) => a + idf(w), 0) || 1;\n const wantFace = /\\b(portrait|face|person|people)\\b/i.test(subject);\n const scored = lib.map((p, i) => {\n if (used.has(p.id) || avoid.has(p.id) || (orient && shape(p) !== orient)) return null;\n const d = docs[i]; let got = 0; const missing = [];\n for (const w of q) {\n if (d.has(w)) got += idf(w);\n else if ((SYNONYMS[w] || []).some((x) => d.has(stem(x)))) got += 0.7 * idf(w);\n else if ([...d].some((x) => { let k = 0; while (k \u003c x.length && k \u003c w.length && x[k] === w[k]) k++; return k >= 5 || (k >= 4 && Math.max(x.length, w.length) \u003c= 6 && k >= 0.8 * Math.min(x.length, w.length)); })) got += 0.8 * idf(w);\n else missing.push(w);\n }\n const cov = got / total;\n if (cov \u003c 0.45) return null;\n let s = cov * 10;\n for (const t of p.tags) if (t.includes(' ') && subject.toLowerCase().includes(t)) s += 1;\n if (p.q === 3) s += 0.8;\n if (p.tags.includes('overused')) s -= 0.8;\n if (p.people === 'face' && !wantFace) s -= 3;\n if (recent.has(p.id)) s -= 4;\n s += jitter() * (flag('seed', '') ? 4 : 2.5);\n if (header) { if (header.paper.band === 'dark' && p.tone !== 'light') s += 0.3; if (header.paper.band === 'light' && p.tone !== 'dark') s += 0.15; if ((header.accent.band === 'warm') === (p.temp === 'warm')) s += 0.2; }\n return { p, s, cov, missing };\n }).filter(Boolean).sort((a, b) => b.s - a.s).slice(0, n);\n for (const { p } of scored) used.add(p.id);\n result.push({ subject, picks: scored.map(({ p, s, cov, missing }) => ({ ...p, orient: shape(p), score: Math.round(s * 10) / 10, match: cov >= 0.75 ? 'close' : 'partial', ...(missing.length ? { missing } : {}), sizes: widthsOf(p), credit: creditOf(p) })) });\n }\n const saveDir = flag('save', '');\n if (saveDir) {\n mkdirSync(resolve(saveDir), { recursive: true });\n for (const r of result) for (const p of r.picks) {\n const w = p.sizes[p.sizes.length - 1];\n const file = join(resolve(saveDir), `${savedName(p.id)}-${w}.jpg`);\n try {\n const url = thumbOf(p, w);\n if (!url.startsWith('https://upload.wikimedia.org/')) throw new Error('not a Wikimedia Commons file');\n let res;\n for (let t = 0; t \u003c 4; t++) { res = await fetch(url, { headers: { 'User-Agent': 'Hallmark/2.0 (https://github.com/Nutlope/hallmark; theme.mjs photos --save)' }, signal: AbortSignal.timeout(60000) }); if (res.status !== 429) break; await new Promise((ok) => setTimeout(ok, 10000 * (t + 1))); }\n if (!res.ok) throw new Error(`HTTP ${res.status}`);\n if (!/^image\\//.test(res.headers.get('content-type') || '')) throw new Error(`not an image (${res.headers.get('content-type') || 'no content-type'})`);\n writeFileSync(file, Buffer.from(await res.arrayBuffer())); p.saved = file;\n } catch (e) { p.saved = null; p.error = String(e.message || e); }\n }\n }\n if (has('json')) { console.log(JSON.stringify({ theme: slug || null, treatment: header ? header.photo || 'natural' : null, subjects: result })); return; }\n if (header) console.log(`treatment: ${header.photo || 'natural'} (${slug})${treat.css ? '. It is CSS on every picture in a slot, written into base.css by the scaffold; a page built without it adds:\\n' + treat.css : ', no filter'}`);\n for (const r of result) {\n console.log(`\\n\"${r.subject}\"${r.picks.length ? '' : ': nothing in the library fits; try other words (the place, the material, the action), or leave a slot for the user\\'s photo'}`);\n for (const p of r.picks) {\n const src = p.saved || thumbOf(p, p.sizes.includes(1280) ? 1280 : p.sizes[p.sizes.length - 1]);\n const set = p.saved ? '' : ` srcset=\"${p.sizes.map((w) => `${thumbOf(p, w)} ${w}w`).join(', ')}\"`;\n const pos = p.fp ? ` style=\"object-position: ${Math.round(p.fp[0] * 100)}% ${Math.round(p.fp[1] * 100)}%\"` : '';\n console.log(` ${p.subject} · ${p.w}x${p.h} ${p.orient} · ${p.tone}, ${p.temp}${p.people !== 'none' ? `, ${p.people}` : ''}${p.match === 'partial' ? ` · partial match, no ${p.missing.join(', ')}` : ''}${p.error ? ` · not saved: ${p.error}` : ''}`);\n const [iw, ih] = p.saved ? [p.sizes[p.sizes.length - 1], Math.round(p.h * p.sizes[p.sizes.length - 1] / p.w)] : [p.w, p.h];\n console.log(` \u003cimg src=\"${src}\"${set}${p.saved ? '' : ' sizes=\"100vw\"'} width=\"${iw}\" height=\"${ih}\" alt=\"${p.subject}\"${pos}>`);\n console.log(` credit: ${p.credit}`);\n }\n }\n console.log('\\nset sizes to the layout (50vw for a half, 100vw full-bleed) and crop with the frame (aspect-ratio and object-fit: cover; object-position keeps the subject). Every photo carries its credit on the page: one line in the footer, \"Photographs: \u003ccredit>, \u003ccredit>\", each linked to its page (imagery.md § 2). Log the ids in the entry\\'s photos list.');\n}\n\nconst WIDTHS = [960, 1280, 1920];\n// The file name --save writes, which sloplint maps back to the library entry for its credit.\nfunction savedName(id) { return id.replace(/\\.[a-z]+$/i, '').normalize('NFKD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || `photo-${createHash('sha1').update(id).digest('hex').slice(0, 10)}`; }\n// Commons serves thumbnails at standard widths only, and none at or past the original's width.\nfunction widthsOf(p) { const w = WIDTHS.filter((x) => x \u003c p.w); return w.length ? w : [WIDTHS[0]]; }\nfunction thumbOf(p, w) {\n const name = p.file.split('/').pop();\n return `${p.file.replace('/wikipedia/commons/', '/wikipedia/commons/thumb/')}/${w}px-${p.thumbName || name}`;\n}\nfunction creditOf(p) { return `\u003ca href=\"${p.page}\">${p.author}\u003c/a> (${p.license}, Wikimedia Commons)`; }\n\nfunction tokenBlockOf(src, file) {\n const m = /```css\\s*\\n:root\\s*\\{([\\s\\S]*?)\\n\\}\\s*\\n```/.exec(src);\n if (!m) throw new Error(`${file}: no :root block in a css fence`);\n return m[1].replace(/\\/\\*[\\s\\S]*?\\*\\//g, '').split('\\n').map((l) => l.replace(/\\s+$/, '')).filter((l) => l.trim()).join('\\n');\n}\n\nconst SCALE = ` --space-3xs: 2px; --space-2xs: 4px; --space-xs: 8px; --space-sm: 12px; --space-md: 16px; --space-lg: 24px; --space-xl: 40px; --space-2xl: 64px; --space-3xl: 96px; --space-4xl: 144px;\n --text-xs: 0.8125rem; --text-sm: 0.875rem; --text-md: 1rem; --text-lg: 1.25rem; --text-xl: 1.5625rem; --text-2xl: 1.953rem; --text-3xl: 2.441rem;\n --text-display-s: clamp(2.25rem, 4vw + 0.5rem, 3.75rem); --text-display: clamp(2.75rem, 5vw + 1rem, 5.25rem);\n --text-section: clamp(2rem, 2.5vw + 1rem, 3.5rem); --text-title: clamp(1.25rem, 0.75vw + 0.875rem, 1.625rem); --text-lead: clamp(1.125rem, 0.35vw + 1rem, 1.375rem);\n --ease-out: cubic-bezier(0.16, 1, 0.3, 1); --ease-in: cubic-bezier(0.7, 0, 0.84, 0); --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);\n --dur-micro: 120ms; --dur-short: 220ms; --dur-long: 420ms;\n --color-error: oklch(52% 0.19 27); --color-success: oklch(50% 0.12 150);\n --container: 1440px; --gutter: 64px; --gap-columns: 32px; --section-gap-s: 120px; --section-gap: 144px; --section-gap-l: 176px;\n --z-base: 1; --z-raised: 10; --z-dropdown: 100; --z-sticky: 200; --z-sticky-nav: 300; --z-modal: 400; --z-toast: 500; --z-tooltip: 600;`;\n\nconst CUSTOM_TOKENS = ` --color-paper: FILL ME; --color-paper-2: FILL ME; --color-paper-3: FILL ME;\n --color-ink: FILL ME; --color-ink-2: FILL ME; --color-muted: FILL ME;\n --color-rule: FILL ME;\n --color-accent: FILL ME; --color-accent-ink: FILL ME; --color-focus: FILL ME;\n --font-display: \"FILL ME\", ui-serif, serif; --font-body: \"FILL ME\", ui-sans-serif, system-ui, sans-serif; --font-outlier: var(--font-body);\n --radius-card: 4px; --radius-pill: 999px; --rule-hair: 1px; --shadow-card: none;\n --tracking-display: -0.02em;`;\n\nconst BASE = `*, *::before, *::after { box-sizing: border-box; }\nhtml, body { overflow-x: clip; }\nhtml { scrollbar-color: var(--color-muted) var(--color-paper); scroll-padding-top: var(--space-3xl); }\nbody { margin: 0; background: var(--color-paper); color: var(--color-ink); font-family: var(--font-body); font-size: var(--text-md); line-height: 1.55; -webkit-font-smoothing: antialiased; caret-color: var(--color-accent); }\n::selection { background: color-mix(in oklab, var(--color-accent) 22%, transparent); color: var(--color-ink); }\n:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }\na { color: inherit; text-decoration-thickness: 1px; text-underline-offset: 0.16em; }\nh1, h2, h3 { margin: 0; font-family: var(--font-display); font-weight: var(--display-weight, 700); font-synthesis: none; letter-spacing: var(--tracking-display); line-height: 1.05; overflow-wrap: break-word; min-width: 0; text-wrap: balance; }\nh1 { max-width: 20ch; font-size: var(--text-display); } h2 { max-width: 30ch; font-size: var(--text-section); } h3 { max-width: 40ch; font-size: var(--text-title); line-height: 1.2; }\n.lead { font-size: var(--text-lead); line-height: 1.45; max-width: 52ch; }\np { margin: 0; text-wrap: pretty; }\np:not([class]) { max-width: 65ch; }\nimg, video { max-width: 100%; height: auto; display: block; }\nsvg { display: block; }\n[hidden] { display: none !important; }\nbutton, input, select, textarea { font: inherit; }\n.container { width: 100%; max-width: var(--container); margin-inline: auto; padding-inline: var(--gutter); box-sizing: border-box; }\n.num, .figure, td.num { font-variant-numeric: tabular-nums; }\n.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n[data-slot] { position: relative; overflow: hidden; background: linear-gradient(160deg, var(--color-paper-2, var(--color-paper)), var(--color-paper-3, var(--color-paper))); }\n[data-slot] > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 1; }\n[data-slot]:has(> img) > :not(img):not([data-keep]) { display: none !important; }\n@media (max-width: 60rem) { :root { --gutter: 24px; --section-gap-s: 80px; --section-gap: 96px; --section-gap-l: 120px; } }\n@media (max-width: 40rem) { :root { --gutter: 16px; --section-gap-s: 56px; --section-gap: 72px; --section-gap-l: 96px; --gap-columns: 16px; } }\n@media (prefers-reduced-motion: reduce) { *:not(.keep-motion), *:not(.keep-motion)::before, *:not(.keep-motion)::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } }`;\n\n// A custom run sets its own type: its base keeps the Floor's rules and drops the heading sizes, weight, leading and measure.\nconst BASE_CUSTOM = BASE.replace(/^h1, h2, h3 \\{[^}]*\\}$/m, 'h1, h2, h3 { margin: 0; font-family: var(--font-display); font-synthesis: none; overflow-wrap: break-word; min-width: 0; text-wrap: balance; }').replace(/^h1 \\{ max-width[^\\n]*\\n/m, '').replace(/^\\.lead \\{[^\\n]*\\n/m, '');\n\nconst SLOTS_JS = `window.hallmarkSlots = (m) => { for (const [k, v] of Object.entries(m)) { const s = document.querySelector(\\`[data-slot=\"\\${k}\"]\\`); if (!s) continue; const im = new Image(); im.src = typeof v === 'string' ? v : v.src; im.alt = (typeof v === 'object' && v.alt) || s.dataset.alt || ''; s.querySelectorAll(':scope > img').forEach((o) => o.remove()); s.prepend(im); } };\\ndocument.querySelectorAll('[data-slot] img').forEach((i) => { if (i.complete && !i.naturalWidth) i.remove(); else i.addEventListener('error', () => i.remove(), { once: true }); });`;\n\nfunction scaffold() {\n const brand = has('brand');\n const custom = has('custom') || brand;\n const depth = flag('redesign', '');\n if (depth && !['polish', 'restyle', 'full'].includes(depth)) { console.error('--redesign takes polish, restyle or full'); process.exit(2); }\n const tail = depth ? ` · redesign: ${depth}` : '';\n const slug = custom ? null : positional()[0];\n if (!custom && !slug) { usage(); process.exit(2); }\n const app = flag('scope', '') === 'app';\n const sheet = ['src/index.css', 'src/app/globals.css', 'app/globals.css', 'src/styles/globals.css', 'styles/globals.css', 'src/styles/global.css', 'src/main.css', 'src/styles.css', 'src/global.css', 'src/app.css', 'src/App.css', 'app/app.css'].find((f) => existsSync(f));\n const dir = resolve(flag('dir', sheet ? dirname(sheet) : '.'));\n let name = 'custom world';\n let tokens = CUSTOM_TOKENS;\n let header = null;\n if (!custom) {\n const file = join(THEMES, `${slug}.md`);\n if (!isSlug(slug) || !existsSync(file)) { console.error(`no theme at ${file}; run \\`theme.mjs list\\` for the slugs`); process.exit(1); }\n const src = readFileSync(file, 'utf8');\n header = parseHeader(src, file);\n name = header.name;\n tokens = tokenBlockOf(src, file);\n }\n const out = join(dir, 'tokens.css');\n const baseOut = join(dir, 'base.css');\n for (const f of [out, baseOut]) if (existsSync(f) && !has('force')) { console.error(`${f} exists; pass --force to overwrite it`); process.exit(1); }\n mkdirSync(dir, { recursive: true });\n const dark = header && header.paper.band === 'dark';\n const sized = app ? SCALE.replace(/ --text-xs:[^\\n]*\\n --text-display-s:[^\\n]*\\n --text-section:[^\\n]*\\n/, ' --text-xs: 0.75rem; --text-sm: 0.875rem; --text-md: 1rem; --text-lg: 1.2rem; --text-xl: 1.44rem; --text-2xl: 1.728rem; --text-3xl: 2.074rem;\\n --text-display-s: 1.44rem; --text-display: 1.728rem; --text-section: 1.2rem; --text-title: 1rem; --text-lead: 1rem;\\n') : SCALE;\n const scale = dark ? sized.replace('--color-error: oklch(52% 0.19 27); --color-success: oklch(50% 0.12 150);', '--color-error: oklch(72% 0.15 27); --color-success: oklch(76% 0.13 150);') : sized;\n const sheetSrc = sheet ? readFileSync(sheet, 'utf8') : '';\n const tw = /@import\\s+[\"']tailwindcss/.test(sheetSrc);\n const tw3 = !tw && /@tailwind\\s+(?:base|utilities)\\b/.test(sheetSrc);\n const scheme = header ? ` color-scheme: ${dark ? 'dark' : 'light'};\\n` : '';\n const decls = `${scale}\\n${tokens}`.split(/;(?![^()]*\\))(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)/).map((d) => d.trim()).filter(Boolean);\n const themed = decls.filter((d) => /^--(color|font|text|tracking|radius|shadow|ease)-/.test(d));\n const css = tw\n ? `@theme static {\\n${themed.map((d) => ` ${d};`).join('\\n')}\\n}\\n:root {\\n${scheme}${decls.filter((d) => !themed.includes(d)).map((d) => ` ${d};`).join('\\n')}\\n}\\n`\n : `:root {\\n${scheme}${scale}\\n${tokens}\\n}\\n`;\n writeFileSync(out, css);\n const own = !custom && /## Build hint[\\s\\S]*?(::selection\\s*\\{[^}]*\\})/.exec(readFileSync(join(THEMES, `${slug}.md`), 'utf8'));\n const base = own ? BASE.replace(/::selection \\{[^}]*\\}/, own[1].replace(/\\s+/g, ' ')) : custom && !brand ? BASE_CUSTOM : BASE;\n const steps = base.split('\\n').filter((l) => /^@media[^{]*\\{\\s*:root/.test(l));\n const photo = custom ? '' : treatmentOf(header.photo, tokensOf(readFileSync(join(THEMES, `${slug}.md`), 'utf8'), slug)).css;\n writeFileSync(baseOut, (tw ? base.split('\\n').filter((l) => !steps.includes(l)).join('\\n') : base) + (photo ? `\\n/* photographs: ${header.photo} */\\n${photo}` : '') + '\\n');\n if (tw) writeFileSync(out, readFileSync(out, 'utf8') + steps.join('\\n') + '\\n');\n const src = custom ? '' : readFileSync(join(THEMES, `${slug}.md`), 'utf8');\n const dial = /stated in the stamp \\(`([a-z0-9-]+):/.exec(src);\n const theme = brand ? 'brand' : custom ? 'custom' : `${name} (${flag('dealt', '') ? `deal ${flag('dealt', '')}` : 'by request'})`;\n const made = custom ? `\\n * paper: oklch(\u003c>) · accent: oklch(\u003c>) · display: \u003cface> · body: \u003cface>` : '';\n const axes = custom ? '\u003cpaper>/\u003cdisplay>/\u003caccent> · posture: \u003cposture>' : `${triple(header)} · posture: ${header.posture}`;\n const kept = depth ? ', or kept' : '';\n const stamp = app\n ? `/* Hallmark · shape: app screen · genre: ${header ? header.genre : '\u003cgenre>'} · theme: ${theme}${tail}${made}\\n * nav: \u003cthe shell${kept}> · signature: \u003cthe one object${kept}> · states: \u003cthe ones the screens have>${dial ? ` · ${dial[1]}: \u003cthe dials>` : ''}\\n * axes: ${axes} · contrast: pass · v${VERSION} */`\n : brand\n ? `/* Hallmark · shape: \u003cshape, or kept> · genre: \u003cgenre> · theme: brand${tail}${made} · hero: \u003cposture, or kept> · nav: \u003cshape, or kept> · footer: \u003cshape, or kept> · signature: \u003cthe one move, or kept>\\n * axes: ${axes} · contrast: pass · fold: pass · v${VERSION} */`\n : custom\n ? `/* Hallmark · shape: \u003cyour name for the structure> · genre: \u003cgenre> · theme: custom${tail} · job: \u003csubscribe|book|visit|attend|buy|try|sign up|hire|join|listen> · idea: \"\u003cone sentence>\" · vibe: \"\u003c4 to 8 words>\"${made} · hero: \u003cin your own words> · nav: \u003cshape> · footer: \u003cshape> · imagery: \u003cphotographs, the product, or none> · signature: \u003cthe one thing a visitor remembers>\\n * axes: ${axes} · contrast: pass · fold: pass · v${VERSION} */`\n : `/* Hallmark · shape: \u003cshape${kept}> · genre: ${header.genre} · theme: ${theme}${tail}${depth ? '' : ' · job: \u003csubscribe|book|visit|attend|buy|try|sign up|hire|join|listen> · draw: \u003cseed>'}\\n * hero: \u003cposture${kept}> · nav: \u003cshape${kept}> · footer: \u003cshape${kept}> · imagery: \u003cphotographs, the product, or none> · signature: \u003cthe one move>${dial ? ` · ${dial[1]}: \u003cthe dials>` : ''}\\n * axes: ${axes} · contrast: pass · fold: pass · v${VERSION} */`;\n console.log(`wrote ${out} (${brand ? 'the shared scale; fill every --color-* and --font-* from the palette and faces the pre-flight found' : custom ? 'a starting scale, yours to rewrite; fill every --color-* and --font-* before building' : `${name} tokens plus the shared scale`}) and ${baseOut} (the base rules the Floor already decided)${sheet && !flag('dir', '') ? `, beside ${sheet}` : ''}`);\n if (!custom && !flag('dealt', '')) {\n const pic = join(THEMES, `${slug}.jpg`);\n if (existsSync(pic)) console.log(`look at ${pic}: the material at full size${header.pictured ? `, from a page built as ${header.pictured}` : ''}. Take its palette balance, type voice and surfaces, never its layout; the room and the sections are layout.md.`);\n }\n console.log('');\n console.log('start the page\\'s stylesheet with these lines, then write only the page:');\n console.log(stamp);\n console.log('/* Hallmark · pre-emit critique: P_ H_ E_ S_ R_ V_ · weakest: _, \u003cwhat you did about it> */');\n console.log(tw || tw3 ? '@import \"./tokens.css\";' : '@import url(\"./tokens.css\");');\n console.log(tw || tw3 ? '@import \"./base.css\" layer(base);' : '@import url(\"./base.css\");');\n if (tw3) console.log('Tailwind 3: the stamp and both imports go above the @tailwind directives; an @import below them is dropped without an error.');\n if (tw) console.log('Tailwind 4: both imports go below @import \"tailwindcss\". tokens.css is written as an @theme block, so every colour, face, size, radius, shadow and ease is a utility (bg-paper, font-display, rounded-card); export-formats.md § Tailwind v4 has the names it shares with Tailwind.');\n if (app) return;\n console.log('');\n console.log('and, only if the page has data-slot containers, once before \u003c/body>:');\n console.log(`\u003cscript>${SLOTS_JS}\u003c/script>`);\n console.log('');\n console.log(`pictures (imagery.md): node ${join(HERE, 'theme.mjs')} photos \"\u003csubject>\" [\"\u003csubject>\" ...]${slug ? ` --theme ${slug}` : ''}`);\n}\n\nfunction validate(files) {\n let bad = 0;\n for (const f of files) {\n try {\n const src = readFileSync(f, 'utf8');\n const e = parseHeader(src, f);\n const problems = [];\n if (!PAPER_BANDS.has(e.paper.band)) problems.push(`paper band ${e.paper.band}`);\n if (!DISPLAY_CLASSES.has(e.display.class)) problems.push(`display class ${e.display.class}`);\n if (!ACCENT_BAND.test(e.accent.band)) problems.push(`accent band ${e.accent.band}`);\n if (!['editorial', 'modern-minimal', 'atmospheric', 'playful'].includes(e.genre)) problems.push(`genre ${e.genre}`);\n if (!['restrained', 'committed', 'full-palette', 'drenched'].includes(e.posture)) problems.push(`posture ${e.posture}`);\n if (e.keywords.length \u003c 8) problems.push(`only ${e.keywords.length} keywords`);\n if (/[\\u2014\\u2013]/.test(src)) problems.push('contains an em or en dash');\n if (Buffer.byteLength(src) > 8000) problems.push(`${Buffer.byteLength(src)} bytes, over 8,000`);\n for (const sec of ['Tokens', 'Type', 'Surfaces', 'Range', 'Controls', 'Motion', 'Voice', 'Do-nots', 'Build hint']) if (!new RegExp(`^## ${sec}\\\\b`, 'm').test(src)) problems.push(`missing the ${sec} section`);\n if (!/^photo:/m.test(src)) problems.push('no photo: line in the header (natural, warm, muted, mono, duotone \u003cdark> \u003clight> or tint \u003ctoken>, imagery.md § 5)');\n else { const t = treatmentOf(/^photo:\\s*(.*)$/m.exec(src)[1], tokensOf(src, f)); if (t.bad.length) problems.push(`photo: ${t.bad.join('; ')}`); }\n if (/\\b(?:hand-built|hand-drawn|drawn) (?:svg|illustration|object|figure)|\\bsvg (?:illustration|drawing|figure)/i.test(src)) problems.push('prescribes a drawn illustration (imagery.md § 7, gate 80)');\n for (const m of src.matchAll(/https:\\/\\/api\\.fontshare\\.com\\/v2\\/css\\?[^\"'\\s>]*/g)) if ((m[0].match(/f\\[\\]=/g) || []).length > 1) problems.push('a Fontshare link loading more than one family (Fontshare may drop all but the first; use one link per family)');\n if (/^signature:/m.test(src)) problems.push('a signature: line in the header (a theme is a material; the page finds its signature in the prompt)');\n if (/object family|the one object|signature object|signature move/i.test(src)) problems.push('names an object or a signature move (a theme carries no components)');\n if (/\\b(?:generous|plenty of) (?:air|whitespace|white space)|(?:air|whitespace|white space|space) (?:carr(?:y|ies)|doing|does) (?:the|every)/i.test(src)) problems.push('a rule about space (layout.md owns room and density)');\n if (/--tracking-label/.test(src)) problems.push('declares --tracking-label (labels are untracked, gate 67)');\n if (/font-variant(-caps)?:\\s*[a-z-]*small-caps|small caps|small capitals|small-caps/i.test(src)) problems.push('prescribes small caps (gate 67)');\n if (/eyebrow|kicker|overline/i.test(src)) problems.push('names an eyebrow (gate 54)');\n if (/→|Learn more/.test(src)) problems.push('arrow or Learn-more copy (gate 63)');\n for (const m of src.matchAll(/^[^\\n]*text-transform:\\s*uppercase[^\\n]*$/gm)) {\n const line = m[0];\n const ls = /letter-spacing:\\s*(-?[\\d.]+)em/.exec(line);\n const isDisplay = /\\.display|h1|h2|\\.marquee|wordmark/.test(line);\n if (ls && parseFloat(ls[1]) > 0.045) problems.push(`uppercase tracked ${ls[1]}em in the build hint (gate 67)`);\n else if (!isDisplay && /label|caption|meta|\\bth\\b|small|tag|chip|badge|nav|count/.test(line)) problems.push(`uppercase label rule in the build hint: ${line.trim().slice(0, 60)} (gate 67)`);\n }\n const hint = (/## Build hint[\\s\\S]*?```css\\n([\\s\\S]*?)```/.exec(src) || [])[1] || '';\n for (const m of hint.matchAll(/(?:transition|animation)[^;{}]*?\\b(\\d{2,3})ms\\b/g)) problems.push(`raw duration ${m[1]}ms in the build hint (use --dur-micro, --dur-short or --dur-long)`);\n if (/(?:transition|animation)[^;{}]*(?\u003c![-\\w])ease(?:-in-out|-in|-out)?\\b/.test(hint)) problems.push('bare ease keyword in the build hint (use --ease-out, --ease-in or --ease-in-out)');\n for (const m of hint.matchAll(/(?\u003c![-\\w])(black|white)\\b(?!-)/g)) problems.push(`named colour ${m[1]} in the build hint (route it through a token)`);\n for (const m of hint.matchAll(/\\b(?:padding|margin|gap|row-gap|column-gap)(?:-[a-z-]+)?\\s*:\\s*([^;{}]+)/g)) {\n const raw = m[1].replace(/var\\([^)]*\\)/g, ' ').replace(/calc\\([^)]*\\)/g, ' ');\n for (const px of raw.match(/(?\u003c![\\w.-])(\\d+)px\\b/g) || []) if ([2, 4, 8, 12, 16, 24, 40, 64, 96, 144].includes(parseInt(px, 10))) problems.push(`raw ${px} spacing in the build hint where a --space-* token exists (24a)`);\n }\n const fence = tokenBlockOf(src, f);\n if (/\\/\\*/.test(fence)) problems.push('comment inside the token block');\n if (!existsSync(f.replace(/\\.md$/, '.jpg'))) console.log(`${f}: note, no picture yet (scripts/picture.mjs)`);\n if (!/waive 22/.test(src)) for (const m of fence.matchAll(/(--color-(?:paper|paper-2|paper-3|ink|ink-2|muted|rule|rule-2)):\\s*oklch\\(\\s*[\\d.]+%\\s+([\\d.]+)/g)) if (Number(m[2]) \u003c 0.005) problems.push(`${m[1]} chroma ${m[2]} is under the 0.005 floor (gate 22), or the file names waive 22`);\n for (const k of ['--color-paper', '--color-ink', '--color-muted', '--color-rule', '--color-accent', '--color-accent-ink', '--color-focus', '--font-display', '--font-body', '--radius-card', '--tracking-display', '--display-weight']) if (!fence.includes(k + ':')) problems.push(`token block lacks ${k}`);\n const tok = {};\n for (const m of fence.matchAll(/(--color-[\\w-]+):\\s*oklch\\(\\s*([\\d.]+)%\\s+([\\d.]+)\\s+([\\d.]+)\\s*\\)/g)) tok[m[1]] = [Number(m[2]) / 100, Number(m[3]), Number(m[4])];\n const pairs = [['--color-ink-2', '--color-paper'], ['--color-muted', '--color-paper'], ['--color-muted', '--color-paper-2'], ['--color-accent-ink', '--color-accent']];\n for (const [fg, bg] of pairs) if (tok[fg] && tok[bg]) { const r = contrastOf(tok[fg], tok[bg]); if (r \u003c 4.5) problems.push(`${fg} on ${bg} is ${r.toFixed(2)}:1, under 4.5 (gate 40)`); }\n if (problems.length) { bad++; console.log(`${f}: ${problems.join('; ')}`); } else console.log(`${f}: ok (${e.name}, ${triple(e)}, ${src.length} bytes)`);\n } catch (err) { bad++; console.log(`${f}: ${err.message}`); }\n }\n process.exitCode = bad ? 1 : 0;\n}\n\ntry {\n if (cmd === 'deal') deal();\n else if (cmd === 'draw') draw();\n else if (cmd === 'check') check();\n else if (cmd === 'scaffold') scaffold();\n else if (cmd === 'log') addLog();\n else if (cmd === 'photos') await photos();\n else if (cmd === 'index') { const e = buildIndex(); console.log(`indexed ${e.length} themes into ${INDEX}`); }\n else if (cmd === 'list') list();\n else if (cmd === 'validate') {\n const files = args.slice(1).filter((a) => !a.startsWith('--') && basename(a) !== 'README.md');\n if (!files.length) { usage(); process.exit(2); }\n validate(files);\n }\n else { usage(); process.exit(2); }\n} catch (e) {\n console.error(String(e.message || e)); process.exit(1);\n}\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":58940,"content_sha256":"246f8773c5772986c1f382a8c60e7e2194df4b8c04851f59fd4ff216accd158f"},{"filename":"scripts/theme.test.mjs","content":"import assert from 'node:assert/strict';\nimport { spawnSync } from 'node:child_process';\nimport { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport { join } from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport test from 'node:test';\n\ntest('validate accepts a theme with either LF or CRLF line endings', () => {\n const source = readFileSync(new URL('../references/themes/hum.md', import.meta.url), 'utf8').replace(/\\r\\n/g, '\\n');\n const cli = fileURLToPath(new URL('./theme.mjs', import.meta.url));\n const dir = mkdtempSync(join(tmpdir(), 'hallmark-theme-'));\n try {\n for (const [name, text] of [['lf', source], ['crlf', source.replace(/\\n/g, '\\r\\n')]]) {\n assert.ok(Buffer.byteLength(text) \u003c 8000, 'fixture must stay below the independent size limit');\n const file = join(dir, `${name}.md`);\n writeFileSync(file, text);\n const result = spawnSync(process.execPath, [cli, 'validate', file], { encoding: 'utf8' });\n assert.equal(result.status, 0, `${name}: ${result.stdout}${result.stderr}`);\n assert.match(result.stdout, /: ok \\(Hum,/);\n }\n } finally {\n rmSync(dir, { recursive: true, force: true });\n }\n});\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":1223,"content_sha256":"ce427fdd47290584c6707ff2a14db978db8a40c3fbd9f8fb19cc34bb823d1edc"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"Hallmark","type":"text"}]},{"type":"paragraph","content":[{"text":"Makes the UIs a coding assistant generates look made, not generated: a hard floor against the defaults every model was trained into, a dealt direction so consecutive builds do not converge, one idea per screen. Above the floor the page is yours: you are a capable designer.","type":"text"}]},{"type":"paragraph","content":[{"text":"Powered by Together AI.","type":"text","marks":[{"type":"strong"}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"The Floor","type":"text"}]},{"type":"paragraph","content":[{"text":"Binds every output in every harness, none of it waivable, even when nothing else is read.","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Every colour and every font goes through a token declared at ","type":"text"},{"text":":root","type":"text","marks":[{"type":"code_inline"}]},{"text":" (or a declared theme block). No literal colour or ","type":"text"},{"text":"font-family","type":"text","marks":[{"type":"code_inline"}]},{"text":" past the token block. (48)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Never ","type":"text"},{"text":"transition: all","type":"text","marks":[{"type":"code_inline"}]},{"text":". Never animate ","type":"text"},{"text":"width","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"height","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"top","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"left","type":"text","marks":[{"type":"code_inline"}]},{"text":". (10a)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Never one italic word inside a roman heading. (38i)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"overflow-x: clip","type":"text","marks":[{"type":"code_inline"}]},{"text":" on both ","type":"text"},{"text":"html","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"body","type":"text","marks":[{"type":"code_inline"}]},{"text":". Never ","type":"text"},{"text":"hidden","type":"text","marks":[{"type":"code_inline"}]},{"text":", never ","type":"text"},{"text":"100vw","type":"text","marks":[{"type":"code_inline"}]},{"text":". (34)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Image-bearing grid tracks use ","type":"text"},{"text":"minmax(0, 1fr)","type":"text","marks":[{"type":"code_inline"}]},{"text":"; display heads carry ","type":"text"},{"text":"overflow-wrap: break-word; min-width: 0","type":"text","marks":[{"type":"code_inline"}]},{"text":". (50)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Every interactive element has ","type":"text"},{"text":":focus-visible","type":"text","marks":[{"type":"code_inline"}]},{"text":", appearing instantly. (26a, 15)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Every animation has a ","type":"text"},{"text":"prefers-reduced-motion: reduce","type":"text","marks":[{"type":"code_inline"}]},{"text":" fallback, and content is visible with scripting off. (27, 62)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Never an invented metric, testimonial, logo wall or case-study count. (46a)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Never an eyebrow, and never a label in tracked capitals or small caps: labels, captions, table heads, tags and nav links are the body face (or the theme's label face), a step smaller, sentence or lower case, untracked. (54, 67)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Never middle-dot-joined meta strings, arrows glued to link text, or decorative status dots. (63)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Never a purple-to-blue gradient, a card inside a card, an emoji as an icon, or a hand-drawn browser or OS window. (2a, 4, 30a, 47a)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The hero's headline, one sentence and primary action are all visible at 1280 x 800 without scrolling. (44b)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"About 80 words on any one screen; the hero's text is the headline, one sentence and its actions. (58)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The container is capped and centred: at 1920 the gutters grow, the measure and the grids do not. (44d)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The first non-empty line of the CSS source is the Hallmark stamp, then the critique line, then any waivers. (20)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If Node is available, run ","type":"text"},{"text":"node \u003cskill-dir>/scripts/sloplint.mjs \u003coutput> --render","type":"text","marks":[{"type":"code_inline"}]},{"text":" before handing back. Fix every ","type":"text"},{"text":"FAIL","type":"text","marks":[{"type":"code_inline"}]},{"text":"; fix or waive every ","type":"text"},{"text":"REFLEX","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Above the Floor, a ","type":"text"},{"text":"Reflex","type":"text","marks":[{"type":"strong"}]},{"text":" gate is a default a model falls into, waivable on the record with a real reason, and a ","type":"text"},{"text":"Finish","type":"text","marks":[{"type":"strong"}]},{"text":" check is craft detail, answered, never blocking (","type":"text"},{"text":"references/gates.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/gates.md","title":null}},{"type":"code_inline"}]},{"text":" § 1 and § 2).","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Flow","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Pre-flight: read the project before asking anything.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Ask once: material, audience, use, tone.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Shape: design the page, then name it.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Direction: deal a theme, or design freely when asked for custom.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Load the layout, imagery, craft, genre and theme files and ","type":"text"},{"text":"gates.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" § 1 to § 4.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The Picks block, once.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Build: the hero first, then the page; stamp, ","type":"text"},{"text":"tokens.css","type":"text","marks":[{"type":"code_inline"}]},{"text":", log.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Sweep: ","type":"text"},{"text":"sloplint --render","type":"text","marks":[{"type":"code_inline"}]},{"text":" until clean, the judged gates, the finish review, the material ask.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Verbs,","type":"text","marks":[{"type":"strong"}]},{"text":" only when the user names Hallmark: ","type":"text"},{"text":"redesign \u003ctarget>","type":"text","marks":[{"type":"code_inline"}]},{"text":" (a full redesign keeping content and wiring) and ","type":"text"},{"text":"audit \u003ctarget>","type":"text","marks":[{"type":"code_inline"}]},{"text":" (a report); read ","type":"text"},{"text":"references/redesign.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/redesign.md","title":null}},{"type":"code_inline"}]},{"text":" first. Any other change to an existing design is not a Hallmark run: do it as asked, in the project's own system.","type":"text"}]},{"type":"paragraph","content":[{"text":"Codex CLI and OpenCode read ","type":"text"},{"text":"references/flow.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" § Harnesses first; other harnesses run this file as written.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"0. Pre-flight","type":"text"}]},{"type":"paragraph","content":[{"text":"Signals, in order: ","type":"text"},{"text":"design.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"DESIGN.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" at the root (a locked system: it overrides everything below, and pages share it); ","type":"text"},{"text":".hallmark/brand-spec.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" or a prompt naming a real brand (run ","type":"text"},{"text":"references/brand-truth.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/brand-truth.md","title":null}},{"type":"code_inline"}]},{"text":"; never theme a real brand from memory); the project's fonts, palette, spacing, motion libraries and framework (","type":"text"},{"text":"package.json","type":"text","marks":[{"type":"code_inline"}]},{"text":", Tailwind config, ","type":"text"},{"text":":root","type":"text","marks":[{"type":"code_inline"}]},{"text":", tokens, font links); ","type":"text"},{"text":"the product itself","type":"text","marks":[{"type":"strong"}]},{"text":": a URL, a running dev server, a project that starts with one command or an app on the machine, which the build captures with ","type":"text"},{"text":"scripts/capture.mjs","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"imagery.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" § 4); an Inspo MCP server (","type":"text"},{"text":"mcp__inspo__*","type":"text","marks":[{"type":"code_inline"}]},{"text":" tools: signal 8, ","type":"text"},{"text":"flow.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" § Signal 8; a ","type":"text"},{"text":"/hallmark","type":"text","marks":[{"type":"code_inline"}]},{"text":" prompt, Hallmark named or a custom ask beats it); ","type":"text"},{"text":"TOGETHER_API_KEY","type":"text","marks":[{"type":"code_inline"}]},{"text":" set (signal 9, the comp step).","type":"text"}]},{"type":"paragraph","content":[{"text":"Output one block (file:line citations, what is preserved, what is introduced), cached in ","type":"text"},{"text":".hallmark/preflight.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"flow.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" § Pre-flight).","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"1. Ask once","type":"text"}]},{"type":"paragraph","content":[{"text":"One message, even on a five-word prompt:","type":"text"}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"Before I build, four things. 1. ","type":"text","marks":[{"type":"em"}]},{"text":"Material","type":"text","marks":[{"type":"strong"},{"type":"em"}]},{"text":", the one that matters most: what can the page show? Screenshots or a screen recording (or point me at the app, its URL or its folder and I will run it and take them), your photos, real numbers (customers, uptime, prices), a quote with the name of who said it, logos you may use. Paste them or give paths; without photos I place stand-ins from Hallmark's photo library that your own replace later, and I never invent a number, a quote or a logo. 2. ","type":"text","marks":[{"type":"em"}]},{"text":"Audience","type":"text","marks":[{"type":"strong"},{"type":"em"}]},{"text":": who uses this and what do they care about? 3. ","type":"text","marks":[{"type":"em"}]},{"text":"Use","type":"text","marks":[{"type":"strong"},{"type":"em"}]},{"text":": the one action the page should drive. 4. ","type":"text","marks":[{"type":"em"}]},{"text":"Tone","type":"text","marks":[{"type":"strong"},{"type":"em"}]},{"text":": pick an extreme (editorial, brutalist, soft, utilitarian, luxury, playful, technical, austere); \"clean and modern\" is not a tone. Plus the vibe in 4 to 8 words and an anchor colour if you have one. Or say ","type":"text","marks":[{"type":"em"}]},{"text":"go ahead","type":"text","marks":[{"type":"strong"},{"type":"em"}]},{"text":" and I will infer and tell you what I picked.","type":"text","marks":[{"type":"em"}]}]}]},{"type":"paragraph","content":[{"text":"Skipped fields are opt-outs: infer them, state the inferences in one line at the top of the reply, stamp them; no follow-up questions. Facts are never inferred: a number, a quote or a logo not given is absent; a picture not given is a stand-in in a slot. When the prompt answers all four, or no one can answer (a headless run, a batch), do not ask.","type":"text"}]},{"type":"paragraph","content":[{"text":"Genre,","type":"text","marks":[{"type":"strong"}]},{"text":" silently: ","type":"text"},{"text":"AI tool, generative, music, video, voice, dark, atmospheric","type":"text","marks":[{"type":"em"}]},{"text":" → ","type":"text"},{"text":"atmospheric","type":"text","marks":[{"type":"strong"}]},{"text":"; ","type":"text"},{"text":"SaaS, enterprise, API, platform, developer tool, infra, B2B","type":"text","marks":[{"type":"em"}]},{"text":" → ","type":"text"},{"text":"modern-minimal","type":"text","marks":[{"type":"strong"}]},{"text":"; ","type":"text"},{"text":"playful, fun, friendly, soft, kids, games, community","type":"text","marks":[{"type":"em"}]},{"text":" → ","type":"text"},{"text":"playful","type":"text","marks":[{"type":"strong"}]},{"text":"; otherwise ","type":"text"},{"text":"editorial","type":"text","marks":[{"type":"strong"}]},{"text":", which holds most consumer and family prompts. Once a theme is dealt, its genre wins (genre file, stamp, ","type":"text"},{"text":"--genre","type":"text","marks":[{"type":"code_inline"}]},{"text":"). When two non-default genres fire, add one either-or to the message, or on go ahead pick and say so.","type":"text"}]},{"type":"paragraph","content":[{"text":"Scope.","type":"text","marks":[{"type":"strong"}]},{"text":" A prompt naming one element (button, input, card, modal, nav, footer, tab strip, chip) or saying \"just the X\" is a component (","type":"text"},{"text":"flow.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" § Component scope). A screen someone works in (a dashboard, an admin, settings, a tool) is an app screen (","type":"text"},{"text":"flow.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" § App screens).","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"2. Shape","type":"text"}]},{"type":"paragraph","content":[{"text":"Material first.","type":"text","marks":[{"type":"strong"}]},{"text":" The inventory, one line: ","type":"text"},{"text":"real","type":"text","marks":[{"type":"em"}]},{"text":" (every concrete thing the prompt and the project supply: name, claim, numbers, places, dates, images), ","type":"text"},{"text":"pictures","type":"text","marks":[{"type":"em"}]},{"text":" (what the page shows and where each comes from: the user's, screenshots of the real product, the product built, the library), ","type":"text"},{"text":"absent","type":"text","marks":[{"type":"em"}]},{"text":" (every proof it will not have: logos, quotes, metrics nobody gave). The shape follows it: a page with pictures leads with them (","type":"text"},{"text":"imagery.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" § 1); a page about words carries type and facts (Long Document, Letter, Index-First, Manifesto).","type":"text"}]},{"type":"paragraph","content":[{"text":"The job.","type":"text","marks":[{"type":"strong"}]},{"text":" Name what a visitor comes to do (subscribe, book, visit, attend, buy, try, sign up, hire, join, listen) and read that job in ","type":"text"},{"text":"references/anatomy.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/anatomy.md","title":null}},{"type":"code_inline"}]},{"text":": where real sites in the category put the action, the sections they are built around (the menu, the lineup, the issues, the record), and how they end. The page is built from that anatomy, never from a default run of figures, steps and an email band (85).","type":"text"}]},{"type":"paragraph","content":[{"text":"Then design the page: its shape, how it opens, each section's content and composition, the nav, the footer, what carries the hero. Starting points: 21 shapes in ","type":"text"},{"text":"references/macrostructures.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/macrostructures.md","title":null}},{"type":"code_inline"}]},{"text":" (load only the file you pick); compositions, navs and footers in ","type":"text"},{"text":"layout.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"The draw.","type":"text","marks":[{"type":"strong"}]},{"text":" On a theme build, once Step 3 has picked the theme, run ","type":"text"},{"text":"node \u003cskill-dir>/scripts/theme.mjs draw --material photo|product|words --job \u003cjob> --theme \u003cslug> [--log .hallmark/log.json]","type":"text","marks":[{"type":"code_inline"}]},{"text":". Clear of the log and the theme's picture, it deals a shape, the hero posture, the nav, the footer, the first section, where the action sits, two compositions to leave out and what the hero leads with. Take it unless the material refuses a pick, and name any swap in the Picks block: one prompt in one theme, built twice, makes two pages. ","type":"text"},{"text":"Mirror test:","type":"text","marks":[{"type":"strong"}]},{"text":" would a neighbouring prompt in the same category get this exact combination? If yes, change one decision and say which.","type":"text"}]},{"type":"paragraph","content":[{"text":"Signature.","type":"text","marks":[{"type":"strong"}]},{"text":" The one thing a visitor would remember: the prompt's strongest real thing shown at its best (the photograph that is the place, the product doing its one job, a price set huge because it is the offer, a control to try). Make it in the theme's material (on a custom run, in its own look), show it on the first screen, name it in the Picks block and the stamp. A number drawn as marks is a device, not a signature (82).","type":"text"}]},{"type":"paragraph","content":[{"text":"Hold all of it; it is said once, at Step 5.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"3. Direction","type":"text"}]},{"type":"paragraph","content":[{"text":"The first match wins.","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The user named a theme","type":"text","marks":[{"type":"strong"}]},{"text":" → build it (stamp ","type":"text"},{"text":"theme: \u003cname> (by request)","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The user asked for custom","type":"text","marks":[{"type":"strong"}]},{"text":" (\"custom\", \"derive it\", \"bespoke\", \"no theme\", \"a world of its own\") → ","type":"text"},{"text":"references/direction.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/direction.md","title":null}},{"type":"code_inline"}]},{"text":": the design is yours. It replaces the draw, the shapes, ","type":"text"},{"text":"layout.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", the genre file and Step 6's composition and fold rules; the Floor, the gates and the sweep hold. Silence never takes this route.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Everything else deals a theme:","type":"text","marks":[{"type":"strong"}]}]}]}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"node \u003cskill-dir>/scripts/theme.mjs deal --genre \u003cgenre> --prompt \"\u003cthe prompt>\" [--log .hallmark/log.json]","type":"text"}]},{"type":"paragraph","content":[{"text":"It deals three themes that fit the prompt and clear the log. Look at the three pictures, pick the one whose material can carry the prompt (motion, photographs, code), say which and why, and load only ","type":"text"},{"text":"references/themes/\u003cslug>.md","type":"text","marks":[{"type":"code_inline"}]},{"text":". Re-deal only when asked (","type":"text"},{"text":"--reroll 1","type":"text","marks":[{"type":"code_inline"}]},{"text":"). No Node: pick from ","type":"text"},{"text":"references/themes/index.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" by hand and stamp ","type":"text"},{"text":"deal: manual","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"A theme is a material, not a plan: it never sets what the page contains or in what order. Where it and a shape, genre or craft file disagree on material, controls or motion, the theme wins; the Floor and the user's own words beat both.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"4. Load","type":"text"}]},{"type":"paragraph","content":[{"text":"Exactly these, for a page build: ","type":"text"},{"text":"references/layout.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/layout.md","title":null}},{"type":"code_inline"}]},{"text":" · ","type":"text"},{"text":"references/anatomy.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/anatomy.md","title":null}},{"type":"code_inline"}]},{"text":", the job's part · ","type":"text"},{"text":"references/imagery.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/imagery.md","title":null}},{"type":"code_inline"}]},{"text":" · ","type":"text"},{"text":"references/craft.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/craft.md","title":null}},{"type":"code_inline"}]},{"text":" · ","type":"text"},{"text":"references/genres/\u003cgenre>.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" · ","type":"text"},{"text":"references/themes/\u003cslug>.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" and its picture, already seen at the deal (a named theme: look at ","type":"text"},{"text":"\u003cslug>.jpg","type":"text","marks":[{"type":"code_inline"}]},{"text":" now). From the theme take its palette balance, type voice, surfaces and its Range (the sections it makes without a photograph), never its layout · ","type":"text"},{"text":"references/gates.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/gates.md","title":null}},{"type":"code_inline"}]},{"text":" § 1 to § 4. A custom run loads ","type":"text"},{"text":"direction.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" instead of the layout, genre and theme files.","type":"text"}]},{"type":"paragraph","content":[{"text":"Only when needed, in ","type":"text"},{"text":"references/","type":"text","marks":[{"type":"code_inline"}]},{"text":": one macrostructure file · ","type":"text"},{"text":"microinteractions.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" for stateful UI · ","type":"text"},{"text":"interaction-and-states.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" for forms · ","type":"text"},{"text":"responsive.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" for an unusual layout · ","type":"text"},{"text":"texture.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" when the theme names texture · ","type":"text"},{"text":"scroll-choreography.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" for a scroll story · ","type":"text"},{"text":"dark-mode.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" for both modes · ","type":"text"},{"text":"data-viz.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" for charts · ","type":"text"},{"text":"fonts.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" on custom runs · ","type":"text"},{"text":"comp.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" on signal 9 (comps, generated stills) · ","type":"text"},{"text":"design-md.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" to lock the system.","type":"text"}]},{"type":"paragraph","content":[{"text":"gates.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" § 5 waits for Step 7, § 6 for the reviews; loading more is the largest avoidable cost.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"5. The Picks block","type":"text"}]},{"type":"paragraph","content":[{"text":"The run's only recital of its decisions, and the user's redirect window:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"markdown"},"content":[{"text":"**Hallmark · v2.0.0**\n\n- **Shape** · Workbench, from draw 7f3a2c · genre modern-minimal · job: sign up, the action in the hero and beside the price\n- **Theme** · Coral, dealt 2/3 (warm grey, one coral signal, General Sans, soft pills) · clears Cobalt on paper band\n- **Chrome** · nav: edge-aligned, a wordmark and one action · footer: a mast, the wordmark over the links\n- **Hero** · Stage: head on columns 1 to 5, the slot on 6 to 12 at 560px, centred\n- **Signature** · the invoice list chasing a late payment, built live in the hero\n- **Sections** · Hero (the invoice list) · Pinned (one late payment, chased in three panels) · Switcher (the client's view and yours) · Table (pricing) · Footer\n- **Material** · real: the 2-minute setup, the CSV import, Lisbon · pictures: the invoice list built in the hero, library photos of a Lisbon studio (muted) · absent: logos, quotes, metrics\n- **Sweep** · run after build","type":"text"}]},{"type":"paragraph","content":[{"text":"A custom run's rows are in ","type":"text"},{"text":"direction.md","type":"text","marks":[{"type":"code_inline"}]},{"text":". Then one quiet line, skipped in component scope or when ","type":"text"},{"text":"design.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" exists: ","type":"text"},{"text":"System portable? Say ","type":"text","marks":[{"type":"em"}]},{"text":"lock the system","type":"text","marks":[{"type":"code_inline"},{"type":"em"}]},{"text":" for a ","type":"text","marks":[{"type":"em"}]},{"text":"design.md","type":"text","marks":[{"type":"code_inline"},{"type":"em"}]},{"text":".","type":"text","marks":[{"type":"em"}]},{"text":" On signal 9, ","type":"text"},{"text":"comp.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" runs here.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"6. Build","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Start from the scaffold.","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"node \u003cskill-dir>/scripts/theme.mjs scaffold \u003cslug>","type":"text","marks":[{"type":"code_inline"}]},{"text":" (with the deal's ","type":"text"},{"text":"--dealt","type":"text","marks":[{"type":"code_inline"}]},{"text":"; ","type":"text"},{"text":"--custom","type":"text","marks":[{"type":"code_inline"}]},{"text":" on a custom run) writes ","type":"text"},{"text":"tokens.css","type":"text","marks":[{"type":"code_inline"}]},{"text":" (the theme's tokens and the shared scale) and ","type":"text"},{"text":"base.css","type":"text","marks":[{"type":"code_inline"}]},{"text":" (what the Floor already decided) beside the project's stylesheet, and prints the stamp skeleton, the two imports and the slot script. Write only the page.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Read ","type":"text","marks":[{"type":"strong"}]},{"text":"references/patterns.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/patterns.md","title":null}},{"type":"code_inline"},{"type":"strong"}]},{"text":" once:","type":"text","marks":[{"type":"strong"}]},{"text":" controls in all eight states, a slot, a missing fact, a form that posts nowhere yet, one reveal, figures that compare. Take their shape, never their copy.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Hero first.","type":"text","marks":[{"type":"strong"}]},{"text":" Write the nav and the hero, nothing below them yet, run ","type":"text"},{"text":"node \u003cskill-dir>/scripts/sloplint.mjs \u003cfile> --render --stage hero","type":"text","marks":[{"type":"code_inline"}]},{"text":", then the hero review in fresh context (","type":"text"},{"text":"gates.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" § 6). Build the sections once the hero ships; photo credits and page-wide checks wait for the page.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"One idea per screen, set big.","type":"text","marks":[{"type":"strong"}]},{"text":" Hero: a headline of seven words or fewer (50 characters), one sentence of twelve words or fewer, one or two actions. Six to nine sections, hero and footer included (five on a thin prompt), 450 visible words at most, each a piece of the category's own content in a composition with mass (","type":"text"},{"text":"layout.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" § 4 and § 5). The hero is the first screen and only the first screen, its posture taken from the job (","type":"text"},{"text":"layout.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" § 6). Fullness comes from scale and pictures, never from more words (77 to 79).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Show, do not describe","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"imagery.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"). The page shows the real thing: the user's pictures, screenshots of the real product taken with ","type":"text"},{"text":"scripts/capture.mjs","type":"text","marks":[{"type":"code_inline"}]},{"text":" whenever it can be reached, the product built at full fidelity only when it cannot, or library photographs (","type":"text"},{"text":"node \u003cskill-dir>/scripts/theme.mjs photos \"\u003csubject>\" --theme \u003cslug>","type":"text","marks":[{"type":"code_inline"}]},{"text":"), big, in the theme's treatment, in slots the user's files replace, each library photo credited in the footer. Pictures take at most a third of the page below the hero, never two photograph-led sections in a row, and the theme's Range carries the rest (83). Never a stock-site URL, a drawn illustration (80), an empty wash (81), a row of counting marks (82) or an invented figure.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Page invariants","type":"text","marks":[{"type":"strong"}]},{"text":" (F15): one primary button style, one container width, one column gap below the hero, one radius per nesting level, never more centred heading groups than left-aligned ones.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Type and motion","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"craft.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" § 1 and § 5). The theme's faces, two families at most (three with one outlier role); body 16px or more; heads one weight and one colour (38iii); labels never mono unless mono is the material (64). Eight states on every control; ","type":"text"},{"text":"transform","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"opacity","type":"text","marks":[{"type":"code_inline"}]},{"text":" only; one reveal at most, none where the theme keeps still, visible by default.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Stamp.","type":"text","marks":[{"type":"strong"}]},{"text":" Line 1 of CSS is the scaffold's stamp skeleton with every field filled, then the critique line (","type":"text"},{"text":"gates.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" § 3), then any ","type":"text"},{"text":"waive","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"answered","type":"text","marks":[{"type":"code_inline"}]},{"text":" lines, all within the first 40 lines.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Existing stylesheets are append-only:","type":"text","marks":[{"type":"strong"}]},{"text":" the imports go right after the last ","type":"text"},{"text":"@import","type":"text","marks":[{"type":"code_inline"}]},{"text":", never below a rule or a ","type":"text"},{"text":"@tailwind","type":"text","marks":[{"type":"code_inline"}]},{"text":" directive; reuse the project's token names (","type":"text"},{"text":"references/export-formats.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/export-formats.md","title":null}},{"type":"code_inline"}]},{"text":").","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Log.","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"node \u003cskill-dir>/scripts/theme.mjs log --entry '\u003cjson>'","type":"text","marks":[{"type":"code_inline"}]},{"text":" puts the build atop ","type":"text"},{"text":".hallmark/log.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" with the fields rotation reads (","type":"text"},{"text":"shape","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"theme","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"axes","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"hero","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"nav","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"footer","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"imagery","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"photos","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"entry","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"fingerprint","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"sections","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"signature","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"waived","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"prompt","type":"text","marks":[{"type":"code_inline"}]},{"text":") and names any you left out.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"7. Sweep","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"node \u003cskill-dir>/scripts/sloplint.mjs \u003cfiles> --genre \u003cgenre> --render","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"--scope component","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"--scope app","type":"text","marks":[{"type":"code_inline"}]},{"text":" where it applies; in a framework project, ","type":"text"},{"text":"--url http://localhost:\u003cport>/\u003croute>","type":"text","marks":[{"type":"code_inline"}]},{"text":" with the dev server running). Fix every FAIL and re-run until clean. The render tier writes the screenshots; never drive a browser by hand.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Load ","type":"text"},{"text":"references/gates.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/gates.md","title":null}},{"type":"code_inline"}]},{"text":" § 5 and walk the J and R/J gates. Confirm or dismiss every WARN. Fix every open Floor finding; fix or waive every Reflex one; answer every Finish one.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The finish review in fresh context (","type":"text"},{"text":"gates.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" § 6; the reviewer reads its prompt, you never open ","type":"text"},{"text":"review-*.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"): act on its first word (","type":"text"},{"text":"recapture","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"rebuild","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"fix","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"ship","type":"text","marks":[{"type":"code_inline"}]},{"text":") and score the fixes yourself.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Close with the material ask: every slot, its size and what belongs there, then the one piece of material that would most improve the page.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Update the Picks block: any row the build changed, and the last row with the outcome (","type":"text"},{"text":"Floor 0 FAIL · Reflex 2 (1 waived: 47b) · finish-review: subagent, 3 resolved","type":"text","marks":[{"type":"code_inline"}]},{"text":"). A fabricated pass is itself slop.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Verification is budgeted: one batched inspection, one fix batch, at most one confirming round; each review adds one fix batch and its recapture.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Branches","type":"text"}]},{"type":"paragraph","content":[{"text":"references/flow.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/flow.md","title":null}},{"type":"code_inline"}]},{"text":", one section at a time: component scope, app screens, signal 8, comp before build, the harness levers, the pre-flight edge cases, ","type":"text"},{"text":"fast mode","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"--fast","type":"text","marks":[{"type":"code_inline"}]},{"text":", or a prompt that says quick, rough or prototype) and the edit-time hook. In an existing project, name the files you will touch first; never delete a route or a component tree without a file-level plan the user approved.","type":"text"}]}]},"metadata":{"date":"2026-11-02","name":"hallmark","author":"@skillopedia","source":{"stars":45774,"repo_name":"hallmark","origin_url":"https://github.com/Nutlope/hallmark/blob/HEAD/skills/hallmark/SKILL.md","repo_owner":"Nutlope","body_sha256":"25a1ff92d925b2389e8409f42fb8ad7e033fceca8b5d1f07ed4ca4c28da53dc3","cluster_key":"90202e9f55f7844230f735332f20c1b81311257cdeb85d48868c681c2e8b2350","clean_bundle":{"format":"clean-skill-bundle-v1","source":"Nutlope/hallmark/skills/hallmark/SKILL.md","text_bytes":1047584,"source_commit":"4f6a46c6dc325d40ab268c93e25c7b96676ebccd","skills_sh_rank":574,"attachment_count":100,"text_attachments":100,"attachment_storage":"skill_attachments","binary_attachments":0},"cluster_size":1,"skill_md_path":"skills/hallmark/SKILL.md","import_metadata":{"date":"2026-11-02","author":"@skillopedia","version":"2.0.0","category":"design-ux","category_label":"Design"},"exact_dupes_collapsed_into_this":0},"version":"2.0.0","category":"design-ux","metadata":{"opencode/slash":"true"},"import_tag":"skills-sh-top20k-2026-11","description":"Design skill for websites and new pages that look designed, not generated. Use when the user asks to build or design something new, or names Hallmark. Never use it on an existing design unless the user names Hallmark, as in `/hallmark redesign \u003cpath>`.","argument-hint":"\u003cprompt> | redesign \u003cpath> | audit \u003cpath>"}},"renderedAt":1793755388435}

Hallmark Makes the UIs a coding assistant generates look made, not generated: a hard floor against the defaults every model was trained into, a dealt direction so consecutive builds do not converge, one idea per screen. Above the floor the page is yours: you are a capable designer. Powered by Together AI. The Floor Binds every output in every harness, none of it waivable, even when nothing else is read. 1. Every colour and every font goes through a token declared at (or a declared theme block). No literal colour or past the token block. (48) 2. Never . Never animate , , or . (10a) 3. Never on…