mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-12 19:44:48 +01:00
Merge remote-tracking branch 'origin/feat/design-lint-chrome-assets-finish' into feat/design-lint
This commit is contained in:
commit
69a7ec0e82
9 files changed
+1777
-8
No files matched your search
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* The assets rule family of the design lint (lib/design-lint/rules/assets.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 * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { lint } from '../lib/design-lint/index';
|
||||
import { formatFinding, type Finding } from '../lib/design-lint/report';
|
||||
import { lintStatic } from '../lib/design-lint/static';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const FIXTURES = path.join(ROOT, 'test', 'fixtures', 'design-lint');
|
||||
|
||||
const TOKENS = ':root { --color-paper: oklch(97% 0.01 80); --color-ink: oklch(20% 0.02 250); --color-accent: oklch(50% 0.16 30); --color-accent-ink: oklch(98% 0.01 30); --color-muted: oklch(50% 0.02 250); --font-display: "Fraunces", serif; --font-body: "Public Sans", sans-serif; }';
|
||||
|
||||
function page(css: string, body: string): string {
|
||||
return '<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8"><title>Harbour office</title><meta name="description" content="A test page."><link rel="icon" href="data:,">\n<style>\n' + TOKENS + '\n' + css + '\n</style>\n</head>\n<body>\n' + body + '\n</body>\n</html>\n';
|
||||
}
|
||||
|
||||
const lineOf = (html: string, needle: string): number => {
|
||||
const at = html.indexOf(needle);
|
||||
if (at < 0) throw new Error('needle not in page: ' + needle);
|
||||
return html.slice(0, at).split('\n').length;
|
||||
};
|
||||
|
||||
function run(html: string, files: Record<string, string> = {}): Finding[] {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-assets-'));
|
||||
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;
|
||||
}
|
||||
|
||||
const only = (findings: Finding[], rule: string) => findings.filter((f) => f.rule === rule);
|
||||
const show = (findings: Finding[]) => findings.map(formatFinding);
|
||||
|
||||
describe('design-lint assets: icons (gate 30)', () => {
|
||||
test('emoji-decoration blocks on an emoji standing in for an icon, in a tile, a heading or a component string; an SVG icon is clean', () => {
|
||||
const slop = page('', '<main>\n<div class="feature__icon">\u2728</div>\n<h3>\ud83d\ude80 Ship faster</h3>\n<p>\ud83d\udc4b Welcome aboard</p>\n</main>');
|
||||
const hits = only(run(slop), 'emoji-decoration');
|
||||
expect(hits.map((f) => [f.grade, f.line])).toEqual([['blocks', lineOf(slop, 'feature__icon')], ['blocks', lineOf(slop, 'Ship faster')], ['blocks', lineOf(slop, 'Welcome aboard')]]);
|
||||
expect(hits[0].evidence).toBe('an emoji icon "\u2728" in <div class="feature__icon">');
|
||||
expect(hits[2].evidence).toContain('text opens with an emoji');
|
||||
const tsx = 'export const Icon = () => <Feature icon="\u2728" title="Sync" />;\n';
|
||||
expect(only(run(page('', '<main><p>Ready.</p></main>'), { 'Feature.tsx': tsx }), 'emoji-decoration').map((f) => [f.file, f.evidence])).toEqual([['Feature.tsx', 'an emoji passed as an icon: \u2728']]);
|
||||
expect(only(run(page('', '<main><div class="feature__icon"><svg aria-hidden="true" viewBox="0 0 16 16"><path d="M0 0h16v16H0z"/></svg></div><h3>Ship faster</h3></main>')), 'emoji-decoration')).toEqual([]);
|
||||
});
|
||||
|
||||
test('two-icon-libraries asks when two libraries share a page; one library is clean', () => {
|
||||
const mixed = page('', '<main>\n<i class="lucide lucide-check"></i>\n<svg class="heroicon" aria-hidden="true"></svg>\n</main>');
|
||||
const hits = only(run(mixed), 'two-icon-libraries');
|
||||
expect(hits.map((f) => [f.grade, f.line, f.evidence])).toEqual([['ask', 1, 'icon libraries mixed on one page: heroicons and lucide']]);
|
||||
expect(only(run(page('', '<main><i class="lucide lucide-check" aria-hidden="true"></i><i class="lucide lucide-x" aria-hidden="true"></i></main>')), 'two-icon-libraries')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint assets: decoration labels (gate 33)', () => {
|
||||
test('unlabeled-decoration blocks on a bare <svg> or <canvas>; aria-hidden, a label, a <title> or a labelled ancestor is clean', () => {
|
||||
const slop = page('', '<main>\n<svg viewBox="0 0 10 10"><circle r="4"/></svg>\n<canvas width="10" height="10"></canvas>\n</main>');
|
||||
const hits = only(run(slop), 'unlabeled-decoration');
|
||||
expect(hits.map((f) => [f.grade, f.line, f.evidence])).toEqual([
|
||||
['blocks', lineOf(slop, '<svg'), '<svg> with neither aria-hidden="true" nor a label'],
|
||||
['blocks', lineOf(slop, '<canvas'), '<canvas> with neither aria-hidden="true" nor a label'],
|
||||
]);
|
||||
const clean = page('', '<main><svg aria-hidden="true" viewBox="0 0 10 10"></svg><svg viewBox="0 0 10 10"><title>The depot</title></svg><a href="/x" aria-label="Home"><svg viewBox="0 0 10 10"></svg></a><canvas role="img" aria-label="Days to close"></canvas></main>');
|
||||
expect(show(only(run(clean), 'unlabeled-decoration'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint assets: contrast (gates 40, 41)', () => {
|
||||
test('low-contrast lands at ask (the static tier suspects, the render tier proves) for text under its threshold; large text at 3:1 and body text at 4.5:1 are clean', () => {
|
||||
const slop = page('.muted {\n color: #999;\n background: #fff;\n}\n.big { font-size: 32px; color: #767676; background: #fff; }', '<main><p class="muted">Faint</p><p class="big">Large</p></main>');
|
||||
const hits = only(run(slop), 'low-contrast');
|
||||
expect(hits.map((f) => [f.tier, f.grade, f.line])).toEqual([['ask', 'ask', lineOf(slop, 'color: #999')]]);
|
||||
expect(hits[0].evidence).toBe('2.85:1 under the 4.5:1 threshold on .muted');
|
||||
expect(only(run(page('.body { color: #595959; background: #fff; }', '<main><p class="body">Readable</p></main>')), 'low-contrast')).toEqual([]);
|
||||
});
|
||||
|
||||
test('gray-on-color lands at ask for button text within a step of its fill, a dark surface that sets no text colour and a missing accent ink; a paired ink is clean', () => {
|
||||
const slop = page('.btn {\n color: #7a7a7a;\n background-color: #777;\n}\n.panel {\n background: #222;\n padding: 1rem;\n}', '<main><button class="btn">Go</button><section class="panel"><p>Dark</p></section></main>');
|
||||
const hits = only(run(slop), 'gray-on-color');
|
||||
expect(hits.map((f) => [f.tier, f.grade, f.line])).toEqual([['ask', 'ask', lineOf(slop, 'color: #7a7a7a')], ['ask', 'ask', lineOf(slop, 'background: #222')]]);
|
||||
expect(hits[0].evidence).toMatch(/^button text within a step of its fill on \.btn/);
|
||||
expect(hits[1].evidence).toBe('a dark surface (lightness 25%) on .panel sets no text colour');
|
||||
const noInk = page('.btn { background: var(--color-accent); color: white; }', '<main><button class="btn">Go</button></main>').replace('--color-accent-ink: oklch(98% 0.01 30); ', '');
|
||||
expect(only(run(noInk), 'gray-on-color').map((f) => f.evidence)).toEqual(['the accent fills a surface but --color-accent-ink is not defined']);
|
||||
expect(only(run(page('.btn { background: var(--color-accent); color: var(--color-accent-ink); } .panel { background: var(--color-ink); color: var(--color-paper); }', '<main><button class="btn">Go</button><section class="panel"><p>Dark</p></section></main>')), 'gray-on-color')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint assets: labels that wrap (gate 49)', () => {
|
||||
test('button-label-wraps lands at ask for a long button or primary nav label with no nowrap; link-wraps asks for a long footer link; nowrap and short labels are clean', () => {
|
||||
const slop = page('', '<nav><a class="nav__link" href="/reports">Download the complete quarterly report</a></nav>\n<main>\n<button>Download the complete quarterly report</button>\n</main>\n<footer>\n<a href="/privacy">Read the complete privacy and cookie policy</a>\n</footer>');
|
||||
const findings = run(slop);
|
||||
const buttons = only(findings, 'button-label-wraps');
|
||||
expect(buttons.map((f) => [f.tier, f.grade, f.line])).toEqual([['ask', 'ask', lineOf(slop, '<nav>')], ['ask', 'ask', lineOf(slop, '<button>')]]);
|
||||
expect(buttons[1].evidence).toMatch(/^the long label "Download the complete quarterly rep…" on <button> can wrap, and nothing sets nowrap$/);
|
||||
const links = only(findings, 'link-wraps');
|
||||
expect(links.map((f) => [f.grade, f.line])).toEqual([['ask', lineOf(slop, 'href="/privacy"')]]);
|
||||
const clean = run(page('button, .nav__link { white-space: nowrap; }', '<nav><a class="nav__link" href="/reports">Download the complete quarterly report</a></nav><main><button>Download the complete quarterly report</button></main><footer><a href="/privacy">Privacy</a></footer>'));
|
||||
expect(only(clean, 'button-label-wraps')).toEqual([]);
|
||||
expect(only(clean, 'link-wraps')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint assets: images (gate 76)', () => {
|
||||
test('broken-image blocks on an <img> with no source; srcset or data-src counts as a source', () => {
|
||||
const slop = page('', '<main>\n<img alt="The depot">\n<img src="#" alt="Also empty">\n</main>');
|
||||
const hits = only(run(slop), 'broken-image');
|
||||
expect(hits.map((f) => [f.grade, f.line, f.evidence])).toEqual([['blocks', lineOf(slop, '<img alt'), 'an <img> with no source']]);
|
||||
expect(only(run(page('', '<main><img src="depot.jpg" alt="The depot"><img srcset="a.jpg 1x" alt="Lazy"></main>')), 'broken-image')).toEqual([]);
|
||||
});
|
||||
|
||||
test('stock-photo-hotlink blocks on an Unsplash+ photo and lands at ask for a free stock hotlink; a local photograph is clean', () => {
|
||||
const slop = page('', '<main>\n<img src="https://plus.unsplash.com/premium_photo-1661 ?w=800" alt="Paid">\n<img src="https://images.pexels.com/photos/12345/pexels-photo.jpeg" alt="Free">\n</main>');
|
||||
const hits = only(run(slop), 'stock-photo-hotlink').sort((a, b) => a.line - b.line);
|
||||
expect(hits.map((f) => [f.tier, f.grade, f.line])).toEqual([['blocks', 'blocks', lineOf(slop, 'premium_photo')], ['ask', 'ask', lineOf(slop, 'pexels')]]);
|
||||
expect(hits[0].evidence).toBe('an Unsplash+ photo hotlinked, which needs a paid licence');
|
||||
expect(hits[1].evidence).toBe('a photo hotlinked from Pexels, whose terms bar images picked by an AI tool');
|
||||
expect(only(run(page('', '<main><img src="/img/depot-1280.jpg" alt="The depot"></main>')), 'stock-photo-hotlink')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint assets: fixtures', () => {
|
||||
test('the clean page raises nothing from this family; slopped.html plants the emoji icon and the bare svg', async () => {
|
||||
const assets = new Set(['emoji-decoration', 'two-icon-libraries', 'unlabeled-decoration', 'low-contrast', 'gray-on-color', 'button-label-wraps', 'link-wraps', 'broken-image', 'stock-photo-hotlink']);
|
||||
const clean = await lint([path.join(FIXTURES, 'clean')], { cwd: ROOT });
|
||||
expect(show(clean.findings.filter((f) => assets.has(f.rule)))).toEqual([]);
|
||||
const slopped = await lint([path.join(FIXTURES, 'slopped.html')], { cwd: ROOT });
|
||||
expect(only(slopped.findings, 'emoji-decoration').map((f) => [f.grade, f.line, f.evidence])).toEqual([['blocks', 126, 'an emoji icon "\u2728" in <div class="feature__icon">']]);
|
||||
expect(only(slopped.findings, 'unlabeled-decoration').map((f) => [f.grade, f.line])).toEqual([['blocks', 114]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* The chrome rule family of the design lint (lib/design-lint/rules/chrome.ts,
|
||||
* 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 * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { lint } from '../lib/design-lint/index';
|
||||
import { formatFinding, type Finding } from '../lib/design-lint/report';
|
||||
import { lintStatic } from '../lib/design-lint/static';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const FIXTURES = path.join(ROOT, 'test', 'fixtures', 'design-lint');
|
||||
|
||||
const TOKENS = ':root { --color-paper: oklch(97% 0.01 80); --color-ink: oklch(20% 0.02 250); --color-accent: oklch(50% 0.16 30); --color-accent-ink: oklch(98% 0.01 30); --color-muted: oklch(50% 0.02 250); --font-display: "Fraunces", serif; --font-body: "Public Sans", sans-serif; }';
|
||||
|
||||
/** A page with the css in one <style> and the body as given; the tokens block keeps the other families quiet where it can. */
|
||||
function page(css: string, body: string): string {
|
||||
return '<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8"><title>Harbour office</title><meta name="description" content="A test page."><link rel="icon" href="data:,">\n<style>\n' + TOKENS + '\n' + css + '\n</style>\n</head>\n<body>\n' + body + '\n</body>\n</html>\n';
|
||||
}
|
||||
|
||||
const lineOf = (html: string, needle: string): number => {
|
||||
const at = html.indexOf(needle);
|
||||
if (at < 0) throw new Error('needle not in page: ' + needle);
|
||||
return html.slice(0, at).split('\n').length;
|
||||
};
|
||||
|
||||
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-'));
|
||||
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);
|
||||
return lintStatic([dir], { cwd: dir, warn: (l) => { throw new Error(l); }, ...rest }).findings;
|
||||
}
|
||||
|
||||
const only = (findings: Finding[], rule: string) => findings.filter((f) => f.rule === rule);
|
||||
const show = (findings: Finding[]) => findings.map(formatFinding);
|
||||
|
||||
describe('design-lint chrome: copy (gate 19)', () => {
|
||||
test('aphoristic-cadence: the rebuttal cadence three times blocks; once is voice', () => {
|
||||
const tic = page('', '<main>\n<p>Ship it. Not a feature. A platform. Fast. Not a tool. A system. Clean. Not a demo. A product.</p>\n</main>');
|
||||
const hits = only(run(tic), 'aphoristic-cadence');
|
||||
expect(show(hits)).toHaveLength(1);
|
||||
expect(hits[0].grade).toBe('blocks');
|
||||
expect(hits[0].line).toBe(lineOf(tic, 'Not a feature'));
|
||||
expect(hits[0].evidence).toContain('3 times');
|
||||
expect(only(run(page('', '<main><p>Not a feature. A platform. The ledger closes on the second of the month.</p></main>')), 'aphoristic-cadence')).toEqual([]);
|
||||
});
|
||||
|
||||
test('generic-hero-copy: a filler opener, the quiet-proof line and the Meet heading block; plain prose is clean', () => {
|
||||
const slop = page('', '<main>\n<h1>Built for the modern team</h1>\n<p>Quietly trusted by the people who ship.</p>\n<h2>Meet Ledgerline</h2>\n</main>');
|
||||
const hits = only(run(slop), 'generic-hero-copy');
|
||||
expect(hits.map((f) => [f.grade, f.line])).toEqual([['blocks', lineOf(slop, 'Built for')], ['blocks', lineOf(slop, 'Quietly')], ['blocks', lineOf(slop, 'Meet ')]]);
|
||||
expect(hits[0].evidence).toContain('a filler opener');
|
||||
expect(only(run(page('', '<main><h1>October closed in two days</h1><p>Halvorsen Freight reconciles 1,140 invoices a month.</p></main>')), 'generic-hero-copy')).toEqual([]);
|
||||
});
|
||||
|
||||
test('generic-cta-copy: the stock second action beside a filled primary asks, and so do two "Learn more" links; a second action that names its destination is clean', () => {
|
||||
const css = '.btn { background: var(--color-accent); color: var(--color-accent-ink); } .cta-quiet { color: var(--color-ink); }';
|
||||
const stock = page(css, '<section class="hero">\n<h1>October closed in two days</h1>\n<a class="btn" href="/book">Book the walkthrough</a>\n<a class="cta-quiet" href="/tour">Learn more</a>\n</section>');
|
||||
const hits = only(run(stock), 'generic-cta-copy');
|
||||
expect(show(hits)).toHaveLength(1);
|
||||
expect(hits[0].grade).toBe('ask');
|
||||
expect(hits[0].line).toBe(lineOf(stock, 'Learn more'));
|
||||
const links = page(css, '<main>\n<p><a href="/a">Read more</a></p>\n<p><a href="/b">Read more</a></p>\n</main>');
|
||||
const linkHits = only(run(links), 'generic-cta-copy');
|
||||
expect(linkHits.map((f) => f.line)).toEqual([lineOf(links, '<a href="/a">')]);
|
||||
expect(linkHits[0].evidence).toContain('on 2 links');
|
||||
const named = page(css, '<section class="hero"><h1>October closed in two days</h1><a class="btn" href="/book">Book the walkthrough</a><a class="cta-quiet" href="/method">Read how the close works</a></section>');
|
||||
expect(only(run(named), 'generic-cta-copy')).toEqual([]);
|
||||
});
|
||||
|
||||
test('em-dash-overuse: three dashes ask with the count; two are not overuse', () => {
|
||||
const dashy = page('', '<main>\n<p>The close ran in two days \u2014 not nine \u2014 and the review \u2013 the slow part \u2014 started on the first.</p>\n</main>');
|
||||
const hits = only(run(dashy), 'em-dash-overuse');
|
||||
expect(show(hits)).toHaveLength(1);
|
||||
expect(hits[0].grade).toBe('ask');
|
||||
expect(hits[0].line).toBe(lineOf(dashy, 'two days'));
|
||||
expect(hits[0].evidence).toMatch(/^4 dashes/);
|
||||
expect(only(run(page('', '<main><p>The close ran in two days \u2014 not nine \u2014 and the review started on the first.</p></main>')), 'em-dash-overuse')).toEqual([]);
|
||||
});
|
||||
|
||||
test('template-footer-line: "All rights reserved" asks; the page\'s own footer line is clean', () => {
|
||||
const stock = page('', '<main><h1>Ledgerline</h1></main>\n<footer>\n<p>© 2026 Ledgerline. All rights reserved.</p>\n</footer>');
|
||||
const hits = only(run(stock), 'template-footer-line');
|
||||
expect(hits.map((f) => [f.grade, f.line])).toEqual([['ask', lineOf(stock, 'All rights')]]);
|
||||
expect(only(run(page('', '<footer><p>Ledgerline, Bergen. Photographs by Halvorsen Freight.</p></footer>')), 'template-footer-line')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint chrome: the reflex nav and footer (gate 42)', () => {
|
||||
test('template-nav: four to six inline links, a button and a 1px hairline ask; three links and no hairline are clean', () => {
|
||||
const css = 'nav { display: flex; border-bottom: 1px solid var(--color-muted); }';
|
||||
const reflex = page(css, '<nav class="nav">\n<a href="/">Ledgerline</a><a href="/method">Method</a><a href="/pricing">Pricing</a><a href="/customers">Customers</a><a href="/docs">Docs</a>\n<a class="btn" href="/book">Book</a>\n</nav>\n<main><h1>Ledgerline</h1></main>');
|
||||
const hits = only(run(reflex), 'template-nav');
|
||||
expect(hits.map((f) => [f.grade, f.line])).toEqual([['ask', lineOf(reflex, '<nav')]]);
|
||||
expect(hits[0].evidence).toBe('the reflex nav shape: 6 inline links, a button and a 1px hairline under');
|
||||
expect(only(run(page('nav { display: flex; }', '<nav><a href="/">Ledgerline</a><a href="/method">Method</a><a href="/pricing">Pricing</a></nav><main><h1>Ledgerline</h1></main>')), 'template-nav')).toEqual([]);
|
||||
expect(only(run(reflex, { scope: 'component' }), 'template-nav')).toEqual([]);
|
||||
});
|
||||
|
||||
test('template-footer: four link columns ask; a one-line footer is clean', () => {
|
||||
const col = '<ul><li><a href="/a">A</a></li><li><a href="/b">B</a></li><li><a href="/c">C</a></li></ul>';
|
||||
const reflex = page('', '<main><h1>Ledgerline</h1></main>\n<footer>\n' + col + '\n' + col + '\n' + col + '\n' + col + '\n</footer>');
|
||||
const hits = only(run(reflex), 'template-footer');
|
||||
expect(hits.map((f) => [f.grade, f.line])).toEqual([['ask', lineOf(reflex, '<footer')]]);
|
||||
expect(hits[0].evidence).toContain('4 link columns');
|
||||
expect(only(run(page('', '<footer><p>Ledgerline, Bergen.</p></footer>')), 'template-footer')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint chrome: proof, drawn chrome and material (gates 46a, 47)', () => {
|
||||
test('invented-proof: a metric the page cannot source lands at ask for a person to confirm; a plain count is clean', () => {
|
||||
const slop = page('', '<main>\n<p>Trusted by 50,000+ teams shipping 10x faster.</p>\n</main>');
|
||||
const hits = only(run(slop), 'invented-proof');
|
||||
expect(hits.map((f) => [f.tier, f.grade, f.line])).toEqual([['ask', 'ask', lineOf(slop, 'Trusted by')]]);
|
||||
expect(only(run(page('', '<main><p>Halvorsen Freight reconciles 1,140 invoices a month.</p></main>')), 'invented-proof')).toEqual([]);
|
||||
});
|
||||
|
||||
test('drawn-browser-chrome blocks on a traffic-light bar class or selector; device-frame asks on a phone frame; a real figure is clean', () => {
|
||||
const drawn = page('.window-frame { border: 1px solid var(--color-muted); }', '<main>\n<div class="browser-bar"><span class="dot"></span></div>\n<div class="phone-frame"></div>\n</main>');
|
||||
const findings = run(drawn);
|
||||
const bar = only(findings, 'drawn-browser-chrome');
|
||||
expect(bar.map((f) => [f.grade, f.line])).toEqual([['blocks', lineOf(drawn, 'browser-bar')]]);
|
||||
expect(bar[0].evidence).toContain('class "browser-bar"');
|
||||
const frame = only(findings, 'device-frame');
|
||||
expect(frame.map((f) => [f.grade, f.line]).sort((a, b) => Number(a[1]) - Number(b[1]))).toEqual([['ask', lineOf(drawn, '.window-frame')], ['ask', lineOf(drawn, 'phone-frame"')]]);
|
||||
const clean = run(page('', '<main><figure><img src="shot.png" alt="The close screen"><figcaption>The close screen.</figcaption></figure></main>'));
|
||||
expect(only(clean, 'drawn-browser-chrome')).toEqual([]);
|
||||
expect(only(clean, 'device-frame')).toEqual([]);
|
||||
});
|
||||
|
||||
test('imitation-material: a placeholder image service asks, in markup or CSS; a real asset is clean', () => {
|
||||
const fake = page('', '<main>\n<img src="https://via.placeholder.com/300" alt="">\n</main>').replace('<style>', '<link rel="stylesheet" href="style.css"><style>');
|
||||
const css = '.hero {\n background: url(https://placehold.co/600x400);\n}\n';
|
||||
const hits = only(run(fake, { files: { 'style.css': css } }), 'imitation-material');
|
||||
expect(hits.map((f) => [f.grade, f.file, f.line]).sort((a, b) => String(a[1]).localeCompare(String(b[1])))).toEqual([['ask', 'index.html', lineOf(fake, 'via.placeholder')], ['ask', 'style.css', 2]]);
|
||||
expect(hits[0].evidence).toMatch(/^a placeholder image service \((placehold\.co|via\.placeholder\.com)\) stands in for a picture$/);
|
||||
expect(only(run(page('', '<main><img src="shot.png" alt="The close screen"></main>')), 'imitation-material')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint chrome: the eyebrow (gate 54)', () => {
|
||||
test('kicker-above-heading asks on a labelled span before a heading and on a section labelled by a non-heading; a figcaption before a heading is clean', () => {
|
||||
const slop = page('.eyebrow { text-transform: uppercase; font-size: 0.75rem; letter-spacing: 0.1em; }', '<main>\n<section>\n<div>\n<span class="eyebrow">01 / Features</span>\n<h2>What it does</h2>\n</div>\n</section>\n<section aria-labelledby="plans">\n<p id="plans" class="eyebrow">Plans</p>\n<p>Three plans, one ledger.</p>\n</section>\n</main>');
|
||||
const hits = only(run(slop), 'kicker-above-heading');
|
||||
expect(hits.map((f) => [f.grade, f.line])).toEqual([['ask', lineOf(slop, '<span class="eyebrow">')], ['ask', lineOf(slop, 'id="plans"')]]);
|
||||
expect(hits[0].evidence).toBe('the eyebrow "01 / Features" before <h2>');
|
||||
expect(hits[1].evidence).toContain('labelled by <p>');
|
||||
expect(only(run(page('', '<main><figure><figcaption>Plate 4</figcaption><h3>Off the grid</h3></figure></main>')), 'kicker-above-heading')).toEqual([]);
|
||||
expect(only(run(slop, { scope: 'component' }), 'kicker-above-heading')).toEqual([]);
|
||||
});
|
||||
|
||||
test('the eyebrow fixtures: six on eyebrow-fail, none on eyebrow-pass', async () => {
|
||||
const fail = await lint([path.join(FIXTURES, 'eyebrow-fail')], { cwd: ROOT });
|
||||
expect(only(fail.findings, 'kicker-above-heading').map((f) => f.line)).toEqual([65, 74, 83, 92, 101, 110]);
|
||||
const pass = await lint([path.join(FIXTURES, 'eyebrow-pass')], { cwd: ROOT });
|
||||
expect(show(only(pass.findings, 'kicker-above-heading'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint chrome: punctuation, slots, echoes and sends (gates 63, 68, 69, 70)', () => {
|
||||
test('template-punctuation blocks on middle-dot meta strings, a glued arrow and three decorative dots; one dot in a byline is clean', () => {
|
||||
const slop = page('', '<main>\n<p class="meta">Design \u00b7 12 min read \u00b7 March</p>\n<a href="/method">Learn how it works \u2192</a>\n<ul><li><span class="dot"></span>Live</li><li><span class="dot"></span>Beta</li><li><span class="dot"></span>Soon</li></ul>\n</main>');
|
||||
const hits = only(run(slop), 'template-punctuation');
|
||||
expect(hits.map((f) => [f.grade, f.line])).toEqual([['blocks', lineOf(slop, 'class="meta"')], ['blocks', lineOf(slop, '<a href="/method">')], ['blocks', lineOf(slop, '<ul>')]]);
|
||||
expect(hits.map((f) => f.evidence.split(':')[0].split(' "')[0])).toEqual(['middle dots join a meta string', 'an arrow glued to the label', 'decorative status dots (dot) repeated across the page']);
|
||||
expect(only(run(page('', '<main><p class="byline">Marta Silveira \u00b7 14 March 2026</p><a href="/method">Read the method</a></main>')), 'template-punctuation')).toEqual([]);
|
||||
});
|
||||
|
||||
test('wireframe-slots asks past five empty slots on a page and at four in one row; two slots holding pictures are clean', () => {
|
||||
const slots = (n: number) => Array.from({ length: n }, (_, i) => '<div data-slot="s' + i + '"></div>').join('\n');
|
||||
const many = page('', '<main>\n<section class="team">\n' + slots(6) + '\n</section>\n</main>');
|
||||
const hits = only(run(many), 'wireframe-slots');
|
||||
expect(hits.map((f) => [f.grade, f.line, f.evidence])).toEqual([
|
||||
['ask', lineOf(many, 'data-slot="s5"'), '6 empty slots on one page'],
|
||||
['ask', lineOf(many, '<section class="team">'), '6 empty slots in one row or grid'],
|
||||
]);
|
||||
const row = page('', '<main><section><div class="row">\n' + slots(4) + '\n</div></section></main>');
|
||||
expect(only(run(row), 'wireframe-slots').map((f) => f.evidence)).toEqual(['4 empty slots in one row or grid']);
|
||||
expect(only(run(page('', '<main><div data-slot="a"><img src="a.jpg" alt="Mari"></div><div data-slot="b"><img src="b.jpg" alt="Depot"></div></main>')), 'wireframe-slots')).toEqual([]);
|
||||
});
|
||||
|
||||
test('repeated-fact asks when one fact is printed in four sections; the same fact twice is clean', () => {
|
||||
const band = (i: number) => '<section id="s' + i + '"><p>From $49 a month.</p></section>';
|
||||
const echo = page('', '<main>\n' + [1, 2, 3, 4].map(band).join('\n') + '\n</main>');
|
||||
const hits = only(run(echo), 'repeated-fact');
|
||||
expect(hits.map((f) => [f.grade, f.line, f.evidence])).toEqual([['ask', 1, 'the same fact printed in 4 sections: $49 (4)']]);
|
||||
expect(only(run(page('', '<main>' + [1, 2].map(band).join('') + '</main>')), 'repeated-fact')).toEqual([]);
|
||||
});
|
||||
|
||||
test('fake-send lands at ask when a script prints success with no request behind it; the same script with fetch() is clean', () => {
|
||||
const script = (send: string) => '<main>\n<form id="f"><input type="email" name="email"><button>Join</button></form>\n<script>\ndocument.getElementById("f").addEventListener("submit", (e) => {\n e.preventDefault();\n ' + send + '\n document.getElementById("f").textContent = "Thanks, you are on the list";\n});\n</script>\n</main>';
|
||||
const fake = page('', script(''));
|
||||
const hits = only(run(fake), 'fake-send');
|
||||
expect(hits.map((f) => [f.tier, f.grade, f.line])).toEqual([['ask', 'ask', lineOf(fake, '"Thanks, you are')]]);
|
||||
expect(hits[0].evidence).toContain('a script prints success');
|
||||
expect(only(run(page('', script('fetch("/api/join", { method: "POST", body: new FormData(e.target) });'))), 'fake-send')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint chrome: the email band close (gate 85)', () => {
|
||||
const signup = '<form class="newsletter">\n<label for="e">Email</label><input id="e" type="email" name="email"><button>Subscribe</button>\n</form>';
|
||||
test('email-band-close asks when the last section before the footer is an email sign-up and no job was named; a subscribe job, a booking form and a component context are clean', () => {
|
||||
const close = page('', '<main>\n<section class="story"><h2>Where the seven days went</h2><p>Three days were matching receipts.</p></section>\n<section class="band">\n' + signup + '\n</section>\n</main>\n<footer><p>Ledgerline, Bergen.</p></footer>');
|
||||
const hits = only(run(close), 'email-band-close');
|
||||
expect(hits.map((f) => [f.grade, f.line])).toEqual([['ask', lineOf(close, '<form class="newsletter">')]]);
|
||||
expect(hits[0].evidence).toBe('the page closes on an email sign-up <form class="newsletter"> as its last section, and no job was named (--job)');
|
||||
expect(only(run(close, { job: 'buy' }), 'email-band-close')[0].evidence).toContain('on a buy page');
|
||||
expect(only(run(close, { job: 'subscribe' }), 'email-band-close')).toEqual([]);
|
||||
expect(only(run(close, { scope: 'component' }), 'email-band-close')).toEqual([]);
|
||||
const footerForm = page('', '<main><section class="story"><h2>Where the seven days went</h2></section></main>\n<footer>\n' + signup + '\n</footer>');
|
||||
expect(only(run(footerForm), 'email-band-close')[0].evidence).toContain('in the footer');
|
||||
const booking = page('', '<main><section class="close"><form><input type="email" name="email"><button>Book the walkthrough</button></form></section></main><footer><p>Ledgerline, Bergen.</p></footer>');
|
||||
expect(only(run(booking), 'email-band-close')).toEqual([]);
|
||||
const notLast = page('', '<main><section class="band">' + signup + '</section><section class="close"><h2>See your own close</h2><a href="/book">Book the walkthrough</a></section></main><footer><p>Ledgerline, Bergen.</p></footer>');
|
||||
expect(only(run(notLast), 'email-band-close')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint chrome: two filled buttons in the hero (F4 half)', () => {
|
||||
test('two-primary-buttons prints at polish for two filled buttons side by side in the hero; a filled primary beside a quiet link is clean', () => {
|
||||
const css = '.btn { background: var(--color-accent); color: var(--color-accent-ink); } .quiet { color: var(--color-ink); text-decoration: underline; }';
|
||||
const two = page(css, '<section class="hero">\n<h1>October closed in two days</h1>\n<a class="btn" href="/book">Book the walkthrough</a>\n<a class="btn" href="/tour">Take the tour</a>\n</section>');
|
||||
const hits = only(run(two), 'two-primary-buttons');
|
||||
expect(hits.map((f) => [f.grade, f.line])).toEqual([['polish', lineOf(two, 'Take the tour')]]);
|
||||
expect(hits[0].evidence).toBe('two filled buttons side by side in the hero: "Book the walkthrough" and "Take the tour"');
|
||||
expect(only(run(page(css, '<section class="hero"><h1>October closed in two days</h1><a class="btn" href="/book">Book the walkthrough</a><a class="quiet" href="/method">Read how the close works</a></section>')), 'two-primary-buttons')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint chrome: fixtures', () => {
|
||||
test('the clean page raises nothing from this family; slopped.html plants the invented metric and the eyebrow', async () => {
|
||||
const chrome = new Set(['aphoristic-cadence', 'generic-hero-copy', 'generic-cta-copy', 'em-dash-overuse', 'template-footer-line', 'template-nav', 'template-footer', 'invented-proof', 'drawn-browser-chrome', 'device-frame', 'imitation-material', 'kicker-above-heading', 'template-punctuation', 'wireframe-slots', 'repeated-fact', 'fake-send', 'email-band-close']);
|
||||
const clean = await lint([path.join(FIXTURES, 'clean')], { cwd: ROOT });
|
||||
expect(show(clean.findings.filter((f) => chrome.has(f.rule)))).toEqual([]);
|
||||
const slopped = await lint([path.join(FIXTURES, 'slopped.html')], { cwd: ROOT });
|
||||
expect(only(slopped.findings, 'invented-proof').map((f) => [f.grade, f.line])).toEqual([['ask', 113]]);
|
||||
expect(only(slopped.findings, 'kicker-above-heading').map((f) => [f.grade, f.line, f.evidence])).toEqual([['ask', 120, 'the eyebrow "01 / FEATURES" before <h2>']]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* The finish rule family of the design lint (lib/design-lint/rules/finish.ts):
|
||||
* for every rule id, one page that is clean for it and one with the tell
|
||||
* 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 * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { lint } from '../lib/design-lint/index';
|
||||
import { formatFinding, type Finding } from '../lib/design-lint/report';
|
||||
import { lintStatic } from '../lib/design-lint/static';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const FIXTURES = path.join(ROOT, 'test', 'fixtures', 'design-lint');
|
||||
|
||||
const TOKENS = ':root { --color-paper: oklch(97% 0.01 80); --color-ink: oklch(20% 0.02 250); --color-accent: oklch(50% 0.16 30); --color-accent-ink: oklch(98% 0.01 30); --color-muted: oklch(50% 0.02 250); --font-display: "Fraunces", serif; --font-body: "Public Sans", sans-serif; }';
|
||||
|
||||
function page(css: string, body: string, htmlAttrs = ' lang="en"'): string {
|
||||
return '<!doctype html>\n<html' + htmlAttrs + '>\n<head><meta charset="utf-8"><title>Harbour office</title><meta name="description" content="A test page."><link rel="icon" href="data:,">\n<style>\n' + TOKENS + '\n' + css + '\n</style>\n</head>\n<body>\n' + body + '\n</body>\n</html>\n';
|
||||
}
|
||||
|
||||
const lineOf = (html: string, needle: string): number => {
|
||||
const at = html.indexOf(needle);
|
||||
if (at < 0) throw new Error('needle not in page: ' + needle);
|
||||
return html.slice(0, at).split('\n').length;
|
||||
};
|
||||
|
||||
function run(html: string, files: Record<string, string> = {}): Finding[] {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-finish-'));
|
||||
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;
|
||||
}
|
||||
|
||||
const only = (findings: Finding[], rule: string) => findings.filter((f) => f.rule === rule);
|
||||
const show = (findings: Finding[]) => findings.map(formatFinding);
|
||||
const polishAt = (findings: Finding[], rule: string, line: number) => expect(only(findings, rule).map((f) => [f.grade, f.line])).toEqual([['polish', line]]);
|
||||
|
||||
describe('design-lint finish: prose (F1, F2)', () => {
|
||||
test('straight-quotes prints at polish with the count; curly quotes are clean', () => {
|
||||
const slop = page('', '<main>\n<p>Mari said "the close ran itself" and that\'s the review.</p>\n</main>');
|
||||
const hits = only(run(slop), 'straight-quotes');
|
||||
polishAt(hits, 'straight-quotes', lineOf(slop, 'Mari said'));
|
||||
expect(hits[0].evidence).toMatch(/^3 straight quotes in prose \(first: /);
|
||||
expect(only(run(page('', '<main><p>Mari said \u201cthe close ran itself\u201d and that\u2019s the review.</p></main>')), 'straight-quotes')).toEqual([]);
|
||||
});
|
||||
|
||||
test('three-dot-ellipsis prints at polish; the ellipsis character is clean', () => {
|
||||
const slop = page('', '<main>\n<p>Wait for it...</p>\n</main>');
|
||||
polishAt(run(slop), 'three-dot-ellipsis', lineOf(slop, 'Wait for'));
|
||||
expect(only(run(page('', '<main><p>Wait for it\u2026</p></main>')), 'three-dot-ellipsis')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint finish: heads and buttons (F3, F4, F6)', () => {
|
||||
test('balanced-display-head prints at polish when no display head has text-wrap: balance; one balanced head rule is clean', () => {
|
||||
const slop = page('h1 {\n font-size: 3rem;\n}', '<main><h1>October closed in two days</h1></main>');
|
||||
const hits = only(run(slop), 'balanced-display-head');
|
||||
polishAt(hits, 'balanced-display-head', lineOf(slop, 'h1 {'));
|
||||
expect(hits[0].evidence).toBe('no text-wrap: balance on any display head (1 head rules, the first h1)');
|
||||
expect(only(run(page('h1 { font-size: 3rem; text-wrap: balance; }', '<main><h1>October closed in two days</h1></main>')), 'balanced-display-head')).toEqual([]);
|
||||
});
|
||||
|
||||
test('two-primary-buttons prints at polish for two filled primary recipes; one filled recipe and an outline is clean', () => {
|
||||
const slop = page('.btn-book { background: var(--color-accent); }\n.btn-buy {\n background: var(--color-ink);\n}', '<main><a class="btn-book" href="/book">Book</a><a class="btn-buy" href="/buy">Buy</a></main>');
|
||||
const hits = only(run(slop), 'two-primary-buttons');
|
||||
polishAt(hits, 'two-primary-buttons', lineOf(slop, 'background: var(--color-ink)'));
|
||||
expect(hits[0].evidence).toBe('2 filled primary-button recipes on one page: .btn-book and .btn-buy');
|
||||
expect(only(run(page('.btn-book { background: var(--color-accent); } .btn-quiet { background: transparent; border: 1px solid var(--color-accent); }', '<main><a class="btn-book" href="/book">Book</a><a class="btn-quiet" href="/method">Method</a></main>')), 'two-primary-buttons')).toEqual([]);
|
||||
});
|
||||
|
||||
test('display-figures-tabular prints at polish for a display-size stat without tabular numerals; tabular-nums on the selector or the root is clean', () => {
|
||||
const slop = page('.stat__num {\n font-size: 48px;\n}', '<main><p class="stat__num">1,140</p></main>');
|
||||
const hits = only(run(slop), 'display-figures-tabular');
|
||||
polishAt(hits, 'display-figures-tabular', lineOf(slop, 'font-size: 48px'));
|
||||
expect(hits[0].evidence).toBe('figures on .stat__num at display size without tabular numerals');
|
||||
expect(only(run(page('.stat__num { font-size: 48px; font-variant-numeric: tabular-nums; }', '<main><p class="stat__num">1,140</p></main>')), 'display-figures-tabular')).toEqual([]);
|
||||
expect(only(run(page('body { font-variant-numeric: tabular-nums; } .stat__num { font-size: 48px; }', '<main><p class="stat__num">1,140</p></main>')), 'display-figures-tabular')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint finish: surfaces and recipes (F5, F7, F24)', () => {
|
||||
test('icon-coin prints at polish for a small round filled icon box; a bare inline icon is clean', () => {
|
||||
const slop = page('.feature__icon {\n width: 40px;\n border-radius: 50%;\n background: var(--color-accent);\n}', '<main><span class="feature__icon" aria-hidden="true"></span></main>');
|
||||
const hits = only(run(slop), 'icon-coin');
|
||||
polishAt(hits, 'icon-coin', lineOf(slop, 'background: var(--color-accent)'));
|
||||
expect(hits[0].evidence).toBe('an icon in a coloured coin: .feature__icon');
|
||||
expect(only(run(page('.feature__icon { width: 20px; color: currentColor; }', '<main><span class="feature__icon" aria-hidden="true"></span></main>')), 'icon-coin')).toEqual([]);
|
||||
});
|
||||
|
||||
test('stamped-recipe prints at polish for one container recipe on six selectors and for the pill radius on five non-controls; four varied boxes are clean', () => {
|
||||
const recipe = ' { border: 1px solid var(--color-muted); border-radius: 8px; background: var(--color-paper); }';
|
||||
const six = page(['.a', '.b', '.c', '.d', '.e', '.f'].map((s) => s + recipe).join('\n'), '<main><div class="a"></div></main>');
|
||||
const hits = only(run(six), 'stamped-recipe');
|
||||
polishAt(hits, 'stamped-recipe', lineOf(six, '.a {'));
|
||||
expect(hits[0].evidence).toBe('one container recipe on 6 selectors (.a, .b, .c, .d and more)');
|
||||
const pills = page('.card, .panel, .tile, .box, .media {\n border-radius: 9999px;\n}', '<main><div class="card"></div></main>');
|
||||
const pillHits = only(run(pills), 'stamped-recipe');
|
||||
polishAt(pillHits, 'stamped-recipe', lineOf(pills, 'border-radius: 9999px'));
|
||||
expect(pillHits[0].evidence).toBe('the pill radius on 5 non-control selectors (.card, .panel, .tile, .box)');
|
||||
expect(only(run(page('.btn, .chip, .tag, .badge, .pill { border-radius: 9999px; } .a' + recipe + ' .b { border-radius: 4px; background: var(--color-paper); }', '<main><div class="a"></div></main>')), 'stamped-recipe')).toEqual([]);
|
||||
});
|
||||
|
||||
test('hairline-under-shadow prints at polish for a 1px border under a wide soft shadow, in CSS or Tailwind classes; a shadow alone is clean', () => {
|
||||
const slop = page('.card {\n border: 1px solid var(--color-muted);\n box-shadow: 0 10px 30px -5px rgb(0 0 0 / 0.2);\n}', '<main><div class="card"></div></main>');
|
||||
const hits = only(run(slop), 'hairline-under-shadow');
|
||||
polishAt(hits, 'hairline-under-shadow', lineOf(slop, 'box-shadow'));
|
||||
expect(hits[0].evidence).toBe('a hairline border under a wide soft shadow on .card');
|
||||
const tw = page('', '<main>\n<div class="rounded-lg border shadow-xl"></div>\n</main>');
|
||||
expect(only(run(tw), 'hairline-under-shadow').map((f) => [f.grade, f.line])).toEqual([['polish', lineOf(tw, 'shadow-xl')]]);
|
||||
expect(only(run(page('.card { box-shadow: 0 10px 30px -5px rgb(0 0 0 / 0.2); }', '<main><div class="card"></div></main>')), 'hairline-under-shadow')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint finish: motion and people (F8, F9)', () => {
|
||||
test('scroll-choreography-mix prints at polish for two scroll-driven families; one is clean', () => {
|
||||
const slop = page('.reveal { animation: rise linear both; animation-timeline: view(); }\n.bar {\n animation: grow linear both;\n animation-timeline: scroll();\n}', '<main><div class="reveal"></div><div class="bar"></div></main>');
|
||||
const hits = only(run(slop), 'scroll-choreography-mix');
|
||||
polishAt(hits, 'scroll-choreography-mix', lineOf(slop, 'animation-timeline: scroll()'));
|
||||
expect(hits[0].evidence).toBe('2 scroll-choreography patterns on one page (reveal, bar)');
|
||||
expect(only(run(page('.reveal { animation: rise linear both; animation-timeline: view(); }', '<main><div class="reveal"></div></main>')), 'scroll-choreography-mix')).toEqual([]);
|
||||
});
|
||||
|
||||
test('fake-avatar blocks on a generated-face service; stock-person-photo prints at polish for a person-keyword stock URL; a named local portrait is clean', () => {
|
||||
const slop = page('.hero { background-image: url(https://images.unsplash.com/photo-1?portrait); }', '<main>\n<img src="https://i.pravatar.cc/80?u=mari" alt="Mari">\n<img src="https://images.unsplash.com/photo-15?headshot=1" alt="A customer">\n</main>');
|
||||
const findings = run(slop);
|
||||
expect(only(findings, 'fake-avatar').map((f) => [f.tier, f.grade, f.line, f.evidence])).toEqual([['blocks', 'blocks', lineOf(slop, 'pravatar'), 'a generated-face service (i.pravatar.cc) stands in for a person']]);
|
||||
expect(only(findings, 'stock-person-photo').map((f) => [f.grade, f.line]).sort((a, b) => Number(a[1]) - Number(b[1]))).toEqual([['polish', lineOf(slop, 'photo-1?portrait')], ['polish', lineOf(slop, 'headshot')]]);
|
||||
const clean = run(page('', '<main><img src="/people/mari-halvorsen.jpg" alt="Mari Halvorsen"></main>'));
|
||||
expect(only(clean, 'fake-avatar')).toEqual([]);
|
||||
expect(only(clean, 'stock-person-photo')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint finish: page invariants and browser surfaces (F15, F16)', () => {
|
||||
test('page-invariants prints at polish for two container widths and for three column gaps; one width and one gap are clean', () => {
|
||||
const widths = page('.wrap { max-width: 1200px; }\n.container { max-width: 1360px; }', '<main class="wrap"></main>');
|
||||
const w = only(run(widths), 'page-invariants');
|
||||
expect(w.map((f) => [f.grade, f.line, f.evidence])).toEqual([['polish', 1, '2 container widths on one page (1200, 1360px)']]);
|
||||
const gaps = page('.a { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; } .b { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 32px; } .c { display: grid; grid-template-columns: repeat(2, 1fr); gap: 48px; }', '<main></main>');
|
||||
expect(only(run(gaps), 'page-invariants').map((f) => f.evidence)).toEqual(['3 different column gaps across multi-column sections (24px, 32px, 48px)']);
|
||||
expect(only(run(page('.wrap { max-width: 1360px; } .a, .b { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; }', '<main class="wrap"></main>')), 'page-invariants')).toEqual([]);
|
||||
});
|
||||
|
||||
test('unthemed-browser-surfaces prints at polish naming the missing surfaces on a page with an accent; themed CSS or Tailwind surfaces are clean, and a page without an accent is skipped', () => {
|
||||
const slop = page('', '<main><input type="email"></main>');
|
||||
const hits = only(run(slop), 'unthemed-browser-surfaces');
|
||||
expect(hits.map((f) => [f.grade, f.line, f.evidence])).toEqual([['polish', 1, 'browser surfaces left unthemed: ::selection, scrollbar-color, caret-color']]);
|
||||
expect(only(run(page('::selection { background: var(--color-accent); } html { scrollbar-color: var(--color-muted) var(--color-paper); } input { caret-color: var(--color-accent); }', '<main><input type="email"></main>')), 'unthemed-browser-surfaces')).toEqual([]);
|
||||
expect(only(run(page('', '<body class="selection:bg-accent [scrollbar-color:var(--color-muted)_var(--color-paper)]"><main><input type="email" class="caret-accent"></main></body>')), 'unthemed-browser-surfaces')).toEqual([]);
|
||||
expect(only(run(page('', '<main></main>').replace('--color-accent: oklch(50% 0.16 30); ', '')), 'unthemed-browser-surfaces')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint finish: the document (F21, F22, F23)', () => {
|
||||
test('skipped-heading prints at polish when a level is skipped; stepped levels are clean', () => {
|
||||
const slop = page('', '<main>\n<h1>October</h1>\n<h3>Where the days went</h3>\n</main>');
|
||||
const hits = only(run(slop), 'skipped-heading');
|
||||
polishAt(hits, 'skipped-heading', lineOf(slop, '<h3>'));
|
||||
expect(hits[0].evidence).toBe('h1 is followed by h3 ("Where the days went")');
|
||||
expect(only(run(page('', '<main><h1>October</h1><h2>Where the days went</h2><h3>Matching</h3></main>')), 'skipped-heading')).toEqual([]);
|
||||
});
|
||||
|
||||
test('justified-text prints at polish without hyphens: auto, in CSS or as text-justify; hyphenated justification is clean', () => {
|
||||
const slop = page('p {\n text-align: justify;\n}', '<main><p>Three days were matching receipts.</p></main>');
|
||||
const hits = only(run(slop), 'justified-text');
|
||||
polishAt(hits, 'justified-text', lineOf(slop, 'text-align: justify'));
|
||||
expect(hits[0].evidence).toBe('justified text without hyphenation on p');
|
||||
const tw = page('', '<main>\n<p class="text-justify">Three days were matching receipts.</p>\n</main>');
|
||||
expect(only(run(tw), 'justified-text').map((f) => f.line)).toEqual([lineOf(tw, 'text-justify')]);
|
||||
expect(only(run(page('p { text-align: justify; hyphens: auto; }', '<main><p>Three days were matching receipts.</p></main>')), 'justified-text')).toEqual([]);
|
||||
});
|
||||
|
||||
test('missing-lang prints at polish for <html> without lang; the title and description halves belong to the copy tier', () => {
|
||||
const slop = page('', '<main></main>', '');
|
||||
const hits = only(run(slop), 'missing-lang');
|
||||
polishAt(hits, 'missing-lang', lineOf(slop, '<html>'));
|
||||
expect(hits[0].evidence).toBe('<html> names no lang');
|
||||
expect(only(run(page('', '<main></main>')), 'missing-lang')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint finish: fixtures', () => {
|
||||
test('the clean page raises nothing from this family; slopped.html plants the unbalanced head and the straight quotes; the waiver fixture waives balanced-display-head', async () => {
|
||||
const finish = new Set(['straight-quotes', 'three-dot-ellipsis', 'balanced-display-head', 'two-primary-buttons', 'icon-coin', 'display-figures-tabular', 'stamped-recipe', 'scroll-choreography-mix', 'fake-avatar', 'stock-person-photo', 'page-invariants', 'unthemed-browser-surfaces', 'skipped-heading', 'justified-text', 'missing-lang', 'hairline-under-shadow']);
|
||||
const clean = await lint([path.join(FIXTURES, 'clean')], { cwd: ROOT });
|
||||
expect(show(clean.findings.filter((f) => finish.has(f.rule)))).toEqual([]);
|
||||
const slopped = await lint([path.join(FIXTURES, 'slopped.html')], { cwd: ROOT });
|
||||
expect(only(slopped.findings, 'balanced-display-head').map((f) => [f.grade, f.line])).toEqual([['polish', 63]]);
|
||||
expect(only(slopped.findings, 'straight-quotes').map((f) => [f.grade, f.line])).toEqual([['polish', 141]]);
|
||||
const waiver = await lint([path.join(FIXTURES, 'waiver')], { cwd: ROOT });
|
||||
expect(only(waiver.findings, 'balanced-display-head').map((f) => f.grade)).toEqual(['waived']);
|
||||
});
|
||||
});
|
||||
+1
@@ -29,6 +29,7 @@
|
||||
--ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
html, body { overflow-x: clip; }
|
||||
html { scrollbar-color: var(--color-muted) var(--color-paper); }
|
||||
body { margin: 0; background: var(--color-paper); color: var(--color-ink); font-family: var(--font-body); font-size: 1.125rem; line-height: 1.6; }
|
||||
::selection { background: var(--color-accent); color: var(--color-accent-ink); }
|
||||
.skip { position: absolute; left: var(--space-2); top: var(--space-2); }
|
||||
|
||||
Reference in new issue
Block a user