design-lint: third review cycle fixes

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-26 08:21:13 +00:00
1 parent ddaeabe1f4
commit 6100d6e94d
15 files changed
+193 -123

No files matched your search

+31
View File
@@ -7,6 +7,8 @@
* srgbToOklch, contrastRatio; MIT, see NOTICE.md).
*/
import { resolveVars, type Tokens } from './css';
export interface Rgb { r: number; g: number; b: number }
export interface Oklch { L: number; C: number; H: number }
export interface Color extends Rgb { alpha: number; okl?: Oklch }
@@ -134,3 +136,32 @@ export function contrastRatio(a: Rgb, b: Rgb): number {
export const hueIn = (h: number, lo: number, hi: number): boolean => (lo <= hi ? h >= lo && h <= hi : h >= lo || h <= hi);
export const COLOR_LITERAL = /#[0-9a-fA-F]{3,8}\b|oklch\([^)]*\)|rgba?\([^)]*\)|hsla?\([^)]*\)|color-mix\((?:[^()]|\([^()]*\))*\)|\b(white|black|transparent)\b/i;
/** Whether the page's paper token resolves to a dark colour (OKLCH lightness under 0.4). */
// sloplint paperIsDark (gate 2)
export function paperIsDark(tokens: Tokens): boolean {
const p = parseColor(resolveVars(tokens['--color-paper'] || '', tokens));
return !!p && oklchOf(p).L < 0.4;
}
/** Two indigo-to-violet stops this many degrees apart read as a ramp; closer is one hue shaded. */
const INDIGO_VIOLET_SPAN = 12;
/**
* The banned ramp among a gradient's stop hues: purple to blue, purple to
* pink, indigo to violet, or cyan on a dark paper. One classifier for a
* stylesheet gradient and for Tailwind stops, so both tiers give one answer.
*/
// sloplint bannedGradient (gate 2a)
export function bannedGradient(hues: number[], tokens: Tokens): string | null {
const purple = hues.some((h) => hueIn(h, 265, 330));
const blue = hues.some((h) => hueIn(h, 200, 264));
const pink = hues.some((h) => hueIn(h, 331, 25));
const cyan = hues.some((h) => hueIn(h, 170, 230));
if (purple && blue) return 'purple-to-blue';
if (purple && pink) return 'purple-to-pink';
const band = hues.filter((h) => hueIn(h, 250, 330));
if (band.length >= 2 && Math.max(...band) - Math.min(...band) >= INDIGO_VIOLET_SPAN) return 'indigo-to-violet';
if (cyan && paperIsDark(tokens)) return 'cyan-on-dark';
return null;
}
-2
View File
@@ -9,8 +9,6 @@ import type { Doc } from './html';
export interface CssFile {
file: string;
raw: string;
/** Reached through @import or <link>, not named on the command line. */
attached?: boolean;
}
/** Project-wide facts shared across a directory of components, so a focus ring in globals.css counts for Hero.tsx. */
+5 -3
View File
@@ -12,7 +12,7 @@
*/
import { docKindOf } from './html';
import { lintRender, type RenderOptions } from './render';
import { exitCodeOf, finalize, formatFinding, formatSummary, relPath, summarize, type Finding, type Summary } from './report';
import { exitCodeOf, finalize, formatFinding, formatSummary, oneLine, relPath, summarize, type Finding, type Summary } from './report';
import { lintStaticRaw, settleContext, type StaticOptions } from './static';
export interface LintOptions extends StaticOptions {
@@ -20,6 +20,8 @@ export interface LintOptions extends StaticOptions {
urls?: string[];
/** Replaces the render engine choice (tests inject one). */
engine?: RenderOptions['engine'];
/** Replaces the render page runner (tests inject one). */
run?: RenderOptions['run'];
}
export interface LintResult {
@@ -42,13 +44,13 @@ export async function lint(paths: string[], opts: LintOptions = {}): Promise<Lin
const byPage = new Map<string, Finding[]>();
if (opts.render || opts.urls?.length) {
const pages = stat.files.filter((f) => docKindOf(f) === 'html');
const r = await lintRender([...pages, ...(opts.urls ?? [])], { cwd: opts.cwd, scope: opts.scope, engine: opts.engine });
const r = await lintRender([...pages, ...(opts.urls ?? [])], { cwd: opts.cwd, scope: opts.scope, engine: opts.engine, run: opts.run });
render = r.status;
for (const f of r.findings) byPage.set(f.file, [...(byPage.get(f.file) ?? []), f]);
}
const findings: Finding[] = [];
stat.contexts.forEach((ctx, i) => {
const page = ctx.docs.length === 1 && ctx.docs[0].kind === 'html' ? relPath(ctx.key, cwd) : null;
const page = ctx.docs.length === 1 && ctx.docs[0].kind === 'html' ? oneLine(relPath(ctx.key, cwd)) : null;
const measured = page ? byPage.get(page) ?? [] : [];
if (page) byPage.delete(page);
findings.push(...settleContext(ctx, [...stat.raw[i], ...measured], cwd));
+13 -6
View File
@@ -29,6 +29,8 @@ export interface RenderOptions {
timeoutMs?: number;
/** Replaces the engine choice (tests inject one); defaults to lib/aside-render's pickEngine. */
engine?: () => EngineChoice;
/** Replaces the page runner itself, so every answer the browser could give (none, null, malformed, a probe error) can be settled without a browser. */
run?: Runner;
}
export interface RenderResult {
@@ -41,13 +43,16 @@ const WIDTHS = [320, 375, 414, 768, 1024, 1280, 1920];
const LAPTOP = { width: 1440, height: 900 };
const RING_FLOOR = 2.95;
type Runner = (spec: RenderSpec) => Promise<EngineResult>;
export type Runner = (spec: RenderSpec) => Promise<EngineResult>;
export async function lintRender(targets: string[], opts: RenderOptions = {}): Promise<RenderResult> {
if (!targets.length) return { findings: [], status: 'skipped: no HTML page to render' };
const choice = (opts.engine ?? pickEngine)();
if (!choice.engine) return { findings: [], status: `skipped: ${choice.error}` };
const run: Runner = (spec) => (choice.engine === 'browse' ? renderWithBrowse(spec, choice.bin) : render(spec));
let run = opts.run;
if (!run) {
const choice = (opts.engine ?? pickEngine)();
if (!choice.engine) return { findings: [], status: `skipped: ${choice.error}` };
run = (spec) => (choice.engine === 'browse' ? renderWithBrowse(spec, choice.bin) : render(spec));
}
const r = new Reporter(opts.scope ?? 'page', opts.cwd ?? process.cwd());
for (const target of targets) {
try { await lintPage(target, r, run, opts.timeoutMs ?? 120_000); } catch (e) {
@@ -163,8 +168,10 @@ async function lintPage(target: string, r: Reporter, run: Runner, timeoutMs: num
});
const scrolls = found.filter((f) => f.rule === 'horizontal-scroll' && !f.at);
if (scrolls.length > 1) {
scrolls[0].evidence += ` (also at ${scrolls.slice(1).map((f) => f.label.replace(/px$/, '')).join(', ')}px)`;
for (const f of scrolls.slice(1)) found.splice(found.indexOf(f), 1);
const keep = scrolls.find((f) => f.grade === 'fail') ?? scrolls[0];
const rest = scrolls.filter((f) => f !== keep);
keep.evidence += ` (also at ${rest.map((f) => f.label.replace(/px$/, '')).join(', ')}px)`;
for (const f of rest) found.splice(found.indexOf(f), 1);
}
const bare = rings.filter((x) => x.bare);
if (bare.length) report({ rule: 'missing-focus-visible', grade: 'fail', evidence: `[1440px] nothing shows keyboard focus on ${bare.slice(0, 4).map((x) => x.key).join(', ')}`, fix: 'give every control a :focus-visible ring; never remove an outline without putting one back', at: bare[0].at }, src);
+22 -5
View File
@@ -14,7 +14,6 @@ import { trunc, type Reporter } from '../report';
const EMOJI = /(?![©®™‼⁉←-⇿])\p{Extended_Pictographic}/u;
const GLYPH = /(?![©®™‼⁉←-⇿✓-✖])(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}️)/u;
const lineAt = (text: string, pos: number): number => text.slice(0, pos).split('\n').length;
// sloplint check30 (gate 30a)
function checkEmojiIcons(ctx: LintContext, r: Reporter): void {
@@ -48,9 +47,9 @@ function checkEmojiIcons(ctx: LintContext, r: Reporter): void {
}
const raw = doc.raw;
const body = doc.kind === 'html' ? doc.masked : raw;
for (const m of body.matchAll(/>\s*((?:\p{Emoji_Presentation}|\p{Extended_Pictographic}️)[^<>{}\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) + '"');
for (const m of raw.matchAll(/placeholder\s*=\s*["']([^"']*)["']/g)) if (GLYPH.test(m[1])) flag(lineAt(raw, m.index!), 'an emoji in a placeholder "' + trunc(m[1], 28) + '"');
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!), 'an emoji passed as an icon: ' + m[2]);
for (const m of body.matchAll(/>\s*((?:\p{Emoji_Presentation}|\p{Extended_Pictographic}️)[^<>{}\n]{0,40})/gu)) flag(doc.lineOf(m.index! + m[0].length - m[1].length), 'text opens with an emoji: "' + trunc(m[1].trim(), 32) + '"');
for (const m of raw.matchAll(/placeholder\s*=\s*["']([^"']*)["']/g)) if (GLYPH.test(m[1])) flag(doc.lineOf(m.index!), 'an emoji in a placeholder "' + trunc(m[1], 28) + '"');
if (doc.kind !== 'html') for (const m of raw.matchAll(/(["'`])\s*((?:(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}️?)‍?){1,3})\s*\1/gu)) flag(doc.lineOf(m.index!), 'an emoji passed as an icon: ' + m[2]);
}
}
@@ -65,9 +64,27 @@ const ICON_LIBS = new Map<string, RegExp>([
['bootstrap-icons', /bootstrap-icons|\bbi-[a-z]/i],
]);
/** The class attributes, stylesheet and script references, and @import and url() targets of a context: where an icon library shows, prose left out. */
function iconSurfaces(ctx: LintContext): string {
const out: string[] = [];
for (const doc of ctx.docs) {
for (const t of [...doc.tags, ...doc.comps]) {
if (t.close) continue;
const cls = getAttr(t.attrs, 'class') ?? getAttr(t.attrs, 'className');
if (cls) out.push(cls);
if (t.name === 'link' || t.name === 'script' || t.name === 'use') out.push(getAttr(t.attrs, 'href') ?? getAttr(t.attrs, 'src') ?? '');
}
for (const call of doc.virtuals) out.push(call.toks.join(' '));
for (const toks of doc.classAt.values()) out.push(toks.join(' '));
for (const m of doc.scripts.matchAll(/\b(?:import|require|from)\b[^\n;]*['"]([^'"]+)['"]/g)) out.push(m[1]);
}
for (const f of ctx.cssFiles) for (const m of f.raw.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)|url\(\s*['"]?([^'")]+)/g)) out.push(m[1] ?? m[2]);
return out.join('\n');
}
// sloplint check30 (gate 30b)
function checkIconLibraries(ctx: LintContext, r: Reporter): void {
const hay = ctx.docs.map((d) => d.src).join('\n') + ctx.cssFiles.map((f) => f.raw).join('\n');
const hay = iconSurfaces(ctx);
const found = [...ICON_LIBS].filter(([, re]) => re.test(hay)).map(([name]) => name);
if (found.length < 2) return;
const file = ctx.docs[0]?.file || ctx.cssFiles[0]?.file;
+21 -3
View File
@@ -9,7 +9,7 @@
*/
import { styleFor, type LintContext } from '../context';
import { resolveVars } from '../css';
import { ancestorsOf, getAttr, innerOf, maskedProse, plainText, type Doc, type Tag } from '../html';
import { ancestorsOf, getAttr, innerOf, maskedProse, plainText, treeOf, type Doc, type Tag } from '../html';
import { trunc, type Reporter } from '../report';
@@ -40,9 +40,16 @@ function checkHeroCopy(ctx: LintContext, r: Reporter): void {
lines.add(line);
r.report(rule, doc.file, line, `${what}: "${trunc(text, 40)}"`);
};
const hero = heroRange(doc);
for (const [re, what] of HERO_TELLS) {
const m = re.exec(prose);
if (m) tell('generic-hero-copy', m.index, what, m[0]);
const all = [...prose.matchAll(new RegExp(re.source, re.flags + 'g'))];
const inHero = all.find((m) => hero && m.index! >= hero[0] && m.index! < hero[1]);
const m = inHero ?? all[0];
if (!m) continue;
const line = doc.lineOf(m.index!);
if (lines.has(line)) continue;
lines.add(line);
r.report('generic-hero-copy', doc.file, line, `${what}: "${trunc(m[0], 40)}"`, inHero ? {} : { suspected: true, fix: 'a line in the product\'s own voice; in body copy this is a suspicion, so a waiver can answer it' });
}
const footer = FOOTER_LINE.exec(prose);
if (footer) tell('template-footer-line', footer.index, 'a stock footer line', footer[0]);
@@ -91,6 +98,17 @@ const SECOND_CTA = /^(learn more|see how it works|watch (the )?demo|get started|
/** The <section> or <header> that holds the h1, by tag index; -1 when none does. */
// sloplint heroIndexOf
/** The source range the hero copy lives in: the section or header holding the h1, else the h1's parent; null without an h1. A tell here is the Floor, elsewhere it is suspected. */
function heroRange(doc: Doc): [number, number] | null {
const h1 = doc.tags.findIndex((t) => !t.close && t.name === 'h1');
if (h1 < 0) return null;
const heroIdx = heroIndexOf(doc, h1);
const holder = heroIdx >= 0 ? heroIdx : treeOf(doc).parent[h1];
if (holder < 0) return null;
const inner = innerOf(doc, holder);
return inner ? [doc.tags[holder].end, inner.endPos] : null;
}
export function heroIndexOf(doc: Doc, h1Idx: number): number {
for (let i = h1Idx - 1; i >= 0; i--) {
const t = doc.tags[i];
+1 -1
View File
@@ -217,7 +217,7 @@ function checkWireframeSlots(ctx: LintContext, r: Reporter): void {
}
}
const FACT = /(?:[£$€¥]\s?\d[\d,.]*|\d[\d,.]*\s?(?:pm|am|MAD|kg|g|ml|km|mi|miles|%)\b|\b\d{1,2}:\d{2}\b)/gi;
const FACT = /(?:[£$€¥]\s?\d[\d,.]*|\d[\d,.]*\s?(?:%|(?:pm|am|MAD|kg|g|ml|km|mi|miles)\b)|\b\d{1,2}:\d{2}\b)/gi;
const REGION = /^(section|header|footer|article|aside|nav|form|dialog)$/;
// sloplint check69 (gate 69)
+1 -20
View File
@@ -6,7 +6,7 @@
* genre exempts a page, no theme is pinned by name, so every check here runs
* in its strict form.
*/
import { COLOR_LITERAL, hueIn, oklchOf, parseColor } from '../color';
import { bannedGradient, COLOR_LITERAL, hueIn, oklchOf, parseColor } from '../color';
import type { LintContext } from '../context';
import { mergedRules } from '../context';
import { firstFamily, GENERIC_FAMILIES, isTokenRule, pxOf, resolveVars, splitTop, type CssDecl, type MergedRule, type Tokens } from '../css';
@@ -27,25 +27,6 @@ function gradientHues(value: string, tokens: Tokens): number[] {
return hues;
}
// sloplint paperIsDark (gate 2)
function paperIsDark(tokens: Tokens): boolean {
const p = parseColor(resolveVars(tokens['--color-paper'] || '', tokens));
return !!p && oklchOf(p).L < 0.4;
}
// sloplint bannedGradient (gate 2a)
function bannedGradient(hues: number[], tokens: Tokens): string | null {
const purple = hues.some((h) => hueIn(h, 265, 330));
const blue = hues.some((h) => hueIn(h, 200, 264));
const pink = hues.some((h) => hueIn(h, 331, 25));
const cyan = hues.some((h) => hueIn(h, 170, 230));
if (purple && blue) return 'purple-to-blue';
if (purple && pink) return 'purple-to-pink';
if (new Set(hues.filter((h) => hueIn(h, 250, 330)).map((h) => Math.round(h / 4))).size >= 2) return 'indigo-to-violet';
if (cyan && paperIsDark(tokens)) return 'cyan-on-dark';
return null;
}
interface GradientText { r: MergedRule; clip: CssDecl; grad: CssDecl | undefined }
// sloplint gradientTextRules (gate 2b)
+5 -2
View File
@@ -177,6 +177,9 @@ function autorotate(ctx: LintContext, r: Reporter): void {
}
// sloplint check26 (gates 26a, 26b); ctx.shared carries a project's global state styles
/** An outline value that takes the ring away: none, or a zero width; `0.25rem solid` is a ring. */
const OUTLINE_REMOVED = /^(?:none|0(?:px|rem|em)?)(?![\w.%])/i;
function focusAndStates(ctx: LintContext, r: Reporter): void {
const hasInteractive = ctx.docs.some((doc) => /<(a|button|input|select|textarea)\b|role\s*=\s*["']button/i.test(doc.src))
|| ctx.rules.some((rule) => selectorIsInteractive(rule.selector));
@@ -188,10 +191,10 @@ function focusAndStates(ctx: LintContext, r: Reporter): void {
if (!has(':focus-visible') && !has(':focus') && !shared?.focus && ![...tv].some((v) => /^focus(?:-visible|-within)?$/.test(v)) && once(ctx, '26a')) {
r.report('missing-focus-visible', file, 1, 'no :focus-visible style anywhere: keyboard users cannot see where they are');
}
const ringDecl = (d: { prop: string; value: string }) => (/^outline(-style|-width|-color)?$/.test(d.prop) && !/^(none|0|0px)\b/i.test(d.value.trim())) || (d.prop === 'box-shadow' && !/^none\b/i.test(d.value.trim()));
const ringDecl = (d: { prop: string; value: string }) => (/^outline(-style|-width|-color)?$/.test(d.prop) && !OUTLINE_REMOVED.test(d.value.trim())) || (d.prop === 'box-shadow' && !/^none\b/i.test(d.value.trim()));
const ringAnywhere = ctx.rules.some((rule) => /:focus/.test(rule.selector) && rule.decls.some(ringDecl)) || !!shared?.focus || [...tv].some((v) => /^focus(?:-visible|-within)?$/.test(v));
if (!ringAnywhere) {
const removed = ctx.rules.find((rule) => /:focus/.test(rule.selector) && rule.decls.some((d) => /^outline(-style)?$/.test(d.prop) && /^(none|0|0px)\b/i.test(d.value.trim())));
const removed = ctx.rules.find((rule) => /:focus/.test(rule.selector) && rule.decls.some((d) => /^outline(-style)?$/.test(d.prop) && OUTLINE_REMOVED.test(d.value.trim())));
const outlineD = removed?.decls.find((d) => /^outline(-style)?$/.test(d.prop));
if (removed && outlineD && once(ctx, '26a:removed')) r.report('missing-focus-visible', removed.file, outlineD.line, `${outlineD.prop}: ${outlineD.value.trim()} on ${trunc(removed.selector, 40)} with nothing in its place: keyboard users cannot see where they are`, { fix: 'a :focus-visible ring at 3:1 or better, instant; never remove an outline without putting one back' });
}
+35 -17
View File
@@ -5,8 +5,9 @@
* double-sticky, screen-word-budget. Gate 59 (section-gap) has no static check: the render tier owns it (render-probe-space.ts glGaps), as it owns
* the render halves of 34 (horizontal-scroll) and 44d.
*/
import { isTokenRule, pxOf, resolveVars } from '../css';
import { mergedRules, type LintContext } from '../context';
import { COLOR_LITERAL, oklchOf, parseColor } from '../color';
import { isTokenRule, pxOf, resolveVars, type MergedRule } from '../css';
import { memo, mergedRules, type LintContext } from '../context';
import { getAttr, innerOf, plainText, treeOf, wordsOf, type Doc } from '../html';
import { trunc, type Reporter } from '../report';
import { twGroup } from '../tailwind';
@@ -20,18 +21,27 @@ const stripVariants = (cls: string): string => cls.replace(/^(?:[\w-]+:)+/, '');
/** The inner HTML of every open element in the context whose class attribute carries one of the classes. */
function innerHtmlWithClass(ctx: LintContext, classes: string[]): string[] {
const out: string[] = [];
if (!classes.length) return out;
for (const doc of ctx.docs) {
for (let i = 0; i < doc.tags.length; i++) {
const t = doc.tags[i];
if (t.close || t.selfClosing) continue;
const have = (getAttr(t.attrs, 'class') || '').split(/\s+/);
if (!classes.some((c) => have.includes(c))) continue;
const inner = innerOf(doc, i);
if (inner) out.push(inner.inner);
if (!classes.length) return [];
const byClass = memo(ctx, 'innerByClass', () => {
const index = new Map<string, string[]>();
for (const doc of ctx.docs) {
for (let i = 0; i < doc.tags.length; i++) {
const t = doc.tags[i];
if (t.close || t.selfClosing) continue;
const inner = innerOf(doc, i);
if (!inner) continue;
for (const c of new Set((getAttr(t.attrs, 'class') || '').split(/\s+/).filter(Boolean))) {
let list = index.get(c);
if (!list) index.set(c, list = []);
list.push(inner.inner);
}
}
}
}
return index;
});
const out: string[] = [];
const seen = new Set<string>();
for (const c of classes) for (const inner of byClass.get(c) ?? []) if (!seen.has(inner)) { seen.add(inner); out.push(inner); }
return out;
}
@@ -59,7 +69,12 @@ function sideStripes(ctx: LintContext, r: Reporter): void {
for (const d of rule.decls) {
if (!/^border-(left|right|inline-start|inline-end)$/.test(d.prop)) continue;
const m = /(\d+(?:\.\d+)?)px/.exec(d.value);
if (m && parseFloat(m[1]) >= 3 && !/transparent|none/i.test(d.value)) {
if (!m || parseFloat(m[1]) < 3 || /transparent|none/i.test(d.value)) continue;
const col = COLOR_LITERAL.exec(resolveVars(d.value, ctx.tokens));
const parsed = col ? parseColor(col[0]) : null;
const ok = parsed ? oklchOf(parsed) : null;
if (parsed && ok && (parsed.alpha <= 0.05 || (ok.C < 0.04 && ok.L > 0.6))) continue;
{
r.report('template-cards', rule.file, d.line, `a ${m[1]}px coloured side stripe (${d.prop}: ${trunc(d.value, 32)}) on ${rule.selector}`, { fix: 'a hairline all round, no border, or an accent square' });
}
}
@@ -270,9 +285,12 @@ function radioTabJump(ctx: LintContext, r: Reporter): void {
// sloplint check56 (gate 56)
function doubleSticky(ctx: LintContext, r: Reporter): void {
const sticky = mergedRules(ctx).filter((m) =>
m.decls.some((d) => d.prop === 'position' && /sticky/.test(d.value))
&& m.decls.some((d) => d.prop === 'top' && /^(0|0px|0rem|-1px)$/.test(resolveVars(d.value, ctx.tokens).trim())));
const bySelector = new Map<string, MergedRule>();
for (const m of mergedRules(ctx)) {
if (m.decls.some((d) => d.prop === 'position' && /sticky/.test(d.value))
&& m.decls.some((d) => d.prop === 'top' && /^(0|0px|0rem|-1px)$/.test(resolveVars(d.value, ctx.tokens).trim())) && !bySelector.has(m.selector)) bySelector.set(m.selector, m);
}
const sticky = [...bySelector.values()];
if (sticky.length < 2) return;
const sels = sticky.map((m) => m.selector).join(' and ');
r.report('double-sticky', sticky[1].file, sticky[1].line, `two elements stick at top: 0 (${trunc(sels, 52)}): the second slides under the first`);
+5 -2
View File
@@ -135,8 +135,11 @@ function check38a(ctx: LintContext, r: Reporter): void {
for (const doc of ctx.docs) {
for (const m of doc.src.matchAll(/<h([1-6])\b([^>]*)>([\s\S]*?)<\/h\1>/gi)) {
const classes = ((/class\s*=\s*["']([^"']*)["']/i.exec(m[2]) || [])[1] || '').split(/\s+/).filter(Boolean);
const tag = /<(em|i)\b/i.exec(m[3]);
if (tag && !normalised(m[1], classes)) r.report('italic-word-in-heading', doc.file, doc.lineOf(m.index!), `an <${tag[1].toLowerCase()}> inside an otherwise roman <h${m[1]}>`);
const tag = /<(em|i)\b[^>]*>([\s\S]*?)<\/\1\s*>/i.exec(m[3]);
if (!tag || normalised(m[1], classes)) continue;
const italic = plainText(tag[2]);
if (!italic || italic.length >= 0.8 * plainText(m[3]).length) continue;
r.report('italic-word-in-heading', doc.file, doc.lineOf(m.index!), `an <${tag[1].toLowerCase()}> inside an otherwise roman <h${m[1]}>`);
}
}
}
+17 -12
View File
@@ -40,22 +40,29 @@ export interface RawStaticResult {
const BUILD_DIRS = new Set(['dist', 'build', 'coverage']);
/** Every .css page and component file under the paths; node_modules, dot folders and (below the top) build output are skipped. */
/** A source file past this size is a bundle or a generated artefact, not a page to read; it is skipped with a warning so the lint answers in bounded time. */
export const MAX_FILE_BYTES = 2 * 1024 * 1024;
/** Every .css page and component file under the paths; node_modules, dot folders and (below the top) build output are skipped, a link cycle is walked once, and a file over MAX_FILE_BYTES is skipped with a warning. */
export function collectFiles(paths: string[], warn: (l: string) => void = () => {}): string[] {
const files: string[] = [];
const seen = new Set<string>();
const visit = (p: string, top: boolean) => {
const abs = path.resolve(p);
if (seen.has(abs)) return;
let abs = path.resolve(p);
let st: fs.Stats;
try { st = fs.statSync(abs); } catch { warn(`design-lint: cannot read ${p}`); return; }
try { abs = fs.realpathSync(abs); st = fs.statSync(abs); } catch { warn(`design-lint: cannot read ${p}`); return; }
if (seen.has(abs)) return;
if (st.isDirectory()) {
seen.add(abs);
const base = path.basename(abs);
if (base === 'node_modules' || base.startsWith('.') || (!top && BUILD_DIRS.has(base))) return;
for (const e of fs.readdirSync(abs).sort()) visit(path.join(abs, e), false);
return;
}
if (path.extname(abs).toLowerCase() === '.css' || docKindOf(abs)) { seen.add(abs); files.push(abs); }
if (path.extname(abs).toLowerCase() !== '.css' && !docKindOf(abs)) return;
seen.add(abs);
if (st.size > MAX_FILE_BYTES) { warn(`design-lint: skipped ${p} (${Math.round(st.size / 1024 / 1024)} MB, over the ${MAX_FILE_BYTES / 1024 / 1024} MB limit)`); return; }
files.push(abs);
};
for (const p of paths) visit(p, true);
return files;
@@ -130,7 +137,7 @@ function attachStylesheets(cssFiles: CssFile[], rules: CssRule[], pending: strin
inGroup.add(p);
const got = cache.read(p);
if (!got) continue;
cssFiles.push({ file: p, raw: got.raw, attached: true });
cssFiles.push({ file: p, raw: got.raw });
rules.push(...got.rules);
pending.push(...got.imports);
}
@@ -222,13 +229,11 @@ export function groupContexts(files: string[], opts: StaticOptions, warn: (l: st
return contexts;
}
const projectRoots = new Map<string, string | null>();
function projectRoot(dir: string): string | null {
if (projectRoots.has(dir)) return projectRoots.get(dir)!;
const up = path.dirname(dir);
const root = fs.existsSync(path.join(dir, 'package.json')) ? dir : up === dir ? null : projectRoot(up);
projectRoots.set(dir, root);
return root;
for (let d = dir; ; d = path.dirname(d)) {
if (fs.existsSync(path.join(d, 'package.json'))) return d;
if (path.dirname(d) === d) return null;
}
}
/**
+7 -35
View File
@@ -11,7 +11,7 @@
*/
import type { LintContext } from './context';
import { memo } from './context';
import { COLOR_LITERAL, hueIn, oklchOf, parseColor, type Color } from './color';
import { bannedGradient, COLOR_LITERAL, oklchOf, parseColor, type Color } from './color';
import { GENERIC_FAMILIES, LABEL_PX, MONO_FACE, TRACKED_EM, firstFamily, pxOf, resolveVars, splitTop, type Tokens } from './css';
import { braceEnd, closeIndex, clsLits, directChildren, getAttr, innerOf, plainText, type Doc, type Tag } from './html';
import { trunc } from './report';
@@ -194,8 +194,6 @@ export interface TwInfo {
animate: string | null;
transform: boolean;
transition: boolean;
caret: boolean;
scrollbar: boolean;
hero: boolean;
padded: boolean;
}
@@ -309,8 +307,7 @@ function twReadSurface(ctx: LintContext, b: string, tok: string, p: TwParsed, f:
if (/^rounded(?:-|$)/.test(b)) { if (b !== 'rounded-none') f.rounded = true; return; }
if (b === 'grid-cols-3') { f.cols3 = true; return; }
if (/^p[xytrblse]?-/.test(b)) { f.padded = true; return; }
if (b.startsWith('caret-')) { f.caret = true; return; }
if (b.startsWith('scrollbar') || b.startsWith('[scrollbar-')) f.scrollbar = true;
if (b.startsWith('caret-')) return;
}
/** Read one class string into its facts; the variants it uses land in the shared set as well as on the info. */
@@ -319,7 +316,7 @@ export function twInfo(ctx: LintContext, toks: string[], variants: Set<string>):
classes: toks, bg: null, text: null, stops: [], gradientText: false, sizePx: null, trackingEm: null, upper: false, smallCaps: false, mono: false, variants: new Set<string>(),
normalCase: false, accent: null, tAll: null, bounce: null, overshoot: null, hScale: null, hRotate: null, grad: false, clip: false, clear: false,
bblur: null, glass: null, blur: 0, blurTok: null, pos: null, fill: false, shadows: [], shadowCol: null, vh: null, oxHidden: null, oxClip: false,
colours: [], font: null, pal: [], rounded: false, cols3: false, animate: null, transform: false, transition: false, caret: false, scrollbar: false, hero: false, padded: false,
colours: [], font: null, pal: [], rounded: false, cols3: false, animate: null, transform: false, transition: false, hero: false, padded: false,
};
const acc: TwAcc = { sizeV: null, tracks: [] };
for (const tok of toks) {
@@ -447,31 +444,23 @@ export interface TwItem {
export interface TwGroup {
items: TwItem[];
variants: Set<string>;
caret: boolean;
scrollbar: boolean;
/** The first element that animates or transitions a transform. */
motion: TwItem | null;
/** An h1 or h2 set uppercase, which excuses uppercase controls. */
upperDisplay: boolean;
}
/** Every class-bearing element, component tag and helper call in the context, parsed. */
/** Give a component context the project's tokens and drop the Tailwind facts read before they were known. */
export function setTwTokens(ctx: LintContext, tokens: Tokens): void {
ctx.twTokens = tokens;
delete ctx.cache.tw;
}
/** Every class-bearing element, component tag and helper call in the context, parsed. */
export function twGroup(ctx: LintContext): TwGroup {
return memo(ctx, 'tw', () => {
const tw: TwGroup = { items: [], variants: new Set<string>(), caret: false, scrollbar: false, motion: null, upperDisplay: false };
const tw: TwGroup = { items: [], variants: new Set<string>(), upperDisplay: false };
const add = (it: TwItem) => {
const f = it.info;
tw.items.push(it);
if (f.caret) tw.caret = true;
if (f.scrollbar) tw.scrollbar = true;
if (!tw.motion && (f.animate || (f.transition && f.transform))) tw.motion = it;
if (/^h[12]$/.test(it.tag) && f.upper) tw.upperDisplay = true;
if (/^h[12]$/.test(it.tag) && it.info.upper) tw.upperDisplay = true;
};
for (const doc of ctx.docs) {
const stack: TwFrame[] = [];
@@ -544,23 +533,6 @@ export function twText(it: TwItem): string {
return it.text;
}
const paperIsDark = (tokens: Tokens): boolean => {
const p = parseColor(resolveVars(tokens['--color-paper'] || '', tokens));
return !!p && oklchOf(p).L < 0.4;
};
/** The banned ramp among a set of stop hues: purple to blue, purple to pink, or cyan on a dark paper. */
export function twBannedHues(hues: number[], tokens: Tokens): string | null {
const purple = hues.some((h) => hueIn(h, 265, 330));
const blue = hues.some((h) => hueIn(h, 200, 264));
const pink = hues.some((h) => hueIn(h, 331, 25));
const cyan = hues.some((h) => hueIn(h, 170, 230));
if (purple && blue) return 'purple-to-blue';
if (purple && pink) return 'purple-to-pink';
if (cyan && paperIsDark(tokens)) return 'cyan-on-dark';
return null;
}
export interface TwGradient { banned: string | null; text: boolean; list: string }
/** The gradient an element's stops describe: its banned pairing if any, whether it is text, and the stop classes. */
@@ -573,7 +545,7 @@ export function twGradient(ctx: LintContext, f: TwInfo): TwGradient | null {
if (names.some((x) => x !== a && TW_BLUE.test(x))) { banned = 'purple-to-blue'; break; }
if (names.some((x) => x !== a && TW_PINK.test(x))) { banned = 'purple-to-pink'; break; }
}
if (!banned) banned = twBannedHues(f.stops.map((s) => s.hue).filter((h): h is number => h != null), twTokens(ctx));
if (!banned) banned = bannedGradient(f.stops.map((s) => s.hue).filter((h): h is number => h != null), twTokens(ctx));
return { banned, text: f.clip && f.clear, list: f.stops.map((s) => s.tok).join(' ') };
}
+21 -10
View File
@@ -35,9 +35,15 @@ export interface WaiverClaim {
raw: string;
}
export interface Waiver {
reason: string;
/** The stylesheet (as printed) a claim written in a stylesheet answers for; null for a claim in the page, which answers for the whole page. */
sheet: string | null;
}
export interface WaiverSet {
/** Rule ids the page waived, each with its recorded reason. */
accepted: Map<string, string>;
accepted: Map<string, Waiver>;
/** Refused waivers, already shaped as findings. */
refused: Finding[];
}
@@ -45,7 +51,8 @@ export interface WaiverSet {
export const WAIVER_MIN_REASON = 24;
export const WAIVER_MAX = 3;
const WAIVER_RE = /(?:gstack-lint-disable|impeccable-disable)\s+([a-z][a-z0-9-]*)\s*:\s*([^\r\n]*)/g;
/** The reason runs to the end of its comment or line, so two waivers on one line are both read. */
const WAIVER_RE = /(?:gstack-lint-disable|impeccable-disable)\s+([a-z][a-z0-9-]*)\s*:\s*([^\r\n]*?)(?=-->|\*\/|[\r\n]|$)/g;
const WAIVER_REASON_MAX = 300;
const WAIVER_FILLER = /\b(a|an|the|is|it|this|that|was|are|and|but|so|very|just|really|here|quite|more|most)\b/gi;
@@ -68,7 +75,7 @@ export function findWaivers(file: string, source: string): WaiverClaim[] {
const out: WaiverClaim[] = [];
const lineOf = lineIndex(source);
for (const m of source.matchAll(WAIVER_RE)) {
const reason = m[2].replace(/(?:-->|\*\/).*$/, '').trim().slice(0, WAIVER_REASON_MAX);
const reason = m[2].trim().slice(0, WAIVER_REASON_MAX);
out.push({ rule: m[1], reason, file, line: lineOf(m.index!), raw: m[0] });
}
return out;
@@ -78,6 +85,7 @@ const pageWordsOf = (text: string): Set<string> =>
new Set((text.toLowerCase().replace(WAIVER_RE, ' ').match(/[a-z]{4,}/g) || []));
export const FLOOR_REFUSAL = 'it blocks; the Floor is never waivable';
export const OWN_RULE_REFUSAL = "the lint's own rule, never waivable";
/** The `waiver` finding for a claim the lint refused. */
function refusal(c: WaiverClaim, why: string, cwd = process.cwd()): Finding {
@@ -107,7 +115,7 @@ export function suspectedRules(findings: Finding[]): Set<string> {
* blocking rule, measured or not found, is refused.
*/
export function resolveWaivers(claims: WaiverClaim[], pageText: string, cwd = process.cwd(), suspected: ReadonlySet<string> = new Set()): WaiverSet {
const accepted = new Map<string, string>();
const accepted = new Map<string, Waiver>();
const refused: Finding[] = [];
const pageWords = pageWordsOf(pageText);
const reasons = new Map<string, number>();
@@ -115,15 +123,17 @@ export function resolveWaivers(claims: WaiverClaim[], pageText: string, cwd = pr
const refuse = (c: WaiverClaim, why: string) => refused.push(refusal(c, why, cwd));
for (const c of claims) {
const meta = ruleMeta(c.rule);
if (!meta || meta.gate === 'waiver' || meta.gate === 'render' || meta.gate === 'lint') { refuse(c, 'no such rule'); continue; }
if (!meta) { refuse(c, 'no such rule'); continue; }
if (meta.gate === 'waiver' || meta.gate === 'render' || meta.gate === 'lint') { refuse(c, OWN_RULE_REFUSAL); continue; }
if (meta.tier === 'blocks' && !suspected.has(c.rule)) { refuse(c, FLOOR_REFUSAL); continue; }
if (accepted.has(c.rule)) { refuse(c, 'one waiver per rule'); continue; }
if (c.reason.length < WAIVER_MIN_REASON) { refuse(c, `reason under ${WAIVER_MIN_REASON} characters`); continue; }
if (reasonIsBoilerplate(c.reason)) { refuse(c, 'boilerplate reason'); continue; }
if ((reasons.get(c.reason.toLowerCase()) || 0) > 1) { refuse(c, 'reason reused on another waiver'); continue; }
const words = c.reason.toLowerCase().match(/[a-z]{4,}/g) || [];
if (pageWords.size && !/\.css$/i.test(c.file) && !words.some((w) => pageWords.has(w))) { refuse(c, 'reason shares no word with the page'); continue; }
accepted.set(c.rule, c.reason);
const sheet = /\.css$/i.test(c.file);
if (pageWords.size && !sheet && !words.some((w) => pageWords.has(w))) { refuse(c, 'reason shares no word with the page'); continue; }
accepted.set(c.rule, { reason: c.reason, sheet: sheet ? relPath(c.file, cwd) : null });
}
if (accepted.size > WAIVER_MAX) {
const first = claims[0];
@@ -137,11 +147,12 @@ export function resolveWaivers(claims: WaiverClaim[], pageText: string, cwd = pr
return { accepted, refused };
}
/** Mark waived ask and polish findings; blocking findings never change. Returns the findings plus the refusals. */
/** Mark waived ask and polish findings; blocking findings never change, and a stylesheet's claim reaches only findings in that stylesheet. Returns the findings plus the refusals. */
export function applyWaivers(findings: Finding[], set: WaiverSet): Finding[] {
const out = findings.map((f) => {
if (f.tier === 'blocks' || !set.accepted.has(f.rule)) return f;
return { ...f, grade: 'waived' as const, waiver: oneLine(set.accepted.get(f.rule) ?? '') };
const w = set.accepted.get(f.rule);
if (f.tier === 'blocks' || !w || (w.sheet && f.file !== w.sheet)) return f;
return { ...f, grade: 'waived' as const, waiver: oneLine(w.reason) };
});
return [...out, ...set.refused];
}
+9 -5
View File
@@ -238,9 +238,9 @@ describe('design-lint: the static tier\'s files and contexts', () => {
const { contexts } = lintStatic([path.join(dir, 'site')], { cwd: dir, warn: (l) => { throw new Error(l); } });
expect(contexts).toHaveLength(1);
expect(contexts[0].cssFiles.map((c) => [path.relative(dir, c.file), c.attached ?? false])).toEqual([['site/local.css', false], ['shared/theme.css', true]]);
expect(contexts[0].cssFiles.map((c) => path.relative(dir, c.file))).toEqual(['site/local.css', 'shared/theme.css']);
const set = contextWaivers(contexts[0], dir);
expect([...set.accepted.entries()]).toEqual([['section-gap', 'the ledger separates its rows with rules'], ['template-nav', 'the ledger keeps three links for its three modes']]);
expect([...set.accepted.entries()]).toEqual([['section-gap', { reason: 'the ledger separates its rows with rules', sheet: 'site/local.css' }], ['template-nav', { reason: 'the ledger keeps three links for its three modes', sheet: 'shared/theme.css' }]]);
expect(set.refused).toEqual([]);
});
@@ -365,7 +365,7 @@ describe('design-lint: hardening (ship review)', () => {
});
const { contexts, findings } = lintStatic([path.join(dir, 'site')], { cwd: dir, warn: () => {} });
const page = contexts.find((c) => c.key.endsWith('index.html'))!;
expect(page.cssFiles.map((c) => [path.relative(dir, c.file), c.attached ?? false])).toEqual([['site/css/theme.css', true]]);
expect(page.cssFiles.map((c) => path.relative(dir, c.file))).toEqual(['site/css/theme.css']);
expect(findings.filter((f) => f.rule === 'overflow-x-unclipped')).toEqual([]);
});
@@ -374,7 +374,7 @@ describe('design-lint: hardening (ship review)', () => {
for (let i = 0; i < 12; i++) files[`pages/p${i}.html`] = `<!doctype html><html lang="en"><head><title>P${i}</title><link rel="stylesheet" href="../css/site.css"></head><body><main><h1>Page ${i}</h1><p>Hello there.</p></main></body></html>`;
const dir = tmpTree(files);
const r = lintStatic([dir], { cwd: dir, warn: () => {} });
expect(r.contexts.filter((c) => c.cssFiles.some((f) => f.file.endsWith('site.css') && f.attached))).toHaveLength(12);
expect(r.contexts.filter((c) => c.cssFiles.some((f) => f.file.endsWith('site.css')))).toHaveLength(12);
expect(r.contexts.filter((c) => c.key.endsWith('css'))).toEqual([]);
const rulesOf = (c: LintContext) => c.rules.filter((x) => x.file.endsWith('site.css'));
expect(rulesOf(r.contexts[0])[0]).toBe(rulesOf(r.contexts[1])[0]);
@@ -464,7 +464,11 @@ describe('design-lint: hardening (ship review)', () => {
for (const rule of ['internal-error', 'waiver', 'house-style', 'render']) {
const set = resolveWaivers([{ rule, reason: 'the manifesto sets long declarative lines', file: 'a.html', line: 1, raw: '' }], page);
expect([...set.accepted.keys()]).toEqual([]);
expect(set.refused.map((f) => f.evidence)).toEqual([`waiver on ${rule} refused: no such rule`]);
expect(set.refused.map((f) => f.evidence)).toEqual([`waiver on ${rule} refused: the lint's own rule, never waivable`]);
}
const unknown = resolveWaivers([{ rule: 'no-such-thing', reason: 'the manifesto sets long declarative lines', file: 'a.html', line: 1, raw: '' }], page);
expect(unknown.refused.map((f) => f.evidence)).toEqual(['waiver on no-such-thing refused: no such rule']);
{
}
});