diff --git a/lib/design-lint/color.ts b/lib/design-lint/color.ts index 82e5b32a4..e17041238 100644 --- a/lib/design-lint/color.ts +++ b/lib/design-lint/color.ts @@ -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; +} diff --git a/lib/design-lint/context.ts b/lib/design-lint/context.ts index e7c3317fb..89765cde0 100644 --- a/lib/design-lint/context.ts +++ b/lib/design-lint/context.ts @@ -9,8 +9,6 @@ import type { Doc } from './html'; export interface CssFile { file: string; raw: string; - /** Reached through @import or , 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. */ diff --git a/lib/design-lint/index.ts b/lib/design-lint/index.ts index 34317285b..91fff9bb4 100644 --- a/lib/design-lint/index.ts +++ b/lib/design-lint/index.ts @@ -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(); 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)); diff --git a/lib/design-lint/render.ts b/lib/design-lint/render.ts index 467f8ab24..9ae308659 100644 --- a/lib/design-lint/render.ts +++ b/lib/design-lint/render.ts @@ -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; +export type Runner = (spec: RenderSpec) => Promise; export async function lintRender(targets: string[], opts: RenderOptions = {}): Promise { 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); diff --git a/lib/design-lint/rules/assets.ts b/lib/design-lint/rules/assets.ts index aca0bd825..57b2cfaf6 100644 --- a/lib/design-lint/rules/assets.ts +++ b/lib/design-lint/rules/assets.ts @@ -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([ ['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; diff --git a/lib/design-lint/rules/chrome-copy.ts b/lib/design-lint/rules/chrome-copy.ts index dd91c9e49..5a74756c0 100644 --- a/lib/design-lint/rules/chrome-copy.ts +++ b/lib/design-lint/rules/chrome-copy.ts @@ -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
or
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]; diff --git a/lib/design-lint/rules/chrome.ts b/lib/design-lint/rules/chrome.ts index e9bc216bb..af1d055c7 100644 --- a/lib/design-lint/rules/chrome.ts +++ b/lib/design-lint/rules/chrome.ts @@ -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) diff --git a/lib/design-lint/rules/color.ts b/lib/design-lint/rules/color.ts index 668651a05..52722adc0 100644 --- a/lib/design-lint/rules/color.ts +++ b/lib/design-lint/rules/color.ts @@ -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) diff --git a/lib/design-lint/rules/motion.ts b/lib/design-lint/rules/motion.ts index 5f16d9a5b..ed4464a4c 100644 --- a/lib/design-lint/rules/motion.ts +++ b/lib/design-lint/rules/motion.ts @@ -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' }); } diff --git a/lib/design-lint/rules/structure.ts b/lib/design-lint/rules/structure.ts index 699c13b72..0102bca05 100644 --- a/lib/design-lint/rules/structure.ts +++ b/lib/design-lint/rules/structure.ts @@ -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(); + 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(); + 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(); + 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`); diff --git a/lib/design-lint/rules/type.ts b/lib/design-lint/rules/type.ts index 8f439e6c8..1de61ab2c 100644 --- a/lib/design-lint/rules/type.ts +++ b/lib/design-lint/rules/type.ts @@ -135,8 +135,11 @@ function check38a(ctx: LintContext, r: Reporter): void { for (const doc of ctx.docs) { for (const m of doc.src.matchAll(/]*)>([\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 `); + 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 `); } } } diff --git a/lib/design-lint/static.ts b/lib/design-lint/static.ts index efe7cf969..de3ffca92 100644 --- a/lib/design-lint/static.ts +++ b/lib/design-lint/static.ts @@ -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(); 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(); 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; + } } /** diff --git a/lib/design-lint/tailwind.ts b/lib/design-lint/tailwind.ts index 4a9ed8a5c..4677448fb 100644 --- a/lib/design-lint/tailwind.ts +++ b/lib/design-lint/tailwind.ts @@ -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): classes: toks, bg: null, text: null, stops: [], gradientText: false, sizePx: null, trackingEm: null, upper: false, smallCaps: false, mono: false, variants: new Set(), 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; - 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(), caret: false, scrollbar: false, motion: null, upperDisplay: false }; + const tw: TwGroup = { items: [], variants: new Set(), 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(' ') }; } diff --git a/lib/design-lint/waivers.ts b/lib/design-lint/waivers.ts index 2d2350e5f..3af868f22 100644 --- a/lib/design-lint/waivers.ts +++ b/lib/design-lint/waivers.ts @@ -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; + accepted: Map; /** 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 => 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 { * blocking rule, measured or not found, is refused. */ export function resolveWaivers(claims: WaiverClaim[], pageText: string, cwd = process.cwd(), suspected: ReadonlySet = new Set()): WaiverSet { - const accepted = new Map(); + const accepted = new Map(); const refused: Finding[] = []; const pageWords = pageWordsOf(pageText); const reasons = new Map(); @@ -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]; } diff --git a/test/design-lint.test.ts b/test/design-lint.test.ts index e3bf52afd..5b530d0b3 100644 --- a/test/design-lint.test.ts +++ b/test/design-lint.test.ts @@ -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`] = `P${i}

Page ${i}

Hello there.

`; 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']); + { } });