From c0422db7f2d242ffe2018c796fee176a737d35af Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 26 Oct 2026 09:25:04 +0000 Subject: [PATCH] design-lint: fourth-cycle simplifications and the pixel pass pinned without a browser heroClasses reads the hero through heroIndexOf, check64 uses twMonoIsMaterial, check67Html imports capsRatio from chrome-eyebrow, sideStripes loses its bare block; a settle test drives confirmPixels with the swatch fixture (a claim dropped by the pixels behind it, one rewritten, a weak ring confirmed). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/design-lint/rules/structure.ts | 14 +++------- lib/design-lint/rules/type.ts | 16 +++-------- test/design-lint-fourth-cycle.test.ts | 39 +++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 22 deletions(-) diff --git a/lib/design-lint/rules/structure.ts b/lib/design-lint/rules/structure.ts index 05a834b32..3e0f446a3 100644 --- a/lib/design-lint/rules/structure.ts +++ b/lib/design-lint/rules/structure.ts @@ -74,9 +74,7 @@ function sideStripes(ctx: LintContext, r: Reporter): void { const parsed = col ? parseColor(col[0]) : null; const ok = parsed ? oklchOf(parsed) : null; if (parsed && ok && (parsed.alpha <= 0.05 || (ok.C < 0.04 && ok.L > 0.6))) continue; - { - r.report('template-cards', rule.file, d.line, `a ${m[1]}px coloured side stripe (${d.prop}: ${trunc(d.value, 32)}) on ${rule.selector}`, { fix: 'a hairline all round, no border, or an accent square' }); - } + r.report('template-cards', rule.file, d.line, `a ${m[1]}px coloured side stripe (${d.prop}: ${trunc(d.value, 32)}) on ${rule.selector}`, { fix: 'a hairline all round, no border, or an accent square' }); } } } @@ -109,13 +107,9 @@ function heroClasses(ctx: LintContext): Set { const out = new Set(); for (const doc of ctx.docs) { const h1 = doc.tags.findIndex((t) => !t.close && t.name === 'h1'); - if (h1 < 0) continue; - for (let i = h1 - 1; i >= 0; i--) { - const t = doc.tags[i]; - if (t.close || !/^(section|header)$/.test(t.name)) continue; - for (const c of (getAttr(t.attrs, 'class') || '').split(/\s+/)) if (c) out.add(c); - break; - } + const hero = h1 < 0 ? -1 : heroIndexOf(doc, h1); + if (hero < 0) continue; + for (const c of (getAttr(doc.tags[hero].attrs, 'class') || '').split(/\s+/)) if (c) out.add(c); } return out; } diff --git a/lib/design-lint/rules/type.ts b/lib/design-lint/rules/type.ts index 481d9e0cb..20524b1ca 100644 --- a/lib/design-lint/rules/type.ts +++ b/lib/design-lint/rules/type.ts @@ -11,6 +11,8 @@ import { GENERIC_FAMILIES, HEADING_SEL, LABEL_PX, MONO_FACE, TRACKED_EM, famKey, import { ancestorsOf, closeIndex, getAttr, innerOf, openTags, plainText, stripTags, textOf } from '../html'; import type { Reporter } from '../report'; import { trunc } from '../report'; +import { twMonoIsMaterial } from '../tailwind'; +import { capsRatio } from './chrome-eyebrow'; /** A selector without its pseudo-classes and pseudo-elements, whitespace folded. */ const baseSelector = (sel: string): string => sel.replace(/::?[a-z-]+(\([^)]*\))?/gi, '').replace(/\s+/g, ' ').trim(); @@ -223,15 +225,9 @@ function check55(ctx: LintContext, r: Reporter): void { } -// sloplint monoIsMaterial (gate 64): a monospace face declared as the display, body or outlier is the page's material, not a costume -function monoIsMaterial(ctx: LintContext): boolean { - const mono = (v: string | undefined) => !!v && MONO_FACE.test(firstFamily(resolveVars(v, ctx.tokens))); - return mono(ctx.tokens['--font-display']) || mono(ctx.tokens['--font-body']) || mono(ctx.tokens['--font-outlier']); -} - -// sloplint check64 (gate 64) +// sloplint check64 (gate 64); twMonoIsMaterial is sloplint's monoIsMaterial: a mono face declared as the display, body or outlier is the page's material, not a costume function check64(ctx: LintContext, r: Reporter): void { - if (monoIsMaterial(ctx)) return; + if (twMonoIsMaterial(ctx.tokens)) return; const monoFam = (v: string) => MONO_FACE.test(firstFamily(resolveVars(v, ctx.tokens))); const LABELISH = /label|caption|meta|eyebrow|kicker|tag\b|badge|chip|small|folio|footnote|legend|\bth\b|thead|figcaption|overline|dateline|byline|credit|note\b/i; const DATAISH = /code|pre\b|kbd|samp|num|figure|stat|price|value|data|tabular|counter|timer|clock|coord|amount|total|score|version|hash|\bid\b|sku|time|date|hour|utc|eta|lot\b|edition|ref\b|dimension|measure|reading|plate-?no|tag-?no|url|uri\b|path|endpoint|command|cmd|snippet|install/i; @@ -371,10 +367,6 @@ function check67(ctx: LintContext, r: Reporter): void { } const CAPS_TAGS = /^(p|span|small|a|li|div|figcaption|td|th|dt|dd|label|time|cite|strong|b|em|button|summary)$/; -const capsRatio = (s: string): number => { - const letters = s.replace(/[^A-Za-z]/g, ''); - return letters ? (letters.match(/[A-Z]/g) || []).length / letters.length : 0; -}; // sloplint check67Html (gate 67), the markup half: text typed in capitals at label size or tracked function check67Html(ctx: LintContext, r: Reporter): void { diff --git a/test/design-lint-fourth-cycle.test.ts b/test/design-lint-fourth-cycle.test.ts index f2bf73661..6ebc089b0 100644 --- a/test/design-lint-fourth-cycle.test.ts +++ b/test/design-lint-fourth-cycle.test.ts @@ -258,3 +258,42 @@ describe('design-lint fourth cycle: never-waivable classes refuse by class befor expect(applyWaivers(measured.findings, set).map((f) => [f.rule, f.grade])).toEqual([['nested-cards', 'blocks'], ['waiver', 'ask'], ['waiver', 'ask'], ['waiver', 'ask']]); }); }); + +describe('design-lint fourth cycle: the pixel pass with a readable screenshot', () => { + const PAGE = HEAD + '

