diff --git a/lib/design-lint/rules/assets.ts b/lib/design-lint/rules/assets.ts index 1c134dd8f..8ff78c89d 100644 --- a/lib/design-lint/rules/assets.ts +++ b/lib/design-lint/rules/assets.ts @@ -1,10 +1,230 @@ /** * Assets rules of the design lint (sloplint gates 30a 30b 33 40 41 49a 49b 76; Nutlope/hallmark, * MIT, see NOTICE.md), rewritten in gstack's voice. Rule ids: emoji-decoration, two-icon-libraries, unlabeled-decoration, low-contrast, gray-on-color, button-label-wraps, link-wraps, broken-image, stock-photo-hotlink. + * The static tier can only suspect contrast and wrapping (it has no painted pixels and no + * widths), so low-contrast, gray-on-color and button-label-wraps report at ask for a person to + * confirm; the render tier proves them. hallmark's photo library (a credited Commons photo) is + * not shipped, so the no-credit half of gate 76 is not ported. */ -import type { LintContext } from '../context'; -import type { Reporter } from '../report'; +import { contrastRatio, oklchOf, parseColor, type Color } from '../color'; +import { mergedRules, type LintContext } from '../context'; +import { isTokenRule, resolveVars, pxOf, type MergedRule, type Tokens } from '../css'; +import { ancestorMap, getAttr, hasAttr, innerOf, plainText } from '../html'; +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 { + 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; + lines.add(line); + n++; + r.report('emoji-decoration', doc.file, line, what); + }; + for (let i = 0; i < doc.tags.length; i++) { + const t = doc.tags[i]; + if (t.close || t.selfClosing) continue; + const cls = getAttr(t.attrs, 'class') || ''; + 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(); + if (content && content.length <= 4 && EMOJI.test(content) && (iconish || !/<[a-z]/.test(inner.inner))) { + lines.add(doc.lineOf(t.end + inner.inner.search(EMOJI))); + r.report('emoji-decoration', doc.file, t.line, 'an emoji icon "' + content + '" in <' + t.name + ' class="' + trunc(cls, 28) + '">'); + continue; + } + if (!/^(h[1-6]|button|a|label|th|summary|option|legend|dt)$/.test(t.name) || /<(p|div|ul|ol|table|section)\b/i.test(inner.inner)) continue; + const text = plainText(inner.inner); + const at = inner.inner.search(GLYPH); + if (!text || at < 0) continue; + flag(doc.lineOf(t.end + at), 'an emoji in <' + t.name + '> "' + trunc(text, 32) + '"'); + } + 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]); + } +} + +const ICON_LIBS = new Map([ + ['material', /material-icons|material-symbols/i], + ['heroicons', /heroicon/i], + ['lucide', /lucide/i], + ['phosphor', /phosphor|\bph-(?:bold|fill|light|duotone|[a-z]+-[a-z]+)/i], + ['font-awesome', /font-?awesome|\bfa-(solid|regular|brands|light)\b/i], + ['feather', /feather-icons|\bfeather\b/i], + ['tabler', /tabler-icons/i], + ['bootstrap-icons', /bootstrap-icons|\bbi-[a-z]/i], +]); + +// 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 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; + if (file) r.report('two-icon-libraries', file, 1, 'icon libraries mixed on one page: ' + found.join(' and ')); +} + +// sloplint check33 (gate 33) +function checkUnlabeledDecoration(ctx: LintContext, r: Reporter): void { + const labeled = (a: string): boolean => hasAttr(a, 'aria-hidden') || getAttr(a, 'aria-label') != null + || getAttr(a, 'aria-labelledby') != null + || ['img', 'presentation', 'none'].includes((getAttr(a, 'role') || '').toLowerCase()); + for (const doc of ctx.docs) { + const stack: Array<{ name: string; labeled: boolean }> = []; + for (let i = 0; i < doc.tags.length; i++) { + const t = doc.tags[i]; + if (t.close) { + for (let k = stack.length - 1; k >= 0; k--) if (stack[k].name === t.name) { stack.splice(k); break; } + continue; + } + if (t.name === 'svg' || t.name === 'canvas') { + const titled = t.name === 'svg' && !t.selfClosing && /]*>\s*\S/i.test(innerOf(doc, i)?.inner || ''); + if (!labeled(t.attrs) && !stack.some((s) => s.labeled) && !titled) { + r.report('unlabeled-decoration', doc.file, t.line, '<' + t.name + '> with neither aria-hidden="true" nor a label'); + } + } + if (!t.selfClosing) stack.push({ name: t.name, labeled: labeled(t.attrs) }); + } + } +} + +const localTokens = (rule: MergedRule): Tokens => { + const local: Tokens = {}; + for (const d of rule.decls) if (d.prop.startsWith('--')) local[d.prop] = d.value; + return local; +}; +const bgDecl = (rule: MergedRule) => rule.decls.find((d) => d.prop === 'background-color') || rule.decls.find((d) => d.prop === 'background' && !/gradient|url\(/i.test(d.value)); +const withinAStep = (a: Color, b: Color): boolean => { + const x = oklchOf(a), y = oklchOf(b); + return Math.abs(x.L - y.L) < 0.05 && Math.abs(x.C - y.C) < 0.05; +}; + +// sloplint check40_41 (gates 40 and 41), the per-rule pairs +function checkContrastPairs(ctx: LintContext, r: Reporter): void { + const colorOf = (value: string, local: Tokens): Color | null => parseColor(resolveVars(value, ctx.tokens, local).trim()); + for (const rule of mergedRules(ctx)) { + const local = localTokens(rule); + const colorD = rule.decls.find((d) => d.prop === 'color'); + const bgD = bgDecl(rule); + if (colorD && bgD && !/::selection|::placeholder|::-moz/.test(rule.selector)) { + const fg = colorOf(colorD.value, local); + const bg = colorOf(bgD.value, local); + if (fg && bg && fg.alpha >= 1 && bg.alpha >= 1) { + const sizeD = rule.decls.find((d) => d.prop === 'font-size'); + const weightD = rule.decls.find((d) => d.prop === 'font-weight'); + const sizePx = sizeD ? pxOf(resolveVars(sizeD.value, ctx.tokens, local)) : null; + const weight = weightD ? parseInt(weightD.value, 10) || 400 : 400; + const large = sizePx != null && (sizePx >= 24 || (sizePx >= 18 && weight >= 700)); + const threshold = large ? 3.0 : 4.5; + const ratio = contrastRatio(fg, bg); + if (ratio < threshold - 0.05) { + r.report('low-contrast', rule.file, colorD.line, ratio.toFixed(2) + ':1 under the ' + threshold + ':1 threshold on ' + rule.selector, { suspected: true }); + } + if (/btn|button|cta|pill|chip/i.test(rule.selector) && withinAStep(fg, bg)) { + r.report('gray-on-color', rule.file, colorD.line, 'button text within a step of its fill on ' + rule.selector + ' (lightness differs by ' + Math.abs(oklchOf(fg).L - oklchOf(bg).L).toFixed(3) + ')', { suspected: true }); + } + } + } + const faceProp = Object.keys(local).find((k) => /(face|fill|bg)$/.test(k)); + const inkProp = Object.keys(local).find((k) => /(ink|text)$/.test(k)); + if (!faceProp || !inkProp) continue; + const face = colorOf(local[faceProp], local); + const ink = colorOf(local[inkProp], local); + if (face && ink && face.alpha >= 1 && ink.alpha >= 1 && withinAStep(face, ink)) { + r.report('gray-on-color', rule.file, rule.line, inkProp + ' sits within a step of ' + faceProp + ' on ' + rule.selector, { suspected: true }); + } + } +} + +// sloplint check40_41 (gate 41), the accent ink token and the dark surfaces +function checkSurfaceInk(ctx: LintContext, r: Reporter): void { + const file = ctx.cssFiles[0]?.file || ctx.docs[0]?.file; + const accent = ctx.tokens['--color-accent'], accentInk = ctx.tokens['--color-accent-ink']; + if (file && accent && !accentInk) { + const used = ctx.rules.some((rule) => !isTokenRule(rule) && rule.decls.some((d) => /^background(-color)?$/.test(d.prop) && /var\(\s*--color-accent\s*[),]/.test(d.value))); + if (used) r.report('gray-on-color', file, 1, 'the accent fills a surface but --color-accent-ink is not defined', { suspected: true, fix: 'define --color-accent-ink and set it on text over the accent' }); + } else if (file && accent && accentInk) { + const a = parseColor(resolveVars(accent, ctx.tokens)); + const i = parseColor(resolveVars(accentInk, ctx.tokens)); + if (a && i && contrastRatio(a, i) < 4.45) r.report('gray-on-color', file, 1, '--color-accent-ink against --color-accent is only ' + contrastRatio(a, i).toFixed(2) + ':1', { suspected: true, fix: 'an accent ink at 4.5:1 or better against the accent' }); + } + const sectionish = /section|panel|hero|footer|band|block|col\b|__col|card|cta|dark|invert/i; + for (const rule of mergedRules(ctx)) { + if (!sectionish.test(rule.selector) || /:hover|:focus|:active|::|@font-face/.test(rule.selector)) continue; + const bgD = bgDecl(rule); + if (!bgD) continue; + const bg = parseColor(resolveVars(bgD.value, ctx.tokens, localTokens(rule)).trim()); + if (!bg || bg.alpha < 1) continue; + const L = oklchOf(bg).L; + if (L < 0.5 && !rule.decls.some((d) => d.prop === 'color')) { + r.report('gray-on-color', rule.file, bgD.line, 'a dark surface (lightness ' + (L * 100).toFixed(0) + '%) on ' + rule.selector + ' sets no text colour', { suspected: true, fix: 'swap the text to the paper colour in the same rule' }); + } + } +} + +// sloplint check49 (gates 49a and 49b) +function checkWrappingLabels(ctx: LintContext, r: Reporter): void { + const nowrapRules = ctx.rules.filter((rule) => rule.decls.some((d) => d.prop === 'white-space' && /nowrap/.test(d.value))); + const covered = (cls: string, tag: string): boolean => nowrapRules.some((rule) => + cls.split(/\s+/).some((c) => c && rule.selector.includes('.' + c)) || new RegExp('(^|[\\s,>+~])' + tag + '\\b').test(rule.selector)); + for (const doc of ctx.docs) { + const anc = ancestorMap(doc); + for (let i = 0; i < doc.tags.length; i++) { + const t = doc.tags[i]; + if (t.close || t.selfClosing) continue; + const cls = getAttr(t.attrs, 'class') || ''; + const button = t.name === 'button' || (t.name === 'a' && /btn|cta|button|nav/i.test(cls)) + || (/(^|\s)(btn|button)(\s|$|__|--)/i.test(cls) && !/^(section|header|footer|main|article|aside|nav|form|ul|ol)$/.test(t.name)); + const link = !button && t.name === 'a' && (/tab|crumb/i.test(cls) || (anc.get(i) || []).includes('footer')); + if (!button && !link) continue; + const inner = innerOf(doc, i); + if (!inner) continue; + const label = inner.inner.replace(/<[^>]*>/g, ' ').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'); + } + } +} + +const STOCK = /(?:images|plus)\.unsplash\.com\/(premium_photo|photo)-[\w-]+|images\.pexels\.com\/photos\/\d+|cdn\.pixabay\.com\/photo\//g; + +// sloplint check76 (gate 76): the stock hotlinks and the empty ; the library-credit half needs hallmark's photo index +function checkImages(ctx: LintContext, r: Reporter): void { + for (const doc of ctx.docs) { + let stock = 0, plus = 0; + for (const m of doc.src.matchAll(STOCK)) { + const host = /unsplash/.test(m[0]) ? 'Unsplash' : /pexels/.test(m[0]) ? 'Pexels' : 'Pixabay'; + if (m[1] === 'premium_photo') { if (!plus++) r.report('stock-photo-hotlink', doc.file, doc.lineOf(m.index!), 'an Unsplash+ photo hotlinked, which needs a paid licence'); } + else if (!stock++) r.report('stock-photo-hotlink', doc.file, doc.lineOf(m.index!), 'a photo hotlinked from ' + host + ', whose terms bar images picked by an AI tool', { suspected: true }); + } + if (doc.kind !== 'html') continue; + for (const m of doc.src.matchAll(/]*)>/gi)) { + const src = getAttr(m[1], 'src'); + if ((src == null || !src.trim() || src.trim() === '#') && !getAttr(m[1], 'srcset') && !hasAttr(m[1], 'data-src')) { + r.report('broken-image', doc.file, doc.lineOf(m.index!), 'an with no source', { fix: 'the real file, or remove the element' }); + break; + } + } + } +} export function run(ctx: LintContext, r: Reporter): void { - void ctx; void r; + checkEmojiIcons(ctx, r); + checkIconLibraries(ctx, r); + checkUnlabeledDecoration(ctx, r); + checkContrastPairs(ctx, r); + checkSurfaceInk(ctx, r); + checkWrappingLabels(ctx, r); + checkImages(ctx, r); } diff --git a/lib/design-lint/rules/chrome-copy.ts b/lib/design-lint/rules/chrome-copy.ts new file mode 100644 index 000000000..30e5c1b10 --- /dev/null +++ b/lib/design-lint/rules/chrome-copy.ts @@ -0,0 +1,170 @@ +/** + * Chrome rules, the copy half (sloplint gate 19, the hero half of gate 42 and + * the two-filled-buttons half of F4; Nutlope/hallmark, MIT, see NOTICE.md), + * rewritten in gstack's voice. Rule ids: aphoristic-cadence, + * generic-hero-copy, generic-cta-copy, em-dash-overuse, template-footer-line, + * two-primary-buttons. Also holds the prose mask the chrome and finish + * families share. Placeholder names, brands, lorem and buzzwords are the copy + * tier's (rules/copy.ts) and are not reported here. + */ +import { styleFor, type LintContext } from '../context'; +import { resolveVars } from '../css'; +import { ancestorMap, getAttr, innerOf, plainText, type Doc, type Tag } from '../html'; +import { trunc, type Reporter } from '../report'; + +const pad = (m: string): string => m.replace(/[^\n]/g, ' '); + +// sloplint proseRun +const proseRun = (run: string): string => { + let r = run; + for (let k = 0; k < 3 && /\{[^{}]*\}/.test(r); k++) r = r.replace(/\{[^{}]*\}/g, pad); + return /[;=(){}]|\breturn\b/.test(r) ? pad(r) : r; +}; + +/** The document's prose: tags, comments, scripts, styles and code blanked with positions preserved; component text runs that read as code are blanked too. */ +// sloplint maskedProse +export function maskedProse(doc: Doc): string { + const cached = doc.cache.prose as string | undefined; + if (cached != null) return cached; + const src = doc.kind !== 'html' ? doc.src.replace(/(^|>)([^<>]*)(?=<|$)/g, (_m0, a: string, run: string) => a + proseRun(run)) : doc.src; + const prose = src + .replace(/<(script|style|pre|code|kbd|samp|textarea)\b[^>]*>[\s\S]*?<\/\1>/gi, pad) + .replace(//g, pad) + .replace(/<[^>]*>/g, pad); + doc.cache.prose = prose; + return prose; +} + +// sloplint TELLS19 (gate 19), less the lines the copy tier owns +const HERO_TELLS: Array<[RegExp, string]> = [ + [/\bwhether you['’]?re\b/i, 'a filler opener'], + [/\bbuilt for the modern\b/i, 'a filler opener'], + [/\bin today['’]s\b/i, 'a filler opener'], + [/\bquietly (?:trusted|in use)\b/i, 'the quiet-proof line'], + [/\bwe['’]?ve got you covered\b/i, 'a stock line'], + [/\bsay goodbye to\b/i, 'a stock line'], + [/\bno credit card required\b/i, 'a template line'], + [/\beverything you need to\b/i, 'a stock line'], + [/\ball in one place\b/i, 'a stock line'], + [/\bmade with\s*(?:♥|❤|\u{1f9e1}|\u{1f49b}|\u{1f49a}|\u{1f499}|\u{1f49c}|\u{1f5a4}|\u{1f90d}|♥|♥|❤|<3)/iu, 'the made-with-a-heart line'], +]; +const FOOTER_LINE = /\ball rights reserved\b/i; +const STOCK_LINK = /^(learn more|read more|find out more|click here|more info|see more)$/i; + +// sloplint check19Tells (gate 19) +function checkHeroCopy(ctx: LintContext, r: Reporter): void { + for (const doc of ctx.docs) { + const prose = maskedProse(doc); + const lines = new Set(); + const tell = (rule: string, idx: number, what: string, text: string): void => { + const line = doc.lineOf(idx); + if (lines.has(line)) return; + lines.add(line); + r.report(rule, doc.file, line, `${what}: "${trunc(text, 40)}"`); + }; + for (const [re, what] of HERO_TELLS) { + const m = re.exec(prose); + if (m) tell('generic-hero-copy', m.index, what, m[0]); + } + const footer = FOOTER_LINE.exec(prose); + if (footer) tell('template-footer-line', footer.index, 'a stock footer line', footer[0]); + const stock: Array<{ t: Tag; label: string }> = []; + for (let i = 0; i < doc.tags.length; i++) { + const t = doc.tags[i]; + if (t.close) continue; + if (t.name === 'a') { + const label = plainText(innerOf(doc, i)?.inner || '').replace(/[\u2192\u203a\u00bb]|->/g, '').trim(); + if (STOCK_LINK.test(label)) stock.push({ t, label }); + continue; + } + if (!/^h[1-6]$/.test(t.name)) continue; + const text = plainText(innerOf(doc, i)?.inner || ''); + if (/^Meet\s+[A-Z][\w-]*[.!]?$/.test(text)) tell('generic-hero-copy', t.pos, 'the "Meet " heading', text); + else if (/^[A-Z][a-z]+\.\s+[A-Z][a-z]+\.\s+[A-Z][a-z]+\.$/.test(text)) tell('generic-hero-copy', t.pos, 'the three-word triad heading', text); + } + if (stock.length >= 2) { + r.report('generic-cta-copy', doc.file, stock[0].t.line, `the stock link label "${stock[0].label}" on ${stock.length} links`, { fix: 'say where each link goes: the thing it opens, in its own noun' }); + } + } +} + +const DASH = /\w\s--\s\w|[\u2014\u2013]|—|–|—|–/g; +const CADENCE = /(^|[.!?]\s+)(not|no)\s+[a-z][^.!?\n]{0,40}[.]\s+(just|only|a|an)?\s*[a-z][^.!?\n]{0,40}[.]/gi; + +// sloplint checkCopyTell (gate 19): the cadence and the dashes; the buzzwords are the copy tier's +function checkCadenceAndDashes(ctx: LintContext, r: Reporter): void { + for (const doc of ctx.docs) { + const prose = maskedProse(doc); + const dashes = [...prose.matchAll(DASH)]; + if (dashes.length >= 3) { + const at = dashes[0].index ?? 0; + const around = prose.slice(Math.max(0, at - 16), at + 16).replace(/\s+/g, ' ').trim(); + r.report('em-dash-overuse', doc.file, doc.lineOf(at), `${dashes.length} dashes carry the prose (first: "${trunc(around, 40)}")`); + } + const cadence = prose.match(CADENCE); + if (cadence && cadence.length >= 3) { + const first = cadence[0].trim(); + r.report('aphoristic-cadence', doc.file, doc.lineOf(Math.max(0, prose.indexOf(first))), `the rebuttal cadence ${cadence.length} times ("${trunc(first, 40)}")`, { fix: 'once is voice, three times is a tic: say what the product does, in its own nouns' }); + } + } +} + +const SECOND_CTA = /^(learn more|see how it works|watch (the )?demo|get started|book a demo)$/i; + +/** The
or
that holds the h1, by tag index; -1 when none does. */ +// sloplint heroIndexOf +export function heroIndexOf(doc: Doc, h1Idx: number): number { + for (let i = h1Idx - 1; i >= 0; i--) { + const t = doc.tags[i]; + if (t.close || !/^(section|header)$/.test(t.name)) continue; + const inner = innerOf(doc, i); + if (inner && inner.endPos > doc.tags[h1Idx].pos) return i; + } + return -1; +} + +interface HeroControl { t: Tag; cls: string; label: string } + +/** Whether a control's resting background is a fill (not none, transparent or the paper). */ +function isFilled(ctx: LintContext, c: HeroControl): boolean { + let raw: string | null = null; + for (const d of styleFor(ctx, c.cls, c.t.name, true)) if (d.prop === 'background-color' || d.prop === 'background') raw = d.value; + if (!raw || /--color-paper\b/.test(raw)) return false; + const bg = resolveVars(raw, ctx.tokens); + return !/^(none|transparent|inherit|initial|unset)$/i.test(bg.trim()) && !/transparent/i.test(bg); +} + +// sloplint check42TwoButton (gate 19 and F4) +function checkHeroActions(ctx: LintContext, r: Reporter): void { + for (const doc of ctx.docs) { + const h1 = doc.tags.findIndex((t) => !t.close && t.name === 'h1'); + if (h1 < 0) continue; + const heroIdx = heroIndexOf(doc, h1); + const hero = heroIdx >= 0 ? innerOf(doc, heroIdx) : null; + if (!hero) continue; + const anc = ancestorMap(doc); + const controls: HeroControl[] = []; + for (let i = heroIdx + 1; i < doc.tags.length && doc.tags[i].pos < hero.endPos; i++) { + const t = doc.tags[i]; + if (t.close || t.selfClosing || !(t.name === 'button' || t.name === 'a')) continue; + const cls = getAttr(t.attrs, 'class') || ''; + if (t.name === 'a' && !/btn|cta|button/i.test(cls)) continue; + if ((anc.get(i) || []).includes('nav')) continue; + controls.push({ t, cls, label: plainText(innerOf(doc, i)?.inner || '').trim() }); + } + if (controls.length < 2) continue; + const firstFilled = isFilled(ctx, controls[0]); + if (firstFilled && isFilled(ctx, controls[1])) { + r.report('two-primary-buttons', doc.file, controls[1].t.line, `two filled buttons side by side in the hero: "${trunc(controls[0].label, 20)}" and "${trunc(controls[1].label, 20)}"`); + continue; + } + if (!firstFilled || !SECOND_CTA.test(controls[1].label)) continue; + r.report('generic-cta-copy', doc.file, controls[1].t.line, `the stock second action in the hero: "${trunc(controls[0].label, 20)}" then "${controls[1].label}"`, { fix: 'one action in the hero, or a second that names where it goes' }); + } +} + +export function runChromeCopy(ctx: LintContext, r: Reporter): void { + checkHeroCopy(ctx, r); + checkCadenceAndDashes(ctx, r); + checkHeroActions(ctx, r); +} diff --git a/lib/design-lint/rules/chrome-eyebrow.ts b/lib/design-lint/rules/chrome-eyebrow.ts new file mode 100644 index 000000000..16cf80a1e --- /dev/null +++ b/lib/design-lint/rules/chrome-eyebrow.ts @@ -0,0 +1,139 @@ +/** + * Chrome rules, the eyebrow half (sloplint gate 54; Nutlope/hallmark, MIT, see + * NOTICE.md), rewritten in gstack's voice. Rule id: kicker-above-heading. The + * eyebrow is short inert type before a heading that only announces it; a + * caption, a table head, a form label, a nav label, a byline, a unit after + * its figure, a real filename, a live status chip and a link that goes + * somewhere real are not eyebrows. + */ +import { fontPx, styleFor, trackingEm, valueOf, type LintContext } from '../context'; +import { ancestorMap, directChildren, getAttr, innerOf, plainText, type Child } from '../html'; +import { trunc, type Reporter } from '../report'; + +const EYEBROW_CONTAINER = /^(div|header|footer|section|article|aside|hgroup|li|main|figure|a|body|blockquote)$/; +const EYEBROW_SKIP_TAGS = /^(figcaption|label|legend|caption|th|dt|summary|option|code|kbd|button|select|nav|time)$/; +const EYEBROW_ANCESTOR_SKIP = /^(form|fieldset|table|thead|tbody|nav|menu|dl|select|figcaption|label|button)$/; +const EYEBROW_BLOCKY = /<(p|ul|ol|hr|h[1-6]|img|table|figure|section|article|blockquote|form|dl)\b/i; +const EYEBROW_CLASS = /\b(eyebrow|kicker|overline|superhead|suphead|rubric|strap(line)?|pre-?(title|head(ing)?)|section-?label|num-?label|chapter|ordinal|topic|category|dept)\b|__(num|label|tag|kicker|chip|badge|meta|index|caps|micro)\b|\b(tag|chip|badge|meta|caps|micro|label|index)\b/i; +const EYEBROW_ANNOUNCE = /^(features?|benefits?|introducing|new|how it works?|the (process|tour)|our (process|story|work|team|approach|values?)|why (us|choose|we)|pricing|plans?|faq|questions?|testimonials?|reviews?|trusted by|as seen in|what we do|about( us)?|services?|solutions?|use cases?|get started|contact|team|work|projects?|gallery|blog|news|resources?|examples?|overview)$/i; + +// sloplint capsRatio +export function capsRatio(s: string): number { + const letters = s.replace(/[^A-Za-z]/g, ''); + if (!letters) return 0; + return (letters.match(/[A-Z]/g) || []).length / letters.length; +} + +// sloplint eyebrowHeadingBearing (gate 54) +function eyebrowHeadingBearing(ctx: LintContext, B: Child, aPx: number | null): boolean { + if (/^h[1-6]$/.test(B.name)) return true; + if (/]/i.test(B.html)) return true; + const cls = getAttr(B.open, 'class') || ''; + if (!/__(title|display|head(line)?|h\d)\b|hero__/i.test(cls)) return false; + const bPx = fontPx(ctx, styleFor(ctx, cls, B.name)); + return bPx != null && aPx != null && bPx >= 1.5 * aPx; +} + +/** The text of A when A is an eyebrow before B; null when A carries a fact, a destination or an identifier. */ +// sloplint eyebrowHit (gate 54) +function eyebrowHit(ctx: LintContext, A: Child, B: Child, inOrderedList: boolean): { text: string } | null { + if (EYEBROW_SKIP_TAGS.test(A.name)) return null; + if (/(^|\s)for\s*=/i.test(A.open)) return null; + if (/role\s*=\s*["']status|aria-live|aria-current|aria-hidden\s*=\s*["']true/i.test(A.open)) return null; + const inner = A.html.slice(A.open.length); + if (EYEBROW_BLOCKY.test(inner) || /]*\sdatetime\s*=/i.test(A.html)) return null; + if (/]*\salt\s*=\s*["'][^"']+/i.test(A.html)) return null; + + const ownHref = A.name === 'a' ? getAttr(A.open, 'href') : null; + if (ownHref && !/^#?$/.test(ownHref.trim())) return null; + const innerHref = /]*\shref\s*=\s*["']([^"']*)["']/i.exec(inner); + if (innerHref && !/^#?$/.test(innerHref[1].trim())) return null; + + const text = plainText(A.html); + if (!text) return null; + const words = text.split(/[\s·/|\u2014\u2013]+/).filter(Boolean); + if (words.length > 7 || text.length > 56) return null; + if (/[\w/-]+\.[a-z]{1,4}$/i.test(text)) return null; + + if (/^[\d.,]+\s*(?:[%°+]|[a-z]{1,3})$/i.test(text)) return null; + const stripped = text.replace(/^\s*(?:no\.?\s*)?\d{1,3}(?:\s*[·./\u2014\u2013-]\s*|\s+|$)/i, '').trim(); + if ((stripped.match(/\d/g) || []).length >= 2) return null; + + if (inOrderedList && stripped && !EYEBROW_ANNOUNCE.test(stripped)) return null; + + const cls = getAttr(A.open, 'class') || ''; + const aDecls = styleFor(ctx, cls, A.name); + const pos = valueOf(ctx, aDecls, 'position'); + if (pos && /absolute|fixed/.test(pos)) return null; + const aPx = fontPx(ctx, aDecls); + if (!eyebrowHeadingBearing(ctx, B, aPx)) return null; + + if (EYEBROW_CLASS.test(cls)) return { text }; + let signals = 0; + const tt = valueOf(ctx, aDecls, 'text-transform'); + if ((tt && /uppercase/.test(tt)) || (capsRatio(text) >= 0.6 && /[A-Z]/.test(text))) signals++; + const tr = trackingEm(ctx, aDecls, aPx || 16); + if (tr != null && tr >= 0.04) signals++; + if (aPx != null && aPx <= 15) signals++; + const ff = valueOf(ctx, aDecls, 'font-family'); + if (ff && /mono|--font-label/i.test(ff)) signals++; + const fv = valueOf(ctx, aDecls, 'font-variant-caps'); + if (fv && /small-caps/.test(fv)) signals++; + if (signals >= 2) return { text }; + if (EYEBROW_ANNOUNCE.test(stripped)) return { text }; + return null; +} + +// sloplint check54 (gate 54) +function checkEyebrow(ctx: LintContext, r: Reporter): void { + for (const doc of ctx.docs) { + const anc = ancestorMap(doc); + for (let i = 0; i < doc.tags.length; i++) { + const t = doc.tags[i]; + if (t.close || t.selfClosing || !EYEBROW_CONTAINER.test(t.name)) continue; + const ancestors = anc.get(i) || []; + if (ancestors.some((n) => EYEBROW_ANCESTOR_SKIP.test(n))) continue; + const inOl = ancestors.includes('ol'); + const kids = directChildren(doc, i); + for (let k = 0; k < kids.length - 1; k++) { + const A = kids[k]; + let B: Child | null = null; + for (let j = k + 1; j < Math.min(kids.length, k + 3); j++) { + const c = kids[j]; + if (c.name === 'hr' || /aria-hidden\s*=\s*["']true/i.test(c.open)) continue; + B = c; + break; + } + if (!B) continue; + const hit = eyebrowHit(ctx, A, B, inOl); + if (!hit) continue; + r.report('kicker-above-heading', doc.file, doc.tags[A.idx]?.line || t.line, 'the eyebrow "' + trunc(hit.text, 32) + '" before <' + B.name + '>', { fix: 'cut it; the heading already says this. Where the heading is figurative, the label becomes the heading' }); + break; + } + } + } +} + +// sloplint check54Unheaded (gate 54) +function checkUnheadedSection(ctx: LintContext, r: Reporter): void { + 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 || !/^(section|article)$/.test(t.name)) continue; + const labelledBy = getAttr(t.attrs, 'aria-labelledby'); + if (!labelledBy) continue; + const body = innerOf(doc, i); + if (!body || /]/i.test(body.inner)) continue; + const idRe = new RegExp('<([a-z][\\w-]*)\\b[^>]*\\sid\\s*=\\s*["\']' + labelledBy.split(/\s+/)[0] + '["\'][^>]*>', 'i'); + const m = idRe.exec(body.inner); + if (!m || /^h[1-6]$/i.test(m[1])) continue; + r.report('kicker-above-heading', doc.file, doc.lineOf(body.endPos - body.inner.length + m.index), 'a section labelled by <' + m[1].toLowerCase() + '> with no heading element', { fix: 'promote the label to a heading; a label doing heading duty is a heading' }); + } + } +} + +export function runEyebrow(ctx: LintContext, r: Reporter): void { + checkEyebrow(ctx, r); + checkUnheadedSection(ctx, r); +} diff --git a/lib/design-lint/rules/chrome.ts b/lib/design-lint/rules/chrome.ts index 53193118a..e2fb2d55e 100644 --- a/lib/design-lint/rules/chrome.ts +++ b/lib/design-lint/rules/chrome.ts @@ -1,10 +1,341 @@ /** * Chrome rules of the design lint (sloplint gates 19 42 46a 47a 47b 47c 54 63 68 69 70 85; Nutlope/hallmark, * MIT, see NOTICE.md), rewritten in gstack's voice. Rule ids: aphoristic-cadence, generic-hero-copy, generic-cta-copy, em-dash-overuse, template-footer-line, template-nav, template-footer, invented-proof, drawn-browser-chrome, device-frame, imitation-material, kicker-above-heading, template-punctuation, wireframe-slots, repeated-fact, fake-send, email-band-close. + * Gate 19 lives in rules/chrome-copy.ts (with the two-filled-buttons half of F4), gate 54 in rules/chrome-eyebrow.ts. + * hallmark's theme stamp does not exist here: the nav and footer shapes always report, and the + * visitor's job comes from --job alone. */ import type { LintContext } from '../context'; -import type { Reporter } from '../report'; +import { lineIndex } from '../css'; +import { getAttr, innerOf, plainText, type Doc, type Tag } from '../html'; +import { trunc, type Reporter } from '../report'; +import { maskedProse, runChromeCopy } from './chrome-copy'; +import { runEyebrow } from './chrome-eyebrow'; + +// sloplint check42 (gate 42) +function checkNavShape(ctx: LintContext, r: Reporter): void { + for (const doc of ctx.docs) { + const navIdx = doc.tags.findIndex((t) => !t.close && (t.name === 'nav' || (t.name === 'header' && /banner|nav/i.test(t.attrs)))); + if (navIdx === -1) continue; + const inner = innerOf(doc, navIdx); + if (!inner) continue; + const links = (inner.inner.match(/ ((navCls && rule.selector.includes(navCls)) || /(^|[\s,])(nav|header)\b/.test(rule.selector)) + && rule.decls.some((d) => /^border(-bottom)?$/.test(d.prop) && /\b1px\b/.test(d.value))); + const hasCta = /btn|cta|button/i.test(inner.inner); + if (links >= 4 && links <= 6 && hairline && hasCta) { + r.report('template-nav', doc.file, navTag.line, 'the reflex nav shape: ' + links + ' inline links, a button and a 1px hairline under'); + } + } +} + +// sloplint check42Footer (gate 42) +function checkFooterShape(ctx: LintContext, r: Reporter): void { + for (const doc of ctx.docs) { + const idx = doc.tags.findIndex((t) => !t.close && t.name === 'footer'); + if (idx === -1) continue; + const inner = innerOf(doc, idx); + if (!inner) continue; + const uls = (inner.inner.match(/ /footer/i.test(rule.selector) && rule.decls.some((d) => + d.prop === 'grid-template-columns' + && (/repeat\(\s*4\s*,/.test(d.value) || /^(1fr|minmax\([^)]*\))(\s+(1fr|minmax\([^)]*\))){3,}$/.test(d.value.trim())))); + let linkGroups = 0; + const colRe = /<(ul|div|p|nav)\b[^>]*class\s*=\s*"[^"]*(col|column|links|group)[^"]*"[^>]*>([\s\S]*?)<\/\1>/gi; + let cm: RegExpExecArray | null; + while ((cm = colRe.exec(inner.inner))) { + if ((cm[3].match(/= 3) linkGroups++; + } + for (const um of inner.inner.matchAll(/]*>([\s\S]*?)<\/ul>/gi)) { + if ((um[1].match(/= 3) linkGroups++; + } + if (uls >= 4 || (fourEqualCol && linkGroups >= 3)) { + r.report('template-footer', doc.file, doc.tags[idx].line, 'the reflex footer shape: ' + (uls >= 4 ? uls + ' link columns' : 'four equal columns of links')); + } + } +} + +const PROOF_SHAPES = [ + /\b\d+(\.\d+)?\s*[x×]\s*faster\b/i, + /trusted by [\d,.]+\s*\+?/i, + /\b99\.9+\s*%/, + /\b[\d,]{4,}\s*\+\s*(teams|users|customers|developers|companies|creators)\b/i, + /\b\d+(\.\d+)?\s*%\s*(faster|uptime|conversion|accuracy|retention|growth|fewer|less|more)\b/i, + /\+\s*\d+(\.\d+)?\s*%\s*(conversion|growth|revenue|retention)\b/i, + /\bsaves?\s+\d+\s+hours?\b/i, +]; + +// sloplint check46 (gate 46a) +function checkInventedProof(ctx: LintContext, r: Reporter): void { + for (const doc of ctx.docs) { + const prose = maskedProse(doc); + const lines = new Set(); + for (const re of PROOF_SHAPES) { + const m = re.exec(prose); + if (!m) continue; + const line = doc.lineOf(m.index); + if (lines.has(line)) continue; + lines.add(line); + r.report('invented-proof', doc.file, line, 'the metric "' + m[0].trim() + '" has no source on the page', { suspected: true, fix: 'the number the user supplied, or drop the claim' }); + } + } +} + +const CHROME_47: Array<[string, string, RegExp]> = [ + ['drawn-browser-chrome', 'a drawn browser bar', /(browser|window|mac|osx)[-_]?(bar|chrome|dots|controls)|traffic[-_]?light|title[-_]?bar|window[-_]?(dots|buttons)|url[-_]?(bar|pill)/i], + ['device-frame', 'a device frame', /phone[-_]?(frame|mock(up)?)|device[-_]?frame|\bnotch\b|terminal[-_]?(chrome|frame|bar|dots|header)|(ide|editor)[-_]?(chrome|frame)|(browser|window)[-_]?frame/i], +]; + +// sloplint check47 (gates 47a and 47b) +function checkDrawnChrome(ctx: LintContext, r: Reporter): void { + for (const [rule, what, re] of CHROME_47) { + for (const doc of ctx.docs) { + for (const t of doc.tags) { + if (t.close) continue; + const cls = getAttr(t.attrs, 'class'); + const m = cls ? re.exec(cls) : null; + if (m) { r.report(rule, doc.file, t.line, what + ' drawn in CSS: class "' + m[0] + '" (class="' + trunc(cls!, 32) + '")'); break; } + } + } + for (const cssRule of ctx.rules) { + const m = re.exec(cssRule.selector); + if (m) { r.report(rule, cssRule.file, cssRule.line, what + ' drawn in CSS: selector ' + cssRule.selector); break; } + } + } +} + +const PLACEHOLDER_IMG = /placehold\.co|via\.placeholder\.com|source\.unsplash\.com\/random|dummyimage\.com/i; + +// sloplint check47Placeholders (gate 47c) +function checkPlaceholderImages(ctx: LintContext, r: Reporter): void { + const fix = 'a real asset, or a labelled slot in the page\'s own colours'; + for (const doc of ctx.docs) { + const m = PLACEHOLDER_IMG.exec(doc.src); + if (m) r.report('imitation-material', doc.file, doc.lineOf(m.index), 'a placeholder image service (' + m[0] + ') stands in for a picture', { fix }); + } + for (const c of ctx.cssFiles) { + const m = PLACEHOLDER_IMG.exec(c.raw); + if (m) r.report('imitation-material', c.file, lineIndex(c.raw)(m.index), 'a placeholder image service (' + m[0] + ') stands in for a picture', { fix }); + } +} + +const TEXT_TAGS = new Set(['p', 'span', 'li', 'small', 'a', 'div', 'figcaption', 'td', 'th', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'time', 'dd', 'dt', 'cite', 'strong', 'em', 'b', 'label', 'button', 'summary']); +const TICKER = /marquee|ticker|tape|crawl/i; + +// sloplint check63 (gate 63) +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]> = []; + 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]); + } + 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; + const inner = innerOf(doc, i); + if (!inner || /<(p|div|li|section|article|ul|ol|table)\b/i.test(inner.inner)) continue; + const text = plainText(inner.inner); + if (!text) continue; + const dots = (text.match(/\s[·•]\s/g) || []).length; + if (dots >= 2 && !dotLines.has(t.line)) { + dotLines.add(t.line); + r.report('template-punctuation', doc.file, t.line, 'middle dots join a meta string: "' + trunc(text, 50) + '"', { fix: 'line breaks, columns or hairlines; one dot in a byline at most' }); + } + if ((t.name === 'a' || t.name === 'button') && /(\u2192|->|\u203a|\u00bb|\u27f6)\s*$/.test(text) && !arrowLines.has(t.line)) { + arrowLines.add(t.line); + r.report('template-punctuation', doc.file, t.line, 'an arrow glued to the label "' + trunc(text, 40) + '"', { fix: 'a verb as the label; the link already says it goes somewhere' }); + } + if (/^[A-Z]{3,}\s+-\s+\S/.test(text) && text.length < 60) { + r.report('template-punctuation', doc.file, t.line, 'a WORD - fragment label "' + trunc(text, 40) + '"', { fix: 'say it as a sentence or a heading' }); + } + } + let dotCount = 0; + for (let i = 0; i < doc.tags.length; i++) { + const t = doc.tags[i]; + if (t.close || !/^(span|i|b|em|s)$/.test(t.name)) continue; + const cls = getAttr(t.attrs, 'class') || ''; + if (!/(^|\s)(dot|status-dot|indicator|bullet|pip|blip)(\s|$|__|-)/i.test(cls)) continue; + if (/aria-live|role\s*=\s*"status"/i.test(t.attrs)) continue; + const inner = innerOf(doc, i); + if (inner && plainText(inner.inner)) continue; + dotCount++; + if (dotCount === 3) r.report('template-punctuation', doc.file, t.line, 'decorative status dots (' + cls.trim() + ') repeated across the page', { fix: 'delete them; a dot means live state, and only where it is one' }); + } + } +} + +/** The tag index of the nearest open ancestor of the tag at from, or -1. */ +function parentIndex(doc: Doc, from: number): number { + let depth = 0; + for (let k = from - 1; k >= 0; k--) { + const t = doc.tags[k]; + if (t.selfClosing) continue; + if (t.close) depth++; + else if (depth === 0) return k; + else depth--; + } + return -1; +} + +// sloplint check68 (gate 68); the no-slot-script half (F18) is render-only and not ported +function checkWireframeSlots(ctx: LintContext, r: Reporter): void { + for (const doc of ctx.docs) { + if (doc.kind !== 'html') continue; + const slots: Array<{ i: number; t: Tag }> = []; + for (let i = 0; i < doc.tags.length; i++) { + const t = doc.tags[i]; + if (t.close || !/\bdata-slot\s*=/.test(t.attrs)) continue; + const inner = t.selfClosing ? null : innerOf(doc, i); + if (inner && /]*\bsrc\s*=\s*["'][^"']+["']/i.test(inner.inner)) continue; + if (inner && (inner.inner.match(/<[a-z][\w-]*\b[^>]*>\s*[^<\s]/gi) || []).length >= 6) continue; + let hiddenUp = false; + for (let k = parentIndex(doc, i); k >= 0 && !hiddenUp; k = parentIndex(doc, k)) if (/(?:^|\s)hidden(?:\s|=|$)/.test(doc.tags[k].attrs)) hiddenUp = true; + if (hiddenUp) continue; + slots.push({ i, t }); + } + if (slots.length > 5) r.report('wireframe-slots', doc.file, slots[5].t.line, slots.length + ' empty slots on one page', { fix: 'two to four slots carry a page; set the rest in type, with names and facts in a list or table' }); + const byRun = new Map(); + for (const sl of slots) { + const parent = parentIndex(doc, sl.i); + const gp = parent >= 0 ? parentIndex(doc, parent) : -1; + const ggp = gp >= 0 ? parentIndex(doc, gp) : -1; + for (const key of new Set([parent, gp, ggp])) { if (key < 0) continue; byRun.set(key, (byRun.get(key) || 0) + 1); } + } + for (const [key, n] of byRun) { + if (n < 4) continue; + r.report('wireframe-slots', doc.file, doc.tags[key].line, n + ' empty slots in one row or grid', { fix: 'a set the page has no pictures of is set in type, with one slot at most' }); + break; + } + } +} + +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) +function checkRepeatedFact(ctx: LintContext, r: Reporter): void { + for (const doc of ctx.docs) { + if (doc.kind !== 'html') continue; + const bodyAt = doc.tags.findIndex((t) => !t.close && t.name === 'body'); + const stack: Array<{ name: string; id: number | string }> = []; + const seen = new Map>(); + let uid = 0; + const regionOf = (): number | string => { + const region = stack.find((e) => REGION.test(e.name)); + if (region) return region.id; + const band = stack.find((e, i) => i > 0 && (stack[i - 1].name === 'body' || stack[i - 1].name === 'main') && e.name !== 'main'); + return band ? band.id : 'root'; + }; + for (let i = Math.max(bodyAt, 0); i < doc.tags.length; i++) { + const t = doc.tags[i]; + if (t.close) { const at = stack.map((e) => e.name).lastIndexOf(t.name); if (at >= 0) stack.length = at; } + else if (!t.selfClosing) stack.push({ name: t.name, id: uid++ }); + const next = doc.tags[i + 1]; + if (/^(script|style|template|noscript)$/.test(t.name) && !t.close) continue; + const chunk = doc.masked.slice(t.end, next ? next.pos : doc.masked.length) + .replace(/£/g, '£').replace(/€/g, '€').replace(/ | /g, ' '); + if (!chunk.trim()) continue; + const region = regionOf(); + for (const f of chunk.match(FACT) || []) { + const k = f.replace(/\s+/g, '').toLowerCase(); + if (!seen.has(k)) seen.set(k, new Set()); + seen.get(k)!.add(region); + } + } + const loud = [...seen].map(([k, s]) => [k, s.size] as const).filter(([, n]) => n >= 4).sort((a, b) => b[1] - a[1]); + if (loud.length) r.report('repeated-fact', doc.file, 1, 'the same fact printed in ' + loud[0][1] + ' sections: ' + loud.slice(0, 3).map(([k, n]) => k + ' (' + n + ')').join(', ')); + } +} + +const SUCCESS = /\b(sent|thanks|thank you|reserved|booked|on the list|subscribed|in touch|received|confirmed|ordered|chosen|on its way|signed up|all set|you're in|you are in|done)\b/i; +const NEGATED = /\b(not|nothing|no|never|isn't|wasn't|didn't|cannot|can't|failed|try again|soon)\b/i; +const REQUEST = /\bfetch\s*\(|XMLHttpRequest|navigator\.sendBeacon|\.submit\s*\(\s*\)|axios|\$\.ajax/; +const JS_STRING = /(["'`])((?:(?!\1)[^\\\n]|\\.)*)\1/g; + +// sloplint check70 (gate 70); string literals are paired in sequence so a short literal earlier in the script cannot desync the scan +function checkFakeSend(ctx: LintContext, r: Reporter): void { + for (const doc of ctx.docs) { + const js = doc.scripts || ''; + if (!/ 140) continue; + const words = x[2].trim().split(/\s+/); + if (words.length >= 2 && SUCCESS.test(words.slice(0, 4).join(' ')) && !NEGATED.test(x[2])) { hit = x[0]; break; } + } + if (!hit || REQUEST.test(js)) continue; + const at = doc.src.indexOf(hit); + r.report('fake-send', doc.file, doc.lineOf(at >= 0 ? at : 0), 'a script prints success ("' + trunc(hit.replace(/["'`]/g, ''), 30) + '") with no request behind it', { suspected: true }); + } +} + +const NATIVE_LIST_JOB = /subscribe|join|attend|listen|visit|newsletter|waitlist/i; +const ASKS = /\b(book|enquir|inquir|request|contact|walkthrough|demo|quote|reserve|apply|start|create|order|buy|download|try)/i; +const LIST_WORDS = /newsletter|updates|subscribe|the list|mailing/i; + +/** Whether the form opened at idx is an email sign-up: one or two fields, one of them email, under a button that does not book, order or ask. */ +function isEmailSignup(doc: Doc, idx: number): boolean { + const inner = innerOf(doc, idx); + if (!inner) return false; + const html = inner.inner; + const buttons = [...html.matchAll(/]*>([\s\S]*?)<\/button>|]*\btype\s*=\s*["']submit["'][^>]*)>/gi)] + .map((m) => (m[1] != null ? plainText(m[1]) : getAttr(m[2], 'value') || '')).join(' '); + if (ASKS.test(buttons) && !LIST_WORDS.test(buttons)) return false; + const fields: string[] = []; + for (const m of html.matchAll(/<(input|textarea|select)\b([^>]*)>/gi)) { + const type = (getAttr(m[2], 'type') || 'text').toLowerCase(); + if (m[1].toLowerCase() === 'input' && /^(hidden|submit|checkbox|radio|button|reset|image)$/.test(type)) continue; + fields.push(type + ' ' + [getAttr(m[2], 'name'), getAttr(m[2], 'id'), getAttr(m[2], 'placeholder'), getAttr(m[2], 'autocomplete')].filter(Boolean).join(' ')); + } + return fields.length >= 1 && fields.length <= 2 && fields.some((f) => /^email\b/.test(f) || /e-?mail/i.test(f)); +} + +interface Open { t: Tag; i: number } + +// gate 85, written for the static tier: the render tier's email-band close, read from the markup +function checkEmailBandClose(ctx: LintContext, r: Reporter): void { + if (ctx.job && NATIVE_LIST_JOB.test(ctx.job)) return; + for (const doc of ctx.docs) { + if (doc.kind !== 'html') continue; + const opens: Open[] = doc.tags.map((t, i) => ({ t, i })).filter(({ t }) => !t.close && !t.selfClosing); + const endOf = (o: Open): number => innerOf(doc, o.i)?.endPos ?? doc.src.length; + const footer = opens.filter(({ t }) => t.name === 'footer').pop() ?? null; + const limit = footer ? footer.t.pos : doc.src.length; + const before = opens.filter((o) => /^(section|form|article)$/.test(o.t.name) && o.t.pos < limit && endOf(o) <= limit).pop() ?? null; + for (const box of [before, footer]) { + if (!box) continue; + const end = endOf(box); + const form = opens.filter((o) => o.t.name === 'form' && o.i >= box.i && o.t.pos < end).find((o) => isEmailSignup(doc, o.i)); + if (!form) continue; + const cls = getAttr(form.t.attrs, 'class'); + const name = cls ? '
' : ''; + const where = box === footer ? 'in the footer' : 'as its last section'; + const job = ctx.job ? ' on a ' + ctx.job + ' page' : ', and no job was named (--job)'; + r.report('email-band-close', doc.file, form.t.line, 'the page closes on an email sign-up ' + name + ' ' + where + job); + return; + } + } +} export function run(ctx: LintContext, r: Reporter): void { - void ctx; void r; + runChromeCopy(ctx, r); + runEyebrow(ctx, r); + checkNavShape(ctx, r); + checkFooterShape(ctx, r); + checkInventedProof(ctx, r); + checkDrawnChrome(ctx, r); + checkPlaceholderImages(ctx, r); + checkTemplatePunctuation(ctx, r); + checkWireframeSlots(ctx, r); + checkRepeatedFact(ctx, r); + checkFakeSend(ctx, r); + checkEmailBandClose(ctx, r); } diff --git a/lib/design-lint/rules/finish.ts b/lib/design-lint/rules/finish.ts index 24c4e86fc..3715f824f 100644 --- a/lib/design-lint/rules/finish.ts +++ b/lib/design-lint/rules/finish.ts @@ -1,10 +1,344 @@ /** * Finish rules of the design lint (sloplint gates F1-F9 F15 F16 F21-F24; Nutlope/hallmark, * MIT, see NOTICE.md), rewritten in gstack's voice. Rule ids: straight-quotes, three-dot-ellipsis, balanced-display-head, two-primary-buttons, icon-coin, display-figures-tabular, stamped-recipe, scroll-choreography-mix, fake-avatar, stock-person-photo, page-invariants, unthemed-browser-surfaces, skipped-heading, justified-text, missing-lang, hairline-under-shadow. + * Everything here is polish except fake-avatar, which blocks. F17 to F20 are render-only and + * live in the render tier; the title and description halves of F23 are the copy tier's. */ -import type { LintContext } from '../context'; -import type { Reporter } from '../report'; +import { mergedRules, type LintContext } from '../context'; +import { HEADING_SEL, isTokenRule, lineIndex, pxOf, resolveVars, type MergedRule } from '../css'; +import { getAttr } from '../html'; +import { trunc, type Reporter } from '../report'; +import { twVariants } from '../tailwind'; +import { maskedProse } from './chrome-copy'; + +/** The largest px, rem or em length in a value, at a 16px root; null when it has none. */ +// sloplint maxPxIn +function maxPxIn(value: string): number | null { + let best: number | null = null; + for (const m of value.matchAll(/(-?\d+(?:\.\d+)?)(px|rem|em)/g)) { + const px = m[2] === 'px' ? parseFloat(m[1]) : parseFloat(m[1]) * 16; + if (best == null || px > best) best = px; + } + return best; +} + +// sloplint baseSelector +const baseSelector = (sel: string): string => sel.replace(/::?[a-z-]+(\([^)]*\))?/gi, '').replace(/\s+/g, ' ').trim(); + +const firstFile = (ctx: LintContext): string | null => ctx.cssFiles[0]?.file || ctx.docs[0]?.file || null; + +/** isTokenRule over a merged rule (merging already dropped keyframes). */ +const tokenRule = (rule: MergedRule): boolean => isTokenRule({ ...rule, keyframes: null }); + +// sloplint checkF1 (F1) +function checkStraightQuotes(ctx: LintContext, r: Reporter): void { + const dq = /(?:"|"|�?34;)(?=[A-Za-z])|[A-Za-z](?:"|"|�?34;)/g; + const ap = /[A-Za-z](?:'|�?39;|')(?=[A-Za-z])/g; + for (const doc of ctx.docs) { + const prose = maskedProse(doc); + const hits = [...prose.matchAll(dq), ...prose.matchAll(ap)].sort((a, b) => a.index! - b.index!); + if (!hits.length) continue; + const at = hits[0].index!; + const around = prose.slice(Math.max(0, at - 16), at + 16).replace(/\s+/g, ' ').trim(); + r.report('straight-quotes', doc.file, doc.lineOf(at), hits.length + ' straight quote' + (hits.length === 1 ? '' : 's') + ' in prose (first: ' + trunc(around, 40) + ')', { fix: 'typographic quotes: \u201C \u201D \u2019' }); + } +} + +// sloplint checkF2 (F2) +function checkEllipsis(ctx: LintContext, r: Reporter): void { + for (const doc of ctx.docs) { + const prose = maskedProse(doc); + const hits = [...prose.matchAll(/\.{3,}/g)]; + if (hits.length) r.report('three-dot-ellipsis', doc.file, doc.lineOf(hits[0].index!), hits.length + ' three-dot ellips' + (hits.length === 1 ? 'is' : 'es') + ' in prose', { fix: 'the ellipsis character (\u2026)' }); + } +} + +// sloplint checkF3 (F3) +function checkBalancedHeads(ctx: LintContext, r: Reporter): void { + const H12 = /(^|[\s,>+~(])h[12]\b/i; + const targets: MergedRule[] = []; + let satisfied = false; + for (const rule of mergedRules(ctx)) { + const isH12 = H12.test(rule.selector); + const isDisplayClass = HEADING_SEL.test(rule.selector) && rule.decls.some((d) => d.prop === 'font-size' && (maxPxIn(resolveVars(d.value, ctx.tokens)) ?? 0) >= 40); + if (!isH12 && !isDisplayClass) continue; + targets.push(rule); + if (rule.decls.some((d) => /^text-wrap(-style)?$/.test(d.prop) && /balance/.test(d.value))) satisfied = true; + } + if (targets.length && !satisfied) { + r.report('balanced-display-head', targets[0].file, targets[0].line, 'no text-wrap: balance on any display head (' + targets.length + ' head rules, the first ' + trunc(targets[0].selector, 32) + ')', { fix: 'text-wrap: balance on the h1 and h2 level heads' }); + } +} + +// sloplint checkF4 (F4): the recipe count; the side-by-side half is rules/chrome-copy.ts +function checkPrimaryRecipes(ctx: LintContext, r: Reporter): void { + const BTN = /\b(btn|button|cta)\b|__(btn|button|cta)\b/i; + const filled = new Map(); + for (const rule of mergedRules(ctx)) { + if (tokenRule(rule) || /:hover|:active|:focus|:disabled|\.is-|\[disabled/i.test(rule.selector)) continue; + const first = baseSelector(rule.selector.split(',')[0]); + if (!BTN.test(first)) continue; + for (const d of rule.decls) { + if (!/^background(-color)?$/.test(d.prop)) continue; + if (/transparent|none/i.test(resolveVars(d.value, ctx.tokens))) continue; + if (/var\(\s*--color-(?!paper|rule|muted|focus)[\w-]+/.test(d.value) && !filled.has(first)) filled.set(first, { rule, line: d.line }); + } + } + if (filled.size < 2) return; + const sels = [...filled.keys()]; + const second = filled.get(sels[1])!; + r.report('two-primary-buttons', second.rule.file, second.line, filled.size + ' filled primary-button recipes on one page: ' + trunc(sels.join(' and '), 52)); +} + +// sloplint checkF5 (F5) +function checkIconCoins(ctx: LintContext, r: Reporter): void { + for (const rule of mergedRules(ctx)) { + if (tokenRule(rule) || !/icon|\bico\b/i.test(rule.selector)) continue; + const radius = rule.decls.find((d) => d.prop === 'border-radius'); + if (!radius) continue; + const rv = resolveVars(radius.value, ctx.tokens); + const pct = /(\d+(?:\.\d+)?)%/.exec(rv); + if (!((pct && parseFloat(pct[1]) >= 50) || (maxPxIn(rv) ?? 0) >= 999)) continue; + const bg = rule.decls.find((d) => /^background(-color)?$/.test(d.prop) && !/^(none|transparent)$/i.test(resolveVars(d.value, ctx.tokens).trim())); + if (!bg) continue; + const small = rule.decls.some((d) => (d.prop === 'width' || d.prop === 'height') && (pxOf(resolveVars(d.value, ctx.tokens)) ?? 999) <= 64); + if (small) r.report('icon-coin', rule.file, bg.line, 'an icon in a coloured coin: ' + trunc(rule.selector, 40), { fix: 'drop the tinted coin; the icon at text size in currentColor' }); + } +} + +// sloplint checkF6 (F6) +function checkTabularFigures(ctx: LintContext, r: Reporter): void { + const NUM_SEL = /\b(stat(?!ement)|price|table|figure|count|num)[\w-]*/i; + const hasTnum = (rule: { decls: MergedRule['decls'] }): boolean => rule.decls.some((d) => d.prop === 'font-variant-numeric' || (d.prop === 'font-feature-settings' && /tnum/.test(d.value))); + if (ctx.rules.some((rule) => /^(html|body|:root)\b/i.test(rule.selector.trim()) && hasTnum(rule))) return; + const bySel = mergedRules(ctx); + const covered = new Set(bySel.filter(hasTnum).map((rule) => baseSelector(rule.selector))); + const warned = new Set(); + for (const rule of bySel) { + if (tokenRule(rule) || !NUM_SEL.test(rule.selector)) continue; + const base = baseSelector(rule.selector); + if (warned.has(base) || covered.has(base)) continue; + const size = rule.decls.find((d) => d.prop === 'font-size'); + if (!size || (maxPxIn(resolveVars(size.value, ctx.tokens)) ?? 0) < 24) continue; + warned.add(base); + r.report('display-figures-tabular', rule.file, size.line, 'figures on ' + trunc(base, 36) + ' at display size without tabular numerals'); + } +} + +// sloplint checkF7 (F7) +function checkStampedRecipe(ctx: LintContext, r: Reporter): void { + const byKey = new Map; rule: MergedRule }>(); + for (const rule of mergedRules(ctx)) { + if (tokenRule(rule) || /::(before|after)/.test(rule.selector)) continue; + const get = (re: RegExp): string => { + const d = rule.decls.filter((x) => re.test(x.prop)).pop(); + return d ? resolveVars(d.value, ctx.tokens).replace(/\s+/g, ' ').trim() : ''; + }; + const border = get(/^border$/) || [get(/^border-width$/), get(/^border-style$/), get(/^border-color$/)].filter(Boolean).join(' '); + const radius = get(/^border-radius$/); + const surface = get(/^background(-color)?$/); + const shadow = get(/^box-shadow$/); + if (!radius || (!border && !surface && !shadow)) continue; + const key = [border, radius, surface, shadow].join(' | '); + if (!byKey.has(key)) byKey.set(key, { sels: new Set(), rule }); + for (const part of rule.selector.split(',')) byKey.get(key)!.sels.add(baseSelector(part)); + } + for (const v of byKey.values()) { + if (v.sels.size < 6) continue; + r.report('stamped-recipe', v.rule.file, v.rule.line, 'one container recipe on ' + v.sels.size + ' selectors (' + trunc([...v.sels].slice(0, 4).join(', '), 48) + ' and more)', { fix: 'vary weight, radius or fill by role; not every box is the same box' }); + } +} + +// sloplint checkF7Pills (F7) +function checkPillRadius(ctx: LintContext, r: Reporter): void { + const CONTROLISH = /btn|button|cta|input|select|textarea|switch|toggle|control|chip|pill|tag|badge|status/i; + const sels = new Set(); + let where: { rule: MergedRule; line: number } | null = null; + for (const rule of mergedRules(ctx)) { + if (tokenRule(rule)) continue; + const d = rule.decls.filter((x) => x.prop === 'border-radius').pop(); + if (!d || !/^(9999px|999px|50rem)$/.test(resolveVars(d.value, ctx.tokens).trim())) continue; + for (const part of rule.selector.split(',')) { + const b = baseSelector(part); + if (!b || CONTROLISH.test(b)) continue; + sels.add(b); + where = where || { rule, line: d.line }; + } + } + if (sels.size >= 5 && where) r.report('stamped-recipe', where.rule.file, where.line, 'the pill radius on ' + sels.size + ' non-control selectors (' + trunc([...sels].slice(0, 4).join(', '), 48) + ')', { fix: 'pills on controls, tags and badges; boxes and media take the card radius' }); +} + +// sloplint checkF8 (F8) +function checkScrollChoreography(ctx: LintContext, r: Reporter): void { + const famOf = (sel: string): string => { + const m = /\.([a-z][\w-]*)/i.exec(sel) || /(^|[\s>+~])([a-z][\w-]*)/i.exec(sel); + const name = m ? (m[1] || m[2]) : sel; + return name.split('__')[0].split('--')[0].toLowerCase(); + }; + const NAVISH = /^(nav|header|topbar|masthead|banner)$/; + type Site = { rule: MergedRule | typeof ctx.rules[number]; line: number }; + const timeline = new Map(), sticky = new Map(), animated = new Set(); + for (const rule of ctx.rules) { + if (rule.keyframes || isTokenRule(rule)) continue; + for (const d of rule.decls) { + if (d.prop === 'animation-timeline' && !/^(none|auto)$/i.test(d.value.trim()) && !timeline.has(famOf(rule.selector))) timeline.set(famOf(rule.selector), { rule, line: d.line }); + if (d.prop === 'animation' || d.prop === 'animation-name') animated.add(famOf(rule.selector)); + if (d.prop === 'position' && /sticky/i.test(d.value)) sticky.set(famOf(rule.selector), { rule, line: d.line }); + } + } + const families = new Map(timeline); + for (const [fam, v] of sticky) if (!families.has(fam) && animated.has(fam) && !NAVISH.test(fam)) families.set(fam, v); + if (families.size < 2) return; + const names = [...families.keys()]; + const second = families.get(names[1])!; + r.report('scroll-choreography-mix', second.rule.file, second.line, families.size + ' scroll-choreography patterns on one page (' + trunc(names.join(', '), 44) + ')', { fix: 'one scroll pattern a page; cut the rest' }); +} + +const FAKE_AVATAR = /(i\.pravatar\.cc|pravatar\.cc|api\.dicebear\.com|dicebear\.com|randomuser\.me|thispersondoesnotexist\.com|ui-avatars\.com)/i; +const UNSPLASH_URL = /https?:\/\/[^"'()\s]*unsplash\.com[^"'()\s]*/gi; +const PERSONY = /person|face|portrait|people|headshot/i; + +// sloplint checkF9 (F9) +function checkAvatars(ctx: LintContext, r: Reporter): void { + const scan = (text: string, file: string, lineAt: (i: number) => number): void => { + const f = FAKE_AVATAR.exec(text); + if (f) r.report('fake-avatar', file, lineAt(f.index), 'a generated-face service (' + f[1] + ') stands in for a person', { fix: 'the real person\'s own photo, initials, or no portrait; a generated stranger is fabricated social proof' }); + for (const m of text.matchAll(UNSPLASH_URL)) { + if (PERSONY.test(m[0])) r.report('stock-person-photo', file, lineAt(m.index!), 'a stock person-keyword URL ' + trunc(m[0], 44), { fix: 'faces as social proof need real people; a stock face is a stranger' }); + } + }; + for (const doc of ctx.docs) scan(doc.src, doc.file, (i) => doc.lineOf(i)); + for (const c of ctx.cssFiles) scan(c.raw, c.file, lineIndex(c.raw)); +} + +// sloplint trackCount +function trackCount(v: string): number { + const t = v.replace(/\[[^\]]*\]/g, ' ').trim(); + const rep = /^repeat\(\s*(\d+)/.exec(t); + if (rep) return Number(rep[1]); + let depth = 0, n = 0, inTok = false; + for (const ch of t) { + if (ch === '(') depth++; + else if (ch === ')') depth--; + if (depth === 0 && /\s/.test(ch)) inTok = false; + else if (!inTok) { inTok = true; n++; } + } + return n; +} + +// sloplint checkF15 (F15) +function checkPageInvariants(ctx: LintContext, r: Reporter): void { + const file = firstFile(ctx); + if (!file) return; + const widths = new Map(); + for (const rule of ctx.rules) { + if (rule.keyframes || rule.media || !/container|wrap|inner|shell|content|section|page|main/i.test(rule.selector)) continue; + for (const d of rule.decls) { + if (d.prop !== 'max-width') continue; + const px = maxPxIn(resolveVars(d.value, ctx.tokens)); + if (px && px >= 900) widths.set(px, rule.selector); + } + } + if (widths.size > 1) r.report('page-invariants', file, 1, widths.size + ' container widths on one page (' + [...widths.keys()].join(', ') + 'px)', { fix: 'one container width, applied the same way in every section' }); + const gaps = new Map(); + for (const rule of ctx.rules) { + if (rule.keyframes || rule.media || /hero|masthead|fold|\b(?:app|ui|demo|sample|screen|product|mock|device|console|editor|window)(?:\b|__|--)|\[data-slot/i.test(rule.selector)) continue; + const cols = rule.decls.find((d) => d.prop === 'grid-template-columns' && trackCount(d.value) > 1 && !/^repeat\(\s*auto/.test(d.value.trim())); + if (!cols) continue; + const gap = rule.decls.find((d) => /^(gap|column-gap)$/.test(d.prop)); + if (!gap) continue; + const v = resolveVars(gap.value, ctx.tokens).split(/\s+/).pop()!; + const px = maxPxIn(v); + if (px != null && px >= 24) gaps.set(v, rule.selector); + } + if (gaps.size > 2) r.report('page-invariants', file, 1, gaps.size + ' different column gaps across multi-column sections (' + [...gaps.keys()].join(', ') + ')', { fix: 'one column gap for every multi-column section' }); +} + +// sloplint checkF16 (F16) +function checkBrowserSurfaces(ctx: LintContext, r: Reporter): void { + const file = firstFile(ctx); + if (!file || ctx.tokensOnly || !ctx.tokens['--color-accent']) return; + const classes = ctx.docs.map((d) => [...d.src.matchAll(/class(?:Name)?\s*=\s*["']([^"']*)["']/gi)].map((m) => m[1]).join(' ')).join(' '); + const missing: string[] = []; + if (!/::selection/.test(ctx.allCss) && !twVariants(ctx).has('selection') && !/(? /<(input|textarea)\b/i.test(d.src)); + if (hasInputs && !/caret-color/.test(ctx.allCss) && !/(?]*>([\s\S]*?)<\/h\1>/gi)) { + const level = Number(m[1]); + if (prev && level > prev + 1) { + r.report('skipped-heading', doc.file, doc.lineOf(m.index!), 'h' + prev + ' is followed by h' + level + ' ("' + trunc(m[2].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(), 32) + '")', { fix: 'step the levels one at a time; size a head with CSS, never with its level' }); + break; + } + prev = level; + } + } +} + +// sloplint checkF22 (F22) +function checkJustifiedText(ctx: LintContext, r: Reporter): void { + for (const rule of ctx.rules) { + const j = rule.decls.find((d) => d.prop === 'text-align' && /^justify\b/.test(d.value.trim())); + if (j && !rule.decls.some((d) => d.prop === 'hyphens' && /auto/.test(d.value))) { r.report('justified-text', rule.file, j.line, 'justified text without hyphenation on ' + rule.selector); return; } + } + for (const doc of ctx.docs) { + const m = /class(?:Name)?\s*=\s*["'][^"']*(?]*)>/i.exec(doc.src); + if (!html) continue; + if (!getAttr(html[1], 'lang')) r.report('missing-lang', doc.file, doc.lineOf(html.index), ' names no lang'); + } +} + +// sloplint checkF24 (F24) +function checkHairlineUnderShadow(ctx: LintContext, r: Reporter): void { + const wide = (value: string): boolean => resolveVars(value, ctx.tokens).split(/,(?![^(]*\))/).some((layer) => { + if (/\binset\b/.test(layer)) return false; + const len = [...layer.replace(/(?:oklch|oklab|rgba?|hsla?|color|color-mix)\([^)]*\)/g, '').matchAll(/(-?\d*\.?\d+)(px|rem|em)?(?=\s|$)/g)].map((m) => parseFloat(m[1]) * (m[2] === 'rem' || m[2] === 'em' ? 16 : 1)); + return len.length >= 3 && len[2] + Math.min(0, len[3] || 0) >= 24; + }); + for (const rule of ctx.rules) { + const sh = rule.decls.find((d) => d.prop === 'box-shadow' && d.value.trim() !== 'none'); + if (!sh || !wide(sh.value)) continue; + const edge = rule.decls.find((d) => /^border(?:-(?:top|right|bottom|left|inline|block)(?:-(?:start|end))?)?$/.test(d.prop) && /(?:^|\s)(?:1px|0\.5px|thin|var\(--rule-hair\))(?:\s|$)/.test(d.value) && !/\b(?:none|transparent)\b/.test(d.value)); + if (edge) { r.report('hairline-under-shadow', rule.file, sh.line, 'a hairline border under a wide soft shadow on ' + rule.selector); return; } + } + for (const doc of ctx.docs) { + const m = /class(?:Name)?\s*=\s*["'][^"']*(?\n\nHarbour office\n\n\n\n' + body + '\n\n\n'; +} + +const lineOf = (html: string, needle: string): number => { + const at = html.indexOf(needle); + if (at < 0) throw new Error('needle not in page: ' + needle); + return html.slice(0, at).split('\n').length; +}; + +function run(html: string, files: Record = {}): Finding[] { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-assets-')); + fs.writeFileSync(path.join(dir, 'index.html'), html); + for (const [name, text] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), text); + return lintStatic([dir], { cwd: dir, warn: (l) => { throw new Error(l); } }).findings; +} + +const only = (findings: Finding[], rule: string) => findings.filter((f) => f.rule === rule); +const show = (findings: Finding[]) => findings.map(formatFinding); + +describe('design-lint assets: icons (gate 30)', () => { + test('emoji-decoration blocks on an emoji standing in for an icon, in a tile, a heading or a component string; an SVG icon is clean', () => { + const slop = page('', '
\n
\u2728
\n

