mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 06:28:59 +02:00
fix(design): Aside dump script stays single-quoted; redaction gate sized to the dump cap; doctrine made consistent
- The DOM-dump Aside block was the only double-quoted `aside repl` script in
the tree (to splice the function text), which put the agent-filled <url>
inside a double-quoted bash string: a same-origin href carrying $(...) would
run in the reviewer's shell when Phase 3 opened that page. The script is
single-quoted like every other Aside script and the function text enters
through a closed-quote segment ('"$_DUMP"'); the fallback line is
`$B js '('"$_DUMP"')()'`. A free test pins that no rendered Aside script
opens with a double quote.
- The persist block capped dumps at 10 MiB but ran gstack-redact with its
1 MiB default, so every real page between the two was deleted as
DOM_DUMP_REDACTION_BLOCKED; the gate passes --max-bytes at the dump cap and
blocks on any exit other than clean (0) or MEDIUM (2), so a redaction tool
that fails to run can no longer fall through to "persist".
- Dump hygiene removes <template> and <noscript> subtrees (invisible to the
attribute walk), inline on* handlers, and the cross-origin <link> nodes
already named in the note, so the file handed to the engine references no
remote stylesheet.
- Doctrine: the Codex design-voice prompts said "2-3 intentional motions"
against the one-authored-moment rule; the overused-display heading scoped
its ban to Persuade/Experience while the catalog and hard rules ban it
everywhere; design-consultation's Important Rule 4 still said "as primary";
design-html's blacklist header is now "Never include by default" with the
mockup/DESIGN.md/user-ask override the catalog grants; the slop gate honors
Decisions Log and Do's and Don'ts blessings like /review does; the landing
"poster" line says poster in stance, not type size; the design binary's
variant dials no longer flip light/dark for variety; gstack's DESIGN.md
rows name data labels (UI labels stay the DM Sans token) and call the
skill-bar fill and hovers functional transitions.
- design-review names how the base branch is found (gh pr view, then the
repo default; never main) for the source-mode scan and the diff-aware mode.
- frontend-scope matches the config globs at the repo root only, like the
bash arm; the parity test carries nested samples.
- Cleanups: renderCatalog's stale style option, an unused import, the
identity-map bannedFontNames, the checklist header's "same entries" claim,
the catalog header's consumer list, the orphaned main() docstring, the
plan doc's IIFE bullet. design-html's skeleton ceiling is re-measured
(54,184) for the two doctrine sentences.
Tests: AUTO-FIX rendering from the catalog, the E2E slice markers checked in
the free suite, the hygiene cases for templates/noscript/handlers/remote
links, and the review E2E counting detector rows separately from the seven
checklist plants.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
ae5a5298e0
commit
982a738663
@@ -139,5 +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. |
|
||||
| 2026-09-08 | Live-feed pulse is the one authored motion moment | The 2s pulse on the live-feed dot is the one authored motion moment, an intentional exception to the pulsing-dot catalog rule. The skill-bar fill and the 150ms hover transitions are functional state transitions, not authored moments. |
|
||||
| 2026-09-08 | JetBrains Mono data labels at 11px | Data labels at Nano (11px) run in JetBrains Mono, below the 12px UI-text floor; UI labels stay DM Sans (the `typography.label` token). Recorded so the detector's undersized-ui-text and monospace-costume rows are read as decided, not open. |
|
||||
|
||||
@@ -950,7 +950,7 @@ already knows. A good test: would this insight save time in a future session? If
|
||||
1. **Propose, don't present menus.** You are a consultant, not a form. Make opinionated recommendations based on the product context, then let the user adjust.
|
||||
2. **Every recommendation needs a rationale.** Never say "I recommend X" without "because Y."
|
||||
3. **Coherence over individual choices.** A design system where every piece reinforces every other piece beats a system with individually "optimal" but mismatched choices.
|
||||
4. **Never recommend blacklisted or overused fonts as primary.** If the user specifically requests one, comply but explain the tradeoff.
|
||||
4. **Never a banned face in any role, never an overused face as the display voice.** Body or UI on an Operate or Read surface follows the role-scoped list in the proposal section. If the user asks for a listed face by name, comply and state the tradeoff once.
|
||||
5. **The preview page must be beautiful.** It's the first visual output and sets the tone for the whole skill.
|
||||
6. **Conversational tone.** This isn't a rigid workflow. If the user wants to talk through a decision, engage as a thoughtful design partner.
|
||||
7. **Accept the user's final choice.** Nudge on coherence issues, but never block or refuse to write a DESIGN.md because you disagree with a choice.
|
||||
|
||||
@@ -235,7 +235,7 @@ If the user said no research, skip entirely and proceed to Phase 3 using your bu
|
||||
1. **Propose, don't present menus.** You are a consultant, not a form. Make opinionated recommendations based on the product context, then let the user adjust.
|
||||
2. **Every recommendation needs a rationale.** Never say "I recommend X" without "because Y."
|
||||
3. **Coherence over individual choices.** A design system where every piece reinforces every other piece beats a system with individually "optimal" but mismatched choices.
|
||||
4. **Never recommend blacklisted or overused fonts as primary.** If the user specifically requests one, comply but explain the tradeoff.
|
||||
4. **Never a banned face in any role, never an overused face as the display voice.** Body or UI on an Operate or Read surface follows the role-scoped list in the proposal section. If the user asks for a listed face by name, comply and state the tradeoff once.
|
||||
5. **The preview page must be beautiful.** It's the first visual output and sets the tone for the whole skill.
|
||||
6. **Conversational tone.** This isn't a rigid workflow. If the user wants to talk through a decision, engage as a thoughtful design partner.
|
||||
7. **Accept the user's final choice.** Nudge on coherence issues, but never block or refuse to write a DESIGN.md because you disagree with a choice.
|
||||
|
||||
@@ -62,7 +62,7 @@ The SAFE/RISK breakdown is critical. Design coherence is table stakes — every
|
||||
|
||||
**Choosing faces: a procedure, not a menu.** Type comes from the subject's world, in the mode's register. (1) Name the world: the publication, notation, identity program, or object this audience already reads. (2) Shortlist three faces per role (display, body, label, mono) from that world. (3) Strike anything on the overused list for the role it would play. (4) Verify availability this session: WebSearch or Aside the Google Fonts / Fontshare page, or confirm the license of a self-hosted face. Unverified faces do not go in the proposal. (5) State the loading strategy with the name.
|
||||
|
||||
**Overused as display** (never the display voice on a Persuade or Experience surface; the detector flags several as `overused-font`): Inter, Roboto, Arial, Helvetica, Open Sans, Lato, Montserrat, Poppins, Space Grotesk, Space Mono, Fraunces, Playfair Display, Cormorant, Lora, Crimson, Newsreader, Syne, IBM Plex Sans, IBM Plex Serif, DM Sans, DM Serif, Outfit, Plus Jakarta Sans, Instrument Sans, Geist.
|
||||
**Overused as display** (never the display voice, on any surface; the body/UI exception below is the only one; the detector flags several as `overused-font`): Inter, Roboto, Arial, Helvetica, Open Sans, Lato, Montserrat, Poppins, Space Grotesk, Space Mono, Fraunces, Playfair Display, Cormorant, Lora, Crimson, Newsreader, Syne, IBM Plex Sans, IBM Plex Serif, DM Sans, DM Serif, Outfit, Plus Jakarta Sans, Instrument Sans, Geist.
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
@@ -697,7 +697,7 @@ For framework output, save to:
|
||||
- `prefers-reduced-motion` for animation respect
|
||||
- Real content extracted from the mockup (never lorem ipsum)
|
||||
|
||||
**Never include (AI slop blacklist):**
|
||||
**Never include by default (AI slop blacklist):** an approved mockup that carries one, a DESIGN.md blessing, or an explicit user ask overrides it; say the tradeoff once.
|
||||
- Purple/blue gradients as default <!-- ai-color-palette -->
|
||||
- Cream-and-serif default palette <!-- cream-palette -->
|
||||
- Gradient text <!-- gradient-text -->
|
||||
@@ -759,7 +759,7 @@ If the Setup probe printed `IMPECCABLE_READY`, scan the finalized page once befo
|
||||
_DJ=$(mktemp); bun --no-env-file run $HOME/.claude/skills/gstack/bin/gstack-design-detect.ts scan --format gstack --host claude <finalized.html> > "$_DJ"; echo "DETECT_EXIT_CODE=$?"; echo "DETECT_JSON=$_DJ"
|
||||
```
|
||||
|
||||
Exit 2 → one surgical fix pass over the non-advisory rules in the `DETECT_TOP` block, then scan once more. Whatever remains, present the page with those findings listed as accepted-with-reason: a pattern the approved mockup contains, a value DESIGN.md's tokens bless, or an inline `<!-- impeccable-disable <rule>: <reason> -->` the user agreed to. One pass, not a loop. Any other first line from the probe: skip, no ceremony.
|
||||
Exit 2 → one surgical fix pass over the non-advisory rules in the `DETECT_TOP` block, then scan once more. Whatever remains, present the page with those findings listed as accepted-with-reason: a pattern the approved mockup contains, a value DESIGN.md's tokens bless or a pattern its Decisions Log or Do's and Don'ts records as intentional, or an inline `<!-- impeccable-disable <rule>: <reason> -->` the user agreed to. One pass, not a loop. Any other first line from the probe: skip, no ceremony.
|
||||
|
||||
### Verification Screenshots
|
||||
|
||||
|
||||
@@ -277,7 +277,7 @@ For framework output, save to:
|
||||
- `prefers-reduced-motion` for animation respect
|
||||
- Real content extracted from the mockup (never lorem ipsum)
|
||||
|
||||
**Never include (AI slop blacklist):**
|
||||
**Never include by default (AI slop blacklist):** an approved mockup that carries one, a DESIGN.md blessing, or an explicit user ask overrides it; say the tradeoff once.
|
||||
- Purple/blue gradients as default <!-- ai-color-palette -->
|
||||
- Cream-and-serif default palette <!-- cream-palette -->
|
||||
- Gradient text <!-- gradient-text -->
|
||||
|
||||
+15
-15
@@ -813,7 +813,7 @@ echo "REPORT_DIR: $REPORT_DIR"; echo "RUN_ID: $RUN_ID"
|
||||
|
||||
Remember `RUN_ID` and restate it literally in later blocks (each bash block is a fresh shell). DOM dumps land in `$REPORT_DIR/dom/$RUN_ID/`; nothing from earlier runs is touched.
|
||||
|
||||
**Phase 0: mechanical scan** (only after `IMPECCABLE_READY`). Pick the mode once: a URL target (any URL, localhost included) is DOM mode; diff-aware with no URL is source mode. Source mode scans the changed frontend files now:
|
||||
**Phase 0: mechanical scan** (only after `IMPECCABLE_READY`). Pick the mode once: a URL target (any URL, localhost included) is DOM mode; diff-aware with no URL is source mode. Source mode scans the changed frontend files now, against the base branch (`gh pr view --json baseRefName -q .baseRefName`, else `gh repo view --json defaultBranchRef -q .defaultBranchRef.name`; never assume `main`; an unknown base is refused, exit 1):
|
||||
|
||||
```bash
|
||||
_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"
|
||||
@@ -961,7 +961,7 @@ Comprehensive review: 10-15 pages, every interaction flow, exhaustive checklist.
|
||||
|
||||
### Diff-aware (automatic when on a feature branch with no URL)
|
||||
When on a feature branch, scope to pages affected by the branch changes:
|
||||
1. Analyze the branch diff: `git diff main...HEAD --name-only`
|
||||
1. Analyze the branch diff: `git diff <base>...HEAD --name-only` (the base branch: `gh pr view --json baseRefName -q .baseRefName`, else `gh repo view --json defaultBranchRef -q .defaultBranchRef.name`; never assume `main`)
|
||||
2. Map changed files to affected pages/routes
|
||||
3. Detect running app on common local ports (3000, 4000, 8080)
|
||||
4. Audit only affected pages, compare design quality before/after
|
||||
@@ -1074,31 +1074,31 @@ 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` (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):
|
||||
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, templates, noscript blocks, inline event handlers, 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 script stays single-quoted like every other Aside script, so the URL and the page slug are never inside a double-quoted bash string; only the function text is spliced in from the file through a closed-quote segment, and `pg.evaluate` receives the function and runs it in the page (`{page}` is the screenshot slug: letters, digits, hyphens):
|
||||
|
||||
```bash
|
||||
_DUMP=$(cat "$HOME/.claude/skills/gstack/lib/dom-dump.js")
|
||||
aside repl "
|
||||
const pg = await openTab(\"<url>\");
|
||||
const html = await pg.evaluate($_DUMP);
|
||||
await fs.writeFile(path.join(pwd, \"{page}.dom.html\"), html);
|
||||
console.log(\"ASIDE_DIR=\" + pwd); await closeTab(pg); console.log(\"GSTACK_STEP_OK\");
|
||||
"
|
||||
aside repl '
|
||||
const pg = await openTab("<url>");
|
||||
const html = await pg.evaluate('"$_DUMP"');
|
||||
await fs.writeFile(path.join(pwd, "{page}.dom.html"), html);
|
||||
console.log("ASIDE_DIR=" + pwd); await closeTab(pg); console.log("GSTACK_STEP_OK");
|
||||
'
|
||||
```
|
||||
|
||||
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):
|
||||
Fallback engine (`$B js` calls the function in the page, spliced the same way; `--out` accepts only temp dirs or cwd; never `$B html`, which wraps output in content markers):
|
||||
|
||||
```bash
|
||||
_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"
|
||||
$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). Each bash block is a fresh shell: restate the report directory and run id from Setup literally.
|
||||
Persist it into this run's directory, size-capped and redaction-checked: a HIGH finding, or a redaction tool that fails to run, 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"; _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"
|
||||
elif $HOME/.claude/skills/gstack/bin/gstack-redact --from-file "$_D" --max-bytes 10485760 >/dev/null 2>&1; _RC=$?; [ "$_RC" -ne 0 ] && [ "$_RC" -ne 2 ]; then echo "DOM_DUMP_REDACTION_BLOCKED: {page} redact-exit=$_RC"; rm -f "$_D"
|
||||
else mkdir -p "$_REPORT/dom/$_RUN" && cp "$_D" "$_REPORT/dom/$_RUN/" && rm -f "$_D" && echo "DOM_DUMP_OK: {page}"; fi
|
||||
```
|
||||
|
||||
@@ -1501,7 +1501,7 @@ Tie everything to user goals and product objectives. Always suggest specific imp
|
||||
- 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 text faces max (plus a mono for data and code), 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 one composition, not a document (poster in stance, not in type size: display stays under 6rem)
|
||||
|
||||
**App UI rules** (apply when classifier = OPERATE / APP UI):
|
||||
- Calm surface hierarchy, strong typography, few colors
|
||||
@@ -1590,7 +1590,7 @@ codex exec "Review the frontend source code in this repo. Evaluate against these
|
||||
- Color: CSS variables with defined system, or hardcoded hex scattered?
|
||||
- Responsive: breakpoints defined? calc(100svh - header) for heroes? Mobile tested?
|
||||
- A11y: ARIA landmarks, alt text, contrast ratios, 44px touch targets?
|
||||
- Motion: 2-3 intentional animations, or zero / ornamental only?
|
||||
- Motion: one authored moment (an entrance or scroll-linked reveal, ease-out from a visible default) plus state transitions only where they carry information, or zero / ornamental only?
|
||||
- Cards: used only when card IS the interaction? No decorative card grids?
|
||||
|
||||
First classify as MARKETING/LANDING PAGE vs APP UI vs HYBRID, then apply matching rules.
|
||||
|
||||
@@ -32,7 +32,7 @@ const STYLE_VARIATIONS = [
|
||||
"Use a calmer, more minimal style with generous whitespace and subtle colors.",
|
||||
"Use a warmer, more approachable style with rounded corners and friendly typography.",
|
||||
"Use a more professional, corporate style with sharp edges and structured grid layout.",
|
||||
"Use a dark theme with light text and accent colors for key interactive elements.",
|
||||
"Commit to one saturated hue across large surfaces (drenched color) with restrained decoration and texture from the product's material world.",
|
||||
"Use a playful, modern style with asymmetric layout and unexpected color accents.",
|
||||
];
|
||||
|
||||
|
||||
@@ -49,12 +49,12 @@ Delivery: ONE PR (user decision 2026-09-08, overriding the review's two-PR sugge
|
||||
|
||||
## Accepted Scope (added to this plan)
|
||||
|
||||
**1. Generation-time slop guard (commit 7b).** `lib/design-catalog.ts` sets `mockupNever: true` on exactly these ten ids: `kicker-above-heading`, `icon-tile-stack`, `gradient-text`, `ai-color-palette`, `cream-palette`, `nested-cards`, `dark-glow`, `pulsing-dot`, and the gstack-only `identical-cards` and `hero-metrics`. It exports `MOCKUP_NEVER_NAMES: string[]` (their `name` fields, deduped, plain English, no ids). `design/src/brief.ts` `briefToPrompt()` pushes `"Never: " + MOCKUP_NEVER_NAMES.join(", ") + "."` before its fixed tail. Import path `../../lib/design-catalog` (precedent: `design/src/receipted-fetch.ts` imports `../../lib/egress-receipt`; `scripts/build.sh` bundles relative TS into the binary; `setup` already treats `lib/` mtime as a rebuild trigger). `lib/design-catalog.ts` imports nothing from `scripts/`. The catalog invariant test checks the ten ids; the new `design/test/brief.test.ts` asserts the "Never:" line is present, precedes the fixed tail, and lists `MOCKUP_NEVER_NAMES.length` names. Binary rebuilt by `./setup`; `design/dist/` is never committed.
|
||||
**1. Generation-time slop guard (commit 7b).** `lib/design-catalog.ts` sets `mockupNever: true` on exactly these ten ids: `kicker-above-heading`, `icon-tile-stack`, `gradient-text`, `ai-color-palette`, `cream-palette`, `nested-cards`, `dark-glow`, `pulsing-dot`, and the gstack-only `identical-cards` and `hero-metrics`. It exports `MOCKUP_NEVER_NAMES: string[]` (their `name` fields, deduped, plain English, no ids). `design/src/brief.ts` `briefToPrompt()` pushes `"Never: " + MOCKUP_NEVER_NAMES.join(", ") + "."` (shipped as `Never by default (unless the brief above asks for it): …`) before its fixed tail. Import path `../../lib/design-catalog` (precedent: `design/src/receipted-fetch.ts` imports `../../lib/egress-receipt`; `scripts/build.sh` bundles relative TS into the binary; `setup` already treats `lib/` mtime as a rebuild trigger). `lib/design-catalog.ts` imports nothing from `scripts/`. The catalog invariant test checks the ten ids; the new `design/test/brief.test.ts` asserts the "Never:" line is present, precedes the fixed tail, and lists `MOCKUP_NEVER_NAMES.length` names. Binary rebuilt by `./setup`; `design/dist/` is never committed.
|
||||
|
||||
**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 (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.
|
||||
- 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 (superseded before shipping: the script is an arrow function called in the page, spliced into the single-quoted Aside script and into `$B js '('"$_DUMP"')()'` through a closed-quote segment, never a double-quoted block; see lib/dom-dump-script.ts) (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".
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
//
|
||||
// lib/design-catalog.ts
|
||||
// ├─ scripts/resolvers/constants.ts AI_SLOP_BLACKLIST: the 11 legacy lines, verbatim, in order
|
||||
// ├─ scripts/resolvers/design.ts DESIGN_METHODOLOGY cat 9, DESIGN_HARD_RULES,
|
||||
// │ DESIGN_REVIEW_LITE, OVERUSED_FONTS, DESIGN_SLOP_BULLETS
|
||||
// ├─ scripts/resolvers/design.ts DESIGN_METHODOLOGY cat 9, DESIGN_HARD_RULES, DESIGN_DETECTOR
|
||||
// │ (handoffs), OVERUSED_FONTS, DESIGN_SLOP_BULLETS, and the
|
||||
// │ design-html anti-slop line (catalogEntries)
|
||||
// ├─ scripts/resolvers/design-checklist.ts review/design-checklist.md (generated)
|
||||
// ├─ bin/gstack-design-detect.ts normalizes engine findings by impeccableId
|
||||
// └─ design/src/brief.ts MOCKUP_NEVER_NAMES in the image-generation prompt
|
||||
@@ -65,7 +66,7 @@ export interface DesignSlopEntry {
|
||||
mockupNever?: true;
|
||||
}
|
||||
|
||||
/** Training-data defaults: never the display voice on a Persuade or Experience surface. */
|
||||
/** Training-data defaults: never the display voice on any surface (body/UI on Operate/Read is the one exception, FONTS_BODY_UI_OK). */
|
||||
const OVERUSED_DISPLAY = [
|
||||
'Inter', 'Roboto', 'Arial', 'Helvetica', 'Open Sans', 'Lato', 'Montserrat', 'Poppins',
|
||||
'Space Grotesk', 'Space Mono', 'Fraunces', 'Playfair Display', 'Cormorant', 'Lora', 'Crimson',
|
||||
@@ -657,7 +658,7 @@ export const MOCKUP_NEVER_NAMES: readonly string[] = [
|
||||
|
||||
// ── Fonts ──
|
||||
|
||||
/** Never the display voice on Persuade/Experience; the detector flags several as `overused-font`. */
|
||||
/** Never the display voice; the detector flags several as `overused-font`. */
|
||||
export const OVERUSED_FONTS_DISPLAY: readonly string[] = OVERUSED_DISPLAY;
|
||||
|
||||
/** Never, in any role. */
|
||||
|
||||
@@ -34,7 +34,10 @@
|
||||
// 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.
|
||||
// stripped) so the report can say so once. <template> and <noscript>
|
||||
// subtrees (invisible to the querySelectorAll walk) and inline on*
|
||||
// handlers are removed; cross-origin <link> nodes leave the clone too,
|
||||
// so the file handed to the engine names no remote stylesheet.
|
||||
export const DOM_DUMP_SCRIPT = String.raw`() => {
|
||||
const root = document.documentElement.cloneNode(true);
|
||||
const head = root.querySelector("head") || root;
|
||||
@@ -51,6 +54,7 @@ export const DOM_DUMP_SCRIPT = String.raw`() => {
|
||||
if (cloneLinks[i]) cloneLinks[i].remove();
|
||||
} catch (err) {
|
||||
crossOrigin.push(sheet.href || "(unknown)");
|
||||
if (cloneLinks[i]) cloneLinks[i].remove();
|
||||
}
|
||||
});
|
||||
const dataUrl = new RegExp("url\\((\"?)data:[^)]{1024,}\\)", "g");
|
||||
@@ -74,11 +78,13 @@ export const DOM_DUMP_SCRIPT = String.raw`() => {
|
||||
if (el.textContent) { el.textContent = ""; scripts += 1; }
|
||||
}
|
||||
for (const el of Array.from(root.querySelectorAll("textarea"))) el.textContent = "";
|
||||
for (const el of Array.from(root.querySelectorAll("template, noscript"))) el.remove();
|
||||
for (const el of Array.from(root.querySelectorAll("*"))) {
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
const name = attr.name;
|
||||
const value = attr.value;
|
||||
if (name === "value" && (el.nodeName === "INPUT" || el.nodeName === "TEXTAREA")) el.setAttribute(name, "");
|
||||
if (name.indexOf("on") === 0) el.removeAttribute(name);
|
||||
else 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 === "srcset") el.setAttribute(name, value.split(",").map((c) => { const parts = c.trim().split(/\s+/); parts[0] = cutQuery(parts[0] || ""); return parts.join(" "); }).join(", "));
|
||||
|
||||
+4
-1
@@ -14,6 +14,7 @@
|
||||
if (cloneLinks[i]) cloneLinks[i].remove();
|
||||
} catch (err) {
|
||||
crossOrigin.push(sheet.href || "(unknown)");
|
||||
if (cloneLinks[i]) cloneLinks[i].remove();
|
||||
}
|
||||
});
|
||||
const dataUrl = new RegExp("url\\((\"?)data:[^)]{1024,}\\)", "g");
|
||||
@@ -37,11 +38,13 @@
|
||||
if (el.textContent) { el.textContent = ""; scripts += 1; }
|
||||
}
|
||||
for (const el of Array.from(root.querySelectorAll("textarea"))) el.textContent = "";
|
||||
for (const el of Array.from(root.querySelectorAll("template, noscript"))) el.remove();
|
||||
for (const el of Array.from(root.querySelectorAll("*"))) {
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
const name = attr.name;
|
||||
const value = attr.value;
|
||||
if (name === "value" && (el.nodeName === "INPUT" || el.nodeName === "TEXTAREA")) el.setAttribute(name, "");
|
||||
if (name.indexOf("on") === 0) el.removeAttribute(name);
|
||||
else 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 === "srcset") el.setAttribute(name, value.split(",").map((c) => { const parts = c.trim().split(/\s+/); parts[0] = cutQuery(parts[0] || ""); return parts.join(" "); }).join(", "));
|
||||
|
||||
@@ -13,7 +13,9 @@ const EXTENSIONS = new Set([
|
||||
'.html',
|
||||
]);
|
||||
|
||||
const BASENAME_PREFIXES = ['tailwind.config.', 'postcss.config.'];
|
||||
// Root-level only: the bash arm's glob (`tailwind.config.*`) is matched against the
|
||||
// whole repo-relative path, so a nested `apps/web/tailwind.config.js` is not frontend there.
|
||||
const ROOT_CONFIG_PREFIXES = ['tailwind.config.', 'postcss.config.'];
|
||||
|
||||
/** Repo-relative path (forward slashes) → is it a frontend file per gstack-diff-scope? */
|
||||
export function isFrontendPath(relPath: string): boolean {
|
||||
@@ -22,7 +24,7 @@ export function isFrontendPath(relPath: string): boolean {
|
||||
const dot = base.lastIndexOf('.');
|
||||
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.includes('/') && ROOT_CONFIG_PREFIXES.some(p => base.startsWith(p))) return true;
|
||||
if (rel.startsWith('app/views/')) return true;
|
||||
if (rel.includes('/components/')) return true;
|
||||
if (rel.startsWith('styles/') || rel.startsWith('css/')) return true;
|
||||
|
||||
@@ -1008,7 +1008,7 @@ LITMUS CHECKS — answer YES or NO for each:
|
||||
7. Would design feel premium with all decorative shadows removed?
|
||||
|
||||
HARD RULES — first classify as MARKETING/LANDING PAGE vs APP UI vs HYBRID, then flag violations of the matching rule set:
|
||||
- MARKETING: First viewport as one composition, brand-first hierarchy, full-bleed hero, 2-3 intentional motions, composition-first layout
|
||||
- MARKETING: First viewport as one composition, brand-first hierarchy, full-bleed hero, one authored motion moment on the first viewport, composition-first layout
|
||||
- APP UI: Calm surface hierarchy, dense but readable, utility language, minimal chrome
|
||||
- UNIVERSAL: CSS variables for colors, no default font stacks, one job per section, cards earn existence
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ FIX TO 10: Rewrite vague UI descriptions with specific alternatives.
|
||||
- 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 text faces max (plus a mono for data and code), 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 one composition, not a document (poster in stance, not in type size: display stays under 6rem)
|
||||
|
||||
**App UI rules** (apply when classifier = OPERATE / APP UI):
|
||||
- Calm surface hierarchy, strong typography, few colors
|
||||
|
||||
@@ -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` 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`.
|
||||
> **Generated from the catalog.** Category 1 renders the grep-detectable slop entries of `lib/design-catalog.ts` plus the legacy blacklist lines: a subset of what DESIGN_METHODOLOGY category 9 renders, drawn from the same catalog, so the shared entries cannot drift. Edit the catalog, then run `bun run gen:skill-docs`.
|
||||
|
||||
## Instructions
|
||||
|
||||
|
||||
+13
-12
@@ -993,18 +993,6 @@ function findTemplates(): string[] {
|
||||
|
||||
const ALL_HOSTS: Host[] = ALL_HOST_NAMES as Host[];
|
||||
|
||||
/**
|
||||
* The generator's whole executable body. Import-purity contract: importing
|
||||
* this module must NEVER touch the tree — test/gen-skill-docs.test.ts pulls
|
||||
* assertSinglePreamble via require(), test/catalog-trim.test.ts imports
|
||||
* helpers, and before this guard existed every such import regenerated all
|
||||
* 71 SKILL.md in place at module-load time (the root cause of half the
|
||||
* TREE_MUTATING serial shard; hazard class #2532). Pinned by
|
||||
* test/gen-skill-docs-import-purity.test.ts.
|
||||
*
|
||||
* 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
|
||||
@@ -1029,6 +1017,19 @@ function emitGenerated(outputPath: string, content: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The generator's whole executable body. Import-purity contract: importing
|
||||
* this module must NEVER touch the tree — test/gen-skill-docs.test.ts pulls
|
||||
* assertSinglePreamble via require(), test/catalog-trim.test.ts imports
|
||||
* helpers, and before this guard existed every such import regenerated all
|
||||
* 71 SKILL.md in place at module-load time (the root cause of half the
|
||||
* TREE_MUTATING serial shard; hazard class #2532). Pinned by
|
||||
* test/gen-skill-docs-import-purity.test.ts.
|
||||
*
|
||||
* Returns the process exit code. Kept synchronous so the module stays
|
||||
* require()-able (see the llms.txt IIFE note below).
|
||||
*/
|
||||
|
||||
export function main(): number {
|
||||
const hostsToRun: Host[] = HOST_ARG_VAL === 'all' ? ALL_HOSTS : [HOST];
|
||||
const failures: { host: string; error: Error }[] = [];
|
||||
|
||||
@@ -49,17 +49,12 @@ function renderSlopItem(e: DesignSlopEntry): string {
|
||||
return `- **[${e.confidence}]**${id} ${prose}${heuristic}${values}`;
|
||||
}
|
||||
|
||||
/** Font names without role qualifiers, for a grep. */
|
||||
function bannedFontNames(): string[] {
|
||||
return BANNED_FONTS.map(f => f.replace(/\s*\(.*\)$/, ''));
|
||||
}
|
||||
|
||||
export function generateDesignChecklistMd(): string {
|
||||
const slop = checklistSlopEntries();
|
||||
return `${DESIGN_CHECKLIST_HEADER}
|
||||
# ${DESIGN_CHECKLIST_TITLE}
|
||||
|
||||
> **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\`.
|
||||
> **Generated from the catalog.** Category 1 renders the grep-detectable slop entries of \`lib/design-catalog.ts\` plus the legacy blacklist lines: a subset of what DESIGN_METHODOLOGY category 9 renders, drawn from the same catalog, so the shared entries cannot drift. Edit the catalog, then run \`bun run gen:skill-docs\`.
|
||||
|
||||
## Instructions
|
||||
|
||||
@@ -152,7 +147,7 @@ ${slop.map(renderSlopItem).join('\n\n')}
|
||||
|
||||
- **[HIGH]** Heading hierarchy skipping levels: \`h1\` followed by \`h3\` without an \`h2\` in the same file/component. Check HTML/JSX for heading tags.
|
||||
|
||||
- **[HIGH]** Blacklisted fonts: ${bannedFontNames().join(', ')}. Grep \`font-family\` for these names.
|
||||
- **[HIGH]** Blacklisted fonts: ${BANNED_FONTS.join(', ')}. Grep \`font-family\` for these names.
|
||||
|
||||
### 3. Spacing & Layout (4 items)
|
||||
|
||||
|
||||
+19
-20
@@ -3,7 +3,6 @@ import { AI_SLOP_BLACKLIST, OPENAI_HARD_REJECTIONS, OPENAI_LITMUS_CHECKS, CODEX_
|
||||
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(' ');
|
||||
@@ -108,7 +107,7 @@ Comprehensive review: 10-15 pages, every interaction flow, exhaustive checklist.
|
||||
|
||||
### Diff-aware (automatic when on a feature branch with no URL)
|
||||
When on a feature branch, scope to pages affected by the branch changes:
|
||||
1. Analyze the branch diff: \`git diff main...HEAD --name-only\`
|
||||
1. Analyze the branch diff: \`git diff <base>...HEAD --name-only\` (the base branch: \`gh pr view --json baseRefName -q .baseRefName\`, else \`gh repo view --json defaultBranchRef -q .defaultBranchRef.name\`; never assume \`main\`)
|
||||
2. Map changed files to affected pages/routes
|
||||
3. Detect running app on common local ports (3000, 4000, 8080)
|
||||
4. Audit only affected pages, compare design quality before/after
|
||||
@@ -221,31 +220,31 @@ 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}\` (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):
|
||||
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, templates, noscript blocks, inline event handlers, 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 script stays single-quoted like every other Aside script, so the URL and the page slug are never inside a double-quoted bash string; only the function text is spliced in from the file through a closed-quote segment, and \`pg.evaluate\` receives the function and runs it in the page (\`{page}\` is the screenshot slug: letters, digits, hyphens):
|
||||
|
||||
\`\`\`bash
|
||||
_DUMP=$(cat "${toShellPath(ctx.paths.skillRoot)}/${DOM_DUMP_FILE}")
|
||||
aside repl "
|
||||
const pg = await openTab(\\"<url>\\");
|
||||
const html = await pg.evaluate($_DUMP);
|
||||
await fs.writeFile(path.join(pwd, \\"{page}.dom.html\\"), html);
|
||||
console.log(\\"ASIDE_DIR=\\" + pwd); await closeTab(pg); console.log(\\"GSTACK_STEP_OK\\");
|
||||
"
|
||||
aside repl '
|
||||
const pg = await openTab("<url>");
|
||||
const html = await pg.evaluate('"$_DUMP"');
|
||||
await fs.writeFile(path.join(pwd, "{page}.dom.html"), html);
|
||||
console.log("ASIDE_DIR=" + pwd); await closeTab(pg); console.log("GSTACK_STEP_OK");
|
||||
'
|
||||
\`\`\`
|
||||
|
||||
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):
|
||||
Fallback engine (\`$B js\` calls the function in the page, spliced the same way; \`--out\` accepts only temp dirs or cwd; never \`$B html\`, which wraps output in content markers):
|
||||
|
||||
\`\`\`bash
|
||||
_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"
|
||||
$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). Each bash block is a fresh shell: restate the report directory and run id from Setup literally.
|
||||
Persist it into this run's directory, size-capped and redaction-checked: a HIGH finding, or a redaction tool that fails to run, 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"; _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"
|
||||
elif ${toShellPath(ctx.paths.binDir)}/gstack-redact --from-file "$_D" --max-bytes ${DETECT_LIMITS.domDumpBytes} >/dev/null 2>&1; _RC=$?; [ "$_RC" -ne 0 ] && [ "$_RC" -ne 2 ]; then echo "${SENTINEL.DOM_DUMP_REDACTION_BLOCKED}: {page} redact-exit=$_RC"; rm -f "$_D"
|
||||
else mkdir -p "$_REPORT/dom/$_RUN" && cp "$_D" "$_REPORT/dom/$_RUN/" && rm -f "$_D" && echo "${SENTINEL.DOM_DUMP_OK}: {page}"; fi
|
||||
\`\`\`
|
||||
|
||||
@@ -716,7 +715,7 @@ LITMUS CHECKS — answer YES or NO for each:
|
||||
${litmusList}
|
||||
|
||||
HARD RULES — first classify as MARKETING/LANDING PAGE vs APP UI vs HYBRID, then flag violations of the matching rule set:
|
||||
- MARKETING: First viewport as one composition, brand-first hierarchy, full-bleed hero, 2-3 intentional motions, composition-first layout
|
||||
- MARKETING: First viewport as one composition, brand-first hierarchy, full-bleed hero, one authored motion moment on the first viewport, composition-first layout
|
||||
- APP UI: Calm surface hierarchy, dense but readable, utility language, minimal chrome
|
||||
- UNIVERSAL: CSS variables for colors, no default font stacks, one job per section, cards earn existence
|
||||
|
||||
@@ -738,7 +737,7 @@ For each finding: what's wrong, severity (critical/high/medium), and the fix.`;
|
||||
- Color: CSS variables with defined system, or hardcoded hex scattered?
|
||||
- Responsive: breakpoints defined? calc(100svh - header) for heroes? Mobile tested?
|
||||
- A11y: ARIA landmarks, alt text, contrast ratios, 44px touch targets?
|
||||
- Motion: 2-3 intentional animations, or zero / ornamental only?
|
||||
- Motion: one authored moment (an entrance or scroll-linked reveal, ease-out from a visible default) plus state transitions only where they carry information, or zero / ornamental only?
|
||||
- Cards: used only when card IS the interaction? No decorative card grids?
|
||||
|
||||
First classify as MARKETING/LANDING PAGE vs APP UI vs HYBRID, then apply matching rules.
|
||||
@@ -883,7 +882,7 @@ export function generateDesignDetector(ctx: TemplateContext, args?: string[]): s
|
||||
const bin = `bun --no-env-file run ${toShellPath(ctx.paths.binDir)}/gstack-design-detect.ts`;
|
||||
const mode = args?.[0] ?? 'probe';
|
||||
if (mode === 'phase0') {
|
||||
return `**Phase 0: mechanical scan** (only after \`${SENTINEL.READY}\`). Pick the mode once: a URL target (any URL, localhost included) is DOM mode; diff-aware with no URL is source mode. Source mode scans the changed frontend files now:
|
||||
return `**Phase 0: mechanical scan** (only after \`${SENTINEL.READY}\`). Pick the mode once: a URL target (any URL, localhost included) is DOM mode; diff-aware with no URL is source mode. Source mode scans the changed frontend files now, against the base branch (\`gh pr view --json baseRefName -q .baseRefName\`, else \`gh repo view --json defaultBranchRef -q .defaultBranchRef.name\`; never assume \`main\`; an unknown base is refused, exit 1):
|
||||
|
||||
\`\`\`bash
|
||||
_DJ=$(mktemp); ${bin} scan --changed <base> --format gstack --host ${ctx.host} > "$_DJ"${DETECT_EXIT_ECHO}; echo "${SENTINEL.DETECT_JSON}=$_DJ"
|
||||
@@ -900,7 +899,7 @@ If the Setup probe printed \`${SENTINEL.READY}\`, scan the finalized page once b
|
||||
_DJ=$(mktemp); ${bin} scan --format gstack --host ${ctx.host} <finalized.html> > "$_DJ"${DETECT_EXIT_ECHO}; echo "${SENTINEL.DETECT_JSON}=$_DJ"
|
||||
\`\`\`
|
||||
|
||||
Exit 2 → one surgical fix pass over the non-advisory rules in the \`${SENTINEL.DETECT_TOP}\` block, then scan once more. Whatever remains, present the page with those findings listed as accepted-with-reason: a pattern the approved mockup contains, a value DESIGN.md's tokens bless, or an inline \`<!-- impeccable-disable <rule>: <reason> -->\` the user agreed to. One pass, not a loop. Any other first line from the probe: skip, no ceremony.`;
|
||||
Exit 2 → one surgical fix pass over the non-advisory rules in the \`${SENTINEL.DETECT_TOP}\` block, then scan once more. Whatever remains, present the page with those findings listed as accepted-with-reason: a pattern the approved mockup contains, a value DESIGN.md's tokens bless or a pattern its Decisions Log or Do's and Don'ts records as intentional, or an inline \`<!-- impeccable-disable <rule>: <reason> -->\` the user agreed to. One pass, not a loop. Any other first line from the probe: skip, no ceremony.`;
|
||||
}
|
||||
return `**Design detector (optional, deterministic):** gstack runs impeccable's engine when the user installed it, and never installs, downloads, or runs anything that could download (that includes \`npx impeccable\` and the skill's launcher).
|
||||
|
||||
@@ -941,7 +940,7 @@ ${check}
|
||||
// pbakaus/impeccable reference/new-work.md (Apache-2.0), rewritten. See NOTICE.md.
|
||||
export function generateOverusedFonts(_ctx: TemplateContext): string {
|
||||
const free = FONTS_VERIFIED_FREE;
|
||||
return `**Overused as display** (never the display voice on a Persuade or Experience surface; the detector flags several as \`overused-font\`): ${OVERUSED_FONTS_DISPLAY.join(', ')}.
|
||||
return `**Overused as display** (never the display voice, on any surface; the body/UI exception below is the only one; the detector flags several as \`overused-font\`): ${OVERUSED_FONTS_DISPLAY.join(', ')}.
|
||||
|
||||
**Fine as body/UI on an Operate or Read surface when the proposal says so:** ${FONTS_BODY_UI_OK.join(', ')}. **Mono for data and code:** ${FONTS_MONO_OK.join(', ')}.
|
||||
|
||||
@@ -954,7 +953,7 @@ User asks for a listed face by name: comply, state the tradeoff once.`;
|
||||
|
||||
/** Prose-only slop bullets for the proposal skills: no ids, polish-level tells omitted. */
|
||||
export function generateDesignSlopBullets(_ctx: TemplateContext): string {
|
||||
return renderCatalog({ kind: 'slop', style: 'bullets', omitImpact: ['polish'] });
|
||||
return renderCatalog({ kind: 'slop', omitImpact: ['polish'] });
|
||||
}
|
||||
|
||||
// ─── Design Hard Rules (OpenAI framework + gstack slop catalog) ───
|
||||
@@ -1015,7 +1014,7 @@ ${litmusItems}
|
||||
- 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 text faces max (plus a mono for data and code), 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 one composition, not a document (poster in stance, not in type size: display stays under 6rem)
|
||||
|
||||
**App UI rules** (apply when classifier = OPERATE / APP UI):
|
||||
- Calm surface hierarchy, strong typography, few colors
|
||||
|
||||
@@ -176,7 +176,7 @@ const MOCKUP_NEVER_IDS = ['kicker-above-heading', 'icon-tile-stack', 'gradient-t
|
||||
|
||||
function designHtmlNeverIds(): string[] {
|
||||
const tmpl = fs.readFileSync(path.join(ROOT, 'design-html', 'SKILL.md.tmpl'), 'utf-8');
|
||||
const start = tmpl.indexOf('**Never include (AI slop blacklist):**');
|
||||
const start = tmpl.indexOf('**Never include by default (AI slop blacklist):**');
|
||||
expect(start).toBeGreaterThan(0);
|
||||
const block = tmpl.slice(start, tmpl.indexOf('\n\n', start + 10));
|
||||
const lines = block.split('\n').filter(l => l.startsWith('- '));
|
||||
|
||||
@@ -12,7 +12,7 @@ import * as path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
import {
|
||||
generateDesignChecklistMd, checklistSlopEntries,
|
||||
DESIGN_CHECKLIST_HEADER, DESIGN_CHECKLIST_TITLE, DESIGN_CHECKLIST_SLOP_HEADING,
|
||||
DESIGN_CHECKLIST_HEADER, DESIGN_CHECKLIST_TITLE, DESIGN_CHECKLIST_SLOP_HEADING, autoFixEntries,
|
||||
} from '../scripts/resolvers/design-checklist';
|
||||
import { DESIGN_SLOP_CATALOG, BANNED_FONTS } from '../lib/design-catalog';
|
||||
import { AI_SLOP_BLACKLIST } from '../scripts/resolvers/constants';
|
||||
@@ -41,6 +41,17 @@ describe('review/design-checklist.md is generated', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('the Classification AUTO-FIX list renders every auto-fix catalog entry with its id', () => {
|
||||
const md = generateDesignChecklistMd();
|
||||
const block = md.slice(md.indexOf('**AUTO-FIX**'), md.indexOf('**ASK**'));
|
||||
const entries = autoFixEntries();
|
||||
expect(entries.length).toBeGreaterThan(0);
|
||||
for (const e of entries) {
|
||||
expect(e.tier).toBe('auto-fix');
|
||||
expect(block).toContain(`- [${e.impeccableId}] ${e.prose}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('category 1 renders every grep-detectable slop entry and every legacy line', () => {
|
||||
const md = generateDesignChecklistMd();
|
||||
const entries = checklistSlopEntries();
|
||||
@@ -99,6 +110,28 @@ describe('gen-skill-docs writes the checklist for the Claude host only', () => {
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
test('--host claude --out-dir also renders lib/dom-dump.js; a modified out-dir copy flips --dry-run to STALE', () => {
|
||||
const out = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-assets-'));
|
||||
try {
|
||||
const claude = runGen(['--host', 'claude', '--out-dir', out]);
|
||||
expect(claude.status).toBe(0);
|
||||
expect(claude.stdout).toContain('GENERATED: lib/dom-dump.js');
|
||||
const dump = fs.readFileSync(path.join(out, 'lib', 'dom-dump.js'), 'utf-8');
|
||||
expect(dump).toBe(fs.readFileSync(path.join(ROOT, 'lib', 'dom-dump.js'), 'utf-8'));
|
||||
const fresh = runGen(['--host', 'claude', '--out-dir', out, '--dry-run']);
|
||||
expect(fresh.stdout).toContain('FRESH: lib/dom-dump.js');
|
||||
expect(fresh.stdout).toContain('FRESH: review/design-checklist.md');
|
||||
fs.appendFileSync(path.join(out, 'review', 'design-checklist.md'), '\nhand edit\n');
|
||||
fs.writeFileSync(path.join(out, 'lib', 'dom-dump.js'), '// tampered\n');
|
||||
const stale = runGen(['--host', 'claude', '--out-dir', out, '--dry-run']);
|
||||
expect(stale.stdout).toContain('STALE: review/design-checklist.md');
|
||||
expect(stale.stdout).toContain('STALE: lib/dom-dump.js');
|
||||
expect(stale.status).not.toBe(0);
|
||||
} finally {
|
||||
fs.rmSync(out, { recursive: true, force: true });
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
test('--dry-run reports the checklist FRESH', () => {
|
||||
const r = runGen(['--dry-run']);
|
||||
expect(r.stdout).toContain('FRESH: review/design-checklist.md');
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
* 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.
|
||||
* the trailing note and removed from the markup, inlined <link> nodes removed,
|
||||
* <template> and <noscript> subtrees dropped, inline on* handlers dropped.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
@@ -48,7 +49,8 @@ describe.skipIf(!BROWSE || !POSIX || !OPTED_IN)('lib/dom-dump.js in a real DOM (
|
||||
<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>
|
||||
<form action="/submit?csrf=SECRETCSRF"><button formaction="/alt?f=SECRETFORM" onclick="track('SECRETHANDLER')">go</button></form>
|
||||
<template><input value="SECRET TEMPLATE"><a href="/t?x=SECRETTPL">t</a></template><noscript><img src="/px.gif?id=SECRETNOSCRIPT"></noscript>
|
||||
<style>.inline { background: url("${big}") }</style>
|
||||
<div data-long="${'L'.repeat(40)}" data-short="ok" title="${big}">x</div>
|
||||
<img src="${big}">
|
||||
@@ -79,7 +81,7 @@ describe.skipIf(!BROWSE || !POSIX || !OPTED_IN)('lib/dom-dump.js in a real DOM (
|
||||
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).not.toMatch(/<link[^>]*cross-origin\.css/); // named in the note, removed from the markup: the engine never sees a remote stylesheet
|
||||
expect(html).toContain(`<!-- ${DOM_DUMP_NOTE_PREFIX} `);
|
||||
expect(html).toContain('cross-origin stylesheets not resolved');
|
||||
expect(html).toContain('scripts stripped: 1');
|
||||
@@ -94,6 +96,12 @@ describe.skipIf(!BROWSE || !POSIX || !OPTED_IN)('lib/dom-dump.js in a real DOM (
|
||||
expect(html).not.toContain('SECRETSET');
|
||||
expect(html).not.toContain('SECRETCSRF');
|
||||
expect(html).not.toContain('SECRETFORM');
|
||||
expect(html).not.toContain('SECRETHANDLER');
|
||||
expect(html).not.toMatch(/ onclick=/);
|
||||
expect(html).not.toContain('SECRET TEMPLATE');
|
||||
expect(html).not.toContain('SECRETTPL');
|
||||
expect(html).not.toContain('SECRETNOSCRIPT');
|
||||
expect(html).not.toMatch(/<template|<noscript/);
|
||||
expect(html).toContain('srcset="/a.png 1x, /b.png 2x"');
|
||||
expect(html).not.toContain('L'.repeat(40));
|
||||
expect(html).toContain('data-short="ok"');
|
||||
@@ -105,5 +113,5 @@ describe.skipIf(!BROWSE || !POSIX || !OPTED_IN)('lib/dom-dump.js in a real DOM (
|
||||
fs.rmSync(site, { recursive: true, force: true });
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}, 120_000);
|
||||
}, 180_000);
|
||||
});
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@
|
||||
"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 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)",
|
||||
"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,
|
||||
|
||||
@@ -32,6 +32,8 @@ const SAMPLES: Array<[string, boolean]> = [
|
||||
['public/index.html', true],
|
||||
['tailwind.config.js', true],
|
||||
['postcss.config.cjs', true],
|
||||
['src/tailwind.config.js', false], // the bash glob is matched against the whole repo-relative path: root-level configs only
|
||||
['packages/ui/postcss.config.cjs', false],
|
||||
['app/assets/stylesheets/app.css', true],
|
||||
['lib/util/components/helper.rb', true],
|
||||
['lib/server.ts', false],
|
||||
|
||||
@@ -1835,7 +1835,7 @@ describe('DESIGN_HARD_RULES resolver', () => {
|
||||
|
||||
test('design-html blacklist lines carry catalog ids', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'design-html', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('**Never include (AI slop blacklist):**');
|
||||
expect(content).toContain('**Never include by default (AI slop blacklist):**');
|
||||
expect(content).toContain('Purple/blue gradients as default <!-- ai-color-palette -->');
|
||||
expect(content).toContain('lib/design-catalog.ts');
|
||||
});
|
||||
@@ -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 js "($_DUMP)()" --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');
|
||||
@@ -1905,10 +1905,31 @@ describe('DESIGN_DETECTOR resolver', () => {
|
||||
expect(c).not.toMatch(/```js\n/);
|
||||
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(`const html = await pg.evaluate('"$_DUMP"');`);
|
||||
expect(c).toContain('_TMP=$(mktemp -d); _DUMP=$(cat "$HOME/.claude/skills/gstack/lib/dom-dump.js")');
|
||||
});
|
||||
|
||||
test('every rendered Aside script is single-quoted: a page-controlled <url> is never inside a double-quoted bash string', () => {
|
||||
const files = [...fs.readdirSync(ROOT).filter(d => fs.existsSync(path.join(ROOT, d, 'SKILL.md'))).map(d => path.join(ROOT, d, 'SKILL.md')),
|
||||
...fs.readdirSync(ROOT).flatMap(d => fs.existsSync(path.join(ROOT, d, 'sections')) ? fs.readdirSync(path.join(ROOT, d, 'sections')).filter(f => f.endsWith('.md')).map(f => path.join(ROOT, d, 'sections', f)) : [])];
|
||||
expect(files.length).toBeGreaterThan(10);
|
||||
for (const f of files) {
|
||||
const c = fs.readFileSync(f, 'utf-8');
|
||||
expect(c, path.relative(ROOT, f)).not.toMatch(/^aside repl "/m);
|
||||
}
|
||||
});
|
||||
|
||||
test('the E2E fixture slice markers exist in the rendered design skills (a template rename fails here, not in paid CI)', () => {
|
||||
const dr = designReview();
|
||||
const dh = fs.readFileSync(path.join(ROOT, 'design-html', 'SKILL.md'), 'utf-8');
|
||||
for (const [a, b] of [['**Design detector (optional, deterministic):**', '**Create output directories:**'], ['**Phase 0: mechanical scan**', '## Phases 1-6'], ['### DOM dump (DOM mode only', '### Auth Detection']]) {
|
||||
expect(sliceBetween(dr, a, b).length, `${a} .. ${b}`).toBeGreaterThan(100);
|
||||
}
|
||||
for (const [a, b] of [['**Design detector (optional, deterministic):**', '## Step 0: Input Detection'], ['### Slop Gate (bounded, never a loop)', '### Verification Screenshots']]) {
|
||||
expect(sliceBetween(dh, a, b).length, `${a} .. ${b}`).toBeGreaterThan(100);
|
||||
}
|
||||
});
|
||||
|
||||
test('design-html carries the probe and the bounded slop gate', () => {
|
||||
const c = designHtml();
|
||||
expect(c).toContain('gstack-design-detect.ts probe --host claude');
|
||||
@@ -2612,6 +2633,7 @@ describe('Factory generation (--host factory)', () => {
|
||||
// ─── Parameterized host smoke tests (config-driven) ─────────
|
||||
|
||||
import { ALL_HOST_CONFIGS, getExternalHosts } from '../hosts/index';
|
||||
import { sliceBetween } from './helpers/skill-fixture';
|
||||
|
||||
describe('Parameterized host smoke tests', () => {
|
||||
// Every external host was rendered up front by the module-level
|
||||
|
||||
@@ -158,6 +158,14 @@ describe('design_detector (auto|off, rejecting validator)', () => {
|
||||
expect(get('design_detector').out).toBe('auto');
|
||||
});
|
||||
|
||||
test('list and defaults enumerate design_detector', () => {
|
||||
for (const verb of ['list', 'defaults']) {
|
||||
const r = spawnSync('bash', [CONFIG_BIN, verb], { encoding: 'utf-8', timeout: 30_000, env: { ...process.env, GSTACK_STATE_ROOT: STATE } });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toMatch(/design_detector:\s+auto/);
|
||||
}
|
||||
});
|
||||
|
||||
test('set off / set auto round-trip', () => {
|
||||
spawnSync('bash', [CONFIG_BIN, 'set', 'design_detector', 'off'], { encoding: 'utf-8', timeout: 30_000, env: { ...process.env, GSTACK_STATE_ROOT: STATE } });
|
||||
expect(get('design_detector').out).toBe('off');
|
||||
|
||||
@@ -678,7 +678,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
gateAfterStop: undefined, // operational skill, no plan-mode gate
|
||||
},
|
||||
behavioral: 'prompt',
|
||||
maxSkeletonBytes: 54_000, // + v1.82 design detector: {{DESIGN_DETECTOR}} probe + Step 4 slop gate + catalog id tags; measured 53_592
|
||||
maxSkeletonBytes: 54_300, // measured 54,184 (2026-09-08): the Slop Gate's Decisions-Log blessing clause and the blacklist header's override sentence, both review-cycle coherence fixes
|
||||
minUnionBytes: 57_500, // Phase 4 wave 4; measured union 58,682
|
||||
mustContain: ["Don't make me think", "Users scan, they don't read", 'The Goodwill Reservoir', 'PRETEXT API CHEATSHEET', 'Pattern 3: Text around obstacles'],
|
||||
},
|
||||
|
||||
@@ -136,7 +136,7 @@ describe('DOM_DUMP_SCRIPT contract', () => {
|
||||
expect(DOM_DUMP_SCRIPT).not.toContain('${');
|
||||
expect(DOM_DUMP_SCRIPT).not.toContain('`');
|
||||
// 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)()"`.
|
||||
// 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();
|
||||
@@ -151,7 +151,7 @@ describe('DOM_DUMP_SCRIPT contract', () => {
|
||||
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"', 'cutQuery(value)', 'value.length > 1024',
|
||||
'gstack-stripped', 'cloneLinks[i].remove()', 'querySelectorAll("style")']) {
|
||||
'gstack-stripped', 'cloneLinks[i].remove()', 'querySelectorAll("style")', 'querySelectorAll("template, noscript")', 'name.indexOf("on") === 0']) {
|
||||
expect(DOM_DUMP_SCRIPT).toContain(rule);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -871,7 +871,7 @@ Do not run npx. Do not fix anything.`,
|
||||
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); // $B js "($_DUMP)()" with the file spliced in
|
||||
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'));
|
||||
|
||||
@@ -274,10 +274,11 @@ Important: The design checklist should catch issues like blacklisted fonts, smal
|
||||
// Issue 7: 3-column feature grid — LOW
|
||||
if (review.includes('3-column') || review.includes('three-column') || review.includes('feature grid') || review.includes('icon') || review.includes('circle')) detected++;
|
||||
// Signal 8: the mechanical pass (fake impeccable engine via IMPECCABLE_BIN) surfaced a detector row
|
||||
if (review.includes('detector') || review.includes('[ai-color-palette]') || review.includes('[low-contrast]') || review.includes('impeccable')) detected++;
|
||||
const detectorSeen = review.includes('detector') || review.includes('[ai-color-palette]') || review.includes('[low-contrast]') || review.includes('impeccable');
|
||||
|
||||
console.log(`Design review detected ${detected}/8 planted signals`);
|
||||
expect(detected).toBeGreaterThanOrEqual(4);
|
||||
console.log(`Design review detected ${detected}/7 planted checklist signals; detector rows surfaced: ${detectorSeen}`);
|
||||
expect(detected).toBeGreaterThanOrEqual(4); // the LLM-checklist bar, unchanged by the detector
|
||||
expect(detectorSeen).toBe(true); // the fake engine's rows are deterministic; the review must carry them
|
||||
}
|
||||
}, CAPTURE_MS);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user