mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
feat(design-lint): section-gap measured in the render tier (gate 59, with the six-to-nine section count) and the hero action count; a free test over the recorded review slop page; usage, render glue, file walk, multi-page ownership and copy-tier tests; NOTICE and licences leave FREE_ONLY_PR_FILES so the paid-free boundary test stands
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
bf9d9c5b04
commit
a088fc3d8c
10 files changed
+236
-11
No files matched your search
+2
-2
@@ -8,13 +8,13 @@
|
||||
- **`bin/gstack-design-lint <file-or-dir ...> [--render] [--url] [--scope page|component] [--job <job>] [--json] [--rules]`**: reads `.html`, `.css`, `.jsx`, `.tsx`, `.vue`, `.svelte` and `.astro` (Tailwind class strings included, `cn()`/`clsx()`/`cva()` calls too), links each page to the stylesheets it reaches through `<link>` and `@import`, resolves CSS variables, converts colours to OKLCH and computes contrast, and prints one line per finding: `LINT: <blocks|ask|polish|waived> <rule-id> <file>:<line> <evidence> | fix: <fix>`, then `LINT_SUMMARY: blocks=N ask=N waived=N polish=N render=<ran|skipped: reason>`. Exit 2 on blocks, 1 on an unanswered ask, 0 otherwise. `docs/design-lint.md` is the contract and lists every rule.
|
||||
- **Three tiers in gstack's words, no fourth scale.** `blocks` (hallmark's Floor: contrast, focus rings, reduced motion, a card in a card, a purple-blue gradient, content hidden until JavaScript runs; never waivable), `ask` (hallmark's Reflex: three equal cards, Inter as the display, the centred-stack hero; waivable on the record) and `polish` (hallmark's Finish: straight quotes, a lonely last grid item; never blocks). Blocking and waived counts are never summed, so `blocks=0` is always a true sentence about the Floor. Three house rules hallmark holds at Floor stay at ask here: the eyebrow above a heading, the 80-word screen and the dash.
|
||||
- **Waivers on the record.** `<!-- gstack-lint-disable <rule-id>: <reason> -->` (or the `/* */` and `//` comment forms; `impeccable-disable <rule>: <reason>` is an alias) answers an ask or polish finding when the reason is about this page: 24 characters or more, not boilerplate, not reused on another waiver, sharing a word with the page's own text. A waiver of a blocking rule is refused and the rule still fires; more than three waivers on one page is reported as a house style that belongs in DESIGN.md.
|
||||
- **The render tier** (`--render`, or `--url` for a dev server) runs on the same engine `gstack-render` picks, Aside first and gstack's own headless browser otherwise, with nothing to install: the hero fits at 1280x800 (headline, sentence, action and visual), about 80 words per 900px window, contrast against the painted pixels, focus rings found by focusing every control, headings crowded against the section above, text painted under another element, horizontal scroll between 320 and 1920, wrapping button labels, thin sections, dead bands, drawn illustrations, empty picture slots, counting marks and photographs covering more than a third of the page. Without a browser the summary says `render=skipped: <reason>` and the static tier still runs.
|
||||
- **The render tier** (`--render`, or `--url` for a dev server) runs on the same engine `gstack-render` picks, Aside first and gstack's own headless browser otherwise, with nothing to install: the hero fits at 1280x800 (headline, sentence, action and visual), about 80 words per 900px window, contrast against the painted pixels, focus rings found by focusing every control, headings crowded against the section above, text painted under another element, horizontal scroll between 320 and 1920, wrapping button labels, a hero offering more than two actions, section gaps under 120px or all alike and more than nine sections, thin sections, dead bands, drawn illustrations, empty picture slots, counting marks and photographs covering more than a third of the page. Without a browser the summary says `render=skipped: <reason>` and the static tier still runs.
|
||||
- `lib/aside-render.ts`: an eval step takes an optional viewport (`width`, `height`, `deviceScaleFactor`, `mobile`), set before the eval and cleared after it the way the screenshot step already does, and a spec's `file` may be an http(s) URL, opened as it is with no loopback server. Both engines; nothing else in the file changes.
|
||||
- **The copy tier**: `gstack-design-craft`'s content-realism and ship-floor tells (placeholder names, lorem, buzzwords, round numbers, `[Needs proof]`, dead links, images without alt, the missing title, description, skip link and `aria-current`) report under the same contract and ids, so one command prints one list. `bin/gstack-design-craft` itself is unchanged until Phase 2 swaps the call.
|
||||
- Fixtures under `test/fixtures/design-lint/` (hallmark's `slopped.html`, eyebrow and Tailwind pass/fail pairs and the waiver page, MIT-attributed, plus a clean page every rule family must leave alone); `licenses/MIT-hallmark.txt`; a NOTICE.md entry.
|
||||
|
||||
### Tests
|
||||
- `test/design-lint.test.ts`: the parsers (nested CSS, tokens, var() fallbacks, colour spaces, JSX pseudo-HTML), the rule table against `docs/design-lint.md`, the output lines and exit codes, waiver acceptance and every refusal reason, the waiver fixture. One test per rule family (`test/design-lint-<family>.test.ts`) holds a negative control and a planted positive for every rule id. `test/design-lint-render.test.ts` drives the browse daemon when it is built and skips with a named reason otherwise.
|
||||
- `test/design-lint.test.ts`: the parsers (nested CSS, tokens, var() fallbacks, colour spaces, JSX pseudo-HTML), the rule table against `docs/design-lint.md`, the output lines and exit codes, waiver acceptance and every refusal reason, the waiver fixture. One test per rule family (`test/design-lint-<family>.test.ts`) holds a negative control and a planted positive for every rule id. `test/design-lint-render.test.ts` drives the browse daemon when it is built and skips with a named reason otherwise. `test/design-lint-recorded.test.ts` lints the repository's recorded design slop page (the review eval's ground truth) and pins which rule answers each annotated issue, the same gate set hallmark's sloplint reports on the same files.
|
||||
|
||||
### Gate integrity
|
||||
- No threshold, budget, timeout, trial count or assertion was weakened. New code only; no template, catalog or eval changed.
|
||||
|
||||
+1
-1
@@ -138,7 +138,7 @@ as `house-style`.
|
||||
| `radio-tab-jump` | blocks | 53 | CSS-only radio tabs at position: absolute; top: 0 with no JS guard | radios in flow at zero size, or a handler with preventScroll |
|
||||
| `double-sticky` | blocks | 56 | two position: sticky; top: 0 elements | top: var(--banner-height) on the second |
|
||||
| `screen-word-budget` | ask | 58 | more than about 80 words in one 900px window, or a hero with more than two text blocks before its actions | headline, one sentence, actions; a section is a head block, then its composition |
|
||||
| `section-gap` | ask | 59 | a gap under 120px between sections, or every gap the same | 120px at least, varied |
|
||||
| `section-gap` | ask | 59 | a gap under 120px between sections at 1440, every gap the same, or more than nine sections (render) | 120px at least, varied; six to nine sections a page |
|
||||
| `heading-rhythm` | ask | 71 | a heading nearer what precedes it than what it heads (render) | at least twice the space above a head as below it |
|
||||
| `cramped-padding` | ask | 72 | text closer than 8px (or half an em) to the side of its filled box (render) | pad the box; a 16px gutter |
|
||||
| `thin-section` | ask | 77 | a section below the hero with no mass: a narrow body on a wide canvas (render) | widen the body, add a visual, set a statement, or merge the section |
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* nothing outside its arguments.
|
||||
*
|
||||
* Ported from Nutlope/hallmark's sloplint-render.mjs observeSpace (MIT, see
|
||||
* NOTICE.md): gates 44a, 44c, 44d, 58, 77, 78, 79, 80, 81, 82, 83, F17, F18.
|
||||
* NOTICE.md): gates 44a, 44c, 44d, 58, 59, 77, 78, 79, 80, 81, 82, 83, F17, F18.
|
||||
* The posture, hero-shape, photo-shape and job guards are gone: the strict
|
||||
* form applies.
|
||||
*/
|
||||
@@ -105,7 +105,39 @@ export function glHero(P: Probes): HeroCtx {
|
||||
export function glSpace(P: Probes, atLaptop: boolean): ProbeFinding[] {
|
||||
const ctx = P.hero(P);
|
||||
if (!atLaptop) return P.wide(P, ctx);
|
||||
return [...P.words(P, ctx), ...P.slots(P), ...P.pictures(P), ...P.marks(P), ...P.photos(P, ctx), ...P.heroSpace(P, ctx), ...P.thin(P, ctx)];
|
||||
return [...P.words(P, ctx), ...P.slots(P), ...P.pictures(P), ...P.marks(P), ...P.photos(P, ctx), ...P.heroSpace(P, ctx), ...P.thin(P, ctx), ...P.gaps(P, ctx)];
|
||||
}
|
||||
|
||||
// sloplint-render observeSpace, the gaps between sections at 1440 and the section count (gate 59)
|
||||
export function glGaps(P: Probes, ctx: HeroCtx): ProbeFinding[] {
|
||||
const { at } = P.H;
|
||||
const out: ProbeFinding[] = [];
|
||||
const { secs, content } = ctx;
|
||||
const paperOf = (el: Element): string => {
|
||||
for (let q: Element | null = el; q; q = q.parentElement) {
|
||||
const cs = getComputedStyle(q);
|
||||
if (/gradient|url\(/.test(cs.backgroundImage)) return cs.backgroundColor + '|' + cs.backgroundImage;
|
||||
if (!/^(transparent|rgba\(0, 0, 0, 0\))$/.test(cs.backgroundColor)) return cs.backgroundColor;
|
||||
}
|
||||
return 'none';
|
||||
};
|
||||
const gaps: number[] = [], shifts: boolean[] = [];
|
||||
for (let i = 0; i < secs.length - 1; i++) {
|
||||
gaps.push(Math.round(content(secs[i + 1]).top - content(secs[i]).bottom));
|
||||
shifts.push(paperOf(secs[i]) !== paperOf(secs[i + 1]));
|
||||
}
|
||||
const real = gaps.filter((g) => g > -50);
|
||||
const short = gaps.filter((g, i) => g > -50 && g < (shifts[i] ? 64 : 112));
|
||||
if (real.length) {
|
||||
if (short.length) {
|
||||
const i = gaps.indexOf(Math.min(...short));
|
||||
out.push({ rule: 'section-gap', grade: 'fail', evidence: 'the smallest gap between sections is ' + Math.min(...short) + 'px (gaps: ' + real.join(', ') + ')', fix: '120px or more between sections (64px where the paper changes)', at: at(secs[i + 1]) });
|
||||
} else if (real.length > 2 && new Set(real).size === 1) {
|
||||
out.push({ rule: 'section-gap', grade: 'fail', evidence: 'every section gap is ' + real[0] + 'px', fix: 'vary the rhythm: a small, a regular and a large section gap', at: at(secs[1]) });
|
||||
}
|
||||
}
|
||||
if (secs.length > 9) out.push({ rule: 'section-gap', grade: 'warn', evidence: secs.length + ' sections on one page', fix: 'six to nine sections a page, hero and footer included; merge or cut', at: at(secs[9]) });
|
||||
return out;
|
||||
}
|
||||
|
||||
// sloplint-render observeSpace, the word budget (gate 58)
|
||||
@@ -129,6 +161,10 @@ export function glWords(P: Probes, ctx: HeroCtx): ProbeFinding[] {
|
||||
if (maxWords > 95) out.push({ rule: 'screen-word-budget', grade: 'fail', evidence: maxWords + ' words on one 900px screen (from y=' + at + ')', fix: 'one idea per screen: cut to about 80 words and make the section an object' });
|
||||
else if (maxWords > 80) out.push({ rule: 'screen-word-budget', grade: 'warn', evidence: maxWords + ' words on one 900px screen (from y=' + at + '), close to the 80-word budget' });
|
||||
if (ctx.stack > 2) out.push({ rule: 'screen-word-budget', grade: 'fail', evidence: 'the hero carries ' + ctx.stack + ' text blocks before its actions', fix: 'headline, one sentence, actions; delete the rest', at: ctx.h1 ? P.H.at(ctx.h1) : undefined });
|
||||
if (ctx.hero && ctx.h1) {
|
||||
const actions = Array.from(ctx.hero.querySelectorAll('a, button')).filter((c) => vis(c) && (c.textContent || '').trim() && !c.closest('nav, form, figure, footer'));
|
||||
if (actions.length > 2) out.push({ rule: 'screen-word-budget', grade: 'fail', evidence: 'the hero offers ' + actions.length + ' actions', fix: 'one primary action and at most one quiet second; the rest belong to their sections', at: P.H.at(actions[2]) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* space and quality measurements are in render-probe-space.ts and
|
||||
* render-probe-quality.ts.
|
||||
*/
|
||||
import { glHero, glHeroSpace, glMarks, glPhotos, glPictures, glSlots, glSpace, glThin, glWide, glWords } from './render-probe-space';
|
||||
import { glGaps, glHero, glHeroSpace, glMarks, glPhotos, glPictures, glSlots, glSpace, glThin, glWide, glWords } from './render-probe-space';
|
||||
import { glFields, glGrid, glPadding, glProse, glQuality, glRhythm, glStrain } from './render-probe-quality';
|
||||
|
||||
export interface ProbeAt { tag: string; cls: string; text: string; /** A distinguishing attribute in the open tag, like data-slot="hero" or id="main". */ attr?: string }
|
||||
@@ -109,6 +109,7 @@ export interface Probes {
|
||||
photos: typeof glPhotos;
|
||||
heroSpace: typeof glHeroSpace;
|
||||
thin: typeof glThin;
|
||||
gaps: typeof glGaps;
|
||||
wide: typeof glWide;
|
||||
quality: typeof glQuality;
|
||||
rhythm: typeof glRhythm;
|
||||
@@ -119,7 +120,7 @@ export interface Probes {
|
||||
grid: typeof glGrid;
|
||||
}
|
||||
|
||||
const PROBE_FNS = { setup: glSetup, layout: glLayout, contrast: glContrast, accent: glAccent, focus: glFocus, occlusion: glOcclusion, hide: glHide, restore: glRestore, hero: glHero, space: glSpace, words: glWords, slots: glSlots, pictures: glPictures, marks: glMarks, photos: glPhotos, heroSpace: glHeroSpace, thin: glThin, wide: glWide, quality: glQuality, rhythm: glRhythm, padding: glPadding, prose: glProse, fields: glFields, strain: glStrain, grid: glGrid };
|
||||
const PROBE_FNS = { setup: glSetup, layout: glLayout, contrast: glContrast, accent: glAccent, focus: glFocus, occlusion: glOcclusion, hide: glHide, restore: glRestore, hero: glHero, space: glSpace, words: glWords, slots: glSlots, pictures: glPictures, marks: glMarks, photos: glPhotos, heroSpace: glHeroSpace, thin: glThin, gaps: glGaps, wide: glWide, quality: glQuality, rhythm: glRhythm, padding: glPadding, prose: glProse, fields: glFields, strain: glStrain, grid: glGrid };
|
||||
|
||||
export const PROBE_GLOBAL = 'window.__gstackLint';
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ export const RULES: Record<string, RuleMeta> = {
|
||||
'radio-tab-jump': { tier: 'blocks', gate: '53', title: 'CSS-only radio tabs at position: absolute; top: 0 with no JS guard', fix: 'radios in flow at zero size, or a handler with preventScroll' },
|
||||
'double-sticky': { tier: 'blocks', gate: '56', title: 'two position: sticky; top: 0 elements', fix: 'top: var(--banner-height) on the second' },
|
||||
'screen-word-budget': { tier: 'ask', gate: '58', title: 'more than about 80 words in one 900px window, or a hero with more than two text blocks before its actions', fix: 'headline, one sentence, actions; a section is a head block, then its composition' },
|
||||
'section-gap': { tier: 'ask', gate: '59', title: 'a gap under 120px between sections, or every gap the same', fix: '120px at least, varied', pageOnly: true },
|
||||
'section-gap': { tier: 'ask', gate: '59', title: 'a gap under 120px between sections at 1440, every gap the same, or more than nine sections', fix: '120px at least, varied; six to nine sections a page', pageOnly: true, render: true },
|
||||
'heading-rhythm': { tier: 'ask', gate: '71', title: 'a heading nearer what precedes it than what it heads', fix: 'at least twice the space above a head as below it', render: true },
|
||||
'cramped-padding': { tier: 'ask', gate: '72', title: 'text closer than 8px (or half an em) to the side of its filled box', fix: 'pad the box; a 16px gutter', render: true },
|
||||
'thin-section': { tier: 'ask', gate: '77', title: 'a section below the hero with no mass: a narrow body on a wide canvas', fix: 'widen the body, add a visual, set a statement, or merge the section', render: true },
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Structure rules of the design lint (sloplint gates 3 4 6a 24a 24b 34 44d 50 53 56 58; Nutlope/hallmark,
|
||||
* MIT, see NOTICE.md), rewritten in gstack's voice. Rule ids: 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. Gate 59 (section-gap) has no static check: the render tier owns it, as it owns
|
||||
* double-sticky, screen-word-budget. Gate 59 (section-gap) has no static check: the render tier owns it (render-probe-space.ts glGaps), as it owns
|
||||
* the render halves of 34 (horizontal-scroll) and 44d.
|
||||
*/
|
||||
import { isTokenRule, pxOf, resolveVars } from '../css';
|
||||
|
||||
@@ -248,8 +248,6 @@ export const FREE_ONLY_PR_FILES = [
|
||||
'.github/ISSUE_TEMPLATE/**', // Issue templates: read by GitHub and free test/gstack-doctor.test.ts.
|
||||
'scripts/retired-command.ts', // One-release stubs for retired package scripts; no paid case imports it.
|
||||
'careful/bin/check-careful.sh', // /careful PreToolUse hook: covered by test/hook-scripts.test.ts; no paid fixture copies or runs it.
|
||||
'NOTICE.md', // Third-party attribution for people; no test or paid case reads it.
|
||||
'licenses/*.txt', // The licence texts NOTICE.md points at; same reason.
|
||||
// Reporting and launch tools: they read CI history or start a lane, never run inside a paid case.
|
||||
'scripts/test-health-report.ts',
|
||||
'scripts/bump-harness-version.ts',
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* The lint over the repository's recorded design slop page (the review eval's
|
||||
* ground truth, test/fixtures/review-eval-design-slop.{html,css}): every issue
|
||||
* the page annotates that the lint has a rule for is reported, the clean
|
||||
* fixture is not, and both agree with hallmark's own sloplint on the same
|
||||
* files (checked by hand at hallmark 7ec8682; the gate set is identical).
|
||||
* Phase 1 of plans/absorb-hallmark.md, item D7: the free regression that a
|
||||
* recorded output keeps linting the same way.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { lint } from '../lib/design-lint';
|
||||
import { exitCodeOf, formatSummary } from '../lib/design-lint/report';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const FIXTURES = path.join(ROOT, 'test', 'fixtures');
|
||||
|
||||
/** The recorded page as it was served: index.html beside the styles.css it links. */
|
||||
function recordedPage(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-recorded-'));
|
||||
fs.copyFileSync(path.join(FIXTURES, 'review-eval-design-slop.html'), path.join(dir, 'index.html'));
|
||||
fs.copyFileSync(path.join(FIXTURES, 'review-eval-design-slop.css'), path.join(dir, 'styles.css'));
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('design-lint over the recorded review slop page', () => {
|
||||
test('each annotated issue the lint has a rule for is reported, with its line, and the page exits 2', async () => {
|
||||
const dir = recordedPage();
|
||||
try {
|
||||
const r = await lint([dir], { cwd: dir });
|
||||
const at = (rule: string) => r.findings.filter((f) => f.rule === rule).map((f) => [f.grade, path.basename(f.file), f.line]);
|
||||
// Issue 1: the blacklisted face is a font literal outside the tokens
|
||||
expect(at('literal-outside-tokens')).toContainEqual(['blocks', 'styles.css', 6]);
|
||||
// Issue 5: the purple gradient's two stops are colour literals outside the tokens
|
||||
expect(at('literal-outside-tokens').filter((f) => f[2] === 14)).toHaveLength(2);
|
||||
// Issue 3: outline: none with nothing in its place
|
||||
expect(at('missing-focus-visible')).toEqual([['blocks', 'styles.css', 1]]);
|
||||
// Issue 7: the three-column icon-tile grid and its coins
|
||||
expect(at('template-cards')).toEqual([['ask', 'styles.css', 33]]);
|
||||
expect(at('icon-coin')).toEqual([['polish', 'styles.css', 51]]);
|
||||
// Issue 6: generic hero copy
|
||||
expect(at('marketing-buzzword')).toEqual([['ask', 'index.html', 1]]);
|
||||
// Tells the page does not annotate but hallmark's sloplint reports on the same files
|
||||
expect(at('pure-black-white').map((f) => f[2])).toEqual([17, 63, 85]);
|
||||
expect(at('overflow-x-unclipped')).toEqual([['ask', 'styles.css', 1]]);
|
||||
expect(at('off-scale-spacing')).toEqual([['ask', 'styles.css', 78]]);
|
||||
expect(at('skipped-heading')).toEqual([['polish', 'index.html', 21]]);
|
||||
expect(r.findings.some((f) => f.grade === 'waived')).toBe(false);
|
||||
expect(exitCodeOf(r.summary)).toBe(2);
|
||||
expect(formatSummary(r.summary)).toMatch(/^LINT_SUMMARY: blocks=11 ask=\d+ waived=0 polish=4 render=skipped: not requested$/);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -238,4 +238,25 @@ describe.skipIf(!hasBrowser)('design-lint render: the browser tier', () => {
|
||||
expect(r.of('photo-wall').map((f) => [f.grade, f.evidence])).toEqual([['ask', expect.stringMatching(/^\[1440px\] photographs cover \d\d% of the page below the hero \(3 pictures\)$/)]]);
|
||||
expect(r.of('accent-footprint').map((f) => [f.grade, f.evidence])).toEqual([['ask', expect.stringMatching(/^\[1280px\] the accent paints about \d+\.\d% of the 1280x800 viewport \(over 8%\)$/)]]);
|
||||
}, 60_000);
|
||||
|
||||
test('a hero with more than two actions reports the count under screen-word-budget; the clean two do not', async () => {
|
||||
const html = pageWith('', '', [['<a class="quiet" href="/method">Read how the close works</a>', '<a class="quiet" href="/method">Read how the close works</a>\n <a class="quiet" href="/pricing">See the pricing</a>\n <a class="quiet" href="/tour">Take the tour</a>']]);
|
||||
const r = await measure(html);
|
||||
expect(r.of('screen-word-budget').map((f) => [f.grade, f.line, f.evidence])).toEqual([['ask', lineIn(html, 'href="/pricing"'), '[1440px] the hero offers 4 actions']]);
|
||||
}, 60_000);
|
||||
|
||||
test('section gaps: sections crowded together, every gap the same, and more than nine sections', async () => {
|
||||
const block = (n: number) => '<section class="gap' + n + '"><h2>Section ' + n + ' of the close</h2><p>' + WORDS(24) + '.</p></section>';
|
||||
const crowded = await measure(pageWith('.gap1, .gap2, .gap3 { padding: 0; margin: 4px 0; }', block(1) + '\n' + block(2) + '\n' + block(3)));
|
||||
expect(crowded.of('section-gap').map((f) => [f.grade, f.evidence])).toEqual([['ask', expect.stringMatching(/^\[1440px\] the smallest gap between sections is \d+px \(gaps: [-\d, ]+\)$/)]]);
|
||||
const same = await measure(pageWith(
|
||||
'.wrap > section { padding: 0; margin: 0 0 140px; } .wrap > section:last-child { margin-bottom: 0; } .hero { min-height: 0; } .hero figure { display: none; }',
|
||||
block(1) + '\n' + block(2) + '\n' + block(3),
|
||||
[['<section class="story">', '<section class="story" style="display: none">'], ['<section class="ledger">', '<section class="ledger" style="display: none">'], ['<section class="close">', '<section class="close" style="display: none">']],
|
||||
));
|
||||
expect(same.of('section-gap').map((f) => [f.grade, f.evidence])).toEqual([['ask', expect.stringMatching(/^\[1440px\] every section gap is \d+px$/)]]);
|
||||
const many = await measure(pageWith('.wrap > section { padding: 0; margin: 0 0 160px; }', Array.from({ length: 7 }, (_, i) => block(i + 1)).join('\n')));
|
||||
expect(many.of('section-gap').map((f) => [f.grade, f.evidence])).toContainEqual(['ask', '[1440px] 11 sections on one page']);
|
||||
expect(many.of('section-gap').find((f) => f.evidence.endsWith('sections on one page'))!.line).toBe(lineIn(many.html, 'class="gap6"'));
|
||||
}, 90_000);
|
||||
});
|
||||
@@ -13,6 +13,8 @@ import { contrastRatio, parseColor, srgbToOklch } from '../lib/design-lint/color
|
||||
import { getAttr, parseDoc, plainText } from '../lib/design-lint/html';
|
||||
import { lint, formatResult } from '../lib/design-lint/index';
|
||||
import { Reporter, exitCodeOf, finalize, formatFinding, formatSummary, summarize } from '../lib/design-lint/report';
|
||||
import { collectFiles, contextWaivers, lintStatic } from '../lib/design-lint/static';
|
||||
import { browserAvailable } from './helpers/browser-available';
|
||||
import { RULES, RULE_IDS } from '../lib/design-lint/rules/ids';
|
||||
import { FAMILIES } from '../lib/design-lint/rules';
|
||||
import { findWaivers, reasonIsBoilerplate, resolveWaivers, applyWaivers } from '../lib/design-lint/waivers';
|
||||
@@ -33,6 +35,16 @@ function tmpPage(html: string, name = 'index.html'): string {
|
||||
return file;
|
||||
}
|
||||
|
||||
/** 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-'));
|
||||
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);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('design-lint: CSS reading', () => {
|
||||
test('nested rules, media blocks and keyframes flatten with line numbers; tokens resolve through var() with fallbacks', () => {
|
||||
const css = `:root { --color-ink: oklch(20% 0.02 250); --space-4: 1rem; }
|
||||
@@ -161,6 +173,106 @@ describe('design-lint: the output contract', () => {
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(formatResult(result)).toContain('LINT_SUMMARY: blocks=0 ask=0');
|
||||
});
|
||||
|
||||
// Value: protects=every usage exit (3) the bin promises and --help's 0; fails_when=a flag check is dropped or a usage error falls through to lint() and exits 0; why_new=the bin test only covers no-args and --scope; seam=none; no_claim=stderr wording beyond the key phrase
|
||||
test('the bin exits 3 for a non-http --url, an unknown flag, a flag without its value, a path it cannot read and a tree with nothing to lint; --help exits 0', () => {
|
||||
const url = runBin(['--url', 'ftp://example.test/page']);
|
||||
expect([url.code, url.err]).toEqual([3, expect.stringContaining('http or https')]);
|
||||
const flag = runBin(['--bogus', 'x.html']);
|
||||
expect([flag.code, flag.err]).toEqual([3, expect.stringContaining('unknown flag --bogus')]);
|
||||
const value = runBin(['x.html', '--job']);
|
||||
expect([value.code, value.err]).toEqual([3, expect.stringContaining('--job needs a value')]);
|
||||
const missing = runBin(['/nowhere/design-lint/none.html']);
|
||||
expect([missing.code, missing.err]).toEqual([3, expect.stringContaining('cannot read /nowhere/design-lint/none.html')]);
|
||||
expect(missing.err).toContain('no .html, .css, .jsx, .tsx, .vue, .svelte or .astro files found');
|
||||
const nothing = runBin([tmpTree({ 'notes.txt': 'not a page', 'data.json': '{}' })]);
|
||||
expect([nothing.code, nothing.out]).toEqual([3, '']);
|
||||
const help = runBin(['--help']);
|
||||
expect([help.code, help.out.trim(), help.err]).toEqual([0, expect.stringMatching(/^usage: gstack-design-lint </), '']);
|
||||
});
|
||||
|
||||
// Value: protects=lint() wires --render/--url to the render tier: only HTML pages render, a URL is a target of its own; fails_when=render is requested but never reaches lintRender, or a URL target is dropped; why_new=lintRender is tested alone, the glue in lint() and the bin is not; seam=none; no_claim=the no-browser summary wording beyond its prefix
|
||||
test('render requested on a tree without a page says so in the summary; --url with no file is a render target of its own', async () => {
|
||||
const cssOnly = tmpTree({ 'tokens.css': ':root { --color-ink: oklch(22% 0.03 250); }', 'Hero.tsx': 'export const Hero = () => <h1 className="text-xl">Ledger</h1>;' });
|
||||
const result = await lint([cssOnly], { cwd: cssOnly, render: true, warn: () => {} });
|
||||
expect(result.files.map((f) => path.basename(f))).toEqual(['Hero.tsx', 'tokens.css']);
|
||||
expect(result.summary.render).toBe('skipped: no HTML page to render');
|
||||
expect(formatResult(result)).toMatch(/render=skipped: no HTML page to render$/);
|
||||
const nothing = await lint([cssOnly], { cwd: cssOnly, warn: () => {} });
|
||||
expect(nothing.summary.render).toBe('skipped: not requested');
|
||||
|
||||
const r = runBin(['--url', 'http://127.0.0.1:9/']);
|
||||
if (browserAvailable()) {
|
||||
expect(r.code).toBe(2);
|
||||
expect(r.out).toMatch(/^LINT: blocks render http:\/\/127\.0\.0\.1:9\/:1 could not reach http:\/\/127\.0\.0\.1:9\/: .+ \| fix: .+$/m);
|
||||
expect(r.out).toMatch(/^LINT_SUMMARY: blocks=1 ask=0 waived=0 polish=0 render=ran$/m);
|
||||
} else {
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.out).toMatch(/^LINT_SUMMARY: blocks=0 ask=0 waived=0 polish=0 render=skipped: /m);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint: the static tier\'s files and contexts', () => {
|
||||
// Value: protects=which files the walk reads and which stylesheet may carry a page's waiver; fails_when=vendored or built CSS is linted, or a linked stylesheet from elsewhere waives a page's finding; why_new=no test walks a tree with node_modules, dot or build directories, or a waiver in CSS; seam=none; no_claim=ordering inside one directory beyond sorted names
|
||||
test('the walk skips node_modules, dot folders and build output below the top, warns on an unreadable path; a waiver counts from the page\'s own stylesheet, not one it only links', () => {
|
||||
const dir = tmpTree({
|
||||
'site/index.html': '<!doctype html><html lang="en"><head><title>Ledger</title><link rel="stylesheet" href="local.css"><link rel="stylesheet" href="../shared/theme.css"></head><body><main><h1>The ledger</h1><p>The ledger separates its rows with hairline rules.</p></main></body></html>',
|
||||
'site/local.css': '/* gstack-lint-disable section-gap: the ledger separates its rows with rules */\n.row { border-top: 1px solid var(--color-line); }',
|
||||
'shared/theme.css': '/* gstack-lint-disable template-nav: the ledger keeps three links for its three modes */\n:root { --color-line: oklch(85% 0.015 80); }',
|
||||
'site/node_modules/lib/vendor.css': '.v { color: #123456; }',
|
||||
'site/.cache/stale.html': '<h1>stale</h1>',
|
||||
'site/dist/built.css': '.b { color: #654321; }',
|
||||
'dist/top.css': '.t { color: var(--color-line); }',
|
||||
});
|
||||
const warned: string[] = [];
|
||||
const files = collectFiles([path.join(dir, 'site'), path.join(dir, 'dist'), path.join(dir, 'site', 'index.html'), path.join(dir, 'gone')], (l) => warned.push(l));
|
||||
expect(files.map((f) => path.relative(dir, f))).toEqual(['site/index.html', 'site/local.css', 'dist/top.css']);
|
||||
expect(warned).toEqual([`design-lint: cannot read ${path.join(dir, 'gone')}`]);
|
||||
|
||||
const { contexts } = lintStatic([path.join(dir, 'site')], { cwd: dir, warn: (l) => { throw new Error(l); } });
|
||||
expect(contexts).toHaveLength(1);
|
||||
expect(contexts[0].cssFiles.map((c) => [path.relative(dir, c.file), c.attached ?? false])).toEqual([['site/local.css', false], ['shared/theme.css', true]]);
|
||||
const set = contextWaivers(contexts[0], dir);
|
||||
expect([...set.accepted.entries()]).toEqual([['section-gap', 'the ledger separates its rows with rules']]);
|
||||
expect(set.refused).toEqual([]);
|
||||
});
|
||||
|
||||
// Value: protects=two pages in one directory each own only the stylesheets they link; fails_when=grouping hands every stylesheet to every page, so one page's waiver answers the other's finding and the finding prints twice; why_new=every fixture directory holds one page; seam=none; no_claim=which page a stylesheet linked by neither belongs to
|
||||
test('two pages in one directory: a finding in one page\'s stylesheet is reported once and is not waived by the other page', () => {
|
||||
const dir = tmpTree({
|
||||
'a.html': '<!doctype html><html lang="en"><head><title>Ledger</title><link rel="stylesheet" href="a.css"></head><body><main><h1>The ledger</h1><p>The ledger closes the month on the first day.</p></main></body></html>',
|
||||
'a.css': ':root { --color-gray: oklch(55% 0 0); --color-ink: oklch(22% 0.03 250); }\n.rule { border-color: var(--color-gray); }',
|
||||
'b.html': '<!-- gstack-lint-disable zero-chroma-neutrals: the archive keeps its grey plates grey -->\n<!doctype html><html lang="en"><head><title>Archive</title><link rel="stylesheet" href="b.css"></head><body><main><h1>The archive</h1><p>The archive keeps its grey plates on the shelf.</p></main></body></html>',
|
||||
'b.css': ':root { --color-ink: oklch(22% 0.03 250); }\n.shelf { color: var(--color-ink); }',
|
||||
});
|
||||
const result = lintStatic([dir], { cwd: dir, warn: (l) => { throw new Error(l); } });
|
||||
expect(result.contexts.map((c) => [path.basename(c.key), c.cssFiles.map((f) => path.basename(f.file))])).toEqual([['a.html', ['a.css']], ['b.html', ['b.css']]]);
|
||||
const grey = finalize(result.findings).filter((f) => f.rule === 'zero-chroma-neutrals');
|
||||
expect(grey.map((f) => [f.grade, f.file, f.line])).toEqual([['ask', 'a.css', 1]]);
|
||||
expect(result.findings.filter((f) => f.rule === 'waiver')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint: the copy tier', () => {
|
||||
// Value: protects=design-craft tells reach the lint as findings at line 1 with their count, source copy, under the craft ids; fails_when=the craft bridge drops ids, loses the count suffix, or re-reports the static tier's 100vh and transition: all tells; why_new=no test asserts a single copy-tier finding (the fixtures only check line format); seam=none; no_claim=the craft patterns themselves, which lib/design-craft owns
|
||||
test('lorem, missing alt and alert() report with their counts at line 1 as copy findings; 100vh and transition: all stay with the static tier; a component file gets none', () => {
|
||||
const dir = tmpTree({
|
||||
'index.html': '<!doctype html>\n<html lang="en">\n<head><title>Ledger</title><meta name="description" content="The ledger closes the month."><link rel="icon" href="/icon.svg"><style>.hero { min-height: 100vh; transition: all 0.2s; }</style></head>\n<body><a class="skip" href="#main">Skip to content</a><main id="main"><h1>Lorem ipsum dolor</h1><img src="a.png"><img src="b.png"><p>Lorem ipsum again on the ledger.</p></main>\n<script>alert("saved")</script></body></html>',
|
||||
'Card.tsx': 'export const Card = () => <article className="p-4"><p>Lorem ipsum in a component</p><img src="c.png" /></article>;',
|
||||
});
|
||||
const { findings } = lintStatic([dir], { cwd: dir, warn: (l) => { throw new Error(l); } });
|
||||
const copy = findings.filter((f) => f.source === 'copy');
|
||||
expect(copy.map((f) => [f.grade, f.rule, f.file, f.line, f.evidence])).toEqual([
|
||||
['blocks', 'lorem', 'index.html', 1, 'lorem ipsum placeholder text, 2 times'],
|
||||
['ask', 'alert', 'index.html', 1, 'alert() for a message'],
|
||||
['blocks', 'img-no-alt', 'index.html', 1, 'an <img> without alt, 2 times'],
|
||||
]);
|
||||
expect(copy.find((f) => f.rule === 'img-no-alt')?.fix).toBe('add alt text (alt="" when decorative)');
|
||||
expect(findings.filter((f) => f.rule === 'vh-height').map((f) => [f.source, f.grade])).toEqual([['static', 'blocks']]);
|
||||
expect(findings.filter((f) => f.rule === 'layout-transition').map((f) => f.source)).toEqual(['static']);
|
||||
expect(findings.some((f) => f.file === 'Card.tsx' && f.source === 'copy')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint: waivers', () => {
|
||||
|
||||
Reference in new issue
Block a user