From a84b030ca06495033eff5ceb0d25ac73a9eef1c0 Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 26 Oct 2026 12:01:41 +0000 Subject: [PATCH] design-lint: eighth review cycle fixes Waivers follow the context's shape, not the --scope flag: one html page keeps page semantics under either scope (its claim reaches the sheet it links) and a lone component beside an unlinked stylesheet answers for its own file only. Every '<' that starts no tag is a stray spaced out of doc.src (a '' ahead stays), an unclosed comment runs to the end of the file, the three bare /<[^>]*>/ strips use stripTags, the copy tier drops open script, style, template and nav tags nothing closes, component tags are paired once per document, columnCount counts repeat() arithmetically, an unclosed @starting-style block is read once, the import scan matches import shapes only, the render locator refuses a non-letter tag and reads the class attribute linearly, tickers are answered from the tree, a tooltip duration is not a delay, class tokens are appended in a loop, the emoji dedupe is keyed by the icon, the emoji classes spell U+FE0F and U+200D as escapes, the contrast and focus viewports are named once, and the hero-fold title matches what the probe measures. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/design-lint/html.ts | 35 +++++++++++++++--------------- lib/design-lint/render.ts | 27 +++++++++++++---------- lib/design-lint/rules/assets.ts | 24 ++++++++++---------- lib/design-lint/rules/chrome.ts | 11 ++++------ lib/design-lint/rules/component.ts | 6 ++--- lib/design-lint/rules/copy.ts | 19 +++++++++++++++- lib/design-lint/rules/ids.ts | 2 +- lib/design-lint/rules/motion.ts | 8 ++++--- lib/design-lint/rules/structure.ts | 16 +++++++------- lib/design-lint/static.ts | 9 ++++---- lib/design-lint/tailwind.ts | 33 +++++++++++++++++----------- lib/design-lint/waivers.ts | 5 +++-- 12 files changed, 113 insertions(+), 82 deletions(-) diff --git a/lib/design-lint/html.ts b/lib/design-lint/html.ts index 157f910e8..6cc80ef73 100644 --- a/lib/design-lint/html.ts +++ b/lib/design-lint/html.ts @@ -15,15 +15,7 @@ import { lineIndex } from './css'; export type DocKind = 'html' | 'jsx' | 'vue' | 'svelte' | 'astro'; -export interface Tag { - close: boolean; - name: string; - attrs: string; - pos: number; - end: number; - selfClosing: boolean; - line: number; -} +export interface Tag { close: boolean; name: string; attrs: string; pos: number; end: number; selfClosing: boolean; line: number } /** A string as a literal inside a RegExp source. */ export const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -70,10 +62,11 @@ interface Span { start: number; bodyStart: number; bodyEnd: number; end: number /** * Every `open … close` span of lower in one linear pass, so a file of - * unclosed openers costs its length and not its square. An opener with no - * closer after it is left alone, which is what the lazy regex it replaces did. - * For an element, open is `` (spaces before the `>` allowed). + * unclosed openers costs its length and not its square. An unclosed comment + * runs to the end of the file, as a browser reads it; an element opener with + * no closer after it is left alone, as a tag. For an element, open is + * `` (spaces before the `>` allowed). */ function spansOf(lower: string, open: string, close: string, element: boolean): Span[] { const out: Span[] = []; @@ -84,7 +77,7 @@ function spansOf(lower: string, open: string, close: string, element: boolean): const after = start + open.length; if (!element) { const c = lower.indexOf(close, after); - if (c < 0) break; + if (c < 0) { out.push({ start, bodyStart: after, bodyEnd: lower.length, end: lower.length }); break; } out.push({ start, bodyStart: after, bodyEnd: c, end: c + close.length }); i = c + close.length; continue; @@ -140,19 +133,25 @@ const MAX_UNQUOTED_ATTRS = 8192; * ``, with quoted attribute values skipped whole. A `<` that never * reaches a `>`, or a quote that never closes, is text; a `<` that opens a * tag inside unquoted attribute text starts the next tag instead, as a - * browser reads ``. + * browser reads ``. Every `<` that starts no tag is a stray, except + * a `` after it. */ function scanTags(s: string, onTag: (close: boolean, name: string, attrs: string, pos: number, end: number) => void, strays?: number[]): void { const n = s.length; const noQuoteFrom: Record = { '"': n, "'": n }; - let i = 0; + let i = 0, nextGt = -1; while (i < n) { const lt = s.indexOf('<', i); if (lt < 0) break; let j = lt + 1; const close = s[j] === '/'; if (close) j++; - if (!/[a-zA-Z]/.test(s[j] || '')) { i = lt + 1; continue; } + if (!/[a-zA-Z]/.test(s[j] || '')) { + if (nextGt < lt) { const g = s.indexOf('>', lt); nextGt = g < 0 ? n : g; } + if (!/[!?]/.test(s[j] || '') || nextGt >= n) strays?.push(lt); + i = lt + 1; + continue; + } let k = j + 1; while (k < n && /[a-zA-Z0-9-]/.test(s[k])) k++; let p = k, unquoted = 0, next = lt + 1, gt = -1; @@ -176,7 +175,7 @@ function scanTags(s: string, onTag: (close: boolean, name: string, attrs: string } } -/** The text with each stray `<` (a tag-shaped start the scanner found no `>` for) replaced by a space, so a `]*>` regex over it always finds its `>` within the tag and a page whose tail is ten thousand unclosed ``, a `<[^>]*>` regex over it costs its length, and a page whose tail is ten thousand unclosed ` ({ label: `${w}px`, width: w, height: 800, call: w === 1280 ? 'P.layout(P, true).concat(P.contrast(P), P.accent(P))' : 'P.layout(P, false)' })); - m.push({ label: '1440px', ...LAPTOP, call: 'P.space(P, true)' }); + const m: Measure[] = WIDTHS.map((w) => ({ label: `${w}px`, width: w, height: CONTRAST_VIEW.height, call: w === CONTRAST_VIEW.width ? 'P.layout(P, true).concat(P.contrast(P), P.accent(P))' : 'P.layout(P, false)' })); + m.push({ label: `${LAPTOP.width}px`, ...LAPTOP, call: 'P.space(P, true)' }); m.push({ label: '1920px', width: 1920, height: 1080, call: 'P.space(P, false)' }); - for (const call of ['P.quality(P)', 'P.occlusion(P)', 'P.focus(P)']) m.push({ label: '1440px', ...LAPTOP, call }); + for (const call of ['P.quality(P)', 'P.occlusion(P)', 'P.focus(P)']) m.push({ label: `${LAPTOP.width}px`, ...LAPTOP, call }); return m; } @@ -105,16 +107,19 @@ function parsed(text: string | undefined): Parsed { } } -/** The source line of the element a finding names: its tag, first class, a distinguishing attribute and its opening text. Best effort; 1 when nothing matches. */ +/** A probe's locator field: a string no longer than a class name or an opening line could be; anything else locates nothing. */ +const SHORT = (v: unknown): v is string => typeof v === 'string' && v.length <= 400; + +/** The source line of the element a finding names: its tag, first class, a distinguishing attribute and its opening text. Best effort; 1 when nothing matches, or when the locator is not shaped like one the probes emit. */ export function lineOf(src: string | null, at?: ProbeAt, lineAt: (i: number) => number = src ? lineIndex(src) : () => 1): number { - if (!src || !at || typeof at.tag !== 'string' || typeof at.text !== 'string') return 1; - const cls = typeof at.cls === 'string' && at.cls ? new RegExp(`class=["'](?:[^"']*\\s)?${escapeRe(at.cls)}(?:\\s[^"']*)?["']`, 'i') : null; - const attr = typeof at.attr === 'string' && at.attr ? at.attr : null; + if (!src || !at || !SHORT(at.tag) || !/^[a-z]/i.test(at.tag) || !SHORT(at.text)) return 1; + const cls = SHORT(at.cls) && at.cls ? at.cls : null; + const attr = SHORT(at.attr) && at.attr ? at.attr : null; const want = at.text.toLowerCase(); let fallback = -1; for (const m of src.matchAll(new RegExp(`<${escapeRe(at.tag)}\\b[^>]*>`, 'gi'))) { const pos = m.index ?? 0; - if (cls && !cls.test(m[0])) continue; + if (cls && !(/\bclass\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(m[0]) ?? ['', '']).slice(1).join(' ').split(/\s+/).includes(cls)) continue; if (attr && !m[0].includes(attr)) continue; if (!want) { fallback = pos; break; } if (plainText(src.slice(pos + m[0].length, pos + m[0].length + 600)).toLowerCase().startsWith(want)) { fallback = pos; break; } @@ -197,7 +202,7 @@ async function lintPage(target: string, r: Reporter, run: Runner): Promise 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); + if (bare.length) report({ rule: 'missing-focus-visible', grade: 'fail', evidence: `[${LAPTOP.width}px] 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); const textProbes = found.filter((f) => f.probe); const weakRings = rings.filter((x) => x.ratio != null && x.ratio < RING_FLOOR); @@ -212,7 +217,7 @@ async function lintPage(target: string, r: Reporter, run: Runner): Promise for (const f of found) if (!(f.probe && dropped.has(f.probe.path))) report(f, src); for (const ring of weakRings) { const real = confirmed?.ring.get(ring.path) ?? ring.ratio!; - if (real < RING_FLOOR) report({ rule: 'missing-focus-visible', grade: 'fail', evidence: `[1440px] focus ring ${real.toFixed(2)}:1 against what is behind it on ${ring.key}`, fix: 'a ring at 3:1 or better against the surface it is drawn over', at: ring.at }, src); + if (real < RING_FLOOR) report({ rule: 'missing-focus-visible', grade: 'fail', evidence: `[${LAPTOP.width}px] focus ring ${real.toFixed(2)}:1 against what is behind it on ${ring.key}`, fix: 'a ring at 3:1 or better against the surface it is drawn over', at: ring.at }, src); } } @@ -225,7 +230,7 @@ async function confirmPixels(file: string, run: Runner, textProbes: Found[], wea const textItems: PixelItem[] = [...new Map(textProbes.map((f) => [f.probe!.path, { path: f.probe!.path, kind: 'text' as const }])).values()]; const ringItems: PixelItem[] = weakRings.map((x) => ({ path: x.path, kind: 'ring' as const, pad: x.pad })); const groups: Array<{ items: PixelItem[]; width: number; height: number; shot: string; evalIdx: number }> = []; - if (textItems.length) groups.push({ items: textItems, width: 1280, height: 800, shot: path.join(dir, 'text.png'), evalIdx: -1 }); + if (textItems.length) groups.push({ items: textItems, ...CONTRAST_VIEW, shot: path.join(dir, 'text.png'), evalIdx: -1 }); if (ringItems.length) groups.push({ items: ringItems, width: LAPTOP.width, height: LAPTOP.height, shot: path.join(dir, 'ring.png'), evalIdx: -1 }); const steps: RenderStep[] = openingSteps(); for (const g of groups) { diff --git a/lib/design-lint/rules/assets.ts b/lib/design-lint/rules/assets.ts index 302795feb..5385c00f2 100644 --- a/lib/design-lint/rules/assets.ts +++ b/lib/design-lint/rules/assets.ts @@ -9,22 +9,24 @@ import { contrastRatio, oklchOf, parseColor, type Color } from '../color'; import { mergedRules, type LintContext } from '../context'; import { CONTRAST_SLACK, isTokenRule, resolveVars, pxOf, type MergedRule, type Tokens } from '../css'; -import { ancestorsOf, getAttr, hasAttr, innerOf, textOf, treeOf } from '../html'; -import { trunc, type Reporter } from '../report'; +import { ancestorsOf, getAttr, hasAttr, innerOf, stripTags, textOf, treeOf } from '../html'; +import { trunc, type Finding, type Reporter } from '../report'; const EMOJI = /(?![©®™‼⁉←-⇿])\p{Extended_Pictographic}/u; -const GLYPH = /(?![©®™‼⁉←-⇿✓-✖])(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}️)/u; +const GLYPH = /(?![©®™‼⁉←-⇿✓-✖])(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}\uFE0F)/u; // sloplint check30 (gate 30a) function checkEmojiIcons(ctx: LintContext, r: Reporter): void { for (const doc of ctx.docs) { const lines = new Set(); let n = 0; - const flag = (line: number, what: string): void => { - if (lines.has(line) || n >= 8) return; + const icons = (doc.cache.emojiIcons ??= new Map()) as Map; + const flag = (line: number, what: string): boolean => { + if (lines.has(line) || n >= 8) return false; lines.add(line); n++; r.report('emoji-decoration', doc.file, line, what); + return true; }; for (let i = 0; i < doc.tags.length; i++) { const t = doc.tags[i]; @@ -33,9 +35,9 @@ function checkEmojiIcons(ctx: LintContext, r: Reporter): void { const iconish = /icon|card|step|tier|feature|value|prop|badge/i.test(cls); const inner = innerOf(doc, i); if (!inner) continue; - const content = inner.inner.replace(/<[^>]*>/g, '').trim(); + const content = stripTags(inner.inner).replace(/\s+/g, ' ').trim(); if (content && content.length <= 4 && EMOJI.test(content) && (iconish || !/<[a-z]/.test(inner.inner))) { - flag(t.line, 'an emoji icon "' + content + '" in <' + t.name + ' class="' + trunc(cls, 28) + '">'); + if (flag(t.line, 'an emoji icon "' + content + '" in <' + t.name + ' class="' + trunc(cls, 28) + '">')) icons.set(content, r.findings[r.findings.length - 1]); lines.add(doc.lineOf(t.end + inner.inner.search(EMOJI))); continue; } @@ -47,9 +49,9 @@ function checkEmojiIcons(ctx: LintContext, r: Reporter): void { } const raw = doc.raw; const body = doc.masked; - 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 body.matchAll(/>\s*((?:\p{Emoji_Presentation}|\p{Extended_Pictographic}\uFE0F)[^<>{}\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 body.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]); + if (doc.kind !== 'html') for (const m of raw.matchAll(/(["'`])\s*((?:(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}\uFE0F?)\u200D?){1,3})\s*\1/gu)) flag(doc.lineOf(m.index!), 'an emoji passed as an icon: ' + m[2]); } } @@ -76,7 +78,7 @@ function iconSurfaces(ctx: LintContext): string { } 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 m of doc.scripts.matchAll(/\b(?:import\s*\(?\s*|require\s*\(\s*|from\s+)['"]([^'"\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'); @@ -205,7 +207,7 @@ function checkWrappingLabels(ctx: LintContext, r: Reporter): void { if (!button && !link) continue; const inner = innerOf(doc, i); if (!inner) continue; - const label = inner.inner.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); + const label = stripTags(inner.inner).replace(/\s+/g, ' ').trim(); if (label.length <= 24 || covered(cls, t.name)) continue; if (button) r.report('button-label-wraps', doc.file, t.line, 'the long label "' + trunc(label, 36) + '" on <' + t.name + '> can wrap, and nothing sets nowrap', { suspected: true }); else r.report('link-wraps', doc.file, t.line, 'the long link "' + trunc(label, 36) + '" can wrap, and nothing sets nowrap'); diff --git a/lib/design-lint/rules/chrome.ts b/lib/design-lint/rules/chrome.ts index e562103cd..be0670926 100644 --- a/lib/design-lint/rules/chrome.ts +++ b/lib/design-lint/rules/chrome.ts @@ -125,18 +125,15 @@ const TICKER = /marquee|ticker|tape|crawl/i; function checkTemplatePunctuation(ctx: LintContext, r: Reporter): void { for (const doc of ctx.docs) { const dotLines = new Set(), arrowLines = new Set(); - const tickerRanges: Array<[number, number]> = []; + const { parent } = treeOf(doc); + const inTicker = new Uint8Array(doc.tags.length); for (let i = 0; i < doc.tags.length; i++) { const t = doc.tags[i]; - if (t.close || t.selfClosing || !TICKER.test(getAttr(t.attrs, 'class') || '')) continue; - const inner = innerOf(doc, i); - if (inner) tickerRanges.push([t.pos, inner.endPos]); + inTicker[i] = (parent[i] >= 0 && inTicker[parent[i]]) || (!t.close && !t.selfClosing && TICKER.test(getAttr(t.attrs, 'class') || '') ? 1 : 0); } for (let i = 0; i < doc.tags.length; i++) { const t = doc.tags[i]; - if (t.close || t.selfClosing || !TEXT_TAGS.has(t.name)) continue; - const cls = getAttr(t.attrs, 'class') || ''; - if (TICKER.test(cls) || tickerRanges.some(([a, b]) => t.pos > a && t.pos < b)) continue; + if (t.close || t.selfClosing || !TEXT_TAGS.has(t.name) || inTicker[i]) continue; const inner = innerOf(doc, i); if (!inner || /<(p|div|li|section|article|ul|ol|table)\b/i.test(inner.inner)) continue; const text = textOf(doc, i); diff --git a/lib/design-lint/rules/component.ts b/lib/design-lint/rules/component.ts index 2ee252853..c9b0ceae8 100644 --- a/lib/design-lint/rules/component.ts +++ b/lib/design-lint/rules/component.ts @@ -6,7 +6,7 @@ * emoji icons and template punctuation (30a, 63) in component text. */ import type { LintContext } from '../context'; -import { oneLine, relPath, trunc, type Reporter } from '../report'; +import { oneLine, relPath, trunc, type Finding, type Reporter } from '../report'; import { compInner, twEyebrow, twGlow, twGradient, twGroup, twMonoIsMaterial, twPlain, twText, twTiles, twTokens, twTrackedCaps, twWhere, twDataText, type TwItem } from '../tailwind'; const EMOJI_ICON = /(?![©®™‼⁉←-⇿])\p{Extended_Pictographic}/u; @@ -93,7 +93,6 @@ function checkTailwind(ctx: LintContext, r: Reporter): void { // sloplint checkComponentText (gates 63 30a) function checkComponentText(ctx: LintContext, r: Reporter): void { for (const doc of ctx.docs) { - const file = oneLine(relPath(doc.file, r.cwd)); for (let c = 0; c < doc.comps.length; c++) { const t = doc.comps[c]; if (t.close || t.selfClosing) continue; @@ -104,7 +103,8 @@ function checkComponentText(ctx: LintContext, r: Reporter): void { if (/(?:link|button|btn|cta)$/i.test(t.name) && /(→|->|›|»|⟶)\s*$/.test(text)) r.report('template-punctuation', doc.file, t.line, `arrow glued to the label "${trunc(text, 40)}" (<${t.name}>)`, { fix: 'a verb as the label; the link already says it goes somewhere' }); if ((text.match(/\s[·•]\s/g) || []).length >= 2) r.report('template-punctuation', doc.file, t.line, `middle-dot meta string "${trunc(text, 50)}"`, { fix: 'line breaks, columns or hairlines; one dot in a byline at most' }); if (text.length <= 4 && EMOJI_ICON.test(text)) { - const outer = r.findings.findIndex((f) => f.rule === 'emoji-decoration' && f.file === file && f.evidence.includes(`"${text}"`)); + const icon = (doc.cache.emojiIcons as Map | undefined)?.get(text); + const outer = icon ? r.findings.indexOf(icon) : -1; if (outer >= 0) r.findings.splice(outer, 1); r.report('emoji-decoration', doc.file, t.line, `emoji icon "${text}" in <${t.name}>`, { fix: 'one icon library or custom SVG' }); } diff --git a/lib/design-lint/rules/copy.ts b/lib/design-lint/rules/copy.ts index d9629229a..a41d598fa 100644 --- a/lib/design-lint/rules/copy.ts +++ b/lib/design-lint/rules/copy.ts @@ -9,15 +9,32 @@ */ import { checkCraft } from '../../design-craft'; import type { LintContext } from '../context'; +import type { Doc } from '../html'; import type { Reporter } from '../report'; import { ruleMeta } from './ids'; const STATIC_OWNS = new Set(['transition-all', 'vh-height']); +/** The elements the craft check reads from open tag to close tag with a lazy scan. */ +const SPANNED = new Set(['script', 'style', 'template', 'nav']); + +/** The page as the craft check reads it: an open script, style, template or nav tag that nothing closes after it is blanked, so each lazy scan to its close finds one and the page costs its length. */ +function craftSource(doc: Doc): string { + const lastClose = new Map(); + for (const t of doc.tags) if (t.close && SPANNED.has(t.name)) lastClose.set(t.name, t.pos); + let out = '', from = 0; + for (const t of doc.tags) { + if (t.close || !SPANNED.has(t.name) || t.pos < (lastClose.get(t.name) ?? -1)) continue; + out += doc.src.slice(from, t.pos) + ' '; + from = t.pos + 1; + } + return out + doc.src.slice(from); +} + export function run(ctx: LintContext, r: Reporter): void { for (const doc of ctx.docs) { if (doc.kind !== 'html') continue; - for (const f of checkCraft(doc.src)) { + for (const f of checkCraft(craftSource(doc))) { if (STATIC_OWNS.has(f.id) || !ruleMeta(f.id)) continue; const title = ruleMeta(f.id)!.title; r.report(f.id, doc.file, 1, f.count === 1 ? title : `${title}, ${f.count} times`, { fix: f.fix, source: 'copy' }); diff --git a/lib/design-lint/rules/ids.ts b/lib/design-lint/rules/ids.ts index 3d8aa5b83..26455db1b 100644 --- a/lib/design-lint/rules/ids.ts +++ b/lib/design-lint/rules/ids.ts @@ -66,7 +66,7 @@ export const RULES: Record = { 'overflow-x-unclipped': { tier: 'ask', gate: '34', title: 'overflow-x missing on html and body, or a 100vw width', fix: 'overflow-x: clip on both; width: 100%', pageOnly: true }, 'horizontal-scroll': { tier: 'blocks', gate: '34', title: 'horizontal scroll at a width between 320 and 1920', fix: 'find the element wider than its viewport and cap it', render: true, pageOnly: true }, 'hero-balance': { tier: 'ask', gate: '44a/44c', title: 'the hero\'s content sits off centre for its screen, or its visual stands apart from the text', fix: 'centre the content in its screen; put the visual beside the text', render: true, pageOnly: true }, - 'hero-fold': { tier: 'blocks', gate: '44b', title: 'headline, lede, primary action and visual not all visible at 1280x800', fix: 'pull the display clamp down, tighten leading, a two-line lede, trim padding', render: true, pageOnly: true }, + 'hero-fold': { tier: 'blocks', gate: '44b', title: "the headline's bottom or the hero's first action below the 800px fold at 1280 wide; the lede and the visual are not measured", fix: 'pull the display clamp down, tighten leading, a two-line lede, trim padding', render: true, pageOnly: true }, 'wide-container': { tier: 'blocks', gate: '44d', title: 'at 1920x1080 the container has no max-width, gutters under 120px, or a grid gains columns', fix: 'cap 1360 to 1520px and centre; hold column counts; cap vw visuals', pageOnly: true }, 'bare-fr-track': { tier: 'blocks', gate: '50', title: 'an image-bearing grid track on bare 1fr', fix: 'minmax(0, 1fr)', pageOnly: true }, 'display-head-unwrapped': { tier: 'blocks', gate: '50', title: 'a display head without overflow-wrap: break-word and min-width: 0', fix: 'overflow-wrap: break-word; min-width: 0 (never anywhere)', pageOnly: true }, diff --git a/lib/design-lint/rules/motion.ts b/lib/design-lint/rules/motion.ts index 62ca53f76..f2e8721ed 100644 --- a/lib/design-lint/rules/motion.ts +++ b/lib/design-lint/rules/motion.ts @@ -162,7 +162,7 @@ function tooltipDelay(ctx: LintContext, r: Reporter): void { const delayed = (sel: RegExp): { file: string; line: number } | null => { for (const rule of tips) { if (!sel.test(rule.selector)) continue; - const d = rule.decls.find((x) => /^transition(-delay)?$/.test(x.prop) && /(\d{3,})ms|([1-9][\d.]*)s\b/.test(x.value)); + const d = rule.decls.find((x) => /^transition(-delay)?$/.test(x.prop) && /(? 0) { if (css[k] === '{') depth++; else if (css[k] === '}') depth--; k++; } + taken = k; out += css.slice(m.index!, k) + ' '; const open = css.lastIndexOf('{', m.index! - 1), close = css.lastIndexOf('}', m.index! - 1); if (open > close) out += css.slice(Math.max(close + 1, 0), open) + ' '; diff --git a/lib/design-lint/rules/structure.ts b/lib/design-lint/rules/structure.ts index cf3f05eb5..a5675af7b 100644 --- a/lib/design-lint/rules/structure.ts +++ b/lib/design-lint/rules/structure.ts @@ -8,7 +8,7 @@ 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, textOf, treeOf, wordsOf, type Doc } from '../html'; +import { getAttr, innerOf, stripTags, textOf, treeOf, wordsOf, type Doc } from '../html'; import { trunc, type Reporter } from '../report'; import { twGroup } from '../tailwind'; import { heroIndexOf } from './chrome-copy'; @@ -258,7 +258,7 @@ function displayHeadUnwrapped(ctx: LintContext, r: Reporter): void { if (!/^h[12]$/.test(t.name) && !/hero__(title|display)|section__title|__display/.test(cls)) continue; const inner = innerOf(doc, i); if (!inner) continue; - const text = inner.inner.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); + const text = stripTags(inner.inner).replace(/\s+/g, ' ').trim(); const risky = text.split(' ').find((word) => word.split('-').some((seg) => seg.replace(/[^\w]/g, '').length >= 16)); if (risky && !wrapCovered(cls, t.name)) { r.report('display-head-unwrapped', doc.file, t.line, `the display head holds "${trunc(risky, 30)}" with no overflow-wrap: at 320px that word overflows`); @@ -351,13 +351,13 @@ function minWidthPx(media: string | null): number | null { /** How many columns a grid-template-columns value lays down; null when the count depends on the viewport (auto-fit, auto-fill) or on a var. */ function columnCount(value: string): number | null { if (/auto-fi(t|ll)|var\(/.test(value)) return null; - let v = value; - for (const m of value.matchAll(/repeat\(\s*(\d+)\s*,\s*((?:[^()]|\([^()]*\))*)\)/g)) { - const inner = m[2].replace(/\([^()]*\)/g, 'X').trim().split(/\s+/).length; - v = v.replace(m[0], Array(parseInt(m[1], 10) * inner).fill('X').join(' ')); - } + let repeated = 0; + const v = value.replace(/repeat\(\s*(\d+)\s*,\s*((?:[^()]|\([^()]*\))*)\)/g, (_, count: string, tracks: string) => { + repeated += parseInt(count, 10) * tracks.replace(/\([^()]*\)/g, 'X').trim().split(/\s+/).length; + return ' '; + }); if (/repeat\(/.test(v)) return null; - return v.replace(/\([^()]*\)/g, 'X').replace(/\[[^\]]*\]/g, ' ').trim().split(/\s+/).filter(Boolean).length; + return repeated + v.replace(/\([^()]*\)/g, 'X').replace(/\[[^\]]*\]/g, ' ').trim().split(/\s+/).filter(Boolean).length; } /** Whether some container is capped: a max-width paired with auto margins, a max-width of 900px or more, or a Tailwind container or max-w utility. */ diff --git a/lib/design-lint/static.ts b/lib/design-lint/static.ts index f6977e832..b6a722431 100644 --- a/lib/design-lint/static.ts +++ b/lib/design-lint/static.ts @@ -293,13 +293,14 @@ export function shareProjects(contexts: LintContext[]): void { /** * The waiver comments of a context, in its documents and every stylesheet it reads, weighed against what its rules found. - * A stylesheet's claims answer for that stylesheet on every page that links it. In a directory of components (under - * either scope) each file's claims answer for that file alone, weighed against its own text and its own suspected - * rules, with the house-style count per file. + * A stylesheet's claims answer for that stylesheet on every page that links it. A context that is one html page keeps + * page semantics under either scope: its claims answer for the page and the stylesheets it links. In a directory of + * components (under either scope) each file's claims answer for that file alone, weighed against its own text and its + * own suspected rules, with the house-style count per file. */ export function contextWaivers(ctx: LintContext, cwd: string, findings: Finding[] = []): WaiverSet { const sheets = ctx.cssFiles.flatMap((c) => findWaivers(c.file, c.raw)); - if (ctx.scope !== 'component' && ctx.docs.length <= 1) return resolveWaivers([...ctx.docs.flatMap((d) => findWaivers(d.file, d.raw)), ...sheets], ctx.docs.map((d) => d.text).join(' '), cwd, suspectedRules(findings)); + if (ctx.docs.length <= 1 && ctx.docs.every((d) => d.kind === 'html')) return resolveWaivers([...ctx.docs.flatMap((d) => findWaivers(d.file, d.raw)), ...sheets], ctx.docs.map((d) => d.text).join(' '), cwd, suspectedRules(findings)); const sets = [ ...ctx.docs.map((d) => { const file = oneLine(relPath(d.file, cwd)); return resolveWaivers(findWaivers(d.file, d.raw), d.text, cwd, suspectedRules(findings.filter((f) => f.file === file)), true); }), resolveWaivers(sheets, '', cwd, suspectedRules(findings)), diff --git a/lib/design-lint/tailwind.ts b/lib/design-lint/tailwind.ts index 48d72e483..23f089ff2 100644 --- a/lib/design-lint/tailwind.ts +++ b/lib/design-lint/tailwind.ts @@ -373,9 +373,7 @@ export function twTokensOf(doc: Doc, t: Tag): string[] { if (q === '"' || q === "'") { const e = tag.indexOf(q, i + 1); val = tag.slice(i + 1, e < 0 ? tag.length : e); } else if (q === '{') { val = tag.slice(i + 1, braceEnd(tag, i)); dyn = true; } else val = (/^[^\s>]*/.exec(tag.slice(i)) || [''])[0]; - if (dyn) out.push(...clsLits(val)); - else if (val.includes('{')) out.push(...twInterp(val)); - else out.push(...val.split(/\s+/)); + for (const t of dyn ? clsLits(val) : val.includes('{') ? twInterp(val) : val.split(/\s+/)) out.push(t); } for (const d of tag.matchAll(/(?:^|\s)class:(?!list\b)([\w-]+)/g)) out.push(d[1]); return out.filter(Boolean); @@ -491,18 +489,27 @@ export function twWhere(it: TwItem): string { return it.virtual ? `${it.tag}() class string` : `<${it.tag} class="${trunc(it.info.classes.join(' '), 32)}">`; } -/** The source between a component tag and its matching close. */ -export function compInner(doc: Doc, c: number): string { - const open = doc.comps[c]; - if (open.selfClosing) return ''; - let depth = 1; - for (let i = c + 1; i < doc.comps.length; i++) { +/** Component tags paired once per document: for each open tag the index of its own close tag, -1 when none closes it. */ +function compCloses(doc: Doc): Int32Array { + const cached = doc.cache.compCloses as Int32Array | undefined; + if (cached) return cached; + const closes = new Int32Array(doc.comps.length).fill(-1); + const open = new Map(); + for (let i = 0; i < doc.comps.length; i++) { const t = doc.comps[i]; - if (t.name !== open.name || t.selfClosing) continue; - if (!t.close) depth++; - else if (--depth === 0) return doc.src.slice(open.end, t.pos); + if (t.selfClosing) continue; + if (!t.close) { const s = open.get(t.name); if (s) s.push(i); else open.set(t.name, [i]); continue; } + const s = open.get(t.name); + if (s?.length) closes[s.pop()!] = i; } - return ''; + doc.cache.compCloses = closes; + return closes; +} + +/** The source between a component tag and its matching close; empty when nothing closes it. */ +export function compInner(doc: Doc, c: number): string { + const k = compCloses(doc)[c]; + return k < 0 ? '' : doc.src.slice(doc.comps[c].end, doc.comps[k].pos); } /** Visible text of a fragment, with template expressions blanked in Vue, Svelte and Astro files. */ diff --git a/lib/design-lint/waivers.ts b/lib/design-lint/waivers.ts index 7744fd2cd..4f05c1f32 100644 --- a/lib/design-lint/waivers.ts +++ b/lib/design-lint/waivers.ts @@ -15,8 +15,9 @@ * which a waiver answers like any ask); a reason under 24 characters, * boilerplate ("by design", "intentional"), reused word for word on another * waiver, or sharing no word of four letters or more with the page's own - * text; a second waiver of one rule. More than three waivers on one page is a - * house style and belongs in DESIGN.md: reported at polish. + * text; a second waiver of one rule; a claim on one of the lint's own rules + * (waiver, house-style, render, internal-error). More than three waivers on + * one page is a house style and belongs in DESIGN.md: reported at polish. * * Adapted from Nutlope/hallmark's sloplint.mjs applyWaivers (MIT, see * NOTICE.md); the guard evidence field is dropped because gstack has no theme