');
const f = await lintPage(html);
expect(hits(f, 'template-cards')).toEqual([['ask', lineOf(html, 'repeat(3, 1fr)')], ['ask', lineOf(html, 'border-left: 4px')]]);
expect(only(f, 'template-cards')[0].evidence).toBe('three equal columns of icon-above-heading tiles on .features');
});
test('three text columns without icons and a hairline side border are not the template', async () => {
const html = page('.features { display: grid; grid-template-columns: repeat(3, 1fr); }\n.note { border-left: 1px solid var(--color-ink); }',
'
Sync
Once.
Share
Twice.
Ship
Daily.
\n
A note
');
none(await lintPage(html), 'template-cards');
});
});
describe('design-lint structure: nested-cards (gate 4)', () => {
test('a card inside a card blocks at the inner card', async () => {
const html = page('.card { border: 1px solid var(--color-ink); }', '
\n
Outer
\n
\n
Inner
\n
\n
');
const f = await lintPage(html);
expect(hits(f, 'nested-cards')).toEqual([['blocks', lineOf(html, 'card feature-card')]]);
expect(only(f, 'nested-cards')[0].evidence).toBe('a card inside a card: class="card feature-card"');
});
test('a card inside a card grid, and sibling cards, are one containment layer', async () => {
const html = page('.card { border: 1px solid var(--color-ink); }', '
One
Two
');
none(await lintPage(html), 'nested-cards');
});
});
describe('design-lint structure: vh-height (gate 6a)', () => {
test('a hero sized in vh blocks, by the hero word or by the class of the section holding the h1', async () => {
const html = page('.hero { min-height: 100vh; }\n.intro { height: 80vh; }', '
Two days to close
\n
More
');
const f = await lintPage(html);
expect(hits(f, 'vh-height')).toEqual([['blocks', lineOf(html, '100vh')], ['blocks', lineOf(html, '80vh')]]);
});
test('svh and dvh pass, and vh on a non-hero rule is not this rule', async () => {
const html = page('.hero { min-height: 100svh; }\n.sheet { height: 100dvh; }\n.band { min-height: 40vh; }', '
Two days to close
\n');
none(await lintPage(html), 'vh-height');
});
});
describe('design-lint structure: raw-spacing (gate 24a)', () => {
test('a raw length that matches a --space-* token blocks with that token as the fix; past eight in one file the rest fold into one line', async () => {
const rules = Array.from({ length: 10 }, (_, i) => `.r${i} { padding: 32px; }`).join('\n');
const html = page(':root { --space-4: 2rem; --space-2: 1rem; }\n.box { margin: 0 1rem; }\n' + rules, '
x
');
const f = only(await lintPage(html), 'raw-spacing');
expect(f.find((x) => x.line === lineOf(html, 'margin: 0 1rem'))).toMatchObject({ grade: 'blocks', evidence: 'margin: 0 1rem on .box where --space-2 exists', fix: 'var(--space-2)' });
expect(f).toHaveLength(9);
expect(f.filter((x) => x.line === 1).map((x) => x.evidence)).toEqual(['3 more raw spacing lengths in this file have a matching --space-* token']);
});
test('tokens in use, a length no token matches, and a page with no spacing tokens are all clean', async () => {
const withTokens = page(':root { --space-4: 2rem; }\n.box { padding: var(--space-4); margin: 0 36px; }', '
');
const f = await lintPage(html);
expect(hits(f, 'off-scale-spacing')).toEqual([['ask', lineOf(html, '17px')], ['ask', lineOf(html, '10px 8px')]]);
expect(only(f, 'off-scale-spacing')[0].evidence).toBe('padding: 17px on .nav is off the 4px scale');
});
test('multiples of four, 1px and 2px nudges, and rem values are on the scale', async () => {
const html = page('.nav { padding: 16px 2px; margin: 1px 0 1.25rem; gap: 24px; }', '
x
');
none(await lintPage(html), 'off-scale-spacing');
});
});
describe('design-lint structure: overflow-x-unclipped (gate 34, static half)', () => {
test('overflow-x: hidden, a 100vw width, clip on one of html and body, and no clip at all each report at ask', async () => {
const hidden = page('html { overflow-x: hidden; }\n.full { width: 100vw; }', '
x
', { clip: false });
const f = await lintPage(hidden);
expect(hits(f, 'overflow-x-unclipped')).toEqual([['ask', lineOf(hidden, 'overflow-x: hidden')], ['ask', lineOf(hidden, '100vw')]]);
expect(only(f, 'overflow-x-unclipped').map((x) => x.evidence)).toEqual(['overflow-x: hidden on html: hidden breaks position: sticky', 'width: 100vw on .full: 100vw counts the scrollbar and overflows']);
const one = page('html { overflow-x: clip; }', '
', { clip: false });
expect(only(await lintPage(neither), 'overflow-x-unclipped').map((x) => [x.line, x.evidence])).toEqual([[1, 'no overflow-x: clip on html and body']]);
});
test('overflow-x: clip on both html and body with width: 100% is clean', async () => {
const html = page('.full { width: 100%; }', '
';
test('with no capped container, a grid that gains a column past 1440px and a vw-sized visual with no cap block', async () => {
const html = page(grid, body);
const f = await lintPage(html);
expect(hits(f, 'wide-container')).toEqual([['blocks', lineOf(html, 'repeat(4')], ['blocks', lineOf(html, '60vw')]]);
expect(only(f, 'wide-container').map((x) => x.evidence)).toEqual([
'no container has a max-width, and .grid gains columns at (min-width: 1600px) (3 to 4)',
'no container has a max-width, and .hero__visual is sized in vw with no cap (width: 60vw)',
]);
});
test('a centred max-width container, a clamp() on the visual, or a column count that holds are clean', async () => {
none(await lintPage(page('.wrap { max-width: 1360px; margin: 0 auto; }\n' + grid, '
')), 'bare-fr-track');
});
});
describe('design-lint structure: display-head-unwrapped (gate 50)', () => {
test('a display head holding a word of 16 letters or more, with no overflow-wrap anywhere that reaches it, blocks', async () => {
const html = page('h1 { font-family: var(--font-display); }', '\n
Hyperproductivity for every crew
\n');
const f = await lintPage(html);
expect(hits(f, 'display-head-unwrapped')).toEqual([['blocks', lineOf(html, '
')]]);
expect(only(f, 'display-head-unwrapped')[0].evidence).toBe('the display head holds "Hyperproductivity" with no overflow-wrap: at 320px that word overflows');
});
test('overflow-wrap: break-word on the heading, or a short-word heading, is clean', async () => {
none(await lintPage(page('h1 { overflow-wrap: break-word; min-width: 0; }', '
')), 'display-head-unwrapped');
});
});
describe('design-lint structure: radio-tab-jump (gate 53)', () => {
const tabs = '';
test('radio tabs parked at position: absolute; top: 0 with no preventScroll in a script block', async () => {
const html = page('.tabs input[type="radio"] { position: absolute; top: 0; opacity: 0; }', tabs);
const f = await lintPage(html);
expect(hits(f, 'radio-tab-jump')).toEqual([['blocks', lineOf(html, 'position: absolute; top: 0')]]);
});
test('radios kept in flow at zero size, or a focus handler with preventScroll, are clean', async () => {
none(await lintPage(page('.tabs input[type="radio"] { position: absolute; width: 0; height: 0; opacity: 0; }', tabs)), 'radio-tab-jump');
none(await lintPage(page('.tabs input[type="radio"] { position: absolute; top: 0; opacity: 0; }', tabs + '\n')), 'radio-tab-jump');
});
});
describe('design-lint structure: double-sticky (gate 56)', () => {
test('two elements sticking at top: 0 block at the second', async () => {
const html = page('.nav { position: sticky; top: 0; }\n.subnav { position: sticky; top: 0px; }', '
s
');
const f = await lintPage(html);
expect(hits(f, 'double-sticky')).toEqual([['blocks', lineOf(html, '.subnav')]]);
expect(only(f, 'double-sticky')[0].evidence).toBe('two elements stick at top: 0 (.nav and .subnav): the second slides under the first');
});
test('the second sticky offset by the banner height is clean', async () => {
const html = page(':root { --banner-height: 64px; }\n.nav { position: sticky; top: 0; }\n.subnav { position: sticky; top: var(--banner-height); }', '
s
');
none(await lintPage(html), 'double-sticky');
});
});
describe('design-lint structure: screen-word-budget (gate 58, static half)', () => {
test('an h1 past seven words, a hero paragraph past twelve, and a full page past 450 visible words report at ask', async () => {
const html = page('', '\n
A very long headline that keeps on going and going
\n
Halvorsen Freight reconciles 1,140 invoices a month and their books now close before the third.
\n');
const f = await lintPage(html);
expect(hits(f, 'screen-word-budget')).toEqual([['ask', lineOf(html, '
')], ['ask', lineOf(html, '
Halvorsen')]]);
expect(only(f, 'screen-word-budget').map((x) => x.evidence)).toEqual(['the h1 runs 10 words ("A very long headline that keeps on goin…")', 'the hero paragraph runs 15 words']);
const words = Array.from({ length: 460 }, (_, i) => 'word' + i).join(' ');
const long = page('', `
Short head
\n
${words}
\n`);
expect(only(await lintPage(long), 'screen-word-budget').map((x) => [x.line, x.evidence])).toEqual([[1, '464 visible words on the page (450 at most)']]);
});
test('seven words of h1, twelve of lede, and a page under 450 words are within the budget', async () => {
const html = page('', '\n
October closed in two days, not nine
\n
1,140 invoices a month, and the books now close before the third.
\n\n');
none(await lintPage(html), 'screen-word-budget');
});
});
describe('design-lint structure: the shared fixtures', () => {
test('slopped.html trips the planted structure tells at their lines', async () => {
const f = (await lint([path.join(FIXTURES, 'slopped.html')], { cwd: ROOT, warn: (l: string) => { throw new Error(l); } })).findings;
const mine = f.filter((x) => FAMILY.includes(x.rule)).map((x) => [x.rule, x.grade, x.line]);
expect(mine).toEqual([
['bare-fr-track', 'blocks', 93],
['display-head-unwrapped', 'blocks', 112],
['double-sticky', 'blocks', 59],
['nested-cards', 'blocks', 128],
['off-scale-spacing', 'ask', 57],
['overflow-x-unclipped', 'ask', 47],
]);
});
test('the clean page raises no structure finding', async () => {
const f = (await lint([path.join(FIXTURES, 'clean')], { cwd: ROOT, warn: (l: string) => { throw new Error(l); } })).findings;
expect(f.filter((x) => FAMILY.includes(x.rule)).map(formatFinding)).toEqual([]);
});
});
describe('design-lint structure: third review cycle', () => {
test('sibling cards in a list whose end tags are left out are not nested cards', async () => {
const f = await lintPage(page('', '
'));
expect(hits(nested, 'nested-cards').map(([g]) => g)).toEqual(['blocks']);
});
test('one selector sticky at two breakpoints is not a double sticky; two selectors are', async () => {
none(await lintPage(page('.site-header { position: sticky; top: 0; }\n@media (min-width: 900px) { .site-header { position: sticky; top: 0; padding: 16px; } }', 'H')), 'double-sticky');
const two = await lintPage(page('.site-header { position: sticky; top: 0; }\n.toolbar { position: sticky; top: 0; }', 'H
T
'));
expect(hits(two, 'double-sticky').map(([g]) => g)).toEqual(['blocks']);
});
test('a light grey side border on a blockquote is not the accent stripe; a dark ink stripe and a coloured one are', async () => {
none(await lintPage(page('blockquote { border-left: 4px solid #dddddd; }', '
')), 'template-cards').map(([g]) => g)).toEqual(['ask']);
});
test('many grid rules with a bare fr track do not rescan the document per rule', async () => {
let css = '';
let body = '';
for (let i = 0; i < 1500; i++) {
css += `.c${i} { grid-template-columns: 1fr 2fr; }\n`;
body += `
Heading ${i}
Text.
`;
}
const t = performance.now();
const f = await lintPage(page(css, body));
expect(performance.now() - t).toBeLessThan(8000);
expect(hits(f, 'bare-fr-track').length).toBe(1500);
});
});