diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bc75c54d..5817c53c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [1.91.274.0] - 2026-10-27 + +**/design-html and /design-review now run the design lint, and its copy tier reports every content and ship-floor tell at its line (hallmark phase 2, GSTA-92; Garry 2026-10-26 "Do it", option a).** The two design skills still called `gstack-design-craft`, which counted tells without positions and kept accepted exceptions in finalized.json. The lint already carried the same ids, so the skills now call it, the copy tier has its own patterns with line numbers, and a page answers a tell with a waiver comment beside it instead of a side record. + +### Changed +- **/design-html** Step 4 runs `gstack-design-lint --render` after the Slop Gate and reads its exit code: 2 fixes every `blocks` line, 1 fixes or waives each `ask` line, 0 lists `polish` as notes. A missing `LINT_SUMMARY`, a `render` line or `render=skipped` is said plainly and the static lines stand. +- **Waivers live in the page.** A real customer named Nexus or a supplied 50% figure gets ``, written only for what the user supplied or confirmed. This supersedes #3422's persisted craft exceptions: `--accepted` and finalized.json's `craft_exceptions` are gone, and the hand-off lists every waiver. +- **Copy-tier tiers**: `placeholder-name`, `placeholder-brand`, `lorem`, `round-number` and `needs-proof` move from blocks to ask, so the waiver above can answer them. /design-review still files a waived `needs-proof` or `lorem`, which a live page cannot carry. +- **/design-review's craft audit** runs the lint on each saved page: `blocks` and `ask` lines are category 9 findings at their line, `waived` lines are listed with their reason, `empty-mount-node` sends the audit to the saved DOM. A saved live page has lost its linked CSS, so the audit files only its copy-tier (`craft`) lines there and judges the CSS rules by eye. +- **`lib/design-lint/rules/copy.ts`** holds the copy tier's own patterns (moved from `lib/design-craft.ts`), reporting each tell at its line with what matched. It reads visible text, script bodies and inline handlers (single-quoted included) and the parsed tags, so a hydration payload, commented-out markup or a `` string inside a script no longer counts. + +### Added +- **`empty-mount-node`** (ask, page only): a body with no text, a `<noscript>` notice aside, that loads a script is a client-rendered shell; the lint says it read none of its content instead of passing it. + +### Removed +- `bin/gstack-design-craft`, `lib/design-craft.ts` and `test/design-craft.test.ts`. Their checks are in the lint's copy tier and its tests; the archetype handoff tests moved to `test/design-craft-resolvers.test.ts`. + +### Tests +- `test/design-lint.test.ts`: the copy tier at its lines with the catalog's fixes, the clean page with inlined Pretext, the skip link and `aria-current`, visible-text reading, the four checker limits #3433 documented, and the shell check (noscript, image-only page, component scope). `test/design-lint-recorded.test.ts` reads the recorded page's buzzwords at lines 32 and 37. +- The `design-html-slop-gate` paid case looks for a `gstack-design-lint` call, and its touchfiles select on `lib/design-lint/**` and `bin/gstack-design-lint`. + ## [1.91.273.0] - 2026-10-27 **A PR body opens with the problem it solves, says what else it touches, and the prose /ship publishes is checked for autopilot tells before it goes out.** A /ship PR body began with a list of what changed, so a reader had to work out the problem from the inventory; nothing in the body said what else the change could break or how sure the author was that it would not; and a CHANGELOG entry or PR body could carry the words a model writes when it is not thinking (`a vibrant tapestry of enhancements`, `not just X, but Y`, an arrow where a sentence belongs) because nothing checked prose mechanically. Garry approved the tstack and pstack absorb plans on 2026-10-26 ("Do it, send"); this release carries tstack item 4 and pstack items 2(a) and 11. diff --git a/NOTICE.md b/NOTICE.md index b77598cb3..e5e57e646 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -46,8 +46,8 @@ signature components are not carried over): line-break and motion rules from landing-page-design Part B, the website context of design-to-motion and the redesign-skill audit; and the generated `design-html/SKILL.md` / `sections/craft.md`. -- `lib/design-craft.ts` and `bin/gstack-design-craft`: the content-realism and - ship-floor tells, turned into a free check. +- `lib/design-lint/rules/copy.ts`: the content-realism and ship-floor tells, + the design lint's copy tier. - `design-shotgun/SKILL.md.tmpl`: page archetypes as a variant axis. - `design-review/sections/craft-audit.md.tmpl` (and the generated `sections/craft-audit.md`): the redesign-skill audit of an existing site, diff --git a/VERSION b/VERSION index 93b08fb40..345857c33 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.273.0 +1.91.274.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 56c74ecd6..14424b2af 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.273.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.274.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, 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 <file.html> [--accepted <finalized.json>] -// -// Prints one "CRAFT_CHECK: <id> <count> <fix>" line per tell, then -// "CRAFT_RESULT: clean" (exit 0) or "CRAFT_RESULT: <n> 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: <id> <count> <reason>" and does not fail the check; the -// result line then adds " (<k> accepted)" or ", <k> 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 <file.html> [--accepted <finalized.json>]'; -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/design-html/SKILL.md b/design-html/SKILL.md index bafc4e1e4..2d46d1bde 100644 --- a/design-html/SKILL.md +++ b/design-html/SKILL.md @@ -732,7 +732,7 @@ _DJ=$(mktemp "${TMPDIR:-/tmp}/gstack-detect.XXXXXX"); bun --no-env-file --config Exit 2 → one surgical fix pass over the non-advisory rules in the `DETECT_TOP` block, then scan once more. Whatever remains, present the page with those findings listed as accepted-with-reason: a pattern the approved mockup contains, a value DESIGN.md's tokens bless or a pattern its Decisions Log or Do's and Don'ts records as intentional, or an inline `<!-- impeccable-disable <rule>: <reason> -->` the user agreed to. One pass, not a loop. Any other first line from the probe: skip, no ceremony. -Then, always: the craft section's check, `~/.claude/skills/gstack/bin/gstack-design-craft <path-to-finalized.html>` (plus `--accepted` per the section), one fix pass and one rerun, as above. +Then, always: the craft section's lint, `~/.claude/skills/gstack/bin/gstack-design-lint <path-to-finalized.html> --render`, one fix pass and one rerun, as above. ### Verification Screenshots @@ -833,7 +833,6 @@ Write `finalized.json` alongside the HTML: "pretext_tier": "<selected tier>", "framework": "<vanilla|react|svelte|vue>", "iterations": <number of refinement iterations>, - "craft_exceptions": [{"id": "<craft id>", "count": <n>, "reason": "<why accepted>"}], "date": "<ISO 8601>", "screen": "<screen name>", "branch": "<current branch>" diff --git a/design-html/SKILL.md.tmpl b/design-html/SKILL.md.tmpl index 80d425437..bf5314f5b 100644 --- a/design-html/SKILL.md.tmpl +++ b/design-html/SKILL.md.tmpl @@ -342,7 +342,7 @@ kill <PID> 2>/dev/null || true {{DESIGN_DETECTOR:gate}} -Then, always: the craft section's check, `{{BIN_DIR}}/gstack-design-craft <path-to-finalized.html>` (plus `--accepted` per the section), one fix pass and one rerun, as above. +Then, always: the craft section's lint, `{{BIN_DIR}}/gstack-design-lint <path-to-finalized.html> --render`, one fix pass and one rerun, as above. ### Verification Screenshots @@ -443,7 +443,6 @@ Write `finalized.json` alongside the HTML: "pretext_tier": "<selected tier>", "framework": "<vanilla|react|svelte|vue>", "iterations": <number of refinement iterations>, - "craft_exceptions": [{"id": "<craft id>", "count": <n>, "reason": "<why accepted>"}], "date": "<ISO 8601>", "screen": "<screen name>", "branch": "<current branch>" diff --git a/design-html/sections/craft.md b/design-html/sections/craft.md index e17d7ee12..6cb3cb19a 100644 --- a/design-html/sections/craft.md +++ b/design-html/sections/craft.md @@ -57,18 +57,18 @@ - Curves are named tokens: DESIGN.md's, else `cubic-bezier(0.16, 1, 0.3, 1)` for entrances and `cubic-bezier(0.65, 0, 0.35, 1)` for wipes; `linear` only for constant motion. - Loops pause off-screen. `prefers-reduced-motion: reduce` shows the final state. Full-height sections use `min-height: 100dvh`, not `100vh`. -### Craft check +### Design lint -At Step 4, after the Slop Gate and before the screenshots, run the free checker on a vanilla HTML output (for framework output, say it is unavailable): +At Step 4, after the Slop Gate and before the screenshots, run the free lint on the finalized page (`--scope component` for a component file alone; its copy tier reads HTML only, so judge those tells by eye there): ```bash -~/.claude/skills/gstack/bin/gstack-design-craft <path-to-finalized.html> +~/.claude/skills/gstack/bin/gstack-design-lint <path-to-finalized.html> --render ``` -It prints one `CRAFT_CHECK: <id> <count> <fix>` line per tell (the realism rules above, `[Needs proof:` markers, scroll listeners, `100vh`, `transition: all`, `href="#"`, `alert()`, missing alt; on a full document a missing title, description, favicon, working skip link, or `aria-current` on route navigation), then `CRAFT_RESULT: clean` or `CRAFT_RESULT: <n> issue(s)`, plus the accepted count when `--accepted` covers any. No `CRAFT_RESULT` line: say the check was unavailable and continue. Same contract as the Slop Gate: one surgical fix pass, one rerun, the rest listed as accepted-with-reason (a real customer named Nexus, a supplied 50% figure). Rerun once when the refinement loop exits; report only new lines. +It prints one `LINT: <grade> <rule-id> <file>:<line> <evidence> | fix: <fix>` line per tell (the realism rules above among them), then `LINT_SUMMARY: blocks=N ask=N waived=N polish=N render=…`. Read the exit code: 2, fix every `blocks` line (never waivable); 1, fix each `ask` line or waive it; 0, list the `polish` lines as notes. No `LINT_SUMMARY` line: say the lint was unavailable and continue. A `render` line or `render=skipped`: say the page was not measured; the static lines stand. Same contract as the Slop Gate: one surgical fix pass, one rerun. Rerun once when the refinement loop exits; report only new lines. -**Accepted exceptions persist.** Step 5's finalized.json records each one in `craft_exceptions` as `{ id, count, reason }`, carrying forward earlier ones the check still reports at the count it reports now (`[]` when none, never the template's placeholder entry). When evolving a finalized page, or rerunning on the page this run finalized, append `--accepted <that finalized.json>` to the command; never after Start fresh or for a new page, whose old exceptions covered different content: a tell it covers prints `CRAFT_ACCEPTED: <id> <count> <reason>` and does not fail the check, so name those as accepted earlier instead of fixing or re-flagging them. A count above the accepted one stays a `CRAFT_CHECK` line; it is new, so fix it or accept the new count with a reason. A `cannot use exceptions` warning with exit 2 means that record is malformed: the page was still checked with none accepted, so treat every line as new and write `craft_exceptions` correctly at Step 5. +**Waivers live in the page.** A tell that is right for this page (a real customer named Nexus, a supplied 50% figure, which `invented-proof` may also flag) gets a comment per rule, `<!-- gstack-lint-disable <rule-id>: <reason about this page, 24+ chars> -->`, and prints as `waived` with its reason on every later run, an evolve included. Waive only what the user supplied or confirmed, never a line you could fix. -**Before this can ship.** Open the Step 4 hand-off, and repeat before Step 5's "Copy to project": every `[Needs proof: …]` marker, the dependencies the page cannot own (legal URLs, form endpoint, og:image, indexing, analytics and cookie consent where required), the accepted exceptions, and every claim the check made you change. +**Before this can ship.** Open the Step 4 hand-off, and repeat before Step 5's "Copy to project": every `[Needs proof: …]` marker, the dependencies the page cannot own (legal URLs, form endpoint, og:image, indexing, analytics and cookie consent where required), every waiver, and every claim the lint made you change. Adapted from Elaya's [ai-design-skills](https://github.com/elayadesign/ai-design-skills) and [redesign-skill](https://github.com/elayadesign/redesign-skill) (MIT, © 2026 Elaya), rewritten for gstack. See NOTICE.md. diff --git a/design-html/sections/craft.md.tmpl b/design-html/sections/craft.md.tmpl index b249a50ee..a0dbcc799 100644 --- a/design-html/sections/craft.md.tmpl +++ b/design-html/sections/craft.md.tmpl @@ -55,18 +55,18 @@ - Curves are named tokens: DESIGN.md's, else `cubic-bezier(0.16, 1, 0.3, 1)` for entrances and `cubic-bezier(0.65, 0, 0.35, 1)` for wipes; `linear` only for constant motion. - Loops pause off-screen. `prefers-reduced-motion: reduce` shows the final state. Full-height sections use `min-height: 100dvh`, not `100vh`. -### Craft check +### Design lint -At Step 4, after the Slop Gate and before the screenshots, run the free checker on a vanilla HTML output (for framework output, say it is unavailable): +At Step 4, after the Slop Gate and before the screenshots, run the free lint on the finalized page (`--scope component` for a component file alone; its copy tier reads HTML only, so judge those tells by eye there): ```bash -{{BIN_DIR}}/gstack-design-craft <path-to-finalized.html> +{{BIN_DIR}}/gstack-design-lint <path-to-finalized.html> --render ``` -It prints one `CRAFT_CHECK: <id> <count> <fix>` line per tell (the realism rules above, `[Needs proof:` markers, scroll listeners, `100vh`, `transition: all`, `href="#"`, `alert()`, missing alt; on a full document a missing title, description, favicon, working skip link, or `aria-current` on route navigation), then `CRAFT_RESULT: clean` or `CRAFT_RESULT: <n> issue(s)`, plus the accepted count when `--accepted` covers any. No `CRAFT_RESULT` line: say the check was unavailable and continue. Same contract as the Slop Gate: one surgical fix pass, one rerun, the rest listed as accepted-with-reason (a real customer named Nexus, a supplied 50% figure). Rerun once when the refinement loop exits; report only new lines. +It prints one `LINT: <grade> <rule-id> <file>:<line> <evidence> | fix: <fix>` line per tell (the realism rules above among them), then `LINT_SUMMARY: blocks=N ask=N waived=N polish=N render=…`. Read the exit code: 2, fix every `blocks` line (never waivable); 1, fix each `ask` line or waive it; 0, list the `polish` lines as notes. No `LINT_SUMMARY` line: say the lint was unavailable and continue. A `render` line or `render=skipped`: say the page was not measured; the static lines stand. Same contract as the Slop Gate: one surgical fix pass, one rerun. Rerun once when the refinement loop exits; report only new lines. -**Accepted exceptions persist.** Step 5's finalized.json records each one in `craft_exceptions` as `{ id, count, reason }`, carrying forward earlier ones the check still reports at the count it reports now (`[]` when none, never the template's placeholder entry). When evolving a finalized page, or rerunning on the page this run finalized, append `--accepted <that finalized.json>` to the command; never after Start fresh or for a new page, whose old exceptions covered different content: a tell it covers prints `CRAFT_ACCEPTED: <id> <count> <reason>` and does not fail the check, so name those as accepted earlier instead of fixing or re-flagging them. A count above the accepted one stays a `CRAFT_CHECK` line; it is new, so fix it or accept the new count with a reason. A `cannot use exceptions` warning with exit 2 means that record is malformed: the page was still checked with none accepted, so treat every line as new and write `craft_exceptions` correctly at Step 5. +**Waivers live in the page.** A tell that is right for this page (a real customer named Nexus, a supplied 50% figure, which `invented-proof` may also flag) gets a comment per rule, `<!-- gstack-lint-disable <rule-id>: <reason about this page, 24+ chars> -->`, and prints as `waived` with its reason on every later run, an evolve included. Waive only what the user supplied or confirmed, never a line you could fix. -**Before this can ship.** Open the Step 4 hand-off, and repeat before Step 5's "Copy to project": every `[Needs proof: …]` marker, the dependencies the page cannot own (legal URLs, form endpoint, og:image, indexing, analytics and cookie consent where required), the accepted exceptions, and every claim the check made you change. +**Before this can ship.** Open the Step 4 hand-off, and repeat before Step 5's "Copy to project": every `[Needs proof: …]` marker, the dependencies the page cannot own (legal URLs, form endpoint, og:image, indexing, analytics and cookie consent where required), every waiver, and every claim the lint made you change. Adapted from Elaya's [ai-design-skills](https://github.com/elayadesign/ai-design-skills) and [redesign-skill](https://github.com/elayadesign/redesign-skill) (MIT, © 2026 Elaya), rewritten for gstack. See NOTICE.md. diff --git a/design-review/sections/craft-audit.md b/design-review/sections/craft-audit.md index ebcaed021..74363a93b 100644 --- a/design-review/sections/craft-audit.md +++ b/design-review/sections/craft-audit.md @@ -4,17 +4,17 @@ Run this in Phases 1-6, on every audited page, when a page is Persuade (the classifier in the Design Hard Rules) or the user asked for a redesign or a craft audit. -### 1. Run the craft check on each page +### 1. Run the design lint on each page With a URL, save the page in the script that opens it. Aside: add `await fs.writeFile(path.join(pwd, ".{page}.html"), await pg.evaluate(() => document.documentElement.outerHTML));`. Fallback engine: `_CR=$(mktemp -d "${TMPDIR:-/tmp}/gstack-craft.XXXXXX"); $B js 'document.documentElement.outerHTML' --out "$_CR/{page}.page.html" --raw`. A saved page holds session data: whatever the exit code, `rm -rf` it (or `${_CR:?}`) in the block that checks it, and never copy it to the report directory. With no running app, use the page's HTML file in the repo. ```bash -~/.claude/skills/gstack/bin/gstack-design-craft <path-to-page.html> +~/.claude/skills/gstack/bin/gstack-design-lint <path-to-page.html> ``` -Each `CRAFT_CHECK: <id> <count> <fix>` line is one category 9 finding under that id; `CRAFT_RESULT: clean` adds none; no `CRAFT_RESULT` line (framework source or an empty or unreadable file) or a mount-node shell: say the check was unavailable for that page and judge the tells below by eye. A saved page holds only inline CSS and JS: judge `100vh`, `transition: all`, scroll listeners and `alert()` by eye. For a page /design-html finalized, add `--accepted` with that screen's finalized.json (under `$GSTACK_STATE_ROOT/projects/$SLUG/designs/`; unsure: omit it): a `CRAFT_ACCEPTED` line is a finding the user accepted, listed with its reason, not filed again, except `needs-proof` and `lorem`, which a live page cannot carry. After a `cannot use exceptions` warning, file every line. A hit real user content explains (a customer named Nexus) is noted, not filed. Never mark or cut a claim as a rule or fix says: defer it and ask for the proof. +Each `blocks` or `ask` line is one category 9 finding under its rule id and line; `polish` lines are notes. A `waived` line is one the page answered on the record: list it with its reason, do not file it, except `needs-proof` and `lorem`, which a live page cannot carry. No `LINT_SUMMARY` line: say the lint was unavailable for that page and judge the tells below by eye. `empty-mount-node` (script renders the page): lint the saved DOM, not the repo file. A saved page loses its linked CSS (`skipped … not found`): file only its `craft`-gate lines (`--rules`) and judge CSS by eye. A hit real user content explains (a customer named Nexus) is noted, not filed. Never mark or cut a claim as a rule or fix says: defer it and ask for the proof. -### 2. What the check cannot see +### 2. What the lint cannot see **Claims come from the user, never from you.** Testimonials, customer names and logos, results, uptime, prices and guarantees appear only when supplied. Missing proof: leave the slot out, or mark it `[Needs proof: …]` in the warning color so it cannot ship by accident. A believable invented claim is worse than an obvious placeholder. diff --git a/design-review/sections/craft-audit.md.tmpl b/design-review/sections/craft-audit.md.tmpl index b6bad2b1b..c36a27552 100644 --- a/design-review/sections/craft-audit.md.tmpl +++ b/design-review/sections/craft-audit.md.tmpl @@ -2,17 +2,17 @@ Run this in Phases 1-6, on every audited page, when a page is Persuade (the classifier in the Design Hard Rules) or the user asked for a redesign or a craft audit. -### 1. Run the craft check on each page +### 1. Run the design lint on each page With a URL, save the page in the script that opens it. Aside: add `await fs.writeFile(path.join(pwd, ".{page}.html"), await pg.evaluate(() => document.documentElement.outerHTML));`. Fallback engine: `_CR=$(mktemp -d "${TMPDIR:-/tmp}/gstack-craft.XXXXXX"); $B js 'document.documentElement.outerHTML' --out "$_CR/{page}.page.html" --raw`. A saved page holds session data: whatever the exit code, `rm -rf` it (or `${_CR:?}`) in the block that checks it, and never copy it to the report directory. With no running app, use the page's HTML file in the repo. ```bash -{{BIN_DIR}}/gstack-design-craft <path-to-page.html> +{{BIN_DIR}}/gstack-design-lint <path-to-page.html> ``` -Each `CRAFT_CHECK: <id> <count> <fix>` line is one category 9 finding under that id; `CRAFT_RESULT: clean` adds none; no `CRAFT_RESULT` line (framework source or an empty or unreadable file) or a mount-node shell: say the check was unavailable for that page and judge the tells below by eye. A saved page holds only inline CSS and JS: judge `100vh`, `transition: all`, scroll listeners and `alert()` by eye. For a page /design-html finalized, add `--accepted` with that screen's finalized.json (under `$GSTACK_STATE_ROOT/projects/$SLUG/designs/`; unsure: omit it): a `CRAFT_ACCEPTED` line is a finding the user accepted, listed with its reason, not filed again, except `needs-proof` and `lorem`, which a live page cannot carry. After a `cannot use exceptions` warning, file every line. A hit real user content explains (a customer named Nexus) is noted, not filed. Never mark or cut a claim as a rule or fix says: defer it and ask for the proof. +Each `blocks` or `ask` line is one category 9 finding under its rule id and line; `polish` lines are notes. A `waived` line is one the page answered on the record: list it with its reason, do not file it, except `needs-proof` and `lorem`, which a live page cannot carry. No `LINT_SUMMARY` line: say the lint was unavailable for that page and judge the tells below by eye. `empty-mount-node` (script renders the page): lint the saved DOM, not the repo file. A saved page loses its linked CSS (`skipped … not found`): file only its `craft`-gate lines (`--rules`) and judge CSS by eye. A hit real user content explains (a customer named Nexus) is noted, not filed. Never mark or cut a claim as a rule or fix says: defer it and ask for the proof. -### 2. What the check cannot see +### 2. What the lint cannot see {{DESIGN_CRAFT_RULE:Claims come from the user}} diff --git a/docs/design-lint.md b/docs/design-lint.md index 12a30b758..ead69e1d0 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 <html> | | `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 <link rel="icon"> | | `missing-skip-link` | ask | craft | no skip link reaching <main> | add a "Skip to content" link first in <body> whose #id exists on <main> | | `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 @@ -308,7 +313,7 @@ literal on a page with no tokens bypasses nothing; its Tailwind half, an arbitrary value like `bg-[#0b0b12]` or `font-['Inter']`, fires on any page, since Tailwind's theme is the token block it bypasses). A project that names its tokens `--bg` or `--brand` gets none of those checks and no signal that they -did not run; Phase 2 reads the roles from DESIGN.md. +did not run; reading the roles from DESIGN.md is left to a later phase. The token block is `:root`, `html`, a `[data-theme|mode|scheme]` scope, `@theme`, `@font-face` and anything in a `tokens.css`. Its values are read in one mode: @@ -334,17 +339,19 @@ 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 `<p>` 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 -`<script>`, `<style>`, `<template>` or `<nav>` that nothing closes after it -dropped, so a page of unclosed openers costs its length there too. +case). The copy tier reads the visible text (comments, scripts, styles and +`<template>` removed, entities decoded), script bodies and inline handlers +for `alert()` and scroll listeners, and the parsed tags for links, images and +the head, so a hydration payload or commented-out markup is not counted; it +does not fetch external scripts, and its head rules run only on a document +with a `<head>`. A body with no text (a `<noscript>` notice aside) that loads +a script is an `empty-mount-node`: lint the rendered DOM instead; `--url` +measures layout, it does not read the copy. `tooltip-focus-delay` reads the delay slot of a tooltip's `transition` (the second time in each comma part) or its `transition-delay`, 100ms and over; a `0.2s` or `200ms` fade is a duration, not a delay. Where a rule id is shared with `lib/design-catalog.ts`, the tier here follows hallmark's gate (several -catalog `ask` rows are `blocks` here); Phase 2 reconciles the two before a +catalog `ask` rows are `blocks` here); a later phase reconciles the two before a catalog row is marked `detect: lint`. Also outside the lint: `system-ui` or `-apple-system` as the body's first face (`system-font-primary`; the `overused-font` rule reads display tokens and heading selectors, as hallmark's @@ -388,4 +395,4 @@ range). hallmark's theme stamp (the CSS comment that declares a theme, a posture and a hero shape) and every guard that reads it; the genre flag; the judged gates (the AI template shape, section rhythm, hover by judgement, toasts, the -reviews); the `--before` / `--kept` redesign record (Phase 2); the photo index. +reviews); the `--before` / `--kept` redesign record (left to a later phase); the photo index. diff --git a/lib/design-catalog.ts b/lib/design-catalog.ts index b2093e147..e07aa09b7 100644 --- a/lib/design-catalog.ts +++ b/lib/design-catalog.ts @@ -12,14 +12,14 @@ // │ design-html anti-slop line (catalogEntries) // ├─ scripts/resolvers/design-checklist.ts review/design-checklist.md (generated) // ├─ bin/gstack-design-detect.ts normalizes engine findings by impeccableId -// ├─ lib/design-craft.ts CRAFT_CHECK ids and their `fix` text (gstack-design-craft) +// ├─ lib/design-lint/rules/ids.ts copy-tier ids but empty-mount-node print the catalog's `fix` (test-enforced) // └─ design/src/brief.ts MOCKUP_NEVER_NAMES in the image-generation prompt // // Rule ids. An entry's `impeccableId` is set only when that id exists in // test/fixtures/impeccable-antipatterns.json (test-enforced), and rendered // prose brackets an id only in that case, so a reader never meets a bracketed // id the detector cannot emit. Everything else is a gstack-only tell that the -// LLM pass judges (or, for an entry with a `fix`, the craft check counts). The four lists this file replaced (constants.ts, the +// LLM pass judges (or, for an entry with a `fix`, the design lint's copy tier reports). The four lists this file replaced (constants.ts, the // consultation proposal section, design-html's blacklist, and the review // checklist) had drifted apart; they now render from here. @@ -60,9 +60,9 @@ export interface DesignSlopEntry { /** roles the values are banned for; present iff values is */ roles?: FontRole[]; handoff?: Handoff; - /** one-line fix the free craft check prints beside this id (lib/design-craft.ts) */ + /** one-line fix the design lint's copy tier prints beside this id (lib/design-lint/rules/copy.ts) */ fix?: string; - /** reported only by gstack-design-craft, with its fix; never rendered into skill prose (token weight) */ + /** reported only by gstack-design-lint, with its fix; never rendered into skill prose (token weight) */ checkerOnly?: true; source: 'gstack' | 'impeccable' | 'both'; /** the 11 originals; AI_SLOP_BLACKLIST derives from these verbatim */ @@ -464,7 +464,7 @@ export const DESIGN_SLOP_CATALOG: DesignSlopEntry[] = [ handoff: 'harden', source: 'gstack', }, - // ── Craft tells (GSTA-92): grep-checkable; the free craft check (lib/design-craft.ts) + // ── Craft tells (GSTA-92): grep-checkable; the design lint's copy tier (lib/design-lint/rules/copy.ts) // prints each id with its `fix`. checkerOnly keeps them out of the rendered skill lists. Adapted from Elaya's landing-page-design and // redesign-skill (MIT); see NOTICE.md. { @@ -828,7 +828,7 @@ export interface RenderCatalogOptions { omitImpact?: Impact[]; } -/** Entries skill prose renders: everything except the craft check's own ids. */ +/** Entries skill prose renders: everything except the lint-only craft ids. */ export const RENDERED: readonly DesignSlopEntry[] = DESIGN_SLOP_CATALOG.filter(e => !e.checkerOnly); export function selectCatalog(o: RenderCatalogOptions): DesignSlopEntry[] { diff --git a/lib/design-craft.ts b/lib/design-craft.ts deleted file mode 100644 index be8966ee0..000000000 --- a/lib/design-craft.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Free craft check for a page (/design-html Step 4, /design-review craft - * audit). Counts the tells that make a page read as generated or unfinished: - * placeholder content, AI cliché copy, scroll listeners, 100vh, dead links, - * and the missing ship-floor tags. Copy checks read the visible text only - * (script, style and tags removed); code checks read the source. Head checks - * run only on a full document. Adapted from Elaya's landing-page-design and - * redesign-skill (MIT); see NOTICE.md. Every id it prints is a - * lib/design-catalog.ts entry and the fix beside it is that entry's `fix` - * (test-enforced): buzzwords report under `marketing-buzzword`, whose - * heuristic words BUZZWORDS covers, and `transition: all` under - * `layout-transition`. - */ - -import { catalogEntries } from './design-catalog'; - -/** The catalog's marketing-buzzword words plus Elaya's AI cliché words. */ -export const BUZZWORDS: readonly string[] = [ - 'seamless', 'effortless', 'supercharge', 'streamline', 'revolutionize', 'unlock', 'empower', 'elevate', - 'unleash', 'next-gen', 'game-changer', 'delve', 'tapestry', 'in the world of', -]; - -function buzzwordPattern(): RegExp { - const stem = (w: string) => w.replace(/-/g, '[- ]').replace(/e$/, ''); - return new RegExp(`\\b(?:${BUZZWORDS.map(stem).join('|')})(?:e|es|ed|ing|ly|er|ers|s|d|eration)?\\b`, 'gi'); -} - -export interface CraftFinding { - id: string; - count: number; - fix: string; -} - -interface CountRule { - id: string; - on: 'source' | 'text' | 'script'; - pattern: RegExp; -} - -interface PresenceRule { - id: string; - pattern: RegExp; -} - -const COUNT_RULES: CountRule[] = [ - { id: 'placeholder-name', on: 'text', pattern: /\b(?:John|Jane) Doe\b/gi }, - { id: 'placeholder-brand', on: 'text', pattern: /\b(?:Acme|Nexus|SmartFlow)\b/g }, - { id: 'lorem', on: 'text', pattern: /\blorem ipsum\b/gi }, - { id: 'marketing-buzzword', on: 'text', pattern: buzzwordPattern() }, - { id: 'round-number', on: 'text', pattern: /(?<![\d.])(?:99\.9+|[1-9]0|100)%\s+(?:uptime|faster|more|less|fewer|better|growth|increase|reduction|of (?:teams|users|customers|companies))\b|\b(?:\d+0|\d+(?:,\d{3})+)\+?\s+(?:happy |active )?(?:users|customers|teams|companies)\b|(?<![\d.])10x\s+(?:faster|more|better)\b/gi }, - { id: 'needs-proof', on: 'text', pattern: /\[Needs proof:/g }, - { id: 'oops', on: 'text', pattern: /\boops\b/gi }, - { id: 'scroll-listener', on: 'source', pattern: /addEventListener\(\s*['"`]scroll['"`]/g }, - { id: 'layout-transition', on: 'source', pattern: /\btransition\s*:\s*all\b|\btransition-all\b/g }, - { id: 'dead-link', on: 'source', pattern: /\bhref\s*=\s*["']#["']/g }, - { id: 'alert', on: 'script', pattern: /(?<![\w.$])(?:window\.)?alert\s*\(/g }, - { id: 'img-no-alt', on: 'source', pattern: /<img\b(?![^>]*(?<![\w-])alt\s*=)[^>]*>/gi }, -]; - -const HEAD_RULES: PresenceRule[] = [ - { id: 'missing-title', pattern: /<title>\s*[^<\s][^<]*<\/title>/i }, - { id: 'missing-meta-description', pattern: /<meta\b[^>]*\bname\s*=\s*["']description["']/i }, - { id: 'missing-favicon', pattern: /<link\b[^>]*\brel\s*=\s*["'][^"']*\bicon\b/i }, -]; - -/** Every id the check can print, in print order; each is a catalog entry with a fix (test-enforced). */ -export const CRAFT_IDS: readonly string[] = [ - ...COUNT_RULES.map(r => r.id), 'vh-height', ...HEAD_RULES.map(r => r.id), 'missing-skip-link', 'missing-aria-current', -]; - -function finding(id: string, count: number): CraftFinding { - const fix = catalogEntries([id])[0].fix; - if (!fix) throw new Error(`lib/design-catalog.ts: entry "${id}" has no craft-check fix`); - return { id, count, fix }; -} - -export function visibleText(html: string): string { - return html - .replace(/<!--[\s\S]*?-->/g, ' ') - .replace(/<(script|style|template)\b[\s\S]*?<\/\1>/gi, ' ') - .replace(/<[^>]+>/g, ' ') - .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))) - .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16))) - .replace(/&(nbsp|lt|gt|quot|apos|amp);/g, (_, e) => ({ nbsp: ' ', lt: '<', gt: '>', quot: '"', apos: "'", amp: '&' })[e as string] as string) - .replace(/\s+/g, ' '); -} - -export function checkCraft(html: string): CraftFinding[] { - const text = visibleText(html); - const scripts = [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi), ...html.matchAll(/\bon[a-z]+\s*=\s*"([^"]*)"/gi)].map(m => m[1]).join('\n'); - const findings: CraftFinding[] = []; - for (const rule of COUNT_RULES) { - const count = (rule.on === 'text' ? text : rule.on === 'script' ? scripts : html).match(rule.pattern)?.length ?? 0; - if (count > 0) findings.push(finding(rule.id, count)); - } - const vh = [...html.matchAll(/\b((?:min-|max-)?height)\s*:\s*100vh\b(\s*!important)?([^}]*)/g)] - .filter(m => m[2] || !new RegExp(`(?<![\\w-])${m[1]}\\s*:\\s*100[dsl]vh\\b`).test(m[3])).length - + (html.match(/\b(?:min-)?h-screen\b/g)?.length ?? 0); - if (vh > 0) findings.push(finding('vh-height', vh)); - if (!/<head\b/i.test(html)) return findings; - for (const rule of HEAD_RULES) { - if (!rule.pattern.test(html)) findings.push(finding(rule.id, 1)); - } - const body = html.slice(html.search(/<body\b/i) + 1); - const firstFocusable = body.match(/<(?:a\b[^>]*\bhref|button|input|select|textarea)\b[^>]*>/i)?.[0] ?? ''; - const skipTarget = firstFocusable.match(/^<a\b[^>]*\bhref\s*=\s*["']#([^"']+)["']/i)?.[1]; - const main = html.match(/<main\b[^>]*(?<![\w-])id\s*=\s*["']([^"']+)["']/i)?.[1] - ?? html.match(/<[a-z]+\b[^>]*\brole\s*=\s*["']main["'][^>]*(?<![\w-])id\s*=\s*["']([^"']+)["']/i)?.[1]; - if (!skipTarget || skipTarget !== main) { - findings.push(finding('missing-skip-link', 1)); - } - const routeLinks = html.match(/<nav\b[\s\S]*?<\/nav>/gi)?.join('').match(/<a\b[^>]*\bhref\s*=\s*["'](?!#)[^"']+["']/gi)?.length ?? 0; - if (routeLinks >= 2 && !/\baria-current\s*=\s*["']page["']/i.test(html)) { - findings.push(finding('missing-aria-current', 1)); - } - return findings; -} - -/** An accepted finding recorded in finalized.json's `craft_exceptions`. */ -export interface CraftException { - id: string; - count: number; - reason: string; -} - -/** Reads `craft_exceptions` from a finalized.json; a record without one has none. Throws on a malformed record. */ -export function parseCraftExceptions(json: string): CraftException[] { - const record = JSON.parse(json); - if (typeof record !== 'object' || record === null || Array.isArray(record)) throw new Error('not a finalized.json object'); - const list = record.craft_exceptions ?? []; - const valid = (e: any) => typeof e?.id === 'string' && Number.isInteger(e.count) && e.count > 0 && typeof e.reason === 'string' && e.reason.trim() !== ''; - if (!Array.isArray(list) || !list.every(valid)) throw new Error('craft_exceptions must be a list of { id, count, reason } with a positive count and a reason'); - return list.map(({ id, count, reason }) => ({ id: id === 'transition-all' ? 'layout-transition' : id, count, reason: reason.replace(/\s+/g, ' ').trim() })); -} - -/** A finding is accepted when an exception names its id with at least its count; a higher count stays an issue and says how many were accepted. */ -export function acceptFindings(findings: CraftFinding[], exceptions: CraftException[]): { issues: CraftFinding[]; accepted: CraftException[] } { - const issues: CraftFinding[] = []; - const accepted: CraftException[] = []; - for (const finding of findings) { - const exception = exceptions.filter(e => e.id === finding.id).sort((a, b) => b.count - a.count)[0]; - if (!exception) issues.push(finding); - else if (finding.count <= exception.count) accepted.push({ id: finding.id, count: finding.count, reason: exception.reason }); - else issues.push({ ...finding, fix: `${finding.fix} (${exception.count} accepted earlier: ${exception.reason})` }); - } - return { issues, accepted }; -} - -export function formatCraft(findings: CraftFinding[], accepted: CraftException[] = []): string { - const lines = [ - ...findings.map(f => `CRAFT_CHECK: ${f.id} ${f.count} ${f.fix}`), - ...accepted.map(a => `CRAFT_ACCEPTED: ${a.id} ${a.count} ${a.reason}`), - ]; - const note = accepted.length === 0 ? '' : findings.length === 0 ? ` (${accepted.length} accepted)` : `, ${accepted.length} accepted`; - lines.push(`${findings.length === 0 ? 'CRAFT_RESULT: clean' : `CRAFT_RESULT: ${findings.length} issue(s)`}${note}`); - return lines.join('\n'); -} diff --git a/lib/design-lint/rules/copy.ts b/lib/design-lint/rules/copy.ts index 6404ee438..814618e2d 100644 --- a/lib/design-lint/rules/copy.ts +++ b/lib/design-lint/rules/copy.ts @@ -1,43 +1,166 @@ /** - * The copy tier: gstack-design-craft's content-realism and ship-floor tells - * (lib/design-craft.ts, Elaya-derived, MIT) under the lint's contract. The - * craft check counts occurrences without positions, so these report at the - * document's first line with the count as evidence until Phase 2 folds the - * patterns into the lint. Tells the static tier already reports with a line - * (transition: all under layout-transition, 100vh under vh-height) are left - * to it. + * The copy tier: content realism and the ship floor (Elaya-derived, MIT; see + * NOTICE.md), each tell reported at its line. Copy rules read the visible + * text (comments, scripts, styles and templates removed, entities decoded); + * code rules read script bodies and inline handlers; tag rules read the + * parsed tags, so a hydration payload or commented-out markup is never + * counted. Head rules run only on a document with a <head>. A page whose body + * holds no text and loads a script is an empty mount node: script renders it, + * so nothing here saw its content, and it says so instead of passing. */ -import { checkCraft } from '../../design-craft'; import type { LintContext } from '../context'; -import type { Doc } from '../html'; +import { getAttr, hasAttr, stripTags, textOf, treeOf, type Doc, type Tag } from '../html'; import type { Reporter } from '../report'; -import { ruleMeta } from './ids'; +import { RULES } from './ids'; -const STATIC_OWNS = new Set(['layout-transition', 'vh-height']); +/** The catalog's marketing-buzzword words plus Elaya's AI cliché words. */ +export const BUZZWORDS: readonly string[] = [ + 'seamless', 'effortless', 'supercharge', 'streamline', 'revolutionize', 'unlock', 'empower', 'elevate', + 'unleash', 'next-gen', 'game-changer', 'delve', 'tapestry', 'in the world of', +]; -/** The elements the craft check reads from open tag to close tag with a lazy scan. */ -const SPANNED = new Set(['script', 'style', 'template', 'nav']); +const stem = (w: string) => w.replace(/-/g, '[- ]').replace(/e$/, ''); -/** The page as the craft check reads it: an open script, style, template or nav tag that nothing closes after it is blanked, so each lazy scan to its close finds one and the page costs its length. */ -function craftSource(doc: Doc): string { - const lastClose = new Map<string, number>(); - for (const t of doc.tags) if (t.close && SPANNED.has(t.name)) lastClose.set(t.name, t.pos); - let out = '', from = 0; - for (const t of doc.tags) { - if (t.close || !SPANNED.has(t.name) || t.pos < (lastClose.get(t.name) ?? -1)) continue; - out += doc.src.slice(from, t.pos) + ' '; - from = t.pos + 1; +const TEXT_RULES: Array<[string, RegExp]> = [ + ['placeholder-name', /\b(?:John|Jane) Doe\b/gi], + ['placeholder-brand', /\b(?:Acme|Nexus|SmartFlow)\b/g], + ['lorem', /\blorem ipsum\b/gi], + ['marketing-buzzword', new RegExp(`\\b(?:${BUZZWORDS.map(stem).join('|')})(?:e|es|ed|ing|ly|er|ers|s|d|eration)?\\b`, 'gi')], + ['round-number', /(?<![\d.])(?:99\.9+|[1-9]0|100)%\s+(?:uptime|faster|more|less|fewer|better|growth|increase|reduction|of (?:teams|users|customers|companies))\b|\b(?:\d+0|\d+(?:,\d{3})+)\+?\s+(?:happy |active )?(?:users|customers|teams|companies)\b|(?<![\d.])10x\s+(?:faster|more|better)\b/gi], + ['needs-proof', /\[Needs proof:/g], + ['oops', /\boops\b/gi], +]; + +const SCRIPT_RULES: Array<[string, RegExp]> = [ + ['scroll-listener', /addEventListener\(\s*['"`]scroll['"`]/g], + ['alert', /(?<![\w.$])(?:window\.)?alert\s*\(/g], +]; + +/** Every id the copy tier can print. */ +export const COPY_IDS: readonly string[] = [ + ...TEXT_RULES.map(([id]) => id), ...SCRIPT_RULES.map(([id]) => id), 'dead-link', 'img-no-alt', + 'missing-title', 'missing-meta-description', 'missing-favicon', 'missing-skip-link', 'missing-aria-current', 'empty-mount-node', +]; + +const ENTITY = /&(?:#(\d{1,7})|#x([0-9a-f]{1,6})|(nbsp|lt|gt|quot|apos|amp));/iy; +const NAMED: Record<string, string> = { nbsp: ' ', lt: '<', gt: '>', quot: '"', apos: "'", amp: '&' }; + +/** The visible text with each character's source position: tags, comments, scripts, styles and the dropped elements removed, entities decoded, whitespace folded. */ +function visible(doc: Doc, drop: ReadonlySet<string>): { text: string; at: number[] } { + const { close } = treeOf(doc); + const ranges: Array<[number, number]> = []; + doc.tags.forEach((t, i) => { + if (!t.close && !t.selfClosing && drop.has(t.name)) ranges.push([t.pos, close[i] < 0 ? doc.masked.length : doc.tags[close[i]].end]); + }); + let blanked = '', from = 0; + for (const [a, b] of ranges) if (a >= from) { blanked += doc.masked.slice(from, a) + ' '.repeat(b - a); from = b; } + const src = stripTags(blanked + doc.masked.slice(from), true); + let text = ''; + const at: number[] = []; + const push = (ch: string, pos: number) => { + if (/\s/.test(ch)) { if (!text.endsWith(' ')) { text += ' '; at.push(pos); } return; } + for (let k = 0; k < ch.length; k++) at.push(pos); + text += ch; + }; + for (let i = 0; i < src.length;) { + if (src[i] === '&') { + ENTITY.lastIndex = i; + const m = ENTITY.exec(src); + const cp = m && (m[1] ? Number(m[1]) : m[2] ? parseInt(m[2], 16) : -1); + if (m && (cp === -1 || cp! <= 0x10ffff)) { + push(m[3] ? NAMED[m[3].toLowerCase()] : String.fromCodePoint(cp!), i); + i += m[0].length; + continue; + } + } + const c = src.codePointAt(i)!; + const ch = String.fromCodePoint(c); + push(ch, i); + i += ch.length; } - return out + doc.src.slice(from); + return { text, at }; } +/** Each script body and inline event handler, with the position its code starts. */ +function codeOf(doc: Doc): Array<{ code: string; pos: number }> { + const { close } = treeOf(doc); + const out: Array<{ code: string; pos: number }> = []; + doc.tags.forEach((t, i) => { + if (t.close) return; + if (t.name === 'script' && close[i] >= 0) out.push({ code: doc.src.slice(t.end, doc.tags[close[i]].pos), pos: t.end }); + for (const m of t.attrs.matchAll(/(?:^|\s)on[a-z]+\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)) { + const code = m[1] ?? m[2]; + out.push({ code, pos: t.pos + 1 + t.name.length + m.index! + m[0].length - 1 - code.length }); + } + }); + return out; +} + +const firstOpen = (doc: Doc, name: string): number => doc.tags.findIndex((t) => !t.close && t.name === name); + export function run(ctx: LintContext, r: Reporter): void { for (const doc of ctx.docs) { if (doc.kind !== 'html') continue; - for (const f of checkCraft(craftSource(doc))) { - if (STATIC_OWNS.has(f.id) || !ruleMeta(f.id)) continue; - const title = ruleMeta(f.id)!.title; - r.report(f.id, doc.file, 1, f.count === 1 ? title : `${title}, ${f.count} times`, { fix: f.fix, source: 'copy' }); + const hits = new Map<string, Map<number, string[]>>(); + const hit = (id: string, pos: number, what: string) => { + const byLine = hits.get(id) ?? hits.set(id, new Map()).get(id)!; + const line = doc.lineOf(pos); + const found = byLine.get(line); + if (found) found.push(what); + else byLine.set(line, [what]); + }; + const { text, at } = visible(doc, new Set(['template'])); + for (const [id, re] of TEXT_RULES) for (const m of text.matchAll(re)) hit(id, at[m.index!], `"${m[0]}"`); + for (const { code, pos } of codeOf(doc)) for (const [id, re] of SCRIPT_RULES) for (const m of code.matchAll(re)) hit(id, pos + m.index!, ''); + for (const t of doc.tags) { + if (t.close) continue; + if (getAttr(t.attrs, 'href')?.trim() === '#') hit('dead-link', t.pos, ''); + if (t.name === 'img' && !hasAttr(t.attrs, 'alt')) hit('img-no-alt', t.pos, ''); + } + const head = firstOpen(doc, 'head'); + const bodyIdx = firstOpen(doc, 'body'); + const bodyTag: Tag | undefined = doc.tags[bodyIdx]; + if (head >= 0) { + const at0 = doc.tags[head].pos; + const opens = (name: string) => doc.tags.map((t, i) => [t, i] as const).filter(([t]) => !t.close && t.name === name); + if (!opens('title').some(([, i]) => textOf(doc, i).trim() !== '')) hit('missing-title', at0, ''); + if (!opens('meta').some(([t]) => getAttr(t.attrs, 'name')?.trim().toLowerCase() === 'description')) hit('missing-meta-description', at0, ''); + if (!opens('link').some(([t]) => /\bicon\b/i.test(getAttr(t.attrs, 'rel') ?? ''))) hit('missing-favicon', at0, ''); + const focusable = doc.tags.slice(Math.max(bodyIdx, 0)).find((t) => !t.close && ((t.name === 'a' && hasAttr(t.attrs, 'href')) || /^(?:button|input|select|textarea)$/.test(t.name))); + const skipTarget = focusable?.name === 'a' ? getAttr(focusable.attrs, 'href')?.match(/^#(.+)$/)?.[1] : undefined; + const mainTag = doc.tags.find((t) => !t.close && t.name === 'main' && getAttr(t.attrs, 'id')) + ?? doc.tags.find((t) => !t.close && getAttr(t.attrs, 'role') === 'main' && getAttr(t.attrs, 'id')); + if (!skipTarget || skipTarget !== getAttr(mainTag?.attrs ?? '', 'id')) hit('missing-skip-link', bodyTag?.pos ?? at0, ''); + const { close } = treeOf(doc); + let firstNav = -1, navEnd = -1, routeLinks = 0; + for (let i = 0; i < doc.tags.length && routeLinks < 2; i++) { + const t = doc.tags[i]; + if (t.close) continue; + if (t.name === 'nav') { + if (firstNav < 0) firstNav = i; + navEnd = Math.max(navEnd, close[i] < 0 ? doc.tags.length : close[i]); + } else if (i < navEnd && t.name === 'a' && /^[^#]/.test(getAttr(t.attrs, 'href') ?? '')) routeLinks++; + } + if (routeLinks >= 2 && !doc.tags.some((t) => !t.close && getAttr(t.attrs, 'aria-current') === 'page')) hit('missing-aria-current', doc.tags[firstNav].pos, ''); + } + const headClose = head >= 0 ? treeOf(doc).close[head] : -1; + const bodyFrom = bodyTag?.pos ?? (headClose >= 0 ? doc.tags[headClose].end : 0); + const shellText = visible(doc, new Set(['template', 'noscript'])); + const bodyStart = shellText.at.findIndex((p) => p >= bodyFrom); + const bodyText = bodyStart < 0 ? '' : shellText.text.slice(bodyStart); + if (!/[\p{L}\p{N}]/u.test(bodyText) && doc.tags.some((t) => !t.close && t.name === 'script')) { + const { close } = treeOf(doc); + const mount = doc.tags.find((t, i) => !t.close && t.pos >= bodyFrom && !t.selfClosing && t.name !== 'script' && t.name !== 'noscript' && t.name !== 'body' + && (getAttr(t.attrs, 'id') !== null || t.name.includes('-')) && close[i] === i + 1); + hit('empty-mount-node', mount?.pos ?? bodyFrom, mount ? `<${mount.name}${getAttr(mount.attrs, 'id') ? ` id="${getAttr(mount.attrs, 'id')}"` : ''}>` : ''); + } + for (const [id, byLine] of hits) { + const title = RULES[id].title; + for (const [line, found] of byLine) { + const named = [...new Set(found.filter(Boolean))]; + const evidence = named.length ? `${title}: ${named.join(', ')}` : found.length > 1 ? `${title}, ${found.length} on this line` : title; + r.report(id, doc.file, line, evidence, { source: 'copy' }); + } } } } diff --git a/lib/design-lint/rules/ids.ts b/lib/design-lint/rules/ids.ts index 89ee67eff..6a328fa99 100644 --- a/lib/design-lint/rules/ids.ts +++ b/lib/design-lint/rules/ids.ts @@ -21,7 +21,7 @@ export type Tier = 'blocks' | 'ask' | 'polish'; export interface RuleMeta { tier: Tier; - /** The sloplint gate (or gstack-design-craft id) this rule was ported from. */ + /** The sloplint gate this rule was ported from, or `craft` for the copy tier (rules/copy.ts). */ gate: string; /** One line naming the tell. */ title: string; @@ -151,13 +151,14 @@ export const RULES: Record<string, RuleMeta> = { 'justified-text': { tier: 'polish', gate: 'F22', title: 'justified text without hyphens: auto', fix: 'hyphens: auto, or left-aligned' }, 'missing-lang': { tier: 'polish', gate: 'F23', title: 'a document without lang', fix: 'lang on <html>' }, 'hairline-under-shadow': { tier: 'polish', gate: 'F24', title: 'a hairline border under a wide soft shadow on one surface', fix: 'the shadow or the border, not both' }, - // ── Copy tier (gstack-design-craft, Elaya-derived; same ids) ── - 'placeholder-name': { tier: 'blocks', gate: 'craft', title: 'a placeholder name (John Doe)', fix: 'use a supplied name, or one that fits the product in sample data' }, - 'placeholder-brand': { tier: 'blocks', gate: 'craft', title: 'a placeholder brand (Acme, Nexus)', fix: 'use a supplied customer, or a fitting name in sample data' }, - 'lorem': { tier: 'blocks', gate: 'craft', title: 'lorem ipsum placeholder text', fix: 'write real copy' }, + // ── Copy tier (rules/copy.ts, Elaya-derived). The content-realism ids sit at ask: a real customer named + // Nexus or a supplied 50% figure is answered by a waiver that names the page (Garry, 2026-10-26, option a). ── + 'placeholder-name': { tier: 'ask', gate: 'craft', title: 'a placeholder name (John Doe)', fix: 'use a supplied name, or one that fits the product in sample data' }, + 'placeholder-brand': { tier: 'ask', gate: 'craft', title: 'a placeholder brand (Acme, Nexus)', fix: 'use a supplied customer, or a fitting name in sample data' }, + 'lorem': { tier: 'ask', gate: 'craft', title: 'lorem ipsum placeholder text', fix: 'write real copy' }, 'marketing-buzzword': { tier: 'ask', gate: 'craft', title: 'a marketing buzzword', fix: 'say the specific thing' }, - 'round-number': { tier: 'blocks', gate: 'craft', title: 'a round invented number', fix: 'use the supplied figure, or drop the claim' }, - 'needs-proof': { tier: 'blocks', gate: 'craft', title: 'a [Needs proof] marker left in the page', fix: 'supply the proof or remove the claim before publishing' }, + 'round-number': { tier: 'ask', gate: 'craft', title: 'a round invented number', fix: 'use the supplied figure, or drop the claim' }, + 'needs-proof': { tier: 'ask', gate: 'craft', title: 'a [Needs proof] marker left in the page', fix: 'supply the proof or remove the claim before publishing' }, 'oops': { tier: 'ask', gate: 'craft', title: '"oops" as an error message', fix: 'say what happened and what to do' }, 'scroll-listener': { tier: 'ask', gate: 'craft', title: 'a scroll event listener', fix: 'use IntersectionObserver; read scroll inside requestAnimationFrame for a scroll-linked effect' }, 'dead-link': { tier: 'ask', gate: 'craft', title: 'a dead link (href="#")', fix: 'link it, or render the control disabled' }, @@ -168,6 +169,7 @@ export const RULES: Record<string, RuleMeta> = { 'missing-favicon': { tier: 'polish', gate: 'craft', title: 'a document without a favicon', fix: 'add a favicon <link rel="icon">', pageOnly: true }, 'missing-skip-link': { tier: 'ask', gate: 'craft', title: 'no skip link reaching <main>', fix: 'add a "Skip to content" link first in <body> whose #id exists on <main>', pageOnly: true }, 'missing-aria-current': { tier: 'ask', gate: 'craft', title: 'route navigation without aria-current', fix: 'mark the current nav link aria-current="page"', pageOnly: true }, + 'empty-mount-node': { tier: 'ask', gate: 'craft', title: 'an empty mount node: the body has no text and a script renders it, so the lint read none of its content', fix: 'lint the rendered page: save document.documentElement.outerHTML to a file and lint that', pageOnly: true }, // ── The lint's own bookkeeping ── 'waiver': { tier: 'ask', gate: 'waiver', title: 'a waiver the lint refused', fix: 'fix the finding, or waive it with the rule id and a reason about this page' }, 'house-style': { tier: 'polish', gate: 'waiver', title: 'more than three rules waived on one page', fix: 'lock the choices into DESIGN.md so later pages inherit them instead of re-arguing them' }, diff --git a/package.json b/package.json index 1eb32cdc0..2318094fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.273", + "version": "1.91.274", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/design-craft-resolvers.test.ts b/test/design-craft-resolvers.test.ts index 51a12ce31..d05d961df 100644 --- a/test/design-craft-resolvers.test.ts +++ b/test/design-craft-resolvers.test.ts @@ -9,6 +9,8 @@ import { describe, expect, test } from 'bun:test'; import * as fs from 'fs'; import * as path from 'path'; import { DESIGN_CRAFT_RULE, DESIGN_PERSUADE_CHECKS } from '../scripts/resolvers/design-craft'; +import { selectTests } from './helpers/test-selection'; +import { E2E_TOUCHFILES, LLM_JUDGE_TOUCHFILES } from './helpers/touchfiles-data'; import type { TemplateContext } from '../scripts/resolvers/types'; const ROOT = path.resolve(import.meta.dir, '..'); @@ -38,15 +40,65 @@ describe('craft resolvers', () => { expect(read('plan-design-review', 'sections', 'review-sections.md')).toContain(checks); }); - test('the design-review craft audit settles each check outcome and exception kind, and keeps the saved page private', () => { + test('the design-review craft audit settles each lint outcome and waiver, and keeps the saved page private', () => { const audit = read('design-review', 'sections', 'craft-audit.md'); - expect(audit).toContain('line is one category 9 finding under that id'); - expect(audit).toContain('no `CRAFT_RESULT` line (framework source'); - expect(audit).toContain('or a mount-node shell: say the check was unavailable for that page and judge the tells below by eye'); - expect(audit).toContain('add `--accepted` with that screen\'s finalized.json'); - expect(audit).toContain('a `CRAFT_ACCEPTED` line is a finding the user accepted, listed with its reason, not filed again, except `needs-proof` and `lorem`'); - expect(audit).toContain('After a `cannot use exceptions` warning, file every line.'); + expect(audit).toContain('gstack-design-lint <path-to-page.html>'); + expect(audit).toContain('line is one category 9 finding under its rule id and line'); + expect(audit).toContain('No `LINT_SUMMARY` line: say the lint was unavailable for that page and judge the tells below by eye.'); + expect(audit).toContain('`empty-mount-node` (script renders the page): lint the saved DOM, not the repo file.'); + expect(audit).toContain('A `waived` line is one the page answered on the record: list it with its reason, do not file it, except `needs-proof` and `lorem`'); + expect(audit).not.toContain('gstack-design-craft'); + expect(audit).toContain('A saved page loses its linked CSS (`skipped … not found`): file only its `craft`-gate lines'); expect(audit).toContain('Never mark or cut a claim as a rule or fix says: defer it and ask for the proof.'); expect(audit).toContain('never copy it to the report directory'); }); }); + +describe('design-shotgun to design-html handoff', () => { + const ARCHETYPES = ['classic-hero', 'long-form-story', 'minimal-conversion', 'comparison']; + + 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 design lint selects the design-html-slop-gate E2E that runs it', () => { + for (const file of ['lib/design-lint/rules/copy.ts', 'lib/design-catalog.ts', 'bin/gstack-design-lint']) { + 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 the lint and its 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'); + }); + + // Value: protects=design-html runs the lint and keeps waivers in the page, with no finalized.json exception record left behind; fails_when=the prose names the deleted checker, --accepted or craft_exceptions again; why_new=option (a) replaced craft_exceptions with in-page waivers; seam=none; no_claim=that an agent writes good waiver reasons (paid case) + test('design-html runs the lint and records waivers in the page, not in finalized.json', () => { + const html = read('design-html', 'SKILL.md'); + const craft = read('design-html', 'sections', 'craft.md'); + expect(html).toContain('gstack-design-lint <path-to-finalized.html> --render`, one fix pass and one rerun'); + expect(craft).toContain('<!-- gstack-lint-disable <rule-id>: <reason about this page, 24+ chars> -->'); + for (const text of [html, craft]) for (const gone of ['gstack-design-craft', 'craft_exceptions', '--accepted', 'CRAFT_']) expect(text).not.toContain(gone); + }); + + // Value: protects=the content-realism tells have one checker, the lint's copy tier, and NOTICE.md credits the adapted rules to the file that ships them; fails_when=a merge restores the removed checker, its bin or its suite beside the copy tier, or NOTICE.md still credits the removed files; why_new=the fold deleted the second checker and no other test notices its return; seam=none; no_claim=what each tell detects (test/design-lint.test.ts) + test('the content-realism tells have one checker, the lint copy tier, and NOTICE.md credits it', () => { + for (const gone of ['lib/design-craft.ts', 'bin/gstack-design-craft', 'test/design-craft.test.ts']) expect(fs.existsSync(path.join(ROOT, gone))).toBe(false); + const notice = read('NOTICE.md'); + expect(notice).toContain('`lib/design-lint/rules/copy.ts`: the content-realism and ship-floor tells'); + expect(notice).not.toContain('lib/design-craft.ts'); + expect(notice).not.toContain('bin/gstack-design-craft'); + }); +}); diff --git a/test/design-craft.test.ts b/test/design-craft.test.ts deleted file mode 100644 index a93ae17c9..000000000 --- a/test/design-craft.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { acceptFindings, BUZZWORDS, CRAFT_IDS, checkCraft, formatCraft, parseCraftExceptions, visibleText } from '../lib/design-craft'; -import { catalogEntry, DESIGN_SLOP_CATALOG, detectorSlopEntries, judgmentTellEntries, renderCatalog, selectCatalog } from '../lib/design-catalog'; -import { autoFixEntries, checklistSlopEntries } from '../scripts/resolvers/design-checklist'; -import { selectTests } from './helpers/test-selection'; -import { E2E_TOUCHFILES, LLM_JUDGE_TOUCHFILES } from './helpers/touchfiles-data'; - -const ROOT = path.resolve(import.meta.dir, '..'); -const BIN = path.join(ROOT, 'bin', 'gstack-design-craft'); - -const DIRTY = `<!doctype html><html><head><style>.hero{height:100vh;transition: all .3s}</style></head><body> -<nav><a href="#">Docs</a><a href="/">Home</a><a href="/pricing">Pricing</a></nav> -<h1>Elevate your workflow seamlessly</h1><p>Trusted by Acme and John Doe. 99.99% uptime, 50% faster, 10,000+ happy users. [Needs proof: logo]</p> -<img src="a.png"><p>Oops! Something broke. Lorem ipsum.</p> -<script>window.addEventListener('scroll', () => {}); alert('hi'); showAlert(1);</script></body></html>`; - -const CLEAN = `<!doctype html><html lang="en"><head><title>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-eighth-cycle.test.ts b/test/design-lint-eighth-cycle.test.ts index 76737f663..e4a3cdd34 100644 --- a/test/design-lint-eighth-cycle.test.ts +++ b/test/design-lint-eighth-cycle.test.ts @@ -95,7 +95,7 @@ describe('design-lint eighth cycle: a file costs its length', () => { }, 60_000); test('repeated unclosed ', - '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