mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-10 23:19:09 +02:00
Merge origin/main and advance release to v1.84.1.0
Preserve the design interoperability release and clarify ship publication sequencing under frontier evaluation. Co-authored-by: OpenAI Codex <noreply@openai.com>
This commit is contained in:
@@ -0,0 +1,739 @@
|
||||
// lib/design-catalog.ts — gstack's design anti-pattern vocabulary, typed.
|
||||
// Derived in part from pbakaus/impeccable (Apache-2.0), modified. See NOTICE.md.
|
||||
//
|
||||
// Pure module: no I/O, no imports from scripts/. bin/ and lib/ travel together
|
||||
// on every host, scripts/ is never linked, so anything runtime may import this
|
||||
// and nothing here may import scripts/.
|
||||
//
|
||||
// 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_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
|
||||
//
|
||||
// Rule ids. An entry's `impeccableId` is set only when that id exists in
|
||||
// test/fixtures/impeccable-antipatterns.json (test-enforced), and rendered
|
||||
// prose brackets an id only in that case, so a reader never meets a bracketed
|
||||
// id the detector cannot emit. Everything else is a gstack-only tell that the
|
||||
// LLM pass judges. The four lists this file replaced (constants.ts, the
|
||||
// consultation proposal section, design-html's blacklist, and the review
|
||||
// checklist) had drifted apart; they now render from here.
|
||||
|
||||
export type SlopCategory =
|
||||
| 'scaffold' | 'surface' | 'type' | 'color' | 'layout'
|
||||
| 'motion' | 'copy' | 'states' | 'imagery' | 'browser-surface';
|
||||
export type DetectMethod = 'engine' | 'grep' | 'render' | 'llm';
|
||||
export type Confidence = 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
export type ReviewTier = 'auto-fix' | 'ask' | 'possible';
|
||||
export type Impact = 'high' | 'medium' | 'polish';
|
||||
export type FontRole = 'display' | 'body' | 'ui' | 'mono';
|
||||
/** The `/impeccable <cmd>` commands a deferred finding may hand off to (one source for the type and the prose). */
|
||||
export const HANDOFF_COMMANDS = ['typeset', 'layout', 'colorize', 'harden', 'clarify', 'polish', 'animate', 'quieter'] as const;
|
||||
export type Handoff = (typeof HANDOFF_COMMANDS)[number];
|
||||
|
||||
export interface DesignSlopEntry {
|
||||
/** kebab-case; equals impeccableId when the detector knows the rule */
|
||||
id: string;
|
||||
/** set only when the id is in test/fixtures/impeccable-antipatterns.json */
|
||||
impeccableId?: string;
|
||||
/** short label (compact renders, mockup "Never:" line) */
|
||||
name: string;
|
||||
/** the doctrine line, gstack voice (cat 9, checklist, consultation bullets) */
|
||||
prose: string;
|
||||
category: SlopCategory;
|
||||
kind: 'slop' | 'quality';
|
||||
detect: DetectMethod[];
|
||||
/** design-checklist tier */
|
||||
confidence: Confidence;
|
||||
/** review-lite bucket */
|
||||
tier: ReviewTier;
|
||||
/** design-review triage */
|
||||
impact: Impact;
|
||||
/** grep hint rendered in design-checklist.md category 1 */
|
||||
heuristic?: string;
|
||||
/** overused-font names */
|
||||
values?: string[];
|
||||
/** roles the values are banned for; present iff values is */
|
||||
roles?: FontRole[];
|
||||
handoff?: Handoff;
|
||||
source: 'gstack' | 'impeccable' | 'both';
|
||||
/** the 11 originals; AI_SLOP_BLACKLIST derives from these verbatim */
|
||||
legacyBlacklist?: true;
|
||||
/** feeds the design binary's "Never:" prompt line */
|
||||
mockupNever?: true;
|
||||
}
|
||||
|
||||
/** 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',
|
||||
'Newsreader', 'Syne', 'IBM Plex Sans', 'IBM Plex Serif', 'DM Sans', 'DM Serif', 'Outfit',
|
||||
'Plus Jakarta Sans', 'Instrument Sans', 'Geist',
|
||||
];
|
||||
|
||||
export const DESIGN_SLOP_CATALOG: DesignSlopEntry[] = [
|
||||
// ── The 11 legacy lines. Order and prose are load-bearing: AI_SLOP_BLACKLIST is this list. ──
|
||||
{
|
||||
id: 'ai-color-palette', impeccableId: 'ai-color-palette', name: 'Purple gradient palette',
|
||||
prose: 'Purple/violet/indigo gradient backgrounds or blue-to-purple color schemes',
|
||||
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',
|
||||
prose: '**The 3-column feature grid:** icon-in-colored-circle + bold title + 2-line description, repeated 3x symmetrically. THE most recognizable AI layout.',
|
||||
category: 'scaffold', kind: 'slop', detect: ['grep', 'llm'], confidence: 'LOW', tier: 'ask', impact: 'medium',
|
||||
heuristic: 'Look for a grid/flex container with exactly 3 children that each contain a circular element + heading + paragraph.',
|
||||
handoff: 'layout', source: 'gstack', legacyBlacklist: true,
|
||||
},
|
||||
{
|
||||
id: 'icon-circle-decoration', name: 'Icons in colored circles',
|
||||
prose: 'Icons in colored circles as section decoration (SaaS starter template look)',
|
||||
category: 'scaffold', kind: 'slop', detect: ['grep', 'llm'], confidence: 'LOW', tier: 'ask', impact: 'medium',
|
||||
heuristic: 'Look for elements with `border-radius: 50%` + a background color used as decorative containers for icons.',
|
||||
handoff: 'quieter', source: 'gstack', legacyBlacklist: true,
|
||||
},
|
||||
{
|
||||
id: 'centered-everything', name: 'Centered everything',
|
||||
prose: 'Centered everything (`text-align: center` on all headings, descriptions, cards)',
|
||||
category: 'layout', kind: 'slop', detect: ['grep', 'llm'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
heuristic: 'Grep for `text-align: center` density: if more than 60% of text containers center, flag it.',
|
||||
handoff: 'layout', source: 'gstack', legacyBlacklist: true,
|
||||
},
|
||||
{
|
||||
id: 'uniform-radius', name: 'Uniform bubbly border-radius',
|
||||
prose: 'Uniform bubbly border-radius on every element (same large radius on everything)',
|
||||
category: 'surface', kind: 'slop', detect: ['grep', 'llm'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
heuristic: 'Aggregate `border-radius` values: if more than 80% share one value of 16px or more, flag it. Pill radius on everything is the extreme case.',
|
||||
handoff: 'polish', source: 'gstack', legacyBlacklist: true,
|
||||
},
|
||||
{
|
||||
id: 'decorative-blobs', name: 'Decorative blobs and dividers',
|
||||
prose: 'Decorative blobs, floating circles, wavy SVG dividers (if a section feels empty, it needs better content, not decoration)',
|
||||
category: 'imagery', kind: 'slop', detect: ['llm'], confidence: 'LOW', tier: 'ask', impact: 'medium',
|
||||
handoff: 'quieter', source: 'gstack', legacyBlacklist: true,
|
||||
},
|
||||
{
|
||||
id: 'emoji-decoration', name: 'Emoji as design elements',
|
||||
prose: 'Emoji as design elements (rockets in headings, emoji as bullet points)',
|
||||
category: 'imagery', kind: 'slop', detect: ['grep', 'llm'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
heuristic: 'Grep headings, list items, and buttons for emoji code points used as icons or bullets.',
|
||||
handoff: 'polish', source: 'gstack', legacyBlacklist: true,
|
||||
},
|
||||
{
|
||||
id: 'side-tab', impeccableId: 'side-tab', name: 'Colored left-border on cards',
|
||||
prose: 'Colored left-border on cards (`border-left: 3px solid <accent>`)',
|
||||
category: 'surface', kind: 'slop', detect: ['engine', 'grep'], confidence: 'HIGH', tier: 'ask', impact: 'medium',
|
||||
heuristic: 'Grep for `border-left: <n>px solid` on card, callout, or list-item selectors.',
|
||||
handoff: 'polish', source: 'both', legacyBlacklist: true,
|
||||
},
|
||||
{
|
||||
id: 'generic-hero-copy', name: 'Generic hero copy',
|
||||
prose: 'Generic hero copy ("Welcome to [X]", "Unlock the power of...", "Your all-in-one solution for...")',
|
||||
category: 'copy', kind: 'slop', detect: ['grep', 'llm'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
heuristic: 'Grep HTML/JSX content for "Welcome to", "Unlock the power of", "Your all-in-one solution", "Revolutionize your", "Streamline your workflow".',
|
||||
handoff: 'clarify', source: 'gstack', legacyBlacklist: true,
|
||||
},
|
||||
{
|
||||
id: 'cookie-cutter-rhythm', name: 'Cookie-cutter section rhythm',
|
||||
prose: 'Cookie-cutter section rhythm (hero → 3 features → testimonials → pricing → CTA, every section same height)',
|
||||
category: 'scaffold', kind: 'slop', detect: ['llm'], confidence: 'LOW', tier: 'ask', impact: 'medium',
|
||||
handoff: 'layout', source: 'gstack', legacyBlacklist: true,
|
||||
},
|
||||
{
|
||||
id: 'system-font-primary', name: 'system-ui as the primary face',
|
||||
prose: 'system-ui or `-apple-system` as the PRIMARY display/body font — the "I gave up on typography" signal. Pick a real typeface.',
|
||||
category: 'type', kind: 'slop', detect: ['grep'], confidence: 'HIGH', tier: 'ask', impact: 'medium',
|
||||
heuristic: 'Grep `font-family` on body, headings, and base styles for `system-ui` or `-apple-system` as the first face in the stack.',
|
||||
handoff: 'typeset', source: 'gstack', legacyBlacklist: true,
|
||||
},
|
||||
|
||||
// ── Slop the detector knows (ids from the registry fixture). ──
|
||||
{
|
||||
id: 'border-accent-on-rounded', impeccableId: 'border-accent-on-rounded', name: 'Border accent on a rounded card',
|
||||
prose: 'A colored edge on a rounded card: the side-tab in a costume. Signal state with a background tint, an icon, or a label.',
|
||||
category: 'surface', kind: 'slop', detect: ['engine'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
handoff: 'polish', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'overused-font', impeccableId: 'overused-font', name: 'Overused display font',
|
||||
prose: 'A training-data default as the display voice means you stopped looking. As body or UI on an Operate or Read surface, several of these are fine. Say which and why.',
|
||||
category: 'type', kind: 'slop', detect: ['engine', 'grep', 'llm'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
heuristic: 'Grep `font-family` for a listed face as the first face on display selectors (h1, h2, .hero, .display).',
|
||||
values: OVERUSED_DISPLAY, roles: ['display'],
|
||||
handoff: 'typeset', source: 'both',
|
||||
},
|
||||
{
|
||||
id: 'flat-type-hierarchy', impeccableId: 'flat-type-hierarchy', name: 'Flat type hierarchy',
|
||||
prose: 'Headings within a step of body size. Pick a scale and let the levels differ by more than a weight.',
|
||||
category: 'type', kind: 'slop', detect: ['engine', 'llm'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
handoff: 'typeset', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'gradient-text', impeccableId: 'gradient-text', name: 'Gradient text',
|
||||
prose: 'Emphasis is weight or size. Gradient text is emphasis in a costume.',
|
||||
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',
|
||||
prose: 'One gap value between everything. Rhythm needs a large step and a small step, not a single beat.',
|
||||
category: 'layout', kind: 'slop', detect: ['engine', 'llm'], confidence: 'MEDIUM', tier: 'ask', impact: 'polish',
|
||||
handoff: 'layout', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'bounce-easing', impeccableId: 'bounce-easing', name: 'Bounce easing',
|
||||
prose: 'Overshoot and bounce curves on UI motion. Exponential ease-out from an already-visible default.',
|
||||
category: 'motion', kind: 'slop', detect: ['engine', 'grep'], confidence: 'HIGH', tier: 'ask', impact: 'polish',
|
||||
heuristic: 'Grep transitions and keyframes for cubic-bezier curves with a control point past 1, or `bounce` in animation names.',
|
||||
handoff: 'animate', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'pulsing-dot', impeccableId: 'pulsing-dot', name: 'Pulsing status dot',
|
||||
prose: 'A small circle pulsing forever next to "Live" or "Online". Motion that says nothing new after the first loop.',
|
||||
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',
|
||||
prose: 'A fake terminal cursor blinking in marketing copy. Theater, not interface.',
|
||||
category: 'motion', kind: 'slop', detect: ['engine'], confidence: 'MEDIUM', tier: 'ask', impact: 'polish',
|
||||
handoff: 'animate', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'shape-assembled-illustration', impeccableId: 'shape-assembled-illustration', name: 'Shape-assembled illustration',
|
||||
prose: 'An illustration built from CSS shapes standing in for an asset. Produce the asset or ship nothing.',
|
||||
category: 'imagery', kind: 'slop', detect: ['engine', 'llm'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
handoff: 'quieter', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'dark-glow', impeccableId: 'dark-glow', name: 'Dark-mode glow',
|
||||
prose: 'Glowing edges on dark surfaces: look number two. Depth has an offset; a zero-offset colored halo is decoration.',
|
||||
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',
|
||||
prose: 'A radial gradient halo behind the hero content. Look number two again.',
|
||||
category: 'surface', kind: 'slop', detect: ['engine'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
handoff: 'quieter', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'radial-spotlight-glow', impeccableId: 'radial-spotlight-glow', name: 'Radial spotlight glow',
|
||||
prose: 'A spotlight glow washing the top of the page. Same family as the halo.',
|
||||
category: 'surface', kind: 'slop', detect: ['engine'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
handoff: 'quieter', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'marquee', impeccableId: 'marquee', name: 'Logo marquee',
|
||||
prose: 'An infinitely scrolling logo strip. If the logos matter, show them still; if they do not, cut them.',
|
||||
category: 'motion', kind: 'slop', detect: ['engine', 'llm'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
handoff: 'animate', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'icon-tile-stack', impeccableId: 'icon-tile-stack', name: 'Icon tile above every heading',
|
||||
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',
|
||||
prose: 'Look three: the italic display serif reaching for editorial credibility. Earn it with the content or set the display upright.',
|
||||
category: 'type', kind: 'slop', detect: ['engine', 'llm'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
handoff: 'typeset', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'hero-eyebrow-chip', impeccableId: 'hero-eyebrow-chip', name: 'Hero eyebrow chip',
|
||||
prose: 'A pill-shaped label floating above the hero headline. The headline carries its own weight; cut the chip.',
|
||||
category: 'scaffold', kind: 'slop', detect: ['engine', 'llm'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
handoff: 'quieter', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'kicker-above-heading', impeccableId: 'kicker-above-heading', name: 'Kicker above heading',
|
||||
prose: 'A kicker above a heading is the strongest default there is: the heading carries its own weight, so delete the label. If the user wants it anyway, comply and say the tradeoff once.',
|
||||
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',
|
||||
prose: '01 / 02 / 03 over sections, unless the sequence is information the reader needs.',
|
||||
category: 'scaffold', kind: 'slop', detect: ['engine', 'llm'], confidence: 'MEDIUM', tier: 'ask', impact: 'polish',
|
||||
handoff: 'layout', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'em-dash-overuse', impeccableId: 'em-dash-overuse', name: 'Em-dash overuse',
|
||||
prose: 'Em dashes in every other sentence. Advisory: a tell of generated copy, never a blocker on its own.',
|
||||
category: 'copy', kind: 'slop', detect: ['engine'], confidence: 'LOW', tier: 'possible', impact: 'polish',
|
||||
handoff: 'clarify', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'marketing-buzzword', impeccableId: 'marketing-buzzword', name: 'Marketing buzzwords',
|
||||
prose: '"Seamless", "effortless", "supercharge", "streamline": words that describe nothing. Say what the product does.',
|
||||
category: 'copy', kind: 'slop', detect: ['engine', 'grep'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
heuristic: 'Grep visible copy for seamless, effortless, supercharge, streamline, revolutionize, unlock, empower, elevate.',
|
||||
handoff: 'clarify', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'aphoristic-cadence', impeccableId: 'aphoristic-cadence', name: 'Aphoristic cadence',
|
||||
prose: 'Short. Punchy. Fragments. Every sentence a slogan. Write like a person explaining something.',
|
||||
category: 'copy', kind: 'slop', detect: ['engine', 'llm'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
handoff: 'clarify', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'oversized-h1', impeccableId: 'oversized-h1', name: 'Oversized h1',
|
||||
prose: 'Display type past 6rem on a page that is not a poster. Size is not hierarchy.',
|
||||
category: 'type', kind: 'slop', detect: ['engine', 'grep'], confidence: 'HIGH', tier: 'ask', impact: 'medium',
|
||||
heuristic: 'Grep h1 and display selectors for font-size above 6rem or 96px.',
|
||||
handoff: 'typeset', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'extreme-negative-tracking', impeccableId: 'extreme-negative-tracking', name: 'Extreme negative tracking',
|
||||
prose: 'Letter-spacing below -0.04em on display type. Tight tracking is a taste; crushed tracking is a tell.',
|
||||
category: 'type', kind: 'slop', detect: ['engine', 'grep'], confidence: 'HIGH', tier: 'ask', impact: 'polish',
|
||||
heuristic: 'Grep `letter-spacing` for values below -0.04em.',
|
||||
handoff: 'typeset', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'gpt-thin-border-wide-shadow', impeccableId: 'gpt-thin-border-wide-shadow', name: 'Thin border plus wide shadow',
|
||||
prose: 'A hairline border and a wide soft shadow on the same card. Pick one way to lift the surface.',
|
||||
category: 'surface', kind: 'slop', detect: ['engine'], confidence: 'MEDIUM', tier: 'ask', impact: 'polish',
|
||||
handoff: 'polish', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'repeating-stripes-gradient', impeccableId: 'repeating-stripes-gradient', name: 'Repeating stripes gradient',
|
||||
prose: 'Diagonal stripe gradients as background texture. Texture from the brand or none.',
|
||||
category: 'surface', kind: 'slop', detect: ['engine'], confidence: 'MEDIUM', tier: 'ask', impact: 'polish',
|
||||
handoff: 'quieter', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'codex-grid-background', impeccableId: 'codex-grid-background', name: 'Grid-paper background',
|
||||
prose: 'A faint grid behind the hero. The blueprint look every generated dev tool ships.',
|
||||
category: 'surface', kind: 'slop', detect: ['engine'], confidence: 'MEDIUM', tier: 'ask', impact: 'polish',
|
||||
handoff: 'quieter', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'theater-slop-phrase', impeccableId: 'theater-slop-phrase', name: 'Theater phrases',
|
||||
prose: '"Built for the way you work", "Designed for teams like yours", "Meet your new...": phrases that perform a launch instead of describing one.',
|
||||
category: 'copy', kind: 'slop', detect: ['engine', 'grep'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
heuristic: 'Grep copy for "built for", "designed for", "meet your new", "ship faster", "the future of".',
|
||||
handoff: 'clarify', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'image-hover-transform', impeccableId: 'image-hover-transform', name: 'Image hover zoom',
|
||||
prose: 'Scaling an image on hover. Motion with no information in it.',
|
||||
category: 'motion', kind: 'slop', detect: ['engine', 'grep'], confidence: 'MEDIUM', tier: 'ask', impact: 'polish',
|
||||
heuristic: 'Grep `:hover` rules on images for `transform: scale`.',
|
||||
handoff: 'animate', source: 'impeccable',
|
||||
},
|
||||
|
||||
// ── gstack-only slop tells: the LLM pass judges these; no detector id, so no brackets. ──
|
||||
{
|
||||
id: 'gradient-cta', name: 'Gradient CTA button',
|
||||
prose: 'Gradient buttons as the primary call to action. One solid color the palette owns.',
|
||||
category: 'color', kind: 'slop', detect: ['grep', 'llm'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
heuristic: 'Grep button and CTA selectors for gradient backgrounds.',
|
||||
handoff: 'colorize', source: 'gstack',
|
||||
},
|
||||
{
|
||||
id: 'stock-photo-hero', name: 'Stock-photo hero',
|
||||
prose: 'A generic stock-photo hero, or a gray placeholder div standing in for one. Show the product or show nothing.',
|
||||
category: 'imagery', kind: 'slop', detect: ['llm'], confidence: 'LOW', tier: 'ask', impact: 'medium',
|
||||
handoff: 'quieter', source: 'gstack',
|
||||
},
|
||||
{
|
||||
id: 'card-default-component', name: 'Cards as the default component',
|
||||
prose: 'Rounded cards with drop shadows as the container for everything. App UI made of stacked cards is not layout.',
|
||||
category: 'scaffold', kind: 'slop', detect: ['llm'], confidence: 'LOW', tier: 'ask', impact: 'medium',
|
||||
handoff: 'layout', source: 'gstack',
|
||||
},
|
||||
{
|
||||
id: 'generic-testimonials', name: 'Generic testimonial section',
|
||||
prose: 'A testimonial row with avatars, five stars, and quotes nobody said. Real names with real claims, or cut it.',
|
||||
category: 'scaffold', kind: 'slop', detect: ['llm'], confidence: 'LOW', tier: 'ask', impact: 'medium',
|
||||
handoff: 'clarify', source: 'gstack',
|
||||
},
|
||||
{
|
||||
id: 'split-hero-template', name: 'Left-text right-image hero',
|
||||
prose: 'The cookie-cutter hero: headline left, screenshot right, two buttons. The first template every generator reaches for.',
|
||||
category: 'scaffold', kind: 'slop', detect: ['llm'], confidence: 'LOW', tier: 'ask', impact: 'medium',
|
||||
handoff: 'layout', source: 'gstack',
|
||||
},
|
||||
{
|
||||
id: 'generic-cta-copy', name: 'Generic CTA labels',
|
||||
prose: '"Get Started" and "Learn More" as the only calls to action. Name the outcome the click buys.',
|
||||
category: 'copy', kind: 'slop', detect: ['grep', 'llm'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
heuristic: 'Grep buttons and links for "Get Started" and "Learn More" with no more specific CTA on the page.',
|
||||
handoff: 'clarify', source: 'gstack',
|
||||
},
|
||||
{
|
||||
id: 'hero-metrics', name: 'Hero metric template',
|
||||
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',
|
||||
prose: 'Frosted-glass panels with blurred backdrops as the default surface. One translucent layer where it explains depth, not everywhere.',
|
||||
category: 'surface', kind: 'slop', detect: ['grep', 'llm'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
heuristic: 'Grep for `backdrop-filter: blur` on more than one container.',
|
||||
handoff: 'quieter', source: 'gstack',
|
||||
},
|
||||
{
|
||||
id: 'hand-drawn-svg', name: 'Hand-drawn SVG illustration',
|
||||
prose: 'Generated SVG doodles and mascots in place of art direction. Commission or license an asset, or ship none.',
|
||||
category: 'imagery', kind: 'slop', detect: ['llm'], confidence: 'LOW', tier: 'ask', impact: 'medium',
|
||||
handoff: 'quieter', source: 'gstack',
|
||||
},
|
||||
{
|
||||
id: 'modal-by-default', name: 'Modal by default',
|
||||
prose: 'Every secondary action in a modal. Inline, a side panel, or a new page usually costs the user less.',
|
||||
category: 'states', kind: 'slop', detect: ['llm'], confidence: 'LOW', tier: 'ask', impact: 'medium',
|
||||
handoff: 'harden', source: 'gstack',
|
||||
},
|
||||
{
|
||||
id: 'monospace-costume', name: 'Monospace as costume',
|
||||
prose: 'Monospace on labels and body copy to look technical. Mono is for code and data columns.',
|
||||
category: 'type', kind: 'slop', detect: ['grep', 'llm'], confidence: 'MEDIUM', tier: 'ask', impact: 'polish',
|
||||
heuristic: 'Grep `font-family` for a monospace stack on non-code, non-tabular selectors.',
|
||||
handoff: 'typeset', source: 'gstack',
|
||||
},
|
||||
{
|
||||
id: 'content-stand-ins', name: 'Content stand-ins',
|
||||
prose: 'Sparklines, progress rings, and fake avatars filling space where content should be. Real data or an honest empty state.',
|
||||
category: 'imagery', kind: 'slop', detect: ['llm'], confidence: 'LOW', tier: 'ask', impact: 'medium',
|
||||
handoff: 'harden', source: 'gstack',
|
||||
},
|
||||
{
|
||||
id: 'mode-by-category', name: 'Mode picked by category',
|
||||
prose: 'Dark because it is a dev tool, light because it is health. Light or dark comes from the use scene: who, where, under what light.',
|
||||
category: 'color', kind: 'slop', detect: ['llm'], confidence: 'LOW', tier: 'ask', impact: 'medium',
|
||||
handoff: 'colorize', source: 'gstack',
|
||||
},
|
||||
{
|
||||
id: 'unthemed-browser-surfaces', name: 'Unthemed browser surfaces',
|
||||
prose: 'Selection color, caret, scrollbars, focus rings, underline offset, tabular numerals left at browser defaults. Theme them from the palette.',
|
||||
category: 'browser-surface', kind: 'slop', detect: ['grep', 'llm'], confidence: 'MEDIUM', tier: 'ask', impact: 'polish',
|
||||
heuristic: 'Grep for `::selection`, `caret-color`, `accent-color`, `scrollbar-color`, `text-underline-offset`, `font-variant-numeric`: none present means none themed.',
|
||||
handoff: 'polish', source: 'gstack',
|
||||
},
|
||||
{
|
||||
id: 'missing-states', name: 'Missing states',
|
||||
prose: 'Only the happy path is designed. Empty, loading, error, and long-content states are part of the component.',
|
||||
category: 'states', kind: 'slop', detect: ['llm'], confidence: 'LOW', tier: 'ask', impact: 'high',
|
||||
handoff: 'harden', source: 'gstack',
|
||||
},
|
||||
|
||||
// ── Quality rules the detector knows. ──
|
||||
{
|
||||
id: 'organic-clip-path', impeccableId: 'organic-clip-path', name: 'Organic clip-path',
|
||||
prose: 'A polygon clip-path approximating a photo edge or a blob. An asset with its own edge, or a rectangle.',
|
||||
category: 'imagery', kind: 'quality', detect: ['engine'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
handoff: 'quieter', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'buried-raster', impeccableId: 'buried-raster', name: 'Buried raster',
|
||||
prose: 'A photo under a near-opaque wash. If the image cannot be seen, it is not doing anything.',
|
||||
category: 'imagery', kind: 'quality', detect: ['engine'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
handoff: 'quieter', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'broken-image', impeccableId: 'broken-image', name: 'Broken image',
|
||||
prose: 'An image that fails to load. Nothing on the page is more visible.',
|
||||
category: 'imagery', kind: 'quality', detect: ['engine', 'render'], confidence: 'HIGH', tier: 'ask', impact: 'high',
|
||||
handoff: 'harden', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'script-error', impeccableId: 'script-error', name: 'Script error',
|
||||
prose: 'A JavaScript error in the console on load. The page is not finished.',
|
||||
category: 'states', kind: 'quality', detect: ['engine', 'render'], confidence: 'HIGH', tier: 'ask', impact: 'high',
|
||||
handoff: 'harden', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'content-hidden-at-rest', impeccableId: 'content-hidden-at-rest', name: 'Content hidden at rest',
|
||||
prose: 'Content at opacity 0 waiting for a scroll animation that may never fire. Content is visible by default.',
|
||||
category: 'motion', kind: 'quality', detect: ['engine', 'render'], confidence: 'HIGH', tier: 'ask', impact: 'high',
|
||||
handoff: 'animate', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'edge-flush-cards', impeccableId: 'edge-flush-cards', name: 'Edge-flush cards',
|
||||
prose: 'Cards touching the viewport edge. Give the layout a gutter.',
|
||||
category: 'layout', kind: 'quality', detect: ['engine', 'render'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
handoff: 'layout', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'text-occlusion', impeccableId: 'text-occlusion', name: 'Text occlusion',
|
||||
prose: 'Text covered by another element. Overlap is a bug until it is a choice.',
|
||||
category: 'layout', kind: 'quality', detect: ['engine', 'render'], confidence: 'HIGH', tier: 'ask', impact: 'high',
|
||||
handoff: 'harden', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'first-viewport-column-overflow', impeccableId: 'first-viewport-column-overflow', name: 'First-viewport overflow',
|
||||
prose: 'A column wider than the first viewport. Horizontal scroll on arrival.',
|
||||
category: 'layout', kind: 'quality', detect: ['engine', 'render'], confidence: 'HIGH', tier: 'ask', impact: 'high',
|
||||
handoff: 'layout', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'gray-on-color', impeccableId: 'gray-on-color', name: 'Gray text on a colored surface',
|
||||
prose: 'Secondary text on a colored surface is tinted from that hue. Never gray.',
|
||||
category: 'color', kind: 'quality', detect: ['engine'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
handoff: 'colorize', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'low-contrast', impeccableId: 'low-contrast', name: 'Low contrast text',
|
||||
prose: 'Text below WCAG AA contrast (4.5:1 body, 3:1 large). Fix the pair, not the opacity.',
|
||||
category: 'color', kind: 'quality', detect: ['engine', 'render'], confidence: 'HIGH', tier: 'ask', impact: 'high',
|
||||
handoff: 'colorize', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'layout-transition', impeccableId: 'layout-transition', name: 'Layout-property transition',
|
||||
prose: '`transition: all`, or transitions on width, height, top, left. Animate transform and opacity.',
|
||||
category: 'motion', kind: 'quality', detect: ['engine', 'grep'], confidence: 'HIGH', tier: 'auto-fix', impact: 'polish',
|
||||
heuristic: 'Grep `transition` for `all` or layout properties.',
|
||||
handoff: 'animate', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'line-length', impeccableId: 'line-length', name: 'Line length',
|
||||
prose: 'Body measure outside 45 to 75 characters. Set a max-width on the text column.',
|
||||
category: 'type', kind: 'quality', detect: ['engine', 'grep'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
heuristic: 'Check for `max-width` on body text wrappers.',
|
||||
handoff: 'typeset', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'cramped-padding', impeccableId: 'cramped-padding', name: 'Cramped padding',
|
||||
prose: 'Padding under 8px on text containers. Text needs room to breathe.',
|
||||
category: 'layout', kind: 'quality', detect: ['engine'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
handoff: 'layout', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'body-text-viewport-edge', impeccableId: 'body-text-viewport-edge', name: 'Body text at the viewport edge',
|
||||
prose: 'Body text within a few pixels of the viewport edge on small screens.',
|
||||
category: 'layout', kind: 'quality', detect: ['engine', 'render'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
handoff: 'layout', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'tight-leading', impeccableId: 'tight-leading', name: 'Tight leading',
|
||||
prose: 'Body line-height under 1.4. Display type can run tight; paragraphs cannot.',
|
||||
category: 'type', kind: 'quality', detect: ['engine', 'grep'], confidence: 'HIGH', tier: 'ask', impact: 'medium',
|
||||
heuristic: 'Grep body and paragraph `line-height` for values below 1.4.',
|
||||
handoff: 'typeset', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'skipped-heading', impeccableId: 'skipped-heading', name: 'Skipped heading level',
|
||||
prose: 'h1 followed by h3 with no h2. Screen readers walk the hierarchy.',
|
||||
category: 'type', kind: 'quality', detect: ['engine', 'grep'], confidence: 'HIGH', tier: 'ask', impact: 'medium',
|
||||
heuristic: 'Check HTML/JSX for heading tags that skip a level within a file or component.',
|
||||
handoff: 'typeset', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'heading-rhythm', impeccableId: 'heading-rhythm', name: 'Heading rhythm',
|
||||
prose: 'More space above a heading than below it. Read the computed values.',
|
||||
category: 'type', kind: 'quality', detect: ['engine'], confidence: 'MEDIUM', tier: 'ask', impact: 'polish',
|
||||
handoff: 'typeset', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'justified-text', impeccableId: 'justified-text', name: 'Justified text',
|
||||
prose: 'Justified body text on the web leaves rivers. Left-align.',
|
||||
category: 'type', kind: 'quality', detect: ['engine', 'grep'], confidence: 'HIGH', tier: 'auto-fix', impact: 'polish',
|
||||
heuristic: 'Grep for `text-align: justify`.',
|
||||
handoff: 'typeset', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'tiny-text', impeccableId: 'tiny-text', name: 'Tiny text',
|
||||
prose: 'Body text under 16px. Bump to 16px.',
|
||||
category: 'type', kind: 'quality', detect: ['engine', 'grep'], confidence: 'HIGH', tier: 'auto-fix', impact: 'medium',
|
||||
heuristic: 'Grep `font-size` on body, p, and base styles for values under 16px (1rem at a 16px base).',
|
||||
handoff: 'typeset', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'undersized-ui-text', impeccableId: 'undersized-ui-text', name: 'Undersized UI text',
|
||||
prose: 'Labels and controls under 12px. Nobody reads 10px.',
|
||||
category: 'type', kind: 'quality', detect: ['engine'], confidence: 'HIGH', tier: 'ask', impact: 'medium',
|
||||
handoff: 'typeset', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'all-caps-body', impeccableId: 'all-caps-body', name: 'All-caps body text',
|
||||
prose: 'Uppercase paragraphs. Caps are for short labels.',
|
||||
category: 'type', kind: 'quality', detect: ['engine', 'grep'], confidence: 'HIGH', tier: 'auto-fix', impact: 'medium',
|
||||
heuristic: 'Grep `text-transform: uppercase` on body and paragraph selectors.',
|
||||
handoff: 'typeset', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'wide-tracking', impeccableId: 'wide-tracking', name: 'Wide tracking on body',
|
||||
prose: 'Letter-spacing above 0.05em on body text. Tracked type is for small-caps labels.',
|
||||
category: 'type', kind: 'quality', detect: ['engine', 'grep'], confidence: 'MEDIUM', tier: 'ask', impact: 'polish',
|
||||
heuristic: 'Grep body `letter-spacing` for values above 0.05em.',
|
||||
handoff: 'typeset', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'text-overflow', impeccableId: 'text-overflow', name: 'Text overflow',
|
||||
prose: 'Text spilling out of its container. Long content is the normal case.',
|
||||
category: 'states', kind: 'quality', detect: ['engine', 'render'], confidence: 'HIGH', tier: 'ask', impact: 'high',
|
||||
handoff: 'harden', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'repeated-container-text', impeccableId: 'repeated-container-text', name: 'Repeated container text',
|
||||
prose: 'The same text repeated across sibling containers. Placeholder content that shipped.',
|
||||
category: 'copy', kind: 'quality', detect: ['engine'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
handoff: 'clarify', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'clipped-overflow-container', impeccableId: 'clipped-overflow-container', name: 'Clipped overflow',
|
||||
prose: 'A container clipping its own content with overflow hidden. Something is cut off.',
|
||||
category: 'states', kind: 'quality', detect: ['engine', 'render'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
handoff: 'harden', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'design-system-font', impeccableId: 'design-system-font', name: 'Off-system font',
|
||||
prose: 'A face DESIGN.md tokens do not name. Add the token or use one that exists.',
|
||||
category: 'type', kind: 'quality', detect: ['engine'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
handoff: 'polish', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'design-system-color', impeccableId: 'design-system-color', name: 'Off-system color',
|
||||
prose: 'A color DESIGN.md tokens do not name. Add the token or use one that exists.',
|
||||
category: 'color', kind: 'quality', detect: ['engine'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
handoff: 'polish', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'design-system-radius', impeccableId: 'design-system-radius', name: 'Off-system radius',
|
||||
prose: 'A radius DESIGN.md tokens do not name. Add the token or use one that exists.',
|
||||
category: 'surface', kind: 'quality', detect: ['engine'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
handoff: 'polish', source: 'impeccable',
|
||||
},
|
||||
{
|
||||
id: 'design-system-font-size', impeccableId: 'design-system-font-size', name: 'Off-system font size',
|
||||
prose: 'A font size DESIGN.md tokens do not name. Add the token or use one on the scale.',
|
||||
category: 'type', kind: 'quality', detect: ['engine'], confidence: 'MEDIUM', tier: 'ask', impact: 'medium',
|
||||
handoff: 'polish', source: 'impeccable',
|
||||
},
|
||||
];
|
||||
|
||||
// ── Mockup prompt ──
|
||||
|
||||
/**
|
||||
* Plain-English names of the entries flagged `mockupNever`, deduped: the design
|
||||
* binary appends "Never: <names>." 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; the detector flags several as `overused-font`. */
|
||||
export const OVERUSED_FONTS_DISPLAY: readonly string[] = OVERUSED_DISPLAY;
|
||||
|
||||
/** Never, in any role. */
|
||||
export const BANNED_FONTS: readonly string[] = [
|
||||
'Papyrus', 'Comic Sans', 'Lobster', 'Impact', 'Jokerman', 'Bleeding Cowboys', 'Permanent Marker',
|
||||
'Bradley Hand', 'Brush Script', 'Hobo', 'Trajan', 'Raleway', 'Clash Display', 'Courier New',
|
||||
];
|
||||
|
||||
/** On the overused list, yet fine as body or UI on an Operate or Read surface when the proposal says so. */
|
||||
export const FONTS_BODY_UI_OK: readonly string[] = ['DM Sans', 'Instrument Sans', 'IBM Plex Sans'];
|
||||
|
||||
/** Mono for data and code. */
|
||||
export const FONTS_MONO_OK: readonly string[] = ['JetBrains Mono', 'IBM Plex Mono', 'Fira Code'];
|
||||
|
||||
/**
|
||||
* Freely available faces on no default list. Availability was verified at the
|
||||
* last edit of this constant; a proposal re-verifies in-session before naming one.
|
||||
*/
|
||||
export const FONTS_VERIFIED_FREE = {
|
||||
verified: '2026-09-08',
|
||||
fontshare: ['Satoshi', 'General Sans', 'Clash Grotesk', 'Cabinet Grotesk'],
|
||||
googleFonts: ['Instrument Serif', 'Source Sans 3', 'JetBrains Mono', 'Fira Code'],
|
||||
} as const;
|
||||
|
||||
// ── Lookups ──
|
||||
|
||||
const BY_ID = new Map(DESIGN_SLOP_CATALOG.map(e => [e.id, e]));
|
||||
const BY_IMPECCABLE_ID = new Map(
|
||||
DESIGN_SLOP_CATALOG.filter(e => e.impeccableId).map(e => [e.impeccableId as string, e]),
|
||||
);
|
||||
|
||||
export function catalogEntry(id: string): DesignSlopEntry | undefined {
|
||||
return BY_ID.get(id);
|
||||
}
|
||||
|
||||
/** The catalog entry for a detector rule id, or undefined when the id is unmapped. */
|
||||
export function entryForImpeccableId(impeccableId: string): DesignSlopEntry | undefined {
|
||||
return BY_IMPECCABLE_ID.get(impeccableId);
|
||||
}
|
||||
|
||||
// ── Rendering ──
|
||||
|
||||
export interface RenderCatalogOptions {
|
||||
kind?: 'slop' | 'quality';
|
||||
/** drop entries whose impact is in this list (e.g. ['polish'] for a shorter list) */
|
||||
omitImpact?: Impact[];
|
||||
}
|
||||
|
||||
export function selectCatalog(o: RenderCatalogOptions): DesignSlopEntry[] {
|
||||
return DESIGN_SLOP_CATALOG.filter(e =>
|
||||
(!o.kind || e.kind === o.kind)
|
||||
&& !(o.omitImpact && o.omitImpact.includes(e.impact)),
|
||||
);
|
||||
}
|
||||
|
||||
/** `- prose` bullets, no ids: the register the proposal skills render (design-consultation, design-shotgun). */
|
||||
export function renderCatalog(o: RenderCatalogOptions): string {
|
||||
return selectCatalog(o).map(e => `- ${e.prose}`).join('\n');
|
||||
}
|
||||
|
||||
/** Slop the detector knows, minus the 11 legacy lines: what design doctrine renders as bracketed ids. */
|
||||
export function detectorSlopEntries(o: { omitPolish?: boolean } = {}): DesignSlopEntry[] {
|
||||
return DESIGN_SLOP_CATALOG.filter(e => e.kind === 'slop' && e.impeccableId && !e.legacyBlacklist && !(o.omitPolish && e.impact === 'polish'));
|
||||
}
|
||||
|
||||
/** gstack-only slop tells (no detector rule), minus the legacy lines: the LLM pass is the detector. */
|
||||
export function judgmentTellEntries(o: { omitPolish?: boolean } = {}): DesignSlopEntry[] {
|
||||
return DESIGN_SLOP_CATALOG.filter(e => e.kind === 'slop' && !e.impeccableId && !e.legacyBlacklist && !(o.omitPolish && e.impact === 'polish'));
|
||||
}
|
||||
|
||||
/** Catalog entries by id, throwing with the id when one is missing (a rename must fail loudly at gen time). */
|
||||
export function catalogEntries(ids: string[]): DesignSlopEntry[] {
|
||||
return ids.map(id => {
|
||||
const e = BY_ID.get(id);
|
||||
if (!e) throw new Error(`lib/design-catalog.ts: no entry with id "${id}"`);
|
||||
return e;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// lib/design-detect-contract.ts — the one owner of the design-detector vocabulary.
|
||||
//
|
||||
// Pure module: no I/O, no imports from scripts/. Every sentinel the wrapper
|
||||
// (bin/gstack-design-detect.ts) or the DESIGN.md tool (bin/gstack-design-md.ts)
|
||||
// prints, and every one the skill prose reads, is a constant here, so the two
|
||||
// sides cannot drift: gen-time resolvers import these strings into SKILL.md
|
||||
// prose, the bins import them at runtime, and test/design-detect-contract.test.ts
|
||||
// asserts that every sentinel-shaped token in generated docs exists here.
|
||||
//
|
||||
// probe ──► one of: IMPECCABLE_READY | IMPECCABLE_NOT_CACHED | IMPECCABLE_NOT_AVAILABLE | IMPECCABLE_DISABLED
|
||||
// ──► always: IMPECCABLE_SKILL, IMPECCABLE_HOOK, IMPECCABLE_IGNORED_RULES, IMPECCABLE_IGNORED_FILES,
|
||||
// IMPECCABLE_IGNORED_VALUES
|
||||
// ──► maybe: IMPECCABLE_HOOK_OTHER, IMPECCABLE_CONFIG_UNREADABLE, IMPECCABLE_ENV_IGNORED,
|
||||
// IMPECCABLE_ENGINE_UNTESTED, DESIGN_DETECTOR_HINT, DESIGN_DETECTOR_INSTALL_OFFER
|
||||
// install ──► IMPECCABLE_INSTALLED: <path> ... | IMPECCABLE_INSTALL_REFUSED: <reason> (then the probe lines)
|
||||
// scan ──► stdout: one JSON document (--format gstack) or engine bytes (--format raw)
|
||||
// ──► stderr: DETECT_TOP block, DETECT_SUMMARY, DETECT_EXIT, DETECT_REFUSED / DETECT_NO_TARGETS /
|
||||
// DETECT_TIMEOUT / DETECT_PARSE_ERROR / DETECT_OUTPUT_TOO_LARGE
|
||||
// any ──► exit 3 + DESIGN_DETECT_INTERNAL_ERROR: a gstack bug, never retried
|
||||
|
||||
export const SENTINEL = {
|
||||
READY: 'IMPECCABLE_READY',
|
||||
NOT_CACHED: 'IMPECCABLE_NOT_CACHED',
|
||||
NOT_AVAILABLE: 'IMPECCABLE_NOT_AVAILABLE',
|
||||
DISABLED: 'IMPECCABLE_DISABLED',
|
||||
SKILL: 'IMPECCABLE_SKILL',
|
||||
HOOK: 'IMPECCABLE_HOOK',
|
||||
HOOK_OTHER: 'IMPECCABLE_HOOK_OTHER',
|
||||
IGNORED_RULES: 'IMPECCABLE_IGNORED_RULES',
|
||||
IGNORED_FILES: 'IMPECCABLE_IGNORED_FILES',
|
||||
IGNORED_VALUES: 'IMPECCABLE_IGNORED_VALUES',
|
||||
CONFIG_UNREADABLE: 'IMPECCABLE_CONFIG_UNREADABLE',
|
||||
ENV_IGNORED: 'IMPECCABLE_ENV_IGNORED',
|
||||
ENGINE_UNTESTED: 'IMPECCABLE_ENGINE_UNTESTED',
|
||||
/** the probe found no engine and the user has not answered the install offer yet: the skill asks once */
|
||||
INSTALL_OFFER: 'DESIGN_DETECTOR_INSTALL_OFFER',
|
||||
/** `install` placed a checksum-verified engine under the user's home */
|
||||
INSTALLED: 'IMPECCABLE_INSTALLED',
|
||||
/** `install` did not write anything, reason after the colon */
|
||||
INSTALL_REFUSED: 'IMPECCABLE_INSTALL_REFUSED',
|
||||
HINT: 'DESIGN_DETECTOR_HINT',
|
||||
DETECT_EXIT: 'DETECT_EXIT',
|
||||
DETECT_EXIT_CODE: 'DETECT_EXIT_CODE',
|
||||
DETECT_SUMMARY: 'DETECT_SUMMARY',
|
||||
DETECT_TOP: 'DETECT_TOP',
|
||||
DETECT_REFUSED: 'DETECT_REFUSED',
|
||||
DETECT_NO_TARGETS: 'DETECT_NO_TARGETS',
|
||||
DETECT_TIMEOUT: 'DETECT_TIMEOUT',
|
||||
DETECT_PARSE_ERROR: 'DETECT_PARSE_ERROR',
|
||||
DETECT_OUTPUT_TOO_LARGE: 'DETECT_OUTPUT_TOO_LARGE',
|
||||
INTERNAL_ERROR: 'DESIGN_DETECT_INTERNAL_ERROR',
|
||||
/** printed by rendered bash: the temp file holding a scan's JSON */
|
||||
DETECT_JSON: 'DETECT_JSON',
|
||||
/** printed by rendered bash after a DOM dump is persisted */
|
||||
DOM_DUMP_OK: 'DOM_DUMP_OK',
|
||||
DOM_DUMP_MISSING: 'DOM_DUMP_MISSING',
|
||||
DOM_DUMP_REDACTION_BLOCKED: 'DOM_DUMP_REDACTION_BLOCKED',
|
||||
DOM_DUMP_TOO_LARGE: 'DOM_DUMP_TOO_LARGE',
|
||||
DESIGN_MD_FORMAT: 'DESIGN_MD_FORMAT',
|
||||
DESIGN_MD_CONVERT_REFUSED: 'DESIGN_MD_CONVERT_REFUSED',
|
||||
DESIGN_MD_INTERNAL_ERROR: 'DESIGN_MD_INTERNAL_ERROR',
|
||||
DESIGN_MD_TOKEN_REF_INVALID: 'DESIGN_MD_TOKEN_REF_INVALID',
|
||||
/** printed by gstack-design-md check / convert */
|
||||
DESIGN_MD_MARKER: 'DESIGN_MD_MARKER',
|
||||
DESIGN_MD_REASON: 'DESIGN_MD_REASON',
|
||||
DESIGN_MD_WRITTEN: 'DESIGN_MD_WRITTEN',
|
||||
DESIGN_MD_BACKUP: 'DESIGN_MD_BACKUP',
|
||||
DESIGN_MD_EDIT_REFUSED: 'DESIGN_MD_EDIT_REFUSED',
|
||||
/** printed by the wrapper: --verbose probe trail, forwarded engine stderr */
|
||||
PROBE_STEP: 'PROBE_STEP',
|
||||
ENGINE_STDERR: 'ENGINE_STDERR',
|
||||
} as const;
|
||||
|
||||
|
||||
/**
|
||||
* Sentinels whose line explains itself after the colon (a path, a version, a
|
||||
* reason). Prose need not teach them; the agent notes them and moves on. The
|
||||
* contract test requires every OTHER sentinel to be taught somewhere the agent
|
||||
* reads.
|
||||
*/
|
||||
export const SELF_DESCRIBING_SENTINELS: readonly string[] = [
|
||||
SENTINEL.HOOK_OTHER, SENTINEL.IGNORED_FILES, SENTINEL.IGNORED_VALUES, SENTINEL.CONFIG_UNREADABLE, SENTINEL.ENV_IGNORED,
|
||||
SENTINEL.ENGINE_UNTESTED, SENTINEL.DETECT_EXIT, SENTINEL.DETECT_REFUSED, SENTINEL.DETECT_NO_TARGETS,
|
||||
SENTINEL.DETECT_TIMEOUT, SENTINEL.DETECT_PARSE_ERROR, SENTINEL.DETECT_OUTPUT_TOO_LARGE,
|
||||
SENTINEL.DESIGN_MD_TOKEN_REF_INVALID, SENTINEL.DESIGN_MD_WRITTEN, SENTINEL.DESIGN_MD_BACKUP, SENTINEL.DESIGN_MD_EDIT_REFUSED,
|
||||
SENTINEL.PROBE_STEP, SENTINEL.ENGINE_STDERR, SENTINEL.DOM_DUMP_MISSING, SENTINEL.INSTALLED, SENTINEL.INSTALL_REFUSED,
|
||||
];
|
||||
|
||||
/** Engine versions the committed fixtures were captured from. */
|
||||
export const TESTED_ENGINE_VERSIONS: readonly string[] = ['0.1.3'];
|
||||
|
||||
/** Where impeccable publishes its engine binaries (GitHub Releases of pbakaus/impeccable, tag engine-v<version>). */
|
||||
export const ENGINE_RELEASE_BASE = 'https://github.com/pbakaus/impeccable/releases/download';
|
||||
|
||||
/** `${process.platform}-${process.arch}` → the release asset's platform suffix (`impeccable-<suffix>`, `.exe` on Windows). */
|
||||
export const ENGINE_ASSETS: Readonly<Record<string, string>> = {
|
||||
'darwin-arm64': 'darwin-arm64',
|
||||
'darwin-x64': 'darwin-x64',
|
||||
'linux-x64': 'linux-x64',
|
||||
'linux-arm64': 'linux-arm64',
|
||||
'win32-x64': 'windows-x64',
|
||||
};
|
||||
|
||||
/**
|
||||
* Checksums gstack pins for the engine versions it has tested, per platform:
|
||||
* the `install` verb refuses a download whose bytes do not hash to the pin.
|
||||
* Captured 2026-09-09 from the release's own .sha256 sidecars
|
||||
* (https://github.com/pbakaus/impeccable/releases/tag/engine-v0.1.3); the
|
||||
* linux-x64 hash also matches the engine gstack's fixtures were captured with.
|
||||
* A pin recorded in this repo defends against a swapped release asset, which a
|
||||
* same-origin sidecar cannot; adding a version means re-capturing the fixtures.
|
||||
*/
|
||||
export const ENGINE_PINS: Readonly<Record<string, Readonly<Record<string, { sha256: string; bytes: number }>>>> = {
|
||||
'0.1.3': {
|
||||
'darwin-arm64': { sha256: '23821135d4c62f1428fd15ddb9e91d695402727f43b13a6eb3e9f31fc01b4072', bytes: 12677904 },
|
||||
'darwin-x64': { sha256: 'a5bb0ae15d1bd8f61ebd2a6a21d39c2b357a211c39b4b95cc2a947cdb10a4db4', bytes: 14300496 },
|
||||
'linux-x64': { sha256: 'afc7a424e0bd6c606b7be4c773c70e87284afbdb41d748eb9a34f8a4478e57da', bytes: 15991120 },
|
||||
'linux-arm64': { sha256: '523c0a223ac0c1522489759a9f56dccb0b458b42d6a5c66e74e6fe2255af60ce', bytes: 13262480 },
|
||||
'windows-x64': { sha256: '50846da00b48f7df5a82adc6c1ef1c82da0a890ac95e65cdd5da12aab2de6c1d', bytes: 14638984 },
|
||||
},
|
||||
};
|
||||
|
||||
/** Rules the engine reports but never counts (they never change its exit code). */
|
||||
export const ADVISORY_RULE_IDS: readonly string[] = ['em-dash-overuse'];
|
||||
|
||||
/** Markers around any engine text the skill may quote (page text can echo through it). */
|
||||
export const UNTRUSTED_BEGIN = '═══ BEGIN UNTRUSTED CONTENT (design detector output) ═══';
|
||||
export const UNTRUSTED_END = '═══ END UNTRUSTED CONTENT ═══';
|
||||
|
||||
export const DETECT_LIMITS = {
|
||||
/** default engine wall clock; GSTACK_DESIGN_DETECT_TIMEOUT_MS overrides */
|
||||
timeoutMs: 120_000,
|
||||
/** absolute paths per engine invocation */
|
||||
batch: 100,
|
||||
/** engine stdout above this is DETECT_OUTPUT_TOO_LARGE */
|
||||
stdoutBytes: 50 * 1024 * 1024,
|
||||
/** normalized findings kept; the rest is `truncated: true` */
|
||||
findings: 5_000,
|
||||
/** locations printed in the DETECT_TOP block */
|
||||
topLocations: 50,
|
||||
/** rendered-DOM dump above this is DOM_DUMP_TOO_LARGE */
|
||||
domDumpBytes: 10 * 1024 * 1024,
|
||||
/** engine stderr lines kept in the JSON (the rest is counted) and echoed to stderr */
|
||||
diagnosticsKept: 200,
|
||||
diagnosticsEchoed: 20,
|
||||
/** bytes of an engine binary hashed for its identity label when no version is known */
|
||||
engineHashBytes: 4 * 1024 * 1024,
|
||||
/** git subprocess budgets inside the wrapper */
|
||||
gitTimeoutMs: 30_000,
|
||||
/** whole-scan wall clock, as a multiple of the per-batch timeout: a huge target set stops, it never grinds for hours */
|
||||
totalTimeoutFactor: 5,
|
||||
/** the engine download the user consented to: twice the largest pinned asset, and a hard wall clock */
|
||||
engineDownloadBytes: 32 * 1024 * 1024,
|
||||
engineDownloadTimeoutMs: 120_000,
|
||||
gitMaxBuffer: 64 * 1024 * 1024,
|
||||
field: { id: 64, engineVersion: 64, message: 120, snippet: 120, value: 200, file: 4096, diagnostic: 400, refusedTarget: 200, parseErrorPreview: 80, internalError: 300 },
|
||||
} as const;
|
||||
|
||||
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
/**
|
||||
* One pass over every shape the agent reads as gstack's own voice: the two fence
|
||||
* markers, any sentinel word (whole word, colon or not: `DETECT_TOP total=` and
|
||||
* `IMPECCABLE_DISABLED` are printed bare), and the `[rule-id] impact=` group
|
||||
* header. Longest sentinel first so DETECT_EXIT_CODE is not split at DETECT_EXIT.
|
||||
*/
|
||||
const NEUTRALIZE_RE = new RegExp(
|
||||
[escapeRe(UNTRUSTED_BEGIN), escapeRe(UNTRUSTED_END),
|
||||
'\\b(?:' + [...new Set(Object.values(SENTINEL))].sort((a, b) => b.length - a.length).map(escapeRe).join('|') + ')\\b',
|
||||
'\\[(?=[a-z0-9-]+\\] impact=)'].join('|'), 'g');
|
||||
|
||||
/**
|
||||
* Break any sentinel, fence marker, or group header that appears INSIDE
|
||||
* engine-derived text, so page content echoed through a finding cannot close
|
||||
* the untrusted envelope or forge a probe line. Inserts a zero-width space after
|
||||
* the first character (the same technique browse/src/content-security.ts uses
|
||||
* for its markers). One precompiled alternation: this runs on four fields of
|
||||
* every kept finding.
|
||||
*/
|
||||
export function neutralizeSentinels(s: string): string {
|
||||
return s.replace(NEUTRALIZE_RE, m => m[0] + '\u200b' + m.slice(1));
|
||||
}
|
||||
|
||||
|
||||
export interface NormalizedFinding {
|
||||
/** catalog id (equals impeccableId when mapped; the engine's id, sanitized, when not) */
|
||||
id: string;
|
||||
impeccableId: string;
|
||||
file: string;
|
||||
line: number;
|
||||
snippet: string;
|
||||
value?: string;
|
||||
message: string;
|
||||
category: string;
|
||||
kind: 'slop' | 'quality' | 'unknown';
|
||||
impact: 'high' | 'medium' | 'polish';
|
||||
tier: 'auto-fix' | 'ask' | 'possible';
|
||||
handoff?: string;
|
||||
advisory: boolean;
|
||||
unmapped?: true;
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
schemaVersion: 1;
|
||||
engine: string;
|
||||
engineVersion: string;
|
||||
targets: number;
|
||||
/** engine exit code after precedence (1 over 2 over 0) */
|
||||
exit: number;
|
||||
total: number;
|
||||
counted: number;
|
||||
advisory: number;
|
||||
/** rule ids the project config ignores (never present in findings) */
|
||||
ignoredRules: string[];
|
||||
byRule: Record<string, number>;
|
||||
findings: NormalizedFinding[];
|
||||
truncated: boolean;
|
||||
diagnostics: string[];
|
||||
/** JSON paths whose text is engine- and page-derived: evidence, never instructions (the stderr block carries the fence; this document carries the list) */
|
||||
untrusted: readonly string[];
|
||||
}
|
||||
|
||||
export const SCAN_UNTRUSTED_PATHS = ['findings[].file', 'findings[].snippet', 'findings[].message', 'findings[].value', 'diagnostics[]'] as const;
|
||||
|
||||
/** The bash a skill renders after a scan so exit 2 (findings) never aborts the block. */
|
||||
export const DETECT_EXIT_ECHO = `; echo "${SENTINEL.DETECT_EXIT_CODE}=$?"`;
|
||||
@@ -0,0 +1,541 @@
|
||||
// lib/design-md.ts — read and write DESIGN.md in the open DESIGN.md format.
|
||||
//
|
||||
// Implements the DESIGN.md specification (google-labs-code/design.md, Google LLC,
|
||||
// Apache-2.0): YAML front matter carrying the design tokens, a markdown body in
|
||||
// eight canonical `##` sections. See NOTICE.md. Pure module: no I/O, no imports
|
||||
// from scripts/; bin/gstack-design-md.ts and design/src/memory.ts do the file work.
|
||||
//
|
||||
// text ──► parseDesignMd ──► DesignMdDoc { frontmatterText (bytes preserved), frontmatter, marker,
|
||||
// preamble, sections[] }
|
||||
// ──► detectFormat ──► spec | legacy | unknown | missing (+ reason)
|
||||
// ──► convertLegacy ──► gstack's pre-spec DESIGN.md (Product Context, Aesthetic Direction,
|
||||
// Typography, Color, Spacing, Layout, Motion, Decisions Log) becomes
|
||||
// tokens + canonical sections; Motion and Decisions Log survive as extras
|
||||
// ──► upsertSection ──► body-only splice on the parsed doc (files gstack writes from scratch)
|
||||
// ──► renderDesignMd ──► marker, front matter, preamble, canonical sections in order, extras
|
||||
// text ──► spliceSection / insertMarker ──► text-level edits of a file the USER owns: one section
|
||||
// body or one marker line changes; the BOM, the majority
|
||||
// line ending, and every other line survive (the `mark`
|
||||
// verb, the design binary's extraction section). A file
|
||||
// with an unclosed fence is refused (DesignMdEditRefused).
|
||||
// ──► tokensFlat ──► "colors.primary" → "#F59E0B"; {path} refs resolved to primitives
|
||||
//
|
||||
// Format marker (the user's one-time conversion answer, persisted in the file):
|
||||
// spec files: line 1 `---`, line 2 `# gstack: design-md-format=spec` (a YAML comment, so
|
||||
// parsers that require `---` on line 1 keep working)
|
||||
// legacy files: line 1 `<!-- gstack: design-md-format=legacy-keep -->`
|
||||
|
||||
import { SENTINEL } from './design-detect-contract';
|
||||
|
||||
export const CANONICAL_SECTIONS = [
|
||||
'Overview', 'Colors', 'Typography', 'Layout', 'Elevation & Depth', 'Shapes', 'Components', "Do's and Don'ts",
|
||||
] as const;
|
||||
export type CanonicalSection = (typeof CANONICAL_SECTIONS)[number];
|
||||
|
||||
/** Spec aliases (and a few punctuation variants) → canonical heading. */
|
||||
export const SECTION_ALIASES: Record<string, CanonicalSection> = {
|
||||
'brand & style': 'Overview',
|
||||
'brand and style': 'Overview',
|
||||
'layout & spacing': 'Layout',
|
||||
'layout and spacing': 'Layout',
|
||||
'elevation': 'Elevation & Depth',
|
||||
'elevation and depth': 'Elevation & Depth',
|
||||
"do's and don'ts": "Do's and Don'ts",
|
||||
'dos and donts': "Do's and Don'ts",
|
||||
"do’s and don’ts": "Do's and Don'ts",
|
||||
};
|
||||
|
||||
export const TOKEN_GROUPS = ['colors', 'typography', 'rounded', 'spacing', 'components'] as const;
|
||||
export type TokenGroup = (typeof TOKEN_GROUPS)[number];
|
||||
|
||||
export const FORMAT_MARKER_PREFIX = 'gstack: design-md-format=';
|
||||
export const FORMAT_CHOICES = ['spec', 'legacy-keep'] as const;
|
||||
export type FormatChoice = (typeof FORMAT_CHOICES)[number];
|
||||
const MARKER_RE_BODY = FORMAT_MARKER_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(' + FORMAT_CHOICES.join('|') + ')';
|
||||
/** `<!-- gstack: design-md-format=... -->` on line 1 (legacy files) */
|
||||
const LEGACY_MARKER_RE = new RegExp('^<!--[ \\t]*' + MARKER_RE_BODY + '[ \\t]*-->\\n?');
|
||||
/** `# gstack: design-md-format=...` as a YAML comment inside the front matter (spec files) */
|
||||
// `[ \\t]*$`, never `\\s*$`: a multi-line match would swallow the blank line after the marker.
|
||||
const YAML_MARKER_RE = new RegExp('^# ' + MARKER_RE_BODY + '[ \\t]*$', 'm');
|
||||
/** The marker line inside front matter, newline included (renderDesignMd drops it before re-emitting the marker itself). */
|
||||
const YAML_MARKER_LINE_RE = new RegExp(YAML_MARKER_RE.source + '\\n', 'm');
|
||||
/** A front matter opener immediately followed by the marker line (insertMarker replaces the old choice). */
|
||||
const FRONTMATTER_OPEN_WITH_MARKER_RE = new RegExp('^---\\n' + YAML_MARKER_RE.source.replace(/^\^/, '') + '\\n', 'm');
|
||||
/** Maximum `{path}` reference hops before a chain counts as a cycle. */
|
||||
export const TOKEN_REF_MAX_HOPS = 8;
|
||||
/** Headings that mark gstack's pre-spec file by themselves (either one is enough evidence of a legacy shape). */
|
||||
export const LEGACY_IDENTITY_HEADINGS = ['Product Context', 'Aesthetic Direction'] as const;
|
||||
export type DesignMdFormat = 'spec' | 'legacy' | 'unknown' | 'missing';
|
||||
/** Machine-readable reason for an `unknown` (or `missing`) verdict; `reason` is the prose. */
|
||||
export type FormatCode = 'spec' | 'legacy' | 'missing' | 'frontmatter-unparsable' | 'ambiguous' | 'no-token-groups' | 'no-shape';
|
||||
|
||||
/** Headings that identify gstack's pre-spec DESIGN.md. */
|
||||
export const LEGACY_HEADINGS = [...LEGACY_IDENTITY_HEADINGS, 'Color', 'Spacing', 'Decisions Log'];
|
||||
|
||||
export interface Section {
|
||||
heading: string;
|
||||
/** canonical name when the heading (or an alias) is one of the eight */
|
||||
canonical?: CanonicalSection;
|
||||
/** body text between this heading and the next `##`, without the trailing blank run */
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface DesignMdDoc {
|
||||
/** raw YAML between the fences, bytes preserved (null when no front matter) */
|
||||
frontmatterText: string | null;
|
||||
/** parsed YAML (null when absent or unparsable) */
|
||||
frontmatter: Record<string, unknown> | null;
|
||||
frontmatterError?: string;
|
||||
marker: FormatChoice | null;
|
||||
/** text between the front matter (or file start) and the first `##` heading, trimmed */
|
||||
preamble: string;
|
||||
sections: Section[];
|
||||
}
|
||||
|
||||
// ── Parsing ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function canonicalFor(heading: string): CanonicalSection | undefined {
|
||||
const key = heading.trim().toLowerCase();
|
||||
const direct = CANONICAL_SECTIONS.find(c => c.toLowerCase() === key);
|
||||
return direct ?? SECTION_ALIASES[key];
|
||||
}
|
||||
|
||||
function parseYaml(text: string): { value: Record<string, unknown> | null; error?: string } {
|
||||
try {
|
||||
const v = (Bun as unknown as { YAML: { parse(s: string): unknown } }).YAML.parse(text);
|
||||
if (v === null || v === undefined) return { value: {} };
|
||||
if (typeof v !== 'object' || Array.isArray(v)) return { value: null, error: 'front matter is not a mapping' };
|
||||
return { value: v as Record<string, unknown> };
|
||||
} catch (e) {
|
||||
return { value: null, error: (e as Error).message.split('\n')[0].slice(0, 200) };
|
||||
}
|
||||
}
|
||||
|
||||
/** A UTF-8 byte-order mark (Windows editors write one); text-level editors keep it at byte 0. */
|
||||
const BOM = '\uFEFF';
|
||||
|
||||
export function parseDesignMd(text: string): DesignMdDoc {
|
||||
const src = text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
|
||||
let rest = src;
|
||||
let marker: FormatChoice | null = null;
|
||||
let frontmatterText: string | null = null;
|
||||
let frontmatter: Record<string, unknown> | null = null;
|
||||
let frontmatterError: string | undefined;
|
||||
|
||||
const legacyMarker = rest.match(LEGACY_MARKER_RE);
|
||||
if (legacyMarker) {
|
||||
marker = legacyMarker[1] as FormatChoice;
|
||||
rest = rest.slice(legacyMarker[0].length);
|
||||
}
|
||||
if (rest.startsWith('---\n')) {
|
||||
// The closing fence is a whole line of `---` (trailing spaces allowed, an editor artifact); a value line like `---x` is not one.
|
||||
const close = /^---[ \t]*$/m.exec(rest.slice(4));
|
||||
if (close) {
|
||||
frontmatterText = rest.slice(4, 4 + close.index);
|
||||
const m = frontmatterText.match(YAML_MARKER_RE);
|
||||
if (m) marker = m[1] as FormatChoice;
|
||||
const parsed = parseYaml(frontmatterText);
|
||||
frontmatter = parsed.value;
|
||||
frontmatterError = parsed.error;
|
||||
rest = rest.slice(4 + close.index + close[0].length + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const lines = rest.split('\n');
|
||||
const { heads } = headingLines(lines);
|
||||
const preambleLines = lines.slice(0, heads[0]?.index ?? lines.length);
|
||||
const sections: Section[] = heads.map((h, k) => {
|
||||
const canonical = canonicalFor(h.heading);
|
||||
const body = lines.slice(h.index + 1, heads[k + 1]?.index ?? lines.length).join('\n').replace(/\s+$/, '');
|
||||
return { heading: h.heading, ...(canonical ? { canonical } : {}), body };
|
||||
});
|
||||
return { frontmatterText, frontmatter, frontmatterError, marker, preamble: preambleLines.join('\n').trim(), sections };
|
||||
}
|
||||
|
||||
/**
|
||||
* The `## ` headings of a body, with code fences skipped: the one section-
|
||||
* boundary rule, shared by parseDesignMd and spliceSection so they cannot drift.
|
||||
* Markdown semantics: an unclosed fence runs to the end of the file, so nothing
|
||||
* after it is a heading. Readers accept that; spliceSection refuses to edit such
|
||||
* a file (`unclosedFence`), because "which section" is ambiguous there.
|
||||
*/
|
||||
function headingLines(lines: string[]): { heads: Array<{ index: number; heading: string }>; unclosedFence: boolean } {
|
||||
const heads: Array<{ index: number; heading: string }> = [];
|
||||
let fence: string | null = null; // the opener's characters (``` or ~~~); only the same kind closes it
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const f = lines[i].match(/^(```|~~~)/);
|
||||
if (f && fence === null) { fence = f[1]; continue; }
|
||||
if (f && fence === f[1]) { fence = null; continue; }
|
||||
if (fence !== null) continue;
|
||||
const h = lines[i].match(/^## (.+?)\s*$/);
|
||||
if (h) heads.push({ index: i, heading: h[1] });
|
||||
}
|
||||
return { heads, unclosedFence: fence !== null };
|
||||
}
|
||||
|
||||
/** Thrown by the text-level editors when the file cannot be edited safely (an unclosed code fence). The bins print it as DESIGN_MD_EDIT_REFUSED and leave the file unchanged. */
|
||||
export class DesignMdEditRefused extends Error {
|
||||
constructor(reason: string) { super(`${SENTINEL.DESIGN_MD_EDIT_REFUSED}: ${reason}; file unchanged`); this.name = 'DesignMdEditRefused'; }
|
||||
}
|
||||
|
||||
/** Does a section heading name the requested section? By canonical name when the request has one, else by exact (case-insensitive) heading. */
|
||||
function headingMatches(heading: string, wanted: string, canonical: CanonicalSection | null): boolean {
|
||||
return canonical ? canonicalFor(heading) === canonical : heading.trim().toLowerCase() === wanted.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** The file's majority line ending; text-level editors restore it so a CRLF file stays CRLF (a lone stray CRLF in an LF file does not flip the file). */
|
||||
function eolOf(text: string): string {
|
||||
const crlf = (text.match(/\r\n/g) ?? []).length;
|
||||
const lf = (text.match(/\n/g) ?? []).length - crlf;
|
||||
return crlf > lf ? '\r\n' : '\n';
|
||||
}
|
||||
|
||||
// ── Format detection ─────────────────────────────────────────────────────────
|
||||
|
||||
export function isLegacyGstackFormat(doc: DesignMdDoc): boolean {
|
||||
const headings = new Set(doc.sections.map(s => s.heading.trim().toLowerCase()));
|
||||
const hits = LEGACY_HEADINGS.filter(h => headings.has(h.toLowerCase())).length;
|
||||
return doc.frontmatterText === null && hits >= 2;
|
||||
}
|
||||
|
||||
export function hasSpecFrontmatter(doc: DesignMdDoc): boolean {
|
||||
if (!doc.frontmatter) return false;
|
||||
return TOKEN_GROUPS.some(g => g in doc.frontmatter!) || 'name' in doc.frontmatter;
|
||||
}
|
||||
|
||||
export function detectFormat(doc: DesignMdDoc | null): { format: DesignMdFormat; code: FormatCode; reason?: string } {
|
||||
if (!doc) return { format: 'missing', code: 'missing' };
|
||||
if (doc.frontmatterText !== null && doc.frontmatter === null) {
|
||||
return { format: 'unknown', code: 'frontmatter-unparsable', reason: `front matter does not parse: ${doc.frontmatterError ?? 'unknown error'}` };
|
||||
}
|
||||
const spec = hasSpecFrontmatter(doc);
|
||||
const identity = new Set<string>(LEGACY_IDENTITY_HEADINGS.map(h => h.toLowerCase()));
|
||||
const legacyHeadings = doc.sections.some(s => identity.has(s.heading.trim().toLowerCase()));
|
||||
if (spec && legacyHeadings) return { format: 'unknown', code: 'ambiguous', reason: 'ambiguous (legacy headings and front matter both present)' };
|
||||
if (spec) return { format: 'spec', code: 'spec' };
|
||||
if (isLegacyGstackFormat(doc)) return { format: 'legacy', code: 'legacy' };
|
||||
if (doc.frontmatterText !== null) return { format: 'unknown', code: 'no-token-groups', reason: 'front matter carries none of the five token groups' };
|
||||
return { format: 'unknown', code: 'no-shape', reason: 'no front matter and no gstack legacy headings' };
|
||||
}
|
||||
|
||||
// ── YAML block emitter ───────────────────────────────────────────────────────
|
||||
|
||||
function needsQuotes(s: string): boolean {
|
||||
// Control characters (an LLM-extracted font family with an embedded newline) must
|
||||
// go through the double-quoted form: a bare multi-line scalar does not parse back.
|
||||
// `\s#` too: a plain scalar ending in ` #F59E0B` would parse back as a comment. YAML 1.2 also
|
||||
// reads 0x1F / 0o17 / .inf / .nan as numbers, so those shapes are quoted as well.
|
||||
return s === '' || /[\x00-\x1f\x7f]/.test(s) || /^[\s#&*!|>'"%@`{[\]},:?-]|[:#]\s|\s#|\s$|^(true|false|null|yes|no|on|off|~)$|^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?$|^0[xob][0-9a-f_]+$|^[-+]?\.(inf|nan)$/i.test(s);
|
||||
}
|
||||
|
||||
function yamlScalar(v: unknown): string {
|
||||
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
||||
if (v === null || v === undefined) return '""';
|
||||
const s = String(v);
|
||||
return needsQuotes(s) ? JSON.stringify(s) : s;
|
||||
}
|
||||
|
||||
/** Block-style YAML for nested mappings of scalars (Bun.YAML.stringify emits flow style). */
|
||||
export function emitYamlBlock(obj: Record<string, unknown>, indent = 0): string {
|
||||
const pad = ' '.repeat(indent);
|
||||
const out: string[] = [];
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const key = needsQuotes(k) ? JSON.stringify(k) : k;
|
||||
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
||||
out.push(`${pad}${key}:`);
|
||||
out.push(emitYamlBlock(v as Record<string, unknown>, indent + 2));
|
||||
} else if (Array.isArray(v)) {
|
||||
out.push(`${pad}${key}:`);
|
||||
for (const item of v) {
|
||||
if (item !== null && typeof item === 'object') throw new TypeError('emitYamlBlock: array items must be scalars (a nested object would be written as "[object Object]")');
|
||||
out.push(`${pad} - ${yamlScalar(item)}`);
|
||||
}
|
||||
} else {
|
||||
out.push(`${pad}${key}: ${yamlScalar(v)}`);
|
||||
}
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RenderOptions {
|
||||
/** emit fresh front matter from `frontmatter` instead of the preserved bytes (convert only) */
|
||||
emitFrontmatter?: boolean;
|
||||
}
|
||||
|
||||
export function renderDesignMd(doc: DesignMdDoc, opts: RenderOptions = {}): string {
|
||||
const parts: string[] = [];
|
||||
const fm = opts.emitFrontmatter && doc.frontmatter ? emitYamlBlock(doc.frontmatter) + '\n' : doc.frontmatterText;
|
||||
if (fm !== null) {
|
||||
const body = fm.replace(YAML_MARKER_LINE_RE, '');
|
||||
parts.push('---');
|
||||
if (doc.marker) parts.push(`# ${FORMAT_MARKER_PREFIX}${doc.marker}`);
|
||||
parts.push(body.replace(/\n$/, ''));
|
||||
parts.push('---');
|
||||
if (doc.preamble) parts.push('', doc.preamble);
|
||||
} else {
|
||||
if (doc.marker) parts.push(`<!-- ${FORMAT_MARKER_PREFIX}${doc.marker} -->`);
|
||||
if (doc.preamble) parts.push(doc.preamble);
|
||||
}
|
||||
// Spec order is a spec-file property. A legacy or unknown file keeps its own
|
||||
// order (Typography and Layout are canonical names, but re-sorting a file the
|
||||
// user chose to keep legacy would rewrite it behind their back).
|
||||
const specShaped = fm !== null;
|
||||
const ordered = specShaped
|
||||
? [
|
||||
...CANONICAL_SECTIONS.map(c => doc.sections.find(s => s.canonical === c)).filter((s): s is Section => Boolean(s)),
|
||||
...doc.sections.filter(s => !s.canonical),
|
||||
]
|
||||
: doc.sections;
|
||||
for (const s of ordered) {
|
||||
parts.push('', `## ${specShaped ? (s.canonical ?? s.heading) : s.heading}`);
|
||||
if (s.body.trim()) parts.push('', s.body.trim());
|
||||
}
|
||||
return parts.join('\n').replace(/^\n+/, '') + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Text-level section splice: replace the body of `## <heading>` (matched by
|
||||
* canonical name or exact heading) or append the section at the end. Every other
|
||||
* byte of the file, front matter included, is untouched. This is what a tool
|
||||
* that edits a file the user owns should use; renderDesignMd is for files gstack
|
||||
* writes from scratch (convert, skeletons).
|
||||
*/
|
||||
export function spliceSection(text: string, heading: string, body: string): string {
|
||||
const bom = text.startsWith(BOM) ? BOM : '';
|
||||
const eol = eolOf(text);
|
||||
const src = text.slice(bom.length).replace(/\r\n/g, '\n');
|
||||
const canonical = canonicalFor(heading);
|
||||
const lines = src.split('\n');
|
||||
const { heads, unclosedFence } = headingLines(lines);
|
||||
if (unclosedFence) throw new DesignMdEditRefused('unclosed code fence (```) makes the section boundaries ambiguous');
|
||||
const k = heads.findIndex(h => headingMatches(h.heading, heading, canonical));
|
||||
const block = `## ${canonical ?? heading}\n\n${body.replace(/\s+$/, '')}\n`;
|
||||
let out: string;
|
||||
if (k === -1) {
|
||||
out = src.replace(/\s*$/, '') + '\n\n' + block;
|
||||
} else {
|
||||
const start = heads[k].index;
|
||||
const end = heads[k + 1]?.index ?? lines.length;
|
||||
const before = lines.slice(0, start).join('\n');
|
||||
const after = lines.slice(end).join('\n');
|
||||
out = before + (before ? '\n' : '') + block + (after.trim() ? '\n' + after.replace(/^\n+/, '') : '');
|
||||
}
|
||||
return bom + (eol === '\n' ? out : out.replace(/\n/g, eol));
|
||||
}
|
||||
|
||||
/**
|
||||
* Text-level marker insertion: a YAML comment on line 2 of a file that opens
|
||||
* with front matter, an HTML comment on line 1 otherwise. Replaces an existing
|
||||
* marker; every other byte is untouched.
|
||||
*/
|
||||
export function insertMarker(text: string, choice: FormatChoice): string {
|
||||
const bom = text.startsWith(BOM) ? BOM : '';
|
||||
const eol = eolOf(text);
|
||||
const src = text.slice(bom.length).replace(/\r\n/g, '\n');
|
||||
const stripped = src.replace(LEGACY_MARKER_RE, '');
|
||||
let out: string;
|
||||
// Front matter, not "starts with ---": a legacy file opening with a horizontal rule gets the HTML comment.
|
||||
if (parseDesignMd(stripped).frontmatterText !== null) {
|
||||
const withoutOld = stripped.replace(FRONTMATTER_OPEN_WITH_MARKER_RE, '---\n');
|
||||
out = withoutOld.replace(/^---\n/, `---\n# ${FORMAT_MARKER_PREFIX}${choice}\n`);
|
||||
} else {
|
||||
out = `<!-- ${FORMAT_MARKER_PREFIX}${choice} -->\n` + stripped;
|
||||
}
|
||||
return bom + (eol === '\n' ? out : out.replace(/\n/g, eol));
|
||||
}
|
||||
|
||||
/** Replace or add a section; canonical names slot into spec order, extras append. Body-only: front matter bytes untouched. */
|
||||
export function upsertSection(doc: DesignMdDoc, heading: string, body: string): DesignMdDoc {
|
||||
const canonical = canonicalFor(heading);
|
||||
const sections = doc.sections.map(s => ({ ...s }));
|
||||
const idx = sections.findIndex(s => headingMatches(s.heading, heading, canonical));
|
||||
const next: Section = { heading: canonical ?? heading, ...(canonical ? { canonical } : {}), body: body.replace(/\s+$/, '') };
|
||||
if (idx >= 0) sections[idx] = next; else sections.push(next);
|
||||
return { ...doc, sections };
|
||||
}
|
||||
|
||||
// ── Tokens ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FlatTokens {
|
||||
tokens: Record<string, string>;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/** Flatten the five token groups to dotted paths; resolve `{path}` references to primitives. */
|
||||
export function tokensFlat(frontmatter: Record<string, unknown> | null): FlatTokens {
|
||||
const tokens: Record<string, string> = {};
|
||||
const errors: string[] = [];
|
||||
if (!frontmatter) return { tokens, errors };
|
||||
const raw: Record<string, unknown> = {};
|
||||
const walk = (prefix: string, v: unknown) => {
|
||||
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
||||
for (const [k, x] of Object.entries(v as Record<string, unknown>)) walk(prefix ? `${prefix}.${k}` : k, x);
|
||||
} else if (v !== null && v !== undefined && !Array.isArray(v)) {
|
||||
raw[prefix] = v;
|
||||
}
|
||||
};
|
||||
for (const g of TOKEN_GROUPS) if (g in frontmatter) walk(g, frontmatter[g]);
|
||||
const groups = new Set(Object.keys(raw).map(k => k.split('.').slice(0, -1).join('.')).filter(Boolean));
|
||||
for (const [k, v] of Object.entries(raw)) {
|
||||
const s = String(v);
|
||||
const ref = s.match(/^\{([a-zA-Z0-9_.-]+)\}$/);
|
||||
if (!ref) { tokens[k] = s; continue; }
|
||||
const target = ref[1];
|
||||
if (target === k) { errors.push(`${SENTINEL.DESIGN_MD_TOKEN_REF_INVALID}: {${target}} (self-reference)`); continue; }
|
||||
if (groups.has(target) || TOKEN_GROUPS.includes(target as TokenGroup)) { errors.push(`${SENTINEL.DESIGN_MD_TOKEN_REF_INVALID}: {${target}} (refers to a group, not a primitive)`); continue; }
|
||||
let seen = 0;
|
||||
let cur: unknown = raw[target];
|
||||
let curKey = target;
|
||||
while (typeof cur === 'string' && /^\{[a-zA-Z0-9_.-]+\}$/.test(cur) && seen < TOKEN_REF_MAX_HOPS) {
|
||||
curKey = cur.slice(1, -1);
|
||||
cur = raw[curKey];
|
||||
seen++;
|
||||
}
|
||||
if (cur === undefined) { errors.push(`${SENTINEL.DESIGN_MD_TOKEN_REF_INVALID}: {${target}} (no such token)`); continue; }
|
||||
if (typeof cur === 'string' && /^\{/.test(cur)) { errors.push(`${SENTINEL.DESIGN_MD_TOKEN_REF_INVALID}: {${target}} (reference cycle)`); continue; }
|
||||
tokens[k] = String(cur);
|
||||
}
|
||||
return { tokens, errors };
|
||||
}
|
||||
|
||||
// ── Legacy conversion ────────────────────────────────────────────────────────
|
||||
|
||||
/** kebab-case token key from a human label */
|
||||
export function slug(s: string): string {
|
||||
return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'token';
|
||||
}
|
||||
|
||||
/** Legacy Color bullets whose label names a strategy or a mode, not a color. */
|
||||
const NOT_COLOR_LABELS = new Set(['approach', 'semantic', 'dark mode', 'light mode', 'neutrals', 'contrast', 'strategy']);
|
||||
|
||||
function bullets(body: string): Array<{ key: string; value: string }> {
|
||||
const out: Array<{ key: string; value: string }> = [];
|
||||
for (const line of body.split('\n')) {
|
||||
const m = line.match(/^\s*-\s+\*\*(.+?):?\*\*:?\s*(.*)$/);
|
||||
if (m) out.push({ key: m[1].trim().replace(/:$/, ''), value: m[2].trim() });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const HEX = /#[0-9a-fA-F]{3,8}\b/;
|
||||
|
||||
function sectionBody(doc: DesignMdDoc, heading: string): string | undefined {
|
||||
return doc.sections.find(s => s.heading.trim().toLowerCase() === heading.toLowerCase())?.body;
|
||||
}
|
||||
|
||||
function firstFontName(value: string): string | undefined {
|
||||
const m = value.match(/^([A-Z][A-Za-z0-9 ]+?)(?:\s*\(|\s+—|\s+-\s|,|$)/);
|
||||
return m ? m[1].trim() : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert gstack's pre-spec DESIGN.md into the open format. Product Context and
|
||||
* Aesthetic Direction fold into Overview; Typography roles become
|
||||
* typography.display/body/label/mono; Color hexes become colors; the Spacing
|
||||
* scale becomes spacing; the Layout border radii become rounded; everything else
|
||||
* (Motion, Decisions Log, Grain Texture, ...) survives as an extra section in
|
||||
* its original order. Idempotent: converting the render again changes nothing.
|
||||
*/
|
||||
export function convertLegacy(doc: DesignMdDoc, opts: { name?: string } = {}): DesignMdDoc {
|
||||
// A heading the conversion consumes must be unique, or a second body would be silently dropped.
|
||||
const counts = new Map<string, number>();
|
||||
for (const s of doc.sections) counts.set(s.heading.trim().toLowerCase(), (counts.get(s.heading.trim().toLowerCase()) ?? 0) + 1);
|
||||
for (const h of [...LEGACY_HEADINGS, 'Typography', 'Layout', 'Colors']) {
|
||||
if ((counts.get(h.toLowerCase()) ?? 0) > 1) throw new DesignMdEditRefused(`legacy heading "## ${h}" appears more than once`);
|
||||
}
|
||||
if (counts.has('color') && counts.has('colors')) throw new DesignMdEditRefused('both "## Color" and "## Colors" are present');
|
||||
const title = doc.preamble.match(/^#\s+(.+)$/m)?.[1]?.trim();
|
||||
const name = opts.name ?? (title ? title.replace(/^Design System\s*[—–-]\s*/i, '').trim() : 'Design System');
|
||||
const fm: Record<string, unknown> = { name };
|
||||
|
||||
const overview: string[] = [];
|
||||
const product = sectionBody(doc, 'Product Context');
|
||||
const aesthetic = sectionBody(doc, 'Aesthetic Direction');
|
||||
if (product) overview.push(product.trim());
|
||||
if (aesthetic) overview.push(aesthetic.trim());
|
||||
|
||||
// Typography
|
||||
const typo = sectionBody(doc, 'Typography');
|
||||
const typography: Record<string, Record<string, string>> = {};
|
||||
if (typo) {
|
||||
const roleMap: Array<[RegExp, string]> = [
|
||||
[/^display/i, 'display'], [/^hero/i, 'display'], [/^body/i, 'body'], [/^ui/i, 'label'], [/^label/i, 'label'],
|
||||
[/^data/i, 'mono'], [/^code/i, 'mono'], [/^mono/i, 'mono'],
|
||||
];
|
||||
for (const b of bullets(typo)) {
|
||||
const role = roleMap.find(([re]) => re.test(b.key))?.[1];
|
||||
if (!role || typography[role]) continue;
|
||||
if (/same as/i.test(b.value)) { const src = b.value.match(/same as (\w+)/i)?.[1]?.toLowerCase(); if (src && typography[src]) typography[role] = { ...typography[src] }; continue; }
|
||||
const family = firstFontName(b.value);
|
||||
if (!family) continue;
|
||||
const t: Record<string, string> = { fontFamily: family };
|
||||
if (role === 'mono') t.fontFeature = 'tnum';
|
||||
typography[role] = t;
|
||||
}
|
||||
}
|
||||
if (Object.keys(typography).length) fm.typography = typography;
|
||||
|
||||
// Colors
|
||||
const color = sectionBody(doc, 'Color') ?? sectionBody(doc, 'Colors');
|
||||
const colors: Record<string, string> = {};
|
||||
if (color) {
|
||||
for (const line of color.split('\n')) {
|
||||
const hex = line.match(HEX)?.[0];
|
||||
if (!hex) continue;
|
||||
const label = (line.match(/\*\*(.+?):?\*\*/)?.[1] ?? line.match(/^\s*-\s*([^:]+):/)?.[1])?.replace(/:$/, '').trim();
|
||||
if (!label || NOT_COLOR_LABELS.has(label.toLowerCase())) continue;
|
||||
const key = slug(label);
|
||||
if (!(key in colors)) colors[key] = hex;
|
||||
}
|
||||
// semantic line: "success #22C55E, warning #F59E0B, ..."
|
||||
const semantic = color.match(/\*\*Semantic:\*\*\s*(.+)$/m)?.[1];
|
||||
if (semantic) for (const m of semantic.matchAll(/([a-z]+)\s+(#[0-9a-fA-F]{3,8})/g)) if (!(m[1] in colors)) colors[m[1]] = m[2];
|
||||
}
|
||||
if (Object.keys(colors).length) fm.colors = colors;
|
||||
|
||||
// Spacing scale "2xs(2px) xs(4px) ..."
|
||||
const spacingBody = sectionBody(doc, 'Spacing');
|
||||
const spacing: Record<string, string> = {};
|
||||
if (spacingBody) {
|
||||
const scale = spacingBody.match(/\*\*Scale:\*\*\s*(.+)$/m)?.[1];
|
||||
if (scale) for (const m of scale.matchAll(/([0-9a-z]+)\(([^)]+)\)/g)) spacing[m[1]] = /px|rem|em$/.test(m[2]) ? m[2] : `${m[2]}px`;
|
||||
}
|
||||
if (Object.keys(spacing).length) fm.spacing = spacing;
|
||||
|
||||
// Border radius "sm:4px, md:8px, lg:12px, full:9999px"
|
||||
const layoutBody = sectionBody(doc, 'Layout');
|
||||
const rounded: Record<string, string> = {};
|
||||
if (layoutBody) {
|
||||
const radius = layoutBody.match(/\*\*Border radius:\*\*\s*(.+)$/m)?.[1];
|
||||
if (radius) for (const m of radius.matchAll(/([a-z0-9]+):\s*([0-9.]+(?:px|rem|em))/g)) rounded[m[1]] = m[2];
|
||||
}
|
||||
if (Object.keys(rounded).length) fm.rounded = rounded;
|
||||
|
||||
const consumed = new Set([...LEGACY_IDENTITY_HEADINGS.map(h => h.toLowerCase()), 'typography', 'color', 'colors', 'spacing', 'layout']);
|
||||
const sections: Section[] = [];
|
||||
sections.push({ heading: 'Overview', canonical: 'Overview', body: overview.join('\n\n') || '(no product context recorded)' });
|
||||
if (color) sections.push({ heading: 'Colors', canonical: 'Colors', body: color.trim() });
|
||||
if (typo) sections.push({ heading: 'Typography', canonical: 'Typography', body: typo.trim() });
|
||||
const layoutParts = [layoutBody?.trim(), spacingBody ? `### Spacing\n${spacingBody.trim()}` : undefined].filter(Boolean) as string[];
|
||||
if (layoutParts.length) sections.push({ heading: 'Layout', canonical: 'Layout', body: layoutParts.join('\n\n') });
|
||||
for (const s of doc.sections) {
|
||||
if (consumed.has(s.heading.trim().toLowerCase())) continue;
|
||||
sections.push(s.canonical ? { ...s } : { heading: s.heading, body: s.body });
|
||||
}
|
||||
return {
|
||||
frontmatterText: emitYamlBlock(fm) + '\n',
|
||||
frontmatter: fm,
|
||||
marker: 'spec',
|
||||
preamble: doc.preamble, // the title line and any intro prose under it survive verbatim
|
||||
sections,
|
||||
};
|
||||
}
|
||||
|
||||
/** A minimal spec-format document (used when a tool must create DESIGN.md from scratch). */
|
||||
export function specSkeleton(name: string, frontmatter: Record<string, unknown>, sections: Array<{ heading: string; body: string }>): DesignMdDoc {
|
||||
const fm = { name, ...frontmatter };
|
||||
const doc: DesignMdDoc = { frontmatterText: emitYamlBlock(fm) + '\n', frontmatter: fm, marker: 'spec', preamble: `# ${name}`, sections: [] };
|
||||
return sections.reduce((d, s) => upsertSection(d, s.heading, s.body), doc);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// lib/dom-dump-script.ts — the rendered-DOM dump script design-review evaluates
|
||||
// in the page before handing the result to the design detector.
|
||||
//
|
||||
// Pure module: no I/O, no imports from scripts/. Consumers:
|
||||
// scripts/resolvers/design.ts Phase 3 prose tells the agent to load lib/dom-dump.js
|
||||
// lib/dom-dump.js committed copy gen-skill-docs writes; the engines load it at runtime
|
||||
// test/fixtures/*.dom.html captured by running it through the browse engine
|
||||
// test/impeccable-fixtures.test.ts pins that the committed dump came from THIS script
|
||||
//
|
||||
// Contract (one script, two engines):
|
||||
// - An arrow-FUNCTION expression, never a self-calling IIFE: Aside's
|
||||
// `pg.evaluate(fn)` receives the function and runs it in the page (an IIFE
|
||||
// would execute in the repl sandbox, where there is no `document`), and the
|
||||
// fallback engine calls it with `$B js "($_DUMP)()"`. Both splice the file's
|
||||
// text into bash, so it contains NO single quotes, no backticks, and no `${`.
|
||||
// - Works on a CLONE of document.documentElement, never the live page.
|
||||
// - Inlines only the stylesheets a <link> owns (inline <style> nodes are
|
||||
// already in the markup; re-serializing them double-counts) as one
|
||||
// <style data-gstack-dom-css> in <head>, and removes each inlined <link>
|
||||
// from the clone so the static engine does not try to resolve its href
|
||||
// relative to the dump file. Cross-origin sheets throw on cssRules access,
|
||||
// stay as <link>, and are listed in the trailing HTML comment.
|
||||
// - The CSSOM serializes author hex colors as rgb(r, g, b); the engine's
|
||||
// palette rules (ai-color-palette, cream-palette, ...) match hex literals,
|
||||
// so opaque rgb() triples are folded back to #rrggbb. Verified on engine
|
||||
// 0.1.3: without this fold the DOM dump loses ai-color-palette.
|
||||
// - Hygiene before the file leaves the browser: <script> bodies emptied,
|
||||
// <input>/<textarea> values dropped, `value=` and `data-*` attributes over
|
||||
// 32 chars emptied, <meta content> emptied (charset and viewport kept: they
|
||||
// carry no user data and the viewport hint is layout-relevant), query
|
||||
// strings cut from every URL-bearing attribute (href, src, srcset, poster,
|
||||
// action, formaction, data, ping, cite: signed CDN and form URLs carry
|
||||
// tokens), data: URLs over 1 KB replaced by a placeholder in attributes, in
|
||||
// the inlined CSS, and in existing <style> nodes.
|
||||
// - The trailing comment names what the dump cannot contain (shadow DOM,
|
||||
// constructed stylesheets, runtime-injected styles when scripts were
|
||||
// stripped) so the report can say so once. <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. CSS url()
|
||||
// query strings (signed asset URLs) are cut in style attributes, <style>
|
||||
// nodes, and the inlined sheets; srcdoc is emptied.
|
||||
export const DOM_DUMP_SCRIPT = String.raw`() => {
|
||||
const root = document.documentElement.cloneNode(true);
|
||||
const head = root.querySelector("head") || root;
|
||||
const inlined = [];
|
||||
const crossOrigin = [];
|
||||
const liveLinks = Array.from(document.querySelectorAll("link"));
|
||||
const cloneLinks = Array.from(root.querySelectorAll("link"));
|
||||
liveLinks.forEach((link, i) => {
|
||||
const sheet = link.sheet;
|
||||
if (!sheet) return;
|
||||
if (link.disabled || (link.getAttribute("rel") || "").indexOf("alternate") !== -1) {
|
||||
if (cloneLinks[i]) cloneLinks[i].remove(); // not active CSS: never scanned as page styles
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let text = Array.from(sheet.cssRules).map((rule) => rule.cssText).join("\n");
|
||||
const media = sheet.media && sheet.media.mediaText;
|
||||
if (media && media !== "all") text = "@media " + media + " {\n" + text + "\n}"; // a print sheet stays a print sheet
|
||||
inlined.push("/* gstack-dom-dump: " + (sheet.href || "link") + " */\n" + text);
|
||||
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");
|
||||
const cssQuery = new RegExp("url\\(\\s*([\"\u0027]?)([^\u0027\")?#]*)[?#][^\u0027\")]*\\1\\s*\\)", "g");
|
||||
const cleanCss = (t) => t.replace(dataUrl, "url(data:,gstack-stripped)").replace(cssQuery, "url($1$2$1)");
|
||||
if (inlined.length) {
|
||||
const style = document.createElement("style");
|
||||
style.setAttribute("data-gstack-dom-css", "");
|
||||
const rgb = new RegExp("rgb\\((\\d+), (\\d+), (\\d+)\\)", "g");
|
||||
const hex = (n) => Number(n).toString(16).padStart(2, "0");
|
||||
style.textContent = cleanCss(inlined.join("\n"))
|
||||
.replace(rgb, (m, r, g, b) => "#" + hex(r) + hex(g) + hex(b));
|
||||
head.appendChild(style);
|
||||
}
|
||||
for (const el of Array.from(root.querySelectorAll("style"))) {
|
||||
if (el.getAttribute("data-gstack-dom-css") === null && el.textContent) el.textContent = cleanCss(el.textContent);
|
||||
}
|
||||
const urlAttrs = ["href", "src", "poster", "action", "formaction", "data", "ping", "cite", "background", "xlink:href"];
|
||||
const cutQuery = (v) => v.split("?")[0].split("#")[0];
|
||||
let scripts = 0;
|
||||
for (const el of Array.from(root.querySelectorAll("script"))) {
|
||||
if (el.textContent) { el.textContent = ""; scripts += 1; }
|
||||
}
|
||||
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.indexOf("on") === 0) el.removeAttribute(name);
|
||||
else if (name === "srcdoc") el.setAttribute(name, "");
|
||||
else if (name === "style") el.setAttribute(name, cleanCss(value));
|
||||
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(", "));
|
||||
else if (urlAttrs.indexOf(name) !== -1 && (value.indexOf("?") !== -1 || value.indexOf("#") !== -1) && value.indexOf("data:") !== 0) el.setAttribute(name, cutQuery(value));
|
||||
else if (value.indexOf("data:") === 0 && value.length > 1024) el.setAttribute(name, "data:,gstack-stripped");
|
||||
}
|
||||
}
|
||||
const notes = ["shadow DOM and constructed stylesheets not captured"];
|
||||
if (crossOrigin.length) notes.push("cross-origin stylesheets not resolved: " + crossOrigin.join(" "));
|
||||
if (scripts) notes.push("scripts stripped: " + scripts + "; styles injected at runtime not captured");
|
||||
return "<!DOCTYPE html>\n" + root.outerHTML + "\n<!-- gstack-dom-dump: " + notes.join("; ") + " -->\n";
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Committed copy of DOM_DUMP_SCRIPT for the browser engines to load at runtime
|
||||
* (written by gen-skill-docs, pinned byte-equal by test/impeccable-fixtures.test.ts).
|
||||
* Skills `cat` it into an Aside script or `cp` it beside `$B eval`; the prose
|
||||
* never carries the script text.
|
||||
*/
|
||||
export const DOM_DUMP_FILE = 'lib/dom-dump.js';
|
||||
|
||||
/** Marker the dump script leaves on the inlined-stylesheet node. */
|
||||
export const DOM_DUMP_STYLE_ATTR = 'data-gstack-dom-css';
|
||||
/** Prefix of the trailing HTML comment the dump script appends. */
|
||||
export const DOM_DUMP_NOTE_PREFIX = 'gstack-dom-dump:';
|
||||
@@ -0,0 +1,68 @@
|
||||
() => {
|
||||
const root = document.documentElement.cloneNode(true);
|
||||
const head = root.querySelector("head") || root;
|
||||
const inlined = [];
|
||||
const crossOrigin = [];
|
||||
const liveLinks = Array.from(document.querySelectorAll("link"));
|
||||
const cloneLinks = Array.from(root.querySelectorAll("link"));
|
||||
liveLinks.forEach((link, i) => {
|
||||
const sheet = link.sheet;
|
||||
if (!sheet) return;
|
||||
if (link.disabled || (link.getAttribute("rel") || "").indexOf("alternate") !== -1) {
|
||||
if (cloneLinks[i]) cloneLinks[i].remove(); // not active CSS: never scanned as page styles
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let text = Array.from(sheet.cssRules).map((rule) => rule.cssText).join("\n");
|
||||
const media = sheet.media && sheet.media.mediaText;
|
||||
if (media && media !== "all") text = "@media " + media + " {\n" + text + "\n}"; // a print sheet stays a print sheet
|
||||
inlined.push("/* gstack-dom-dump: " + (sheet.href || "link") + " */\n" + text);
|
||||
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");
|
||||
const cssQuery = new RegExp("url\\(\\s*([\"\u0027]?)([^\u0027\")?#]*)[?#][^\u0027\")]*\\1\\s*\\)", "g");
|
||||
const cleanCss = (t) => t.replace(dataUrl, "url(data:,gstack-stripped)").replace(cssQuery, "url($1$2$1)");
|
||||
if (inlined.length) {
|
||||
const style = document.createElement("style");
|
||||
style.setAttribute("data-gstack-dom-css", "");
|
||||
const rgb = new RegExp("rgb\\((\\d+), (\\d+), (\\d+)\\)", "g");
|
||||
const hex = (n) => Number(n).toString(16).padStart(2, "0");
|
||||
style.textContent = cleanCss(inlined.join("\n"))
|
||||
.replace(rgb, (m, r, g, b) => "#" + hex(r) + hex(g) + hex(b));
|
||||
head.appendChild(style);
|
||||
}
|
||||
for (const el of Array.from(root.querySelectorAll("style"))) {
|
||||
if (el.getAttribute("data-gstack-dom-css") === null && el.textContent) el.textContent = cleanCss(el.textContent);
|
||||
}
|
||||
const urlAttrs = ["href", "src", "poster", "action", "formaction", "data", "ping", "cite", "background", "xlink:href"];
|
||||
const cutQuery = (v) => v.split("?")[0].split("#")[0];
|
||||
let scripts = 0;
|
||||
for (const el of Array.from(root.querySelectorAll("script"))) {
|
||||
if (el.textContent) { el.textContent = ""; scripts += 1; }
|
||||
}
|
||||
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.indexOf("on") === 0) el.removeAttribute(name);
|
||||
else if (name === "srcdoc") el.setAttribute(name, "");
|
||||
else if (name === "style") el.setAttribute(name, cleanCss(value));
|
||||
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(", "));
|
||||
else if (urlAttrs.indexOf(name) !== -1 && (value.indexOf("?") !== -1 || value.indexOf("#") !== -1) && value.indexOf("data:") !== 0) el.setAttribute(name, cutQuery(value));
|
||||
else if (value.indexOf("data:") === 0 && value.length > 1024) el.setAttribute(name, "data:,gstack-stripped");
|
||||
}
|
||||
}
|
||||
const notes = ["shadow DOM and constructed stylesheets not captured"];
|
||||
if (crossOrigin.length) notes.push("cross-origin stylesheets not resolved: " + crossOrigin.join(" "));
|
||||
if (scripts) notes.push("scripts stripped: " + scripts + "; styles injected at runtime not captured");
|
||||
return "<!DOCTYPE html>\n" + root.outerHTML + "\n<!-- gstack-dom-dump: " + notes.join("; ") + " -->\n";
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// lib/frontend-scope.ts — which repo paths count as frontend.
|
||||
//
|
||||
// Pure module: no I/O, no imports from scripts/. The patterns mirror the
|
||||
// `m_frontend` arm of bin/gstack-diff-scope (the bash source of truth for
|
||||
// SCOPE_FRONTEND); test/frontend-scope.test.ts pins the two against the same
|
||||
// sample paths so they cannot drift. bin/gstack-design-detect.ts uses this to
|
||||
// derive `scan --changed <base>` targets without consuming a shell-split list.
|
||||
|
||||
const EXTENSIONS = new Set([
|
||||
'.css', '.scss', '.less', '.sass', '.pcss',
|
||||
'.tsx', '.jsx', '.vue', '.svelte', '.astro',
|
||||
'.erb', '.haml', '.slim', '.hbs', '.ejs',
|
||||
'.html',
|
||||
]);
|
||||
|
||||
// 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 {
|
||||
const rel = relPath.replace(/\\/g, '/').replace(/^\.\//, '');
|
||||
const base = rel.slice(rel.lastIndexOf('/') + 1);
|
||||
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 (!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;
|
||||
if (rel.startsWith('app/assets/stylesheets/')) return true;
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user