diff --git a/bin/gstack-design-craft b/bin/gstack-design-craft deleted file mode 100755 index 1c92b4483..000000000 --- a/bin/gstack-design-craft +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env -S bun --no-env-file --config=/dev/null -// gstack-design-craft — free craft check for a page (/design-html Step 4, -// /design-review craft audit). -// -// Usage: gstack-design-craft [--accepted ] -// -// Prints one "CRAFT_CHECK: " line per tell, then -// "CRAFT_RESULT: clean" (exit 0) or "CRAFT_RESULT: issue(s)" (exit 1). -// --accepted reads the craft_exceptions a finalized.json recorded: a tell an -// exception covers (same id, count at most the accepted count) prints as -// "CRAFT_ACCEPTED: " and does not fail the check; the -// result line then adds " ( accepted)" or ", accepted". -// Exit 2: usage error, a non-HTML file, or an unreadable or empty file, with -// nothing checked; or an unreadable or malformed exceptions file, after a -// warning and a full check with no exceptions accepted, so a bad record never -// hides the page's tells. Head and navigation rules run only on a full HTML -// document; a fragment gets the copy and code rules alone. - -import { readFileSync } from 'fs'; -import { acceptFindings, checkCraft, formatCraft, parseCraftExceptions, type CraftException } from '../lib/design-craft'; - -const USAGE = 'Usage: gstack-design-craft [--accepted ]'; -const [file, flag, acceptedFile, ...rest] = process.argv.slice(2); -if (!file || (flag !== undefined && (flag !== '--accepted' || !acceptedFile)) || rest.length > 0) { - console.error(USAGE); - process.exit(2); -} -if (!/\.html?$/i.test(file)) { - console.error(`gstack-design-craft: ${file} is not an HTML file; check the rendered HTML preview instead`); - process.exit(2); -} -let source: string; -try { - source = readFileSync(file, 'utf-8'); -} catch (err) { - console.error(`gstack-design-craft: cannot read ${file}: ${(err as Error).message}`); - process.exit(2); -} -if (source.trim() === '') { - console.error(`gstack-design-craft: ${file} is empty; nothing was checked`); - process.exit(2); -} -let exceptions: CraftException[] = []; -let badExceptions = false; -if (acceptedFile) { - try { - exceptions = parseCraftExceptions(readFileSync(acceptedFile, 'utf-8')); - } catch (err) { - console.error(`gstack-design-craft: cannot use exceptions from ${acceptedFile}: ${(err as Error).message}; checked with none accepted`); - badExceptions = true; - } -} -const { issues, accepted } = acceptFindings(checkCraft(source), exceptions); -console.log(formatCraft(issues, accepted)); -process.exit(badExceptions ? 2 : issues.length === 0 ? 0 : 1); diff --git a/docs/design-lint.md b/docs/design-lint.md index 12a30b758..8a703c43b 100644 --- a/docs/design-lint.md +++ b/docs/design-lint.md @@ -13,9 +13,9 @@ picks (Aside when it is running, otherwise the browse daemon `./setup` built). The rules are ported from [Nutlope/hallmark](https://github.com/Nutlope/hallmark)'s `sloplint` (MIT; see NOTICE.md and licenses/MIT-hallmark.txt), rewritten in gstack's voice and mapped onto gstack's severity words. The copy tier is -`gstack-design-craft`'s content-realism and ship-floor check under the same -contract. Phase 1 (this document) is the tool alone; /design-html and -/design-review call it in Phase 2. +the content-realism and ship-floor check adapted from Elaya's design skills +(MIT; see NOTICE.md), each tell at its line. /design-html runs the lint on +every finalized page and /design-review on each audited page. ## Usage @@ -271,12 +271,12 @@ waiver can answer it. | `justified-text` | polish | F22 | justified text without hyphens: auto | hyphens: auto, or left-aligned | | `missing-lang` | polish | F23 | a document without lang | lang on | | `hairline-under-shadow` | polish | F24 | a hairline border under a wide soft shadow on one surface | the shadow or the border, not both | -| `placeholder-name` | blocks | craft | a placeholder name (John Doe) | use a supplied name, or one that fits the product in sample data | -| `placeholder-brand` | blocks | craft | a placeholder brand (Acme, Nexus) | use a supplied customer, or a fitting name in sample data | -| `lorem` | blocks | craft | lorem ipsum placeholder text | write real copy | +| `placeholder-name` | ask | craft | a placeholder name (John Doe) | use a supplied name, or one that fits the product in sample data | +| `placeholder-brand` | ask | craft | a placeholder brand (Acme, Nexus) | use a supplied customer, or a fitting name in sample data | +| `lorem` | ask | craft | lorem ipsum placeholder text | write real copy | | `marketing-buzzword` | ask | craft | a marketing buzzword | say the specific thing | -| `round-number` | blocks | craft | a round invented number | use the supplied figure, or drop the claim | -| `needs-proof` | blocks | craft | a [Needs proof] marker left in the page | supply the proof or remove the claim before publishing | +| `round-number` | ask | craft | a round invented number | use the supplied figure, or drop the claim | +| `needs-proof` | ask | craft | a [Needs proof] marker left in the page | supply the proof or remove the claim before publishing | | `oops` | ask | craft | "oops" as an error message | say what happened and what to do | | `scroll-listener` | ask | craft | a scroll event listener | use IntersectionObserver; read scroll inside requestAnimationFrame for a scroll-linked effect | | `dead-link` | ask | craft | a dead link (href="#") | link it, or render the control disabled | @@ -287,9 +287,14 @@ waiver can answer it. | `missing-favicon` | polish | craft | a document without a favicon | add a favicon | | `missing-skip-link` | ask | craft | no skip link reaching
| add a "Skip to content" link first in whose #id exists on
| | `missing-aria-current` | ask | craft | route navigation without aria-current | mark the current nav link aria-current="page" | +| `empty-mount-node` | ask | craft | an empty mount node: the body has no text and a script renders it, so the lint read none of its content | lint the rendered page: save document.documentElement.outerHTML to a file and lint that | Source gates are Nutlope/hallmark `references/gates.md` at 7ec8682; `craft` -marks a rule carried over from `gstack-design-craft` (Elaya-derived). Four +marks the copy tier (Elaya-derived). Its content-realism ids (placeholder +people and brands, lorem, round numbers, `[Needs proof:` markers) sit at +ask, so a real customer named Nexus or a supplied 50% figure is answered by a +waiver that names the page; `empty-mount-node` says a client-rendered shell +was not read, instead of passing it. Four ids are the lint's own: `waiver` (a refused waiver, ask), `house-style` (more than three waivers, polish), `render` (a page that did not render, blocks: an unrendered page is never a pass) and `internal-error` (a page or stylesheet the lint could not read, or a rule family that @@ -334,12 +339,14 @@ no `max-width` (`line-length` reads a declared `ch` value), an uppercase paragraph at body size (`wide-tracking` reads caps at label size or tracked; a 16px untracked uppercase `

