From d3c3ea27b9e74a6a8ffb22df022ccf0478902536 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 8 Sep 2026 16:15:57 +0000 Subject: [PATCH] feat(design): catalog never-lines in the mockup prompt Ten catalog ids carry `mockupNever` (kicker-above-heading, icon-tile-stack, gradient-text, ai-color-palette, cream-palette, nested-cards, dark-glow, pulsing-dot, identical-cards, hero-metrics) and lib/design-catalog.ts exports their deduped plain-English names as MOCKUP_NEVER_NAMES. briefToPrompt() in the design binary appends "Never: ." before its fixed tail, so `$D generate | variants | evolve` stop reaching for purple gradients, icon tiles, and cream defaults before the comparison board opens. The binary still bundles (`bun build --compile design/src/cli.ts`); ./setup rebuilds it. design-html's Never-include list now covers every mockupNever id (kicker / icon tile, hero metric rows, gradient text, cream palette, nested and identical cards, glow and pulsing dots), each line tagged with its catalog ids; test/design-catalog.test.ts pins the exact ten flags, the deduped names, and that the template list is a superset. New design/test/brief.test.ts pins the prompt shape. Co-Authored-By: Claude Fable 5.1 --- design-html/SKILL.md | 8 +++++- design-html/SKILL.md.tmpl | 8 +++++- design/src/brief.ts | 5 ++++ design/test/brief.test.ts | 44 ++++++++++++++++++++++++++++++++ lib/design-catalog.ts | 22 ++++++++++++++++ test/design-catalog.test.ts | 51 +++++++++++++++++++++++++++++-------- 6 files changed, 125 insertions(+), 13 deletions(-) create mode 100644 design/test/brief.test.ts diff --git a/design-html/SKILL.md b/design-html/SKILL.md index 64b51226f..f26c676c9 100644 --- a/design-html/SKILL.md +++ b/design-html/SKILL.md @@ -699,9 +699,15 @@ For framework output, save to: **Never include (AI slop blacklist):** - Purple/blue gradients as default +- Cream-and-serif default palette +- Gradient text - Generic 3-column feature grids +- Identical card grids, nested cards - Center-everything layouts with no visual hierarchy +- Kickers or icon tiles above headings +- Hero metric rows ("10k+ users") - Decorative blobs, waves, or geometric patterns not in the mockup +- Glowing edges or pulsing status dots - Stock photo placeholder divs - "Get Started" / "Learn More" generic CTAs not from the mockup - Rounded-corner cards with drop shadows as the default component @@ -709,7 +715,7 @@ For framework output, save to: - Generic testimonial sections - Cookie-cutter hero sections with left-text right-image -The comment on each line is the pattern's id in gstack's design catalog (`lib/design-catalog.ts`); the catalog is the authoritative name for these, and the same ids come back from the design detector. +Each `` is the pattern's id in `lib/design-catalog.ts`; the design detector reports the same ids. --- diff --git a/design-html/SKILL.md.tmpl b/design-html/SKILL.md.tmpl index 9667ea56f..b5c731eff 100644 --- a/design-html/SKILL.md.tmpl +++ b/design-html/SKILL.md.tmpl @@ -279,9 +279,15 @@ For framework output, save to: **Never include (AI slop blacklist):** - Purple/blue gradients as default +- Cream-and-serif default palette +- Gradient text - Generic 3-column feature grids +- Identical card grids, nested cards - Center-everything layouts with no visual hierarchy +- Kickers or icon tiles above headings +- Hero metric rows ("10k+ users") - Decorative blobs, waves, or geometric patterns not in the mockup +- Glowing edges or pulsing status dots - Stock photo placeholder divs - "Get Started" / "Learn More" generic CTAs not from the mockup - Rounded-corner cards with drop shadows as the default component @@ -289,7 +295,7 @@ For framework output, save to: - Generic testimonial sections - Cookie-cutter hero sections with left-text right-image -The comment on each line is the pattern's id in gstack's design catalog (`lib/design-catalog.ts`); the catalog is the authoritative name for these, and the same ids come back from the design detector. +Each `` is the pattern's id in `lib/design-catalog.ts`; the design detector reports the same ids. --- diff --git a/design/src/brief.ts b/design/src/brief.ts index 6ebcae6c8..5748491f0 100644 --- a/design/src/brief.ts +++ b/design/src/brief.ts @@ -1,6 +1,7 @@ /** * Structured design brief — the interface between skill prose and image generation. */ +import { MOCKUP_NEVER_NAMES } from "../../lib/design-catalog"; export interface DesignBrief { goal: string; // "Dashboard for coding assessment tool" @@ -31,6 +32,10 @@ export function briefToPrompt(brief: DesignBrief): string { lines.push(`Design reference: ${brief.reference}`); } + // Generation-time slop guard: the catalog's mockupNever names, so the model + // never reaches for purple gradients, icon tiles, or cream defaults on its own. + lines.push(`Never: ${MOCKUP_NEVER_NAMES.join(", ")}.`); + lines.push( "The mockup should look like a real production UI, not a wireframe or concept art.", "All text must be readable. Layout must be clean and intentional.", diff --git a/design/test/brief.test.ts b/design/test/brief.test.ts new file mode 100644 index 000000000..527443cf0 --- /dev/null +++ b/design/test/brief.test.ts @@ -0,0 +1,44 @@ +/** + * briefToPrompt carries the catalog's generation-time slop guard. + * + * The "Never:" line is built from MOCKUP_NEVER_NAMES (lib/design-catalog.ts), + * so the image model is told up front what not to reach for. The catalog test + * owns the "exactly ten ids" invariant; this one pins the prompt shape. + */ +import { describe, expect, test } from "bun:test"; +import { briefToPrompt, type DesignBrief } from "../src/brief"; +import { MOCKUP_NEVER_NAMES } from "../../lib/design-catalog"; + +const brief: DesignBrief = { + goal: "Dashboard for a coding assessment tool", + audience: "Technical users", + style: "Dark theme, minimal", + elements: ["builder name", "score badge"], + screenType: "desktop-dashboard", +}; + +describe("briefToPrompt", () => { + test("carries a Never: line listing every MOCKUP_NEVER_NAMES entry, before the fixed tail", () => { + const prompt = briefToPrompt(brief); + const never = `Never: ${MOCKUP_NEVER_NAMES.join(", ")}.`; + expect(prompt).toContain(never); + expect(MOCKUP_NEVER_NAMES.length).toBeGreaterThanOrEqual(8); + for (const name of MOCKUP_NEVER_NAMES) expect(prompt).toContain(name); + expect(prompt.indexOf(never)).toBeLessThan(prompt.indexOf("The mockup should look like a real production UI")); + expect(prompt.indexOf(never)).toBeGreaterThan(prompt.indexOf("Required elements:")); + }); + + test("names are plain English: no hyphenated rule ids leak into the prompt", () => { + const prompt = briefToPrompt(brief); + expect(prompt).not.toMatch(/\b[a-z]+(-[a-z]+)+\b(?=[,.])/); + for (const name of MOCKUP_NEVER_NAMES) expect(name).not.toMatch(/^[a-z0-9]+(-[a-z0-9]+)+$/); + }); + + test("optional fields still render around the guard", () => { + const prompt = briefToPrompt({ ...brief, constraints: "Max width 1024px", reference: "DESIGN.md excerpt" }); + expect(prompt).toContain("Constraints: Max width 1024px."); + expect(prompt).toContain("Design reference: DESIGN.md excerpt"); + expect(prompt).toContain("Never: "); + expect(prompt.endsWith("1536x1024 pixels.")).toBe(true); + }); +}); diff --git a/lib/design-catalog.ts b/lib/design-catalog.ts index 78fe3adb2..04bae52d9 100644 --- a/lib/design-catalog.ts +++ b/lib/design-catalog.ts @@ -84,6 +84,7 @@ export const DESIGN_SLOP_CATALOG: DesignSlopEntry[] = [ category: 'color', kind: 'slop', detect: ['engine', 'grep', 'llm'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium', heuristic: 'Look for `linear-gradient` with values in the `#6366f1` to `#8b5cf6` range, or CSS custom properties resolving to purple/violet.', handoff: 'colorize', source: 'both', legacyBlacklist: true, + mockupNever: true, }, { id: 'feature-grid-3col', name: 'The 3-column feature grid', @@ -181,18 +182,21 @@ export const DESIGN_SLOP_CATALOG: DesignSlopEntry[] = [ category: 'color', kind: 'slop', detect: ['engine', 'grep'], confidence: 'HIGH', tier: 'ask', impact: 'medium', heuristic: 'Grep for `background-clip: text` next to a gradient background.', handoff: 'colorize', source: 'impeccable', + mockupNever: true, }, { id: 'cream-palette', impeccableId: 'cream-palette', name: 'Cream default palette', prose: 'Cream ground, serif display, terracotta accent: look number one. Fine when the brief asked for it; a default when it did not.', category: 'color', kind: 'slop', detect: ['engine', 'llm'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium', handoff: 'colorize', source: 'impeccable', + mockupNever: true, }, { id: 'nested-cards', impeccableId: 'nested-cards', name: 'Nested cards', prose: 'A card inside a card is always wrong. Cards are the lazy container; nesting them is the lazy container squared.', category: 'scaffold', kind: 'slop', detect: ['engine', 'llm'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium', handoff: 'layout', source: 'impeccable', + mockupNever: true, }, { id: 'monotonous-spacing', impeccableId: 'monotonous-spacing', name: 'Monotonous spacing', @@ -213,6 +217,7 @@ export const DESIGN_SLOP_CATALOG: DesignSlopEntry[] = [ category: 'motion', kind: 'slop', detect: ['engine', 'grep'], confidence: 'MEDIUM', tier: 'ask', impact: 'polish', heuristic: 'Grep for infinite keyframe animations on small round elements.', handoff: 'animate', source: 'impeccable', + mockupNever: true, }, { id: 'blinking-cursor', impeccableId: 'blinking-cursor', name: 'Blinking cursor effect', @@ -232,6 +237,7 @@ export const DESIGN_SLOP_CATALOG: DesignSlopEntry[] = [ category: 'surface', kind: 'slop', detect: ['engine', 'grep'], confidence: 'HIGH', tier: 'ask', impact: 'medium', heuristic: 'Grep `box-shadow` for a zero x/y offset with a large blur and a saturated color.', handoff: 'colorize', source: 'impeccable', + mockupNever: true, }, { id: 'radial-halo', impeccableId: 'radial-halo', name: 'Radial halo', @@ -256,6 +262,7 @@ export const DESIGN_SLOP_CATALOG: DesignSlopEntry[] = [ prose: 'The rounded-square icon above every heading. Try side by side, or drop the container.', category: 'scaffold', kind: 'slop', detect: ['engine', 'llm'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium', handoff: 'layout', source: 'impeccable', + mockupNever: true, }, { id: 'italic-serif-display', impeccableId: 'italic-serif-display', name: 'Italic serif display', @@ -275,6 +282,7 @@ export const DESIGN_SLOP_CATALOG: DesignSlopEntry[] = [ category: 'scaffold', kind: 'slop', detect: ['engine', 'grep', 'llm'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium', heuristic: 'Look for a short uppercase, tracked element immediately before an h1 or h2.', handoff: 'layout', source: 'impeccable', + mockupNever: true, }, { id: 'numbered-section-labels', impeccableId: 'numbered-section-labels', name: 'Numbered section labels', @@ -392,12 +400,14 @@ export const DESIGN_SLOP_CATALOG: DesignSlopEntry[] = [ prose: 'Three big numbers with tiny labels under the hero ("10k+ users", "99.9%"). The template counts, not the product.', category: 'scaffold', kind: 'slop', detect: ['llm'], confidence: 'LOW', tier: 'ask', impact: 'medium', handoff: 'clarify', source: 'gstack', + mockupNever: true, }, { id: 'identical-cards', name: 'Identical card grids', prose: 'A grid of cards with the same shape, the same icon slot, the same two lines. Content of unequal weight given equal boxes.', category: 'scaffold', kind: 'slop', detect: ['llm'], confidence: 'LOW', tier: 'ask', impact: 'medium', handoff: 'layout', source: 'gstack', + mockupNever: true, }, { id: 'glassmorphism', name: 'Glassmorphism', @@ -636,6 +646,18 @@ export const DESIGN_SLOP_CATALOG: DesignSlopEntry[] = [ }, ]; +// ── Mockup prompt ── + +/** + * Plain-English names of the entries flagged `mockupNever`, deduped: the design + * binary appends "Never: ." to every image-generation prompt + * (design/src/brief.ts) so mockups stop reaching for these before the + * comparison board opens. Exactly ten ids carry the flag (test-enforced). + */ +export const MOCKUP_NEVER_NAMES: readonly string[] = [ + ...new Set(DESIGN_SLOP_CATALOG.filter(e => e.mockupNever).map(e => e.name)), +]; + // ── Fonts ── /** Never the display voice on Persuade/Experience; the detector flags several as `overused-font`. */ diff --git a/test/design-catalog.test.ts b/test/design-catalog.test.ts index 9a94425ed..f7772456f 100644 --- a/test/design-catalog.test.ts +++ b/test/design-catalog.test.ts @@ -12,7 +12,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { spawnSync } from 'child_process'; import { - DESIGN_SLOP_CATALOG, HANDOFF_COMMANDS, OVERUSED_FONTS_DISPLAY, BANNED_FONTS, + DESIGN_SLOP_CATALOG, HANDOFF_COMMANDS, OVERUSED_FONTS_DISPLAY, BANNED_FONTS, MOCKUP_NEVER_NAMES, FONTS_BODY_UI_OK, FONTS_MONO_OK, FONTS_VERIFIED_FREE, catalogEntry, entryForImpeccableId, renderCatalog, selectCatalog, } from '../lib/design-catalog'; @@ -178,18 +178,47 @@ describe('renderCatalog', () => { }); }); +const MOCKUP_NEVER_IDS = ['kicker-above-heading', 'icon-tile-stack', 'gradient-text', 'ai-color-palette', 'cream-palette', 'nested-cards', 'dark-glow', 'pulsing-dot', 'identical-cards', 'hero-metrics']; + +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):**'); + expect(start).toBeGreaterThan(0); + const block = tmpl.slice(start, tmpl.indexOf('\n\n', start + 10)); + const lines = block.split('\n').filter(l => l.startsWith('- ')); + expect(lines.length).toBeGreaterThanOrEqual(10); + const ids: string[] = []; + for (const line of lines) { + const found = [...line.matchAll(//g)].map(m => m[1]); + expect(found.length, line).toBeGreaterThan(0); + ids.push(...found); + } + return ids; +} + describe('design-html blacklist is derived-by-test (decision 31)', () => { test('every on the Never-include list names a catalog entry', () => { - const tmpl = fs.readFileSync(path.join(ROOT, 'design-html', 'SKILL.md.tmpl'), 'utf-8'); - const start = tmpl.indexOf('**Never include (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('- ')); - expect(lines.length).toBeGreaterThanOrEqual(10); - for (const line of lines) { - const m = line.match(/$/); - expect(m, line).not.toBeNull(); - expect(catalogEntry(m![1]), m![1]).toBeDefined(); + for (const id of designHtmlNeverIds()) expect(catalogEntry(id), id).toBeDefined(); + }); + + test('every mockupNever entry appears on the Never-include list', () => { + const ids = new Set(designHtmlNeverIds()); + for (const id of MOCKUP_NEVER_IDS) expect(ids.has(id), id).toBe(true); + }); +}); + +describe('mockupNever → MOCKUP_NEVER_NAMES (generation-time slop guard)', () => { + test('exactly the ten agreed ids carry the flag', () => { + const flagged = DESIGN_SLOP_CATALOG.filter(e => e.mockupNever).map(e => e.id).sort(); + expect(flagged).toEqual([...MOCKUP_NEVER_IDS].sort()); + }); + + test('names are deduped plain English with no hyphenated ids', () => { + expect(new Set(MOCKUP_NEVER_NAMES).size).toBe(MOCKUP_NEVER_NAMES.length); + expect(MOCKUP_NEVER_NAMES.length).toBe(10); + for (const n of MOCKUP_NEVER_NAMES) { + expect(n).not.toMatch(/^[a-z0-9]+(-[a-z0-9]+)+$/); + expect(n[0]).toMatch(/[A-Z"0-9]/); } }); });