design-lint: name the tracked-caps thresholds once, string-aware brace scan, drop an unused parameter, clean up test tmpdirs

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-26 07:18:23 +00:00
1 parent 31682440fe
commit 2bf81ad1f1
13 files changed
+68 -25

No files matched your search

+8 -2
View File
@@ -90,6 +90,10 @@ function nestSelector(sel: string, parent: string): string {
/** Faces that read as monospace, whether they arrive as a token, a utility or a declaration; one list so every check classifies a face the same way. */
export const MONO_FACE = /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;
/** The tracked-caps label: tracking past this (em) on text at or under LABEL_PX reads as the reflex, in CSS and in Tailwind alike. */
export const TRACKED_EM = 0.03;
export const LABEL_PX = 15;
/** Real stylesheets nest a handful of levels; past this the rest reads as the parent's body instead of recursing. */
const MAX_NESTING = 64;
@@ -125,8 +129,10 @@ export function parseCss(source: string, file: string, lineOffset = 0): CssRule[
const blockStart = j + 1;
let depth = 1, k = blockStart;
while (k < end && depth > 0) {
if (src[k] === '{') depth++;
else if (src[k] === '}') depth--;
const ch = src[k];
if (ch === '"' || ch === "'") { const q = src.indexOf(ch, k + 1); k = q < 0 || q >= end ? end : q + 1; continue; }
if (ch === '{') depth++;
else if (ch === '}') depth--;
k++;
}
const blockEnd = Math.max(blockStart, k - 1);
+2 -2
View File
@@ -47,7 +47,7 @@ function reportItem(ctx: LintContext, r: Reporter, it: TwItem, monoOk: boolean):
}
/** 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 {
function reportFile(r: Reporter, list: TwItem[]): void {
const doc = list[0].doc;
const pal = new Map<string, TwItem>();
let palAt: TwItem | null = null;
@@ -78,7 +78,7 @@ function checkTailwind(ctx: LintContext, r: Reporter): void {
byDoc.get(it.file)!.push(it);
reportItem(ctx, r, it, monoOk);
}
for (const list of byDoc.values()) reportFile(ctx, r, list);
for (const list of byDoc.values()) reportFile(r, list);
}
// sloplint checkComponentText (gates 63 30a)
+6 -6
View File
@@ -7,7 +7,7 @@
import { OVERUSED_FONTS_DISPLAY } from '../../design-catalog';
import type { LintContext } from '../context';
import { fontPx, mergedRules, styleFor, trackingEm, valueOf } from '../context';
import { MONO_FACE, famKey, firstFamily, GENERIC_FAMILIES, HEADING_SEL, isTokenRule, pxOf, resolveVars, type CssDecl, type CssRule, type MergedRule } from '../css';
import { GENERIC_FAMILIES, HEADING_SEL, LABEL_PX, MONO_FACE, TRACKED_EM, famKey, firstFamily, isTokenRule, pxOf, resolveVars, type CssDecl, type CssRule, type MergedRule } from '../css';
import { ancestorMap, getAttr, innerOf, plainText } from '../html';
import type { Reporter } from '../report';
import { trunc } from '../report';
@@ -272,7 +272,7 @@ function caps67(ctx: LintContext, bySel: MergedRule[], rule: MergedRule): Caps67
const ownSmallCaps = rule.decls.some((d) => isSmallCaps67(d.prop, resolveVars(d.value, ctx.tokens)));
const px = fontSizePxOf(rule, ctx);
const ownEm = trackingEm(ctx, rule.decls, px || 16);
if (!(ownUpper === true || ownSmallCaps || (ownEm != null && ownEm > 0.03))) return null;
if (!(ownUpper === true || ownSmallCaps || (ownEm != null && ownEm > TRACKED_EM))) return null;
const upper = ownUpper != null ? ownUpper : isUpper(inherited67(bySel, rule.selector, 'text-transform'));
const smallCaps = ownSmallCaps || ['font-variant', 'font-variant-caps', 'font-feature-settings'].some((prop) => {
const v = inherited67(bySel, rule.selector, prop);
@@ -298,7 +298,7 @@ function check67(ctx: LintContext, r: Reporter): void {
const caps = caps67(ctx, bySel, rule);
if (!caps) continue;
const { upper, smallCaps, em, px } = caps;
const tracked = em != null && em > 0.03;
const tracked = em != null && em > TRACKED_EM;
const subject = rule.selector.split(',').map((p) => baseSelector(p).split(/[\s>+~]+/).filter(Boolean).pop() || '');
const bigToken = rule.decls.some((d) => d.prop === 'font-size' && /--text-(display|section|title)|clamp\(\s*(?:[2-9]|\d{2,})(?:\.\d+)?rem/.test(d.value));
const labelish = subject.some((s) => LABEL67.test(s)) ? !((px != null && px >= 28) || bigToken)
@@ -307,7 +307,7 @@ function check67(ctx: LintContext, r: Reporter): void {
if (HEADING_SEL.test(rule.selector) && !labelish && !control) continue;
const key = baseSelector(rule.selector);
if (seen.has(key)) continue;
const small = px != null && px <= 15;
const small = px != null && px <= LABEL_PX;
const at = px != null ? ` at ${Math.round(px)}px` : '';
const tracking = tracked ? ` tracked ${em3(em)}em` : '';
if (control && !labelish) {
@@ -352,8 +352,8 @@ function check67Html(ctx: LintContext, r: Reporter): void {
const decls = styleFor(ctx, cls, t.name);
const px = fontPx(ctx, decls);
const em = trackingEm(ctx, decls, px || 16);
const tracked = em != null && em > 0.03;
if (!((px != null && px <= 15) || tracked)) continue;
const tracked = em != null && em > TRACKED_EM;
if (!((px != null && px <= LABEL_PX) || tracked)) continue;
const key = cls || t.name;
if (seen.has(key)) continue;
seen.add(key);
+6 -6
View File
@@ -12,7 +12,7 @@
import type { LintContext } from './context';
import { memo } from './context';
import { COLOR_LITERAL, hueIn, oklchOf, parseColor, type Color } from './color';
import { MONO_FACE, GENERIC_FAMILIES, firstFamily, pxOf, resolveVars, splitTop, type Tokens } from './css';
import { GENERIC_FAMILIES, LABEL_PX, MONO_FACE, TRACKED_EM, 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';
@@ -619,13 +619,13 @@ export interface TwTrackedCaps { what: string; size: number | null; em: number |
/** 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 (!(f.upper || f.smallCaps || (f.trackingEm != null && f.trackingEm > TRACKED_EM))) 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 small = size != null && size <= LABEL_PX;
const tracked = em != null && em > TRACKED_EM;
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;
@@ -638,7 +638,7 @@ export interface TwEyebrow { text: string; next: string; signal: string }
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 (!(f.upper || (f.trackingEm != null && f.trackingEm > TRACKED_EM) || 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;
@@ -650,7 +650,7 @@ export function twEyebrow(it: TwItem): TwEyebrow | 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(', ');
const signal = [f.upper ? 'uppercase' : null, f.trackingEm != null && f.trackingEm > TRACKED_EM ? `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;
+5 -1
View File
@@ -3,7 +3,7 @@
* for every rule id, one page that is clean for it and one with the tell
* planted, checked by id, grade and line.
*/
import { describe, expect, test } from 'bun:test';
import { afterAll, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
@@ -11,6 +11,9 @@ import { lint } from '../lib/design-lint/index';
import { formatFinding, type Finding } from '../lib/design-lint/report';
import { lintStatic } from '../lib/design-lint/static';
const tmpDirs: string[] = [];
afterAll(() => { for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true }); });
const ROOT = path.resolve(import.meta.dir, '..');
const FIXTURES = path.join(ROOT, 'test', 'fixtures', 'design-lint');
@@ -28,6 +31,7 @@ const lineOf = (html: string, needle: string): number => {
function run(html: string, files: Record<string, string> = {}): Finding[] {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-assets-'));
tmpDirs.push(dir);
fs.writeFileSync(path.join(dir, 'index.html'), html);
for (const [name, text] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), text);
return lintStatic([dir], { cwd: dir, warn: (l) => { throw new Error(l); } }).findings;
+5 -1
View File
@@ -3,7 +3,7 @@
* chrome-copy.ts, chrome-eyebrow.ts): for every rule id, one page that is
* clean for it and one with the tell planted, checked by id, grade and line.
*/
import { describe, expect, test } from 'bun:test';
import { afterAll, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
@@ -11,6 +11,9 @@ import { lint } from '../lib/design-lint/index';
import { formatFinding, type Finding } from '../lib/design-lint/report';
import { lintStatic } from '../lib/design-lint/static';
const tmpDirs: string[] = [];
afterAll(() => { for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true }); });
const ROOT = path.resolve(import.meta.dir, '..');
const FIXTURES = path.join(ROOT, 'test', 'fixtures', 'design-lint');
@@ -29,6 +32,7 @@ const lineOf = (html: string, needle: string): number => {
function run(html: string, opts: { job?: string | null; scope?: 'page' | 'component'; files?: Record<string, string> } = {}): Finding[] {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-chrome-'));
tmpDirs.push(dir);
const { files = {}, ...rest } = opts;
fs.writeFileSync(path.join(dir, 'index.html'), html);
for (const [name, text] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), text);
+5 -1
View File
@@ -1,10 +1,13 @@
import { describe, expect, test } from 'bun:test';
import { afterAll, 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 { formatFinding, type Finding } from '../lib/design-lint/report';
import { lintStatic } from '../lib/design-lint/static';
const tmpDirs: string[] = [];
afterAll(() => { for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true }); });
const ROOT = path.resolve(import.meta.dir, '..');
const FIXTURES = path.join(ROOT, 'test', 'fixtures', 'design-lint');
@@ -31,6 +34,7 @@ const page = (css: string, body = '<h1>Ledger</h1><p>The books close on the firs
function lintPage(html: string, name = 'index.html'): { html: string; findings: Finding[]; dir: string } {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-color-'));
tmpDirs.push(dir);
const file = path.join(dir, name);
fs.writeFileSync(file, html);
return { html, dir, findings: lintStatic([file], { cwd: dir, warn: (l) => { throw new Error(l); } }).findings };
+5 -1
View File
@@ -4,7 +4,7 @@
* planted, checked by id, grade and line. Everything here prints at polish
* except fake-avatar, which blocks.
*/
import { describe, expect, test } from 'bun:test';
import { afterAll, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
@@ -12,6 +12,9 @@ import { lint } from '../lib/design-lint/index';
import { formatFinding, type Finding } from '../lib/design-lint/report';
import { lintStatic } from '../lib/design-lint/static';
const tmpDirs: string[] = [];
afterAll(() => { for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true }); });
const ROOT = path.resolve(import.meta.dir, '..');
const FIXTURES = path.join(ROOT, 'test', 'fixtures', 'design-lint');
@@ -29,6 +32,7 @@ const lineOf = (html: string, needle: string): number => {
function run(html: string, files: Record<string, string> = {}): Finding[] {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-finish-'));
tmpDirs.push(dir);
fs.writeFileSync(path.join(dir, 'index.html'), html);
for (const [name, text] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), text);
return lintStatic([dir], { cwd: dir, warn: (l) => { throw new Error(l); } }).findings;
+5 -1
View File
@@ -3,12 +3,15 @@
* planted positive with its line and grade, and a negative control at the edge of the threshold. The shared fixtures
* (slopped.html, clean/) are checked for this family's rules too.
*/
import { describe, expect, test } from 'bun:test';
import { afterAll, 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 { formatFinding, lint, type Finding } from '../lib/design-lint/index';
const tmpDirs: string[] = [];
afterAll(() => { for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true }); });
const ROOT = path.resolve(import.meta.dir, '..');
const FIXTURES = path.join(ROOT, 'test', 'fixtures', 'design-lint');
const FAMILY = ['layout-transition', 'spacing-transition', 'hover-tells', 'bounce-easing', 'focus-ring-fade', 'tooltip-focus-delay', 'autorotate-no-pause', 'missing-focus-visible', 'missing-states', 'missing-reduced-motion', 'autoplay-unmuted', 'lazy-lcp', 'video-without-poster', 'input-state-shift', 'control-height', 'content-hidden-at-rest'];
@@ -24,6 +27,7 @@ function page(css: string, body: string, opts: { states?: boolean; reduce?: bool
async function lintPage(html: string, opts: { scope?: 'page' | 'component' } = {}): Promise<Finding[]> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-motion-'));
tmpDirs.push(dir);
const file = path.join(dir, 'index.html');
fs.writeFileSync(file, html);
return (await lint([file], { cwd: dir, scope: opts.scope, warn: () => {} })).findings;
+5 -1
View File
@@ -3,12 +3,15 @@
* positive with its line and grade, and a negative control at the edge of the threshold. The shared fixtures
* (slopped.html, clean/) are checked for this family's rules too.
*/
import { describe, expect, test } from 'bun:test';
import { afterAll, 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 { formatFinding, lint, type Finding } from '../lib/design-lint/index';
const tmpDirs: string[] = [];
afterAll(() => { for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true }); });
const ROOT = path.resolve(import.meta.dir, '..');
const FIXTURES = path.join(ROOT, 'test', 'fixtures', 'design-lint');
const FAMILY = ['template-cards', 'nested-cards', 'vh-height', 'raw-spacing', 'off-scale-spacing', 'overflow-x-unclipped', 'wide-container', 'bare-fr-track', 'display-head-unwrapped', 'radio-tab-jump', 'double-sticky', 'screen-word-budget'];
@@ -24,6 +27,7 @@ function page(css: string, body: string, opts: { clip?: boolean } = {}): string
async function lintPage(html: string, opts: { scope?: 'page' | 'component'; name?: string } = {}): Promise<Finding[]> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-structure-'));
tmpDirs.push(dir);
const file = path.join(dir, opts.name ?? 'index.html');
fs.writeFileSync(file, html);
return (await lint([file], { cwd: dir, scope: opts.scope, warn: () => {} })).findings;
+5 -1
View File
@@ -3,7 +3,7 @@
* 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 { afterAll, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
@@ -11,6 +11,9 @@ 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 tmpDirs: string[] = [];
afterAll(() => { for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true }); });
const ROOT = path.resolve(import.meta.dir, '..');
const FIXTURES = path.join(ROOT, 'test', 'fixtures', 'design-lint');
@@ -18,6 +21,7 @@ const MY_RULES = ['layout-transition', 'bounce-easing', 'purple-blue-gradient',
function tmpDir(files: Record<string, string>): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-tw-'));
tmpDirs.push(dir);
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);
+5 -1
View File
@@ -1,10 +1,13 @@
import { describe, expect, test } from 'bun:test';
import { afterAll, 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 { formatFinding, type Finding } from '../lib/design-lint/report';
import { lintStatic } from '../lib/design-lint/static';
const tmpDirs: string[] = [];
afterAll(() => { for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true }); });
const ROOT = path.resolve(import.meta.dir, '..');
const FIXTURES = path.join(ROOT, 'test', 'fixtures', 'design-lint');
@@ -34,6 +37,7 @@ const page = (css: string, body = '<h1>Ledger</h1><p>The books close on the firs
function lintPage(html: string, name = 'index.html'): { html: string; findings: Finding[]; dir: string } {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-type-'));
tmpDirs.push(dir);
const file = path.join(dir, name);
fs.writeFileSync(file, html);
return { html, dir, findings: lintStatic([file], { cwd: dir, warn: (l) => { throw new Error(l); } }).findings };
+6 -1
View File
@@ -4,7 +4,7 @@
* (test/design-lint-<family>.test.ts); the render tier is
* test/design-lint-render.test.ts.
*/
import { describe, expect, test } from 'bun:test';
import { afterAll, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
@@ -19,6 +19,9 @@ import { RULES, RULE_IDS } from '../lib/design-lint/rules/ids';
import { FAMILIES } from '../lib/design-lint/rules';
import { findWaivers, reasonIsBoilerplate, resolveWaivers, applyWaivers, suspectedRules } from '../lib/design-lint/waivers';
const tmpDirs: string[] = [];
afterAll(() => { for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true }); });
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin', 'gstack-design-lint');
const FIXTURES = path.join(ROOT, 'test', 'fixtures', 'design-lint');
@@ -30,6 +33,7 @@ function runBin(args: string[], cwd = ROOT) {
function tmpPage(html: string, name = 'index.html'): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-'));
tmpDirs.push(dir);
const file = path.join(dir, name);
fs.writeFileSync(file, html);
return file;
@@ -38,6 +42,7 @@ function tmpPage(html: string, name = 'index.html'): string {
/** A temporary tree: each key is a relative path (parent directories are created), each value the file's text. */
function tmpTree(files: Record<string, string>): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-tree-'));
tmpDirs.push(dir);
for (const [rel, text] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), text);