mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 14:38:59 +02:00
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:
co-authored by
Claude Fable 5.1
parent
b4d88a0126
commit
da6f0ff2f6
@@ -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:**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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";
|
||||
})()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 (h1→h3 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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'];
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user