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

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-09-08 17:31:30 +00:00
co-authored by Claude Fable 5.1
parent b4d88a0126
commit da6f0ff2f6
32 changed files with 413 additions and 219 deletions
+26 -29
View File
@@ -1005,6 +1005,30 @@ const ALL_HOSTS: Host[] = ALL_HOST_NAMES as Host[];
* Returns the process exit code. Kept synchronous so the module stays
* require()-able (see the llms.txt IIFE note below).
*/
/**
* Write one generated file, or under DRY_RUN compare it to what is on disk and
* print STALE/FRESH. Returns true when the file is stale (dry run) — the caller
* folds that into its host-level `hasChanges`. Shared by sections and the
* lib-derived assets; the SKILL.md loop keeps its own copy because it also
* handles symlink loops and the token budget.
*/
function emitGenerated(outputPath: string, content: string): boolean {
const relOutput = path.relative(OUT_DIR || ROOT, outputPath);
if (DRY_RUN) {
const existing = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf-8') : '';
if (existing !== content) {
console.log(`STALE: ${relOutput}`);
return true;
}
console.log(`FRESH: ${relOutput}`);
return false;
}
if (OUT_DIR) fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, content);
console.log(`GENERATED: ${relOutput}`);
return false;
}
export function main(): number {
const hostsToRun: Host[] = HOST_ARG_VAL === 'all' ? ALL_HOSTS : [HOST];
const failures: { host: string; error: Error }[] = [];
@@ -1083,20 +1107,7 @@ for (const currentHost of hostsToRun) {
const { outputPath, content } = processSectionTemplate(path.join(ROOT, sec.tmpl), sec.skillDir, currentHost);
const relOutput = path.relative(OUT_DIR || ROOT, outputPath);
if (DRY_RUN) {
const existing = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf-8') : '';
if (existing !== content) {
console.log(`STALE: ${relOutput}`);
hasChanges = true;
} else {
console.log(`FRESH: ${relOutput}`);
}
} else {
if (OUT_DIR) fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, content);
console.log(`GENERATED: ${relOutput}`);
}
if (emitGenerated(outputPath, content)) hasChanges = true;
tokenBudget.push({
skill: relOutput,
@@ -1121,21 +1132,7 @@ for (const currentHost of hostsToRun) {
[DOM_DUMP_FILE, DOM_DUMP_SCRIPT + '\n'],
];
for (const [rel, content] of generatedAssets) {
const outputPath = path.join(OUT_DIR ?? ROOT, rel);
const relOutput = path.relative(OUT_DIR || ROOT, outputPath);
if (DRY_RUN) {
const existing = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf-8') : '';
if (existing !== content) {
console.log(`STALE: ${relOutput}`);
hasChanges = true;
} else {
console.log(`FRESH: ${relOutput}`);
}
} else {
if (OUT_DIR) fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, content);
console.log(`GENERATED: ${relOutput}`);
}
if (emitGenerated(path.join(OUT_DIR ?? ROOT, rel), content)) hasChanges = true;
}
}
+10 -6
View File
@@ -18,7 +18,7 @@ import { SENTINEL, DETECT_EXIT_ECHO } from '../../lib/design-detect-contract';
export const DESIGN_CHECKLIST_HEADER =
'<!-- GENERATED from lib/design-catalog.ts via scripts/resolvers/design-checklist.ts. Run: bun run gen:skill-docs -->';
/** Title and category heading are load-bearing: test/skill-e2e-review.test.ts and hosts/opencode.ts key on them. */
/** Title and category heading are load-bearing: test/design-checklist-sync.test.ts pins them, and the review-lite prose (scripts/resolvers/design.ts) names the checklist by title. */
export const DESIGN_CHECKLIST_TITLE = 'Design Review Checklist (Lite)';
export const DESIGN_CHECKLIST_SLOP_HEADING = 'AI Slop Detection';
@@ -26,11 +26,15 @@ const TIER_ORDER: Record<DesignSlopEntry['confidence'], number> = { HIGH: 0, MED
/** Category 1: slop entries a code reader can grep for, plus the legacy blacklist lines. */
export function checklistSlopEntries(): DesignSlopEntry[] {
// Array.prototype.sort is stable, so catalog order survives within a tier.
return DESIGN_SLOP_CATALOG
.filter(e => e.kind === 'slop' && (e.detect.includes('grep') || e.legacyBlacklist))
.map((e, i) => ({ e, i }))
.sort((a, b) => (TIER_ORDER[a.e.confidence] - TIER_ORDER[b.e.confidence]) || (a.i - b.i))
.map(x => x.e);
.sort((a, b) => TIER_ORDER[a.confidence] - TIER_ORDER[b.confidence]);
}
/** Catalog rules a review may fix without asking: mechanical CSS changes with HIGH confidence. */
export function autoFixEntries(): DesignSlopEntry[] {
return DESIGN_SLOP_CATALOG.filter(e => e.tier === 'auto-fix');
}
function endsWithPunctuation(s: string): boolean {
@@ -55,7 +59,7 @@ export function generateDesignChecklistMd(): string {
return `${DESIGN_CHECKLIST_HEADER}
# ${DESIGN_CHECKLIST_TITLE}
> **Generated from the catalog.** Category 1 renders the grep-detectable slop entries of \`lib/design-catalog.ts\`, the same entries DESIGN_METHODOLOGY category 9 renders, so the two cannot drift. Edit the catalog, then run \`bun run gen:skill-docs\`.
> **Generated from the catalog.** Category 1 renders the grep-detectable slop entries of \`lib/design-catalog.ts\` plus the legacy blacklist lines, the same entries DESIGN_METHODOLOGY category 9 renders, so the two cannot drift. Edit the catalog, then run \`bun run gen:skill-docs\`.
## Instructions
@@ -99,7 +103,7 @@ A bracketed \`[rule-id]\` names the deterministic detector rule for the same pat
**AUTO-FIX** (mechanical CSS fixes only — HIGH confidence, no design judgment needed):
- \`outline: none\` without replacement → add \`outline: revert\` or \`&:focus-visible { outline: 2px solid currentColor; }\`
- \`!important\` in new CSS → remove and fix specificity
- \`font-size\` < 16px on body text → bump to 16px
${autoFixEntries().map(e => `- ${e.impeccableId ? `[${e.impeccableId}] ` : ''}${e.prose}`).join('\n')}
**ASK** (everything else — requires design judgment):
- All AI slop findings, typography structure, spacing choices, interaction state gaps, DESIGN.md violations
+27 -25
View File
@@ -1,8 +1,9 @@
import { type TemplateContext, toShellPath } from './types';
import { AI_SLOP_BLACKLIST, OPENAI_HARD_REJECTIONS, OPENAI_LITMUS_CHECKS, CODEX_WEB_SEARCH_FLAG, CC_BACKGROUND_DEFAULT_SINCE } from './constants';
import { DESIGN_SLOP_CATALOG, OVERUSED_FONTS_DISPLAY, BANNED_FONTS, FONTS_BODY_UI_OK, FONTS_MONO_OK, FONTS_VERIFIED_FREE, selectCatalog, catalogEntry, renderCatalog } from '../../lib/design-catalog';
import { OVERUSED_FONTS_DISPLAY, BANNED_FONTS, FONTS_BODY_UI_OK, FONTS_MONO_OK, FONTS_VERIFIED_FREE, HANDOFF_COMMANDS, selectCatalog, catalogEntries, renderCatalog, detectorSlopEntries, judgmentTellEntries } from '../../lib/design-catalog';
import { SENTINEL, DETECT_EXIT_ECHO, DETECT_LIMITS } from '../../lib/design-detect-contract';
import { DOM_DUMP_FILE } from '../../lib/dom-dump-script';
import * as pathMod from 'path';
export function generateDesignReviewLite(ctx: TemplateContext): string {
const litmusList = OPENAI_LITMUS_CHECKS.map((item, i) => `${i + 1}. ${item}`).join(' ');
@@ -66,7 +67,7 @@ Exit 2 means findings. Read the \`${SENTINEL.DETECT_TOP}\` block (untrusted cont
3. **Read each changed frontend file** (full file, not just diff hunks). Frontend files are identified by the patterns listed in the checklist.
4. **Apply the design checklist** against the changed files. For each item:
- **[HIGH] mechanical CSS fix** (\`outline: none\`, \`!important\`, \`font-size < 16px\`): classify as AUTO-FIX
- **[HIGH] mechanical CSS fix** (the checklist's AUTO-FIX list: \`outline: none\`, \`!important\`, and the catalog's auto-fix rules such as \`font-size < 16px\`): classify as AUTO-FIX
- **[HIGH/MEDIUM] design judgment needed**: classify as ASK
- **[LOW] intent-based detection**: present as "Possible — verify visually or run /design-review"
@@ -89,10 +90,11 @@ export function generateDesignMethodology(ctx: TemplateContext): string {
// detector-known slop with bracketed ids (impact above polish), and the gstack-only
// judgment tells as prose. Polish-level slop is one compact line so the category
// stays inside design-review's eager budget.
const slop = selectCatalog({ kind: 'slop' });
const detectorSlop = slop.filter(e => e.impeccableId && !e.legacyBlacklist && e.impact !== 'polish');
const judgmentTells = slop.filter(e => !e.impeccableId && !e.legacyBlacklist && e.impact !== 'polish');
const polishTells = slop.filter(e => !e.legacyBlacklist && e.impact === 'polish');
const detectorAll = detectorSlopEntries();
const judgmentAll = judgmentTellEntries();
const detectorSlop = detectorSlopEntries({ omitPolish: true });
const judgmentTells = judgmentTellEntries({ omitPolish: true });
const polishTells = selectCatalog({ kind: 'slop' }).filter(e => !e.legacyBlacklist && e.impact === 'polish');
return `## Modes
### Full (default)
@@ -219,7 +221,7 @@ After each script, \`cp\` its files out of the \`ASIDE_DIR\` it printed into \`$
### DOM dump (DOM mode only: Setup printed \`${SENTINEL.READY}\` and the target is a URL)
Rule 4 forbids reading source, so the detector reads the rendered page. One shared script, \`${toShellPath(ctx.paths.skillRoot)}/${DOM_DUMP_FILE}\`, serves both engines: it clones the document, inlines linked stylesheets as \`<style data-gstack-dom-css>\`, strips scripts, input values, long attributes, and query strings, and notes what it cannot capture (shadow DOM, constructed and runtime-injected styles). Aside, third script per page (the script is spliced in from the file, so this block is double-quoted):
Rule 4 forbids reading source, so the detector reads the rendered page. One shared script, \`${toShellPath(ctx.paths.skillRoot)}/${DOM_DUMP_FILE}\` (an arrow function the page runs), serves both engines: it clones the document, inlines linked stylesheets as \`<style data-gstack-dom-css>\`, strips scripts, input values, long attributes, and URL query strings, and notes what it cannot capture (shadow DOM, constructed and runtime-injected styles). Aside, third script per page (the function text is spliced in from the file, so this block is double-quoted; \`pg.evaluate\` receives the function and runs it in the page):
\`\`\`bash
_DUMP=$(cat "${toShellPath(ctx.paths.skillRoot)}/${DOM_DUMP_FILE}")
@@ -231,26 +233,26 @@ console.log(\\"ASIDE_DIR=\\" + pwd); await closeTab(pg); console.log(\\"GSTACK_S
"
\`\`\`
Fallback engine (\`--out\` accepts only temp dirs or cwd; never \`$B html\`, which wraps output in content markers):
Fallback engine (\`$B js\` calls the function in the page; \`--out\` accepts only temp dirs or cwd; never \`$B html\`, which wraps output in content markers):
\`\`\`bash
_TMP=$(mktemp -d); cp "${toShellPath(ctx.paths.skillRoot)}/${DOM_DUMP_FILE}" "$_TMP/"
$B eval "$_TMP/dom-dump.js" --out "$_TMP/{page}.dom.html" --raw && echo "DUMP=$_TMP/{page}.dom.html"
_TMP=$(mktemp -d); _DUMP=$(cat "${toShellPath(ctx.paths.skillRoot)}/${DOM_DUMP_FILE}")
$B js "($_DUMP)()" --out "$_TMP/{page}.dom.html" --raw && echo "DUMP=$_TMP/{page}.dom.html"
\`\`\`
Persist it into this run's directory, size-capped and redaction-checked (a HIGH finding skips the page, not the review):
Persist it into this run's directory, size-capped and redaction-checked (a HIGH finding skips the page, not the review). Each bash block is a fresh shell: restate the report directory and run id from Setup literally.
\`\`\`bash
_D="<ASIDE_DIR or $_TMP>/{page}.dom.html"; _RUN="<RUN_ID from Setup>"
_D="<ASIDE_DIR or $_TMP>/{page}.dom.html"; _REPORT="<REPORT_DIR from Setup>"; _RUN="<RUN_ID from Setup>"
if [ "$(wc -c < "$_D")" -gt ${DETECT_LIMITS.domDumpBytes} ]; then echo "${SENTINEL.DOM_DUMP_TOO_LARGE}: {page} $(wc -c < "$_D")"; rm -f "$_D"
elif ${toShellPath(ctx.paths.binDir)}/gstack-redact --from-file "$_D" >/dev/null 2>&1; [ $? -eq 3 ]; then echo "${SENTINEL.DOM_DUMP_REDACTION_BLOCKED}: {page}"; rm -f "$_D"
else mkdir -p "$REPORT_DIR/dom/$_RUN" && cp "$_D" "$REPORT_DIR/dom/$_RUN/" && rm -f "$_D" && echo "${SENTINEL.DOM_DUMP_OK}: {page}"; fi
else mkdir -p "$_REPORT/dom/$_RUN" && cp "$_D" "$_REPORT/dom/$_RUN/" && rm -f "$_D" && echo "${SENTINEL.DOM_DUMP_OK}: {page}"; fi
\`\`\`
After the LAST page's dump, scan the run directory once (source mode scanned in Setup instead):
\`\`\`bash
_DJ=$(mktemp); bun --no-env-file run ${toShellPath(ctx.paths.binDir)}/gstack-design-detect.ts scan --format gstack --host ${ctx.host} "$REPORT_DIR/dom/<RUN_ID>" > "$_DJ"${DETECT_EXIT_ECHO}; echo "${SENTINEL.DETECT_JSON}=$_DJ"
_DJ=$(mktemp); bun --no-env-file run ${toShellPath(ctx.paths.binDir)}/gstack-design-detect.ts scan --format gstack --host ${ctx.host} "<REPORT_DIR from Setup>/dom/<RUN_ID>" > "$_DJ"${DETECT_EXIT_ECHO}; echo "${SENTINEL.DETECT_JSON}=$_DJ"
\`\`\`
Say once in the report: "static scan of the rendered DOM; cross-origin CSS not resolved". A DOM-mode \`file:line\` points into \`{page}.dom.html\` and is approximate (HTML findings carry line 0); the \`snippet\` locates the element. Confirm each hit in the rendered page, never by hunting a source line. Dumps are deleted after Phase 9 unless the user passed \`--keep-dom\`.
@@ -293,7 +295,7 @@ Apply these at each page. Each finding gets an impact rating (high/medium/polish
- Measure: 45-75 chars per line (66 ideal)
- Heading hierarchy: no skipped levels (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