diff --git a/lib/design-lint/rules/component.ts b/lib/design-lint/rules/component.ts index 008a5347f..d4edd1c06 100644 --- a/lib/design-lint/rules/component.ts +++ b/lib/design-lint/rules/component.ts @@ -1,10 +1,104 @@ /** - * Component rules of the design lint (sloplint gates checkTailwind, checkComponentText; Nutlope/hallmark, - * MIT, see NOTICE.md), rewritten in gstack's voice. Rule ids: the Tailwind half of 2a 3 6a 11 54 65 67 74 and kin over component class strings, plus emoji and template punctuation in component text. + * Component rules of the design lint (sloplint checkTailwind and + * checkComponentText; Nutlope/hallmark, MIT, see NOTICE.md), rewritten in + * gstack's voice: the Tailwind form of gates 2a 2b 3 6a 10a 11 12 29 48 54 64 + * 67 and 74 read off utility class strings in pages and components, plus + * emoji icons and template punctuation (30a, 63) in component text. */ import type { LintContext } from '../context'; -import type { Reporter } from '../report'; +import { relPath, trunc, 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; + +/** The tells one class string carries on its own: motion, gradient, glow, literals, a moving image, tracked caps, the eyebrow, mono costume. */ +function reportItem(ctx: LintContext, r: Reporter, it: TwItem, monoOk: boolean): void { + const { doc, info: f } = it; + const where = twWhere(it); + const had = (rule: string) => r.findings.some((x) => x.rule === rule && x.file === relPath(doc.file, r.cwd) && x.line === it.line); + if (f.tAll) r.report('layout-transition', doc.file, it.line, `${f.tAll} on ${where}`, { fix: 'name the properties: transition-colors, transition-opacity or transition-transform' }); + if (f.bounce || f.overshoot) r.report('bounce-easing', doc.file, it.line, `${f.bounce ? 'bounce' : 'overshoot easing'} ${f.bounce || f.overshoot} on ${where}`, { fix: 'an exponential ease-out on UI state; no bounce or overshoot' }); + const g = twGradient(ctx, f); + if (g && g.banned) r.report('purple-blue-gradient', doc.file, it.line, `${g.banned} gradient${g.text ? ' text' : ''} (${g.list}) on ${where}`); + else if (g && g.text) r.report('gradient-text', doc.file, it.line, `gradient text (${g.list}) on ${where}`); + const glow = f.bblur && f.glass ? `glass: ${f.bblur} over ${f.glass}` + : f.blur >= 40 && f.pos && (f.fill || f.grad || f.stops.length) ? `blurred orb: ${f.blurTok} on a filled ${f.pos} element` + : twGlow(ctx, f); + if (glow) r.report('glow-wash', doc.file, it.line, `${glow} on ${where}`); + if (f.colours.length || f.font) { + const what = [f.colours.length ? `colour literal ${f.colours[0].tok}` : null, f.font ? `font-family literal ${f.font}` : null].filter(Boolean).join(' and '); + const certain = !!f.font || f.colours.some((c) => !c.oklch); + r.report('literal-outside-tokens', doc.file, it.line, `${what} in a class on ${where}`, { fix: 'lift the value into the token block (@theme or :root) and use its utility name', suspected: !certain }); + } + if ((f.hScale || f.hRotate) && /^(?:img|picture|image)$/i.test(it.tag)) r.report('hover-tells', doc.file, it.line, `${f.hScale || f.hRotate} on ${where}: the image moves on hover`, { fix: 'imagery sits still; give the container the feedback' }); + const caps = twTrackedCaps(it, twGroup(ctx)); + if (caps && !had('wide-tracking')) { + const text = it.virtual ? '' : twText(it); + r.report('wide-tracking', doc.file, it.line, `${text ? `"${trunc(text, 24)}" ` : ''}${caps.what}${caps.size != null ? ` at ${caps.size}px` : ''}${caps.em != null ? ` tracked ${caps.em}em` : ''} on ${where}`, { fix: 'sentence case in the body face, a step smaller, untracked: drop uppercase and the tracking-* class' }); + } + const eyebrow = twEyebrow(it); + if (eyebrow && !had('kicker-above-heading')) { + r.report('kicker-above-heading', doc.file, it.line, `eyebrow ${eyebrow.text ? `"${trunc(eyebrow.text, 24)}" ` : ''}before <${eyebrow.next}> (${eyebrow.signal} at ${it.frame.size}px)`); + } + if (!monoOk && f.mono && !it.virtual && !it.frame.code && !/^(?:code|pre|kbd|samp|time|var)$/.test(it.tag) && it.frame.size != null && it.frame.size <= 14) { + const text = twText(it); + if (/[A-Za-z]/.test(text) && !twDataText(text)) r.report('monospace-costume', doc.file, it.line, `font-mono on "${trunc(text, 24)}" at ${it.frame.size}px (${where})`, { fix: 'labels in the body face at a smaller size and normal case; font-mono only for code and figures' }); + } +} + +/** The tells a file carries as a whole: a default palette, hover scale everywhere, a vh hero, three icon tiles. */ +function reportFile(ctx: LintContext, r: Reporter, list: TwItem[]): void { + const doc = list[0].doc; + const pal = new Map(); + let palAt: TwItem | null = null; + for (const it of list) for (const p of it.info.pal) if (!pal.has(p)) { pal.set(p, it); if (pal.size === 5) palAt = it; } + if (palAt) r.report('framework-palette', doc.file, palAt.line, `a framework default palette instead of the page's tokens: ${pal.size} distinct palette classes (${[...pal.keys()].slice(0, 4).join(' ')})`, { fix: "extend the Tailwind theme with the page's tokens and use those names" }); + const scaled = list.filter((it) => it.info.hScale); + if (scaled.length >= 3) r.report('hover-tells', doc.file, scaled[2].line, `hover scale on ${scaled.length} elements in this file (${scaled.slice(0, 3).map((it) => it.info.hScale).join(', ')})`, { fix: 'one hover signal per element; not every card grows' }); + const first = new Set(['section', 'header'].map((n) => doc.tags.findIndex((t) => !t.close && t.name === n)).filter((x) => x >= 0)); + const byIdx = new Map(); + for (const it of list) if (it.idx >= 0) byIdx.set(it.idx, it); + for (const it of list) { + if (it.info.vh && !it.virtual && (first.has(it.idx) || it.info.hero)) r.report('vh-height', doc.file, it.line, `hero sized in vh: ${it.info.vh} on ${twWhere(it)}`, { fix: 'min-h-dvh or min-h-svh' }); + if (it.info.cols3 && it.idx >= 0) { + const n = twTiles(it, byIdx); + if (n >= 3) r.report('template-cards', doc.file, it.line, `icon-tile row: ${n} children of ${twWhere(it)} open on an icon in a rounded filled tile above an h3`, { fix: 'vary the column widths or drop the icon tiles' }); + } + } +} + +// sloplint checkTailwind (gates 10a 12 2a 2b 29 48 11 67 54 64 74 6a 3) +function checkTailwind(ctx: LintContext, r: Reporter): void { + const tw = twGroup(ctx); + if (!tw.items.length) return; + const monoOk = twMonoIsMaterial(twTokens(ctx)); + const byDoc = new Map(); + for (const it of tw.items) { + if (!byDoc.has(it.file)) byDoc.set(it.file, []); + byDoc.get(it.file)!.push(it); + reportItem(ctx, r, it, monoOk); + } + for (const list of byDoc.values()) reportFile(ctx, r, list); +} + +// sloplint checkComponentText (gates 63 30a) +function checkComponentText(ctx: LintContext, r: Reporter): void { + for (const doc of ctx.docs) { + for (let c = 0; c < doc.comps.length; c++) { + const t = doc.comps[c]; + if (t.close || t.selfClosing) continue; + const inner = compInner(doc, c); + if (!inner || /<(?:p|div|li|section|article|ul|ol|table)\b/i.test(inner)) continue; + const text = twPlain(doc, inner); + if (!text) continue; + 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)) r.report('emoji-decoration', doc.file, t.line, `emoji icon "${text}" in <${t.name}>`, { fix: 'one icon library or custom SVG' }); + } + } +} export function run(ctx: LintContext, r: Reporter): void { - void ctx; void r; + checkTailwind(ctx, r); + checkComponentText(ctx, r); } diff --git a/lib/design-lint/tailwind.ts b/lib/design-lint/tailwind.ts index 57d0ea65b..76af0d62e 100644 --- a/lib/design-lint/tailwind.ts +++ b/lib/design-lint/tailwind.ts @@ -2,22 +2,162 @@ * Tailwind reading for the design lint: utility class strings in HTML and * component files (class, className, :class, class:list and the cn/clsx/cva * helper calls), parsed into the facts the rules ask about (background and - * text colours, gradients, sizes, tracking, case, families, variants). + * text colours, gradients, sizes, tracking, case, families, variants), plus + * the tells the component rules read off them (gradient, glow, tracked caps, + * eyebrow, mono costume, icon tiles). * - * Ported from Nutlope/hallmark's sloplint.mjs (twParse, twInfo, twGroup and - * kin; MIT, see NOTICE.md). + * Ported from Nutlope/hallmark's sloplint.mjs (twParse, twInfo, twGroup, + * twGradient, twGlow, tw67, tw54, twTiles and kin; MIT, see NOTICE.md). */ import type { LintContext } from './context'; import { memo } from './context'; +import { COLOR_LITERAL, hueIn, oklchOf, parseColor, type Color } from './color'; +import { GENERIC_FAMILIES, firstFamily, pxOf, resolveVars, splitTop, type Tokens } from './css'; +import { braceEnd, closeIndex, clsLits, directChildren, getAttr, innerOf, plainText, type Doc, type Tag } from './html'; +import { trunc } from './report'; -export interface TwStop { kind: 'token' | 'palette' | 'literal' | 'other'; hue: number | null; value: string } +export const TW_SIZE: Record = { xs: 12, sm: 14, base: 16, lg: 18, xl: 20, '2xl': 24, '3xl': 30, '4xl': 36, '5xl': 48, '6xl': 60, '7xl': 72, '8xl': 96, '9xl': 128 }; +const TW_TRACK: Record = { tighter: -0.05, tight: -0.025, normal: 0, wide: 0.025, wider: 0.05, widest: 0.1 }; +const TW_HUE: Record = { red: 25, orange: 47, amber: 70, yellow: 86, lime: 131, green: 150, emerald: 163, teal: 183, cyan: 215, sky: 237, blue: 260, indigo: 277, violet: 293, purple: 304, fuchsia: 322, pink: 354, rose: 16 }; +const TW_COLOURS = 'slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose'; +export const TW_PAL = new RegExp(`^(?:bg|text|border(?:-[xytrblse])?|ring(?:-offset)?|outline|decoration|divide|from|via|to|fill|stroke|shadow|accent|caret|placeholder|inset-shadow|inset-ring|drop-shadow)-(?:${TW_COLOURS})-(?:50|[1-9]00|950)(?:\\/(?:\\d{1,3}|\\[[^\\]]*\\]))?$`); +const TW_SHADE = new RegExp(`^(${TW_COLOURS})-(?:50|[1-9]00|950)(?:\\/\\S*)?$`); +const TW_ACCENT = /^text-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:500|600|700)(?:\/\S*)?$/; +const TW_PURPLE = /^(?:purple|violet|fuchsia|indigo)$/; +const TW_BLUE = /^(?:blue|sky|cyan|indigo)$/; +const TW_PINK = /^(?:pink|rose)$/; +const TW_GREY = /^(?:slate|gray|zinc|neutral|stone)$/; +const TW_NONFILL = /^bg-(?:none|transparent|inherit|current|fixed|local|scroll|cover|contain|auto|center|top|bottom|left|right|no-repeat|repeat(?:-[\w-]+)?|(?:left|right|top|bottom)-(?:top|bottom|left|right)|clip-[\w-]+|origin-[\w-]+|blend-[\w-]+|gradient-to-[\w-]+|(?:linear|radial|conic)(?:-.*)?|size-.*|position-.*|\[(?:url|length|position|size|image|(?:repeating-)?(?:linear|radial|conic)-gradient)[^\]]*\].*)$/; +const TW_GLASS = /^bg-(?:white|black|(?:slate|gray|zinc|neutral|stone)-(?:50|100|900|950)|paper|ink|background|foreground)\/(\d{1,2}|\[(?:0?\.\d+|\d{1,2}%)\])$/; +const TW_MONO = /mono|courier|jetbrains|plex mono|geist mono|commit|fira code|space mono|dm mono|roboto mono|source code|silkscreen/i; +const TW_LOGO = /logo|brand|wordmark/i; +const TW_COLOR_LIT = /#[0-9a-fA-F]{3,8}\b|oklch\([^)]*\)|rgba?\([^)]*\)|hsla?\([^)]*\)/g; +const MONO_TOKEN = /mono|code|courier|silkscreen|jetbrains|plex mono|geist mono|commit|fira code|space mono|dm mono|roboto mono|source code|ibm plex mono|press start|vt323|share tech/i; +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_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; + +export interface TwParsed { variants: string[]; base: string; neg: boolean } + +const TW_PARSED = new Map(); + +/** One utility split into its variants (group-/peer- prefixes and /names dropped), its base and a leading minus. */ +export function twParse(tok: string): TwParsed { + const cached = TW_PARSED.get(tok); + if (cached) return cached; + const variants: string[] = []; + let depth = 0, cur = ''; + for (const ch of tok) { + if (ch === '[' || ch === '(') depth++; + else if ((ch === ']' || ch === ')') && depth > 0) depth--; + if (ch === ':' && depth === 0) { variants.push(cur); cur = ''; } else cur += ch; + } + let base = cur; + if (base.startsWith('!')) base = base.slice(1); + if (base.endsWith('!')) base = base.slice(0, -1); + const neg = base.length > 1 && base.startsWith('-'); + if (neg) base = base.slice(1); + const p = { variants: variants.map((v) => v.replace(/^(?:group|peer)-/, '').replace(/\/[\w-]+$/, '')), base, neg }; + TW_PARSED.set(tok, p); + return p; +} + +/** The tokens utilities resolve against: the project's shared tokens for a component directory, else the context's own. */ +export const twTokens = (ctx: LintContext): Tokens => ctx.twTokens ?? ctx.tokens; + +const twValue = (tok: Tokens, v: string): string => resolveVars(v, tok).trim().replace(/^(-?)\./, '$10.'); + +/** The px size a text-* utility sets: the scale, an arbitrary value, or a --text-* token. */ +export function twSize(ctx: LintContext, b: string): number | null { + const tok = twTokens(ctx); + let m = /^text-(xs|sm|base|lg|[2-9]?xl)(?:\/\S+)?$/.exec(b); + if (m) return TW_SIZE[m[1]]; + m = /^text-(?:\[(?:length:)?([^\]]+)\]|\((?:length:)?(--[\w-]+)\))(?:\/\S+)?$/.exec(b); + let v: string | null = m ? (m[1] ? m[1].replace(/_/g, ' ') : `var(${m[2]})`) : null; + if (!m) { + m = /^text-([a-z][\w-]*?)(?:\/\S+)?$/.exec(b); + v = m ? tok[`--text-${m[1]}`] ?? null : null; + } + if (!v) return null; + const r = twValue(tok, v); + const cl = /^clamp\(\s*([^,]+),/.exec(r); + return pxOf(cl ? twValue(tok, cl[1]) : r); +} + +export interface TwTrack { em: number | null; px: number | null } + +/** The tracking a tracking-* utility sets, in em when it says em, else in px. */ +export function twTrack(ctx: LintContext, b: string, neg: boolean): TwTrack | null { + const tok = twTokens(ctx); + let m = /^tracking-(tighter|tight|normal|wide|wider|widest)$/.exec(b); + let v: string | null = null; + if (m) v = `${TW_TRACK[m[1]]}em`; + else if ((m = /^tracking-(?:\[([^\]]+)\]|\((--[\w-]+)\))$/.exec(b))) v = m[1] ? m[1].replace(/_/g, ' ') : `var(${m[2]})`; + else if ((m = /^tracking-([a-z][\w-]*)$/.exec(b)) && tok[`--tracking-${m[1]}`]) v = tok[`--tracking-${m[1]}`]; + if (v == null) return null; + const r = twValue(tok, v); + const e = /^(-?\d*\.?\d+)em$/.exec(r); + const px = e ? null : pxOf(r); + if (e) { const em = parseFloat(e[1]); return { em: neg ? -em : em, px: null }; } + if (px == null) return null; + return { em: null, px: neg ? -px : px }; +} + +/** The OKLCH hue of a colour that has one: opaque enough and chroma past 0.04. */ +export const twHue = (c: Color | null): number | null => { + if (!c || c.alpha <= 0.05) return null; + const ok = oklchOf(c); + return ok.C > 0.04 ? ok.H : null; +}; + +export interface TwStop { + kind: 'token' | 'palette' | 'literal' | 'other'; + hue: number | null; + /** The stop's colour name or value as written after from-/via-/to-. */ + value: string; + /** The whole utility. */ + tok: string; + /** The palette family (violet, sky …) for a palette stop. */ + pal: string | null; +} + +/** One gradient stop (from-x, via-x, to-x) as a kind and a hue; a percentage position is no stop. */ +export function twStop(ctx: LintContext, v: string, tok: string): TwStop | null { + if (/^\[?-?\d*\.?\d+%\]?$/.test(v)) return null; + const tokens = twTokens(ctx); + const pm = TW_SHADE.exec(v); + if (pm) return { tok, value: v, kind: 'palette', pal: pm[1], hue: TW_GREY.test(pm[1]) ? null : TW_HUE[pm[1]] }; + if (/^(?:white|black|transparent|current|inherit)(?:\/\S*)?$/.test(v)) return { tok, value: v, kind: 'other', pal: null, hue: null }; + if (v.startsWith('(')) return { tok, value: v, kind: 'token', pal: null, hue: twHue(parseColor(twValue(tokens, `var${v.replace(/\/\S*$/, '').replace(/^\((?:color:)?/, '(')}`))) }; + if (v.startsWith('[')) { + const inner = v.slice(1, v.lastIndexOf(']')).replace(/_/g, ' '); + return { tok, value: v, kind: /var\(/.test(inner) ? 'token' : 'literal', pal: null, hue: twHue(parseColor(twValue(tokens, inner))) }; + } + const tv = tokens[`--color-${v.replace(/\/\S*$/, '')}`]; + return { tok, value: v, kind: 'token', pal: null, hue: tv ? twHue(parseColor(twValue(tokens, tv))) : null }; +} + +/** The named family inside font-[…], or null for a token, a weight or a generic family. */ +export function twFamily(v: string): string | null { + if (/var\(/.test(v)) return null; + const named = v.replace(/^family-name:/, ''); + if (named === v && !/^['"]/.test(v) && /^(?:\d|weight:|number:)/.test(v)) return null; + const fam = firstFamily(named.replace(/_/g, ' ')); + return !fam || GENERIC_FAMILIES.has(fam) ? null : fam; +} + +export interface TwColourLiteral { tok: string; oklch: boolean } +export interface TwShadow { v: string; tok: string } /** What one element's class string says. */ export interface TwInfo { classes: string[]; + /** The first bg-* fill utility. */ bg: string | null; + /** The first text-* colour utility. */ text: string | null; stops: TwStop[]; + /** Text clipped to a background and made transparent. */ gradientText: boolean; sizePx: number | null; trackingEm: number | null; @@ -25,25 +165,519 @@ export interface TwInfo { smallCaps: boolean; mono: boolean; variants: Set; + normalCase: boolean; + /** The first saturated palette text colour (text-indigo-600 and kin). */ + accent: string | null; + /** transition-all, or a transition naming a layout property. */ + tAll: string | null; + bounce: string | null; + overshoot: string | null; + hScale: string | null; + hRotate: string | null; + grad: boolean; + clip: boolean; + clear: boolean; + bblur: string | null; + glass: string | null; + blur: number; + blurTok: string | null; + pos: string | null; + fill: boolean; + shadows: TwShadow[]; + shadowCol: string | null; + vh: string | null; + oxHidden: string | null; + oxClip: boolean; + colours: TwColourLiteral[]; + font: string | null; + pal: string[]; + rounded: boolean; + cols3: boolean; + animate: string | null; + transform: boolean; + transition: boolean; + caret: boolean; + scrollbar: boolean; + hero: boolean; + padded: boolean; +} + +function twLiterals(b: string, tok: string, f: TwInfo): void { + for (const m of b.matchAll(/\[([^\]]+)\]/g)) { + const v = m[1].replace(/url\([^)]*\)/gi, ' ').replace(/_/g, ' '); + const fam = /^font-family:(.+)$/.exec(v); + if (fam && !/var\(/.test(fam[1]) && !GENERIC_FAMILIES.has(firstFamily(fam[1]))) f.font = f.font || tok; + for (const c of v.matchAll(TW_COLOR_LIT)) { + const col = parseColor(c[0]); + if (col && col.alpha > 0) f.colours.push({ tok, oklch: /^oklch/i.test(c[0]) }); + } + } +} + +interface TwAcc { sizeV: number | null; tracks: Array<{ r: TwTrack; plain: boolean }> } + +/** Case, size, tracking and family utilities; true when the token was one of them. */ +function twReadType(ctx: LintContext, b: string, tok: string, p: TwParsed, f: TwInfo, acc: TwAcc): boolean { + const plain = !p.variants.length; + if (b === 'uppercase') { f.upper = true; return true; } + if (b === 'normal-case') { if (plain) f.normalCase = true; return true; } + if (b === 'small-caps' || /^\[font-variant(?:-caps)?:[^\]]*(?:small|petite)-caps/.test(b) || /^\[font-feature-settings:[^\]]*(?:smcp|c2sc)/.test(b)) { f.smallCaps = true; return true; } + if (b.startsWith('text-')) { + const px = twSize(ctx, b); + if (px != null) { + if (plain) f.sizePx = px; else acc.sizeV = acc.sizeV == null ? px : Math.min(acc.sizeV, px); + return true; + } + if (b === 'text-transparent') { f.clear = true; return true; } + if (!/^text-(?:left|center|right|justify|start|end|wrap|nowrap|balance|pretty|ellipsis|clip)$/.test(b)) f.text = f.text || tok; + if (TW_ACCENT.test(b)) f.accent = f.accent || tok; + return true; + } + if (b.startsWith('tracking-')) { + const r = twTrack(ctx, b, p.neg); + if (r) acc.tracks.push({ r, plain }); + return true; + } + if (b === 'font-mono') { f.mono = true; return true; } + if (b.startsWith('font-')) { + const arb = /^font-\[(.+)\]$/.exec(b); + const fam = arb ? twFamily(arb[1]) : null; + if (fam) { f.font = f.font || tok; if (TW_MONO.test(fam)) f.mono = true; } + const named = !arb && /^font-([a-z][\w-]*)$/.exec(b); + const tv = named ? twTokens(ctx)[`--font-${named[1]}`] : undefined; + if (tv && /mono|courier|code/i.test(firstFamily(resolveVars(tv, twTokens(ctx))))) f.mono = true; + return true; + } + return false; +} + +/** Motion, gradient, surface, shadow, size and layout utilities. */ +function twReadSurface(ctx: LintContext, b: string, tok: string, p: TwParsed, f: TwInfo): void { + const hover = p.variants.includes('hover'); + if (b === 'transition-all' || /^transition-\[[^\]]*\b(?:all|width|height|top|left|right|bottom|inset)\b/.test(b) || /^\[transition(?:-property)?:[^\]]*\b(?:all|width|height|top|left)\b/.test(b)) { + f.tAll = f.tAll || tok; f.transition = true; + return; + } + if (/^transition(?:-transform)?$/.test(b)) { f.transition = true; if (b !== 'transition') f.transform = true; return; } + if (b.startsWith('animate-')) { + if (b !== 'animate-none') f.animate = f.animate || tok; + if (b === 'animate-bounce') f.bounce = f.bounce || tok; + return; + } + const ez = /^ease-\[cubic-bezier\(([^)]*)\)\]$/.exec(b); + if (ez) { + const n = ez[1].split(/[,_\s]+/).filter(Boolean).map(Number); + if (n.length === 4 && [n[1], n[3]].some((y) => y > 1.05 || y < -0.05)) f.overshoot = f.overshoot || tok; + return; + } + if (/^(?:scale|translate|rotate|skew)-/.test(b)) { + f.transform = true; + if (hover && /^scale-/.test(b) && !/^scale-(?:[xy]-)?100$/.test(b)) f.hScale = f.hScale || tok; + if (hover && /^rotate-/.test(b) && b !== 'rotate-0') f.hRotate = f.hRotate || tok; + return; + } + if (/^bg-(?:gradient-to-|linear-|radial|conic)|^bg-\[(?:repeating-)?(?:linear|radial|conic)-gradient/.test(b) || /^\[background(?:-image)?:[^\]]*gradient\(/.test(b)) f.grad = true; + const st = /^(?:from|via|to)-(.+)$/.exec(b); + if (st) { const s = twStop(ctx, st[1], tok); if (s) f.stops.push(s); return; } + if (b === 'bg-clip-text' || /^\[(?:-webkit-)?background-clip:text\]$/.test(b)) { f.clip = true; return; } + if (b === '[-webkit-text-fill-color:transparent]') { f.clear = true; return; } + if (/^backdrop-blur(?:-|$)/.test(b) || /^\[(?:-webkit-)?backdrop-filter:[^\]]*blur/.test(b)) { f.bblur = f.bblur || tok; return; } + const bl = /^blur-(?:(2xl|3xl)|\[(\d*\.?\d+)(px|rem)\])$/.exec(b); + if (bl) { + const px = bl[1] ? (bl[1] === '2xl' ? 40 : 64) : parseFloat(bl[2]) * (bl[3] === 'rem' ? 16 : 1); + if (px > f.blur) { f.blur = px; f.blurTok = tok; } + return; + } + if (b === 'absolute' || b === 'fixed') { f.pos = f.pos || b; return; } + if (b.startsWith('bg-')) { + const gm = TW_GLASS.exec(b); + if (gm) { + const v = gm[1].replace(/^\[|\]$/g, ''); + const a = v.endsWith('%') ? parseFloat(v) / 100 : v.includes('.') ? parseFloat(v) : parseFloat(v) / 100; + if (a < 0.95) f.glass = f.glass || tok; + } + if (!TW_NONFILL.test(b)) { f.fill = true; f.bg = f.bg || tok; } + return; + } + if (b.startsWith('shadow-')) { + const sm = /^shadow-\[(.+)\]$/.exec(b); + if (sm) f.shadows.push({ v: sm[1], tok }); + else if (!/^shadow-(?:2xs|xs|sm|md|lg|xl|2xl|inner|none)$/.test(b)) f.shadowCol = f.shadowCol || b.slice(7); + return; + } + if (/^(?:min-)?h-(?:screen|\[[^\]]*\d\s*vh[^\]]*\])$/.test(b)) { f.vh = f.vh || tok; return; } + if (b === 'overflow-x-hidden') { f.oxHidden = f.oxHidden || tok; return; } + if (b === 'overflow-x-clip' || b === 'overflow-clip') { f.oxClip = true; return; } + if (/^rounded(?:-|$)/.test(b)) { if (b !== 'rounded-none') f.rounded = true; return; } + if (b === 'grid-cols-3') { f.cols3 = true; return; } + if (/^p[xytrblse]?-/.test(b)) { f.padded = true; return; } + if (b.startsWith('caret-')) { f.caret = true; return; } + if (b.startsWith('scrollbar') || b.startsWith('[scrollbar-')) f.scrollbar = true; +} + +/** Read one class string into its facts; the variants it uses land in the shared set as well as on the info. */ +export function twInfo(ctx: LintContext, toks: string[], variants: Set): TwInfo { + const f: TwInfo = { + classes: toks, bg: null, text: null, stops: [], gradientText: false, sizePx: null, trackingEm: null, upper: false, smallCaps: false, mono: false, variants: new Set(), + normalCase: false, accent: null, tAll: null, bounce: null, overshoot: null, hScale: null, hRotate: null, grad: false, clip: false, clear: false, + bblur: null, glass: null, blur: 0, blurTok: null, pos: null, fill: false, shadows: [], shadowCol: null, vh: null, oxHidden: null, oxClip: false, + colours: [], font: null, pal: [], rounded: false, cols3: false, animate: null, transform: false, transition: false, caret: false, scrollbar: false, hero: false, padded: false, + }; + const acc: TwAcc = { sizeV: null, tracks: [] }; + for (const tok of toks) { + if (tok.includes('__')) continue; + const p = twParse(tok); + const b = p.base; + if (!b) continue; + for (const v of p.variants) { variants.add(v); f.variants.add(v); } + if (TW_PAL.test(b)) f.pal.push(b.replace(/\/.*$/, '')); + if (b.includes('[')) twLiterals(b, tok, f); + if (/hero/i.test(b)) f.hero = true; + if (!twReadType(ctx, b, tok, p, f, acc)) twReadSurface(ctx, b, tok, p, f); + } + if (f.sizePx == null) f.sizePx = acc.sizeV; + let emV: number | null = null; + for (const { r, plain } of acc.tracks) { + const em = r.em != null ? r.em : (r.px ?? 0) / (f.sizePx || 16); + if (plain) f.trackingEm = em; else emV = emV == null ? em : Math.max(emV, em); + } + if (f.trackingEm == null) f.trackingEm = emV; + f.gradientText = f.clip && f.clear; + return f; +} + +const TW_CLASS_ATTR = /(?:^|\s)(class|className|:class|v-bind:class|x-bind:class|class:list)\s*=\s*/g; + +const cutGlued = (seg: string, after: boolean, before: boolean): string => { + let s = seg; + if (after) s = s.replace(/^\S+/, ''); + if (before) s = s.replace(/\S+$/, ''); + return s; +}; + +/** Class tokens of a Svelte/Astro attribute value with {expressions} inside it, glued halves dropped. */ +function twInterp(val: string): string[] { + const parts: string[] = []; + let i = 0, seg = '', after = false; + while (i < val.length) { + if (val[i] === '{') { + parts.push(cutGlued(seg, after, true)); + const e = braceEnd(val, i); + parts.push(clsLits(val.slice(i + 1, e)).join(' ')); + i = e + 1; seg = ''; after = true; + continue; + } + seg += val[i++]; + } + parts.push(cutGlued(seg, after, false)); + return parts.join(' ').split(/\s+/).filter(Boolean); +} + +/** Every class token on a tag: the JSX scanner's record, or the class-bearing attributes of the raw tag text. */ +export function twTokensOf(doc: Doc, t: Tag): string[] { + if (doc.kind === 'jsx') return doc.classAt.get(t.pos) || []; + const tag = doc.raw.slice(t.pos, t.end); + if (!tag.includes('class')) return []; + const out: string[] = []; + for (const m of tag.matchAll(TW_CLASS_ATTR)) { + const i = (m.index as number) + m[0].length; + const q = tag[i]; + let val: string; + let dyn = m[1] !== 'class' && m[1] !== 'className'; + 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 d of tag.matchAll(/(?:^|\s)class:(?!list\b)([\w-]+)/g)) out.push(d[1]); + return out.filter(Boolean); +} + +/** What an element inherits from its ancestors: size, tracking, case, and the contexts that exempt it. */ +export interface TwFrame { + size: number | null; + em: number | null; + upper: boolean; + caps: boolean; + noLabel: boolean; + code: boolean; + eyeSkip: boolean; + inOl: boolean; + logo: boolean; + logoSelf: boolean; +} + +const TW_ROOT: TwFrame = { size: null, em: null, upper: false, caps: false, noLabel: false, code: false, eyeSkip: false, inOl: false, logo: false, logoSelf: false }; + +function twFrame(parent: TwFrame, t: { name: string; attrs: string }, f: TwInfo | null, toks: string[]): TwFrame { + const logo = toks.some((x) => TW_LOGO.test(x)) || TW_LOGO.test(getAttr(t.attrs, 'aria-label') || ''); + return { + size: f && f.sizePx != null ? f.sizePx : parent.size, + em: f && f.trackingEm != null ? f.trackingEm : parent.em, + upper: f ? f.upper || (!f.normalCase && parent.upper) : parent.upper, + caps: (f != null && f.smallCaps) || parent.caps, + noLabel: parent.noLabel || /^(?:svg|pre|code|abbr|kbd|samp)$/.test(t.name), + code: parent.code || /^(?:pre|code|kbd|samp)$/.test(t.name), + eyeSkip: parent.eyeSkip || EYEBROW_ANCESTOR_SKIP.test(t.name), + inOl: parent.inOl || t.name === 'ol', + logo: parent.logo || (t.name === 'a' && logo), + logoSelf: logo, + }; } export interface TwItem { file: string; line: number; - /** Tag index in the doc, or -1 for a helper-call site. */ + /** Tag index in the doc, or -1 for a component tag or a helper-call site. */ idx: number; + /** The tag name, the component name, or the helper's name for a call site. */ tag: string; info: TwInfo; + doc: Doc; + /** Index into doc.comps for a component tag, else null. */ + comp: number | null; + /** A cn()/clsx()/cva() call site rather than an element. */ + virtual: boolean; + attrs: string; + toks: string[]; + frame: TwFrame; + text: string | null; } -export interface TwGroup { items: TwItem[]; variants: Set } +export interface TwGroup { + items: TwItem[]; + variants: Set; + caret: boolean; + scrollbar: boolean; + /** The first element that animates or transitions a transform. */ + motion: TwItem | null; + /** An h1 or h2 set uppercase, which excuses uppercase controls. */ + upperDisplay: boolean; +} -/** Every class-bearing element and helper call in the context, parsed. */ +/** Every class-bearing element, component tag and helper call in the context, parsed. */ export function twGroup(ctx: LintContext): TwGroup { - return memo(ctx, 'tw', () => ({ items: [], variants: new Set() })); + return memo(ctx, 'tw', () => { + const tw: TwGroup = { items: [], variants: new Set(), caret: false, scrollbar: false, motion: null, upperDisplay: false }; + const add = (it: TwItem) => { + const f = it.info; + tw.items.push(it); + if (f.caret) tw.caret = true; + if (f.scrollbar) tw.scrollbar = true; + if (!tw.motion && (f.animate || (f.transition && f.transform))) tw.motion = it; + if (/^h[12]$/.test(it.tag) && f.upper) tw.upperDisplay = true; + }; + for (const doc of ctx.docs) { + const stack: TwFrame[] = []; + for (let i = 0; i < doc.tags.length; i++) { + const t = doc.tags[i]; + if (t.close) { stack.pop(); continue; } + const parent = stack.length ? stack[stack.length - 1] : TW_ROOT; + const toks = twTokensOf(doc, t); + const info = toks.length ? twInfo(ctx, toks, tw.variants) : null; + const frame = twFrame(parent, t, info, toks); + if (info) add({ file: doc.file, line: t.line, idx: i, tag: t.name, info, doc, comp: null, virtual: false, attrs: t.attrs, toks, frame, text: null }); + if (!t.selfClosing) stack.push(frame); + } + for (let c = 0; c < doc.comps.length; c++) { + const t = doc.comps[c]; + if (t.close) continue; + const toks = twTokensOf(doc, t); + if (!toks.length) continue; + const info = twInfo(ctx, toks, tw.variants); + add({ file: doc.file, line: t.line, idx: -1, tag: t.name, info, doc, comp: c, virtual: false, attrs: t.attrs, toks, frame: twFrame(TW_ROOT, t, info, toks), text: null }); + } + for (const v of doc.virtuals) { + const info = twInfo(ctx, v.toks, tw.variants); + add({ file: doc.file, line: doc.lineOf(v.pos), idx: -1, tag: v.name, info, doc, comp: null, virtual: true, attrs: '', toks: v.toks, frame: twFrame(TW_ROOT, { name: v.name, attrs: '' }, info, v.toks), text: null }); + } + } + return tw; + }); } /** The Tailwind variants (hover, focus-visible, motion-reduce …) used anywhere in the context. */ export function twVariants(ctx: LintContext): Set { return twGroup(ctx).variants; } + +/** Where a finding points: the helper call, or the element with the start of its class string. */ +export function twWhere(it: TwItem): string { + return it.virtual ? `${it.tag}() class string` : `<${it.tag} class="${trunc(it.toks.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++) { + 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); + } + return ''; +} + +/** Visible text of a fragment, with template expressions blanked in Vue, Svelte and Astro files. */ +export function twPlain(doc: Doc, html: string): string { + let s = html; + if (doc.kind !== 'html' && doc.kind !== 'jsx') for (let k = 0; k < 3 && /\{[^{}]*\}/.test(s); k++) s = s.replace(/\{[^{}]*\}/g, ' '); + return plainText(s); +} + +/** The element's visible text, read once. */ +export function twText(it: TwItem): string { + if (it.text == null) { + let inner = ''; + if (it.idx >= 0) inner = innerOf(it.doc, it.idx)?.inner || ''; + else if (it.comp != null) inner = compInner(it.doc, it.comp); + it.text = twPlain(it.doc, inner); + } + return it.text; +} + +const paperIsDark = (tokens: Tokens): boolean => { + const p = parseColor(resolveVars(tokens['--color-paper'] || '', tokens)); + return !!p && oklchOf(p).L < 0.4; +}; + +/** The banned ramp among a set of stop hues: purple to blue, purple to pink, or cyan on a dark paper. */ +export function twBannedHues(hues: number[], tokens: Tokens): string | null { + const purple = hues.some((h) => hueIn(h, 265, 330)); + const blue = hues.some((h) => hueIn(h, 200, 264)); + const pink = hues.some((h) => hueIn(h, 331, 25)); + const cyan = hues.some((h) => hueIn(h, 170, 230)); + if (purple && blue) return 'purple-to-blue'; + if (purple && pink) return 'purple-to-pink'; + if (cyan && paperIsDark(tokens)) return 'cyan-on-dark'; + return null; +} + +export interface TwGradient { banned: string | null; text: boolean; list: string } + +/** The gradient an element's stops describe: its banned pairing if any, whether it is text, and the stop classes. */ +export function twGradient(ctx: LintContext, f: TwInfo): TwGradient | null { + if (!f.stops.length || (!f.grad && f.stops.length < 2)) return null; + const names = f.stops.map((s) => s.pal).filter((p): p is string => p != null); + let banned: string | null = null; + for (const a of names) { + if (!TW_PURPLE.test(a)) continue; + if (names.some((x) => x !== a && TW_BLUE.test(x))) { banned = 'purple-to-blue'; break; } + if (names.some((x) => x !== a && TW_PINK.test(x))) { banned = 'purple-to-pink'; break; } + } + if (!banned) banned = twBannedHues(f.stops.map((s) => s.hue).filter((h): h is number => h != null), twTokens(ctx)); + return { banned, text: f.clip && f.clear, list: f.stops.map((s) => s.tok).join(' ') }; +} + +const lengthPx = (x: string): number | null => (x === '0' ? 0 : pxOf(x)); + +/** A zero-offset coloured shadow of 8px blur or more: a glow. */ +export function twGlow(ctx: LintContext, f: TwInfo): string | null { + const tokens = twTokens(ctx); + for (const s of f.shadows) { + for (const part of splitTop(resolveVars(s.v.replace(/_/g, ' '), tokens))) { + const cm = COLOR_LITERAL.exec(part); + const lens = (cm ? part.replace(cm[0], ' ') : part).replace(/\binset\b/, ' ').trim().split(/\s+/).filter(Boolean).map(lengthPx); + if (lens.length < 3 || lens[0] !== 0 || lens[1] !== 0 || lens[2] == null || lens[2] < 8) continue; + let chroma = 0; + if (cm) { + const c = parseColor(cm[0]); + if (c && c.alpha > 0.05) chroma = oklchOf(c).C; + } else if (f.shadowCol) { + const name = f.shadowCol.replace(/\/\S*$/, ''); + const pm = TW_SHADE.exec(name); + const tv = tokens[`--color-${name}`]; + chroma = pm ? (TW_GREY.test(pm[1]) ? 0 : 0.2) : tv ? (twHue(parseColor(resolveVars(tv, tokens).trim())) != null ? 0.2 : 0) : 0; + } + if (chroma > 0.04) return `glow: ${s.tok}${cm ? '' : ` with shadow-${f.shadowCol}`}`; + } + } + return null; +} + +/** Whether a mono face is one of the page's declared families, so font-mono labels are material, not costume. */ +export function twMonoIsMaterial(tokens: Tokens): boolean { + const mono = (v: string | undefined) => !!v && MONO_TOKEN.test(firstFamily(resolveVars(v, tokens))); + return mono(tokens['--font-display']) || mono(tokens['--font-body']) || mono(tokens['--font-outlier']); +} + +/** Text that reads as data (a version, a shortcut, a command, a path, a time): mono is material there. */ +export function twDataText(s: string): boolean { + const t = s.trim(); + const letters = (t.match(/[A-Za-z]/g) || []).length; + const digits = (t.match(/\d/g) || []).length; + if (digits >= letters) return true; + if (/^v?\d+(?:\.\d+)+/.test(t) || /[⌘⌥⇧⌃⎋↵]|^(?:ctrl|cmd|alt|shift|option)\s*[+-]/i.test(t)) return true; + if (/[{}();=<>]|^\$\s|^(?:npm|npx|pnpm|yarn|bun|git|curl|cd|ls|brew|pip|docker|node) |^--?[a-z]|[\w-]+\.[a-z][a-z0-9]{0,3}$|^[#.]?[a-z]+(?:[-_][a-z0-9]+)+$|^[a-z]+[A-Z]\w*$|^\/[\w/.-]*$|^https?:/.test(t)) return true; + return digits > 0 && /^(?:\d{1,2}:\d{2}|(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+\d)/i.test(t); +} + +export interface TwTrackedCaps { what: string; size: number | null; em: number | null } + +/** The tracked-caps label: small text set uppercase, small caps or letter-spaced past 0.03em, outside logos, code and display heads. */ +export function twTrackedCaps(it: TwItem, tw: TwGroup): TwTrackedCaps | null { + const f = it.info, fr = it.frame; + if (!(f.upper || f.smallCaps || (f.trackingEm != null && f.trackingEm > 0.03))) return null; + if (fr.logo || fr.logoSelf || fr.noLabel) return null; + const size = fr.size, em = fr.em; + if ((size != null && size >= 24) || (size == null && /^h[1-6]$/.test(it.tag))) return null; + const caps = fr.upper || fr.caps; + const small = size != null && size <= 15; + const tracked = em != null && em > 0.03; + const control = /^(?:button|input)$/.test(it.tag) || /(?:button|btn|cta)$/i.test(it.tag) || (it.tag === 'a' && f.fill && f.padded) || /role\s*=\s*["']?button/i.test(it.attrs); + if (tw.upperDisplay && control && (em == null || em <= 0.04)) return null; + if (!((caps && (small || tracked)) || (tracked && small))) return null; + return { what: fr.caps ? 'small caps' : fr.upper ? 'uppercase' : 'letter-spaced', size, em: tracked && em != null ? Math.round(em * 1000) / 1000 : null }; +} + +export interface TwEyebrow { text: string; next: string; signal: string } + +/** The eyebrow: a short, small, uppercase, tracked or accent-coloured line immediately before an h1 to h3. */ +export function twEyebrow(it: TwItem): TwEyebrow | null { + const f = it.info, fr = it.frame, doc = it.doc; + if (it.idx < 0 || !/^(?:p|span|div|small)$/.test(it.tag) || fr.eyeSkip) return null; + if (!(f.upper || (f.trackingEm != null && f.trackingEm > 0.03) || f.accent)) return null; + if (fr.size == null || fr.size > 14) return null; + const t = doc.tags[it.idx]; + if (/role\s*=\s*["']?status|aria-live|aria-current|aria-hidden\s*=\s*["']?true/i.test(t.attrs)) return null; + const c = closeIndex(doc, it.idx); + const nx = doc.tags[c + 1]; + if (!nx || nx.close || !/^h[1-3]$/.test(nx.name)) return null; + const inner = c > it.idx ? doc.src.slice(t.end, doc.tags[c].pos) : ''; + if (EYEBROW_BLOCKY.test(inner) || /]*\shref\s*=\s*["']([^"']*)["']/i.exec(inner); + if (href && !/^#?$/.test(href[1].trim())) return null; + const text = twPlain(doc, inner); + const signal = [f.upper ? 'uppercase' : null, f.trackingEm != null && f.trackingEm > 0.03 ? `tracked ${Math.round(f.trackingEm * 1000) / 1000}em` : null, f.accent].filter(Boolean).join(', '); + if (!text) return doc.kind !== 'html' && c > it.idx && doc.raw.slice(t.end, doc.tags[c].pos).includes('{') ? { text: '', next: nx.name, signal } : null; + const words = text.split(/[\s·/|\u2014\u2013]+/).filter(Boolean); + if (words.length > 7 || text.length > 56 || /[\w/-]+\.[a-z]{1,4}$/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 (fr.inOl && stripped && !EYEBROW_ANNOUNCE.test(stripped)) return null; + return { text, next: nx.name, signal }; +} + +/** How many direct children of a grid open on an icon alone in a rounded filled tile above an h3. */ +export function twTiles(it: TwItem, byIdx: Map): number { + const doc = it.doc; + let tiles = 0; + for (const k of directChildren(doc, it.idx)) { + if (!/]*class/.test(inner.inner) && !twPlain(doc, inner.inner)) { tiles++; break; } + } + } + return tiles; +} diff --git a/test/design-lint-tailwind.test.ts b/test/design-lint-tailwind.test.ts new file mode 100644 index 000000000..a125b6aa1 --- /dev/null +++ b/test/design-lint-tailwind.test.ts @@ -0,0 +1,358 @@ +/** + * The component family of the design lint: the Tailwind reader (class + * strings in HTML, JSX, Vue, Svelte and Astro, helper calls, shared tokens) + * and the rules it feeds, each with a negative control and a planted tell. + */ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { lint, formatFinding, type Finding } from '../lib/design-lint/index'; +import { lintStatic } from '../lib/design-lint/static'; +import { twGroup, twParse, twVariants } from '../lib/design-lint/tailwind'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const FIXTURES = path.join(ROOT, 'test', 'fixtures', 'design-lint'); + +const MY_RULES = ['layout-transition', 'bounce-easing', 'purple-blue-gradient', 'gradient-text', 'glow-wash', 'literal-outside-tokens', 'hover-tells', 'wide-tracking', 'kicker-above-heading', 'monospace-costume', 'framework-palette', 'vh-height', 'template-cards', 'template-punctuation', 'emoji-decoration']; + +function tmpDir(files: Record): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-tw-')); + for (const [name, text] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, name)), { recursive: true }); + fs.writeFileSync(path.join(dir, name), text); + } + return dir; +} + +async function lintFiles(files: Record, opts: { scope?: 'page' | 'component' } = {}): Promise { + const dir = tmpDir(files); + const result = await lint([dir], { cwd: dir, warn: () => {}, ...opts }); + return result.findings; +} + +const only = (findings: Finding[], rule: string) => findings.filter((f) => f.rule === rule); +const lines = (findings: Finding[], rule: string) => only(findings, rule).map((f) => f.line); +const mine = (findings: Finding[]) => findings.filter((f) => MY_RULES.includes(f.rule)).map(formatFinding); + +/** A page whose body line i (0-based) sits on line 5 + i. */ +const page = (body: string[], head = ''): string => ['', '', `Fieldnote${head}`, '', ...body, ''].join('\n'); +const BODY = 5; + +describe('design-lint tailwind: the reader', () => { + test('twParse splits variants, drops group-/peer- and /names, and reads the ! and - marks', () => { + expect(twParse('md:hover:bg-accent')).toEqual({ variants: ['md', 'hover'], base: 'bg-accent', neg: false }); + expect(twParse('group-hover/card:-translate-y-1')).toEqual({ variants: ['hover'], base: 'translate-y-1', neg: true }); + expect(twParse('!text-[length:var(--x)]')).toEqual({ variants: [], base: 'text-[length:var(--x)]', neg: false }); + expect(twParse('[&:nth-child(2)]:pt-0').variants).toEqual(['[&:nth-child(2)]']); + }); + + test('class strings are read from class, className, :class, class:list, class: directives and cn()/clsx()/cva() calls; variants are collected', async () => { + const dir = tmpDir({ + 'package.json': '{}', + 'Hero.tsx': [ + 'import { cva } from "cva";', + 'import { cn } from "./cn";', + 'const badge = cva("rounded-full text-xs uppercase tracking-widest");', + 'export function Hero({ on }: { on: boolean }) {', + ' const row = clsx("flex", on && "transition-all");', + ' return Go;', + '}', + ].join('\n'), + 'Card.vue': '', + 'Band.astro': '---\nconst x = 1;\n---\n
\n

Hi

', + }); + const { contexts } = lintStatic([dir], { cwd: dir, warn: () => {} }); + const ctx = contexts.find((c) => c.docs.length === 3)!; + const tw = twGroup(ctx); + const by = (tag: string) => tw.items.filter((i) => i.tag === tag).map((i) => i.toks.join(' ')); + expect(by('cva')).toEqual(['rounded-full text-xs uppercase tracking-widest']); + expect(by('clsx')).toEqual(['flex transition-all']); + expect(by('a')).toEqual(['px-3 hover:bg-accent focus-visible:outline-2']); + expect(by('div')).toEqual(['bg-paper disabled:opacity-50 active', 'motion-reduce:transition-none p-4']); + expect(by('p')).toEqual(['mt-2 text-sm tracking-wide']); + expect([...twVariants(ctx)].sort()).toEqual(['disabled', 'focus-visible', 'hover', 'motion-reduce']); + }); + + test('a component directory borrows the project tokens from a sibling globals.css @theme block', async () => { + const dir = tmpDir({ + 'package.json': '{}', + 'app/globals.css': '@import "tailwindcss";\n@theme {\n --color-paper: oklch(97% 0.01 80);\n --color-accent: oklch(55% 0.2 300);\n --text-label: 0.75rem;\n --tracking-label: 0.1em;\n}\n.btn:focus-visible { outline: 2px solid var(--color-accent); }', + 'components/Label.tsx': [ + 'export function Label() {', + ' return Field kit;', + '}', + 'export function Band() {', + ' return