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 '<!' or '<?'
with 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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-26 12:01:41 +00:00
1 parent 0fb23304a6
commit a84b030ca0
12 files changed
+113 -82

No files matched your search

+17 -18
View File
@@ -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 `<name`, which must end the name, and the span runs
* from that open tag through `</name>` (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
* `<name`, which must end the name, and the span runs from that open tag
* through `</name>` (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;
* `</name>`, 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 `<a <b>`.
* browser reads `<a <b>`. Every `<` that starts no tag is a stray, except
* a `<!` or `<?` (a doctype, a declaration) with some `>` 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<string, number> = { '"': 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 `<tag\b[^>]*>` regex over it always finds its `>` within the tag and a page whose tail is ten thousand unclosed `<img ` costs its length. */
/** The text with each stray `<` replaced by a space, so every `<` left starts a tag with its own `>`, a `<[^>]*>` regex over it costs its length, and a page whose tail is ten thousand unclosed `<img ` or `<1` costs its length. */
function blankStrays(s: string, strays: number[]): string {
let out = '', from = 0;
for (const i of strays) { out += s.slice(from, i) + ' '; from = i + 1; }
+16 -11
View File
@@ -41,6 +41,8 @@ export interface RenderResult {
const WIDTHS = [320, 375, 414, 768, 1024, 1280, 1920];
const LAPTOP = { width: 1440, height: 900 };
/** The viewport of the contrast walk and of the pixel pass that confirms it. */
const CONTRAST_VIEW = { width: 1280, height: 800 };
const RING_FLOOR = 2.95;
const RENDER_TIMEOUT_MS = 120_000;
@@ -78,10 +80,10 @@ interface Measure { label: string; width: number; height: number; call: string }
/** The first pass: one eval per viewport, the 1280 one carrying the fold, contrast and accent walks. */
function measures(): Measure[] {
const m: Measure[] = WIDTHS.map((w) => ({ 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<T>(text: string | undefined): Parsed<T> {
}
}
/** 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<void>
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<void>
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) {
+13 -11
View File
@@ -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<number>();
let n = 0;
const flag = (line: number, what: string): void => {
if (lines.has(line) || n >= 8) return;
const icons = (doc.cache.emojiIcons ??= new Map<string, Finding>()) as Map<string, Finding>;
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');
+4 -7
View File
@@ -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<number>(), arrowLines = new Set<number>();
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);
+3 -3
View File
@@ -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<string, Finding> | 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' });
}
+18 -1
View File
@@ -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<string, number>();
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' });
+1 -1
View File
@@ -66,7 +66,7 @@ export const RULES: Record<string, RuleMeta> = {
'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 },
+5 -3
View File
@@ -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) && /(?<![\d.])(?:\d{3,}ms|[1-9]\d*(?:\.\d+)?s)\b/.test(x.value));
if (d) return { file: rule.file, line: d.line };
}
return null;
@@ -329,12 +329,14 @@ function controlHeight(ctx: LintContext, r: Reporter): void {
const HIDE_OK = /tooltip|popover|menu|dropdown|modal|dialog|toast|overlay|backdrop|sheet|drawer|panel|:hover|:focus|:checked|:target|\[open|\[aria-(?:expanded|selected|current|pressed)|::before|::after|\.is-|\[hidden|\[aria-hidden|sr-only|visually-hidden|skip|placeholder|::marker|\[data-slot/i;
const REVEALISH = /reveal|fade|animate|aos|in-view|inview|appear|enter|rise|slide|stagger|observe|lazy/i;
// sloplint startingStyleText (gate 62): every @starting-style block with the selector it sits under
// sloplint startingStyleText (gate 62): every @starting-style block with the selector it sits under; a block inside one already taken is part of it, so an unclosed block costs the rest of the sheet once
function startingStyleText(css: string): string {
let out = '';
let out = '', taken = 0;
for (const m of css.matchAll(/@starting-style\s*\{/gi)) {
if (m.index! < taken) continue;
let depth = 1, k = m.index! + m[0].length;
while (k < css.length && depth > 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) + ' ';
+8 -8
View File
@@ -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. */
+5 -4
View File
@@ -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)),
+20 -13
View File
@@ -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<string, number[]>();
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. */
+3 -2
View File
@@ -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