` passes), and a fixed px `width` on a container with no responsive handling (`wide-container` reads the opposite -case). The copy tier -reports at the document's first line with a count, because -`gstack-design-craft` counts matches without positions; Phase 2 folds those -patterns into the lint with lines. It reads the page with every open -``; - -const CLEAN = `Ledgerline: close the books in 2 days - - -Skip to contentPricing

-

Close the books in 2 days, not 9

Teams at Halvorsen Freight cut month-end by 47.2%. Save 20% yearly. 100% open source. 30-day refund.

-`; - -function run(file?: string, ...extra: string[]) { - const r = Bun.spawnSync([BIN, ...(file ? [file] : []), ...extra], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000 }); - return { code: r.exitCode, out: r.stdout.toString(), err: r.stderr.toString() }; -} - -describe('design-craft check', () => { - test('a page with every tell reports each one with its count', () => { - const counts = Object.fromEntries(checkCraft(DIRTY).map(f => [f.id, f.count])); - expect(counts).toEqual({ - 'placeholder-name': 1, 'placeholder-brand': 1, lorem: 1, 'marketing-buzzword': 2, 'round-number': 3, 'needs-proof': 1, oops: 1, - 'scroll-listener': 1, 'layout-transition': 1, 'dead-link': 1, alert: 1, 'img-no-alt': 1, 'vh-height': 1, - 'missing-title': 1, 'missing-meta-description': 1, 'missing-favicon': 1, - 'missing-skip-link': 1, 'missing-aria-current': 1, - }); - }); - - test('a finished page is clean: CSS 100%, a 100vh line with its 100dvh fallback, in-page anchors, a showAlert helper and inlined Pretext are not flagged', () => { - expect(checkCraft(CLEAN)).toEqual([]); - const pretext = fs.readFileSync(path.join(ROOT, 'design-html', 'vendor', 'pretext.js'), 'utf-8'); - expect(checkCraft(CLEAN.replace('', ``))).toEqual([]); - expect(formatCraft([])).toBe('CRAFT_RESULT: clean'); - }); - - test('every id the check prints is a grep-detectable catalog entry, and the fix it prints is the catalog fix', () => { - expect(new Set(CRAFT_IDS).size).toBe(CRAFT_IDS.length); - for (const id of CRAFT_IDS) { - const entry = catalogEntry(id); - expect(entry, id).toBeDefined(); - expect(entry!.detect, id).toContain('grep'); - expect(entry!.fix?.length ?? 0, id).toBeGreaterThan(0); - } - const findings = checkCraft(DIRTY); - expect(findings.map(f => f.id)).toEqual([...CRAFT_IDS]); - for (const f of findings) expect(f.fix).toBe(catalogEntry(f.id)!.fix!); - }); - - // Value: protects=checker-only craft entries render into no skill prose or checklist, and each is a printed craft id; fails_when=a renderer reads DESIGN_SLOP_CATALOG, not RENDERED, or filters on fix; why_new=only goldens catch it, until regenerated; seam=none; no_claim=byte size of any one skill (size guard owns it) - test('checker-only catalog entries are craft ids that no rendered list carries; catalog entries with a fix still render', () => { - const checkerOnly = DESIGN_SLOP_CATALOG.filter(e => e.checkerOnly); - expect(checkerOnly.length).toBeGreaterThan(0); - const rendered = new Set([selectCatalog({}), detectorSlopEntries(), judgmentTellEntries(), checklistSlopEntries(), autoFixEntries()].flat().map(e => e.id)); - const prose = renderCatalog({ kind: 'slop' }); - for (const e of checkerOnly) { - expect(CRAFT_IDS, e.id).toContain(e.id); - expect(rendered.has(e.id), e.id).toBe(false); - expect(prose, e.id).not.toContain(e.prose); - } - for (const id of ['marketing-buzzword', 'layout-transition']) expect(rendered.has(id), id).toBe(true); - }); - - test('BUZZWORDS covers every word the catalog marketing-buzzword heuristic names', () => { - const heuristic = catalogEntry('marketing-buzzword')?.heuristic ?? ''; - const words = heuristic.replace(/^.*?for /, '').replace(/\.$/, '').split(/,\s*/); - expect(words.length).toBeGreaterThan(5); - for (const word of words) expect(BUZZWORDS).toContain(word); - expect(checkCraft('

Unlocking seamless, next gen workflows that elevate teams

').map(f => [f.id, f.count])).toEqual([['marketing-buzzword', 4]]); - expect(checkCraft('

Take the elevator to the elevation map; streamers sold here

')).toEqual([]); - }); - - test('the skip link must reach an existing id, and aria-current is required only for route navigation', () => { - const ids = (html: string) => checkCraft(html).map(f => f.id); - expect(ids(CLEAN.replace('id="main"', 'id="content"'))).toEqual(['missing-skip-link']); - const sectionNav = CLEAN.replace('HomePricing', 'FeaturesFAQ'); - expect(ids(sectionNav)).toEqual([]); - expect(ids(CLEAN.replace(' aria-current="page"', ''))).toEqual(['missing-aria-current']); - }); - - test('attribute look-alikes, encoded markers and 100vh ordering are read correctly', () => { - const ids = (html: string) => checkCraft(html).map(f => f.id); - expect(ids('')).toEqual(['img-no-alt']); - expect(ids('

[Needs proof: logo]

')).toEqual(['needs-proof']); - expect(ids(CLEAN.replace('id="main"', 'data-id="main"'))).toEqual(['missing-skip-link']); - expect(ids('')).toEqual(['vh-height']); - expect(ids('')).toEqual(['vh-height']); - }); - - test('every word and name the craft section bans is one the checker flags', () => { - const section = fs.readFileSync(path.join(ROOT, 'design-html', 'sections', 'craft.md.tmpl'), 'utf-8'); - const buzz = section.match(/No buzzwords: ([^\n]+)\.\n/)?.[1].replace(/"/g, '').split(/,\s*/) ?? []; - expect(buzz.length).toBeGreaterThan(10); - for (const word of buzz) expect(BUZZWORDS).toContain(word); - const names = section.match(/No placeholder people or brands \(([^)]+)\)/)?.[1].split(/,\s*/) ?? []; - expect(names.length).toBe(5); - for (const name of names) expect(checkCraft(`

