mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-06 00:44:07 +01:00
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>
This commit is contained in:
1 parent
bd2a86fdf7
commit
c0422db7f2
3 files changed
+47
-22
No files matched your search
@@ -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<string> {
|
||||
const out = new Set<string>();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 + '</head><body><main><h1>t</h1><p class="faint">Body.</p><a href="#">Go</a></main></body></html>';
|
||||
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<RenderResult> => {
|
||||
const evals: Record<number, string> = {};
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user