/* ========================================================================= * DSSL: lint.js * * Warnings for things that compile and run and are still wrong for the people * using the page. They fit the language's promise (a mistake should be loud), * and they never stop a build unless you ask for `dssl check --strict`. * * contrast text colour against its own background, in the same rule * focus an outline removed from something you can tab to, with nothing in its place * target a button or link with an explicit size smaller than a thumb can hit * * Only unconditional rules are checked. A rule inside @media or @signal might be * the dark theme, and judging it against the light tokens would be wrong. * * Pure: no DOM. Called by resolve() with the rules it just built. * ========================================================================= */ import { parseColor, evalColorExpr, over, contrast, toRgba, toHex } from './color.js'; const ALIGN_PROPS = new Set(['place-items', 'place-content', 'align-items', 'justify-items', 'align-content', 'justify-content']); /** one design-token lookup closed over the sheet's token map */ export function makeColorResolver(tokenMap) { const lookup = (name) => tokenMap.get(name); /** CSS value as the resolver emits it ("var(--dssl-color-ink)", "color-mix(...)", "#fff") to a colour */ return function cssToColor(css, depth = 0) { if (css === undefined || css === null || depth > 6) return null; const s = String(css).trim(); const direct = parseColor(s); if (direct) return direct; if (!s.includes('var(')) return null; let unresolved = false; const expanded = s.replace(/var\(--dssl-([a-z0-9-]+)\)/g, (m, name) => { const key = name.replace('-', '.'); // namespace is the first segment: color-code-bg is color.code-bg const raw = lookup(key); if (raw === undefined) { unresolved = true; return m; } const c = evalColorExpr(raw, lookup); if (!c) { unresolved = true; return m; } return toRgba(c); }); if (unresolved) return null; return cssToColor(expanded, depth + 1); }; } const lastDecl = (decls, ...props) => { for (let i = decls.length - 1; i >= 0; i--) if (props.includes(decls[i].prop)) return decls[i]; return null; }; const pxOf = (v) => { const m = /^(-?[\d.]+)px$/.exec(String(v || '').trim()); return m ? parseFloat(m[1]) : null; }; const finalCompound = (sel) => String(sel).trim().split(/[\s>+~]+/).pop() || ''; const INTERACTIVE = /^(a|button|input|select|textarea|summary)\b|\[tabindex|\[role=["']?(button|link|tab|menuitem)|^\*$/; /** * @param {object} p { rules, tokenMap, file } * @returns {Array} warnings in the compiler's {file, line, message, hint} shape */ /** * Rules that align their children and never say what kind of box they are. The engine quietly makes the box a grid when it * runs; a `dssl build` cannot, because a stylesheet does not know what each element is. So this is reported by the build (and * by DSSL.lint on a live page, which knows exactly which elements were promoted), not by `check`: per rule it cannot see that * a different rule gave the same element display: flex, and it was wrong about that often enough to be noise. */ export function alignWithoutBox(rules) { const out = []; for (const r of rules) { if (r.pseudo) continue; const conditional = (r.media && r.media.length) || (r.signals && r.signals.length) || (r.containers && r.containers.length); const states = r.states || new Set(); if (conditional || states.size) continue; const aligns = r.decls.find((d) => ALIGN_PROPS.has(d.prop)); if (aligns && !r.decls.some((d) => d.prop === 'display' || d.prop === '@display')) out.push({ sel: r.sel, prop: aligns.prop, line: aligns.line, file: r.file }); } return out; } export function lintRules({ rules, tokenMap, file }) { const warnings = []; const cssToColor = makeColorResolver(tokenMap); const pageBg = (() => { const raw = tokenMap.get('color.paper'); return raw ? evalColorExpr(raw, (n) => tokenMap.get(n)) : null; })(); const killedFocus = []; const focusBases = new Set(); for (const r of rules) { if (r.pseudo) continue; const conditional = (r.media && r.media.length) || (r.signals && r.signals.length) || (r.containers && r.containers.length); const states = r.states || new Set(); const hasFocus = states.has('focus') || states.has('focus-visible'); // ---- contrast if (!conditional) { const fgD = lastDecl(r.decls, 'color'); const bgD = lastDecl(r.decls, 'background-color', 'background'); if (fgD && bgD && typeof fgD.value === 'string' && typeof bgD.value === 'string') { let bg = cssToColor(bgD.value); let fg = cssToColor(fgD.value); if (bg && fg) { // a translucent background takes its look from whatever is behind it, which a single rule cannot know // (a dark theme is usually in a media block, and this check only reads the unconditional rules). So judge // it against the light page colour, white and black, and warn only when it fails on every one of them. const backdrops = bg.a < 0.999 ? [pageBg ? { ...pageBg, a: 1 } : null, { r: 255, g: 255, b: 255, a: 1 }, { r: 0, g: 0, b: 0, a: 1 }].filter(Boolean) : [null]; let bestRatio = -1, bestFg = null, bestBg = null; for (const back of backdrops) { const solidBg = back ? over(bg, back) : bg; const solidFg = fg.a < 0.999 ? over(fg, solidBg) : fg; const rr = contrast(solidFg, solidBg); if (rr > bestRatio) { bestRatio = rr; bestFg = solidFg; bestBg = solidBg; } } if (bestBg) { const fgSolid = bestFg; bg = bestBg; const ratio = bestRatio; const size = pxOf((lastDecl(r.decls, 'font-size') || {}).value); const weightRaw = (lastDecl(r.decls, 'font-weight') || {}).value; const weight = weightRaw === 'bold' ? 700 : Number(weightRaw) || 400; const large = size !== null && (size >= 24 || (size >= 18.66 && weight >= 700)); const need = large ? 3 : 4.5; if (ratio < need) { warnings.push({ file, line: fgD.line, message: `text contrast is ${ratio.toFixed(1)}:1 (${toHex(fgSolid)} on ${toHex(bg)}), below the ${need}:1 that readers need${large ? ' for large text' : ''}`, hint: 'pick a darker or lighter text colour (WCAG AA). Contrast is only checked for a colour and a background written in the same rule', }); } } } } } // ---- focus if (hasFocus) focusBases.add(r.sel.replace(/:(focus-visible|focus)\b/g, '').trim()); if (!hasFocus && INTERACTIVE.test(finalCompound(r.sel).replace(/[.#:][\w-]+.*$/, (m) => m.startsWith('[') ? m : '') || finalCompound(r.sel))) { for (const d of r.decls) { const v = String(d.value).trim(); const killed = (d.prop === 'outline' && /^(none|0|0px)(\s|$)/.test(v)) || (d.prop === 'outline-style' && v === 'none') || (d.prop === 'outline-width' && /^0(px)?$/.test(v)); if (killed) { killedFocus.push({ sel: r.sel, line: d.line }); break; } } } // ---- tap target const tag = finalCompound(r.sel); if (!conditional && /^(button|a)\b|\[role=["']?button/.test(tag)) { for (const dim of ['width', 'height', 'min-width', 'min-height']) { const d = lastDecl(r.decls, dim); const px = d && pxOf(d.value); if (px !== null && px !== undefined && px < 24 && px > 0) { warnings.push({ file, line: d.line, message: `${dim}: ${px}px on "${r.sel}" is below 24px, the smallest target WCAG 2.2 allows`, hint: 'give buttons and links at least 24px (44px is comfortable for a thumb), or add padding instead of shrinking the box', }); break; } } } } for (const k of killedFocus) { if (focusBases.has(k.sel)) continue; warnings.push({ file, line: k.line, message: `the focus outline is removed from "${k.sel}" and nothing replaces it, so keyboard users lose their place`, hint: 'add a focus-visible { outline: 2 solid color.accent } block, or `use focus-ring` if you have that behavior', }); } return warnings; } /* ------------------------------------------------------------------ * * rules that can never win (0.15.0) * ------------------------------------------------------------------ */ const LAYER_RANK = { tokens: 0, base: 1, components: 2, utilities: 3, pin: 4 }; /** split a selector at top-level spaces and > + ~ (outside brackets and parentheses); null when it holds a top-level comma */ function selectorParts(sel) { const parts = []; let depth = 0, cur = '', q = null; for (const ch of String(sel).trim()) { if (q) { cur += ch; if (ch === q) q = null; continue; } if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; } if (ch === '(' || ch === '[') depth++; if (ch === ')' || ch === ']') depth--; if (depth === 0 && ch === ',') return null; if (depth === 0 && (ch === ' ' || ch === '>' || ch === '+' || ch === '~')) { if (cur) { parts.push(cur); cur = ''; } parts.push(ch === ' ' ? ' ' : ch); continue; } cur += ch; } if (cur) parts.push(cur); return parts; } /** the simple selectors of one compound: `div.a#b[x]:not(.c)` is div, .a, #b, [x], :not(.c) */ function simpleSelectors(compound) { const out = []; let i = 0; const n = compound.length; const take = (start) => { let j = start + 1, depth = 0; while (j < n) { const c = compound[j]; if (c === '(' || c === '[') depth++; else if (c === ')' || c === ']') depth--; else if (depth === 0 && /[.#:\[]/.test(c)) break; j++; } return j; }; if (/^[a-zA-Z*]/.test(compound)) { let j = 0; while (j < n && /[\w*-]/.test(compound[j])) j++; out.push(compound.slice(0, j).toLowerCase()); i = j; } while (i < n) { const j = compound[i] === ':' && compound[i + 1] === ':' ? take(i + 1) : take(i); out.push(compound.slice(i, j)); i = j; } return out; } /** * A rule whose every declaration of a property is beaten by a LATER rule that matches everything it matches. There is no specificity * in DSSL, so `.prop.broc { z-index: 1 }` in one file loses to a plain `.prop { z-index: 2 }` in a file that comes after it, and the * line does nothing at all. (The very same selector again is left alone: that is how a later file overrides an earlier one.) Both rules must be unconditional alike (same @media, @signal, container, state) and the later one must be * in the same layer or a higher one. Pure; rules from several files must already carry one global `order`. * @returns {Array<{a:{sel,file,line,props:string[]}, b:{sel,file,line}}>} */ export function shadowedRules(rules) { const info = []; for (const r of rules) { if (!r || r.pseudo || !r.sel || !r.decls || !r.decls.length) continue; const parts = selectorParts(r.sel); if (!parts || !parts.length) continue; const comp = parts[parts.length - 1]; if (/^[ >+~]$/.test(comp)) continue; if (/^:root$|::/.test(r.sel)) continue; const toks = simpleSelectors(comp); if (!toks.length) continue; const cond = JSON.stringify([r.media || [], (r.signals || []).map((s) => [s.name, s.op, s.value, s.values]), r.containers || [], [...(r.states || [])].sort()]); info.push({ idx: info.length, r, toks, set: new Set(toks), single: parts.length === 1, cond, rank: LAYER_RANK[r.layer] ?? 2, order: r.order ?? 0 }); } // the later rules, found by the first simple selector they need const byTok = new Map(); for (const b of info) if (b.single) { const k = b.toks[0]; if (!byTok.has(k)) byTok.set(k, []); byTok.get(k).push(b); } const pairs = new Map(); for (const a of info) { const seenProps = new Set(); const cands = []; for (const t of a.toks) for (const b of byTok.get(t) || []) cands.push(b); if (byTok.has('*')) for (const b of byTok.get('*')) cands.push(b); cands.sort((x, y) => x.order - y.order); for (const b of cands) { if (b === a || b.cond !== a.cond) continue; if (b.r.sel.replace(/\s+/g, ' ').trim() === a.r.sel.replace(/\s+/g, ' ').trim()) continue; // the same selector again is an override on purpose (a theme file, a later tweak) if (b.rank < a.rank || (b.rank === a.rank && b.order <= a.order)) continue; if (b.toks.some((t) => t !== '*' && !a.set.has(t))) continue; const bProps = new Map(); for (const d of b.r.decls) if (d.prop && !String(d.prop).startsWith('@')) bProps.set(d.prop, d.value); const hit = []; for (const d of a.r.decls) { const p = d.prop; if (!p || String(p).startsWith('@') || seenProps.has(p) || !bProps.has(p)) continue; if (String(bProps.get(p)) === String(d.value)) continue; seenProps.add(p); hit.push(p); } if (hit.length) { const key = a.idx + '>' + b.idx; pairs.set(key, { a: { sel: a.r.sel, file: a.r.file, line: (a.r.decls.find((d) => d.prop === hit[0]) || a.r).line ?? a.r.line, props: hit }, b: { sel: b.r.sel, file: b.r.file, line: b.r.line } }); } } } return [...pairs.values()]; } /** the same, as compiler warnings */ export function shadowWarnings(rules) { return shadowedRules(rules).map((p) => ({ file: p.a.file, line: p.a.line, message: `${p.a.props.join(', ')} in "${p.a.sel}" can never apply: "${p.b.sel}" (${p.b.file || ''}${p.b.file ? ':' : ''}${p.b.line}) comes later, matches every element this rule matches, and sets ${p.a.props.length > 1 ? 'the same properties' : 'the same property'}`, hint: 'there is no specificity in DSSL, so the later rule always wins. Move the more specific rule below the general one, or remove the line from the general one', })); }