fix(design): run the DOM dump in the page on both engines; align doctrine with the catalog

The DOM-dump script is an arrow function, not a self-calling IIFE: Aside's
`pg.evaluate($_DUMP)` receives the function and runs it in the page (the IIFE
form executed in the repl sandbox, where `document` does not exist), and the
fallback engine calls it with `$B js "($_DUMP)()" --out --raw`. Hygiene widens
to every URL-bearing attribute (src, srcset per candidate, poster, action,
formaction, data, ping, cite lose their query strings and fragments) and to
data: URLs inside existing <style> nodes. The persist and scan blocks restate
REPORT_DIR and RUN_ID literally instead of relying on a shell variable from an
earlier block; the baseline's targetSet is defined per mode (repo-relative
paths in source mode, page slugs in DOM mode) so DOM-mode deltas can match; the
PR-body Detector line lists the states the probe can actually print. The DOM
fixture is re-captured with the new script from outside the repo (the engine
walks up from cwd for DESIGN.md, which the metadata now records).

Doctrine contradictions the design specialist found: the landing-page motion
rule matches the one-authored-moment reflex; the background rule names the
catalog's halo/spotlight/stripe/grid slop instead of asking for gradients; the
universal font rule is scoped to the display voice with the body/UI exceptions;
"two typefaces max" allows the mono; the methodology's banned-font line renders
BANNED_FONTS; Courier New is banned outright; the Brutalist, Retro-Futuristic,
and Playful menu entries stop recommending system stacks, glow, and bounce; the
coherence nudge uses the decoration vocabulary; Path A's gate names the display
voice; font-loading prose points at the source the procedure verified;
centered-everything is MEDIUM (an aggregate heuristic); the mockup guard reads
"Never by default (unless the brief above asks for it)". The checklist's
AUTO-FIX list renders the catalog's auto-fix rules; category 9 and the Hard
Rules pointer count from the same partition helpers (detectorSlopEntries,
judgmentTellEntries); the handoff list renders from HANDOFF_COMMANDS; a missing
catalog id fails gen-skill-docs by name. gstack's own DESIGN.md gains border
tokens and Decisions Log rows for its live-feed pulse and 11px mono labels.
frontend-scope is case-sensitive like the bash arm. gen-skill-docs shares one
emitGenerated helper for sections and lib-derived assets; renderCatalog keeps
the one style with a caller. Tests: shared sliceBetween that fails on a missing
end marker, the slop-gate fixture's real end marker, an isolated browse daemon
for the DOM-mode E2E, the DOM hygiene test gated to CI or opt-in, docs notes
for the two superseded plan sentences.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-09-08 17:31:30 +00:00
co-authored by Claude Fable 5.1
parent b4d88a0126
commit da6f0ff2f6
32 changed files with 413 additions and 219 deletions
+4
View File
@@ -24,6 +24,8 @@ colors:
base-dark: "#0C0C0C"
surface-light: "#FFFFFF"
base-light: "#FAFAF9"
border-dark: "#262626"
border-light: "#E7E5E4"
success: "#22C55E"
warning: "#F59E0B"
error: "#EF4444"
@@ -137,3 +139,5 @@ Apply a subtle noise overlay to the entire page for materiality:
| 2026-03-21 | Light mode amber-600 | amber-500 too bright/washed against white; amber-700 too brown/umber. amber-600 is the sweet spot. |
| 2026-03-21 | Grain texture | Adds materiality to flat dark surfaces. Prevents the "generic SaaS template" sameness. |
| 2026-09-08 | Open DESIGN.md format | Converted with `gstack-design-md convert`: tokens in front matter, canonical sections, Motion and Decisions Log kept as extras. DM Sans stays the body face: it sits on the overused-as-display list, and body/UI use on an Operate surface is the allowed exception under the role-scoped rule. |
| 2026-09-08 | Live-feed pulse is the one authored motion moment | The dashboard's 2s pulse on the live-feed dot is the page's single authored motion, so it stands as an intentional exception to the pulsing-dot catalog rule; every other surface keeps state changes static. |
| 2026-09-08 | JetBrains Mono labels at 11px | The mono personality font runs on labels at 11px (Nano), below the 12px UI-text floor; an intentional exception recorded here so the detector's undersized-ui-text and monospace-costume rows are read as decided, not open. |
@@ -1,5 +1,6 @@
<!-- AUTO-GENERATED from proposal-and-preview.md.tmpl — do not edit directly -->
<!-- Regenerate: bun run gen:skill-docs -->
<!-- The font-selection procedure and the three-looks calibration in this section are derived from pbakaus/impeccable reference/new-work.md (Apache-2.0), rewritten and modified. See NOTICE.md. -->
## Phase 3: The Complete Proposal
This is the soul of the skill. Propose EVERYTHING as one coherent package.
@@ -42,11 +43,11 @@ The SAFE/RISK breakdown is critical. Design coherence is table stakes — every
**Aesthetic directions** (pick the one that fits the product):
- Brutally Minimal — Type and whitespace only. No decoration. Modernist.
- Maximalist Chaos — Dense, layered, pattern-heavy. Y2K meets contemporary.
- Retro-Futuristic — Vintage tech nostalgia. CRT glow, pixel grids, warm monospace.
- Retro-Futuristic — Vintage tech nostalgia. Phosphor palette, bitmap type, warm monospace for data (no glow halos, no grid-paper backgrounds).
- Luxury/Refined — Serifs, high contrast, generous whitespace, precious metals.
- Playful/Toy-like — Rounded, bouncy, bold primaries. Approachable and fun.
- Playful/Toy-like — Rounded, springy (no overshoot), bold primaries. Approachable and fun.
- Editorial/Magazine — Strong typographic hierarchy, asymmetric grids, pull quotes.
- Brutalist/Raw — Exposed structure, system fonts, visible grid, no polish.
- Brutalist/Raw — Exposed structure, one utilitarian grotesk, visible grid, no polish (a system stack only when the user asks for it by name).
- Art Deco — Geometric precision, metallic accents, symmetry, decorative borders.
- Organic/Natural — Earth tones, rounded forms, hand-drawn texture, grain.
- Industrial/Utilitarian — Function-first, data-dense, monospace accents, muted palette.
@@ -65,7 +66,7 @@ The SAFE/RISK breakdown is critical. Design coherence is table stakes — every
**Fine as body/UI on an Operate or Read surface when the proposal says so:** DM Sans, Instrument Sans, IBM Plex Sans. **Mono for data and code:** JetBrains Mono, IBM Plex Mono, Fira Code.
**Banned in any role:** Papyrus, Comic Sans, Lobster, Impact, Jokerman, Bleeding Cowboys, Permanent Marker, Bradley Hand, Brush Script, Hobo, Trajan, Raleway, Clash Display, Courier New (for body).
**Banned in any role:** Papyrus, Comic Sans, Lobster, Impact, Jokerman, Bleeding Cowboys, Permanent Marker, Bradley Hand, Brush Script, Hobo, Trajan, Raleway, Clash Display, Courier New.
**Freely available faces on no default list** (verified 2026-09-08; re-verify in-session before naming one): Satoshi, General Sans, Clash Grotesk, Cabinet Grotesk (Fontshare); Instrument Serif, Source Sans 3, JetBrains Mono, Fira Code (Google Fonts). Short on purpose. A long list of "good" fonts is how the last convergence happened.
@@ -124,7 +125,7 @@ User asks for a listed face by name: comply, state the tradeoff once.
When the user overrides one section, check if the rest still coheres. Flag mismatches with a gentle nudge — never block:
- Brutalist/Minimal aesthetic + expressive motion → "Heads up: brutalist aesthetics usually pair with minimal motion. Your combo is unusual — which is fine if intentional. Want me to suggest motion that fits, or keep it?"
- Drenched color + restrained decoration → "Bold palette with minimal decoration can work, but the colors will carry a lot of weight. Want me to suggest decoration that supports the palette?"
- Drenched color + minimal decoration → "Bold palette with minimal decoration can work, but the colors will carry a lot of weight. Want me to suggest decoration that supports the palette?"
- Creative-editorial layout + data-heavy product → "Editorial layouts are gorgeous but can fight data density. Want me to show how a hybrid approach keeps both?"
- Always accept the user's final choice. Never refuse to proceed.
@@ -176,7 +177,7 @@ Show each variant inline (Read tool on each PNG) for instant preview.
a human designer be embarrassed to put their name on this?"* If yes, discard the
variant and regenerate. This is a hard gate. A mediocre AI mockup is worse than no
mockup. Embarrassment triggers include: purple gradient hero, 3-column SaaS grid,
centered-everything, Inter body text, generic stock-photo vibe, system-ui font,
centered-everything, an overused face as the display voice, generic stock-photo vibe, system-ui font,
gradient CTA button, bubble-radius everything. Any of those = reject and regenerate.
Tell the user: "I've generated 3 visual directions applying your design system to a realistic [product type] screen. Pick your favorite in the comparison board that just opened in your browser. You can also remix elements across variants."
@@ -322,7 +323,7 @@ open "$PREVIEW_FILE"
The agent writes a **single, self-contained HTML file** (no framework dependencies) that:
1. **Loads proposed fonts** from Google Fonts (or Bunny Fonts) via `<link>` tags
1. **Loads proposed fonts** from the source verified in step (4) of the font procedure (Google Fonts, Fontshare, or the self-hosted files) via `<link>` tags
2. **Uses the proposed color palette** throughout — dogfood the design system
3. **Shows the product name** (not "Lorem Ipsum") as the hero heading
4. **Font specimen section:**
@@ -1,3 +1,4 @@
<!-- The font-selection procedure and the three-looks calibration in this section are derived from pbakaus/impeccable reference/new-work.md (Apache-2.0), rewritten and modified. See NOTICE.md. -->
## Phase 3: The Complete Proposal
This is the soul of the skill. Propose EVERYTHING as one coherent package.
@@ -40,11 +41,11 @@ The SAFE/RISK breakdown is critical. Design coherence is table stakes — every
**Aesthetic directions** (pick the one that fits the product):
- Brutally Minimal — Type and whitespace only. No decoration. Modernist.
- Maximalist Chaos — Dense, layered, pattern-heavy. Y2K meets contemporary.
- Retro-Futuristic — Vintage tech nostalgia. CRT glow, pixel grids, warm monospace.
- Retro-Futuristic — Vintage tech nostalgia. Phosphor palette, bitmap type, warm monospace for data (no glow halos, no grid-paper backgrounds).
- Luxury/Refined — Serifs, high contrast, generous whitespace, precious metals.
- Playful/Toy-like — Rounded, bouncy, bold primaries. Approachable and fun.
- Playful/Toy-like — Rounded, springy (no overshoot), bold primaries. Approachable and fun.
- Editorial/Magazine — Strong typographic hierarchy, asymmetric grids, pull quotes.
- Brutalist/Raw — Exposed structure, system fonts, visible grid, no polish.
- Brutalist/Raw — Exposed structure, one utilitarian grotesk, visible grid, no polish (a system stack only when the user asks for it by name).
- Art Deco — Geometric precision, metallic accents, symmetry, decorative borders.
- Organic/Natural — Earth tones, rounded forms, hand-drawn texture, grain.
- Industrial/Utilitarian — Function-first, data-dense, monospace accents, muted palette.
@@ -71,7 +72,7 @@ The SAFE/RISK breakdown is critical. Design coherence is table stakes — every
When the user overrides one section, check if the rest still coheres. Flag mismatches with a gentle nudge — never block:
- Brutalist/Minimal aesthetic + expressive motion → "Heads up: brutalist aesthetics usually pair with minimal motion. Your combo is unusual — which is fine if intentional. Want me to suggest motion that fits, or keep it?"
- Drenched color + restrained decoration → "Bold palette with minimal decoration can work, but the colors will carry a lot of weight. Want me to suggest decoration that supports the palette?"
- Drenched color + minimal decoration → "Bold palette with minimal decoration can work, but the colors will carry a lot of weight. Want me to suggest decoration that supports the palette?"
- Creative-editorial layout + data-heavy product → "Editorial layouts are gorgeous but can fight data density. Want me to show how a hybrid approach keeps both?"
- Always accept the user's final choice. Never refuse to proceed.
@@ -123,7 +124,7 @@ Show each variant inline (Read tool on each PNG) for instant preview.
a human designer be embarrassed to put their name on this?"* If yes, discard the
variant and regenerate. This is a hard gate. A mediocre AI mockup is worse than no
mockup. Embarrassment triggers include: purple gradient hero, 3-column SaaS grid,
centered-everything, Inter body text, generic stock-photo vibe, system-ui font,
centered-everything, an overused face as the display voice, generic stock-photo vibe, system-ui font,
gradient CTA button, bubble-radius everything. Any of those = reject and regenerate.
Tell the user: "I've generated 3 visual directions applying your design system to a realistic [product type] screen. Pick your favorite in the comparison board that just opened in your browser. You can also remix elements across variants."
@@ -157,7 +158,7 @@ open "$PREVIEW_FILE"
The agent writes a **single, self-contained HTML file** (no framework dependencies) that:
1. **Loads proposed fonts** from Google Fonts (or Bunny Fonts) via `<link>` tags
1. **Loads proposed fonts** from the source verified in step (4) of the font procedure (Google Fonts, Fontshare, or the self-hosted files) via `<link>` tags
2. **Uses the proposed color palette** throughout — dogfood the design system
3. **Shows the product name** (not "Lorem Ipsum") as the hero heading
4. **Font specimen section:**
+1 -1
View File
@@ -686,7 +686,7 @@ For framework output, save to:
**Always include in vanilla HTML:**
- Pretext source (inlined or CDN, see above)
- CSS custom properties for design tokens from DESIGN.md / Step 1 extraction
- Google Fonts via `<link>` tags + `document.fonts.ready` gate before first `prepare()`
- Fonts from the source DESIGN.md names (Google Fonts, Fontshare, or self-hosted) via `<link>` tags + `document.fonts.ready` gate before first `prepare()`
- Semantic HTML5 (`<header>`, `<nav>`, `<main>`, `<section>`, `<footer>`)
- Responsive behavior via Pretext relayout (not just media queries)
- Breakpoint-specific adjustments at 375px, 768px, 1024px, 1440px
+1 -1
View File
@@ -266,7 +266,7 @@ For framework output, save to:
**Always include in vanilla HTML:**
- Pretext source (inlined or CDN, see above)
- CSS custom properties for design tokens from DESIGN.md / Step 1 extraction
- Google Fonts via `<link>` tags + `document.fonts.ready` gate before first `prepare()`
- Fonts from the source DESIGN.md names (Google Fonts, Fontshare, or self-hosted) via `<link>` tags + `document.fonts.ready` gate before first `prepare()`
- Semantic HTML5 (`<header>`, `<nav>`, `<main>`, `<section>`, `<footer>`)
- Responsive behavior via Pretext relayout (not just media queries)
- Breakpoint-specific adjustments at 375px, 768px, 1024px, 1440px
+16 -16
View File
@@ -819,7 +819,7 @@ Remember `RUN_ID` and restate it literally in later blocks (each bash block is a
_DJ=$(mktemp); bun --no-env-file run $HOME/.claude/skills/gstack/bin/gstack-design-detect.ts scan --changed <base> --format gstack --host claude > "$_DJ"; echo "DETECT_EXIT_CODE=$?"; echo "DETECT_JSON=$_DJ"
```
DOM mode never scans source (Rule 4): Phase 3 dumps each page's rendered DOM into `$REPORT_DIR/dom/$RUN_ID/` and scans once after the last page. Exit 2 means findings; exit 1 means a target could not be scanned (note which, move on); exit 3 is a gstack bug (`DESIGN_DETECT_INTERNAL_ERROR`: report it, never retry). Each rule in the `DETECT_TOP` block becomes one `FINDING-NNN` tagged `[rule-id]` with the printed impact and its location list, never one finding per hit. A detector hit is evidence, not a verdict: confirm it in the rendered page before it counts, drop it when DESIGN.md tokens bless the value, never pad the report with advisory rows. Phase 9 recomputes the same way (DOM mode re-dumps the affected pages after reload; source mode rescans the touched files) and Phase 10 reports `Detector: N → M`. When `IMPECCABLE_SKILL: present`, end each deferred finding with the `handoff=` command the scan printed (`/impeccable typeset`, `layout`, `colorize`, `harden`, `clarify`, `animate`, `quieter`, or `polish`); recommend it, never open its files.
DOM mode never scans source (Rule 4): Phase 3 dumps each page's rendered DOM into `$REPORT_DIR/dom/$RUN_ID/` and scans once after the last page. Exit 2 means findings; exit 1 means a target could not be scanned (note which, move on); exit 3 is a gstack bug (`DESIGN_DETECT_INTERNAL_ERROR`: report it, never retry). Each rule in the `DETECT_TOP` block becomes one `FINDING-NNN` tagged `[rule-id]` with the printed impact and its location list, never one finding per hit. A detector hit is evidence, not a verdict: confirm it in the rendered page before it counts, drop it when DESIGN.md tokens bless the value, never pad the report with advisory rows. Phase 9 recomputes the same way (DOM mode re-dumps the affected pages after reload; source mode rescans the touched files) and Phase 10 reports `Detector: N → M`. When `IMPECCABLE_SKILL: present`, end each deferred finding with the `handoff=` command the scan printed (`/impeccable typeset`, `layout`, `colorize`, `harden`, `clarify`, `polish`, `animate`, `quieter`); recommend it, never open its files.
---
@@ -1074,7 +1074,7 @@ After each script, `cp` its files out of the `ASIDE_DIR` it printed into `$REPOR
### DOM dump (DOM mode only: Setup printed `IMPECCABLE_READY` and the target is a URL)
Rule 4 forbids reading source, so the detector reads the rendered page. One shared script, `$HOME/.claude/skills/gstack/lib/dom-dump.js`, serves both engines: it clones the document, inlines linked stylesheets as `<style data-gstack-dom-css>`, strips scripts, input values, long attributes, and query strings, and notes what it cannot capture (shadow DOM, constructed and runtime-injected styles). Aside, third script per page (the script is spliced in from the file, so this block is double-quoted):
Rule 4 forbids reading source, so the detector reads the rendered page. One shared script, `$HOME/.claude/skills/gstack/lib/dom-dump.js` (an arrow function the page runs), serves both engines: it clones the document, inlines linked stylesheets as `<style data-gstack-dom-css>`, strips scripts, input values, long attributes, and URL query strings, and notes what it cannot capture (shadow DOM, constructed and runtime-injected styles). Aside, third script per page (the function text is spliced in from the file, so this block is double-quoted; `pg.evaluate` receives the function and runs it in the page):
```bash
_DUMP=$(cat "$HOME/.claude/skills/gstack/lib/dom-dump.js")
@@ -1086,26 +1086,26 @@ console.log(\"ASIDE_DIR=\" + pwd); await closeTab(pg); console.log(\"GSTACK_STEP
"
```
Fallback engine (`--out` accepts only temp dirs or cwd; never `$B html`, which wraps output in content markers):
Fallback engine (`$B js` calls the function in the page; `--out` accepts only temp dirs or cwd; never `$B html`, which wraps output in content markers):
```bash
_TMP=$(mktemp -d); cp "$HOME/.claude/skills/gstack/lib/dom-dump.js" "$_TMP/"
$B eval "$_TMP/dom-dump.js" --out "$_TMP/{page}.dom.html" --raw && echo "DUMP=$_TMP/{page}.dom.html"
_TMP=$(mktemp -d); _DUMP=$(cat "$HOME/.claude/skills/gstack/lib/dom-dump.js")
$B js "($_DUMP)()" --out "$_TMP/{page}.dom.html" --raw && echo "DUMP=$_TMP/{page}.dom.html"
```
Persist it into this run's directory, size-capped and redaction-checked (a HIGH finding skips the page, not the review):
Persist it into this run's directory, size-capped and redaction-checked (a HIGH finding skips the page, not the review). Each bash block is a fresh shell: restate the report directory and run id from Setup literally.
```bash
_D="<ASIDE_DIR or $_TMP>/{page}.dom.html"; _RUN="<RUN_ID from Setup>"
_D="<ASIDE_DIR or $_TMP>/{page}.dom.html"; _REPORT="<REPORT_DIR from Setup>"; _RUN="<RUN_ID from Setup>"
if [ "$(wc -c < "$_D")" -gt 10485760 ]; then echo "DOM_DUMP_TOO_LARGE: {page} $(wc -c < "$_D")"; rm -f "$_D"
elif $HOME/.claude/skills/gstack/bin/gstack-redact --from-file "$_D" >/dev/null 2>&1; [ $? -eq 3 ]; then echo "DOM_DUMP_REDACTION_BLOCKED: {page}"; rm -f "$_D"
else mkdir -p "$REPORT_DIR/dom/$_RUN" && cp "$_D" "$REPORT_DIR/dom/$_RUN/" && rm -f "$_D" && echo "DOM_DUMP_OK: {page}"; fi
else mkdir -p "$_REPORT/dom/$_RUN" && cp "$_D" "$_REPORT/dom/$_RUN/" && rm -f "$_D" && echo "DOM_DUMP_OK: {page}"; fi
```
After the LAST page's dump, scan the run directory once (source mode scanned in Setup instead):
```bash
_DJ=$(mktemp); bun --no-env-file run $HOME/.claude/skills/gstack/bin/gstack-design-detect.ts scan --format gstack --host claude "$REPORT_DIR/dom/<RUN_ID>" > "$_DJ"; echo "DETECT_EXIT_CODE=$?"; echo "DETECT_JSON=$_DJ"
_DJ=$(mktemp); bun --no-env-file run $HOME/.claude/skills/gstack/bin/gstack-design-detect.ts scan --format gstack --host claude "<REPORT_DIR from Setup>/dom/<RUN_ID>" > "$_DJ"; echo "DETECT_EXIT_CODE=$?"; echo "DETECT_JSON=$_DJ"
```
Say once in the report: "static scan of the rendered DOM; cross-origin CSS not resolved". A DOM-mode `file:line` points into `{page}.dom.html` and is approximate (HTML findings carry line 0); the `snippet` locates the element. Confirm each hit in the rendered page, never by hunting a source line. Dumps are deleted after Phase 9 unless the user passed `--keep-dom`.
@@ -1148,7 +1148,7 @@ Apply these at each page. Each finding gets an impact rating (high/medium/polish
- Measure: 45-75 chars per line (66 ideal)
- Heading hierarchy: no skipped levels (h1→h3 without h2)
- Weight contrast: >=2 weights used for hierarchy
- No blacklisted fonts (Papyrus, Comic Sans, Lobster, Impact, Jokerman)
- No banned fonts (Papyrus, Comic Sans, Lobster, Impact, Jokerman, Bleeding Cowboys, Permanent Marker, Bradley Hand, Brush Script, Hobo, Trajan, Raleway, Clash Display, Courier New)
- Display face on the overused list (Inter, Roboto, Arial, Helvetica, Open Sans, Lato, ...) → flag `[overused-font]`; as body/UI on an Operate or Read surface it passes when DESIGN.md says so
- `text-wrap: balance` or `text-pretty` on headings (check via `await pg.evaluate(() => getComputedStyle(document.querySelector("h1")).textWrap)`)
- Curly quotes used, not straight quotes
@@ -1230,7 +1230,7 @@ Apply these at each page. Each finding gets an impact rating (high/medium/polish
- Instructions detection: any visible instructions longer than one sentence. If users need to read instructions, the design has failed. Flag the instructions AND the interaction they're compensating for.
- Happy talk word count: count total visible words on the page. Classify each text block as "useful content" vs "happy talk" (welcome paragraphs, self-congratulatory text, instructions nobody reads). Report: "This page has X words. Y (Z%) are happy talk."
**9. AI Slop Detection** (11 blacklist patterns, 19 detector rules, 14 judgment tells)
**9. AI Slop Detection** (11 blacklist patterns, 30 detector rules, 16 judgment tells; polish-level ones on the last line)
The test: would a human designer at a respected studio ever ship this? A `[rule-id]` is the detector's name for the same pattern; a scan hit and a judgment hit on one element are one finding.
@@ -1386,7 +1386,7 @@ Write to: `~/.gstack/projects/{slug}/{user}-{branch}-design-audit-{datetime}.md`
"mode": "dom | source | none",
"engine": "<engineVersion from the scan JSON; never a path>",
"base": "<base commit, source mode only>",
"targetSet": "<sha256 of the sorted realpaths scanned>",
"targetSet": "<sha256 of the sorted target set: source mode = repo-relative paths scanned; DOM mode = the {page} slugs dumped (never the dated dump paths, which change every run)>",
"total": 14,
"byRule": { "kicker-above-heading": 2 },
"byPage": { "home": { "kicker-above-heading": 2 } }
@@ -1493,15 +1493,15 @@ Tie everything to user goals and product objectives. Always suggest specific imp
- First viewport reads as one composition, not a dashboard
- Brand-first hierarchy: brand > headline > body > CTA
- Typography: expressive, purposeful — no default stacks (Inter, Roboto, Arial, system)
- No flat single-color backgrounds — use gradients, images, subtle patterns
- No flat single-color backgrounds by default: texture from the brand or a real asset, never a halo, spotlight, stripe, or grid-paper gradient (the catalog names each)
- Hero: full-bleed, edge-to-edge, no inset/tiled/rounded variants
- Hero budget: brand, one headline, one supporting sentence, one CTA group, one image
- No cards in hero. Cards only when card IS the interaction
- One job per section: one purpose, one headline, one short supporting sentence
- Motion: 2-3 intentional motions minimum (entrance, scroll-linked, hover/reveal)
- Motion: one authored moment on the first viewport (an entrance or a scroll-linked reveal), ease-out from a visible default; hover states only where they carry information
- Color: define CSS variables, avoid purple-on-white defaults, one accent color default
- Copy: product language not design commentary. "If deleting 30% improves it, keep deleting"
- Beautiful defaults: composition-first, brand as loudest text, two typefaces max, cardless by default, first viewport as poster not document
- Beautiful defaults: composition-first, brand as loudest text, two text faces max (plus a mono for data and code), cardless by default, first viewport as poster not document
**App UI rules** (apply when classifier = OPERATE / APP UI):
- Calm surface hierarchy, strong typography, few colors
@@ -1524,7 +1524,7 @@ Tie everything to user goals and product objectives. Always suggest specific imp
**Universal rules** (apply to ALL types):
- Define CSS variables for color system
- No default font stacks (Inter, Roboto, Arial, system)
- No default font stacks as the display voice (Inter, Roboto, Arial, system); body/UI use on an Operate or Read surface follows the role-scoped list (DM Sans, Instrument Sans, IBM Plex Sans pass when the proposal says so)
- One job per section
- "If deleting 30% of the copy improves it, keep deleting"
- Cards earn their existence — no decorative card grids
+2 -1
View File
@@ -34,7 +34,8 @@ export function briefToPrompt(brief: DesignBrief): string {
// Generation-time slop guard: the catalog's mockupNever names, so the model
// never reaches for purple gradients, icon tiles, or cream defaults on its own.
lines.push(`Never: ${MOCKUP_NEVER_NAMES.join(", ")}.`);
// "By default": a brief that asks for one of these by name still gets it.
lines.push(`Never by default (unless the brief above asks for it): ${MOCKUP_NEVER_NAMES.join(", ")}.`);
lines.push(
"The mockup should look like a real production UI, not a wireframe or concept art.",
+3 -3
View File
@@ -18,9 +18,9 @@ const brief: DesignBrief = {
};
describe("briefToPrompt", () => {
test("carries a Never: line listing every MOCKUP_NEVER_NAMES entry, before the fixed tail", () => {
test("carries a Never-by-default line listing every MOCKUP_NEVER_NAMES entry, before the fixed tail", () => {
const prompt = briefToPrompt(brief);
const never = `Never: ${MOCKUP_NEVER_NAMES.join(", ")}.`;
const never = `Never by default (unless the brief above asks for it): ${MOCKUP_NEVER_NAMES.join(", ")}.`;
expect(prompt).toContain(never);
expect(MOCKUP_NEVER_NAMES.length).toBeGreaterThanOrEqual(8);
for (const name of MOCKUP_NEVER_NAMES) expect(prompt).toContain(name);
@@ -38,7 +38,7 @@ describe("briefToPrompt", () => {
const prompt = briefToPrompt({ ...brief, constraints: "Max width 1024px", reference: "DESIGN.md excerpt" });
expect(prompt).toContain("Constraints: Max width 1024px.");
expect(prompt).toContain("Design reference: DESIGN.md excerpt");
expect(prompt).toContain("Never: ");
expect(prompt).toContain("Never by default (unless the brief above asks for it): ");
expect(prompt.endsWith("1536x1024 pixels.")).toBe(true);
});
});
+2 -2
View File
@@ -53,14 +53,14 @@ Delivery: ONE PR (user decision 2026-09-08, overriding the review's two-PR sugge
**2. Detector on live URLs via a rendered-DOM dump (commit 7).**
- Mode rule: the target is a URL (including an explicit `http://localhost:3000` on a feature branch) → DOM mode, no source scan. Diff-aware mode with no URL → source mode over the changed frontend dirs. Stated in the Phase 0 prose so nobody scans source "helpfully".
- Hook rule: `IMPECCABLE_HOOK: present` suppresses the source-mode scan only (the hook already scans edited files). DOM mode scans the dump regardless, because the hook cannot see a rendered page.
- Hook rule (superseded before shipping, eng review decision 35): `IMPECCABLE_HOOK: present` never skips a scan; the prose tells the agent to use gstack's rows and ignore the hook's vocabulary. DOM mode scans the dump regardless, because the hook cannot see a rendered page.
- One dump script for both engines, rendered once by the resolver as a fenced JS block, written as an IIFE expression with no single-quote characters (every `aside repl '...'` script is a single-quoted bash string, and `$B eval` wraps an expression): serialize the stylesheets whose `ownerNode` is a `<link>` (inline `<style>` nodes are already in the markup; serializing them too would double-count), catch cross-origin sheets and list them in a trailing HTML comment as unresolved, inject the text as `<style data-gstack-dom-css>` in `<head>`, return `document.documentElement.outerHTML`. Aside: the Phase 3 page script evaluates it, does `fs.writeFile(path.join(pwd, "{page}.dom.html"), html)` (the sandbox `fs` writes only under `pwd`), prints `ASIDE_DIR=`, and bash copies the file to `$REPORT_DIR/dom/`. Browse fallback: `_TMP=$(mktemp -d)`, write the same JS to `$_TMP/dom-dump.js`, run `$B eval "$_TMP/dom-dump.js" --out "$_TMP/{page}.dom.html" --raw` (`--out` accepts only temp dirs or cwd, `browse/src/path-security.ts:26`), then `cp` into `$REPORT_DIR/dom/`. `$B html` is not used: reading commands wrap stdout in untrusted-content markers (`browse/src/server.ts:1249-1255`). The row lives in design-review's own DOM-mode prose in `scripts/resolvers/design.ts`, not in the shared `{{BROWSE_FALLBACK}}` table (that table renders into ten skills). Same script, same serialization on both engines; page content may still differ by session. `{page}` reuses the slug the Phase 3 screenshots already use. In DOM mode, findings' `file:line` point into `{page}.dom.html`; the agent confirms the element in the rendered page (Rule 4), never by hunting a source line.
- `$REPORT_DIR` in `design-review/SKILL.md.tmpl:95` changes from `$HOME/.gstack/...` to `${GSTACK_HOME:-$HOME/.gstack}/...` (as `bin/gstack-slug` already does) so the allow-list and the report dir agree when `GSTACK_HOME` is set. The Output Structure tree at tmpl:119 and the `mkdir` at `design.ts:419` keep their `~/.gstack` shorthand; neither feeds the allow-list.
- Sequencing: in DOM mode the scan runs once, after the last Phase 3 page script has produced its dump; in source mode the scan runs in Setup. Phase 9 recomputes (DOM mode re-dumps the affected pages after reload and rescans; source mode rescans the touched files); Phase 10 reports `Detector: N → M`.
- Scan: `gstack-design-detect.ts scan --format gstack "$REPORT_DIR/dom"`; the wrapper's target validation accepts paths under the repo root OR under `${GSTACK_HOME:-$HOME/.gstack}/projects/*/designs/` (explicit allow-list, tested with a designs path accepted and a non-designs sibling refused). Report line once: "static scan of the rendered DOM; cross-origin CSS not resolved".
- The load-bearing assumption (impeccable's static engine reads inline `<style>` in a `.html` file) is pinned in commit 1, not commit 13: the fixture set includes `review-eval-design-slop.html` served locally, dumped with `$B eval dom-dump.js --out --raw`, and scanned. DOM mode ships in commit 7 on top of that fixture.
**3. Detector counts in the regression baseline (commit 7).** The `design-baseline.json` template and regression prose live in `scripts/resolvers/design.ts` (DESIGN_METHODOLOGY Phase 6, the JSON block and "Regression Output"), not in the skill template; edit there. Field: `detector: { mode: "dom"|"source", engine, total, byRule: {id: n} }`. `engine` is the version parsed from the READY path when it is the `~/.impeccable/bin/<version>/` cache, otherwise the resolved binary path; a version field in `detect --json` output wins if the fixture shows one. Previous baseline = newest `${GSTACK_HOME:-$HOME/.gstack}/projects/$SLUG/designs/design-audit-*/design-baseline.json` older than today's `$REPORT_DIR` (a second run on the same day overwrites today's baseline and compares against the most recent prior day's, by design). Diff only when `mode` matches; otherwise say "detector modes differ (dom vs source), no delta". When `engine` differs, print the delta with the caveat `engine changed X → Y; rule set may differ`. Absent field → "no detector baseline (first scan)", never `+N`. Output: ids that appeared, ids that disappeared, totals, one example row in the prose (`+ kicker-above-heading (2) - gradient-text (1) total 14 → 9`). Known limitation stated: live DOM dumps jitter (ads, timestamps, A/B), so count deltas are advisory and id appear/disappear is the signal.
**3. Detector counts in the regression baseline (commit 7).** The `design-baseline.json` template and regression prose live in `scripts/resolvers/design.ts` (DESIGN_METHODOLOGY Phase 6, the JSON block and "Regression Output"), not in the skill template; edit there. Field: `detector: { mode: "dom"|"source", engine, total, byRule: {id: n} }`. `engine` is the version parsed from the READY path when it is the `~/.impeccable/bin/<version>/` cache, or from a sibling `VERSION` file, otherwise `sha256:<12 hex>` of the binary (superseded before shipping: never a filesystem path); a version field in `detect --json` output wins if the fixture shows one. Previous baseline = newest `${GSTACK_HOME:-$HOME/.gstack}/projects/$SLUG/designs/design-audit-*/design-baseline.json` older than today's `$REPORT_DIR` (a second run on the same day overwrites today's baseline and compares against the most recent prior day's, by design). Diff only when `mode` matches; otherwise say "detector modes differ (dom vs source), no delta". When `engine` differs, print the delta with the caveat `engine changed X → Y; rule set may differ`. Absent field → "no detector baseline (first scan)", never `+N`. Output: ids that appeared, ids that disappeared, totals, one example row in the prose (`+ kicker-above-heading (2) - gradient-text (1) total 14 → 9`). Known limitation stated: live DOM dumps jitter (ads, timestamps, A/B), so count deltas are advisory and id appear/disappear is the signal.
## Deferred to TODOS.md
- `$D check` slop rubric (P3, M): add catalog LLM-only tells to `design/src/check.ts` once the catalog's LLM-tell entries have been exercised in reviews; cost per variant and vision false positives are the open questions.
+27 -26
View File
@@ -29,12 +29,9 @@ export type Confidence = 'HIGH' | 'MEDIUM' | 'LOW';
export type ReviewTier = 'auto-fix' | 'ask' | 'possible';
export type Impact = 'high' | 'medium' | 'polish';
export type FontRole = 'display' | 'body' | 'ui' | 'mono';
export type Handoff = 'typeset' | 'layout' | 'colorize' | 'harden' | 'clarify' | 'polish' | 'animate' | 'quieter';
/** The `/impeccable <cmd>` commands a deferred finding may hand off to. */
export const HANDOFF_COMMANDS: readonly Handoff[] = [
'typeset', 'layout', 'colorize', 'harden', 'clarify', 'polish', 'animate', 'quieter',
];
/** The `/impeccable <cmd>` commands a deferred finding may hand off to (one source for the type and the prose). */
export const HANDOFF_COMMANDS = ['typeset', 'layout', 'colorize', 'harden', 'clarify', 'polish', 'animate', 'quieter'] as const;
export type Handoff = (typeof HANDOFF_COMMANDS)[number];
export interface DesignSlopEntry {
/** kebab-case; equals impeccableId when the detector knows the rule */
@@ -103,7 +100,7 @@ export const DESIGN_SLOP_CATALOG: DesignSlopEntry[] = [
{
id: 'centered-everything', name: 'Centered everything',
prose: 'Centered everything (`text-align: center` on all headings, descriptions, cards)',
category: 'layout', kind: 'slop', detect: ['grep', 'llm'], confidence: 'HIGH', tier: 'ask', impact: 'medium',
category: 'layout', kind: 'slop', detect: ['grep', 'llm'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
heuristic: 'Grep for `text-align: center` density: if more than 60% of text containers center, flag it.',
handoff: 'layout', source: 'gstack', legacyBlacklist: true,
},
@@ -666,7 +663,7 @@ export const OVERUSED_FONTS_DISPLAY: readonly string[] = OVERUSED_DISPLAY;
/** Never, in any role. */
export const BANNED_FONTS: readonly string[] = [
'Papyrus', 'Comic Sans', 'Lobster', 'Impact', 'Jokerman', 'Bleeding Cowboys', 'Permanent Marker',
'Bradley Hand', 'Brush Script', 'Hobo', 'Trajan', 'Raleway', 'Clash Display', 'Courier New (for body)',
'Bradley Hand', 'Brush Script', 'Hobo', 'Trajan', 'Raleway', 'Clash Display', 'Courier New',
];
/** On the overused list, yet fine as body or UI on an Operate or Read surface when the proposal says so. */
@@ -705,33 +702,37 @@ export function entryForImpeccableId(impeccableId: string): DesignSlopEntry | un
export interface RenderCatalogOptions {
kind?: 'slop' | 'quality';
category?: SlopCategory;
/** drop entries whose impact is in this list (e.g. ['polish'] for a shorter list) */
omitImpact?: Impact[];
/**
* bullets: `- prose` (design-consultation, design-shotgun: prose only, no ids)
* ids: `- [impeccableId] prose`, bracket only when the detector knows the id
* compact: `id: name; id: name; ...` on one line
*/
style: 'bullets' | 'ids' | 'compact';
}
export function selectCatalog(o: Omit<RenderCatalogOptions, 'style'>): DesignSlopEntry[] {
export function selectCatalog(o: RenderCatalogOptions): DesignSlopEntry[] {
return DESIGN_SLOP_CATALOG.filter(e =>
(!o.kind || e.kind === o.kind)
&& (!o.category || e.category === o.category)
&& !(o.omitImpact && o.omitImpact.includes(e.impact)),
);
}
/** `- prose` bullets, no ids: the register the proposal skills render (design-consultation, design-shotgun). */
export function renderCatalog(o: RenderCatalogOptions): string {
const entries = selectCatalog(o);
switch (o.style) {
case 'bullets':
return entries.map(e => `- ${e.prose}`).join('\n');
case 'ids':
return entries.map(e => (e.impeccableId ? `- [${e.impeccableId}] ${e.prose}` : `- ${e.prose}`)).join('\n');
case 'compact':
return entries.map(e => `${e.impeccableId ?? e.id}: ${e.name}`).join('; ');
}
return selectCatalog(o).map(e => `- ${e.prose}`).join('\n');
}
/** Slop the detector knows, minus the 11 legacy lines: what design doctrine renders as bracketed ids. */
export function detectorSlopEntries(o: { omitPolish?: boolean } = {}): DesignSlopEntry[] {
return DESIGN_SLOP_CATALOG.filter(e => e.kind === 'slop' && e.impeccableId && !e.legacyBlacklist && !(o.omitPolish && e.impact === 'polish'));
}
/** gstack-only slop tells (no detector rule), minus the legacy lines: the LLM pass is the detector. */
export function judgmentTellEntries(o: { omitPolish?: boolean } = {}): DesignSlopEntry[] {
return DESIGN_SLOP_CATALOG.filter(e => e.kind === 'slop' && !e.impeccableId && !e.legacyBlacklist && !(o.omitPolish && e.impact === 'polish'));
}
/** Catalog entries by id, throwing with the id when one is missing (a rename must fail loudly at gen time). */
export function catalogEntries(ids: string[]): DesignSlopEntry[] {
return ids.map(id => {
const e = BY_ID.get(id);
if (!e) throw new Error(`lib/design-catalog.ts: no entry with id "${id}"`);
return e;
});
}
+20 -10
View File
@@ -8,9 +8,11 @@
// test/impeccable-fixtures.test.ts pins that the committed dump came from THIS script
//
// Contract (one script, two engines):
// - An IIFE expression: Aside's `pg.evaluate(...)` and `$B eval <file>` both
// wrap an expression, and every `aside repl '...'` body is a single-quoted
// bash string, so the text contains NO single-quote characters and no `${`.
// - An arrow-FUNCTION expression, never a self-calling IIFE: Aside's
// `pg.evaluate(fn)` receives the function and runs it in the page (an IIFE
// would execute in the repl sandbox, where there is no `document`), and the
// fallback engine calls it with `$B js "($_DUMP)()"`. Both splice the file's
// text into bash, so it contains NO single quotes, no backticks, and no `${`.
// - Works on a CLONE of document.documentElement, never the live page.
// - Inlines only the stylesheets a <link> owns (inline <style> nodes are
// already in the markup; re-serializing them double-counts) as one
@@ -25,13 +27,15 @@
// - Hygiene before the file leaves the browser: <script> bodies emptied,
// <input>/<textarea> values dropped, `value=` and `data-*` attributes over
// 32 chars emptied, <meta content> emptied (charset and viewport kept: they
// carry no user data and the viewport hint is layout-relevant), href query
// strings cut, data: URLs over 1 KB replaced by a placeholder in attributes
// and in the inlined CSS.
// carry no user data and the viewport hint is layout-relevant), query
// strings cut from every URL-bearing attribute (href, src, srcset, poster,
// action, formaction, data, ping, cite: signed CDN and form URLs carry
// tokens), data: URLs over 1 KB replaced by a placeholder in attributes, in
// the inlined CSS, and in existing <style> nodes.
// - The trailing comment names what the dump cannot contain (shadow DOM,
// constructed stylesheets, runtime-injected styles when scripts were
// stripped) so the report can say so once.
export const DOM_DUMP_SCRIPT = String.raw`(() => {
export const DOM_DUMP_SCRIPT = String.raw`() => {
const root = document.documentElement.cloneNode(true);
const head = root.querySelector("head") || root;
const inlined = [];
@@ -49,10 +53,10 @@ export const DOM_DUMP_SCRIPT = String.raw`(() => {
crossOrigin.push(sheet.href || "(unknown)");
}
});
const dataUrl = new RegExp("url\\((\"?)data:[^)]{1024,}\\)", "g");
if (inlined.length) {
const style = document.createElement("style");
style.setAttribute("data-gstack-dom-css", "");
const dataUrl = new RegExp("url\\((\"?)data:[^)]{1024,}\\)", "g");
const rgb = new RegExp("rgb\\((\\d+), (\\d+), (\\d+)\\)", "g");
const hex = (n) => Number(n).toString(16).padStart(2, "0");
style.textContent = inlined.join("\n")
@@ -60,6 +64,11 @@ export const DOM_DUMP_SCRIPT = String.raw`(() => {
.replace(rgb, (m, r, g, b) => "#" + hex(r) + hex(g) + hex(b));
head.appendChild(style);
}
for (const el of Array.from(root.querySelectorAll("style"))) {
if (el.getAttribute("data-gstack-dom-css") === null && el.textContent) el.textContent = el.textContent.replace(dataUrl, "url(data:,gstack-stripped)");
}
const urlAttrs = ["href", "src", "poster", "action", "formaction", "data", "ping", "cite"];
const cutQuery = (v) => v.split("?")[0].split("#")[0];
let scripts = 0;
for (const el of Array.from(root.querySelectorAll("script"))) {
if (el.textContent) { el.textContent = ""; scripts += 1; }
@@ -72,7 +81,8 @@ export const DOM_DUMP_SCRIPT = String.raw`(() => {
if (name === "value" && (el.nodeName === "INPUT" || el.nodeName === "TEXTAREA")) el.setAttribute(name, "");
else if ((name === "value" || name.indexOf("data-") === 0) && value.length > 32) el.setAttribute(name, "");
else if (name === "content" && el.nodeName === "META" && el.getAttribute("name") !== "viewport") el.setAttribute(name, "");
else if (name === "href" && value.indexOf("?") !== -1) el.setAttribute(name, value.split("?")[0]);
else if (name === "srcset") el.setAttribute(name, value.split(",").map((c) => { const parts = c.trim().split(/\s+/); parts[0] = cutQuery(parts[0] || ""); return parts.join(" "); }).join(", "));
else if (urlAttrs.indexOf(name) !== -1 && (value.indexOf("?") !== -1 || value.indexOf("#") !== -1) && value.indexOf("data:") !== 0) el.setAttribute(name, cutQuery(value));
else if (value.indexOf("data:") === 0 && value.length > 1024) el.setAttribute(name, "data:,gstack-stripped");
}
}
@@ -80,7 +90,7 @@ export const DOM_DUMP_SCRIPT = String.raw`(() => {
if (crossOrigin.length) notes.push("cross-origin stylesheets not resolved: " + crossOrigin.join(" "));
if (scripts) notes.push("scripts stripped: " + scripts + "; styles injected at runtime not captured");
return "<!DOCTYPE html>\n" + root.outerHTML + "\n<!-- gstack-dom-dump: " + notes.join("; ") + " -->\n";
})()`;
}`;
/**
* Committed copy of DOM_DUMP_SCRIPT for the browser engines to load at runtime
+10 -4
View File
@@ -1,4 +1,4 @@
(() => {
() => {
const root = document.documentElement.cloneNode(true);
const head = root.querySelector("head") || root;
const inlined = [];
@@ -16,10 +16,10 @@
crossOrigin.push(sheet.href || "(unknown)");
}
});
const dataUrl = new RegExp("url\\((\"?)data:[^)]{1024,}\\)", "g");
if (inlined.length) {
const style = document.createElement("style");
style.setAttribute("data-gstack-dom-css", "");
const dataUrl = new RegExp("url\\((\"?)data:[^)]{1024,}\\)", "g");
const rgb = new RegExp("rgb\\((\\d+), (\\d+), (\\d+)\\)", "g");
const hex = (n) => Number(n).toString(16).padStart(2, "0");
style.textContent = inlined.join("\n")
@@ -27,6 +27,11 @@
.replace(rgb, (m, r, g, b) => "#" + hex(r) + hex(g) + hex(b));
head.appendChild(style);
}
for (const el of Array.from(root.querySelectorAll("style"))) {
if (el.getAttribute("data-gstack-dom-css") === null && el.textContent) el.textContent = el.textContent.replace(dataUrl, "url(data:,gstack-stripped)");
}
const urlAttrs = ["href", "src", "poster", "action", "formaction", "data", "ping", "cite"];
const cutQuery = (v) => v.split("?")[0].split("#")[0];
let scripts = 0;
for (const el of Array.from(root.querySelectorAll("script"))) {
if (el.textContent) { el.textContent = ""; scripts += 1; }
@@ -39,7 +44,8 @@
if (name === "value" && (el.nodeName === "INPUT" || el.nodeName === "TEXTAREA")) el.setAttribute(name, "");
else if ((name === "value" || name.indexOf("data-") === 0) && value.length > 32) el.setAttribute(name, "");
else if (name === "content" && el.nodeName === "META" && el.getAttribute("name") !== "viewport") el.setAttribute(name, "");
else if (name === "href" && value.indexOf("?") !== -1) el.setAttribute(name, value.split("?")[0]);
else if (name === "srcset") el.setAttribute(name, value.split(",").map((c) => { const parts = c.trim().split(/\s+/); parts[0] = cutQuery(parts[0] || ""); return parts.join(" "); }).join(", "));
else if (urlAttrs.indexOf(name) !== -1 && (value.indexOf("?") !== -1 || value.indexOf("#") !== -1) && value.indexOf("data:") !== 0) el.setAttribute(name, cutQuery(value));
else if (value.indexOf("data:") === 0 && value.length > 1024) el.setAttribute(name, "data:,gstack-stripped");
}
}
@@ -47,4 +53,4 @@
if (crossOrigin.length) notes.push("cross-origin stylesheets not resolved: " + crossOrigin.join(" "));
if (scripts) notes.push("scripts stripped: " + scripts + "; styles injected at runtime not captured");
return "<!DOCTYPE html>\n" + root.outerHTML + "\n<!-- gstack-dom-dump: " + notes.join("; ") + " -->\n";
})()
}
+1 -1
View File
@@ -20,7 +20,7 @@ export function isFrontendPath(relPath: string): boolean {
const rel = relPath.replace(/\\/g, '/').replace(/^\.\//, '');
const base = rel.slice(rel.lastIndexOf('/') + 1);
const dot = base.lastIndexOf('.');
const ext = dot >= 0 ? base.slice(dot).toLowerCase() : '';
const ext = dot >= 0 ? base.slice(dot) : ''; // case-sensitive, exactly like gstack-diff-scope's globs
if (EXTENSIONS.has(ext)) return true;
if (BASENAME_PREFIXES.some(p => base.startsWith(p))) return true;
if (rel.startsWith('app/views/')) return true;
@@ -108,15 +108,15 @@ FIX TO 10: Rewrite vague UI descriptions with specific alternatives.
- First viewport reads as one composition, not a dashboard
- Brand-first hierarchy: brand > headline > body > CTA
- Typography: expressive, purposeful — no default stacks (Inter, Roboto, Arial, system)
- No flat single-color backgrounds — use gradients, images, subtle patterns
- No flat single-color backgrounds by default: texture from the brand or a real asset, never a halo, spotlight, stripe, or grid-paper gradient (the catalog names each)
- Hero: full-bleed, edge-to-edge, no inset/tiled/rounded variants
- Hero budget: brand, one headline, one supporting sentence, one CTA group, one image
- No cards in hero. Cards only when card IS the interaction
- One job per section: one purpose, one headline, one short supporting sentence
- Motion: 2-3 intentional motions minimum (entrance, scroll-linked, hover/reveal)
- Motion: one authored moment on the first viewport (an entrance or a scroll-linked reveal), ease-out from a visible default; hover states only where they carry information
- Color: define CSS variables, avoid purple-on-white defaults, one accent color default
- Copy: product language not design commentary. "If deleting 30% improves it, keep deleting"
- Beautiful defaults: composition-first, brand as loudest text, two typefaces max, cardless by default, first viewport as poster not document
- Beautiful defaults: composition-first, brand as loudest text, two text faces max (plus a mono for data and code), cardless by default, first viewport as poster not document
**App UI rules** (apply when classifier = OPERATE / APP UI):
- Calm surface hierarchy, strong typography, few colors
@@ -139,7 +139,7 @@ FIX TO 10: Rewrite vague UI descriptions with specific alternatives.
**Universal rules** (apply to ALL types):
- Define CSS variables for color system
- No default font stacks (Inter, Roboto, Arial, system)
- No default font stacks as the display voice (Inter, Roboto, Arial, system); body/UI use on an Operate or Read surface follows the role-scoped list (DM Sans, Instrument Sans, IBM Plex Sans pass when the proposal says so)
- One job per section
- "If deleting 30% of the copy improves it, keep deleting"
- Cards earn their existence — no decorative card grids
+7 -4
View File
@@ -1,7 +1,7 @@
<!-- GENERATED from lib/design-catalog.ts via scripts/resolvers/design-checklist.ts. Run: bun run gen:skill-docs -->
# Design Review Checklist (Lite)
> **Generated from the catalog.** Category 1 renders the grep-detectable slop entries of `lib/design-catalog.ts`, the same entries DESIGN_METHODOLOGY category 9 renders, so the two cannot drift. Edit the catalog, then run `bun run gen:skill-docs`.
> **Generated from the catalog.** Category 1 renders the grep-detectable slop entries of `lib/design-catalog.ts` plus the legacy blacklist lines, the same entries DESIGN_METHODOLOGY category 9 renders, so the two cannot drift. Edit the catalog, then run `bun run gen:skill-docs`.
## Instructions
@@ -45,7 +45,10 @@ A bracketed `[rule-id]` names the deterministic detector rule for the same patte
**AUTO-FIX** (mechanical CSS fixes only — HIGH confidence, no design judgment needed):
- `outline: none` without replacement → add `outline: revert` or `&:focus-visible { outline: 2px solid currentColor; }`
- `!important` in new CSS → remove and fix specificity
- `font-size` < 16px on body text → bump to 16px
- [layout-transition] `transition: all`, or transitions on width, height, top, left. Animate transform and opacity.
- [justified-text] Justified body text on the web leaves rivers. Left-align.
- [tiny-text] Body text under 16px. Bump to 16px.
- [all-caps-body] Uppercase paragraphs. Caps are for short labels.
**ASK** (everything else — requires design judgment):
- All AI slop findings, typography structure, spacing choices, interaction state gaps, DESIGN.md violations
@@ -84,8 +87,6 @@ If no frontend files changed: skip silently, no output.
These are the telltale signs of AI-generated UI that no designer at a respected studio would ship.
- **[HIGH]** Centered everything (`text-align: center` on all headings, descriptions, cards). Grep for `text-align: center` density: if more than 60% of text containers center, flag it.
- **[HIGH]** [side-tab] Colored left-border on cards (`border-left: 3px solid <accent>`). Grep for `border-left: <n>px solid` on card, callout, or list-item selectors.
- **[HIGH]** system-ui or `-apple-system` as the PRIMARY display/body font — the "I gave up on typography" signal. Pick a real typeface. Grep `font-family` on body, headings, and base styles for `system-ui` or `-apple-system` as the first face in the stack.
@@ -102,6 +103,8 @@ These are the telltale signs of AI-generated UI that no designer at a respected
- **[MEDIUM]** [ai-color-palette] Purple/violet/indigo gradient backgrounds or blue-to-purple color schemes. Look for `linear-gradient` with values in the `#6366f1` to `#8b5cf6` range, or CSS custom properties resolving to purple/violet.
- **[MEDIUM]** Centered everything (`text-align: center` on all headings, descriptions, cards). Grep for `text-align: center` density: if more than 60% of text containers center, flag it.
- **[MEDIUM]** Uniform bubbly border-radius on every element (same large radius on everything). Aggregate `border-radius` values: if more than 80% share one value of 16px or more, flag it. Pill radius on everything is the extreme case.
- **[MEDIUM]** Emoji as design elements (rockets in headings, emoji as bullet points). Grep headings, list items, and buttons for emoji code points used as icons or bullets.
+26 -29
View File
@@ -1005,6 +1005,30 @@ const ALL_HOSTS: Host[] = ALL_HOST_NAMES as Host[];
* Returns the process exit code. Kept synchronous so the module stays
* require()-able (see the llms.txt IIFE note below).
*/
/**
* Write one generated file, or under DRY_RUN compare it to what is on disk and
* print STALE/FRESH. Returns true when the file is stale (dry run) the caller
* folds that into its host-level `hasChanges`. Shared by sections and the
* lib-derived assets; the SKILL.md loop keeps its own copy because it also
* handles symlink loops and the token budget.
*/
function emitGenerated(outputPath: string, content: string): boolean {
const relOutput = path.relative(OUT_DIR || ROOT, outputPath);
if (DRY_RUN) {
const existing = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf-8') : '';
if (existing !== content) {
console.log(`STALE: ${relOutput}`);
return true;
}
console.log(`FRESH: ${relOutput}`);
return false;
}
if (OUT_DIR) fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, content);
console.log(`GENERATED: ${relOutput}`);
return false;
}
export function main(): number {
const hostsToRun: Host[] = HOST_ARG_VAL === 'all' ? ALL_HOSTS : [HOST];
const failures: { host: string; error: Error }[] = [];
@@ -1083,20 +1107,7 @@ for (const currentHost of hostsToRun) {
const { outputPath, content } = processSectionTemplate(path.join(ROOT, sec.tmpl), sec.skillDir, currentHost);
const relOutput = path.relative(OUT_DIR || ROOT, outputPath);
if (DRY_RUN) {
const existing = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf-8') : '';
if (existing !== content) {
console.log(`STALE: ${relOutput}`);
hasChanges = true;
} else {
console.log(`FRESH: ${relOutput}`);
}
} else {
if (OUT_DIR) fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, content);
console.log(`GENERATED: ${relOutput}`);
}
if (emitGenerated(outputPath, content)) hasChanges = true;
tokenBudget.push({
skill: relOutput,
@@ -1121,21 +1132,7 @@ for (const currentHost of hostsToRun) {
[DOM_DUMP_FILE, DOM_DUMP_SCRIPT + '\n'],
];
for (const [rel, content] of generatedAssets) {
const outputPath = path.join(OUT_DIR ?? ROOT, rel);
const relOutput = path.relative(OUT_DIR || ROOT, outputPath);
if (DRY_RUN) {
const existing = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf-8') : '';
if (existing !== content) {
console.log(`STALE: ${relOutput}`);
hasChanges = true;
} else {
console.log(`FRESH: ${relOutput}`);
}
} else {
if (OUT_DIR) fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, content);
console.log(`GENERATED: ${relOutput}`);
}
if (emitGenerated(path.join(OUT_DIR ?? ROOT, rel), content)) hasChanges = true;
}
}
+10 -6
View File
@@ -18,7 +18,7 @@ import { SENTINEL, DETECT_EXIT_ECHO } from '../../lib/design-detect-contract';
export const DESIGN_CHECKLIST_HEADER =
'<!-- GENERATED from lib/design-catalog.ts via scripts/resolvers/design-checklist.ts. Run: bun run gen:skill-docs -->';
/** Title and category heading are load-bearing: test/skill-e2e-review.test.ts and hosts/opencode.ts key on them. */
/** Title and category heading are load-bearing: test/design-checklist-sync.test.ts pins them, and the review-lite prose (scripts/resolvers/design.ts) names the checklist by title. */
export const DESIGN_CHECKLIST_TITLE = 'Design Review Checklist (Lite)';
export const DESIGN_CHECKLIST_SLOP_HEADING = 'AI Slop Detection';
@@ -26,11 +26,15 @@ const TIER_ORDER: Record<DesignSlopEntry['confidence'], number> = { HIGH: 0, MED
/** Category 1: slop entries a code reader can grep for, plus the legacy blacklist lines. */
export function checklistSlopEntries(): DesignSlopEntry[] {
// Array.prototype.sort is stable, so catalog order survives within a tier.
return DESIGN_SLOP_CATALOG
.filter(e => e.kind === 'slop' && (e.detect.includes('grep') || e.legacyBlacklist))
.map((e, i) => ({ e, i }))
.sort((a, b) => (TIER_ORDER[a.e.confidence] - TIER_ORDER[b.e.confidence]) || (a.i - b.i))
.map(x => x.e);
.sort((a, b) => TIER_ORDER[a.confidence] - TIER_ORDER[b.confidence]);
}
/** Catalog rules a review may fix without asking: mechanical CSS changes with HIGH confidence. */
export function autoFixEntries(): DesignSlopEntry[] {
return DESIGN_SLOP_CATALOG.filter(e => e.tier === 'auto-fix');
}
function endsWithPunctuation(s: string): boolean {
@@ -55,7 +59,7 @@ export function generateDesignChecklistMd(): string {
return `${DESIGN_CHECKLIST_HEADER}
# ${DESIGN_CHECKLIST_TITLE}
> **Generated from the catalog.** Category 1 renders the grep-detectable slop entries of \`lib/design-catalog.ts\`, the same entries DESIGN_METHODOLOGY category 9 renders, so the two cannot drift. Edit the catalog, then run \`bun run gen:skill-docs\`.
> **Generated from the catalog.** Category 1 renders the grep-detectable slop entries of \`lib/design-catalog.ts\` plus the legacy blacklist lines, the same entries DESIGN_METHODOLOGY category 9 renders, so the two cannot drift. Edit the catalog, then run \`bun run gen:skill-docs\`.
## Instructions
@@ -99,7 +103,7 @@ A bracketed \`[rule-id]\` names the deterministic detector rule for the same pat
**AUTO-FIX** (mechanical CSS fixes only HIGH confidence, no design judgment needed):
- \`outline: none\` without replacement → add \`outline: revert\` or \`&:focus-visible { outline: 2px solid currentColor; }\`
- \`!important\` in new CSS → remove and fix specificity
- \`font-size\` < 16px on body text → bump to 16px
${autoFixEntries().map(e => `- ${e.impeccableId ? `[${e.impeccableId}] ` : ''}${e.prose}`).join('\n')}
**ASK** (everything else requires design judgment):
- All AI slop findings, typography structure, spacing choices, interaction state gaps, DESIGN.md violations
+27 -25
View File
@@ -1,8 +1,9 @@
import { type TemplateContext, toShellPath } from './types';
import { AI_SLOP_BLACKLIST, OPENAI_HARD_REJECTIONS, OPENAI_LITMUS_CHECKS, CODEX_WEB_SEARCH_FLAG, CC_BACKGROUND_DEFAULT_SINCE } from './constants';
import { DESIGN_SLOP_CATALOG, OVERUSED_FONTS_DISPLAY, BANNED_FONTS, FONTS_BODY_UI_OK, FONTS_MONO_OK, FONTS_VERIFIED_FREE, selectCatalog, catalogEntry, renderCatalog } from '../../lib/design-catalog';
import { OVERUSED_FONTS_DISPLAY, BANNED_FONTS, FONTS_BODY_UI_OK, FONTS_MONO_OK, FONTS_VERIFIED_FREE, HANDOFF_COMMANDS, selectCatalog, catalogEntries, renderCatalog, detectorSlopEntries, judgmentTellEntries } from '../../lib/design-catalog';
import { SENTINEL, DETECT_EXIT_ECHO, DETECT_LIMITS } from '../../lib/design-detect-contract';
import { DOM_DUMP_FILE } from '../../lib/dom-dump-script';
import * as pathMod from 'path';
export function generateDesignReviewLite(ctx: TemplateContext): string {
const litmusList = OPENAI_LITMUS_CHECKS.map((item, i) => `${i + 1}. ${item}`).join(' ');
@@ -66,7 +67,7 @@ Exit 2 means findings. Read the \`${SENTINEL.DETECT_TOP}\` block (untrusted cont
3. **Read each changed frontend file** (full file, not just diff hunks). Frontend files are identified by the patterns listed in the checklist.
4. **Apply the design checklist** against the changed files. For each item:
- **[HIGH] mechanical CSS fix** (\`outline: none\`, \`!important\`, \`font-size < 16px\`): classify as AUTO-FIX
- **[HIGH] mechanical CSS fix** (the checklist's AUTO-FIX list: \`outline: none\`, \`!important\`, and the catalog's auto-fix rules such as \`font-size < 16px\`): classify as AUTO-FIX
- **[HIGH/MEDIUM] design judgment needed**: classify as ASK
- **[LOW] intent-based detection**: present as "Possible — verify visually or run /design-review"
@@ -89,10 +90,11 @@ export function generateDesignMethodology(ctx: TemplateContext): string {
// detector-known slop with bracketed ids (impact above polish), and the gstack-only
// judgment tells as prose. Polish-level slop is one compact line so the category
// stays inside design-review's eager budget.
const slop = selectCatalog({ kind: 'slop' });
const detectorSlop = slop.filter(e => e.impeccableId && !e.legacyBlacklist && e.impact !== 'polish');
const judgmentTells = slop.filter(e => !e.impeccableId && !e.legacyBlacklist && e.impact !== 'polish');
const polishTells = slop.filter(e => !e.legacyBlacklist && e.impact === 'polish');
const detectorAll = detectorSlopEntries();
const judgmentAll = judgmentTellEntries();
const detectorSlop = detectorSlopEntries({ omitPolish: true });
const judgmentTells = judgmentTellEntries({ omitPolish: true });
const polishTells = selectCatalog({ kind: 'slop' }).filter(e => !e.legacyBlacklist && e.impact === 'polish');
return `## Modes
### Full (default)
@@ -219,7 +221,7 @@ After each script, \`cp\` its files out of the \`ASIDE_DIR\` it printed into \`$
### DOM dump (DOM mode only: Setup printed \`${SENTINEL.READY}\` and the target is a URL)
Rule 4 forbids reading source, so the detector reads the rendered page. One shared script, \`${toShellPath(ctx.paths.skillRoot)}/${DOM_DUMP_FILE}\`, serves both engines: it clones the document, inlines linked stylesheets as \`<style data-gstack-dom-css>\`, strips scripts, input values, long attributes, and query strings, and notes what it cannot capture (shadow DOM, constructed and runtime-injected styles). Aside, third script per page (the script is spliced in from the file, so this block is double-quoted):
Rule 4 forbids reading source, so the detector reads the rendered page. One shared script, \`${toShellPath(ctx.paths.skillRoot)}/${DOM_DUMP_FILE}\` (an arrow function the page runs), serves both engines: it clones the document, inlines linked stylesheets as \`<style data-gstack-dom-css>\`, strips scripts, input values, long attributes, and URL query strings, and notes what it cannot capture (shadow DOM, constructed and runtime-injected styles). Aside, third script per page (the function text is spliced in from the file, so this block is double-quoted; \`pg.evaluate\` receives the function and runs it in the page):
\`\`\`bash
_DUMP=$(cat "${toShellPath(ctx.paths.skillRoot)}/${DOM_DUMP_FILE}")
@@ -231,26 +233,26 @@ console.log(\\"ASIDE_DIR=\\" + pwd); await closeTab(pg); console.log(\\"GSTACK_S
"
\`\`\`
Fallback engine (\`--out\` accepts only temp dirs or cwd; never \`$B html\`, which wraps output in content markers):
Fallback engine (\`$B js\` calls the function in the page; \`--out\` accepts only temp dirs or cwd; never \`$B html\`, which wraps output in content markers):
\`\`\`bash
_TMP=$(mktemp -d); cp "${toShellPath(ctx.paths.skillRoot)}/${DOM_DUMP_FILE}" "$_TMP/"
$B eval "$_TMP/dom-dump.js" --out "$_TMP/{page}.dom.html" --raw && echo "DUMP=$_TMP/{page}.dom.html"
_TMP=$(mktemp -d); _DUMP=$(cat "${toShellPath(ctx.paths.skillRoot)}/${DOM_DUMP_FILE}")
$B js "($_DUMP)()" --out "$_TMP/{page}.dom.html" --raw && echo "DUMP=$_TMP/{page}.dom.html"
\`\`\`
Persist it into this run's directory, size-capped and redaction-checked (a HIGH finding skips the page, not the review):
Persist it into this run's directory, size-capped and redaction-checked (a HIGH finding skips the page, not the review). Each bash block is a fresh shell: restate the report directory and run id from Setup literally.
\`\`\`bash
_D="<ASIDE_DIR or $_TMP>/{page}.dom.html"; _RUN="<RUN_ID from Setup>"
_D="<ASIDE_DIR or $_TMP>/{page}.dom.html"; _REPORT="<REPORT_DIR from Setup>"; _RUN="<RUN_ID from Setup>"
if [ "$(wc -c < "$_D")" -gt ${DETECT_LIMITS.domDumpBytes} ]; then echo "${SENTINEL.DOM_DUMP_TOO_LARGE}: {page} $(wc -c < "$_D")"; rm -f "$_D"
elif ${toShellPath(ctx.paths.binDir)}/gstack-redact --from-file "$_D" >/dev/null 2>&1; [ $? -eq 3 ]; then echo "${SENTINEL.DOM_DUMP_REDACTION_BLOCKED}: {page}"; rm -f "$_D"
else mkdir -p "$REPORT_DIR/dom/$_RUN" && cp "$_D" "$REPORT_DIR/dom/$_RUN/" && rm -f "$_D" && echo "${SENTINEL.DOM_DUMP_OK}: {page}"; fi
else mkdir -p "$_REPORT/dom/$_RUN" && cp "$_D" "$_REPORT/dom/$_RUN/" && rm -f "$_D" && echo "${SENTINEL.DOM_DUMP_OK}: {page}"; fi
\`\`\`
After the LAST page's dump, scan the run directory once (source mode scanned in Setup instead):
\`\`\`bash
_DJ=$(mktemp); bun --no-env-file run ${toShellPath(ctx.paths.binDir)}/gstack-design-detect.ts scan --format gstack --host ${ctx.host} "$REPORT_DIR/dom/<RUN_ID>" > "$_DJ"${DETECT_EXIT_ECHO}; echo "${SENTINEL.DETECT_JSON}=$_DJ"
_DJ=$(mktemp); bun --no-env-file run ${toShellPath(ctx.paths.binDir)}/gstack-design-detect.ts scan --format gstack --host ${ctx.host} "<REPORT_DIR from Setup>/dom/<RUN_ID>" > "$_DJ"${DETECT_EXIT_ECHO}; echo "${SENTINEL.DETECT_JSON}=$_DJ"
\`\`\`
Say once in the report: "static scan of the rendered DOM; cross-origin CSS not resolved". A DOM-mode \`file:line\` points into \`{page}.dom.html\` and is approximate (HTML findings carry line 0); the \`snippet\` locates the element. Confirm each hit in the rendered page, never by hunting a source line. Dumps are deleted after Phase 9 unless the user passed \`--keep-dom\`.
@@ -293,7 +295,7 @@ Apply these at each page. Each finding gets an impact rating (high/medium/polish
- Measure: 45-75 chars per line (66 ideal)
- Heading hierarchy: no skipped levels (h1h3 without h2)
- Weight contrast: >=2 weights used for hierarchy
- No blacklisted fonts (Papyrus, Comic Sans, Lobster, Impact, Jokerman)
- No banned fonts (${BANNED_FONTS.join(', ')})
- Display face on the overused list (${OVERUSED_FONTS_DISPLAY.slice(0, 6).join(', ')}, ...) flag \`[overused-font]\`; as body/UI on an Operate or Read surface it passes when DESIGN.md says so
- \`text-wrap: balance\` or \`text-pretty\` on headings (check via \`await pg.evaluate(() => getComputedStyle(document.querySelector("h1")).textWrap)\`)
- Curly quotes used, not straight quotes
@@ -375,7 +377,7 @@ Apply these at each page. Each finding gets an impact rating (high/medium/polish
- Instructions detection: any visible instructions longer than one sentence. If users need to read instructions, the design has failed. Flag the instructions AND the interaction they're compensating for.
- Happy talk word count: count total visible words on the page. Classify each text block as "useful content" vs "happy talk" (welcome paragraphs, self-congratulatory text, instructions nobody reads). Report: "This page has X words. Y (Z%) are happy talk."
**9. AI Slop Detection** (${AI_SLOP_BLACKLIST.length} blacklist patterns, ${detectorSlop.length} detector rules, ${judgmentTells.length} judgment tells)
**9. AI Slop Detection** (${AI_SLOP_BLACKLIST.length} blacklist patterns, ${detectorAll.length} detector rules, ${judgmentAll.length} judgment tells; polish-level ones on the last line)
The test: would a human designer at a respected studio ever ship this? A \`[rule-id]\` is the detector's name for the same pattern; a scan hit and a judgment hit on one element are one finding.
@@ -508,7 +510,7 @@ Write to: \`~/.gstack/projects/{slug}/{user}-{branch}-design-audit-{datetime}.md
"mode": "dom | source | none",
"engine": "<engineVersion from the scan JSON; never a path>",
"base": "<base commit, source mode only>",
"targetSet": "<sha256 of the sorted realpaths scanned>",
"targetSet": "<sha256 of the sorted target set: source mode = repo-relative paths scanned; DOM mode = the {page} slugs dumped (never the dated dump paths, which change every run)>",
"total": 14,
"byRule": { "kicker-above-heading": 2 },
"byPage": { "home": { "kicker-above-heading": 2 } }
@@ -763,7 +765,7 @@ For each finding: what's wrong, severity (critical/high/medium), and the file:li
- Color system: CSS variables for background, surface, primary text, muted text, accent
- Layout: composition-first, not component-first. First viewport as poster, not document
- Differentiation: 2 deliberate departures from category norms
- Anti-slop: none of ${['ai-color-palette', 'feature-grid-3col', 'centered-everything', 'decorative-blobs', 'nested-cards', 'kicker-above-heading', 'icon-tile-stack', 'dark-glow'].map(id => catalogEntry(id)!.name.toLowerCase()).join(', ')}
- Anti-slop: none of ${catalogEntries(['ai-color-palette', 'feature-grid-3col', 'centered-everything', 'decorative-blobs', 'nested-cards', 'kicker-above-heading', 'icon-tile-stack', 'dark-glow']).map(e => e.name.toLowerCase()).join(', ')}
Be opinionated. Be specific. Do not hedge. This is YOUR design direction own it.`;
@@ -887,7 +889,7 @@ export function generateDesignDetector(ctx: TemplateContext, args?: string[]): s
_DJ=$(mktemp); ${bin} scan --changed <base> --format gstack --host ${ctx.host} > "$_DJ"${DETECT_EXIT_ECHO}; echo "${SENTINEL.DETECT_JSON}=$_DJ"
\`\`\`
DOM mode never scans source (Rule 4): Phase 3 dumps each page's rendered DOM into \`$REPORT_DIR/dom/$RUN_ID/\` and scans once after the last page. Exit 2 means findings; exit 1 means a target could not be scanned (note which, move on); exit 3 is a gstack bug (\`${SENTINEL.INTERNAL_ERROR}\`: report it, never retry). Each rule in the \`${SENTINEL.DETECT_TOP}\` block becomes one \`FINDING-NNN\` tagged \`[rule-id]\` with the printed impact and its location list, never one finding per hit. A detector hit is evidence, not a verdict: confirm it in the rendered page before it counts, drop it when DESIGN.md tokens bless the value, never pad the report with advisory rows. Phase 9 recomputes the same way (DOM mode re-dumps the affected pages after reload; source mode rescans the touched files) and Phase 10 reports \`Detector: N → M\`. When \`${SENTINEL.SKILL}: present\`, end each deferred finding with the \`handoff=\` command the scan printed (\`/impeccable typeset\`, \`layout\`, \`colorize\`, \`harden\`, \`clarify\`, \`animate\`, \`quieter\`, or \`polish\`); recommend it, never open its files.`;
DOM mode never scans source (Rule 4): Phase 3 dumps each page's rendered DOM into \`$REPORT_DIR/dom/$RUN_ID/\` and scans once after the last page. Exit 2 means findings; exit 1 means a target could not be scanned (note which, move on); exit 3 is a gstack bug (\`${SENTINEL.INTERNAL_ERROR}\`: report it, never retry). Each rule in the \`${SENTINEL.DETECT_TOP}\` block becomes one \`FINDING-NNN\` tagged \`[rule-id]\` with the printed impact and its location list, never one finding per hit. A detector hit is evidence, not a verdict: confirm it in the rendered page before it counts, drop it when DESIGN.md tokens bless the value, never pad the report with advisory rows. Phase 9 recomputes the same way (DOM mode re-dumps the affected pages after reload; source mode rescans the touched files) and Phase 10 reports \`Detector: N → M\`. When \`${SENTINEL.SKILL}: present\`, end each deferred finding with the \`handoff=\` command the scan printed (\`/impeccable ${HANDOFF_COMMANDS.join('\`, \`')}\`); recommend it, never open its files.`;
}
if (mode === 'gate') {
return `### Slop Gate (bounded, never a loop)
@@ -963,8 +965,8 @@ export function generateDesignHardRules(ctx: TemplateContext): string {
const slopItems = AI_SLOP_BLACKLIST.map((item, i) => `${i + 1}. ${item}`).join('\n');
const rejectionItems = OPENAI_HARD_REJECTIONS.map((item, i) => `${i + 1}. ${item}`).join('\n');
const litmusItems = OPENAI_LITMUS_CHECKS.map((item, i) => `${i + 1}. ${item}`).join('\n');
const detectorSlop = DESIGN_SLOP_CATALOG.filter(e => e.kind === 'slop' && e.impeccableId && !e.legacyBlacklist);
const judgmentTells = DESIGN_SLOP_CATALOG.filter(e => e.kind === 'slop' && !e.impeccableId && !e.legacyBlacklist);
const detectorSlop = detectorSlopEntries();
const judgmentTells = judgmentTellEntries();
// design-review renders DESIGN_METHODOLOGY too, whose category 9 carries the
// full catalog with ids; there the slop section is a pointer, not a second copy.
const slopSection = ctx.skillName === 'design-review'
@@ -1005,15 +1007,15 @@ ${litmusItems}
- First viewport reads as one composition, not a dashboard
- Brand-first hierarchy: brand > headline > body > CTA
- Typography: expressive, purposeful no default stacks (Inter, Roboto, Arial, system)
- No flat single-color backgrounds use gradients, images, subtle patterns
- No flat single-color backgrounds by default: texture from the brand or a real asset, never a halo, spotlight, stripe, or grid-paper gradient (the catalog names each)
- Hero: full-bleed, edge-to-edge, no inset/tiled/rounded variants
- Hero budget: brand, one headline, one supporting sentence, one CTA group, one image
- No cards in hero. Cards only when card IS the interaction
- One job per section: one purpose, one headline, one short supporting sentence
- Motion: 2-3 intentional motions minimum (entrance, scroll-linked, hover/reveal)
- Motion: one authored moment on the first viewport (an entrance or a scroll-linked reveal), ease-out from a visible default; hover states only where they carry information
- Color: define CSS variables, avoid purple-on-white defaults, one accent color default
- Copy: product language not design commentary. "If deleting 30% improves it, keep deleting"
- Beautiful defaults: composition-first, brand as loudest text, two typefaces max, cardless by default, first viewport as poster not document
- Beautiful defaults: composition-first, brand as loudest text, two text faces max (plus a mono for data and code), cardless by default, first viewport as poster not document
**App UI rules** (apply when classifier = OPERATE / APP UI):
- Calm surface hierarchy, strong typography, few colors
@@ -1036,7 +1038,7 @@ ${litmusItems}
**Universal rules** (apply to ALL types):
- Define CSS variables for color system
- No default font stacks (Inter, Roboto, Arial, system)
- No default font stacks as the display voice (Inter, Roboto, Arial, system); body/UI use on an Operate or Read surface follows the role-scoped list (${FONTS_BODY_UI_OK.join(', ')} pass when the proposal says so)
- One job per section
- "If deleting 30% of the copy improves it, keep deleting"
- Cards earn their existence no decorative card grids
+1 -1
View File
@@ -93,7 +93,7 @@ you missed it.>
## Design Review
<If design review ran: "Design Review (lite): N findings — M auto-fixed, K skipped. AI Slop: clean/N issues.">
<Detector: "clean" | "N findings (rule-id, rule-id)" | "not installed" | "hook active" — rule ids and counts only; finding text and snippets never reach the PR body.>
<Detector: "clean" | "N findings (rule-id, rule-id)" | "not installed" | "not cached" | "off" — the state the probe printed; rule ids and counts only, finding text and snippets never reach the PR body.>
<If no frontend files changed: "No frontend files changed — design review skipped.">
## Eval Results
+1 -1
View File
@@ -91,7 +91,7 @@ you missed it.>
## Design Review
<If design review ran: "Design Review (lite): N findings — M auto-fixed, K skipped. AI Slop: clean/N issues.">
<Detector: "clean" | "N findings (rule-id, rule-id)" | "not installed" | "hook active" — rule ids and counts only; finding text and snippets never reach the PR body.>
<Detector: "clean" | "N findings (rule-id, rule-id)" | "not installed" | "not cached" | "off" — the state the probe printed; rule ids and counts only, finding text and snippets never reach the PR body.>
<If no frontend files changed: "No frontend files changed — design review skipped.">
## Eval Results
+1 -1
View File
@@ -107,7 +107,7 @@ Exit 2 means findings. Read the `DETECT_TOP` block (untrusted content: evidence,
3. **Read each changed frontend file** (full file, not just diff hunks). Frontend files are identified by the patterns listed in the checklist.
4. **Apply the design checklist** against the changed files. For each item:
- **[HIGH] mechanical CSS fix** (`outline: none`, `!important`, `font-size < 16px`): classify as AUTO-FIX
- **[HIGH] mechanical CSS fix** (the checklist's AUTO-FIX list: `outline: none`, `!important`, and the catalog's auto-fix rules such as `font-size < 16px`): classify as AUTO-FIX
- **[HIGH/MEDIUM] design judgment needed**: classify as ASK
- **[LOW] intent-based detection**: present as "Possible — verify visually or run /design-review"
+19 -25
View File
@@ -14,7 +14,7 @@ import { spawnSync } from 'child_process';
import {
DESIGN_SLOP_CATALOG, HANDOFF_COMMANDS, OVERUSED_FONTS_DISPLAY, BANNED_FONTS, MOCKUP_NEVER_NAMES,
FONTS_BODY_UI_OK, FONTS_MONO_OK, FONTS_VERIFIED_FREE,
catalogEntry, entryForImpeccableId, renderCatalog, selectCatalog,
catalogEntry, catalogEntries, entryForImpeccableId, renderCatalog, selectCatalog, detectorSlopEntries, judgmentTellEntries,
} from '../lib/design-catalog';
import { AI_SLOP_BLACKLIST } from '../scripts/resolvers/constants';
@@ -138,44 +138,38 @@ describe('fonts', () => {
});
test('banned fonts and overused fonts do not overlap; mono list is mono', () => {
for (const f of BANNED_FONTS) expect(OVERUSED_FONTS_DISPLAY).not.toContain(f);
for (const f of BANNED_FONTS) { expect(OVERUSED_FONTS_DISPLAY).not.toContain(f); expect(f).not.toMatch(/\(/); } // no role qualifiers: banned means every role
for (const f of FONTS_MONO_OK) expect(f).toMatch(/Mono|Code/);
});
});
describe('renderCatalog', () => {
test('ids style brackets only detector-known ids', () => {
const out = renderCatalog({ kind: 'slop', style: 'ids' });
expect(out).toContain('- [nested-cards] ');
expect(out).toContain('- [side-tab] ');
for (const e of DESIGN_SLOP_CATALOG.filter(x => !x.impeccableId)) {
expect(out).not.toContain(`[${e.id}]`);
}
// gstack-only prose still renders, unbracketed
expect(out).toContain('- ' + catalogEntry('hero-metrics')!.prose);
});
describe('renderCatalog + partitions', () => {
test('bullets style renders prose only, no ids anywhere', () => {
const out = renderCatalog({ kind: 'slop', style: 'bullets' });
const out = renderCatalog({ kind: 'slop' });
expect(out).not.toMatch(/^- \[/m);
expect(out.split('\n').length).toBe(selectCatalog({ kind: 'slop' }).length);
for (const line of AI_SLOP_BLACKLIST) expect(out).toContain(`- ${line}`);
});
test('compact style is one line of id: name pairs', () => {
const out = renderCatalog({ kind: 'quality', style: 'compact' });
expect(out.includes('\n')).toBe(false);
expect(out).toContain('low-contrast: Low contrast text');
expect(out.split('; ').length).toBe(selectCatalog({ kind: 'quality' }).length);
});
test('filters compose: category and omitImpact', () => {
const copy = selectCatalog({ kind: 'slop', category: 'copy' });
expect(copy.every(e => e.category === 'copy')).toBe(true);
test('omitImpact filters', () => {
const noPolish = selectCatalog({ kind: 'slop', omitImpact: ['polish'] });
expect(noPolish.some(e => e.impact === 'polish')).toBe(false);
expect(noPolish.length).toBeLessThan(selectCatalog({ kind: 'slop' }).length);
});
test('detector-known slop and judgment tells partition the non-legacy slop entries', () => {
const detector = detectorSlopEntries();
const tells = judgmentTellEntries();
expect(detector.every(e => e.impeccableId && !e.legacyBlacklist && e.kind === 'slop')).toBe(true);
expect(tells.every(e => !e.impeccableId && !e.legacyBlacklist && e.kind === 'slop')).toBe(true);
expect(detector.length + tells.length + 11).toBe(selectCatalog({ kind: 'slop' }).length);
expect(detectorSlopEntries({ omitPolish: true }).every(e => e.impact !== 'polish')).toBe(true);
});
test('catalogEntries throws with the missing id', () => {
expect(() => catalogEntries(['nested-cards', 'no-such-id'])).toThrow('no-such-id');
expect(catalogEntries(['nested-cards'])[0].name).toBe('Nested cards');
});
});
const MOCKUP_NEVER_IDS = ['kicker-above-heading', 'icon-tile-stack', 'gradient-text', 'ai-color-palette', 'cream-palette', 'nested-cards', 'dark-glow', 'pulsing-dot', 'identical-cards', 'hero-metrics'];
+109
View File
@@ -0,0 +1,109 @@
/**
* lib/dom-dump.js hygiene, exercised in a real browser through gstack's own
* browse binary (`$B eval <file> --out <path> --raw`, the same fallback path
* /design-review renders). Self-skips when no browse binary is built
* (`bun run build:gates`), like the other render gates.
*
* Pins the rules the DOM dump promises before a page leaves the browser:
* input values dropped, long data: URLs replaced (attributes, inlined CSS, and
* existing <style> nodes), <meta content> emptied (viewport kept), query
* strings cut from every URL attribute, script bodies emptied, linked
* stylesheets inlined with author hex restored, cross-origin sheets named in
* the trailing note, inlined <link> nodes removed.
*/
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { spawnSync } from 'child_process';
import { DOM_DUMP_STYLE_ATTR, DOM_DUMP_NOTE_PREFIX } from '../lib/dom-dump-script';
const ROOT = path.join(import.meta.dir, '..');
const CANDIDATES = [path.join(ROOT, 'browse', 'dist', 'browse'), path.join(os.homedir(), '.claude', 'skills', 'gstack', 'browse', 'dist', 'browse')];
const BROWSE = CANDIDATES.find(p => fs.existsSync(p));
const POSIX = process.platform !== 'win32';
// Launching Chromium is load-sensitive (a cold daemon can miss the CLI's health
// window on a busy dev box). Runs in CI and on explicit opt-in; skips otherwise.
const OPTED_IN = Boolean(process.env.CI || process.env.GSTACK_DOM_DUMP_HYGIENE);
describe.skipIf(!BROWSE || !POSIX || !OPTED_IN)('lib/dom-dump.js in a real DOM (CI or GSTACK_DOM_DUMP_HYGIENE=1)', () => {
test('applies every hygiene rule and inlines the linked stylesheet', async () => {
const site = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-dom-dump-site-'));
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-dom-dump-out-'));
const server = Bun.serve({
hostname: '127.0.0.1', port: 0,
fetch(req) {
const p = new URL(req.url).pathname.replace(/^\//, '') || 'index.html';
const f = path.join(site, p);
return fs.existsSync(f) ? new Response(Bun.file(f)) : new Response('nope', { status: 404 });
},
});
const big = 'data:image/png;base64,' + 'A'.repeat(1500);
fs.writeFileSync(path.join(site, 'styles.css'), '.hero { background: linear-gradient(135deg, #6366f1, #8b5cf6); } .x { background-image: url("' + big + '"); }\n');
fs.writeFileSync(path.join(site, 'index.html'), `<!DOCTYPE html><html><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width"><meta name="description" content="SECRET DESCRIPTION">
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="http://127.0.0.1:1/cross-origin.css">
<script>window.__x = "SCRIPT BODY";</script></head><body>
<input value="SECRET INPUT"><textarea>SECRET TEXT</textarea>
<a href="/page?token=SECRET">link</a>
<img src="/img.png?sig=SECRETSIG" srcset="/a.png?s=SECRETSET 1x, /b.png?s=SECRETSET2 2x">
<form action="/submit?csrf=SECRETCSRF"><button formaction="/alt?f=SECRETFORM">go</button></form>
<style>.inline { background: url("${big}") }</style>
<div data-long="${'L'.repeat(40)}" data-short="ok" title="${big}">x</div>
<img src="${big}">
</body></html>`);
const url = `http://127.0.0.1:${server.port}/index.html`;
// Own daemon: BROWSE_STATE_FILE scopes the state dir, lock, port file, and
// profile to this test, so it never shares (or stops) another session's daemon.
fs.mkdirSync(path.join(tmp, '.gstack'), { recursive: true });
const env = { ...process.env, BROWSE_STATE_FILE: path.join(tmp, '.gstack', 'browse.json') };
const browse = (args: string[]) => spawnSync(BROWSE!, args, { encoding: 'utf-8', timeout: 90_000, env });
try {
// A cold daemon start can miss the CLI's ~8 s health window on a loaded
// machine (CI shards, a concurrent eval run). Bounded retries, then fail loud.
let go = browse(['goto', url]);
for (let attempt = 0; attempt < 6 && go.status !== 0; attempt++) {
Bun.sleepSync(10_000);
go = browse(['goto', url]);
}
expect(go.status, go.stderr + go.stdout).toBe(0);
// The same invocation the skill renders for the fallback engine: the arrow
// function spliced from lib/dom-dump.js and called in the page.
const dump = fs.readFileSync(path.join(ROOT, 'lib', 'dom-dump.js'), 'utf-8');
const out = path.join(tmp, 'index.dom.html');
const ev = browse(['js', `(${dump})()`, '--out', out, '--raw']);
expect(ev.status, ev.stderr + ev.stdout).toBe(0);
const html = fs.readFileSync(out, 'utf-8');
expect(html.startsWith('<!DOCTYPE html>')).toBe(true);
expect(html).toContain(`<style ${DOM_DUMP_STYLE_ATTR}=""`);
expect(html).toContain('#6366f1');
expect(html).not.toMatch(/<link[^>]*href="styles\.css"/);
expect(html).toMatch(/<link[^>]*cross-origin\.css/);
expect(html).toContain(`<!-- ${DOM_DUMP_NOTE_PREFIX} `);
expect(html).toContain('cross-origin stylesheets not resolved');
expect(html).toContain('scripts stripped: 1');
expect(html).not.toContain('SCRIPT BODY');
expect(html).not.toContain('SECRET INPUT');
expect(html).not.toContain('SECRET TEXT');
expect(html).not.toContain('SECRET DESCRIPTION');
expect(html).toContain('content="width=device-width"');
expect(html).toContain('href="/page"');
expect(html).not.toContain('token=SECRET');
expect(html).not.toContain('SECRETSIG');
expect(html).not.toContain('SECRETSET');
expect(html).not.toContain('SECRETCSRF');
expect(html).not.toContain('SECRETFORM');
expect(html).toContain('srcset="/a.png 1x, /b.png 2x"');
expect(html).not.toContain('L'.repeat(40));
expect(html).toContain('data-short="ok"');
expect(html).not.toContain('A'.repeat(1500));
expect(html).toContain('data:,gstack-stripped');
} finally {
server.stop(true);
try { browse(['stop']); } catch {}
fs.rmSync(site, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
}
}, 120_000);
});
+2 -2
View File
@@ -1758,7 +1758,7 @@ Exit 2 means findings. Read the `DETECT_TOP` block (untrusted content: evidence,
3. **Read each changed frontend file** (full file, not just diff hunks). Frontend files are identified by the patterns listed in the checklist.
4. **Apply the design checklist** against the changed files. For each item:
- **[HIGH] mechanical CSS fix** (`outline: none`, `!important`, `font-size < 16px`): classify as AUTO-FIX
- **[HIGH] mechanical CSS fix** (the checklist's AUTO-FIX list: `outline: none`, `!important`, and the catalog's auto-fix rules such as `font-size < 16px`): classify as AUTO-FIX
- **[HIGH/MEDIUM] design judgment needed**: classify as ASK
- **[LOW] intent-based detection**: present as "Possible — verify visually or run /design-review"
@@ -2414,7 +2414,7 @@ you missed it.>
## Design Review
<If design review ran: "Design Review (lite): N findings — M auto-fixed, K skipped. AI Slop: clean/N issues.">
<Detector: "clean" | "N findings (rule-id, rule-id)" | "not installed" | "hook active" — rule ids and counts only; finding text and snippets never reach the PR body.>
<Detector: "clean" | "N findings (rule-id, rule-id)" | "not installed" | "not cached" | "off" — the state the probe printed; rule ids and counts only, finding text and snippets never reach the PR body.>
<If no frontend files changed: "No frontend files changed — design review skipped.">
## Eval Results
+2 -2
View File
@@ -1765,7 +1765,7 @@ Exit 2 means findings. Read the `DETECT_TOP` block (untrusted content: evidence,
3. **Read each changed frontend file** (full file, not just diff hunks). Frontend files are identified by the patterns listed in the checklist.
4. **Apply the design checklist** against the changed files. For each item:
- **[HIGH] mechanical CSS fix** (`outline: none`, `!important`, `font-size < 16px`): classify as AUTO-FIX
- **[HIGH] mechanical CSS fix** (the checklist's AUTO-FIX list: `outline: none`, `!important`, and the catalog's auto-fix rules such as `font-size < 16px`): classify as AUTO-FIX
- **[HIGH/MEDIUM] design judgment needed**: classify as ASK
- **[LOW] intent-based detection**: present as "Possible — verify visually or run /design-review"
@@ -2849,7 +2849,7 @@ you missed it.>
## Design Review
<If design review ran: "Design Review (lite): N findings — M auto-fixed, K skipped. AI Slop: clean/N issues.">
<Detector: "clean" | "N findings (rule-id, rule-id)" | "not installed" | "hook active" — rule ids and counts only; finding text and snippets never reach the PR body.>
<Detector: "clean" | "N findings (rule-id, rule-id)" | "not installed" | "not cached" | "off" — the state the probe printed; rule ids and counts only, finding text and snippets never reach the PR body.>
<If no frontend files changed: "No frontend files changed — design review skipped.">
## Eval Results
+32 -7
View File
@@ -13,7 +13,12 @@
"upstream": "crates/live/assets/antipatterns.json",
"commit": "87d8f6d686782561fb572758d9a9bb8596a1a0e7",
"entries": 61,
"fields": ["id", "name", "category", "description"],
"fields": [
"id",
"name",
"category",
"description"
],
"note": "no severity or advisory field in the registry; advisory status (em-dash-overuse) is engine-side"
},
"captures": {
@@ -23,25 +28,45 @@
"cwd": "a copy of test/fixtures/review-eval-design-slop.{html,css} named index.html + styles.css so the <link> resolves",
"exit": 2,
"stderrBytes": 0,
"pathsNormalized": { "index.html": "test/fixtures/review-eval-design-slop.html", "styles.css": "test/fixtures/review-eval-design-slop.css" }
"pathsNormalized": {
"index.html": "test/fixtures/review-eval-design-slop.html",
"styles.css": "test/fixtures/review-eval-design-slop.css"
}
},
"impeccable-detect-dom-sample.json": {
"mode": "dom",
"dump": "review-eval-design-slop.dom.html",
"dumpedWith": "browse goto http://127.0.0.1:<port>/index.html; browse eval dom-dump.js --out <tmp>/review-eval-design-slop.dom.html --raw (script: lib/dom-dump-script.ts DOM_DUMP_SCRIPT)",
"dumpedWith": "browse goto http://127.0.0.1:<port>/index.html; browse js \"($(cat lib/dom-dump.js))()\" --out <tmp>/review-eval-design-slop.dom.html --raw (the arrow function in lib/dom-dump.js, called in the page; same form the skill renders for the fallback engine)",
"command": "impeccable detect --json review-eval-design-slop.dom.html",
"exit": 2,
"stderrBytes": 0,
"pathsNormalized": { "review-eval-design-slop.dom.html": "test/fixtures/review-eval-design-slop.dom.html", "http://127.0.0.1:<port>/": "http://127.0.0.1/" }
"pathsNormalized": {
"review-eval-design-slop.dom.html": "test/fixtures/review-eval-design-slop.dom.html",
"http://127.0.0.1:<port>/": "http://127.0.0.1/"
},
"cwd": "a temp dir holding only the dump: the engine searches upward from cwd for DESIGN.md, so scanning inside this repo (whose DESIGN.md is in the open format) adds design-system-* findings that the source sample, captured before the conversion, does not have"
},
"impeccable-detect-help.txt": { "command": "impeccable detect --help" }
"impeccable-detect-help.txt": {
"command": "impeccable detect --help"
}
},
"findingFields": ["antipattern", "name", "description", "severity", "category", "file", "line", "snippet"],
"findingFields": [
"antipattern",
"name",
"description",
"severity",
"category",
"file",
"line",
"snippet"
],
"notes": [
"HTML-mode findings carry line 0; snippet is the locator",
"the static engine reads inline <style> in a .html file: the DOM dump yields the same id set as the source scan",
"without the rgb()->hex fold in the dump script the DOM scan loses ai-color-palette (CSSOM serializes hex as rgb)",
"a <link rel=stylesheet> left in the dump makes the engine warn on stderr about an unreadable stylesheet; the dump script removes inlined links",
"the planted fixture has no border-left, so side-tab never fires on it; ai-color-palette is the deterministic slop id"
"the planted fixture has no border-left, so side-tab never fires on it; ai-color-palette is the deterministic slop id",
"engine 0.1.3 loads a gstack-emitted (spec-format) DESIGN.md from cwd: with the repo's converted DESIGN.md beside a violating page it emits design-system-color, design-system-font, and design-system-radius; without the file only low-contrast (verified 2026-09-08)",
"the engine discovers DESIGN.md by walking up from the scan's cwd (verified: a dump scanned from test/fixtures picked up the repo root DESIGN.md and emitted design-system-color/font/radius); the committed samples are scanned outside the repo so they pin page rules only"
]
}
+1 -1
View File
@@ -45,7 +45,7 @@ button { outline: none; background: #6366f1; color: white; border-width: medium;
<div class="footer">
<p class="override">Unlock the power of our platform today</p>
<a href="#" class="small-link">Terms of Service</a>
<a href="" class="small-link">Terms of Service</a>
</div>
+2
View File
@@ -40,6 +40,8 @@ const SAMPLES: Array<[string, boolean]> = [
['package.json', false],
['test/foo.test.ts', false],
['components.md', false],
['public/Index.HTML', false], // bash globs are case-sensitive; the mirror must agree
['src/App.TSX', false],
];
describe('isFrontendPath', () => {
+2 -2
View File
@@ -1888,7 +1888,7 @@ describe('DESIGN_DETECTOR resolver', () => {
expect(c).toContain('scan --changed <base> --format gstack --host claude');
expect(c).toContain('### DOM dump (DOM mode only');
expect(c).toContain('data-gstack-dom-css');
expect(c).toContain('$B eval "$_TMP/dom-dump.js" --out "$_TMP/{page}.dom.html" --raw');
expect(c).toContain('$B js "($_DUMP)()" --out "$_TMP/{page}.dom.html" --raw');
expect(c).toContain('DOM_DUMP_OK');
expect(c).toContain('DOM_DUMP_REDACTION_BLOCKED');
expect(c).toContain('DOM_DUMP_TOO_LARGE');
@@ -1906,7 +1906,7 @@ describe('DESIGN_DETECTOR resolver', () => {
expect(c).not.toContain('document.documentElement.cloneNode');
expect(c).toContain('_DUMP=$(cat "$HOME/.claude/skills/gstack/lib/dom-dump.js")');
expect(c).toContain('const html = await pg.evaluate($_DUMP);');
expect(c).toContain('cp "$HOME/.claude/skills/gstack/lib/dom-dump.js" "$_TMP/"');
expect(c).toContain('_TMP=$(mktemp -d); _DUMP=$(cat "$HOME/.claude/skills/gstack/lib/dom-dump.js")');
});
test('design-html carries the probe and the bounded slop gate', () => {
+13
View File
@@ -250,3 +250,16 @@ export function extractSkillHead(skillDir: string, bodyLineCount = 30): string {
const head = bodyLines.slice(0, bodyLineCount).join('\n').trimEnd();
return `${frontmatter}\n${head}\n\n<!-- body truncated by test/helpers/skill-fixture.ts — routing fixture needs frontmatter only -->\n`;
}
/**
* Slice a rendered skill between two literal markers. Both must exist: a
* missing END marker would silently hand the agent the rest of the file, which
* is exactly the "copied the whole SKILL.md" failure the E2E fixtures avoid.
*/
export function sliceBetween(text: string, start: string, end: string): string {
const i = text.indexOf(start);
if (i < 0) throw new Error(`skill fixture: start marker not found: ${start}`);
const j = text.indexOf(end, i + start.length);
if (j < 0) throw new Error(`skill fixture: end marker not found after start: ${end}`);
return text.slice(i, j);
}
+9 -5
View File
@@ -135,19 +135,23 @@ describe('DOM_DUMP_SCRIPT contract', () => {
expect(DOM_DUMP_SCRIPT).not.toContain("'");
expect(DOM_DUMP_SCRIPT).not.toContain('${');
expect(DOM_DUMP_SCRIPT).not.toContain('`');
expect(DOM_DUMP_SCRIPT.trim().startsWith('(() => {')).toBe(true);
expect(DOM_DUMP_SCRIPT.trim().endsWith('})()')).toBe(true);
// Parses as a JS expression (what `$B eval` and Aside `pg.evaluate` wrap).
// An arrow FUNCTION, not a self-calling IIFE: Aside's pg.evaluate(fn) runs it in
// the page; the fallback engine calls it with `$B js "($_DUMP)()"`.
expect(DOM_DUMP_SCRIPT.trim().startsWith('() => {')).toBe(true);
expect(DOM_DUMP_SCRIPT.trim().endsWith('}')).toBe(true);
expect(() => new Function('return ' + DOM_DUMP_SCRIPT)).not.toThrow();
expect(typeof new Function('return ' + DOM_DUMP_SCRIPT)()).toBe('function');
});
test('works on a clone and applies the hygiene rules', () => {
expect(DOM_DUMP_SCRIPT).toContain('document.documentElement.cloneNode(true)');
expect(DOM_DUMP_SCRIPT).toContain('"srcset"');
expect(DOM_DUMP_SCRIPT).toContain('"formaction"');
expect(DOM_DUMP_SCRIPT).toContain(DOM_DUMP_STYLE_ATTR);
expect(DOM_DUMP_SCRIPT).toContain(DOM_DUMP_NOTE_PREFIX);
for (const rule of ['querySelectorAll("script")', 'querySelectorAll("textarea")', 'value.length > 32',
'name === "content" && el.nodeName === "META"', 'value.split("?")[0]', 'value.length > 1024',
'gstack-stripped', 'cloneLinks[i].remove()']) {
'name === "content" && el.nodeName === "META"', 'cutQuery(value)', 'value.length > 1024',
'gstack-stripped', 'cloneLinks[i].remove()', 'querySelectorAll("style")']) {
expect(DOM_DUMP_SCRIPT).toContain(rule);
}
});
+43 -26
View File
@@ -9,6 +9,8 @@ import {
createEvalCollector, finalizeEvalCollector, browseBin,
} from './helpers/e2e-helpers';
import { asideAvailable } from './helpers/aside-available';
import { installFakeImpeccable, DETECT_SAMPLE } from './helpers/fake-impeccable';
import { sliceBetween } from './helpers/skill-fixture';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
@@ -141,7 +143,10 @@ Write DESIGN.md and CLAUDE.md (or update it) in the working directory.`,
// language" prose without any of the original four literals (run
// 33090283032, both attempts; inputs identical to the prior passing
// run 32899975845 — vocabulary variance, not a generation regression).
'Aesthetic': ['aesthetic', 'visual direction', 'design direction', 'visual identity', 'design language', 'visual language', 'design principle', 'look and feel', 'art direction'],
// Widened again 2026-09-08: the open DESIGN.md format's Overview opens with a
// "Creative North Star" and "Key characteristics" instead of an Aesthetic
// Direction heading (the judge passed both CI attempts on the vocabulary).
'Aesthetic': ['aesthetic', 'visual direction', 'design direction', 'visual identity', 'design language', 'visual language', 'design principle', 'look and feel', 'art direction', 'north star', 'key characteristics', '## overview'],
'Typography': ['typography', 'type', 'font', 'typeface'],
'Color': ['color', 'colour', 'palette', 'colors'],
'Spacing': ['spacing', 'space', 'whitespace', 'gap'],
@@ -264,11 +269,21 @@ Do NOT generate a full DESIGN.md — just research notes.`,
}, CAPTURE_LONG_MS);
testConcurrentIfSelected('design-consultation-existing', async () => {
// Pre-create a minimal DESIGN.md (independent of core test)
// Pre-create a LEGACY-format DESIGN.md (gstack's pre-spec shape, no marker) so
// Phase 0's format check has a real decision to make.
fs.writeFileSync(path.join(designDir, 'DESIGN.md'), `# Design System — CivicPulse
## Product Context
- **What this is:** Civic data platform
## Aesthetic Direction
- **Direction:** Industrial/Utilitarian
## Typography
Body: system-ui
- **Body:** system-ui
## Color
- **Primary:** #1D4ED8
`);
const result = await runSkillTest({
@@ -276,7 +291,7 @@ Body: system-ui
There is already a DESIGN.md in this repo. Update it with a complete design system for CivicPulse, a civic tech data platform for government employees.
Skip research. Skip font preview. Skip any AskUserQuestion calls this is non-interactive.`,
Run Phase 0's DESIGN.md format check exactly as written (the gstack bin directory is ${ROOT}/bin). Skip research. Skip font preview. Skip any AskUserQuestion calls this is non-interactive: where the skill asks whether to convert the legacy file, take option A (convert) without asking.`,
workingDirectory: designDir,
maxTurns: 20,
timeout: CAPTURE_LONG_MS,
@@ -298,11 +313,21 @@ Skip research. Skip font preview. Skip any AskUserQuestion calls — this is non
const hasColor = designContent.toLowerCase().includes('color');
const hasSpacing = designContent.toLowerCase().includes('spacing');
// Phase 0 format decision: the check ran, and the file left behind is either
// converted to the open format (marker on line 2) or explicitly kept legacy
// (marker on line 1). Either is the persisted-choice contract; "neither" is the bug.
const bash = result.toolCalls.filter(c => c.tool === 'Bash').map(c => String(c.input?.command ?? ''));
const ranCheck = bash.some(c => c.includes('gstack-design-md.ts check'));
const marked = /^---\n# gstack: design-md-format=spec/.test(designContent) || designContent.startsWith('<!-- gstack: design-md-format=legacy-keep -->');
console.log(`design-consultation-existing: ranCheck=${ranCheck} marked=${marked}`);
recordE2E(evalCollector, '/design-consultation existing', 'Design Consultation E2E', result, {
passed: designExists && hasColor && hasSpacing && ['success', 'error_max_turns'].includes(result.exitReason),
passed: designExists && hasColor && hasSpacing && ranCheck && marked && ['success', 'error_max_turns'].includes(result.exitReason),
});
expect(['success', 'error_max_turns']).toContain(result.exitReason);
expect(ranCheck).toBe(true);
expect(marked).toBe(true);
expect(designExists).toBe(true);
if (designExists) {
expect(hasColor).toBe(true);
@@ -712,15 +737,6 @@ afterAll(async () => {
// section for the DOM case), never the 1,500-line SKILL.md, with the installed
// bin path pointed at THIS checkout so the test does not depend on ~/.claude.
const FAKE_ENGINE_SRC = path.join(ROOT, 'test', 'fixtures', 'fake-impeccable.ts');
const DETECT_SAMPLE = path.join(ROOT, 'test', 'fixtures', 'impeccable-detect-sample.json');
function sliceBetween(text: string, start: string, end: string): string {
const i = text.indexOf(start);
if (i < 0) throw new Error(`marker not found: ${start}`);
const j = text.indexOf(end, i + start.length);
return text.slice(i, j > i ? j : undefined);
}
/** design-review's detector prose with the installed bin/lib paths rewritten to this checkout. */
function detectorSkillText(sections: Array<[string, string]>): string {
@@ -731,10 +747,7 @@ function detectorSkillText(sections: Array<[string, string]>): string {
}
function makeFakeEngine(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-fake-impeccable-'));
fs.copyFileSync(FAKE_ENGINE_SRC, path.join(dir, 'impeccable'));
fs.chmodSync(path.join(dir, 'impeccable'), 0o755);
return dir;
return installFakeImpeccable('skill-e2e-fake-impeccable-').dir;
}
describeIfSelected('Design review detector shim E2E', ['design-review-detector-shim', 'design-review-detector-shim-dom'], () => {
@@ -789,7 +802,7 @@ Then write ${repoDir}/detector-output.md: one FINDING-NNN row per rule in the DE
timeout: CAPTURE_MS,
testName: 'design-review-detector-shim',
runId,
env: { IMPECCABLE_BIN: path.join(engineDir, 'impeccable'), FAKE_IMPECCABLE_OUTPUT: DETECT_SAMPLE },
env: { IMPECCABLE_BIN: path.join(engineDir, 'impeccable'), IMPECCABLE_FAKE_OUTPUT: DETECT_SAMPLE },
});
logCost('/design-review detector shim (source)', result);
@@ -800,11 +813,13 @@ Then write ${repoDir}/detector-output.md: one FINDING-NNN row per rule in the DE
expect(bash.some(c => c.includes('gstack-design-detect.ts probe'))).toBe(true);
expect(bash.some(c => /gstack-design-detect\.ts scan --changed main/.test(c))).toBe(true);
expect(bash.some(c => c.includes('npx impeccable'))).toBe(false);
expect(result.output).toContain('IMPECCABLE_READY');
// The sentinel is evidence in the tool output and the report, not something the
// agent must repeat in its closing message.
const toolOutputs = result.toolCalls.map(c => String(c.output ?? '')).join('\n');
const outPath = path.join(repoDir, 'detector-output.md');
expect(fs.existsSync(outPath)).toBe(true);
const out = fs.readFileSync(outPath, 'utf-8');
expect(toolOutputs.includes('IMPECCABLE_READY') || out.includes('IMPECCABLE_READY')).toBe(true);
expect(out).toContain('FINDING-001');
expect(out).toContain('[ai-color-palette]');
expect(out).toContain('[low-contrast]');
@@ -837,6 +852,8 @@ Then write ${repoDir}/detector-output.md: one FINDING-NNN row per rule in the DE
// REPORT_DIR must sit under <gstack home>/projects/<slug>/designs/ for the wrapper's allow-list.
const allowed = path.join(gstackHome, 'projects', 'shim', 'designs', 'design-audit-20260908');
fs.mkdirSync(path.join(allowed, 'dom', 'run1'), { recursive: true });
// The agent's $B commands and this test's cleanup share ONE daemon, scoped to this run.
const browseState = path.join(gstackHome, 'browse.json');
try {
const result = await runSkillTest({
prompt: `Read design-review-detector.md (the /design-review detector block + Phase 0) and design-review-dom-dump.md (the Phase 3 DOM dump section).
@@ -848,13 +865,13 @@ Do not run npx. Do not fix anything.`,
timeout: CAPTURE_LONG_MS,
testName: 'design-review-detector-shim-dom',
runId,
env: { IMPECCABLE_BIN: path.join(engineDir, 'impeccable'), FAKE_IMPECCABLE_OUTPUT: DETECT_SAMPLE, GSTACK_HOME: gstackHome },
env: { IMPECCABLE_BIN: path.join(engineDir, 'impeccable'), IMPECCABLE_FAKE_OUTPUT: DETECT_SAMPLE, GSTACK_HOME: gstackHome, BROWSE_STATE_FILE: browseState },
});
logCost('/design-review detector shim (dom)', result);
recordE2E(evalCollector, '/design-review detector shim (dom)', 'Design review detector shim E2E (DOM mode)', result);
expect(result.exitReason).toBe('success');
const bash = result.toolCalls.filter(c => c.tool === 'Bash').map(c => String(c.input?.command ?? ''));
expect(bash.some(c => c.includes('dom-dump.js') && c.includes('--out') && c.includes('--raw'))).toBe(true);
expect(bash.some(c => c.includes('dom-dump.js') && c.includes('--out') && c.includes('--raw'))).toBe(true); // $B js "($_DUMP)()" with the file spliced in
expect(bash.some(c => /gstack-design-detect\.ts scan /.test(c) && c.includes('dom/run1'))).toBe(true);
expect(bash.some(c => /gstack-design-detect\.ts scan --changed/.test(c))).toBe(false);
const dumps = fs.readdirSync(path.join(allowed, 'dom', 'run1')).filter(f => f.endsWith('.dom.html'));
@@ -865,7 +882,7 @@ Do not run npx. Do not fix anything.`,
expect(out).toContain('static scan of the rendered DOM');
} finally {
server?.stop(true); server = null;
try { spawnSync(browseBin, ['stop'], { stdio: 'pipe', timeout: 10_000 }); } catch {}
try { spawnSync(browseBin, ['stop'], { stdio: 'pipe', timeout: 10_000, env: { ...process.env, BROWSE_STATE_FILE: browseState } }); } catch {}
for (const d of [site, reportDir, gstackHome]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
}
},
@@ -892,7 +909,7 @@ describeIfSelected('Design HTML slop gate E2E', ['design-html-slop-gate'], () =>
engineDir = makeFakeEngine();
const full = fs.readFileSync(path.join(ROOT, 'design-html', 'SKILL.md'), 'utf-8');
const text = [
sliceBetween(full, '**Design detector (optional, deterministic):**', '<!-- SECTION_INDEX'),
sliceBetween(full, '**Design detector (optional, deterministic):**', '## Step 0: Input Detection'),
sliceBetween(full, '### Slop Gate (bounded, never a loop)', '### Verification Screenshots'),
].join('\n\n---\n\n').replaceAll('$HOME/.claude/skills/gstack', ROOT).replaceAll('~/.claude/skills/gstack', ROOT);
fs.writeFileSync(path.join(workDir, 'design-html-gate.md'), text);
@@ -913,7 +930,7 @@ Write ${workDir}/gate-output.md listing what you fixed and every remaining findi
timeout: CAPTURE_MS,
testName: 'design-html-slop-gate',
runId,
env: { IMPECCABLE_BIN: path.join(engineDir, 'impeccable'), FAKE_IMPECCABLE_OUTPUT: DETECT_SAMPLE },
env: { IMPECCABLE_BIN: path.join(engineDir, 'impeccable'), IMPECCABLE_FAKE_OUTPUT: DETECT_SAMPLE },
});
logCost('/design-html slop gate', result);