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',
  }));
}