diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e4723379..c17f99527 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,13 @@ - **`bin/gstack-design-lint [--render] [--url] [--scope page|component] [--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 `` and `@import`, resolves CSS variables, converts colours to OKLCH and computes contrast, and prints one line per finding: `LINT: : | fix: `, then `LINT_SUMMARY: blocks=N ask=N waived=N polish=N render=`. 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.** `` (or the `/* */` and `//` comment forms; `impeccable-disable : ` 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: ` 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: ` 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-.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-.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. diff --git a/docs/design-lint.md b/docs/design-lint.md index 3afab4870..5f108ffea 100644 --- a/docs/design-lint.md +++ b/docs/design-lint.md @@ -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 | diff --git a/lib/design-lint/render-probe-space.ts b/lib/design-lint/render-probe-space.ts index 57c8c3d08..7c5c0bd4e 100644 --- a/lib/design-lint/render-probe-space.ts +++ b/lib/design-lint/render-probe-space.ts @@ -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; } diff --git a/lib/design-lint/render-probe.ts b/lib/design-lint/render-probe.ts index 1015ecfd3..23b1220c3 100644 --- a/lib/design-lint/render-probe.ts +++ b/lib/design-lint/render-probe.ts @@ -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'; diff --git a/lib/design-lint/rules/ids.ts b/lib/design-lint/rules/ids.ts index 7fb77ae53..78c8d1ab8 100644 --- a/lib/design-lint/rules/ids.ts +++ b/lib/design-lint/rules/ids.ts @@ -72,7 +72,7 @@ export const RULES: Record = { '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 }, diff --git a/lib/design-lint/rules/structure.ts b/lib/design-lint/rules/structure.ts index 96475cf61..aba61e4a9 100644 --- a/lib/design-lint/rules/structure.ts +++ b/lib/design-lint/rules/structure.ts @@ -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'; diff --git a/scripts/test-pr-profile.ts b/scripts/test-pr-profile.ts index 4e4eee400..d334195b0 100644 --- a/scripts/test-pr-profile.ts +++ b/scripts/test-pr-profile.ts @@ -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', diff --git a/test/design-lint-recorded.test.ts b/test/design-lint-recorded.test.ts new file mode 100644 index 000000000..76ad9276d --- /dev/null +++ b/test/design-lint-recorded.test.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 }); + } + }); +}); diff --git a/test/design-lint-render.test.ts b/test/design-lint-render.test.ts index db369c8c4..837bcd977 100644 --- a/test/design-lint-render.test.ts +++ b/test/design-lint-render.test.ts @@ -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('', '', [['Read how the close works', 'Read how the close works\n See the pricing\n Take the tour']]); + 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 ' + n + ' of the close

' + WORDS(24) + '.

'; + 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), + [['
', '