Merge remote-tracking branch 'origin/feat/design-lint-tailwind' into feat/design-lint

This commit is contained in:
garrytan committed 2026-10-26 05:50:06 +00:00
commit dfd6693d51
3 files changed
+1098 -12

No files matched your search

+98 -4
View File
@@ -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<string, TwItem>();
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<number, TwItem>();
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<string, TwItem[]>();
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);
}
+642 -8
View File
@@ -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<string, number> = { 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<string, number> = { tighter: -0.05, tight: -0.025, normal: 0, wide: 0.025, wider: 0.05, widest: 0.1 };
const TW_HUE: Record<string, number> = { 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<string, TwParsed>();
/** 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<string>;
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<string>): 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<string>(),
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<string> }
export interface TwGroup {
items: TwItem[];
variants: Set<string>;
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<string>() }));
return memo(ctx, 'tw', () => {
const tw: TwGroup = { items: [], variants: new Set<string>(), 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<string> {
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) || /<br\b|<time\b/i.test(inner)) return null;
const href = /<a\b[^>]*\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, TwItem>): number {
const doc = it.doc;
let tiles = 0;
for (const k of directChildren(doc, it.idx)) {
if (!/<h3\b/i.test(k.html)) continue;
const end = closeIndex(doc, k.idx);
for (let j = k.idx + 1; j < end; j++) {
const t = doc.tags[j];
if (t.close) continue;
if (/^h[1-6]$/.test(t.name)) break;
const ti = byIdx.get(j);
if (!ti || !ti.info.rounded || !ti.info.fill) continue;
const inner = innerOf(doc, j);
if (inner && /<svg\b|<[A-Z][\w.]*Icon\b|<Icon\b|<i\b[^>]*class/.test(inner.inner) && !twPlain(doc, inner.inner)) { tiles++; break; }
}
}
return tiles;
}
+358
View File
@@ -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, string>): 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<string, string>, opts: { scope?: 'page' | 'component' } = {}): Promise<Finding[]> {
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 => ['<!doctype html>', '<html lang="en">', `<head><meta charset="utf-8"><title>Fieldnote</title>${head}</head>`, '<body>', ...body, '</body></html>'].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 <a className={cn("px-3", on ? "hover:bg-accent focus-visible:outline-2" : "")}>Go</a>;',
'}',
].join('\n'),
'Card.vue': '<template>\n <div :class="{ \'motion-reduce:transition-none\': true }" class="p-4"></div>\n</template>',
'Band.astro': '---\nconst x = 1;\n---\n<div class:list={["bg-paper", { "disabled:opacity-50": true }]} class:active={x}></div>\n<p class="mt-2 {x ? \'text-sm\' : \'\'} tracking-wide">Hi</p>',
});
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 <span className="text-label tracking-label">Field kit</span>;',
'}',
'export function Band() {',
' return <div aria-hidden="true" className="h-2 bg-linear-to-r from-accent to-sky-500" />;',
'}',
].join('\n'),
});
const result = await lint([dir], { cwd: dir, warn: () => {} });
expect(lines(result.findings, 'wide-tracking')).toEqual([2]);
expect(only(result.findings, 'wide-tracking')[0].evidence).toContain('letter-spaced at 12px tracked 0.1em');
expect(lines(result.findings, 'purple-blue-gradient')).toEqual([5]);
expect(only(result.findings, 'purple-blue-gradient')[0].evidence).toContain('purple-to-blue gradient (from-accent to-sky-500)');
});
});
describe('design-lint tailwind: motion rules', () => {
test('layout-transition: transition-all and a transition naming a layout property block; transition-colors does not', async () => {
const f = await lintFiles({ 'index.html': page([
'<a href="/a" class="rounded-md px-3 transition-colors">Fine</a>',
'<a href="/b" class="rounded-md px-3 transition-all">Everything</a>',
'<div class="transition-[width,height]">Grows</div>',
]) });
expect(only(f, 'layout-transition').map((x) => [x.line, x.grade])).toEqual([[BODY + 1, 'blocks'], [BODY + 2, 'blocks']]);
expect(only(f, 'layout-transition')[0].evidence).toBe('transition-all on <a class="rounded-md px-3 transition-all">');
});
test('bounce-easing: animate-bounce and an overshooting cubic-bezier ask; animate-pulse does not', async () => {
const f = await lintFiles({ 'index.html': page([
'<div aria-hidden="true" class="h-2 w-2 animate-pulse"></div>',
'<div aria-hidden="true" class="h-2 w-2 animate-bounce"></div>',
'<button type="button" class="px-3 transition-transform ease-[cubic-bezier(0.34,1.56,0.64,1)]">Go</button>',
]) });
expect(only(f, 'bounce-easing').map((x) => [x.line, x.grade])).toEqual([[BODY + 1, 'ask'], [BODY + 2, 'ask']]);
expect(only(f, 'bounce-easing')[1].evidence).toContain('overshoot easing ease-[cubic-bezier(0.34,1.56,0.64,1)]');
});
test('hover-tells: an image that scales on hover, or hover scale on three elements in one file; a colour change does not', async () => {
const clean = await lintFiles({ 'index.html': page([
'<a href="/a" class="px-3 hover:bg-accent">One</a>',
'<div class="p-4 hover:scale-105">A</div><div class="p-4 hover:scale-105">B</div>',
'<img src="a.jpg" alt="The harbour" class="rounded-lg hover:opacity-90">',
]) });
expect(mine(clean)).toEqual([]);
const f = await lintFiles({ 'index.html': page([
'<img src="a.jpg" alt="The harbour" class="rounded-lg hover:rotate-2">',
'<div class="p-4 hover:scale-105">A</div>',
'<div class="p-4 hover:scale-105">B</div>',
'<div class="p-4 hover:scale-105">C</div>',
]) });
expect(only(f, 'hover-tells').map((x) => [x.line, x.grade, x.evidence])).toEqual([
[BODY, 'ask', 'hover:rotate-2 on <img class="rounded-lg hover:rotate-2">: the image moves on hover'],
[BODY + 3, 'ask', 'hover scale on 3 elements in this file (hover:scale-105, hover:scale-105, hover:scale-105)'],
]);
});
});
describe('design-lint tailwind: colour and surface rules', () => {
test('purple-blue-gradient: purple-to-blue and purple-to-pink palette ramps block, as does cyan on a dark paper; an amber ramp does not', async () => {
const dark = '<style>:root { --color-paper: oklch(18% 0.02 270); }</style>';
const f = await lintFiles({ 'index.html': page([
'<div aria-hidden="true" class="h-2 bg-gradient-to-r from-amber-400 to-orange-600"></div>',
'<div aria-hidden="true" class="h-2 bg-gradient-to-r from-purple-500 to-blue-500"></div>',
'<div aria-hidden="true" class="h-2 bg-gradient-to-r from-violet-500 via-fuchsia-500 to-pink-500"></div>',
'<div aria-hidden="true" class="h-2 bg-gradient-to-r from-cyan-400 to-cyan-600"></div>',
], dark) });
expect(only(f, 'purple-blue-gradient').map((x) => [x.line, x.grade])).toEqual([[BODY + 1, 'blocks'], [BODY + 2, 'blocks'], [BODY + 3, 'blocks']]);
expect(only(f, 'purple-blue-gradient').map((x) => x.evidence.split(' gradient')[0])).toEqual(['purple-to-blue', 'purple-to-pink', 'cyan-on-dark']);
});
test('gradient-text: a ramp clipped to transparent text asks; solid ink and a banned ramp (reported as the gradient) do not', async () => {
const f = await lintFiles({ 'index.html': page([
'<h1 class="text-5xl text-ink">Logbooks your crew will keep</h1>',
'<h1 class="text-5xl"><span class="bg-gradient-to-r from-amber-400 to-orange-600 bg-clip-text text-transparent">Field notes</span></h1>',
'<h2 class="text-3xl bg-gradient-to-r from-purple-500 to-blue-500 bg-clip-text text-transparent">Banned</h2>',
]) });
expect(only(f, 'gradient-text').map((x) => [x.line, x.grade])).toEqual([[BODY + 1, 'ask']]);
expect(only(f, 'gradient-text')[0].evidence).toBe('gradient text (from-amber-400 to-orange-600) on <span class="bg-gradient-to-r from-amber-400…">');
expect(only(f, 'purple-blue-gradient')[0].evidence).toContain('purple-to-blue gradient text');
});
test('glow-wash: glass (backdrop blur over a translucent fill), a blurred filled orb and a zero-offset coloured shadow ask; an offset grey shadow does not', async () => {
const f = await lintFiles({ 'index.html': page([
'<div class="rounded-lg shadow-lg">Card</div>',
'<div class="rounded-lg shadow-[0_4px_12px_rgba(0,0,0,0.2)]">Card</div>',
'<nav class="sticky top-0 bg-white/10 backdrop-blur-md px-6">Nav</nav>',
'<div aria-hidden="true" class="absolute h-72 w-72 rounded-full bg-fuchsia-500 blur-3xl"></div>',
'<button type="button" class="px-4 shadow-[0_0_40px_oklch(62%_0.25_300)]">Preview</button>',
'<button type="button" class="px-4 shadow-[0_0_24px] shadow-violet-500">Glow by class</button>',
]) });
expect(only(f, 'glow-wash').map((x) => [x.line, x.grade])).toEqual([[BODY + 2, 'ask'], [BODY + 3, 'ask'], [BODY + 4, 'ask'], [BODY + 5, 'ask']]);
expect(only(f, 'glow-wash').map((x) => x.evidence.split(' on <')[0])).toEqual([
'glass: backdrop-blur-md over bg-white/10',
'blurred orb: blur-3xl on a filled absolute element',
'glow: shadow-[0_0_40px_oklch(62%_0.25_300)]',
'glow: shadow-[0_0_24px] with shadow-violet-500',
]);
});
test('literal-outside-tokens: a hex colour or a font family in a class blocks, an oklch literal lands at ask for a person to confirm; var() and url() do not fire', async () => {
const f = await lintFiles({ 'index.html': page([
'<div class="bg-[var(--color-well)] bg-[url(/paper.png)] font-[family-name:var(--font-display)]">Fine</div>',
'<p class="bg-[#0b0b12] p-4">Hex</p>',
'<p class="bg-[oklch(62%_0.25_300)] p-4">Oklch</p>',
"<p class=\"font-['Inter'] p-4\">Family</p>",
]) });
const byLine = only(f, 'literal-outside-tokens').sort((a, b) => a.line - b.line);
expect(byLine.map((x) => [x.line, x.grade])).toEqual([[BODY + 1, 'blocks'], [BODY + 2, 'ask'], [BODY + 3, 'blocks']]);
expect(byLine[2].evidence).toBe("font-family literal font-['Inter'] in a class on <p class=\"font-['Inter'] p-4\">");
});
test('framework-palette: five distinct palette classes in one file ask; four do not', async () => {
const four = await lintFiles({ 'index.html': page(['<section class="bg-slate-950 text-slate-100 border-slate-800"><a href="/x" class="text-sky-400">Go</a></section>']) });
expect(only(four, 'framework-palette')).toEqual([]);
const f = await lintFiles({ 'index.html': page([
'<section class="bg-slate-950 text-slate-100 border-slate-800">',
'<a href="/x" class="text-sky-400 ring-indigo-500/50">Go</a>',
'</section>',
]) });
expect(only(f, 'framework-palette').map((x) => [x.line, x.grade])).toEqual([[BODY + 1, 'ask']]);
expect(only(f, 'framework-palette')[0].evidence).toStartWith("a framework default palette instead of the page's tokens: 5 distinct palette classes (bg-slate-950 text-slate-100");
});
});
describe('design-lint tailwind: type rules', () => {
test('wide-tracking: small uppercase or tracked text blocks; a display head, a logo and a tracked line at 16px without caps do not', async () => {
const f = await lintFiles({ 'index.html': page([
'<h1 class="text-5xl uppercase tracking-tight">Field notes</h1>',
'<a href="/" aria-label="Fieldnote logo" class="text-xs uppercase tracking-widest">FN</a>',
'<p class="text-base tracking-wide">Sixteen pixels, lightly tracked</p>',
'<span class="text-xs uppercase tracking-widest">Field kit</span>',
'<div class="text-sm"><small class="uppercase">Inherited size</small></div>',
'<p class="text-[11px] [font-variant-caps:small-caps]">Small caps label</p>',
]) });
expect(only(f, 'wide-tracking').map((x) => [x.line, x.grade])).toEqual([[BODY + 3, 'blocks'], [BODY + 4, 'blocks'], [BODY + 5, 'blocks']]);
expect(only(f, 'wide-tracking').map((x) => x.evidence)).toEqual([
'"Field kit" uppercase at 12px tracked 0.1em on <span class="text-xs uppercase tracking-wide…">',
'"Inherited size" uppercase at 14px on <small class="uppercase">',
'"Small caps label" small caps at 11px on <p class="text-[11px] [font-variant-caps:…">',
]);
});
test('kicker-above-heading: a small uppercase, tracked or accent line right before an h1-h3 asks; a plain lede, a dated line and a component scope do not', async () => {
const body = [
'<section>',
'<p class="text-sm text-muted">A plain line above the head</p>',
'<h2 class="text-3xl">One log per watch</h2>',
'<p class="text-sm uppercase">Features</p>',
'<h2 class="text-3xl">What the crew gets</h2>',
'<p class="text-sm text-indigo-600">Fewer handoffs</p>',
'<h3 class="text-xl">Tide tables</h3>',
'<p class="text-sm uppercase"><time datetime="2026-10-04">4 October</time></p>',
'<h2 class="text-3xl">Dated, so not an eyebrow</h2>',
'</section>',
];
const f = await lintFiles({ 'index.html': page(body) });
expect(only(f, 'kicker-above-heading').map((x) => [x.line, x.grade])).toEqual([[BODY + 3, 'ask'], [BODY + 5, 'ask']]);
expect(only(f, 'kicker-above-heading').map((x) => x.evidence)).toEqual([
'eyebrow "Features" before <h2> (uppercase at 14px)',
'eyebrow "Fewer handoffs" before <h3> (text-indigo-600 at 14px)',
]);
expect(only(await lintFiles({ 'index.html': page(body) }, { scope: 'component' }), 'kicker-above-heading')).toEqual([]);
});
test('monospace-costume: font-mono on a small prose label asks; code, data text, a large line and a page whose body face is mono do not', async () => {
const f = await lintFiles({ 'index.html': page([
'<code class="font-mono text-xs">bun run lint</code>',
'<p class="font-mono text-xs">v1.2.3</p>',
'<p class="font-mono text-base">Sixteen pixels is not a label</p>',
'<p class="font-mono text-xs">Updated weekly by the crew</p>',
]) });
expect(only(f, 'monospace-costume').map((x) => [x.line, x.grade])).toEqual([[BODY + 3, 'ask']]);
expect(only(f, 'monospace-costume')[0].evidence).toBe('font-mono on "Updated weekly by the c…" at 12px (<p class="font-mono text-xs">)');
const material = await lintFiles({ 'index.html': page(['<p class="font-mono text-xs">Updated weekly by the crew</p>'], '<style>:root { --font-body: "JetBrains Mono", monospace; }</style>') });
expect(only(material, 'monospace-costume')).toEqual([]);
});
});
describe('design-lint tailwind: structure rules', () => {
test('vh-height: min-h-screen on the first section or a hero blocks; min-h-dvh and a screen-tall inner panel do not', async () => {
const clean = await lintFiles({ 'index.html': page([
'<section class="min-h-dvh px-6">Hero</section>',
'<section class="px-6"><div class="h-screen overflow-auto">A tall panel, not the hero</div></section>',
]) });
expect(only(clean, 'vh-height')).toEqual([]);
const f = await lintFiles({ 'index.html': page([
'<section class="relative min-h-screen px-6">Hero</section>',
'<div class="hero min-h-[100vh]">Also the hero</div>',
]) });
expect(only(f, 'vh-height').map((x) => [x.line, x.grade, x.evidence])).toEqual([
[BODY, 'blocks', 'hero sized in vh: min-h-screen on <section class="relative min-h-screen px-6">'],
[BODY + 1, 'blocks', 'hero sized in vh: min-h-[100vh] on <div class="hero min-h-[100vh]">'],
]);
});
test('template-cards: three grid children opening on an icon in a rounded filled tile above an h3 ask; tiles with inline icons do not', async () => {
const tile = (icon: string) => `<div class="rounded-xl p-6">${icon}<h3 class="mt-4 font-semibold">Tide tables</h3><p class="mt-2">Every reading on one chart.</p></div>`;
const coin = '<div class="flex h-10 w-10 rounded-lg bg-indigo-600"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 12h16"/></svg></div>';
const inline = '<svg aria-hidden="true" viewBox="0 0 24 24" class="h-5 w-5"><path d="M4 12h16"/></svg>';
const clean = await lintFiles({ 'index.html': page([`<div class="grid md:grid-cols-3">${tile(inline)}${tile(inline)}${tile(inline)}</div>`]) });
expect(only(clean, 'template-cards')).toEqual([]);
const f = await lintFiles({ 'index.html': page(['<h2>What the crew gets</h2>', `<div class="grid gap-8 md:grid-cols-3">${tile(coin)}${tile(coin)}${tile(coin)}</div>`]) });
expect(only(f, 'template-cards').map((x) => [x.line, x.grade])).toEqual([[BODY + 1, 'ask']]);
expect(only(f, 'template-cards')[0].evidence).toStartWith('icon-tile row: 3 children of <div class="grid gap-8 md:grid-cols-3"> open on an icon in a rounded filled tile');
});
});
describe('design-lint tailwind: component text', () => {
const tsx = (jsx: string[]) => ['import { Link, Badge, Meta } from "./ui";', 'export function Page() {', ' return (', ' <section>', ...jsx.map((l) => ' ' + l), ' </section>', ' );', '}'].join('\n');
const JSX = 5;
test('template-punctuation: an arrow glued to a link label and a middle-dot meta string in a component block; a verb label and one dot do not', async () => {
const clean = await lintFiles({ 'package.json': '{}', 'Page.tsx': tsx(['<Link href="/docs">Read the docs</Link>', '<Meta>3 min read · Design</Meta>']) });
expect(only(clean, 'template-punctuation')).toEqual([]);
const f = await lintFiles({ 'package.json': '{}', 'Page.tsx': tsx(['<Link href="/docs">Read more →</Link>', '<Meta>3 min · Oct 4 · Design</Meta>']) });
expect(only(f, 'template-punctuation').map((x) => [x.line, x.grade, x.evidence])).toEqual([
[JSX, 'blocks', 'arrow glued to the label "Read more →" (<Link>)'],
[JSX + 1, 'blocks', 'middle-dot meta string "3 min · Oct 4 · Design"'],
]);
});
test('emoji-decoration: an emoji alone inside a component blocks; a word does not', async () => {
const clean = await lintFiles({ 'package.json': '{}', 'Page.tsx': tsx(['<Badge>New</Badge>']) });
expect(only(clean, 'emoji-decoration')).toEqual([]);
const f = await lintFiles({ 'package.json': '{}', 'Page.tsx': tsx(['<Badge>✨</Badge>']) });
expect(only(f, 'emoji-decoration').map((x) => [x.line, x.grade, x.evidence])).toEqual([[JSX, 'blocks', 'emoji icon "✨" in <Badge>']]);
});
test('component files: cn()/clsx()/cva() in a .tsx, :class in a .vue and class:list in an .astro are read for the same tells', async () => {
const f = await lintFiles({
'package.json': '{}',
'Hero.tsx': [
'import { cva } from "cva";',
'import { cn } from "./cn";',
'const badge = cva("rounded-full px-2 text-xs uppercase tracking-widest", { variants: { tone: { loud: "font-semibold" } } });',
'export function Hero({ on }: { on: boolean }) {',
' return <a href="/x" className={cn("rounded-md px-3", on && "transition-all")}>Go</a>;',
'}',
].join('\n'),
'Feature.vue': '<template>\n <div aria-hidden="true" :class="{ \'animate-bounce\': pulse }" class="h-4 w-4"></div>\n</template>',
'Band.astro': '---\nconst on = true;\n---\n<div aria-hidden="true" class:list={["h-2 bg-gradient-to-r", on && "from-purple-500 to-blue-500"]}></div>',
});
expect(only(f, 'wide-tracking').map((x) => [path.basename(x.file), x.line, x.evidence])).toEqual([['Hero.tsx', 3, 'uppercase at 12px tracked 0.1em on cva() class string']]);
expect(only(f, 'layout-transition').map((x) => [path.basename(x.file), x.line])).toEqual([['Hero.tsx', 5]]);
expect(only(f, 'bounce-easing').map((x) => [path.basename(x.file), x.line])).toEqual([['Feature.vue', 2]]);
expect(only(f, 'purple-blue-gradient').map((x) => [path.basename(x.file), x.line])).toEqual([['Band.astro', 4]]);
});
});
describe('design-lint tailwind: fixtures', () => {
test('tailwind-fail: every utility tell planted in the fixture is found at its line', async () => {
const result = await lint([path.join(FIXTURES, 'tailwind-fail')], { cwd: ROOT, warn: () => {} });
const got = result.findings.filter((f) => MY_RULES.includes(f.rule)).map((f) => `${f.grade} ${f.rule} ${path.basename(f.file)}:${f.line}`).sort();
expect(got).toEqual([
'ask bounce-easing Feature.vue:7', 'ask bounce-easing Hero.tsx:29', 'ask bounce-easing index.html:39',
'ask framework-palette Hero.tsx:22', 'ask framework-palette index.html:30',
'ask glow-wash Feature.vue:8', 'ask glow-wash index.html:16', 'ask glow-wash index.html:25', 'ask glow-wash index.html:37',
'ask gradient-text index.html:30',
'ask hover-tells Feature.vue:5', 'ask hover-tells index.html:59', 'ask hover-tells index.html:69',
'ask kicker-above-heading Hero.tsx:17', 'ask kicker-above-heading index.html:27',
'ask monospace-costume Hero.tsx:21', 'ask monospace-costume index.html:33',
'ask template-cards index.html:48',
'blocks layout-transition Feature.vue:6', 'blocks layout-transition Hero.tsx:22', 'blocks layout-transition index.html:35',
'blocks literal-outside-tokens Hero.tsx:26', 'blocks literal-outside-tokens index.html:71',
'blocks purple-blue-gradient Feature.vue:8', 'blocks purple-blue-gradient Hero.tsx:18', 'blocks purple-blue-gradient index.html:43',
'blocks vh-height Hero.tsx:16', 'blocks vh-height index.html:23',
'blocks wide-tracking Feature.vue:4', 'blocks wide-tracking Hero.tsx:6', 'blocks wide-tracking index.html:19',
].sort());
});
test('tailwind-pass and the clean page raise nothing from the component family', async () => {
for (const fixture of ['tailwind-pass', 'clean']) {
const result = await lint([path.join(FIXTURES, fixture)], { cwd: ROOT, warn: () => {} });
expect(mine(result.findings)).toEqual([]);
}
});
});