t

Body.

Go
'; + const SWATCH = path.join(path.resolve(import.meta.dir, '..'), 'test', 'fixtures', 'design-lint', 'render', 'swatch.png'); + const SETUP = 1, FIRST = 2, FOCUS = 13; + + test('a contrast claim is dropped when the pixels behind it clear the threshold, rewritten when they confirm it, and a weak ring is confirmed from the pixels around it', async () => { + // swatch.png is 8x8, red on the left half and blue on the right; the page rects are in CSS pixels, scaled to the screenshot by its width + const dir = tree({ 'index.html': PAGE }); + const file = path.join(dir, 'index.html'); + const white = { rule: 'low-contrast', grade: 'fail', evidence: 'computed contrast 2.50:1 under the 4.5:1 threshold on h1 (2.50:1)', probe: { path: 'main>h1', fg: [1, 1, 1], threshold: 4.5 } }; + const grey = { rule: 'low-contrast', grade: 'fail', evidence: 'computed contrast 2.50:1 under the 4.5:1 threshold on p.faint (2.50:1)', probe: { path: 'main>p', fg: [120 / 255, 120 / 255, 120 / 255], threshold: 4.5 } }; + const ring = { key: 'a', path: 'main>a', at: { tag: 'a', text: 'Go' }, bare: false, ratio: 1.2, ring: [1, 1, 1], pad: 2 }; + const shots: string[] = []; + const run: Runner = async (spec: RenderSpec): Promise => { + const evals: Record = {}; + const shot = spec.steps.findIndex((s) => s.kind === 'screenshot'); + if (shot < 0) { + evals[SETUP] = '{"lost":[]}'; + for (let i = FIRST; i <= FOCUS; i++) evals[i] = '[]'; + evals[FIRST + 5] = JSON.stringify([white, grey]); + evals[FOCUS] = JSON.stringify([ring]); + return { ok: true, engine: 'browse', outputs: [], evals, stdout: '' }; + } + spec.steps.forEach((s, i) => { + if (s.kind !== 'screenshot') return; + fs.copyFileSync(SWATCH, s.out); + shots.push(path.basename(s.out)); + const width = s.width ?? 0, half = width / 2; + evals[i - 1] = s.out.endsWith('text.png') ? JSON.stringify([{ x: 0, y: 0, w: half, h: width }, { x: half, y: 0, w: half, h: width }]) : JSON.stringify([{ x: 0, y: 0, w: half, h: width }]); + }); + return { ok: true, engine: 'browse', outputs: [], evals, stdout: '' }; + }; + const r = await lintRender([file], { cwd: dir, run }); + expect(shots.sort()).toEqual(['ring.png', 'text.png']); + expect(only(r.findings, 'low-contrast').map((f) => f.evidence)).toEqual(['[1280px] contrast 2.29:1 against the pixels behind, under the 4.5:1 threshold on p.faint (2.29:1)']); + expect(only(r.findings, 'missing-focus-visible')).toEqual([]); + }); +});