${name}

`).map(f => f.id)).toEqual([/Doe/.test(name) ? 'placeholder-name' : 'placeholder-brand']); - }); - - test('skip link is judged by position and target; alert() counts only in script; svg text is copy', () => { - const ids = (html: string) => checkCraft(html).map(f => f.id); - expect(ids(CLEAN.replace('>Skip to content<', '>Zum Inhalt springen<'))).toEqual([]); - expect(ids(CLEAN.replace('Skip to content', ''))).toEqual(['missing-skip-link']); - expect(ids('

Set a price alert (free)

')).toEqual([]); - expect(ids('')).toEqual(['alert']); - expect(ids('Acme')).toEqual(['placeholder-brand']); - }); - - test('copy rules read visible text only; head rules skip a fragment', () => { - expect(visibleText('

Halvorsen

')).toBe(' Halvorsen '); - expect(checkCraft('

Pricing

Start free trial
')).toEqual([]); - }); - - test('the CLI prints CRAFT_CHECK lines and exits 1 on tells, 0 when clean, 2 on usage', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-craft-')); - try { - fs.writeFileSync(path.join(dir, 'dirty.html'), DIRTY); - fs.writeFileSync(path.join(dir, 'clean.html'), CLEAN); - const dirty = run(path.join(dir, 'dirty.html')); - expect(dirty.code).toBe(1); - expect(dirty.out).toContain('CRAFT_CHECK: scroll-listener 1 '); - expect(dirty.out.trim().split('\n').at(-1)).toBe('CRAFT_RESULT: 18 issue(s)'); - const clean = run(path.join(dir, 'clean.html')); - expect(clean).toMatchObject({ code: 0, out: 'CRAFT_RESULT: clean\n' }); - expect(run().code).toBe(2); - expect(run(path.join(dir, 'missing.html')).code).toBe(2); - fs.writeFileSync(path.join(dir, 'empty.html'), ' \n'); - expect(run(path.join(dir, 'empty.html')).code).toBe(2); - fs.writeFileSync(path.join(dir, 'page.tsx'), 'export const P = () =>

Acme

;'); - expect(run(path.join(dir, 'page.tsx'))).toMatchObject({ code: 2, out: '' }); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); -}); - -describe('accepted craft exceptions (finalized.json craft_exceptions)', () => { - test('an exception covers its id up to the accepted count; a higher count stays an issue and names the accepted count', () => { - const findings = checkCraft(DIRTY); - const { issues, accepted } = acceptFindings(findings, [ - { id: 'placeholder-brand', count: 1, reason: 'Acme is the real customer' }, - { id: 'round-number', count: 2, reason: 'supplied figures' }, - { id: 'scroll-listener', count: 5, reason: 'unused' }, - ]); - expect(accepted).toEqual([{ id: 'placeholder-brand', count: 1, reason: 'Acme is the real customer' }, { id: 'scroll-listener', count: 1, reason: 'unused' }]); - expect(issues.find(f => f.id === 'round-number')?.fix).toEndWith('(2 accepted earlier: supplied figures)'); - expect(issues).toHaveLength(findings.length - 2); - expect(acceptFindings([{ id: 'needs-proof', count: 1, fix: 'f' }], [{ id: 'needs-proof', count: 1, reason: 'awaiting logo' }])).toEqual({ issues: [], accepted: [{ id: 'needs-proof', count: 1, reason: 'awaiting logo' }] }); - expect(acceptFindings([{ id: 'needs-proof', count: 2, fix: 'f' }], [{ id: 'needs-proof', count: 1, reason: 'awaiting logo' }])).toEqual({ issues: [{ id: 'needs-proof', count: 2, fix: 'f (1 accepted earlier: awaiting logo)' }], accepted: [] }); - expect(formatCraft([], accepted)).toBe('CRAFT_ACCEPTED: placeholder-brand 1 Acme is the real customer\nCRAFT_ACCEPTED: scroll-listener 1 unused\nCRAFT_RESULT: clean (2 accepted)'); - const carried = [{ id: 'round-number', count: 1, reason: 'first run' }, { id: 'round-number', count: 3, reason: 'supplied figures' }]; - for (const list of [carried, [...carried].reverse()]) { - expect(acceptFindings([{ id: 'round-number', count: 3, fix: 'use the supplied figure' }], list)).toEqual({ issues: [], accepted: [{ id: 'round-number', count: 3, reason: 'supplied figures' }] }); - } - }); - - test('parseCraftExceptions reads the finalized.json design-html writes and rejects a malformed list', () => { - const skill = fs.readFileSync(path.join(ROOT, 'design-html', 'SKILL.md'), 'utf-8'); - const block = skill.slice(skill.indexOf('Write `finalized.json` alongside the HTML')).match(/```json\n([\s\S]*?)```/)![1]; - const record = JSON.parse(block.replace(/|/g, '1')); - expect(parseCraftExceptions(JSON.stringify(record))).toEqual([{ id: '', count: 1, reason: '' }]); - expect(parseCraftExceptions('{"mode":"freeform"}')).toEqual([]); - const legacy = parseCraftExceptions('{"craft_exceptions":[{"id":"transition-all","count":1,"reason":"recorded before the rename"}]}'); - expect(legacy).toEqual([{ id: 'layout-transition', count: 1, reason: 'recorded before the rename' }]); - expect(acceptFindings(checkCraft(''), legacy).accepted).toEqual([{ id: 'layout-transition', count: 1, reason: 'recorded before the rename' }]); - expect(parseCraftExceptions('{"craft_exceptions":[{"id":"oops","count":1,"reason":"quoted\\nfrom the user"}]}')).toEqual([{ id: 'oops', count: 1, reason: 'quoted from the user' }]); - for (const bad of ['[]', 'null', '{"craft_exceptions":{}}', '{"craft_exceptions":[{"id":"oops","count":0,"reason":"x"}]}', '{"craft_exceptions":[{"id":"oops","count":1,"reason":" "}]}', '{"craft_exceptions":[{"id":"oops","count":1.5,"reason":"x"}]}']) { - expect(() => parseCraftExceptions(bad)).toThrow(); - } - }); - - test('the CLI reports accepted tells as CRAFT_ACCEPTED lines: exit 0 when only accepted ones remain, 1 for the rest, 2 on a bad exceptions file after a full check with none accepted', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-craft-accepted-')); - try { - const page = path.join(dir, 'finalized.html'); - const record = path.join(dir, 'finalized.json'); - fs.writeFileSync(page, CLEAN.replace('Halvorsen Freight', 'Acme')); - fs.writeFileSync(record, JSON.stringify({ mode: 'evolve', craft_exceptions: [{ id: 'placeholder-brand', count: 1, reason: 'Acme Freight is the customer' }] })); - expect(run(page, '--accepted', record)).toMatchObject({ code: 0, out: 'CRAFT_ACCEPTED: placeholder-brand 1 Acme Freight is the customer\nCRAFT_RESULT: clean (1 accepted)\n' }); - expect(run(page).code).toBe(1); - fs.writeFileSync(page, CLEAN.replace('Halvorsen Freight', 'Acme and Nexus')); - const twice = run(page, '--accepted', record); - expect(twice.code).toBe(1); - expect(twice.out).toBe('CRAFT_CHECK: placeholder-brand 2 use a supplied customer, or a fitting name in sample data (1 accepted earlier: Acme Freight is the customer)\nCRAFT_RESULT: 1 issue(s)\n'); - fs.writeFileSync(page, DIRTY); - const dirty = run(page, '--accepted', record); - expect(dirty.code).toBe(1); - expect(dirty.out).toContain('CRAFT_ACCEPTED: placeholder-brand 1 '); - expect(dirty.out.trim().split('\n').at(-1)).toBe('CRAFT_RESULT: 17 issue(s), 1 accepted'); - fs.writeFileSync(path.join(dir, 'old.json'), JSON.stringify({ mode: 'freeform' })); - expect(run(page, '--accepted', path.join(dir, 'old.json')).out).toBe(run(page).out); - fs.writeFileSync(path.join(dir, 'bad.json'), '{"craft_exceptions":[{"id":"oops"}]}'); - fs.writeFileSync(path.join(dir, 'broken.json'), '{'); - for (const name of ['bad.json', 'broken.json', 'missing.json']) { - const bad = run(page, '--accepted', path.join(dir, name)); - expect(bad).toMatchObject({ code: 2, out: run(page).out }); - expect(bad.err).toContain('checked with none accepted'); - expect(bad.err).toContain('cannot use exceptions'); - } - fs.writeFileSync(page, CLEAN); - expect(run(page, '--accepted', path.join(dir, 'broken.json'))).toMatchObject({ code: 2, out: 'CRAFT_RESULT: clean\n' }); - expect(run(page, '--accepted').code).toBe(2); - expect(run(page, '--strict').code).toBe(2); - expect(run(page, '--accepted', record, 'extra').code).toBe(2); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - test('an exception the page no longer triggers prints nothing, and a record is read only with --accepted', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-craft-cells-')); - try { - const page = path.join(dir, 'finalized.html'); - const record = path.join(dir, 'finalized.json'); - fs.writeFileSync(page, CLEAN); - fs.writeFileSync(record, JSON.stringify({ craft_exceptions: [{ id: 'placeholder-brand', count: 1, reason: 'Acme is the customer' }] })); - expect(run(page, '--accepted', record)).toMatchObject({ code: 0, out: 'CRAFT_RESULT: clean\n' }); - expect(run(page)).toMatchObject({ code: 0, out: 'CRAFT_RESULT: clean\n', err: '' }); - expect(acceptFindings([], [{ id: 'oops', count: 1, reason: 'x' }])).toEqual({ issues: [], accepted: [] }); - expect(acceptFindings([], [{ id: 'lorem', count: 1, reason: 'draft copy' }])).toEqual({ issues: [], accepted: [] }); - fs.writeFileSync(page, CLEAN.replace('Halvorsen Freight', 'Acme and Nexus')); - expect(run(page)).toMatchObject({ code: 1, out: 'CRAFT_CHECK: placeholder-brand 2 use a supplied customer, or a fitting name in sample data\nCRAFT_RESULT: 1 issue(s)\n' }); - fs.writeFileSync(page, CLEAN); - fs.writeFileSync(record, '{'); - expect(run(page)).toMatchObject({ code: 0, out: 'CRAFT_RESULT: clean\n', err: '' }); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); -}); - -describe('design-shotgun to design-html handoff', () => { - const ARCHETYPES = ['classic-hero', 'long-form-story', 'minimal-conversion', 'comparison']; - const read = (...p: string[]) => fs.readFileSync(path.join(ROOT, ...p), 'utf-8'); - - test('design-shotgun records the chosen archetype and outline in approved.json, and design-html takes them as given', () => { - const shotgun = read('design-shotgun', 'SKILL.md'); - const craft = read('design-html', 'sections', 'craft.md'); - const html = read('design-html', 'SKILL.md'); - for (const id of ARCHETYPES) { - expect(shotgun).toContain(`\`${id}\``); - expect(craft).toContain(`\`${id}\``); - } - const save = shotgun.slice(shotgun.indexOf('## Step 6: Save & Next Steps')); - expect(save).toMatch(/add the chosen variant's `archetype` id and `sections`[^.]*to it with the Edit tool/); - expect(html.slice(html.indexOf('### Case A'), html.indexOf('### Case B'))).toContain('any `archetype`\nand `sections`'); - expect(craft).toMatch(/When approved\.json carries an `archetype`[^.]*: take it and its `sections` outline as given[^.]*do not re-pick or re-ask/); - }); - - test('a change to the craft checker selects the design-html-slop-gate E2E that runs it', () => { - for (const file of ['lib/design-craft.ts', 'lib/design-catalog.ts', 'bin/gstack-design-craft']) { - expect(selectTests([file], E2E_TOUCHFILES).selected).toContain('design-html-slop-gate'); - } - }); - - // Value: protects=a change to the shared craft resolver reruns the paid cases whose prompts it renders; fails_when=scripts/resolvers/design-craft.ts drops from those touchfile lists; why_new=the row above covers lib/design-craft.ts and the bin only; seam=none; no_claim=that the selected cases pass - test('a change to the craft resolvers selects the design-review and plan-design-review cases that render them', () => { - const e2e = selectTests(['scripts/resolvers/design-craft.ts'], E2E_TOUCHFILES).selected; - for (const name of ['design-review-fix', 'design-review-detector-shim', 'design-review-detector-shim-dom', 'design-review-plugin-handoff', 'plan-design-review-plan-mode']) expect(e2e).toContain(name); - expect(selectTests(['scripts/resolvers/design-craft.ts'], LLM_JUDGE_TOUCHFILES).selected).toContain('plan-design-review/SKILL.md passes'); - }); - - test('design-html persists accepted exceptions and passes them back with --accepted', () => { - const html = read('design-html', 'SKILL.md'); - const craft = read('design-html', 'sections', 'craft.md'); - expect(html).toMatch(/gstack-design-craft ` \(plus `--accepted` per the section\)/); - expect(craft).toContain('records each one in `craft_exceptions`'); - expect(craft).toContain('append `--accepted ` to the command'); - expect(craft).toContain('CRAFT_ACCEPTED: '); - }); -}); diff --git a/test/design-lint-recorded.test.ts b/test/design-lint-recorded.test.ts index bce66b328..436031e7a 100644 --- a/test/design-lint-recorded.test.ts +++ b/test/design-lint-recorded.test.ts @@ -44,7 +44,7 @@ describe('design-lint over the recorded review slop page', () => { expect(at('icon-coin')).toEqual([['polish', 'styles.css', 51]]); // Issue 6: generic hero copy, the two lines the design catalog names for it and the buzzword behind them expect(at('generic-hero-copy')).toEqual([['blocks', 'index.html', 12], ['blocks', 'index.html', 13]]); - expect(at('marketing-buzzword')).toEqual([['ask', 'index.html', 1]]); + expect(at('marketing-buzzword')).toEqual([['ask', 'index.html', 32], ['ask', 'index.html', 37]]); // 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]]); diff --git a/test/design-lint.test.ts b/test/design-lint.test.ts index a0e2c7a8c..8e022162f 100644 --- a/test/design-lint.test.ts +++ b/test/design-lint.test.ts @@ -17,7 +17,9 @@ import { collectFiles, contextWaivers, lintStatic, MAX_FILE_BYTES } from '../lib import type { LintContext } from '../lib/design-lint/context'; import { browserAvailable } from './helpers/browser-available'; import { RULES } from '../lib/design-lint/rules/ids'; -import { CRAFT_IDS } from '../lib/design-craft'; +import { BUZZWORDS, COPY_IDS } from '../lib/design-lint/rules/copy'; +import { catalogEntry, DESIGN_SLOP_CATALOG, detectorSlopEntries, judgmentTellEntries, renderCatalog, selectCatalog } from '../lib/design-catalog'; +import { autoFixEntries, checklistSlopEntries } from '../scripts/resolvers/design-checklist'; import { FAMILIES } from '../lib/design-lint/rules'; import { findWaivers, reasonIsBoilerplate, resolveWaivers, applyWaivers, suspectedRules } from '../lib/design-lint/waivers'; @@ -262,23 +264,138 @@ describe('design-lint: the static tier\'s files and contexts', () => { }); 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': '\n\nLedger\n

Lorem ipsum dolor

Lorem ipsum again on the ledger.

\n', - 'Card.tsx': 'export const Card = () =>

Lorem ipsum in a component

;', - }); - 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 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); + const DIRTY = ` + +

Elevate your workflow seamlessly

Trusted by Acme and John Doe. 99.99% uptime, 50% faster, 10,000+ happy users. [Needs proof: logo]

+

Oops! Something broke. Lorem ipsum.

+`; + + const CLEAN = `Ledgerline: close the books in 2 days + + +Skip to contentPricing +

Close the books in 2 days, not 9

Teams at Halvorsen Freight cut month-end by 47.2%. Save 20% yearly. 100% open source. 30-day refund.

+`; + + const copy = (html: string, scope: 'page' | 'component' = 'page') => { + const file = tmpPage(html); + return lintStatic([file], { cwd: path.dirname(file), scope, warn: () => {} }).findings.filter((f) => f.source === 'copy'); + }; + const ids = (html: string) => [...new Set(copy(html).map((f) => f.rule))].sort(); + + // Value: protects=every copy-tier tell reports at the line that holds it, under the craft ids and catalog fixes; fails_when=a rule reports at line 1, drops a tell or re-reports the static tier's 100vh and transition: all; why_new=the fold replaced lib/design-craft's counts with positions; seam=none; no_claim=render-tier findings + test('a page with every tell reports each one at its line, with what matched; 100vh and transition: all stay with the static tier', () => { + const found = copy(DIRTY).map((f) => [f.rule, f.line, f.evidence]); + expect(found).toEqual(expect.arrayContaining([ + ['dead-link', 2, 'a dead link (href="#")'], + ['missing-aria-current', 2, 'route navigation without aria-current'], + ['marketing-buzzword', 3, 'a marketing buzzword: "Elevate", "seamlessly"'], + ['placeholder-brand', 3, 'a placeholder brand (Acme, Nexus): "Acme"'], + ['placeholder-name', 3, 'a placeholder name (John Doe): "John Doe"'], + ['round-number', 3, 'a round invented number: "99.99% uptime", "50% faster", "10,000+ happy users"'], + ['needs-proof', 3, 'a [Needs proof] marker left in the page: "[Needs proof:"'], + ['img-no-alt', 4, 'an without alt'], + ['oops', 4, '"oops" as an error message: "Oops"'], + ['lorem', 4, 'lorem ipsum placeholder text: "Lorem ipsum"'], + ['scroll-listener', 5, 'a scroll event listener'], + ['alert', 5, 'alert() for a message'], + ['missing-title', 1, 'a document without a '], + ])); + expect(ids(DIRTY)).toEqual(['alert', 'dead-link', 'img-no-alt', 'lorem', 'marketing-buzzword', 'missing-aria-current', 'missing-favicon', 'missing-meta-description', 'missing-skip-link', 'missing-title', 'needs-proof', 'oops', 'placeholder-brand', 'placeholder-name', 'round-number', 'scroll-listener']); + for (const f of copy(DIRTY)) expect(f.fix, f.rule).toBe(catalogEntry(f.rule)!.fix!); + const file = tmpPage(DIRTY); + const all = lintStatic([file], { cwd: path.dirname(file), warn: () => {} }).findings; + expect(all.filter((f) => f.rule === 'vh-height').map((f) => f.source)).toEqual(['static']); + expect(all.filter((f) => f.rule === 'layout-transition').map((f) => f.source)).toEqual(['static']); + }); + + test('a finished page is clean: CSS 100%, in-page anchors, a showAlert helper and inlined Pretext are not flagged', () => { + expect(copy(CLEAN)).toEqual([]); + const pretext = fs.readFileSync(path.join(ROOT, 'design-html', 'vendor', 'pretext.js'), 'utf-8'); + expect(copy(CLEAN.replace('</body>', `<script type="module">${pretext}</script></body>`))).toEqual([]); + }); + + test('every copy-tier id is a lint rule; each but the shell check is a grep-detectable catalog entry whose fix the lint prints', () => { + expect(new Set(COPY_IDS).size).toBe(COPY_IDS.length); + for (const id of COPY_IDS) { + expect(RULES[id]?.gate, id).toBe('craft'); + if (id === 'empty-mount-node') continue; + expect(catalogEntry(id)?.detect, id).toContain('grep'); + expect(RULES[id].fix, id).toBe(catalogEntry(id)!.fix!); + } + expect(Object.keys(RULES).filter((id) => RULES[id].gate === 'craft').sort()).toEqual([...COPY_IDS].sort()); + }); + + // Value: protects=checker-only craft entries render into no skill prose or checklist, and the lint reports each; fails_when=a renderer reads DESIGN_SLOP_CATALOG, not RENDERED, or a checker-only id has no lint rule; why_new=moved from test/design-craft.test.ts with the checker; seam=none; no_claim=byte size of any one skill (size guard owns it) + test('checker-only catalog entries are lint rules that no rendered list carries; catalog entries with a fix still render', () => { + const checkerOnly = DESIGN_SLOP_CATALOG.filter((e) => e.checkerOnly); + expect(checkerOnly.length).toBeGreaterThan(0); + const rendered = new Set([selectCatalog({}), detectorSlopEntries(), judgmentTellEntries(), checklistSlopEntries(), autoFixEntries()].flat().map((e) => e.id)); + const prose = renderCatalog({ kind: 'slop' }); + for (const e of checkerOnly) { + expect(RULES[e.id], e.id).toBeDefined(); + expect(rendered.has(e.id), e.id).toBe(false); + expect(prose, e.id).not.toContain(e.prose); + } + for (const id of ['marketing-buzzword', 'layout-transition']) expect(rendered.has(id), id).toBe(true); + }); + + test('BUZZWORDS covers every word the catalog heuristic and the craft section name, and matches stems, not look-alikes', () => { + const heuristic = catalogEntry('marketing-buzzword')?.heuristic ?? ''; + const words = heuristic.replace(/^.*?for /, '').replace(/\.$/, '').split(/,\s*/); + expect(words.length).toBeGreaterThan(5); + for (const word of words) expect(BUZZWORDS).toContain(word); + const section = fs.readFileSync(path.join(ROOT, 'design-html', 'sections', 'craft.md.tmpl'), 'utf-8'); + const buzz = section.match(/No buzzwords: ([^\n]+)\.\n/)?.[1].replace(/"/g, '').split(/,\s*/) ?? []; + expect(buzz.length).toBeGreaterThan(10); + for (const word of buzz) expect(BUZZWORDS).toContain(word); + expect(copy('<p>Unlocking seamless, next gen workflows that elevate teams</p>').map((f) => f.evidence)).toEqual(['a marketing buzzword: "Unlocking", "seamless", "next gen", "elevate"']); + expect(copy('<p>Take the elevator to the elevation map; streamers sold here</p>')).toEqual([]); + const names = section.match(/No placeholder people or brands \(([^)]+)\)/)?.[1].split(/,\s*/) ?? []; + expect(names.length).toBe(5); + for (const name of names) expect(ids(`<p>${name}</p>`)).toEqual([/Doe/.test(name) ? 'placeholder-name' : 'placeholder-brand']); + }); + + test('the skip link is judged by position and target; aria-current is required only for route navigation', () => { + expect(ids(CLEAN.replace('id="main"', 'id="content"'))).toEqual(['missing-skip-link']); + expect(ids(CLEAN.replace('id="main"', 'data-id="main"'))).toEqual(['missing-skip-link']); + expect(ids(CLEAN.replace('>Skip to content<', '>Zum Inhalt springen<'))).toEqual([]); + expect(ids(CLEAN.replace('<a href="#main">Skip to content</a>', ''))).toEqual(['missing-skip-link']); + expect(ids(CLEAN.replace('<a href="/" aria-current="page">Home</a><a href="/pricing">Pricing</a>', '<a href="#features">Features</a><a href="#faq">FAQ</a>'))).toEqual([]); + expect(copy(CLEAN.replace(' aria-current="page"', '')).map((f) => [f.rule, f.line])).toEqual([['missing-aria-current', 4]]); + }); + + test('copy reads visible text: entities decode, svg text counts, and comments, scripts, styles and templates do not', () => { + expect(ids('<p>[Needs proof: logo]</p>')).toEqual(['needs-proof']); + expect(ids('<img src="a.png" data-alt="x">')).toEqual(['img-no-alt']); + expect(ids('<svg><text>Acme</text></svg>')).toEqual(['placeholder-brand']); + expect(ids('<p>Set a price alert (free)</p>')).toEqual([]); + expect(ids('<style>.a{content:"Acme"}</style><script>// John Doe</script><template><p>Lorem ipsum</p></template><p>Halvorsen</p>')).toEqual([]); + expect(ids('<section><h2>Pricing</h2><a href="/buy">Start free trial</a></section>')).toEqual([]); + }); + + // Value: protects=the documented craft-checker limits stay fixed in the fold: script payloads, single-quoted handlers, script text as presence, commented markup; fails_when=a code or presence rule reads the raw source again; why_new=#3433 listed these as checker limits for the follow-up; seam=none; no_claim=external scripts, which the lint does not fetch + test('a hydration payload, commented-out markup and script text are not page content; single-quoted handlers are read', () => { + expect(ids('<p>Halvorsen</p><script>window.__DATA__ = "<img src=a.png><a href=\\"#\\">x</a>";</script>')).toEqual([]); + expect(ids('<!-- <img src="a.png"><p>Lorem ipsum</p><a href="#">x</a> --><p>Halvorsen</p>')).toEqual([]); + expect(copy("<button onclick='alert(1)'>Go</button>\n<button onclick=\"alert(2)\">Go</button>").map((f) => [f.rule, f.line])).toEqual([['alert', 1], ['alert', 2]]); + const head = '<!doctype html><html lang="en"><head><meta name="description" content="d"><link rel="icon" href="/i.svg"><script>const t = "<title>Ledger";Skip

Halvorsen

'; + expect(ids(head)).toEqual(['missing-title']); + }); + + // Value: protects=a client-rendered shell is named an empty mount node instead of passing as clean; fails_when=a