\ud83d\ude80 Ship faster

\n

\ud83d\udc4b Welcome aboard

\n
'); + const hits = only(run(slop), 'emoji-decoration'); + expect(hits.map((f) => [f.grade, f.line])).toEqual([['blocks', lineOf(slop, 'feature__icon')], ['blocks', lineOf(slop, 'Ship faster')], ['blocks', lineOf(slop, 'Welcome aboard')]]); + expect(hits[0].evidence).toBe('an emoji icon "\u2728" in
'); + expect(hits[2].evidence).toContain('text opens with an emoji'); + const tsx = 'export const Icon = () => ;\n'; + expect(only(run(page('', '

Ready.

'), { 'Feature.tsx': tsx }), 'emoji-decoration').map((f) => [f.file, f.evidence])).toEqual([['Feature.tsx', 'an emoji passed as an icon: \u2728']]); + expect(only(run(page('', '

Ship faster

')), 'emoji-decoration')).toEqual([]); + }); + + test('two-icon-libraries asks when two libraries share a page; one library is clean', () => { + const mixed = page('', '
\n\n\n
'); + const hits = only(run(mixed), 'two-icon-libraries'); + expect(hits.map((f) => [f.grade, f.line, f.evidence])).toEqual([['ask', 1, 'icon libraries mixed on one page: heroicons and lucide']]); + expect(only(run(page('', '
')), 'two-icon-libraries')).toEqual([]); + }); +}); + +describe('design-lint assets: decoration labels (gate 33)', () => { + test('unlabeled-decoration blocks on a bare or ; aria-hidden, a label, a or a labelled ancestor is clean', () => { + const slop = page('', '
\n\n\n
'); + const hits = only(run(slop), 'unlabeled-decoration'); + expect(hits.map((f) => [f.grade, f.line, f.evidence])).toEqual([ + ['blocks', lineOf(slop, ' with neither aria-hidden="true" nor a label'], + ['blocks', lineOf(slop, ' with neither aria-hidden="true" nor a label'], + ]); + const clean = page('', '
The depot
'); + expect(show(only(run(clean), 'unlabeled-decoration'))).toEqual([]); + }); +}); + +describe('design-lint assets: contrast (gates 40, 41)', () => { + test('low-contrast lands at ask (the static tier suspects, the render tier proves) for text under its threshold; large text at 3:1 and body text at 4.5:1 are clean', () => { + const slop = page('.muted {\n color: #999;\n background: #fff;\n}\n.big { font-size: 32px; color: #767676; background: #fff; }', '

Faint

Large

'); + const hits = only(run(slop), 'low-contrast'); + expect(hits.map((f) => [f.tier, f.grade, f.line])).toEqual([['ask', 'ask', lineOf(slop, 'color: #999')]]); + expect(hits[0].evidence).toBe('2.85:1 under the 4.5:1 threshold on .muted'); + expect(only(run(page('.body { color: #595959; background: #fff; }', '

Readable

')), 'low-contrast')).toEqual([]); + }); + + test('gray-on-color lands at ask for button text within a step of its fill, a dark surface that sets no text colour and a missing accent ink; a paired ink is clean', () => { + const slop = page('.btn {\n color: #7a7a7a;\n background-color: #777;\n}\n.panel {\n background: #222;\n padding: 1rem;\n}', '

Dark

'); + const hits = only(run(slop), 'gray-on-color'); + expect(hits.map((f) => [f.tier, f.grade, f.line])).toEqual([['ask', 'ask', lineOf(slop, 'color: #7a7a7a')], ['ask', 'ask', lineOf(slop, 'background: #222')]]); + expect(hits[0].evidence).toMatch(/^button text within a step of its fill on \.btn/); + expect(hits[1].evidence).toBe('a dark surface (lightness 25%) on .panel sets no text colour'); + const noInk = page('.btn { background: var(--color-accent); color: white; }', '
').replace('--color-accent-ink: oklch(98% 0.01 30); ', ''); + expect(only(run(noInk), 'gray-on-color').map((f) => f.evidence)).toEqual(['the accent fills a surface but --color-accent-ink is not defined']); + expect(only(run(page('.btn { background: var(--color-accent); color: var(--color-accent-ink); } .panel { background: var(--color-ink); color: var(--color-paper); }', '

Dark

')), 'gray-on-color')).toEqual([]); + }); +}); + +describe('design-lint assets: labels that wrap (gate 49)', () => { + test('button-label-wraps lands at ask for a long button or primary nav label with no nowrap; link-wraps asks for a long footer link; nowrap and short labels are clean', () => { + const slop = page('', '\n
\n\n
\n'); + const findings = run(slop); + const buttons = only(findings, 'button-label-wraps'); + expect(buttons.map((f) => [f.tier, f.grade, f.line])).toEqual([['ask', 'ask', lineOf(slop, '

Ledgerline

')), 'template-nav')).toEqual([]); + expect(only(run(reflex, { scope: 'component' }), 'template-nav')).toEqual([]); + }); + + test('template-footer: four link columns ask; a one-line footer is clean', () => { + const col = ''; + const reflex = page('', '

Ledgerline

\n
\n' + col + '\n' + col + '\n' + col + '\n' + col + '\n
'); + const hits = only(run(reflex), 'template-footer'); + expect(hits.map((f) => [f.grade, f.line])).toEqual([['ask', lineOf(reflex, '

Ledgerline, Bergen.

')), 'template-footer')).toEqual([]); + }); +}); + +describe('design-lint chrome: proof, drawn chrome and material (gates 46a, 47)', () => { + test('invented-proof: a metric the page cannot source lands at ask for a person to confirm; a plain count is clean', () => { + const slop = page('', '
\n

Trusted by 50,000+ teams shipping 10x faster.

\n
'); + const hits = only(run(slop), 'invented-proof'); + expect(hits.map((f) => [f.tier, f.grade, f.line])).toEqual([['ask', 'ask', lineOf(slop, 'Trusted by')]]); + expect(only(run(page('', '

Halvorsen Freight reconciles 1,140 invoices a month.

')), 'invented-proof')).toEqual([]); + }); + + test('drawn-browser-chrome blocks on a traffic-light bar class or selector; device-frame asks on a phone frame; a real figure is clean', () => { + const drawn = page('.window-frame { border: 1px solid var(--color-muted); }', '
\n
\n
\n
'); + const findings = run(drawn); + const bar = only(findings, 'drawn-browser-chrome'); + expect(bar.map((f) => [f.grade, f.line])).toEqual([['blocks', lineOf(drawn, 'browser-bar')]]); + expect(bar[0].evidence).toContain('class "browser-bar"'); + const frame = only(findings, 'device-frame'); + expect(frame.map((f) => [f.grade, f.line]).sort((a, b) => Number(a[1]) - Number(b[1]))).toEqual([['ask', lineOf(drawn, '.window-frame')], ['ask', lineOf(drawn, 'phone-frame"')]]); + const clean = run(page('', '
The close screen
The close screen.
')); + expect(only(clean, 'drawn-browser-chrome')).toEqual([]); + expect(only(clean, 'device-frame')).toEqual([]); + }); + + test('imitation-material: a placeholder image service asks, in markup or CSS; a real asset is clean', () => { + const fake = page('', '
\n\n
').replace('\n\n\n' + body + '\n\n\n'; +} + +const lineOf = (html: string, needle: string): number => { + const at = html.indexOf(needle); + if (at < 0) throw new Error('needle not in page: ' + needle); + return html.slice(0, at).split('\n').length; +}; + +function run(html: string, files: Record = {}): Finding[] { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-finish-')); + fs.writeFileSync(path.join(dir, 'index.html'), html); + for (const [name, text] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), text); + return lintStatic([dir], { cwd: dir, warn: (l) => { throw new Error(l); } }).findings; +} + +const only = (findings: Finding[], rule: string) => findings.filter((f) => f.rule === rule); +const show = (findings: Finding[]) => findings.map(formatFinding); +const polishAt = (findings: Finding[], rule: string, line: number) => expect(only(findings, rule).map((f) => [f.grade, f.line])).toEqual([['polish', line]]); + +describe('design-lint finish: prose (F1, F2)', () => { + test('straight-quotes prints at polish with the count; curly quotes are clean', () => { + const slop = page('', '
\n

Mari said "the close ran itself" and that\'s the review.

\n
'); + const hits = only(run(slop), 'straight-quotes'); + polishAt(hits, 'straight-quotes', lineOf(slop, 'Mari said')); + expect(hits[0].evidence).toMatch(/^3 straight quotes in prose \(first: /); + expect(only(run(page('', '

Mari said \u201cthe close ran itself\u201d and that\u2019s the review.

')), 'straight-quotes')).toEqual([]); + }); + + test('three-dot-ellipsis prints at polish; the ellipsis character is clean', () => { + const slop = page('', '
\n

Wait for it...

\n
'); + polishAt(run(slop), 'three-dot-ellipsis', lineOf(slop, 'Wait for')); + expect(only(run(page('', '

Wait for it\u2026

')), 'three-dot-ellipsis')).toEqual([]); + }); +}); + +describe('design-lint finish: heads and buttons (F3, F4, F6)', () => { + test('balanced-display-head prints at polish when no display head has text-wrap: balance; one balanced head rule is clean', () => { + const slop = page('h1 {\n font-size: 3rem;\n}', '

October closed in two days

'); + const hits = only(run(slop), 'balanced-display-head'); + polishAt(hits, 'balanced-display-head', lineOf(slop, 'h1 {')); + expect(hits[0].evidence).toBe('no text-wrap: balance on any display head (1 head rules, the first h1)'); + expect(only(run(page('h1 { font-size: 3rem; text-wrap: balance; }', '

October closed in two days

')), 'balanced-display-head')).toEqual([]); + }); + + test('two-primary-buttons prints at polish for two filled primary recipes; one filled recipe and an outline is clean', () => { + const slop = page('.btn-book { background: var(--color-accent); }\n.btn-buy {\n background: var(--color-ink);\n}', '
BookBuy
'); + const hits = only(run(slop), 'two-primary-buttons'); + polishAt(hits, 'two-primary-buttons', lineOf(slop, 'background: var(--color-ink)')); + expect(hits[0].evidence).toBe('2 filled primary-button recipes on one page: .btn-book and .btn-buy'); + expect(only(run(page('.btn-book { background: var(--color-accent); } .btn-quiet { background: transparent; border: 1px solid var(--color-accent); }', '
BookMethod
')), 'two-primary-buttons')).toEqual([]); + }); + + test('display-figures-tabular prints at polish for a display-size stat without tabular numerals; tabular-nums on the selector or the root is clean', () => { + const slop = page('.stat__num {\n font-size: 48px;\n}', '

1,140

'); + const hits = only(run(slop), 'display-figures-tabular'); + polishAt(hits, 'display-figures-tabular', lineOf(slop, 'font-size: 48px')); + expect(hits[0].evidence).toBe('figures on .stat__num at display size without tabular numerals'); + expect(only(run(page('.stat__num { font-size: 48px; font-variant-numeric: tabular-nums; }', '

1,140

')), 'display-figures-tabular')).toEqual([]); + expect(only(run(page('body { font-variant-numeric: tabular-nums; } .stat__num { font-size: 48px; }', '

1,140

')), 'display-figures-tabular')).toEqual([]); + }); +}); + +describe('design-lint finish: surfaces and recipes (F5, F7, F24)', () => { + test('icon-coin prints at polish for a small round filled icon box; a bare inline icon is clean', () => { + const slop = page('.feature__icon {\n width: 40px;\n border-radius: 50%;\n background: var(--color-accent);\n}', '
'); + const hits = only(run(slop), 'icon-coin'); + polishAt(hits, 'icon-coin', lineOf(slop, 'background: var(--color-accent)')); + expect(hits[0].evidence).toBe('an icon in a coloured coin: .feature__icon'); + expect(only(run(page('.feature__icon { width: 20px; color: currentColor; }', '
')), 'icon-coin')).toEqual([]); + }); + + test('stamped-recipe prints at polish for one container recipe on six selectors and for the pill radius on five non-controls; four varied boxes are clean', () => { + const recipe = ' { border: 1px solid var(--color-muted); border-radius: 8px; background: var(--color-paper); }'; + const six = page(['.a', '.b', '.c', '.d', '.e', '.f'].map((s) => s + recipe).join('\n'), '
'); + const hits = only(run(six), 'stamped-recipe'); + polishAt(hits, 'stamped-recipe', lineOf(six, '.a {')); + expect(hits[0].evidence).toBe('one container recipe on 6 selectors (.a, .b, .c, .d and more)'); + const pills = page('.card, .panel, .tile, .box, .media {\n border-radius: 9999px;\n}', '
'); + const pillHits = only(run(pills), 'stamped-recipe'); + polishAt(pillHits, 'stamped-recipe', lineOf(pills, 'border-radius: 9999px')); + expect(pillHits[0].evidence).toBe('the pill radius on 5 non-control selectors (.card, .panel, .tile, .box)'); + expect(only(run(page('.btn, .chip, .tag, .badge, .pill { border-radius: 9999px; } .a' + recipe + ' .b { border-radius: 4px; background: var(--color-paper); }', '
')), 'stamped-recipe')).toEqual([]); + }); + + test('hairline-under-shadow prints at polish for a 1px border under a wide soft shadow, in CSS or Tailwind classes; a shadow alone is clean', () => { + const slop = page('.card {\n border: 1px solid var(--color-muted);\n box-shadow: 0 10px 30px -5px rgb(0 0 0 / 0.2);\n}', '
'); + const hits = only(run(slop), 'hairline-under-shadow'); + polishAt(hits, 'hairline-under-shadow', lineOf(slop, 'box-shadow')); + expect(hits[0].evidence).toBe('a hairline border under a wide soft shadow on .card'); + const tw = page('', '
\n
\n
'); + expect(only(run(tw), 'hairline-under-shadow').map((f) => [f.grade, f.line])).toEqual([['polish', lineOf(tw, 'shadow-xl')]]); + expect(only(run(page('.card { box-shadow: 0 10px 30px -5px rgb(0 0 0 / 0.2); }', '
')), 'hairline-under-shadow')).toEqual([]); + }); +}); + +describe('design-lint finish: motion and people (F8, F9)', () => { + test('scroll-choreography-mix prints at polish for two scroll-driven families; one is clean', () => { + const slop = page('.reveal { animation: rise linear both; animation-timeline: view(); }\n.bar {\n animation: grow linear both;\n animation-timeline: scroll();\n}', '
'); + const hits = only(run(slop), 'scroll-choreography-mix'); + polishAt(hits, 'scroll-choreography-mix', lineOf(slop, 'animation-timeline: scroll()')); + expect(hits[0].evidence).toBe('2 scroll-choreography patterns on one page (reveal, bar)'); + expect(only(run(page('.reveal { animation: rise linear both; animation-timeline: view(); }', '
')), 'scroll-choreography-mix')).toEqual([]); + }); + + test('fake-avatar blocks on a generated-face service; stock-person-photo prints at polish for a person-keyword stock URL; a named local portrait is clean', () => { + const slop = page('.hero { background-image: url(https://images.unsplash.com/photo-1?portrait); }', '
\nMari\nA customer\n
'); + const findings = run(slop); + expect(only(findings, 'fake-avatar').map((f) => [f.tier, f.grade, f.line, f.evidence])).toEqual([['blocks', 'blocks', lineOf(slop, 'pravatar'), 'a generated-face service (i.pravatar.cc) stands in for a person']]); + expect(only(findings, 'stock-person-photo').map((f) => [f.grade, f.line]).sort((a, b) => Number(a[1]) - Number(b[1]))).toEqual([['polish', lineOf(slop, 'photo-1?portrait')], ['polish', lineOf(slop, 'headshot')]]); + const clean = run(page('', '
Mari Halvorsen
')); + expect(only(clean, 'fake-avatar')).toEqual([]); + expect(only(clean, 'stock-person-photo')).toEqual([]); + }); +}); + +describe('design-lint finish: page invariants and browser surfaces (F15, F16)', () => { + test('page-invariants prints at polish for two container widths and for three column gaps; one width and one gap are clean', () => { + const widths = page('.wrap { max-width: 1200px; }\n.container { max-width: 1360px; }', '
'); + const w = only(run(widths), 'page-invariants'); + expect(w.map((f) => [f.grade, f.line, f.evidence])).toEqual([['polish', 1, '2 container widths on one page (1200, 1360px)']]); + const gaps = page('.a { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; } .b { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 32px; } .c { display: grid; grid-template-columns: repeat(2, 1fr); gap: 48px; }', '
'); + expect(only(run(gaps), 'page-invariants').map((f) => f.evidence)).toEqual(['3 different column gaps across multi-column sections (24px, 32px, 48px)']); + expect(only(run(page('.wrap { max-width: 1360px; } .a, .b { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; }', '
')), 'page-invariants')).toEqual([]); + }); + + test('unthemed-browser-surfaces prints at polish naming the missing surfaces on a page with an accent; themed CSS or Tailwind surfaces are clean, and a page without an accent is skipped', () => { + const slop = page('', '
'); + const hits = only(run(slop), 'unthemed-browser-surfaces'); + expect(hits.map((f) => [f.grade, f.line, f.evidence])).toEqual([['polish', 1, 'browser surfaces left unthemed: ::selection, scrollbar-color, caret-color']]); + expect(only(run(page('::selection { background: var(--color-accent); } html { scrollbar-color: var(--color-muted) var(--color-paper); } input { caret-color: var(--color-accent); }', '
')), 'unthemed-browser-surfaces')).toEqual([]); + expect(only(run(page('', '
')), 'unthemed-browser-surfaces')).toEqual([]); + expect(only(run(page('', '
').replace('--color-accent: oklch(50% 0.16 30); ', '')), 'unthemed-browser-surfaces')).toEqual([]); + }); +}); + +describe('design-lint finish: the document (F21, F22, F23)', () => { + test('skipped-heading prints at polish when a level is skipped; stepped levels are clean', () => { + const slop = page('', '
\n

October

\n

Where the days went

\n
'); + const hits = only(run(slop), 'skipped-heading'); + polishAt(hits, 'skipped-heading', lineOf(slop, '

')); + expect(hits[0].evidence).toBe('h1 is followed by h3 ("Where the days went")'); + expect(only(run(page('', '

October

Where the days went

Matching

')), 'skipped-heading')).toEqual([]); + }); + + test('justified-text prints at polish without hyphens: auto, in CSS or as text-justify; hyphenated justification is clean', () => { + const slop = page('p {\n text-align: justify;\n}', '

Three days were matching receipts.

'); + const hits = only(run(slop), 'justified-text'); + polishAt(hits, 'justified-text', lineOf(slop, 'text-align: justify')); + expect(hits[0].evidence).toBe('justified text without hyphenation on p'); + const tw = page('', '
\n

Three days were matching receipts.

\n
'); + expect(only(run(tw), 'justified-text').map((f) => f.line)).toEqual([lineOf(tw, 'text-justify')]); + expect(only(run(page('p { text-align: justify; hyphens: auto; }', '

Three days were matching receipts.

')), 'justified-text')).toEqual([]); + }); + + test('missing-lang prints at polish for without lang; the title and description halves belong to the copy tier', () => { + const slop = page('', '
', ''); + const hits = only(run(slop), 'missing-lang'); + polishAt(hits, 'missing-lang', lineOf(slop, '')); + expect(hits[0].evidence).toBe(' names no lang'); + expect(only(run(page('', '
')), 'missing-lang')).toEqual([]); + }); +}); + +describe('design-lint finish: fixtures', () => { + test('the clean page raises nothing from this family; slopped.html plants the unbalanced head and the straight quotes; the waiver fixture waives balanced-display-head', async () => { + const finish = new Set(['straight-quotes', 'three-dot-ellipsis', 'balanced-display-head', 'two-primary-buttons', 'icon-coin', 'display-figures-tabular', 'stamped-recipe', 'scroll-choreography-mix', 'fake-avatar', 'stock-person-photo', 'page-invariants', 'unthemed-browser-surfaces', 'skipped-heading', 'justified-text', 'missing-lang', 'hairline-under-shadow']); + const clean = await lint([path.join(FIXTURES, 'clean')], { cwd: ROOT }); + expect(show(clean.findings.filter((f) => finish.has(f.rule)))).toEqual([]); + const slopped = await lint([path.join(FIXTURES, 'slopped.html')], { cwd: ROOT }); + expect(only(slopped.findings, 'balanced-display-head').map((f) => [f.grade, f.line])).toEqual([['polish', 63]]); + expect(only(slopped.findings, 'straight-quotes').map((f) => [f.grade, f.line])).toEqual([['polish', 141]]); + const waiver = await lint([path.join(FIXTURES, 'waiver')], { cwd: ROOT }); + expect(only(waiver.findings, 'balanced-display-head').map((f) => f.grade)).toEqual(['waived']); + }); +}); diff --git a/test/fixtures/design-lint/clean/index.html b/test/fixtures/design-lint/clean/index.html index f18334469..1618e5b4c 100644 --- a/test/fixtures/design-lint/clean/index.html +++ b/test/fixtures/design-lint/clean/index.html @@ -29,6 +29,7 @@ --ease-out: cubic-bezier(0.2, 0.8, 0.2, 1); } html, body { overflow-x: clip; } +html { scrollbar-color: var(--color-muted) var(--color-paper); } body { margin: 0; background: var(--color-paper); color: var(--color-ink); font-family: var(--font-body); font-size: 1.125rem; line-height: 1.6; } ::selection { background: var(--color-accent); color: var(--color-accent-ink); } .skip { position: absolute; left: var(--space-2); top: var(--space-2); }