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:
Garry Tan
2026-09-09 05:50:27 +00:00
co-authored by OpenAI Codex
94 changed files with 9397 additions and 712 deletions
+739
View File
@@ -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;
});
}
+226
View File
@@ -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}=$?"`;
+541
View File
@@ -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",
"dos and donts": "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);
}
+123
View File
@@ -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:';
+68
View File
@@ -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";
}
+33
View File
@@ -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;
}