mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
v1.91.274.0 feat(design-html,design-review): run gstack-design-lint; copy tier at its lines; waivers replace craft_exceptions (hallmark phase 2) (#3507)
* feat(design-lint): copy tier reports each tell at its line; empty-mount-node; content-realism ids at ask
The copy tier holds its own patterns (from lib/design-craft.ts) and reads
visible text, script bodies, inline handlers and parsed tags, so payloads,
commented markup and script text no longer count. A text-less body that
loads a script reports empty-mount-node. placeholder-name, placeholder-brand,
lorem, round-number and needs-proof move to ask so a page-specific waiver
answers them (Garry 2026-10-26, option a).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(design-html,design-review): run gstack-design-lint; waivers replace craft_exceptions; remove gstack-design-craft
/design-html Step 4 and /design-review's craft audit call the lint and read
its exit code. A waiver comment in the page replaces --accepted and
finalized.json craft_exceptions (supersedes #3422's persisted exceptions).
bin/gstack-design-craft, lib/design-craft.ts and its test are removed; the
handoff tests move to test/design-craft-resolvers.test.ts. The slop-gate paid
case and its touchfiles follow the lint.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(design-lint): the content-realism tells keep one checker, credited in NOTICE.md
Names the removed lib/design-craft.ts and test/design-craft.test.ts so pregate
selects a lane for the deletions (PREGATE_NO_LANE at 17635b6a7).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes
- /design-review files only craft-gate lint lines on a saved page, whose
linked CSS the lint cannot load (false missing-focus-visible blocks)
- missing-aria-current counts route links in one pass (unclosed navs were
superlinear); inline-handler tells report at the value's line; a shell
with no <body> tag is an empty mount node
- /design-html says the copy tier reads HTML only, and that a supplied
figure may also need an invented-proof waiver
- tests pin the five content-realism ids at ask and a waived copy tell
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(changelog): the saved-page craft audit files copy-tier lines only
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(design-lint): forward references say a later phase, not Phase 2
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(carve-guards): re-pin design-review and design-html parity to the merged tree's measured sizes
design-review skeleton 137735, union 144802, ratio 1.3681 (all lower);
design-html skeleton 55834, union 78626 (caps unchanged, comments updated).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
---------
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
a1934a1498
commit
d87eec950a
25 files changed
+460
-629
No files matched your search
@@ -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 <page> --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 `<!-- gstack-lint-disable <rule-id>: <reason about this page, 24+ chars> -->`, 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 `<title>` 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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
@@ -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>"
|
||||
|
||||
@@ -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>"
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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}}
|
||||
|
||||
|
||||
+25
-18
@@ -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.
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
+150
-27
@@ -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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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</title>
|
||||
<meta name="description" content="Close faster."><meta property="og:image" content="/og.png"><link rel="icon" href="/favicon.svg">
|
||||
<style>.hero{min-height:100vh;min-height:100dvh;transition: transform .4s var(--ease-out)} .w{width:100%}</style></head>
|
||||
<body><a href="#main">Skip to content</a><a href="#pricing">Pricing</a><nav><a href="/" aria-current="page">Home</a><a href="/pricing">Pricing</a></nav>
|
||||
<main id="main"><h1>Close the books in 2 days, not 9</h1><p>Teams at Halvorsen Freight cut month-end by 47.2%. Save 20% yearly. 100% open source. 30-day refund.</p><img src="x.png" alt=""></main>
|
||||
<script>const io = new IntersectionObserver(() => {}); window.showAlert = () => {};</script></body></html>`;
|
||||
|
||||
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('</body>', `<script type="module">${pretext}</script></body>`))).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('<p>Unlocking seamless, next gen workflows that elevate teams</p>').map(f => [f.id, f.count])).toEqual([['marketing-buzzword', 4]]);
|
||||
expect(checkCraft('<p>Take the elevator to the elevation map; streamers sold here</p>')).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('<a href="/" aria-current="page">Home</a><a href="/pricing">Pricing</a>', '<a href="#features">Features</a><a href="#faq">FAQ</a>');
|
||||
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('<img src="a.png" data-alt="x">')).toEqual(['img-no-alt']);
|
||||
expect(ids('<p>[Needs proof: logo]</p>')).toEqual(['needs-proof']);
|
||||
expect(ids(CLEAN.replace('id="main"', 'data-id="main"'))).toEqual(['missing-skip-link']);
|
||||
expect(ids('<style>.a{min-height:100dvh;min-height:100vh}</style>')).toEqual(['vh-height']);
|
||||
expect(ids('<style>.a{min-height:100vh !important;min-height:100dvh}</style>')).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(`<p>${name}</p>`).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('<a href="#main">Skip to content</a>', ''))).toEqual(['missing-skip-link']);
|
||||
expect(ids('<p>Set a price alert (free)</p>')).toEqual([]);
|
||||
expect(ids('<button onclick="alert(1)">Go</button>')).toEqual(['alert']);
|
||||
expect(ids('<svg><text>Acme</text></svg>')).toEqual(['placeholder-brand']);
|
||||
});
|
||||
|
||||
test('copy rules read visible text only; head rules skip a fragment', () => {
|
||||
expect(visibleText('<style>.a{content:"Acme"}</style><script>// John Doe</script><p>Halvorsen</p>')).toBe(' Halvorsen ');
|
||||
expect(checkCraft('<section><h2>Pricing</h2><a href="/buy">Start free trial</a></section>')).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 = () => <p>Acme</p>;');
|
||||
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(/<number of refinement iterations>|<n>/g, '1'));
|
||||
expect(parseCraftExceptions(JSON.stringify(record))).toEqual([{ id: '<craft id>', count: 1, reason: '<why accepted>' }]);
|
||||
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('<style>a { transition: all .2s }</style>'), 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 <path-to-finalized\.html>` \(plus `--accepted` per the section\)/);
|
||||
expect(craft).toContain('records each one in `craft_exceptions`');
|
||||
expect(craft).toContain('append `--accepted <that finalized.json>` to the command');
|
||||
expect(craft).toContain('CRAFT_ACCEPTED: <id> <count> <reason>');
|
||||
});
|
||||
});
|
||||
@@ -95,7 +95,7 @@ describe('design-lint eighth cycle: a file costs its length', () => {
|
||||
}, 60_000);
|
||||
|
||||
test('repeated unclosed <script>, <nav>, <style> and <template> openers cost the copy tier their length', () => {
|
||||
for (const tag of ['<script>', '<nav>', '<style>', '<template>']) {
|
||||
for (const tag of ['<script>', '<nav>', '<style>', '<template>', '<nav><a href="/a">A</a>']) {
|
||||
const doc = parseHtml('x.html', HEAD + '</head><body>' + tag.repeat(60_000) + '</body></html>');
|
||||
const reporter = new Reporter('page', '/');
|
||||
expect(timed(() => runCopy({ docs: [doc] } as never, reporter)), tag).toBeLessThan(3000);
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('design-lint over the recorded review slop page', () => {
|
||||
expect(at('icon-coin')).toEqual([['polish', 'styles.css', 51]]);
|
||||
// Issue 6: generic hero copy, the two lines the design catalog names for it and the buzzword behind them
|
||||
expect(at('generic-hero-copy')).toEqual([['blocks', 'index.html', 12], ['blocks', 'index.html', 13]]);
|
||||
expect(at('marketing-buzzword')).toEqual([['ask', 'index.html', 1]]);
|
||||
expect(at('marketing-buzzword')).toEqual([['ask', 'index.html', 32], ['ask', 'index.html', 37]]);
|
||||
// Tells the page does not annotate but hallmark's sloplint reports on the same files
|
||||
expect(at('pure-black-white').map((f) => f[2])).toEqual([17, 63, 85]);
|
||||
expect(at('overflow-x-unclipped')).toEqual([['ask', 'styles.css', 1]]);
|
||||
|
||||
+149
-25
@@ -17,7 +17,9 @@ import { collectFiles, contextWaivers, lintStatic, MAX_FILE_BYTES } from '../lib
|
||||
import type { LintContext } from '../lib/design-lint/context';
|
||||
import { browserAvailable } from './helpers/browser-available';
|
||||
import { RULES } from '../lib/design-lint/rules/ids';
|
||||
import { CRAFT_IDS } from '../lib/design-craft';
|
||||
import { BUZZWORDS, COPY_IDS } from '../lib/design-lint/rules/copy';
|
||||
import { catalogEntry, DESIGN_SLOP_CATALOG, detectorSlopEntries, judgmentTellEntries, renderCatalog, selectCatalog } from '../lib/design-catalog';
|
||||
import { autoFixEntries, checklistSlopEntries } from '../scripts/resolvers/design-checklist';
|
||||
import { FAMILIES } from '../lib/design-lint/rules';
|
||||
import { findWaivers, reasonIsBoilerplate, resolveWaivers, applyWaivers, suspectedRules } from '../lib/design-lint/waivers';
|
||||
|
||||
@@ -262,23 +264,152 @@ describe('design-lint: the static tier\'s files and contexts', () => {
|
||||
});
|
||||
|
||||
describe('design-lint: the copy tier', () => {
|
||||
// Value: protects=design-craft tells reach the lint as findings at line 1 with their count, source copy, under the craft ids; fails_when=the craft bridge drops ids, loses the count suffix, or re-reports the static tier's 100vh and transition: all tells; why_new=no test asserts a single copy-tier finding (the fixtures only check line format); seam=none; no_claim=the craft patterns themselves, which lib/design-craft owns
|
||||
test('lorem, missing alt and alert() report with their counts at line 1 as copy findings; 100vh and transition: all stay with the static tier; a component file gets none', () => {
|
||||
const dir = tmpTree({
|
||||
'index.html': '<!doctype html>\n<html lang="en">\n<head><title>Ledger</title><meta name="description" content="The ledger closes the month."><link rel="icon" href="/icon.svg"><style>.hero { min-height: 100vh; transition: all 0.2s; }</style></head>\n<body><a class="skip" href="#main">Skip to content</a><main id="main"><h1>Lorem ipsum dolor</h1><img src="a.png"><img src="b.png"><p>Lorem ipsum again on the ledger.</p></main>\n<script>alert("saved")</script></body></html>',
|
||||
'Card.tsx': 'export const Card = () => <article className="p-4"><p>Lorem ipsum in a component</p><img src="c.png" /></article>;',
|
||||
});
|
||||
const { findings } = lintStatic([dir], { cwd: dir, warn: (l) => { throw new Error(l); } });
|
||||
const copy = findings.filter((f) => f.source === 'copy');
|
||||
expect(copy.map((f) => [f.grade, f.rule, f.file, f.line, f.evidence])).toEqual([
|
||||
['blocks', 'lorem', 'index.html', 1, 'lorem ipsum placeholder text, 2 times'],
|
||||
['ask', 'alert', 'index.html', 1, 'alert() for a message'],
|
||||
['blocks', 'img-no-alt', 'index.html', 1, 'an <img> without alt, 2 times'],
|
||||
]);
|
||||
expect(copy.find((f) => f.rule === 'img-no-alt')?.fix).toBe('add alt text (alt="" when decorative)');
|
||||
expect(findings.filter((f) => f.rule === 'vh-height').map((f) => [f.source, f.grade])).toEqual([['static', 'blocks']]);
|
||||
expect(findings.filter((f) => f.rule === 'layout-transition').map((f) => f.source)).toEqual(['static']);
|
||||
expect(findings.some((f) => f.file === 'Card.tsx' && f.source === 'copy')).toBe(false);
|
||||
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</title>
|
||||
<meta name="description" content="Close faster."><meta property="og:image" content="/og.png"><link rel="icon" href="/favicon.svg">
|
||||
<style>.hero{min-height:100vh;min-height:100dvh;transition: transform .4s var(--ease-out)} .w{width:100%}</style></head>
|
||||
<body><a href="#main">Skip to content</a><a href="#pricing">Pricing</a><nav><a href="/" aria-current="page">Home</a><a href="/pricing">Pricing</a></nav>
|
||||
<main id="main"><h1>Close the books in 2 days, not 9</h1><p>Teams at Halvorsen Freight cut month-end by 47.2%. Save 20% yearly. 100% open source. 30-day refund.</p><img src="x.png" alt=""></main>
|
||||
<script>const io = new IntersectionObserver(() => {}); window.showAlert = () => {};</script></body></html>`;
|
||||
|
||||
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 <img> 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 <title>'],
|
||||
]));
|
||||
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</title>";</script></head><body><a href="#m">Skip</a><main id="m"><p>Halvorsen</p></main></body></html>';
|
||||
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 <noscript> notice counts as body text, or an image-only page with no script is called a shell; why_new=#3433's craft audit left "mount-node shell" undefined and the checker gave shells a clean result; seam=none; no_claim=that the rendered page is clean
|
||||
test('an empty mount node is reported at its line; a noscript notice does not count as content, an image-only page is not a shell', () => {
|
||||
const shell = '<!doctype html><html lang="en"><head><title>App</title></head>\n<body>\n<noscript>You need to enable JavaScript to run this app.</noscript>\n<div id="root"></div>\n<script src="/main.js"></script></body></html>';
|
||||
expect(copy(shell).filter((f) => f.rule === 'empty-mount-node').map((f) => [f.grade, f.line, f.evidence])).toEqual([['ask', 4, `${RULES['empty-mount-node'].title}: <div id="root">`]]);
|
||||
expect(copy('<!doctype html><html><head><title>A</title></head><body><my-app></my-app><script type="module" src="/a.js"></script></body></html>').some((f) => f.rule === 'empty-mount-node')).toBe(true);
|
||||
expect(ids('<!doctype html><html><head><title>A</title></head><body><img src="a.png" alt="A harbour at dusk"></body></html>')).not.toContain('empty-mount-node');
|
||||
expect(ids(CLEAN)).not.toContain('empty-mount-node');
|
||||
expect(copy(shell, 'component').some((f) => f.rule === 'empty-mount-node')).toBe(false);
|
||||
});
|
||||
|
||||
// Value: protects=the five content-realism ids sit at ask so a page waiver answers a supplied name or figure, as the owner chose; fails_when=one moves back to blocks, or a waiver beside a copy-tier tell is refused or ignored; why_new=option (a) moved them and no test pinned the tier or the waived grade; seam=none; no_claim=how far one page waiver reaches
|
||||
test('the content-realism ids sit at ask, and a waiver about the page answers a copy-tier tell', () => {
|
||||
for (const id of ['placeholder-name', 'placeholder-brand', 'lorem', 'round-number', 'needs-proof']) expect(RULES[id].tier, id).toBe('ask');
|
||||
const page = '<!doctype html><html lang="en"><head><title>Freight ledger</title></head><body>\n<!-- gstack-lint-disable placeholder-brand: Nexus is the freight customer this ledger page was built for -->\n<p>Nexus closes its freight ledger in two days.</p></body></html>';
|
||||
expect(copy(page).filter((f) => f.rule === 'placeholder-brand').map((f) => [f.grade, f.line, f.waiver])).toEqual([['waived', 3, 'Nexus is the freight customer this ledger page was built for']]);
|
||||
});
|
||||
|
||||
// Value: protects=an inline handler's tell reports at the line its code is on, and a shell with no <body> tag is still an empty mount node; fails_when=the handler position is the attribute's start, or the <title> text counts as body text; why_new=the /ship review found both; seam=none; no_claim=every implicit-body layout
|
||||
test('an inline handler reports at its value\'s line; a shell without a <body> tag is an empty mount node', () => {
|
||||
expect(copy('<button\nonclick="alert(1)">Go</button>').map((f) => [f.rule, f.line])).toEqual([['alert', 2]]);
|
||||
const bare = '<!doctype html><html lang="en"><head><title>App</title></head>\n<div id="root"></div><script src="/app.js"></script></html>';
|
||||
expect(copy(bare).filter((f) => f.rule === 'empty-mount-node').map((f) => f.line)).toEqual([2]);
|
||||
});
|
||||
|
||||
test('a component file gets no copy findings', () => {
|
||||
const dir = tmpTree({ 'Card.tsx': 'export const Card = () => <article className="p-4"><p>Lorem ipsum in a component</p><img src="c.png" /></article>;' });
|
||||
expect(lintStatic([dir], { cwd: dir, warn: () => {} }).findings.some((f) => f.source === 'copy')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -397,13 +528,6 @@ describe('design-lint: third review cycle', () => {
|
||||
expect(files.map((f) => path.relative(dir, f))).toEqual(['site/index.html']);
|
||||
expect(warned).toEqual([`design-lint: skipped ${path.join(dir, 'site', 'big.css')} (2 MB, over the 2 MB limit)`]);
|
||||
});
|
||||
|
||||
test('every id gstack-design-craft can emit has a lint rule or belongs to the static tier', () => {
|
||||
const ids = [...CRAFT_IDS];
|
||||
expect(ids.length).toBeGreaterThan(15);
|
||||
expect(ids.filter((id) => !(id in RULES))).toEqual([]);
|
||||
expect(ids.filter((id) => RULES[id].gate !== 'craft')).toEqual(['layout-transition', 'vh-height']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint: hardening (ship review)', () => {
|
||||
|
||||
@@ -751,10 +751,10 @@ do not launch the downstream skill or open a browser.`,
|
||||
// No section-loading paid case yet: a new periodic case would push the
|
||||
// periodic census past its 38-slice cap (test/paid-retry-supervision.test.ts).
|
||||
behavioral: 'none',
|
||||
maxSkeletonBytes: 137_767, // measured 137767 (2026-10-26; 137773 on 2026-10-25 before the shorter craft-audit trigger): the Section index the external-host carve test requires, its craft-audit row, and the STOP pointer (GSTA-92; Garry 2026-10-25 'Do it' (GSTA-92)).
|
||||
minUnionBytes: 143_300, // measured union 145037 (2026-10-26)
|
||||
mustContain: ['AI Slop Detection', 'Fix Loop', 'gstack-design-craft', 'Proof beside each claim', 'holds session data'],
|
||||
maxSizeRatio: 1.3704, // first parity guard for design-review: measured 145082 / 105,842 = 1.3708 (2026-10-25; 145077 after the /ship review fixes, 145068 = 1.3707 after the line-13 reword, 145049 = 1.3705 after the post-merge /ship review, 145037 = 1.3704 after its second pass, 2026-10-26), the skeleton's growth since v1.64.1.0 plus the craft audit (Garry 2026-10-25 'Do it' (GSTA-92)).
|
||||
maxSkeletonBytes: 137_735, // measured 137735 on the merged tree with main f27a760 (2026-10-27, #3507); 137767 (2026-10-26; 137773 on 2026-10-25 before the shorter craft-audit trigger): the Section index the external-host carve test requires, its craft-audit row, and the STOP pointer (GSTA-92; Garry 2026-10-25 'Do it' (GSTA-92)).
|
||||
minUnionBytes: 143_300, // measured union 144802 on the merged tree with main f27a760 (2026-10-27, #3507); 145037 (2026-10-26)
|
||||
mustContain: ['AI Slop Detection', 'Fix Loop', 'gstack-design-lint', 'Proof beside each claim', 'holds session data'],
|
||||
maxSizeRatio: 1.3681, // first parity guard for design-review: measured 144802 / 105,842 = 1.3681 on the merged tree with main f27a760 (2026-10-27, #3507: the design lint swap); before that 145082 / 105,842 = 1.3708 (2026-10-25; 145077 after the /ship review fixes, 145068 = 1.3707 after the line-13 reword, 145049 = 1.3705 after the post-merge /ship review, 145037 = 1.3704 after its second pass, 2026-10-26), the skeleton's growth since v1.64.1.0 plus the craft audit (Garry 2026-10-25 'Do it' (GSTA-92)).
|
||||
},
|
||||
|
||||
// ── Token-reduction Phase 4 wave 4 (v1.69.x branch): design doctrine carve ──
|
||||
@@ -784,8 +784,8 @@ do not launch the downstream skill or open a browser.`,
|
||||
gateAfterStop: undefined, // operational skill, no plan-mode gate
|
||||
},
|
||||
behavioral: 'prompt',
|
||||
maxSkeletonBytes: 56_200, // measured 55,786 (2026-10-25): the craft section pointer, its index row and the Step 4 craft-check line (GSTA-92); before that 55,262 (2026-09-09): the detector install offer pointer + its sections-table row (the brief itself lives in sections/detector-install-offer.md); before that 54,545 for the review-cycle trust prose, the Slop Gate's Decisions-Log clause, and the blacklist header's override sentence + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10).
|
||||
minUnionBytes: 57_500, // Phase 4 wave 4; measured union 58,682
|
||||
maxSkeletonBytes: 56_200, // measured 55,834 on the merged tree with main f27a760 (2026-10-27, #3507: the Step 4 lint line); 55,786 (2026-10-25): the craft section pointer, its index row and the Step 4 craft-check line (GSTA-92); before that 55,262 (2026-09-09): the detector install offer pointer + its sections-table row (the brief itself lives in sections/detector-install-offer.md); before that 54,545 for the review-cycle trust prose, the Slop Gate's Decisions-Log clause, and the blacklist header's override sentence + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10).
|
||||
minUnionBytes: 57_500, // Phase 4 wave 4; measured union 58,682; 78,626 on the merged tree with main f27a760 (2026-10-27, #3507)
|
||||
mustContain: ["Don't make me think", "Users scan, they don't read", 'The Goodwill Reservoir', 'PRETEXT API CHEATSHEET', 'Pattern 3: Text around obstacles'],
|
||||
},
|
||||
'design-shotgun': {
|
||||
|
||||
@@ -872,7 +872,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
'design-review-detector-shim-dom': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-review/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/design-craft.ts', 'scripts/resolvers/outside-voice.ts', 'lib/design-detect-contract.ts', 'lib/dom-dump-script.ts', 'lib/dom-dump.js', 'bin/gstack-design-detect.ts', 'browse/src/**', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/fixtures/review-eval-design-slop.*', 'test/skill-e2e-design.test.ts',
|
||||
'scripts/resolvers/testing.ts', 'test/helpers/aside-available.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'],
|
||||
'design-review-plugin-handoff': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-review/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/design-craft.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/testing.ts', 'lib/design-catalog.ts', 'lib/design-detect-contract.ts', 'bin/gstack-design-detect.ts', 'test/helpers/hermetic-env.ts', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/fixtures/review-eval-design-slop.html', 'test/fixtures/review-eval-design-slop.css', 'test/skill-e2e-design.test.ts', 'test/helpers/aside-available.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'],
|
||||
'design-html-slop-gate': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-html/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/outside-voice.ts', 'lib/design-detect-contract.ts', 'bin/gstack-design-detect.ts', 'lib/design-craft.ts', 'lib/design-catalog.ts', 'bin/gstack-design-craft', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/skill-e2e-design.test.ts', 'test/fixtures/review-eval-design-slop.html', 'test/fixtures/review-eval-design-slop.css', 'test/helpers/aside-available.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'],
|
||||
'design-html-slop-gate': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-html/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/outside-voice.ts', 'lib/design-detect-contract.ts', 'bin/gstack-design-detect.ts', 'lib/design-lint/**', 'lib/design-catalog.ts', 'bin/gstack-design-lint', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/skill-e2e-design.test.ts', 'test/fixtures/review-eval-design-slop.html', 'test/fixtures/review-eval-design-slop.css', 'test/helpers/aside-available.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'],
|
||||
'design-motion-showcase': ['design-motion/**', 'bin/gstack-motion-capture.ts', 'bin/gstack-motion-encode', 'lib/motion-capture.ts', 'lib/motion-encode.ts', 'test/fixtures/design-motion-finalized.html', 'bin/gstack-browser-ensure', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'test/skill-e2e-design.test.ts', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/review-eval-design-slop.html', 'test/fixtures/review-eval-design-slop.css', 'test/helpers/aside-available.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'],
|
||||
|
||||
// /diagram (diagram-render bundle consumers). Triplet = deterministic
|
||||
|
||||
@@ -1316,8 +1316,8 @@ describeIfSelected('Design HTML slop gate E2E', ['design-html-slop-gate'], () =>
|
||||
testConcurrentIfSelected('design-html-slop-gate', async () => {
|
||||
const result = await runSkillTest({
|
||||
prompt: `Read design-html-gate.md: the /design-html detector probe block and its "Slop Gate (bounded, never a loop)" step.
|
||||
finalized.html in this directory is the finished page. Run the probe (--host claude), then the slop gate on finalized.html exactly as written: one surgical fix pass over the non-advisory findings, one rescan. Then the craft check line that follows it, the same way, then stop.
|
||||
Write ${workDir}/gate-output.md listing what you fixed and every remaining finding as accepted-with-reason, each tagged with its [rule-id] or craft id. Do not take screenshots, do not run npx, do not run either check more than twice.`,
|
||||
finalized.html in this directory is the finished page. Run the probe (--host claude), then the slop gate on finalized.html exactly as written: one surgical fix pass over the non-advisory findings, one rescan. Then the design lint line that follows it, the same way, then stop.
|
||||
Write ${workDir}/gate-output.md listing what you fixed and every remaining finding as accepted-with-reason, each tagged with its [rule-id] or lint rule id. Do not take screenshots, do not run npx, do not run either check more than twice.`,
|
||||
workingDirectory: workDir,
|
||||
maxTurns: 20,
|
||||
timeout: CAPTURE_MS,
|
||||
@@ -1332,9 +1332,9 @@ Write ${workDir}/gate-output.md listing what you fixed and every remaining findi
|
||||
const scans = result.toolCalls.filter(c => c.tool === 'Bash' && /gstack-design-detect\.ts scan /.test(String(c.input?.command ?? '')));
|
||||
expect(scans.length).toBeGreaterThanOrEqual(1);
|
||||
expect(scans.length).toBeLessThanOrEqual(2);
|
||||
const craftRuns = result.toolCalls.filter(c => c.tool === 'Bash' && /gstack-design-craft['"]?\s+[^\s;|&]*\.html\b/.test(String(c.input?.command ?? '')));
|
||||
expect(craftRuns.length).toBeGreaterThanOrEqual(1);
|
||||
expect(craftRuns.length).toBeLessThanOrEqual(2);
|
||||
const lintRuns = result.toolCalls.filter(c => c.tool === 'Bash' && /gstack-design-lint['"]?\s+[^\s;|&]*\.html\b/.test(String(c.input?.command ?? '')));
|
||||
expect(lintRuns.length).toBeGreaterThanOrEqual(1);
|
||||
expect(lintRuns.length).toBeLessThanOrEqual(2);
|
||||
const outPath = path.join(workDir, 'gate-output.md');
|
||||
expect(fs.existsSync(outPath)).toBe(true);
|
||||
const out = fs.readFileSync(outPath, 'utf-8').toLowerCase();
|
||||
|
||||
Reference in new issue
Block a user