File size: 13,975 Bytes
e92b981 444a402 e92b981 08132d7 e92b981 e6c76da e92b981 08132d7 e92b981 08132d7 e92b981 6859477 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 | /* =========================================================================
* 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',
}));
}
|