/** * Clone Architect — DESIGN.md Generator * * Transforme tokens.json + layout-analysis.md + raw-css.json * en un DESIGN.md narratif LLM-optimisé — format 9 sections. * * Chaque valeur vient de l'extraction Playwright réelle. * Aucune valeur inventée, aucune spéculation. * * Format inspiré de la structure getdesign.md (9 sections standard) * mais généré automatiquement depuis des données vérifiées. */ import { readFile, writeFile } from 'fs/promises'; import { join } from 'path'; import { existsSync } from 'fs'; // ─── Imports unifiés (Sprint 80/20 + RFC D) ───────────────────────────────── import { parseRgb, luminanceSimple as luminance, colorDistance, parsePixels as parsePixelsShared, normalizeRadius, } from './shared/css-helpers.js'; import { NAMED_COLORS, nameColor as nameColorRaw, createColorNamer, type NamedColor, type NamedColorResult } from './shared/named-colors.js'; /** * Local wrapper around shared nameColor that passes module-level _cssVars for brand-aware naming. * All section generators in this file get semantic CSS var resolution (--brand-primary → "Brand Primary") * automatically. Helpers buildSemanticColorMaps + buildPaletteColorOverrides run BEFORE _cssVars * is populated, so they use nameColorRaw directly to avoid circularity. */ function nameColor(color: string): NamedColorResult { return nameColorRaw(color, _cssVars); } /** Signature legacy rgbToHex(r, g, b) pour generate-design-md */ function rgbToHex(r: number, g: number, b: number): string { return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join(''); } // Factory stateful pour le naming unique (reset à chaque appel generate) const colorNamer = createColorNamer(); function nameColorUnique(color: string): NamedColorResult { return colorNamer.nameColorUnique(color, _cssVars); } // Module-level brand override map — set at start of each generateDesignMd call // Allows all section generators to produce "Rausch" instead of "Warm Rose" let _brandOverrides: Map = new Map(); // Module-level CSS custom properties — set at start of each generateDesignMd call // Passed to nameColor for brand-aware semantic naming (--brand-primary → "Brand Primary") let _cssVars: Record = {}; function resolveColorName(color: string): NamedColorResult { // nameColor (local wrapper) does brand-aware semantic resolution via _cssVars. // _brandOverrides still applied as final layer for palette-WORD short names from buildPaletteColorOverrides. const result = nameColor(color); const override = _brandOverrides.get(result.hex); return override ? { ...result, name: override } : result; } // ── Prose-friendly color naming ───────────────────────────────────── // Brand-aware naming (resolveColorName) maps `--color-text-quaternary` → "Text Quaternary", // `--color-bg-primary` → "Bg Primary". Those FUNCTIONAL ROLE names are perfect for a structured // token table, but they read robotically in editorial prose — "Body text reads in Text Primary", // "Border: Text Quaternary". getdesign.md keeps role names in its `colors:` block but uses // APPEARANCE names in the narrative ("light gray text #f7f8f8"). proseColorName does the same: // when a resolved name is composed entirely of functional-role tokens, it falls back to the // nearest-named-color appearance ("Off-Cream", "Jet Black", "Dim Gray"). Real hue/brand words // (Indigo, Rausch, "Brand Blue") are kept untouched. const ROLE_NAME_TOKENS = new Set([ // layer / position roles 'text', 'bg', 'background', 'foreground', 'fg', 'surface', 'border', 'fill', 'stroke', 'content', 'divider', 'outline', 'ring', 'elevation', 'layer', 'level', // ordinals / scale steps 'primary', 'secondary', 'tertiary', 'quaternary', 'quinary', 'senary', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', // semantic slots 'accent', 'brand', 'action', 'link', 'interactive', 'focus', 'error', 'warning', 'success', 'info', 'danger', 'alert', 'neutral', 'muted', 'subtle', 'emphasis', 'default', 'base', 'strong', 'weak', 'on', 'inverse', ]); /** True when the name carries no hue/appearance word — only functional-role tokens. */ function isGenericRoleName(name: string): boolean { const words = name.trim().toLowerCase().split(/[\s\-_]+/).filter(Boolean); return words.length > 0 && words.every(w => ROLE_NAME_TOKENS.has(w)); } /** Resolve a color name for PROSE: hue/brand names kept, robotic role names → appearance name. */ function proseColorName(color: string): NamedColorResult { const info = resolveColorName(color); if (isGenericRoleName(info.name)) { // nameColorRaw with NO cssVars = pure nearest-named-color appearance (bypasses role-var match) const appearance = nameColorRaw(color, {}); if (!isGenericRoleName(appearance.name)) return { ...info, name: appearance.name }; } return info; } /** Convenience: just the prose-friendly name string. */ function proseName(color: string): string { return proseColorName(color).name; } /** Alias local parsePixels pour préserver API interne */ const parsePixels = parsePixelsShared; // ── Semantic color maps from CSS custom properties ────────────────── // Many design systems expose their palette via --color-text-*, --color-bg-*, etc. // Mapping colors back to their semantic var names (instead of nearest-named-color) // produces dramatically more accurate naming AND filters out parasitic colors // (light-mode values in dark sites, marketing-section accents not in the design system). interface SemanticEntry { role: string; raw: string; // original CSS value, e.g. "rgba(255,255,255,0.05)" — preserves alpha } interface SemanticColorMaps { text: Map; bg: Map; accent: Map; border: Map; } function buildSemanticColorMaps(cssVars: Record): SemanticColorMaps { const text = new Map(); const bg = new Map(); const accent = new Map(); const border = new Map(); // Patterns: keyed by category, ordered by specificity // --palette-bg-*, --palette-text-*, --palette-border-* → routed to the right category first // bare --palette-* and --palette-[brand-name]-* → accent (brand colors like Rausch, Luxe, Plus) const TEXT_PATTERNS = [/^--color-(text|fg|foreground|content)-?/i, /^--text-/i, /^--fg-/i, /^--palette-(text|fg|foreground|icon|label)-/i]; const BG_PATTERNS = [/^--color-(bg|background|surface|canvas|panel|level|elev|elevation|fill)-?/i, /^--bg-/i, /^--surface-/i, /^--panel-/i, /^--palette-(bg|background|surface|fill|selected|hover|pressed|disabled)-/i]; const ACCENT_PATTERNS = [/^--color-(accent|brand|primary|interactive|action|link)-?/i, /^--accent-/i, /^--brand-/i, /^--primary-/i, /^--palette-(rausch|hof|foggy|spruce|arches|luxe|plus|babu|kazan|lima|beach|martini|navy|tiber|hackney|bobo|[a-z]+-gradient)/i]; const BORDER_PATTERNS = [/^--color-(border|divider|outline|stroke|ring)-?/i, /^--border-/i, /^--divider-/i, /^--palette-(border|divider|outline|stroke)-/i]; // Format CSS var name → human-readable role: "--color-text-secondary" → "Text Secondary" function formatRole(key: string): string { return key .replace(/^--/, '') .replace(/^color-/i, '') .split('-') .map(w => w.charAt(0).toUpperCase() + w.slice(1)) .join(' '); } for (const [key, rawValue] of Object.entries(cssVars)) { if (!rawValue || typeof rawValue !== 'string') continue; const value = rawValue.trim(); // Accept hex, rgb, rgba — skip var() chains, calc(), keywords if (!/^#[0-9a-fA-F]{3,8}$/.test(value) && !/^rgba?\(/i.test(value)) continue; // Use raw nameColor here: this helper runs BEFORE _cssVars is set, and we want to // build the semantic map FROM cssVars, not USE existing semantic mapping (chicken/egg). const info = nameColorRaw(value); if (info.hex === '#000000' && info.name === 'Unknown') continue; const hex = info.hex; const role = formatRole(key); // First match wins per category (preserves the most specific name) const entry: SemanticEntry = { role, raw: value }; // For --palette-* vars: skip numeric-suffix tint/shade entries (e.g. --palette-rausch100) // unless they are mid-range colors (lum 0.15-0.80, sat > 25) — avoids near-white tints cluttering the palette const isPaletteNumeric = /^--palette-[a-zA-Z]+\d+$/i.test(key) || /^--palette-shadow/i.test(key); if (isPaletteNumeric) { const rgb = parseRgb(value); if (!rgb) continue; const lum = luminance(rgb); const sat = Math.max(...rgb) - Math.min(...rgb); if (lum > 0.93 || lum < 0.04 || sat < 10) continue; // skip pure-white, pure-black, colorless greys — keep pastels } if (TEXT_PATTERNS.some(p => p.test(key))) { if (!text.has(hex)) text.set(hex, entry); } else if (BG_PATTERNS.some(p => p.test(key))) { // Detect brand/CTA color misrouted via bg token name (e.g. --palette-bg-primary-core = #ff385c). // High-saturation bg vars are brand accents used as CTA backgrounds, not neutral surfaces. // Parse from hex (always 6-char lowercase from nameColor) — parseRgb() only handles rgb() format. const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); const sat = isNaN(r) ? 0 : Math.max(r, g, b) - Math.min(r, g, b); if (sat > 60) { if (!accent.has(hex)) accent.set(hex, entry); } else { if (!bg.has(hex)) bg.set(hex, entry); } } else if (ACCENT_PATTERNS.some(p => p.test(key))) { if (!accent.has(hex)) accent.set(hex, entry); } else if (BORDER_PATTERNS.some(p => p.test(key))) { if (!border.has(hex)) border.set(hex, entry); } } return { text, bg, accent, border }; } // ── Brand Palette Name Overrides ───────────────────────────────────── // Extracts --palette-WORD CSS vars where WORD is a simple brand name (Rausch, Luxe, Babu…) // Returns a map: hex → capitalised brand name. Skips compound keys like --palette-bg-primary-*. function buildPaletteColorOverrides(cssVars: Record): Map { const overrides = new Map(); for (const [key, rawValue] of Object.entries(cssVars)) { if (!rawValue || typeof rawValue !== 'string') continue; // Match --palette-WORD or --color-WORD (single word, no hyphens after prefix) const match = key.match(/^--(palette|color)-([a-zA-Z]+)$/); if (!match) continue; const brandWord = match[2]; // Skip generic words that are semantic roles, not brand names if (/^(primary|secondary|tertiary|bg|text|fg|background|border|divider|surface|accent|link|muted|error|warning|success|info|white|black|dark|light|base)$/i.test(brandWord)) continue; const value = rawValue.trim(); if (!/^#[0-9a-fA-F]{3,8}$/.test(value) && !/^rgba?\(/i.test(value)) continue; // Raw nameColor: this helper builds the override map; using the wrapper would cause circularity. const info = nameColorRaw(value); if (info.hex === '#000000' && info.name === 'Unknown') continue; const brandName = brandWord.charAt(0).toUpperCase() + brandWord.slice(1).toLowerCase(); if (!overrides.has(info.hex)) overrides.set(info.hex, brandName); } return overrides; } // ── Typography Role Detection ─────────────────────────────────────── interface TypoRole { role: string; font: string; size: string; weight: string; lineHeight: string; letterSpacing: string; notes: string; } // Generic font names that should be replaced by custom web fonts in the display const GENERIC_DISPLAY_FONTS = new Set([ 'times new roman', 'times', 'georgia', 'palatino', 'garamond', 'serif', 'arial', 'helvetica', 'verdana', 'tahoma', 'trebuchet ms', 'calibri', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui', '-apple-system', 'blinkmacsystemfont', 'segoe ui', 'roboto', // i18n fallback fonts — never the brand's primary typeface 'noto sans', 'noto serif', 'noto sans math', 'noto sans arabic', 'noto sans hebrew', 'noto sans jp', 'noto sans kr', 'noto sans sc', 'noto color emoji', ]); /** Returns true if the font family is a real brand/product font (not KaTeX, icons, i18n fallback) */ function isRealFont(family: string): boolean { if (!family || family.length < 2) return false; if (family.startsWith('__')) return false; // Next.js internal if (/^katex_/i.test(family)) return false; // KaTeX math library if (GENERIC_DISPLAY_FONTS.has(family.toLowerCase())) return false; if (/katex|dseg|noto\s*sans\s*(math|arabic|hebrew|jp|kr|sc)/i.test(family)) return false; if (/icon|star|emoji|symbol|glyph|webdings|wingdings|awesome/i.test(family)) return false; if (/^[A-Z]+_/.test(family)) return false; // KaTeX_AMS, KaTeX_Caligraphic patterns return true; } function resolveDisplayFont(fontFamily: string, fontFacesList: any[]): string { // Extract first non-generic font from stack const parts = fontFamily.split(',').map((f: string) => f.trim().replace(/"/g, '')); for (const p of parts) { if (p && !GENERIC_DISPLAY_FONTS.has(p.toLowerCase()) && !p.startsWith('__')) return p; } // Fallback: use first custom web font from fontFaces if (fontFacesList && fontFacesList.length > 0) { const custom = fontFacesList.find((ff: any) => isRealFont(ff.family)); if (custom) return custom.family; } return extractFontName(fontFamily); } function detectTypoRoles(rawData: any): TypoRole[] { const roles: TypoRole[] = []; const elements = rawData.elements; const variants = rawData.componentVariants || {}; const fontFacesList = rawData.fontFaces || []; function makeRole(roleName: string, s: any, notes: string): TypoRole { return { role: roleName, font: resolveDisplayFont(s.fontFamily, fontFacesList), size: s.fontSize, weight: s.fontWeight, lineHeight: normalizeLineHeight(s.lineHeight, s.fontSize), letterSpacing: s.letterSpacing === 'normal' ? 'normal' : s.letterSpacing, notes, }; } function isDuplicate(s: any): boolean { return roles.some(r => r.size === s.fontSize && r.weight === s.fontWeight && r.font === resolveDisplayFont(s.fontFamily, fontFacesList) ); } // ── Display Hero — use the LARGEST heading across h1/h2/h3 (marketing sites often use h2 for hero) const headingCandidates = [ elements.heading, elements.subheading, ...(variants.headingH1 || []).slice(0, 3), ...(variants.headingH2 || []).slice(0, 4), ...(variants.headingH3 || []).slice(0, 2), ].filter((el): el is NonNullable => !!(el?.styles && parsePixels(el.styles.fontSize) > 0)); headingCandidates.sort((a, b) => parsePixels(b.styles!.fontSize) - parsePixels(a.styles!.fontSize)); const h1El = headingCandidates[0] ?? null; if (h1El?.styles) { roles.push(makeRole('Display Hero', h1El.styles, `Main headline (${h1El.tag || 'h1/h2'})`)); } // ── H2 — second-largest heading (skip if same element as Display Hero) const h2El = headingCandidates.find(el => el !== h1El) ?? null; if (h2El?.styles && !isDuplicate(h2El.styles)) { const sizePx = parsePixels(h2El.styles.fontSize); const roleName = sizePx >= 28 ? 'Section Heading' : 'Sub-heading'; roles.push(makeRole(roleName, h2El.styles, `Section titles (${h2El.tag || 'h2'})`)); } // ── H3 — from componentVariants.headingH3 const h3El = variants.headingH3?.[0]; if (h3El?.styles && h3El !== h1El && h3El !== h2El && !isDuplicate(h3El.styles)) { roles.push(makeRole('Sub-heading', h3El.styles, 'Third-level heading (h3)')); } // ── H4/H5/H6 — smaller headings (allow up to 2 distinct sizes) let smallHeadingCount = 0; for (const key of ['headingH4', 'headingH5', 'headingH6']) { const hEl = variants[key]?.[0]; if (hEl?.styles && !isDuplicate(hEl.styles) && smallHeadingCount < 2) { roles.push(makeRole('Label Heading', hEl.styles, `Small heading (${key.replace('heading', '')})`)); smallHeadingCount++; } } // ── Card title — from card variants const cardVariants = variants.cards || []; if (cardVariants[0]?.styles && !isDuplicate(cardVariants[0].styles)) { roles.push(makeRole('Card Title', cardVariants[0].styles, 'Card headings')); } // ── Body text if (elements.body?.styles && !isDuplicate(elements.body.styles)) { roles.push(makeRole('Body', elements.body.styles, 'Standard reading text')); } // ── Button text — iterate variants for size diversity // Many sites have multiple button sizes (small/default/large). isDuplicate // ensures we only add genuinely distinct typography signatures. const buttonVariants = variants.buttons || []; const buttonSigs = new Set(); let buttonAddedCount = 0; for (const bv of [...(elements.button ? [elements.button] : []), ...buttonVariants].slice(0, 6)) { if (!bv?.styles || buttonAddedCount >= 8) continue; const sig = `${bv.styles.fontSize}-${bv.styles.fontWeight}`; if (buttonSigs.has(sig)) continue; buttonSigs.add(sig); if (!isDuplicate(bv.styles)) { const sizePx = parsePixels(bv.styles.fontSize); const label = sizePx >= 16 ? 'Button Large' : sizePx <= 12 ? 'Button Small' : 'Button'; roles.push(makeRole(label, bv.styles, `Button label (${sizePx}px)`)); buttonAddedCount++; } } // ── Link text — iterate variants. Modern designs often have several link // sizes: nav link 14px, body link 16px, footer link 12px, etc. const linkVariants = variants.links || []; const linkSigs = new Set(); let linkAddedCount = 0; for (const lv of [...(elements.link ? [elements.link] : []), ...linkVariants].slice(0, 6)) { if (!lv?.styles || linkAddedCount >= 8) continue; const sig = `${lv.styles.fontSize}-${lv.styles.fontWeight}`; if (linkSigs.has(sig)) continue; linkSigs.add(sig); if (!isDuplicate(lv.styles)) { const s = lv.styles; const sizePx = parsePixels(s.fontSize); const label = sizePx >= 16 ? 'Link Large' : sizePx <= 12 ? 'Link Small' : 'Link'; roles.push({ role: label, font: resolveDisplayFont(s.fontFamily, fontFacesList), size: s.fontSize, weight: s.fontWeight, lineHeight: normalizeLineHeight(s.lineHeight, s.fontSize), letterSpacing: s.letterSpacing === 'normal' ? 'normal' : s.letterSpacing, notes: `Link (${sizePx}px) — Decoration: ${s.textDecoration?.includes('none') ? 'none' : s.textDecoration || 'none'}`, }); linkAddedCount++; } } // ── Navigation text if (elements.nav?.styles) { const s = elements.nav.styles; if (!isDuplicate(s)) { roles.push(makeRole('Navigation', s, 'Navigation items')); } } // ── Badge / Caption / Micro — iterate badge variants for size diversity const badgeVariants = variants.badges || []; const badgeSigs = new Set(); let badgeAddedCount = 0; for (const bv of [...(elements.badge ? [elements.badge] : []), ...badgeVariants].slice(0, 4)) { if (!bv?.styles || badgeAddedCount >= 2) continue; const sig = `${bv.styles.fontSize}-${bv.styles.fontWeight}`; if (badgeSigs.has(sig)) continue; badgeSigs.add(sig); if (!isDuplicate(bv.styles)) { const s = bv.styles; const sizePx = parsePixels(s.fontSize); const label = sizePx <= 10 ? 'Tiny / Micro' : sizePx <= 11 ? 'Micro' : 'Caption / Badge'; roles.push({ role: label, font: resolveDisplayFont(s.fontFamily, fontFacesList), size: s.fontSize, weight: s.fontWeight, lineHeight: normalizeLineHeight(s.lineHeight, s.fontSize), letterSpacing: s.letterSpacing === 'normal' ? 'normal' : s.letterSpacing, notes: `${s.textTransform && s.textTransform !== 'none' ? s.textTransform + ', ' : ''}small text (${sizePx}px)`, }); badgeAddedCount++; } } // ── Input text — iterate variants const inputVariants = variants.inputs || []; const inputSigs = new Set(); let inputAddedCount = 0; for (const iv of [...(elements.input ? [elements.input] : []), ...inputVariants].slice(0, 4)) { if (!iv?.styles || inputAddedCount >= 2) continue; const sig = `${iv.styles.fontSize}-${iv.styles.fontWeight}`; if (inputSigs.has(sig)) continue; inputSigs.add(sig); if (!isDuplicate(iv.styles)) { const sizePx = parsePixels(iv.styles.fontSize); const label = sizePx >= 16 ? 'Input Large' : 'Input'; roles.push(makeRole(label, iv.styles, `Form input (${sizePx}px)`)); inputAddedCount++; } } // ── Nav links — from universal selector const navLinkVariants = variants.navLinks || []; let navAddedCount = 0; for (const nv of navLinkVariants.slice(0, 4)) { if (!nv?.styles || navAddedCount >= 3) continue; if (isDuplicate(nv.styles)) continue; roles.push(makeRole('Nav Link', nv.styles, 'Navigation anchor')); navAddedCount++; } // ── Eyebrow / Overline — small labels above headings const ebVariants = variants.eyebrowLabels || []; const ebv = ebVariants[0]; if (ebv?.styles && !isDuplicate(ebv.styles)) { roles.push(makeRole('Eyebrow / Overline', ebv.styles, 'Label above heading, uppercase tag')); } // ── Caption / Helper text const capVariants = variants.captions || []; const capv = capVariants[0]; if (capv?.styles && !isDuplicate(capv.styles)) { roles.push(makeRole('Caption', capv.styles, 'Image caption, helper text')); } // ── Table header const thVariants = variants.tableHeaders || []; const thv = thVariants[0]; if (thv?.styles && !isDuplicate(thv.styles)) { roles.push(makeRole('Table Header', thv.styles, 'Column heading')); } // ── Typography roles from CSS custom properties // Design systems encode size+lineHeight in var names, e.g.: // --typography-body-text_14_18 → 14px/18px (ends with _XX_YY) // --typography-subtitles-book_18_24-font-size → 18px/24px (property suffix after numbers) // Only look at font-size vars (or bare _XX_YY keys) to avoid duplicates from letter-spacing/lh vars. const cssVarsTypo = rawData.cssCustomProperties || {}; const typoVarEntries = Object.keys(cssVarsTypo) .filter(k => /^--typography-/i.test(k)) .filter(k => { // Accept keys that end with _XX_YY or contain _XX_YY-font-size return /_\d+_\d+$/.test(k) || /_\d+_\d+-font-size$/i.test(k); }) .map(k => { const m = k.match(/_(\d+)_(\d+)/); if (!m) return null; const sizePx = parseInt(m[1], 10); const lhPx = parseInt(m[2], 10); if (sizePx < 8 || sizePx > 120) return null; // Resolve actual values from CSS vars using the base key (without property suffix) const baseKey = k.replace(/-font-size$/i, ''); // font-size: prefer explicit var value over name-encoded value let resolvedSizePx = sizePx; const fsKey = baseKey + '-font-size'; const fsVal = (cssVarsTypo[k] as string) || (cssVarsTypo[fsKey] as string) || ''; const remMatch = fsVal.match(/([\d.]+)rem/); const pxMatch = fsVal.match(/([\d.]+)px/); if (remMatch) resolvedSizePx = Math.round(parseFloat(remMatch[1]) * 16); else if (pxMatch) resolvedSizePx = Math.round(parseFloat(pxMatch[1])); if (resolvedSizePx < 8) return null; // font-weight: read from CSS var, not hardcoded const fwKey = baseKey + '-font-weight'; const fwVal = (cssVarsTypo[fwKey] as string) || ''; const resolvedWeight = /^\d+$/.test(fwVal.trim()) ? fwVal.trim() : '400'; // line-height: read from CSS var (rem → px) const lhKey = baseKey + '-line-height'; const lhVarVal = (cssVarsTypo[lhKey] as string) || ''; const lhRemMatch = lhVarVal.match(/([\d.]+)rem/); const lhPxMatch = lhVarVal.match(/([\d.]+)px/); let resolvedLhPx = lhPx; // fallback to name-encoded value if (lhRemMatch) resolvedLhPx = Math.round(parseFloat(lhRemMatch[1]) * 16); else if (lhPxMatch) resolvedLhPx = Math.round(parseFloat(lhPxMatch[1])); // Derive role name: strip --typography- prefix and _XX_YY[-suffix] suffix const namePart = k.replace(/^--typography-/i, '').replace(/_\d+_\d+.*$/, ''); const roleName = namePart.split(/[-_]/) .filter(w => !/^\d+$/.test(w) && w.length > 0) .map(w => w.charAt(0).toUpperCase() + w.slice(1)) .join(' ') .slice(0, 40) || `Size ${resolvedSizePx}px`; return { sizePx: resolvedSizePx, lhPx: resolvedLhPx, weight: resolvedWeight, roleName, baseKey }; }) .filter((x): x is NonNullable => x !== null) // dedupe by font-size + weight (same size different weight = different role) .filter((v, i, arr) => arr.findIndex(x => x.sizePx === v.sizePx && x.weight === v.weight && x.baseKey === v.baseKey) === i) .sort((a, b) => b.sizePx - a.sizePx) .slice(0, 14); for (const { sizePx, lhPx, weight, roleName, baseKey } of typoVarEntries) { // Read letter-spacing from associated CSS var (e.g. --typography-body-text_14_18-letter-spacing) const lsKey = (baseKey || '') + '-letter-spacing'; const lsVal = ((cssVarsTypo as Record)[lsKey] || '').trim(); const resolvedLs = (lsVal && lsVal !== '0' && lsVal !== '0px') ? lsVal : 'normal'; const mockStyles = { fontSize: `${sizePx}px`, fontWeight: weight, lineHeight: `${lhPx}px`, letterSpacing: resolvedLs, fontFamily: rawData.elements?.body?.styles?.fontFamily || 'system-ui' }; if (!isDuplicate(mockStyles)) { roles.push(makeRole(roleName, mockStyles, `Typography system token (${sizePx}px/${lhPx}px)`)); } } // Sort by font size descending, remove tiny sizes (< 8px) roles.sort((a, b) => parsePixels(b.size) - parsePixels(a.size)); return roles.filter(r => parsePixels(r.size) >= 8); } function extractFontName(fontFamily: string): string { if (!fontFamily) return 'system-ui'; const parts = fontFamily.split(',').map(f => f.trim().replace(/"/g, '')); // Return first non-generic, non-system font for (const p of parts) { if (p && !GENERIC_DISPLAY_FONTS.has(p.toLowerCase()) && !p.startsWith('__')) return p; } // Accept system fonts as last resort (but not generic CSS keywords) const cssKeywords = new Set(['serif', 'sans-serif', 'monospace', 'cursive', 'fantasy']); for (const p of parts) { if (p && !cssKeywords.has(p.toLowerCase())) return p; } return parts[0] || 'system-ui'; } function normalizeLineHeight(lh: string, fs: string): string { if (!lh) return 'normal'; if (!lh.includes('px')) return lh; const lhPx = parsePixels(lh); const fsPx = parsePixels(fs); if (lhPx > 0 && fsPx > 0) { const ratio = lhPx / fsPx; return ratio.toFixed(2).replace(/\.?0+$/, ''); } return lh; } // ── Component Spec Builder ────────────────────────────────────────── interface ComponentSpec { name: string; variants: { name: string; background: string; backgroundHex: string; text: string; textHex: string; padding: string; radius: string; border: string; shadow: string; font: string; use: string; }[]; } function buildComponentSpecs(rawData: any, tokens: any): ComponentSpec[] { const specs: ComponentSpec[] = []; const variants = rawData.componentVariants || {}; const elements = rawData.elements; const componentStates = rawData.componentStates || {}; // ── Buttons ── richer variant detection // Goal: 4-7 distinct button variants (Ghost/Subtle/Primary Brand/Pill/Icon/Outline/etc.) // instead of just Primary + 1 deduped Secondary. const buttonVariants = variants.buttons || []; if (buttonVariants.length > 0 || elements.button) { const spec: ComponentSpec = { name: 'Buttons', variants: [] }; const seenSigs = new Set(); // Heuristic: classify a button by its visual signature function classifyButton(s: any, rect: { width?: number; height?: number } = {}): { name: string; use: string } { const bgRgb = parseRgb(s.backgroundColor); const bgLum = bgRgb ? luminance(bgRgb) : 0.5; const bgAlpha = (s.backgroundColor || '').match(/rgba?\([^)]*,\s*([\d.]+)\)\s*$/)?.[1]; const isTransparent = !bgRgb || s.backgroundColor === 'transparent' || s.backgroundColor === 'rgba(0, 0, 0, 0)' || (bgAlpha && parseFloat(bgAlpha) < 0.05); const isTranslucent = bgAlpha && parseFloat(bgAlpha) >= 0.05 && parseFloat(bgAlpha) < 1; const hasExplicitBorder = s.border && s.border !== 'none' && !s.border.startsWith('0px') && !s.border.includes('0px none') && !s.border.includes('rgba(0, 0, 0, 0)'); const radiusPx = parsePixels(s.borderRadius || ''); const isPill = radiusPx >= 999 || (rect.height && radiusPx >= rect.height / 2); const isSquare = rect.width && rect.height && Math.abs(rect.width - rect.height) < 4; const bgSat = bgRgb ? Math.max(...bgRgb) - Math.min(...bgRgb) : 0; const isVibrant = bgSat > 80; if (isSquare && rect.height && rect.height < 48) return { name: 'Icon Button', use: 'Toolbar/UI icons' }; if (isPill) return { name: 'Pill', use: 'Status pills, tags, chips' }; if (isVibrant) return { name: 'Primary Brand', use: 'Primary CTA / brand action' }; if (isTransparent && hasExplicitBorder) return { name: 'Outline', use: 'Secondary action with border' }; if (isTransparent) return { name: 'Ghost', use: 'Subtle action, toolbar, nav button' }; if (isTranslucent) return { name: 'Subtle', use: 'Quiet action against page bg' }; if (bgLum > 0.9) return { name: 'Light / Invert', use: 'Bright CTA on dark sections' }; if (bgLum < 0.1) return { name: 'Dark / Solid', use: 'Solid dark CTA' }; return { name: 'Secondary', use: 'Secondary action' }; } function pushButton(s: any, rect: any) { if (!s) return; const bgColor = nameColor(s.backgroundColor || ''); const txtColor = nameColor(s.color || ''); // Signature: dedupe by background + radius + padding + border (distinct visual identity) const sig = `${bgColor.hex}|${s.borderRadius}|${s.padding}|${s.border || ''}`; if (seenSigs.has(sig)) return; seenSigs.add(sig); const cls = classifyButton(s, rect || {}); // Cap repeats of common classes (Ghost, Icon Button) — beyond 2 occurrences // they're noise and crowd out scarce-but-critical variants like Primary Brand. const repeatableClasses = new Set(['Ghost', 'Icon Button', 'Pill', 'Subtle']); if (repeatableClasses.has(cls.name)) { const sameClassCount = spec.variants.filter(v => v.name === cls.name).length; if (sameClassCount >= 2) return; } spec.variants.push({ name: cls.name, background: `\`${bgColor.displayValue}\``, backgroundHex: bgColor.hex, text: `\`${txtColor.displayValue}\``, textHex: txtColor.hex, padding: s.padding || '', radius: normalizeRadius(s.borderRadius || '') || '', border: s.border && s.border !== 'none' && !s.border.startsWith('0px') ? s.border : 'none', shadow: s.boxShadow && s.boxShadow !== 'none' ? s.boxShadow : 'none', font: `${s.fontSize || '?'} weight ${s.fontWeight || '?'}`, use: cls.use, }); } // Seed with elements.button if present if (elements.button?.styles) pushButton(elements.button.styles, elements.button.rect); // Iterate up to 10 variants — heuristics will dedupe via sig for (const v of buttonVariants.slice(0, 10)) { if (spec.variants.length >= 7) break; pushButton(v?.styles, v?.rect); } // Modern designs often style CTAs as tags (Linear's primary CTA is // a link with bg rgb(94,106,210)). Scan link variants for button-like // appearance: non-transparent bg + padding > 0. const linkVariantsForCta = variants.links || []; for (const v of linkVariantsForCta.slice(0, 10)) { if (spec.variants.length >= 7) break; const s = v?.styles; if (!s) continue; const bgRgb = parseRgb(s.backgroundColor || ''); const bgAlpha = (s.backgroundColor || '').match(/rgba?\([^)]*,\s*([\d.]+)\)\s*$/)?.[1]; const isTransparent = !bgRgb || s.backgroundColor === 'transparent' || s.backgroundColor === 'rgba(0, 0, 0, 0)' || (bgAlpha && parseFloat(bgAlpha) < 0.05); if (isTransparent) continue; // Must have actual padding (a CTA-styled link, not inline text link) const paddingValues = (s.padding || '').match(/\d+(?:\.\d+)?/g); const maxPad = paddingValues ? Math.max(...paddingValues.map(Number)) : 0; if (maxPad < 4) continue; pushButton(s, v?.rect); } if (spec.variants.length > 0) specs.push(spec); } // ── Cards ── const cardVariants = variants.cards || []; if (cardVariants.length > 0 || elements.card) { const spec: ComponentSpec = { name: 'Cards & Containers', variants: [] }; const source = elements.card?.styles || cardVariants[0]?.styles; if (source) { const bgColor = nameColor(source.backgroundColor); spec.variants.push({ name: 'Standard Card', background: `\`${bgColor.displayValue}\``, backgroundHex: bgColor.hex, text: '', textHex: '', padding: source.padding, radius: source.borderRadius, border: source.border && !source.border.includes('0px') ? source.border : 'none', shadow: source.boxShadow && source.boxShadow !== 'none' ? source.boxShadow : 'none', font: '', use: 'Content containers, listing items', }); } if (spec.variants.length > 0) specs.push(spec); } // ── Inputs ── if (elements.input?.styles) { const s = elements.input.styles; const bgColor = nameColor(s.backgroundColor); const txtColor = nameColor(s.color); specs.push({ name: 'Inputs & Forms', variants: [{ name: 'Text Input', background: `\`${bgColor.displayValue}\``, backgroundHex: bgColor.hex, text: `\`${txtColor.displayValue}\``, textHex: txtColor.hex, padding: s.padding, radius: normalizeRadius(s.borderRadius || ''), border: s.border || '1px solid border-color', shadow: s.boxShadow && s.boxShadow !== 'none' ? s.boxShadow : 'none', font: `${s.fontSize} weight ${s.fontWeight}`, use: 'Text fields, search inputs', }], }); } // ── Navigation ── if (elements.header?.styles || elements.nav?.styles) { const headerS = elements.header?.styles; const navS = elements.nav?.styles; const s = headerS || navS; if (s) { const bgColor = nameColor(s.backgroundColor); const navType = elements.nav?.styles?.display || 'flex'; const position = s.position || 'static'; specs.push({ name: 'Navigation', variants: [{ name: 'Main Nav', background: `\`${bgColor.displayValue}\``, backgroundHex: bgColor.hex, text: '', textHex: '', padding: s.padding, radius: 'none', border: s.border && !s.border.includes('0px') ? s.border : 'none', shadow: s.boxShadow && s.boxShadow !== 'none' ? s.boxShadow : 'none', font: `${navS?.fontSize || s.fontSize} weight ${navS?.fontWeight || s.fontWeight}`, use: `${position !== 'static' ? 'Fixed/sticky' : 'Static'} ${navType} nav — ${elements.links?.length || 'N/A'} items`, }], }); } } // ── Search Bar (site-specific) ── const searchBarVariants = variants.searchBar || []; if (searchBarVariants.length > 0) { const spec: ComponentSpec = { name: 'Search Bar', variants: [] }; const seenSigs = new Set(); for (const sv of searchBarVariants.slice(0, 3)) { if (!sv?.styles) continue; const s = sv.styles; const sig = `${s.backgroundColor}|${s.borderRadius}|${s.height}`; if (seenSigs.has(sig)) continue; seenSigs.add(sig); const bgColor = nameColor(s.backgroundColor); const isPill = parsePixels(s.borderRadius) >= 999 || parsePixels(s.borderRadius) >= 30; spec.variants.push({ name: isPill ? 'Search Pill' : 'Search Bar', background: `\`${bgColor.displayValue}\``, backgroundHex: bgColor.hex, text: `\`${nameColor(s.color).displayValue}\``, textHex: nameColor(s.color).hex, padding: s.padding, radius: normalizeRadius(s.borderRadius || ''), border: s.border && !s.border.includes('0px') && !s.border.includes('none') ? s.border : 'none', shadow: s.boxShadow && s.boxShadow !== 'none' ? s.boxShadow : 'none', font: `${s.fontSize} weight ${s.fontWeight}`, use: isPill ? 'Global pill-shaped search bar' : 'Inline search form', }); } if (spec.variants.length > 0) specs.push(spec); } // ── Property Card / Listing Card (site-specific) ── const propertyCardVariants = variants.propertyCard || []; if (propertyCardVariants.length > 0) { const spec: ComponentSpec = { name: 'Property Cards', variants: [] }; const seenSigs = new Set(); for (const pv of propertyCardVariants.slice(0, 4)) { if (!pv?.styles) continue; const s = pv.styles; const sig = `${s.backgroundColor}|${s.borderRadius}`; if (seenSigs.has(sig)) continue; seenSigs.add(sig); const bgColor = nameColor(s.backgroundColor); spec.variants.push({ name: 'Property Card', background: `\`${bgColor.displayValue}\``, backgroundHex: bgColor.hex, text: `\`${nameColor(s.color).displayValue}\``, textHex: nameColor(s.color).hex, padding: s.padding, radius: normalizeRadius(s.borderRadius || ''), border: s.border && !s.border.includes('0px') ? s.border : 'none', shadow: s.boxShadow && s.boxShadow !== 'none' ? s.boxShadow : 'none', font: `${s.fontSize} weight ${s.fontWeight}`, use: 'Photo-first listing card with meta (title, price, distance)', }); } if (spec.variants.length > 0) specs.push(spec); } // ── Rating Display (site-specific) ── const ratingVariants = variants.ratingDisplay || []; if (ratingVariants.length > 0) { const spec: ComponentSpec = { name: 'Rating Display', variants: [] }; for (const rv of ratingVariants.slice(0, 2)) { if (!rv?.styles) continue; const s = rv.styles; const bgColor = nameColor(s.backgroundColor); spec.variants.push({ name: 'Star Rating', background: `\`${bgColor.displayValue}\``, backgroundHex: bgColor.hex, text: `\`${nameColor(s.color).displayValue}\``, textHex: nameColor(s.color).hex, padding: s.padding, radius: normalizeRadius(s.borderRadius || ''), border: 'none', shadow: 'none', font: `${s.fontSize} weight ${s.fontWeight}`, use: 'Inline star/score display on listing cards', }); break; } if (spec.variants.length > 0) specs.push(spec); } // ── Host / Profile Card (site-specific) ── const hostCardVariants = variants.hostCard || []; if (hostCardVariants.length > 0) { const spec: ComponentSpec = { name: 'Host Card', variants: [] }; const sv = hostCardVariants[0]; if (sv?.styles) { const s = sv.styles; const bgColor = nameColor(s.backgroundColor); spec.variants.push({ name: 'Host Profile', background: `\`${bgColor.displayValue}\``, backgroundHex: bgColor.hex, text: `\`${nameColor(s.color).displayValue}\``, textHex: nameColor(s.color).hex, padding: s.padding, radius: normalizeRadius(s.borderRadius || ''), border: s.border && !s.border.includes('0px') ? s.border : 'none', shadow: s.boxShadow && s.boxShadow !== 'none' ? s.boxShadow : 'none', font: `${s.fontSize} weight ${s.fontWeight}`, use: 'Host avatar + name + superhost badge + contact CTA', }); specs.push(spec); } } // ── Pricing Cards (SaaS) ── const pricingVariants = variants.pricingCard || []; if (pricingVariants.length > 0) { const spec: ComponentSpec = { name: 'Pricing Cards', variants: [] }; const seen = new Set(); for (const pv of pricingVariants.slice(0, 4)) { if (!pv?.styles) continue; const s = pv.styles; const bgColor = nameColor(s.backgroundColor); const sig = `${bgColor.hex}|${s.border}|${s.boxShadow}`; if (seen.has(sig)) continue; seen.add(sig); const hasBorder = s.border && !s.border.includes('0px') && !s.border.includes('rgba(0, 0, 0, 0)'); const hasShadow = s.boxShadow && s.boxShadow !== 'none'; spec.variants.push({ name: hasShadow ? 'Featured / Highlighted' : (hasBorder ? 'Outlined Tier' : 'Default Tier'), background: `\`${bgColor.displayValue}\``, backgroundHex: bgColor.hex, text: `\`${nameColor(s.color).displayValue}\``, textHex: nameColor(s.color).hex, padding: s.padding, radius: normalizeRadius(s.borderRadius || ''), border: hasBorder ? s.border : 'none', shadow: hasShadow ? s.boxShadow : 'none', font: `${s.fontSize} weight ${s.fontWeight}`, use: 'Subscription tier card', }); if (spec.variants.length >= 3) break; } if (spec.variants.length > 0) specs.push(spec); } // ── CTA Banners ── const ctaBannerVariants = variants.ctaBanner || []; if (ctaBannerVariants.length > 0) { const spec: ComponentSpec = { name: 'CTA Banners', variants: [] }; const sv = ctaBannerVariants[0]; if (sv?.styles) { const s = sv.styles; const bgColor = nameColor(s.backgroundColor); spec.variants.push({ name: 'Full-width CTA', background: `\`${bgColor.displayValue}\``, backgroundHex: bgColor.hex, text: `\`${nameColor(s.color).displayValue}\``, textHex: nameColor(s.color).hex, padding: s.padding, radius: normalizeRadius(s.borderRadius || ''), border: 'none', shadow: 'none', font: `${s.fontSize} weight ${s.fontWeight}`, use: 'Full-width conversion strip with headline + button', }); specs.push(spec); } } // ── Testimonials ── const testimonialVariants = variants.testimonial || []; if (testimonialVariants.length > 0) { const spec: ComponentSpec = { name: 'Testimonials', variants: [] }; const seen = new Set(); for (const tv of testimonialVariants.slice(0, 3)) { if (!tv?.styles) continue; const s = tv.styles; const bgColor = nameColor(s.backgroundColor); const sig = `${bgColor.hex}|${s.borderRadius}`; if (seen.has(sig)) continue; seen.add(sig); spec.variants.push({ name: 'Quote Card', background: `\`${bgColor.displayValue}\``, backgroundHex: bgColor.hex, text: `\`${nameColor(s.color).displayValue}\``, textHex: nameColor(s.color).hex, padding: s.padding, radius: normalizeRadius(s.borderRadius || ''), border: s.border && !s.border.includes('0px') ? s.border : 'none', shadow: s.boxShadow && s.boxShadow !== 'none' ? s.boxShadow : 'none', font: `${s.fontSize} weight ${s.fontWeight}`, use: 'Customer quote with avatar + company attribution', }); if (spec.variants.length >= 2) break; } if (spec.variants.length > 0) specs.push(spec); } // ── Status Badges ── const statusVariants = variants.statusBadge || []; if (statusVariants.length > 0) { const spec: ComponentSpec = { name: 'Status Badges', variants: [] }; const seen = new Set(); for (const sv of statusVariants.slice(0, 5)) { if (!sv?.styles) continue; const s = sv.styles; const bgColor = nameColor(s.backgroundColor); const txtColor = nameColor(s.color); const sig = `${bgColor.hex}|${txtColor.hex}`; if (seen.has(sig)) continue; seen.add(sig); const bgRgb = parseRgb(s.backgroundColor); const lum = bgRgb ? luminance(bgRgb) : 0.5; const statusName = lum < 0.2 ? 'Neutral Dark' : (lum > 0.8 ? 'Neutral Light' : 'Tinted'); spec.variants.push({ name: statusName, background: `\`${bgColor.displayValue}\``, backgroundHex: bgColor.hex, text: `\`${txtColor.displayValue}\``, textHex: txtColor.hex, padding: s.padding, radius: normalizeRadius(s.borderRadius || ''), border: 'none', shadow: 'none', font: `${s.fontSize} weight ${s.fontWeight}`, use: 'Status indicator, label, pill', }); if (spec.variants.length >= 3) break; } if (spec.variants.length > 0) specs.push(spec); } // ── Tabs ── const tabVariants = variants.tabs || []; if (tabVariants.length > 0) { const spec: ComponentSpec = { name: 'Tabs', variants: [] }; const seen = new Set(); for (const tv of tabVariants.slice(0, 3)) { if (!tv?.styles) continue; const s = tv.styles; const bgColor = nameColor(s.backgroundColor); const sig = `${bgColor.hex}|${s.border}|${s.borderRadius}`; if (seen.has(sig)) continue; seen.add(sig); const isTransparent = !bgColor.hex || s.backgroundColor === 'rgba(0, 0, 0, 0)'; const hasBottomBorder = s.border?.includes('bottom') || s.borderBottom; spec.variants.push({ name: hasBottomBorder ? 'Underline Tab' : (isTransparent ? 'Ghost Tab' : 'Pill Tab'), background: `\`${bgColor.displayValue}\``, backgroundHex: bgColor.hex, text: `\`${nameColor(s.color).displayValue}\``, textHex: nameColor(s.color).hex, padding: s.padding, radius: normalizeRadius(s.borderRadius || ''), border: s.border && !s.border.includes('0px') ? s.border : 'none', shadow: 'none', font: `${s.fontSize} weight ${s.fontWeight}`, use: 'Navigation tabs, filter tabs', }); if (spec.variants.length >= 2) break; } if (spec.variants.length > 0) specs.push(spec); } // ── Code Blocks (SaaS/dev tools) ── const codeBlockVariants = variants.codeBlock || []; if (codeBlockVariants.length > 0) { const spec: ComponentSpec = { name: 'Code Blocks', variants: [] }; const sv = codeBlockVariants[0]; if (sv?.styles) { const s = sv.styles; const bgColor = nameColor(s.backgroundColor); spec.variants.push({ name: 'Inline Code / Pre', background: `\`${bgColor.displayValue}\``, backgroundHex: bgColor.hex, text: `\`${nameColor(s.color).displayValue}\``, textHex: nameColor(s.color).hex, padding: s.padding, radius: normalizeRadius(s.borderRadius || ''), border: s.border && !s.border.includes('0px') ? s.border : 'none', shadow: 'none', font: `${s.fontSize} weight ${s.fontWeight} — ${s.fontFamily?.split(',')[0]?.trim() || 'monospace'}`, use: 'Code samples, CLI commands, syntax highlighting blocks', }); specs.push(spec); } } // ── Alert / Notification Bar ── const alertVariants = variants.alert || []; if (alertVariants.length > 0) { const spec: ComponentSpec = { name: 'Alerts', variants: [] }; const seen = new Set(); for (const av of alertVariants.slice(0, 3)) { if (!av?.styles) continue; const s = av.styles; const bgColor = nameColor(s.backgroundColor); const sig = bgColor.hex; if (seen.has(sig)) continue; seen.add(sig); spec.variants.push({ name: 'Alert Banner', background: `\`${bgColor.displayValue}\``, backgroundHex: bgColor.hex, text: `\`${nameColor(s.color).displayValue}\``, textHex: nameColor(s.color).hex, padding: s.padding, radius: normalizeRadius(s.borderRadius || ''), border: s.border && !s.border.includes('0px') ? s.border : 'none', shadow: 'none', font: `${s.fontSize} weight ${s.fontWeight}`, use: 'System alert, banner notification, toast', }); if (spec.variants.length >= 2) break; } if (spec.variants.length > 0) specs.push(spec); } // ── Product Cards (ecom) ── const productCardVariants = variants.productCard || []; if (productCardVariants.length > 0) { const spec: ComponentSpec = { name: 'Product Cards', variants: [] }; const sv = productCardVariants[0]; if (sv?.styles) { const s = sv.styles; const bgColor = nameColor(s.backgroundColor); spec.variants.push({ name: 'Product Tile', background: `\`${bgColor.displayValue}\``, backgroundHex: bgColor.hex, text: `\`${nameColor(s.color).displayValue}\``, textHex: nameColor(s.color).hex, padding: s.padding, radius: normalizeRadius(s.borderRadius || ''), border: s.border && !s.border.includes('0px') ? s.border : 'none', shadow: s.boxShadow && s.boxShadow !== 'none' ? s.boxShadow : 'none', font: `${s.fontSize} weight ${s.fontWeight}`, use: 'Product listing grid card', }); specs.push(spec); } } // v2.6 Fix-3 — drop empty wrapper variants: when CA grabs an un-styled
instead of the real // styled element, the variant has no padding, no radius, no border AND no shadow — nothing an agent // can build (the audit found "Standard Card → transparent/0px/0px/none"). Real buttons/cards always // carry at least padding or radius, so this only removes genuinely empty specs. const hasNoRenderable = (v: any): boolean => { const noPad = !v.padding || /^(0px\s*)+$/.test(String(v.padding).trim()) || String(v.padding).trim() === '0'; const noRadius = !v.radius || parsePixels(String(v.radius)) === 0; const noBorder = !v.border || v.border === 'none'; const noShadow = !v.shadow || v.shadow === 'none'; return noPad && noRadius && noBorder && noShadow; }; for (const spec of specs) { if (Array.isArray(spec.variants)) spec.variants = spec.variants.filter((v: any) => !hasNoRenderable(v)); } return specs.filter(s => !Array.isArray(s.variants) || s.variants.length > 0); } // ── Layout Analysis ───────────────────────────────────────────────── function analyzeLayout(rawData: any, tokens: any): { type: string; grid: string; maxWidth: string; spacingPhilosophy: string; spacingScale: string[]; radiusScale: { name: string; value: string; use: string }[]; } { const elements = rawData.elements; // Layout type — only consider it a "sidebar app" if the sidebar is positioned // at the top-left (y < 200) AND covers significant viewport height (>400px). // Otherwise it's probably a footer/section sidebar, not an app sidebar. let type = 'top-nav + content'; const sb = elements.sidebar?.rect; const isRealAppSidebar = sb && sb.y < 200 && sb.height > 400 && sb.width > 150; if (isRealAppSidebar) type = 'sidebar + main content'; else if (elements.hero) type = 'hero + sections'; // Grid detection let grid = 'Single column, centered content'; if (elements.main?.styles?.gridTemplateColumns && elements.main.styles.gridTemplateColumns !== 'none') { grid = `CSS Grid: ${elements.main.styles.gridTemplateColumns}`; } else if (elements.main?.styles?.display === 'flex') { grid = `Flexbox ${elements.main.styles.flexDirection || 'row'}`; } // Spacing scale from tokens const spacingScale = Object.entries(tokens.spacing || {}).map(([k, v]) => `${k}: ${v}`); // Border radius scale const radiusEntries = Object.entries(tokens.borderRadius || {}); const radiusScale = radiusEntries.map(([name, value]) => ({ name: name.charAt(0).toUpperCase() + name.slice(1), value: value as string, use: name === 'none' ? 'No rounding' : name === 'xs' ? 'Tiny corners (badges, micro elements)' : name === 'sm' ? 'Buttons, inputs, small elements' : name === 'md' ? 'Cards, containers' : name === 'lg' ? 'Large rounded elements, pill segments' : name === 'xl' ? 'Category strips, featured containers' : name === 'full' ? 'Pills, avatars, circular elements' : 'Various', })); return { type, grid, maxWidth: tokens.layout?.maxWidth || 'none', spacingPhilosophy: parsePixels(tokens.spacing?.xl || '32px') > 40 ? 'Generous spacing — editorial breathing room' : 'Compact spacing — information density prioritized', spacingScale, radiusScale, }; } // ── Shadow System Analysis ────────────────────────────────────────── function analyzeShadows(rawData: any, tokens: any): { levels: { level: string; treatment: string; use: string }[]; philosophy: string; } { const shadows = rawData.allShadows || []; const levels: { level: string; treatment: string; use: string }[] = []; // Always start with flat levels.push({ level: 'Flat (Level 0)', treatment: 'No shadow', use: 'Page background, content blocks', }); // Analyze actual shadows found const uniqueShadows = ([...new Set(shadows)] as string[]).filter((s: string) => s !== 'none'); for (let i = 0; i < Math.min(uniqueShadows.length, 4); i++) { const shadow = uniqueShadows[i]; const isInset = shadow.includes('inset'); const isMultiLayer = (shadow.match(/,/g) || []).length > 1; const hasLargeBlur = /\d{2,}px/.test(shadow); let levelName = `Level ${i + 1}`; let use = 'Interactive elements'; if (isInset) { levelName = `Inset (Level ${i + 1})`; use = 'Buttons, pressed-state elements'; } else if (isMultiLayer) { levelName = `Layered (Level ${i + 1})`; use = 'Cards, elevated surfaces'; } else if (hasLargeBlur) { levelName = `Focus (Level ${i + 1})`; use = 'Active/focus states, hover elevation'; } else { levelName = `Subtle (Level ${i + 1})`; use = 'Cards, dividers, borders'; } // For multi-layer shadows, format each layer on its own line for readability // For short single-layer shadows, keep inline // Must use paren-depth tracking — naive split on comma breaks rgba(R, G, B, A) const splitLayers = (s: string): string[] => { const layers: string[] = []; let depth = 0; let current = ''; for (const char of s) { if (char === '(') depth++; else if (char === ')') depth--; if (char === ',' && depth === 0) { if (current.trim()) layers.push(current.trim()); current = ''; } else { current += char; } } if (current.trim()) layers.push(current.trim()); return layers; }; const treatment = isMultiLayer ? '
' + splitLayers(shadow).map((layer: string) => `\`${layer}\``).join(',
') : `\`${shadow}\``; levels.push({ level: levelName, treatment, use, }); } // Philosophy const hasMultiLayer = uniqueShadows.some((s: string) => (s.match(/,/g) || []).length > 1); const hasInset = uniqueShadows.some((s: string) => s.includes('inset')); const philosophy = uniqueShadows.length === 0 ? 'Flat design — no shadows used. Borders and spacing define structure.' : hasMultiLayer ? 'Multi-layered shadow system creating natural, atmospheric depth. Each shadow level combines multiple layers for realistic elevation.' : hasInset ? 'Inset shadow technique creates tactile, pressed-into-surface depth rather than floating elements.' : 'Minimal shadow usage — clean, flat surfaces with subtle elevation on interactive elements.'; return { levels, philosophy }; } // ── Motion & Interaction Section Generator ─────────────────────────── /** Parse all durations (ms) from a transition string like "color 0.16s ease" */ function _parseDurationsMs(str: string): number[] { const results: number[] = []; // Normalize leading-decimal notation (.14s → 0.14s) before matching // so "14s" is not extracted from ".14s" (which is 0.14s = 140ms, not 14s = 14000ms) const normalized = str.replace(/(? = [ ['cubic-bezier(0.19,1,0.22,1)', 'Expo Out — Snappy Deceleration'], ['cubic-bezier(0.19, 1, 0.22, 1)', 'Expo Out — Snappy Deceleration'], ['cubic-bezier(0.23,1,0.32,1)', 'Quint Out — Strong Ease'], ['cubic-bezier(0.23, 1, 0.32, 1)', 'Quint Out — Strong Ease'], ['cubic-bezier(0.25,0.46,0.45,0.94)', 'Quad Out — Smooth Ease'], ['cubic-bezier(0.25, 0.46, 0.45, 0.94)', 'Quad Out — Smooth Ease'], ['cubic-bezier(0.215,0.61,0.355,1)', 'Cubic Out — Natural Ease'], ['cubic-bezier(0.165,0.84,0.44,1)', 'Quart Out — Energetic Ease'], ['cubic-bezier(0.075,0.82,0.165,1)', 'Circ Out — Fast Exit'], ['cubic-bezier(0.455,0.03,0.515,0.955)', 'Quad In-Out — Balanced'], ['cubic-bezier(0.77,0,0.175,1)', 'Quart In-Out — Precise Symmetric'], ['cubic-bezier(0.86,0,0.07,1)', 'Quint In-Out — Dramatic Symmetric'], ['cubic-bezier(1,0,0,1)', 'Expo In-Out — Mechanical Snap'], ['cubic-bezier(0.55,0.055,0.675,0.19)', 'Cubic In — Slow Start'], ['cubic-bezier(0.895,0.03,0.685,0.22)', 'Quart In — Heavy Acceleration'], ['cubic-bezier(0.95,0.05,0.795,0.035)', 'Expo In — Maximum Acceleration'], ['cubic-bezier(0.4,0,0.2,1)', 'Material Standard — Balanced'], ['cubic-bezier(0.4, 0, 0.2, 1)', 'Material Standard — Balanced'], ['cubic-bezier(0,0,0.2,1)', 'Material Decelerate — Entrance'], ['cubic-bezier(0, 0, 0.2, 1)', 'Material Decelerate — Entrance'], ['cubic-bezier(0.4,0,1,1)', 'Material Accelerate — Exit'], ['cubic-bezier(0.4, 0, 1, 1)', 'Material Accelerate — Exit'], ]; // Normalize whitespace for matching const normalized = curve.replace(/\s+/g, ''); for (const [pattern, label] of KNOWN) { if (pattern.replace(/\s+/g, '') === normalized) return label; } // Spring / linear easing (Airbnb-style) if (curve.startsWith('linear(')) return 'Spring Easing — Physics-Based'; // Fallback: extract control points for rough characterization const m = curve.match(/cubic-bezier\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)/); if (m) { const [, x1, , x2] = m.map(Number); if (x1 > 0.7 && Number(m[3]) > 0.7) return 'Ease In — Accelerating'; if (x2 < 0.4 && Number(m[4]) > 0.6) return 'Ease Out — Decelerating'; return 'Custom Ease — Balanced'; } if (curve === 'ease') return 'CSS ease — Default'; if (curve === 'ease-in-out') return 'Ease In-Out — Symmetric'; if (curve === 'ease-out') return 'Ease Out — Decelerating'; if (curve === 'ease-in') return 'Ease In — Accelerating'; if (curve === 'linear') return 'Linear — No Easing'; return curve; } /** Classify keyframe animation by the properties it animates */ function _classifyKeyframe(steps: Record>): string { const props = new Set( Object.values(steps).flatMap(step => Object.keys(step)) ); const hasOpacity = props.has('opacity'); const hasTransform = props.has('transform'); const hasColor = props.has('color') || props.has('background') || props.has('background-color'); const hasBorder = props.has('border-color') || props.has('border'); const hasFilter = props.has('filter'); if (hasTransform && hasOpacity) return 'Fade + Move'; if (hasOpacity && !hasTransform) return 'Fade'; if (hasTransform && !hasOpacity) { // Try to detect type from transform values const transforms = Object.values(steps).flatMap(s => s.transform ? [s.transform] : []); const allTransformStr = transforms.join(' '); if (/rotate/.test(allTransformStr)) return 'Rotate / Spin'; if (/scale/.test(allTransformStr)) return 'Scale'; if (/translate/.test(allTransformStr)) return 'Slide'; return 'Transform'; } if (hasColor || hasBorder) return 'Color Pulse'; if (hasFilter) return 'Filter'; return 'Custom'; } /** Use hint based on animation name */ function _keyframeUseHint(name: string): string { const n = name.toLowerCase(); if (/fade.?in|enter/.test(n)) return 'Element entrance'; if (/fade.?out|exit|leave/.test(n)) return 'Element exit'; if (/spin|rotate|loader|loading/.test(n)) return 'Loading indicator'; if (/pulse|ping|glow|blink/.test(n)) return 'Attention / status'; if (/slide.?in|slide.?up|slide.?down/.test(n)) return 'Panel / drawer enter'; if (/slide.?out/.test(n)) return 'Panel / drawer exit'; if (/swipe/.test(n)) return 'Swipe gesture dismiss'; if (/bounce|spring|wiggle/.test(n)) return 'Playful feedback'; if (/shake|error/.test(n)) return 'Error feedback'; if (/scale|zoom/.test(n)) return 'Focus / emphasis'; if (/skeleton|shimmer/.test(n)) return 'Loading placeholder'; return 'UI transition'; } function generateMotionSection(rawData: any): string { if (!rawData) return ''; const allTransitions: string[] = rawData.allTransitions || []; const keyframes: Record>> = rawData.keyframes || {}; const cssVars: Record = rawData.cssCustomProperties || {}; // ── Collect easing CSS custom properties ────────────────────────── const easingVarEntries = Object.entries(cssVars).filter(([k, v]) => /easing|ease|transition-timing|motion-ease/i.test(k) && (v.includes('cubic-bezier') || v.includes('linear(') || /^ease/.test(v) || v === 'linear') ); const durationVarEntries = Object.entries(cssVars).filter(([k, v]) => (/duration|speed|timing|transition-duration|motion-duration/i.test(k)) && /\d+(?:ms|s)\b/.test(v) && !v.includes('cubic-bezier') && !v.includes('linear(') ); // ── Parse all duration values (from CSS vars + allTransitions) ────── const allDurationsMs: number[] = []; for (const [, v] of durationVarEntries) { allDurationsMs.push(..._parseDurationsMs(v)); } for (const tr of allTransitions) { allDurationsMs.push(..._parseDurationsMs(tr)); } const uniqueDurationsMs = [...new Set(allDurationsMs)].filter(d => d > 0).sort((a, b) => a - b); // ── Early exit: skip if virtually no motion data ────────────────── const hasKeyframes = Object.keys(keyframes).length > 0; const hasMeaningfulTransitions = allTransitions.some(t => /\d+(ms|s)/.test(t)); const hasEasingVars = easingVarEntries.length > 0; if (!hasKeyframes && !hasMeaningfulTransitions && !hasEasingVars) return ''; // ── Collect dominant easing curves from allTransitions ──────────── const transitionCurvesRaw: string[] = []; for (const tr of allTransitions) { const cubeMatches = tr.match(/cubic-bezier\([^)]+\)/g) || []; transitionCurvesRaw.push(...cubeMatches); if (/\bease-out\b/.test(tr)) transitionCurvesRaw.push('ease-out'); if (/\bease-in-out\b/.test(tr)) transitionCurvesRaw.push('ease-in-out'); if (/\bease-in\b/.test(tr)) transitionCurvesRaw.push('ease-in'); if (/(?:^|\s)ease\b/.test(tr)) transitionCurvesRaw.push('ease'); if (/\blinear\b/.test(tr) && !tr.includes('linear(')) transitionCurvesRaw.push('linear'); } // Also from easing vars for (const [, v] of easingVarEntries) transitionCurvesRaw.push(v.trim()); // Deduplicate by normalized form, keep most frequent const curveCounts = new Map(); for (const c of transitionCurvesRaw) { const norm = c.replace(/\s+/g, ''); curveCounts.set(norm, (curveCounts.get(norm) || 0) + 1); } const topCurves = [...curveCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([c]) => c); let md = '## 7. Motion & Interaction\n\n'; // ── Easing System ────────────────────────────────────────────────── if (easingVarEntries.length > 0) { md += '### Easing System\n\n'; md += '| CSS Variable | Curve | Semantic Name |\n'; md += '|--------------|-------|---------------|\n'; for (const [k, v] of easingVarEntries.slice(0, 12)) { const display = v.length > 60 ? v.slice(0, 57) + '...' : v; const name = _easingName(v.trim()); md += `| \`${k}\` | \`${display}\` | ${name} |\n`; } md += '\n'; } else if (topCurves.length > 0) { // No CSS vars but we extracted curves from transitions md += '### Dominant Easing Curves\n\n'; md += '| Curve | Semantic Name |\n'; md += '|-------|---------------|\n'; for (const c of topCurves) { md += `| \`${c}\` | ${_easingName(c)} |\n`; } md += '\n'; } // ── Duration Scale ───────────────────────────────────────────────── if (durationVarEntries.length > 0 || uniqueDurationsMs.length > 0) { md += '### Duration Scale\n\n'; if (durationVarEntries.length > 0) { // Named duration tokens (preferred — from CSS custom properties) md += '| CSS Variable | Value | Tier |\n'; md += '|--------------|-------|------|\n'; for (const [k, v] of durationVarEntries.slice(0, 10)) { const durations = _parseDurationsMs(v); const tier = durations.length > 0 ? _durationLabel(durations[0]) : '—'; md += `| \`${k}\` | \`${v}\` | ${tier} |\n`; } md += '\n'; } else if (uniqueDurationsMs.length > 0) { // No named tokens — show raw scale md += '| Value | Tier | Typical Use |\n'; md += '|-------|------|-------------|\n'; const USE_HINTS: Record = { Instant: 'State toggles, instant feedback', Fast: 'Hover states, micro-interactions', Normal: 'Panel open/close, navigation', Slow: 'Hero animations, page transitions', Cinematic: 'Splash, marketing reveals', }; for (const ms of uniqueDurationsMs.slice(0, 8)) { const tier = _durationLabel(ms); const display = ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(2).replace(/\.?0+$/, '')}s`; md += `| \`${display}\` | ${tier} | ${USE_HINTS[tier] || '—'} |\n`; } md += '\n'; } } // ── CSS-Ready Transition Snippets ───────────────────────────────── const meaningfulTransitions = allTransitions .filter(t => /\d+(?:ms|s)/.test(t) && t !== 'all' && t.length > 10) .slice(0, 4); if (meaningfulTransitions.length > 0) { md += '### Copy-Paste Transition Snippets\n\n'; for (const tr of meaningfulTransitions) { // Parse primary property for label const firstProp = tr.split(' ')[0].trim(); const label = firstProp === 'color' ? 'Color / Background' : firstProp === 'border' || firstProp === 'border-color' ? 'Border' : firstProp === 'transform' ? 'Transform' : firstProp === 'opacity' ? 'Opacity' : firstProp === 'box-shadow' ? 'Shadow' : firstProp === 'filter' ? 'Filter' : firstProp === 'background' || firstProp === 'background-color' ? 'Background' : 'All Props'; const display = tr.length > 120 ? tr.slice(0, 117) + '...' : tr; md += `**${label}**\n\`\`\`css\ntransition: ${display};\n\`\`\`\n\n`; } } // ── Keyframe Catalog ────────────────────────────────────────────── let uniqueKfCount = 0; if (hasKeyframes) { // Deduplicate: skip repetitive grid-dot-X-Y-Z patterns, group to base name const kfEntries = Object.entries(keyframes); const seen = new Set(); const uniqueKf: Array<[string, Record>]> = []; for (const [name, steps] of kfEntries) { // Collapse grid-dot-N-M-BaseName → BaseName (keep one representative) const baseName = name.replace(/^grid-dot-\d+-\d+-/, '').replace(/-\d+$/, ''); if (seen.has(baseName)) continue; seen.add(baseName); uniqueKf.push([name, steps]); } md += '### Keyframe Animation Catalog\n\n'; md += '| Animation Name | Type | Animated Props | Use Hint |\n'; md += '|----------------|------|----------------|----------|\n'; for (const [name, steps] of uniqueKf.slice(0, 20)) { const type = _classifyKeyframe(steps as any); const props = [...new Set( Object.values(steps).flatMap((s: any) => Object.keys(s)) )].join(', '); const hint = _keyframeUseHint(name); const displayName = name.length > 40 ? name.slice(0, 37) + '...' : name; md += `| \`${displayName}\` | ${type} | ${props} | ${hint} |\n`; } uniqueKfCount = seen.size; if (uniqueKf.length > 20) { md += `\n> *${uniqueKf.length - 20} additional animations omitted (repetitive variants). See raw keyframes in \`raw-css.json\`.*\n`; } md += '\n'; } // ── Motion Fingerprint ──────────────────────────────────────────── { const medianMs = uniqueDurationsMs.length > 0 ? uniqueDurationsMs[Math.floor(uniqueDurationsMs.length / 2)] : null; const speedChar = medianMs === null ? null : medianMs <= 100 ? 'Fast & Responsive' : medianMs <= 250 ? 'Balanced Pacing' : medianMs <= 500 ? 'Deliberate & Calm' : 'Cinematic & Slow'; // Detect easing personality from top curve const dominantCurve = topCurves[0] || ''; const hasSpring = easingVarEntries.some(([, v]) => v.startsWith('linear(')); const easingChar = hasSpring ? 'physics-based spring curves' : dominantCurve.includes('expo') || dominantCurve.includes('0.19,1') ? 'exponential ease-out (snappy)' : dominantCurve.includes('quint') || dominantCurve.includes('0.23,1') ? 'quint ease-out (energetic)' : dominantCurve.includes('0.25,0.46') || dominantCurve.includes('quad') ? 'quad ease-out (smooth)' : dominantCurve.includes('in-out') || dominantCurve.includes('0.77,0') ? 'symmetric in-out (precise)' : dominantCurve ? 'custom cubic-bezier curves' : 'standard CSS easings'; if (speedChar || easingChar) { md += '### Motion Fingerprint\n\n'; if (speedChar && medianMs !== null) { const displayMedian = medianMs < 1000 ? `${Math.round(medianMs)}ms` : `${(medianMs / 1000).toFixed(2).replace(/\.?0+$/, '')}s`; md += `- **Speed Character**: ${speedChar} (median duration: \`${displayMedian}\`)\n`; } if (easingChar) md += `- **Dominant Easing**: ${easingChar}\n`; if (hasKeyframes) { const kfCount = Object.keys(keyframes).length; const uniqueCount = uniqueKfCount || kfCount; md += `- **Animation Library**: ${uniqueCount} unique animation${uniqueCount === 1 ? '' : 's'} (${kfCount} total including variants)\n`; } // Motion philosophy sentence const philosophy = speedChar && easingChar ? `This design moves with ${speedChar.toLowerCase()} timing using ${easingChar}, creating a UI that feels ${ speedChar.includes('Fast') ? 'crisp and immediate' : speedChar.includes('Balanced') ? 'natural and fluid' : speedChar.includes('Deliberate') ? 'calm and controlled' : 'slow and cinematic' }.` : null; if (philosophy) md += `\n> ${philosophy}\n`; md += '\n'; } } return md; } // ── Decisions JSON (Phase 0.3) ────────────────────────────────────── // Structured record of design decisions with evidence — agents can validate // each rule programmatically. Written alongside DESIGN.md as design-decisions.json. interface DesignDecision { rule: string; // human-readable rule type: 'do' | 'dont'; category: 'color' | 'typography' | 'layout' | 'shape' | 'motion' | 'depth' | 'component'; evidence: string[]; // CSS selectors or computed values that triggered this rule confidence: 'high' | 'medium' | 'low'; // how sure are we this rule reflects intent? } /** * Build evidence-attached decisions from rawData + tokens. Each decision references * concrete extracted values so an agent can verify "yes, the body bg is indeed #ffffff" * before applying the rule. Prevents hallucinations like "don't use white" on a white site. */ function buildDecisionsRecord(rawData: any, tokens: any): { decisions: DesignDecision[]; meta: { generatedAt: string; algorithmVersion: string } } { const decisions: DesignDecision[] = []; const elements = rawData.elements; const bodyBg = elements.body?.styles?.backgroundColor; const resolvedBodyBg = tokens.colors?.background?.primary || bodyBg; const bodyBgRgb = resolvedBodyBg ? parseRgb(resolvedBodyBg) : null; // Decision: Page canvas color (cross-validated against extracted body bg) if (bodyBgRgb && resolvedBodyBg) { const isWhite = bodyBgRgb[0] === 255 && bodyBgRgb[1] === 255 && bodyBgRgb[2] === 255; const isDark = luminance(bodyBgRgb) < 0.2; const bgColor = nameColor(resolvedBodyBg); if (isWhite) { decisions.push({ rule: `Use pure white (#ffffff) as the page canvas`, type: 'do', category: 'color', evidence: [`elements.body.styles.backgroundColor = ${bodyBg}`, `tokens.colors.background.primary = ${resolvedBodyBg}`], confidence: 'high', }); } else if (isDark) { decisions.push({ rule: `Use dark canvas (${bgColor.displayValue}) — this is a dark-mode-native design`, type: 'do', category: 'color', evidence: [`elements.body.styles.backgroundColor = ${bodyBg}`, `luminance = ${luminance(bodyBgRgb).toFixed(2)}`], confidence: 'high', }); decisions.push({ rule: `Don't use light backgrounds — the dark canvas is the native medium`, type: 'dont', category: 'color', evidence: [`luminance(${resolvedBodyBg}) = ${luminance(bodyBgRgb).toFixed(2)} (threshold: <0.2)`], confidence: 'high', }); } else { // Off-white / cream / warm — intentional non-white decisions.push({ rule: `Use ${bgColor.name} (${bgColor.displayValue}) as canvas — intentionally not pure white`, type: 'do', category: 'color', evidence: [`elements.body.styles.backgroundColor = ${bodyBg}`], confidence: 'medium', }); } } // Decision: Primary accent color (validated against tokens.accent + componentVariants buttons) const primaryAccent = tokens.colors?.accent?.primary; if (primaryAccent) { const accentName = nameColor(primaryAccent); const buttonBgs: string[] = (rawData.componentVariants?.buttons || []) .map((b: any) => b.styles?.backgroundColor) .filter((c: string) => c && c.includes(primaryAccent.slice(0, 6))); decisions.push({ rule: `Use ${accentName.name} (${accentName.displayValue}) as the primary interactive accent`, type: 'do', category: 'color', evidence: [ `tokens.colors.accent.primary = ${primaryAccent}`, `buttons with accent bg: ${buttonBgs.length}`, ], confidence: buttonBgs.length > 0 ? 'high' : 'medium', }); } // Decision: Font weight ceiling (cross-validated against tokens.typography) const maxWeight = tokens.typography?.fontWeight?.bold || tokens.typography?.fontWeights?.[tokens.typography?.fontWeights?.length - 1]; if (maxWeight) { const weightNum = parseInt(String(maxWeight), 10); if (weightNum <= 500) { decisions.push({ rule: `Don't use weights heavier than ${weightNum} — this design relies on type restraint, not muscle`, type: 'dont', category: 'typography', evidence: [`tokens.typography max weight = ${weightNum}`, `extracted via getComputedStyle()`], confidence: 'high', }); } else if (weightNum >= 800) { decisions.push({ rule: `Use heavy weight ${weightNum} for primary display — the brand voice leans on typographic strength`, type: 'do', category: 'typography', evidence: [`tokens.typography max weight = ${weightNum}`], confidence: 'high', }); } } // Decision: Border-radius personality const buttonRadius = tokens.layout?.borderRadius?.button || tokens.shape?.radius?.button; if (buttonRadius) { const radiusPx = parsePixels(String(buttonRadius)) || 0; if (radiusPx >= 999 || radiusPx >= 9999) { decisions.push({ rule: `Use fully-rounded pills (${buttonRadius}) for primary CTAs — signature shape language`, type: 'do', category: 'shape', evidence: [`tokens.layout.borderRadius.button = ${buttonRadius}`, `componentVariants.buttons.*.styles.borderRadius`], confidence: 'high', }); } else if (radiusPx <= 4) { decisions.push({ rule: `Keep corners crisp (${radiusPx}px max) — sharp geometry is part of the brand`, type: 'do', category: 'shape', evidence: [`tokens.layout.borderRadius.button = ${buttonRadius}`], confidence: 'medium', }); } } // Decision: Shadow design language const allShadows: string[] = rawData.allShadows || []; const nonNoneShadows = allShadows.filter((s: string) => s && s !== 'none' && !s.startsWith('rgba(0, 0, 0, 0)')); if (nonNoneShadows.length === 0) { decisions.push({ rule: `Don't use box-shadows — this is a flat, border-driven design (depth via 1px borders, not blur)`, type: 'dont', category: 'depth', evidence: [`allShadows count: ${allShadows.length}, non-empty: 0`], confidence: 'high', }); } else if (nonNoneShadows.length > 5) { decisions.push({ rule: `Use the documented shadow scale for elevation — multi-level shadows define hierarchy`, type: 'do', category: 'depth', evidence: [`distinct non-empty shadows: ${nonNoneShadows.length}`], confidence: 'high', }); } return { decisions, meta: { generatedAt: new Date().toISOString(), algorithmVersion: '2026.0', }, }; } // ── Do's and Don'ts Generator ─────────────────────────────────────── function generateDosAndDonts(rawData: any, tokens: any): { dos: string[]; donts: string[] } { const dos: string[] = []; const donts: string[] = []; const elements = rawData.elements; // Background color analysis // v2.6 Fix-5 — ALWAYS use the tokenized canvas (tokens.colors.background.primary). tokenize handles // transparent bodies AND the dark-body/light-content rescue (notion: body #191918 → canvas white). // Previously this used raw body bg when non-transparent, which made §8 Do/Don'ts say "dark-mode-native" // while §1 said "pure-white canvas" — a direct contradiction the re-audit flagged as a ship-blocker. const bodyBg = elements.body?.styles?.backgroundColor; const resolvedBodyBg = tokens.colors?.background?.primary || bodyBg || null; const bodyBgRgb = resolvedBodyBg ? parseRgb(resolvedBodyBg) : null; if (bodyBgRgb) { const bgColor = nameColor(resolvedBodyBg!); const isWhite = bodyBgRgb[0] === 255 && bodyBgRgb[1] === 255 && bodyBgRgb[2] === 255; const isDark = luminance(bodyBgRgb) < 0.2; if (!isWhite && !isDark) { dos.push(`Use ${bgColor.name} (\`${bgColor.displayValue}\`) as the page background — it's intentionally not pure white`); donts.push(`Don't use pure white (\`#ffffff\`) as a page background — the warm tone is part of the brand identity`); } else if (isDark) { dos.push(`Use dark background (\`${bgColor.displayValue}\`) as the foundation — this is a dark-mode-native design`); donts.push(`Don't use light backgrounds — the dark canvas is the native medium`); } } // Text color analysis const bodyColor = elements.body?.styles?.color; const bodyColorRgb = parseRgb(bodyColor || ''); if (bodyColorRgb) { const txtColor = nameColor(bodyColor); const isPureBlack = bodyColorRgb[0] === 0 && bodyColorRgb[1] === 0 && bodyColorRgb[2] === 0; if (!isPureBlack && luminance(bodyColorRgb) < 0.2) { dos.push(`Use ${txtColor.name} (\`${txtColor.displayValue}\`) for text — not pure black, it's warmer and more readable`); donts.push(`Don't use pure black (\`#000000\`) for text — the near-black adds warmth`); } } // Typography const bodyFont = extractFontName(elements.body?.styles?.fontFamily || ''); if (bodyFont !== 'system-ui') { dos.push(`Use ${bodyFont} as the primary typeface — it defines the brand personality`); donts.push(`Don't substitute with generic sans-serif or serif — the custom font carries the brand`); } // Font weight analysis — sample elements + ALL CSS custom properties matching --font-weight-* // (essential: many design systems define their weight scale in CSS vars rather than applying // it to every element, so element sampling alone misses values like --font-weight-bold: 680) const allWeights = new Set(); for (const el of Object.values(elements)) { if (el && (el as any).styles?.fontWeight) { allWeights.add((el as any).styles.fontWeight); } } const cssVarsAll = rawData.cssCustomProperties || {}; for (const [key, val] of Object.entries(cssVarsAll)) { if (/font-?weight/i.test(key) && typeof val === 'string') { // Strip non-numeric chars (handle "var(--x)" fallback chains) const numeric = val.trim().match(/^\d+$/); if (numeric) allWeights.add(val.trim()); } } const maxWeight = Math.max(...[...allWeights].map(Number).filter(n => !isNaN(n))); if (maxWeight <= 600) { dos.push(`Keep font weights between 400-${maxWeight} — the system uses a narrow weight range for subtle hierarchy`); donts.push(`Don't use weight 700 (bold) or above — ${maxWeight} is the maximum weight in this system`); } else if (maxWeight < 700) { // System uses a non-standard heavy weight (e.g. 680 instead of 700) dos.push(`Use weight ${maxWeight} for the heaviest text — this design uses a custom heavy weight, not the standard 700 (bold)`); donts.push(`Don't substitute weight ${maxWeight} with the standard 700 — the custom value is part of the brand's type personality`); } // Letter spacing at display sizes const headingLS = elements.heading?.styles?.letterSpacing; if (headingLS && headingLS !== 'normal' && parsePixels(headingLS) < 0) { dos.push(`Use negative letter-spacing (${headingLS}) at display sizes for compressed, editorial headlines`); donts.push(`Don't increase letter-spacing on headings — the type is designed to run tight at scale`); } // Shadow patterns const shadows = rawData.allShadows || []; const hasBoxShadow = shadows.length > 0 && shadows.some((s: string) => s !== 'none'); const hasBorders = elements.card?.styles?.border && !elements.card?.styles?.border.includes('0px'); if (hasBorders && !hasBoxShadow) { dos.push(`Use borders for card containment — this design uses border-based depth, not shadow-based`); donts.push(`Don't use heavy box-shadows for cards — borders are the containment mechanism`); } else if (hasBoxShadow) { dos.push(`Use the extracted shadow patterns for elevation — they are tuned to match the brand palette`); donts.push(`Don't invent new shadow values — use only the extracted shadow levels`); } // Border radius const allRadii = rawData.allBorderRadii || []; const hasPillRadius = allRadii.some((r: string) => parsePixels(r) >= 999); if (hasPillRadius) { dos.push(`Use full-pill radius (9999px) only for specific elements (avatars, pills, toggles)`); donts.push(`Don't apply pill radius on rectangular buttons or cards — it's reserved for specific interactive elements`); } // Accent colors const accentPrimary = tokens.colors?.accent?.primary; if (accentPrimary) { const accentName = resolveColorName(accentPrimary); dos.push(`Use ${accentName.name} (\`${accentName.displayValue}\`) as the primary accent — it's the brand's signature interactive color`); donts.push(`Don't introduce additional saturated accent colors — the palette is intentionally controlled`); } // ── Button-specific rules ────────────────────────────────────────── // Use the primary CTA button radius as the rule const btnVariants: any[] = rawData.componentVariants?.buttons || []; // Prefer vibrant-background button; fallback to first non-icon button variant const primaryBtn = btnVariants.find((v: any) => { const rgb = parseRgb(v.styles?.backgroundColor || ''); return rgb && Math.max(...rgb) - Math.min(...rgb) > 80; }) || btnVariants.find((v: any) => { // Non-square (not icon button), has a real radius const r = v.styles?.borderRadius || ''; const px = parsePixels(r); const w = v.rect?.width || 0; const h = v.rect?.height || 0; const isIconButton = w > 0 && h > 0 && Math.abs(w - h) < 4 && h < 48; return px > 0 && !isIconButton && r !== '50%'; }); if (primaryBtn?.styles?.borderRadius) { const r = normalizeRadius(primaryBtn.styles.borderRadius); const px = parsePixels(r); if (px >= 999) { dos.push(`Use pill-shaped radius (${r}) for primary CTA buttons — the brand uses fully-rounded, pill CTAs`); donts.push(`Don't use rectangular buttons for primary actions — the rounded pill shape is the brand standard`); } else if (px >= 20) { dos.push(`Use ${r} border-radius on primary buttons — the brand uses generously-rounded CTAs`); donts.push(`Don't use sharp-cornered or pill buttons — ${r} is the CTA border-radius standard`); } else if (px > 0 && px < 6) { dos.push(`Use small ${r} radius for buttons — this design system uses subtle rounding, not pill shapes`); donts.push(`Don't over-round buttons with pill or large radius — the system uses restrained, sharp-ish corners`); } else if (px >= 6) { dos.push(`Use ${r} border-radius on buttons — the standard corner rounding for interactive elements`); } } // ── Card containment rules ───────────────────────────────────────── const card = elements.card?.styles; if (card) { const cardPadding = card.padding || ''; const cardRadius = card.borderRadius || ''; if (cardPadding && !cardPadding.startsWith('0')) { dos.push(`Apply ${cardPadding} padding inside cards — matches the design system's content breathing room`); } if (cardRadius && parsePixels(cardRadius) > 0) { dos.push(`Use ${cardRadius} border-radius on cards — consistent with the grid card corners`); } } // ── Input/form rules ─────────────────────────────────────────────── const input = elements.input?.styles; if (input && input.border && !input.border.startsWith('0px')) { const inputBorderColor = nameColor(input.borderColor || ''); dos.push(`Style form inputs with a \`${input.border}\` border — use border-based inputs, not floating labels or underline-only`); donts.push(`Don't remove the input border — the border is the primary affordance indicator for form fields`); } // ── Navigation rules ─────────────────────────────────────────────── const nav = elements.nav?.styles; if (nav) { if (nav.position === 'sticky' || nav.position === 'fixed') { dos.push(`Keep navigation ${nav.position} to the top — the design uses a persistent navigation pattern`); } const navHeightPx = parsePixels(nav.height || ''); // Sanity check: real nav bars are 40px–120px; smaller values are parasitic // (line-height, icon size, or a tiny sub-element picked up by the nav selector) if (navHeightPx >= 40 && navHeightPx <= 120) { dos.push(`Maintain nav height at ${nav.height} — consistent vertical space for the navigation bar`); donts.push(`Don't collapse or hide the nav on scroll without an explicit scroll-triggered animation — the design uses fixed nav presence`); } } // ── Hover/interaction state rules (from componentStates) ────────────── const btnStates = rawData.componentStates?.button; if (btnStates?.hover && btnStates?.default) { const hoverBg = btnStates.hover.backgroundColor; const defaultBg = btnStates.default.backgroundColor; if (hoverBg && hoverBg !== defaultBg && hoverBg !== 'rgba(0, 0, 0, 0)') { const hoverColor = nameColor(hoverBg); dos.push(`Transition button background to ${hoverColor.name} (\`${hoverColor.displayValue}\`) on hover — this is the extracted interactive state`); donts.push(`Don't use opacity or brightness filter for button hover — the design uses an explicit background color change`); } } // ── Spacing scale rule (if we have an explicit scale) ───────────── const spacingXs = tokens.spacing?.xs; const spacingXl = tokens.spacing?.xl; if (spacingXs && spacingXl) { const xs = parsePixels(spacingXs); const xl = parsePixels(spacingXl); if (xl / xs >= 8) { dos.push(`Respect the ${xs}px–${xl}px spacing range — the design uses a wide scale for visual breathing room`); } } // Phase 4.6 — Widget-aware brand rules // Generate brand-specific Do's/Don'ts from detected structural patterns const widgets = rawData?.widgets; if (widgets) { if (widgets.hero) { const h = widgets.hero; if (h.isFullViewport) { dos.push(`Make the hero full-viewport (≥70% screen height) — this design uses an immersive hero, not a tall navbar`); } if (h.composition === 'split-left-text' || h.composition === 'split-right-text') { const mediaSide = h.composition === 'split-right-text' ? 'right' : 'left'; dos.push(`Build the hero as a split layout with media on the ${mediaSide} — text and visual share equal weight`); } if (h.composition === 'centered' && !h.hasMedia) { dos.push(`Keep the hero centered and text-driven — no decorative media, typography carries the message`); donts.push(`Don't add hero illustrations or photos — they'll break the typographic restraint`); } if (h.ctaCount >= 2) { dos.push(`Include exactly ${h.ctaCount} CTAs in the hero (primary + secondary) — this design pattern is multi-action, not single-purpose`); } else if (h.ctaCount === 1) { dos.push(`Use a single primary CTA in the hero — the design intentionally avoids choice paralysis`); } } if (widgets.navigation) { const n = widgets.navigation; if (n.position === 'sticky' || n.position === 'fixed') { dos.push(`Make the navigation ${n.position} — it stays visible during scroll, signaling content depth`); } else { donts.push(`Don't make the nav sticky — this design lets it scroll away to maximize content focus`); } if (n.ctaCount === 0 && n.navItemCount > 0) { donts.push(`Don't put a CTA button in the nav — this design uses links only at the top`); } if (n.hasSearchBar) { dos.push(`Include a search input directly in the nav — discoverability is a primary UX value here`); } if (n.hasDarkMode) { dos.push(`Include a theme toggle in the nav — dark mode is a first-class feature`); } } if (widgets.cardGrid) { const c = widgets.cardGrid; if (c.cardHasImage) { dos.push(`Each card includes a ${c.imagePosition} image — imagery is part of the card identity, not optional`); } else if (c.cardHasIcon) { dos.push(`Each card uses an icon (not photo) — the visual language is illustrative, not photographic`); } if (c.columnsDesktop >= 3) { dos.push(`Use a ${c.columnsDesktop}-column grid on desktop — density matters for this content type`); } } if (widgets.pricingTable) { const p = widgets.pricingTable; if (p.highlightedTierIndex >= 0) { dos.push(`Visually highlight the ${ordinal(p.highlightedTierIndex + 1)} pricing tier — the design intentionally guides users toward this option`); } if (p.layout === 'side-by-side') { dos.push(`Place ${p.tierCount} pricing tiers side-by-side on desktop — direct comparison is the value prop`); } } if (widgets.testimonials) { const t = widgets.testimonials; if (t.hasCompanyLogo) { dos.push(`Show company logos with testimonials — social proof relies on brand recognition, not avatars`); } else if (t.hasAvatar) { dos.push(`Show person avatars with testimonials — social proof is human-centric here`); } if (t.layout === 'grid' && t.count >= 4) { dos.push(`Lay out testimonials in a grid (${t.count}+ items) — quantity signals scale and trust`); } } if (widgets.faq?.isAccordion) { dos.push(`Use expandable accordion for the FAQ — saves vertical space and lets users self-navigate`); } if (widgets.ctaBanner && widgets.ctaBanner.count >= 2) { dos.push(`Repeat the primary CTA across ${widgets.ctaBanner.count} banner sections — conversion paths are layered, not single`); } if (widgets.footer) { const f = widgets.footer; if (f.hasNewsletter) { dos.push(`Include a newsletter signup in the footer — email capture is part of the conversion funnel`); } if (f.columnCount >= 4 && f.linkCount >= 20) { dos.push(`Build a content-dense footer (${f.columnCount} columns, ~${f.linkCount} links) — this site treats the footer as a sitemap, not an afterthought`); } else if (f.columnCount <= 2 && f.linkCount < 15) { donts.push(`Don't over-build the footer — this design keeps it minimal (${f.columnCount} cols, ${f.linkCount} links)`); } } } return { dos, donts }; } // ── Responsive Behavior Analysis ──────────────────────────────────── interface ResponsiveDiffEntry { element: string; property: string; desktop: string; mobile: string; } function buildResponsiveDiff(desktopData: any, mobileData: any): ResponsiveDiffEntry[] { if (!mobileData?.elements) return []; const diffs: ResponsiveDiffEntry[] = []; // Elements + properties to diff const DIFF_MAP: Record = { heading: ['fontSize', 'fontWeight', 'lineHeight', 'letterSpacing'], body: ['fontSize', 'lineHeight', 'padding'], nav: ['display', 'flexDirection', 'padding', 'height'], hero: ['fontSize', 'padding', 'height'], button: ['padding', 'fontSize', 'width'], card: ['padding', 'borderRadius', 'width'], main: ['gridTemplateColumns', 'gap', 'padding', 'maxWidth'], sidebar: ['width', 'display'], }; for (const [elName, props] of Object.entries(DIFF_MAP)) { const desktopEl = desktopData.elements?.[elName]; const mobileEl = mobileData.elements?.[elName]; if (!desktopEl || !mobileEl) continue; for (const prop of props) { const dVal = desktopEl.styles?.[prop]; const mVal = mobileEl.styles?.[prop]; if (dVal && mVal && dVal !== mVal && dVal !== '0px' && dVal !== 'normal' && dVal !== 'none') { diffs.push({ element: elName, property: prop, desktop: dVal, mobile: mVal, }); } } } return diffs; } function analyzeResponsive(rawData: any, mobileData: any): { breakpoints: { name: string; width: string; changes: string }[]; collapsingStrategy: string[]; touchTargets: string[]; tokenDiff: ResponsiveDiffEntry[]; } { const breakpoints: { name: string; width: string; changes: string }[] = []; const mediaBreakpoints = rawData.mediaBreakpoints || []; // Parse media breakpoints — extract px values from min-width / max-width queries only // Filter out sub-320px (print/accessibility), values > 3000px (unlikely viewport), and feature queries const bpValues = mediaBreakpoints .filter((bp: string) => /(min|max)-width\s*:\s*\d/.test(bp) || /^\d/.test(bp)) .map((bp: string) => { // Match first significant numeric value — handle "23em" (×16), "744px", etc. const emMatch = bp.match(/(\d+(?:\.\d+)?)em/); if (emMatch) return Math.round(parseFloat(emMatch[1]) * 16); const pxMatch = bp.match(/(\d+(?:\.\d+)?)(?:px)?/); return pxMatch ? Math.round(parseFloat(pxMatch[1])) : 0; }) .filter((v: number) => v >= 320 && v <= 2560) .sort((a: number, b: number) => a - b); // Prefer well-known "step" breakpoints — cluster by proximity (within 30px = same step) const clustered: number[] = []; let last = -999; for (const v of [...new Set(bpValues)] as number[]) { if (v - last > 30) { clustered.push(v); last = v; } } // Pick up to 6 breakpoints: always include values around 375, 744, 1024, 1128, 1280 if present const PREFERRED_ANCHORS = [375, 480, 640, 744, 768, 1024, 1128, 1280, 1440]; const selectedBps: number[] = []; // First: pick breakpoints near preferred anchors for (const anchor of PREFERRED_ANCHORS) { const near = clustered.find(v => Math.abs(v - anchor) <= 30 && !selectedBps.includes(v)); if (near) selectedBps.push(near); } // Fill remaining slots with any clustered values not yet selected for (const v of clustered) { if (selectedBps.length >= 6) break; if (!selectedBps.includes(v)) selectedBps.push(v); } selectedBps.sort((a, b) => a - b); // Remove duplicates and assign names for (const bp of selectedBps) { let name = 'Custom'; if (bp <= 480) name = 'Mobile'; else if (bp <= 767) name = 'Mobile Large'; else if (bp <= 1024) name = 'Tablet'; else if (bp <= 1280) name = 'Desktop'; else if (bp <= 1536) name = 'Large Desktop'; else name = 'Ultra-wide'; breakpoints.push({ name, width: `${bp}px`, changes: bp <= 480 ? 'Single column, compact spacing' : bp <= 767 ? 'Expanded mobile layout' : bp <= 1024 ? 'Multi-column grids begin' : bp <= 1280 ? 'Full feature layout' : 'Maximum content width', }); } // If no breakpoints detected, use standards if (breakpoints.length === 0) { breakpoints.push( { name: 'Mobile', width: '640px', changes: 'Single column layout' }, { name: 'Tablet', width: '768px', changes: '2-column grids' }, { name: 'Desktop', width: '1024px', changes: 'Full layout' }, { name: 'Large Desktop', width: '1280px', changes: 'Maximum width' }, ); } // Build real diff from desktop vs mobile extraction const tokenDiff = buildResponsiveDiff(rawData, mobileData); // Collapsing strategy — built from real diffs + inferred patterns const collapsingStrategy: string[] = []; // Add verified diffs first (most valuable) for (const diff of tokenDiff) { if (diff.property === 'fontSize' && (diff.element === 'heading' || diff.element === 'hero')) { collapsingStrategy.push(`Headlines: ${diff.desktop} → ${diff.mobile} on mobile`); } else if (diff.property === 'gridTemplateColumns' && diff.element === 'main') { collapsingStrategy.push(`Grid: \`${diff.desktop}\` → single column (\`${diff.mobile}\`) on mobile`); } else if (diff.property === 'display' && diff.element === 'sidebar' && diff.mobile === 'none') { collapsingStrategy.push('Sidebar: hidden on mobile'); } else if (diff.property === 'display' && diff.element === 'nav') { collapsingStrategy.push(`Navigation: ${diff.desktop} → ${diff.mobile} on mobile`); } else if (diff.property === 'padding' && diff.element === 'main') { collapsingStrategy.push(`Container padding: ${diff.desktop} → ${diff.mobile} on mobile`); } } // Fallback generic entries for anything not captured in the diff if (!collapsingStrategy.some(s => s.includes('Navigation'))) { collapsingStrategy.push('Navigation: horizontal links → hamburger menu on mobile'); } if (!collapsingStrategy.some(s => s.toLowerCase().includes('card'))) { collapsingStrategy.push('Cards: multi-column → stacked vertical on mobile'); } if (!collapsingStrategy.some(s => s.toLowerCase().includes('footer'))) { collapsingStrategy.push('Footer: multi-column → stacked single column on mobile'); } // Touch targets const touchTargets: string[] = []; const buttonPadding = rawData.elements?.button?.styles?.padding; if (buttonPadding) { touchTargets.push(`Buttons: ${buttonPadding} padding`); } touchTargets.push('Navigation: adequate spacing between items'); touchTargets.push('Interactive elements: minimum 44px touch target recommended'); return { breakpoints, collapsingStrategy, touchTargets, tokenDiff }; } // ── Agent Prompt Guide Generator ──────────────────────────────────── function generateAgentGuide(tokens: any, rawData: any, domain: string): { quickRef: string[]; prompts: string[]; iterationGuide: string[]; } { const bgColor = proseColorName(tokens.colors?.background?.primary || 'rgb(255,255,255)'); const textColor = proseColorName(tokens.colors?.text?.primary || 'rgb(0,0,0)'); const accentColor = proseColorName(tokens.colors?.accent?.primary || 'rgb(59,130,246)'); const borderColor = proseColorName(tokens.colors?.border || 'rgb(229,231,235)'); // Prefer the font APPLIED TO , falling back to filtered fontFaces (excludes KaTeX/icons/math) const fontFaces = rawData.fontFaces || []; const bodyFontStack = rawData.elements?.body?.styles?.fontFamily || ''; const bodyFontParts = bodyFontStack.split(',').map((f: string) => f.trim().replace(/['"]/g, '')); const bodyPrimaryFont = bodyFontParts.find((f: string) => f && !GENERIC_DISPLAY_FONTS.has(f.toLowerCase()) && !f.startsWith('__') ); const filteredFaces = fontFaces.filter((ff: any) => isRealFont(ff.family)); const font = bodyPrimaryFont || filteredFaces[0]?.family || extractFontName(bodyFontStack) || tokens.typography?.fontFamily?.primary || 'system-ui'; const heading = rawData.elements?.heading?.styles; const body = rawData.elements?.body?.styles; const button = rawData.elements?.button?.styles; // Quick reference const quickRef = [ `Background: ${bgColor.name} (\`${bgColor.displayValue}\`)`, `Primary text: ${textColor.name} (\`${textColor.displayValue}\`)`, `Accent: ${accentColor.name} (\`${accentColor.displayValue}\`)`, `Border: ${borderColor.name} (\`${borderColor.displayValue}\`)`, `Font: ${font}`, `Body: ${body?.fontSize || '16px'} weight ${body?.fontWeight || '400'}`, ]; // Example prompts const prompts: string[] = []; if (heading) { prompts.push(`"Create a hero section on ${bgColor.name} background (${bgColor.displayValue}). Headline at ${heading.fontSize} ${font} weight ${heading.fontWeight}, line-height ${normalizeLineHeight(heading.lineHeight, heading.fontSize)}, ${heading.letterSpacing !== 'normal' ? `letter-spacing ${heading.letterSpacing}, ` : ''}color ${textColor.displayValue}."`); } // ── Primary CTA button: prefer Primary Brand variant over raw button element // The generic .button element is often a ghost/outline button; Primary Brand is the real CTA. const buttonVariants: any[] = rawData.componentVariants?.buttons || []; const primaryVariant = buttonVariants.find((v: any) => { const bg = v.styles?.backgroundColor || ''; const rgb = parseRgb(bg); if (!rgb) return false; const sat = Math.max(...rgb) - Math.min(...rgb); return sat > 80; // vibrant = Primary Brand }); const ctaStyles = primaryVariant?.styles || button; const accentRaw = tokens.colors?.accent?.primary; if (ctaStyles || accentRaw) { // Use accent.primary as authoritative CTA bg (better than raw element which may be ghost) const ctaBgRaw = accentRaw || ctaStyles?.backgroundColor || ''; const ctaBg = nameColor(ctaBgRaw); // For text: use contrast-aware auto-detect — CTA text is white on dark/vibrant, dark on light const ctaBgRgb = parseRgb(ctaBgRaw); const ctaBgLum = ctaBgRgb ? luminance(ctaBgRgb) : 0.5; const ctaTxtRaw = ctaStyles?.color || (ctaBgLum < 0.45 ? '#ffffff' : '#111111'); const ctaTxt = nameColor(ctaTxtRaw); // Radius: prefer full pill if detected, else use extracted or md radius — always normalize const ctaRadius = normalizeRadius((ctaStyles?.borderRadius && ctaStyles.borderRadius !== '0px') ? ctaStyles.borderRadius : (tokens.borderRadius?.full || tokens.borderRadius?.md || '9999px')); // Padding: skip 0px — use extracted font size to derive a sensible default const ctaPaddingRaw = ctaStyles?.padding || ''; const ctaPadding = (ctaPaddingRaw && ctaPaddingRaw !== '0px') ? ctaPaddingRaw : `${Math.round(parsePixels(ctaStyles?.fontSize || '14px') * 0.75)}px ${Math.round(parsePixels(ctaStyles?.fontSize || '14px') * 1.5)}px`; const ctaWeight = (ctaStyles?.fontWeight && ctaStyles.fontWeight !== '400') ? ctaStyles.fontWeight : (tokens.typography?.fontWeight?.bold || '700'); prompts.push(`"Create the primary CTA button: \`${ctaBg.displayValue}\` background, \`${ctaTxt.displayValue}\` text, ${ctaRadius} border-radius, ${ctaPadding} padding, ${ctaWeight} weight, ${font} font."`); } const card = rawData.elements?.card?.styles; if (card) { const cardBg = nameColor(card.backgroundColor || tokens.colors?.background?.secondary || ''); const cardRadius = card.borderRadius || tokens.borderRadius?.md || ''; const cardBorder = card.border && !card.border.startsWith('0px') ? card.border : 'none'; const cardShadow = card.boxShadow && card.boxShadow !== 'none' ? 'use extracted shadow' : 'none'; prompts.push(`"Design a card on \`${cardBg.displayValue}\` background. Border: ${cardBorder}. Radius: ${cardRadius}. Shadow: ${cardShadow}. Padding: ${card.padding || '16px'}."`); } prompts.push(`"Build navigation: ${rawData.elements?.nav?.styles?.position || 'sticky'} on \`${bgColor.displayValue}\`. ${font} ${rawData.elements?.nav?.styles?.fontSize || '16px'} weight ${rawData.elements?.nav?.styles?.fontWeight || '400'} for links."`); // ── Brand-Specific Iteration Guide ──────────────────────────────── // Each rule is grounded in an actual extracted value — no generic filler. const iterationGuide: string[] = []; // 1. Canvas / background const isDark = (() => { const rgb = parseRgb(tokens.colors?.background?.primary || ''); return rgb ? luminance(rgb) < 0.3 : false; })(); const canvasWord = isDark ? 'dark canvas' : 'light canvas'; iterationGuide.push(`**Canvas**: Set the ${canvasWord} to ${bgColor.name} (\`${bgColor.displayValue}\`) — every component is composited against this exact base.`); // 2. Text hierarchy (font + weight range) const allWeightsForGuide = new Set(); for (const el of Object.values(rawData.elements || {})) { const w = (el as any)?.styles?.fontWeight; if (w) { const n = parseInt(w); if (!isNaN(n)) allWeightsForGuide.add(n); } } const cssVarsForGuide: Record = rawData.cssCustomProperties || {}; for (const [k, v] of Object.entries(cssVarsForGuide)) { if (/font-?weight/i.test(k) && typeof v === 'string' && /^\d+$/.test(v.trim())) { allWeightsForGuide.add(parseInt(v.trim())); } } const weightArr = [...allWeightsForGuide].filter(n => n >= 100 && n <= 900).sort((a, b) => a - b); const minW = weightArr[0] ?? 400; const maxW = weightArr[weightArr.length - 1] ?? 700; const variableAxes: string[] = rawData.variableAxes || []; const hasWeightAxis = variableAxes.includes('wght'); if (hasWeightAxis) { iterationGuide.push(`**Typography**: ${font} is a variable font with wght axis — use interpolated weights (${minW}–${maxW}) for precise hierarchy, not integer multiples of 100.`); } else { iterationGuide.push(`**Typography**: All type in ${font}, weight range ${minW}–${maxW}. Never exceed ${maxW} — heavier weights break the brand's tonal restraint.`); } // 3. Accent / interactive color if (tokens.colors?.accent?.primary) { const hoverVal = tokens.colors?.accent?.secondary; const hoverColor = hoverVal ? resolveColorName(hoverVal) : null; if (hoverColor) { iterationGuide.push(`**Accent**: ${accentColor.name} (\`${accentColor.displayValue}\`) is the sole interactive color. On hover → ${hoverColor.name} (\`${hoverColor.displayValue}\`). Use CSS transitions, not opacity/brightness filters.`); } else { iterationGuide.push(`**Accent**: ${accentColor.name} (\`${accentColor.displayValue}\`) — one accent, applied consistently to CTAs, active states, and links. Never introduce a second saturated color.`); } } // 4. Focus ring (if extracted) const focusRingColor = (() => { const cvars = rawData.cssCustomProperties || {}; return Object.entries(cvars as Record).find(([k, v]) => /focus.?ring.?color|focusOutline|focus-color/i.test(k) && /^(#|rgb)/.test(v) )?.[1]; })(); if (focusRingColor) { const fcInfo = nameColor(focusRingColor); iterationGuide.push(`**Focus**: Render focus rings in ${fcInfo.name} (\`${fcInfo.displayValue}\`) — never hide focus outlines. The ring color is distinct from the accent to avoid ambiguity.`); } // 5. Easing / motion const allTransitionsForGuide: string[] = rawData.allTransitions || []; const topTransitionCurve = allTransitionsForGuide .flatMap(t => t.match(/cubic-bezier\([^)]+\)/g) || []) .reduce<[string, number]>((top, cur) => { const k = cur.replace(/\s+/g, ''); if (!top[0]) return [k, 1]; return k === top[0] ? [top[0], top[1] + 1] : top; }, ['', 0])[0] || ''; const hasSpring = Object.entries(cssVarsForGuide).some(([, v]) => typeof v === 'string' && v.startsWith('linear(')); const easingLabel = hasSpring ? 'physics-based spring curves' : topTransitionCurve ? `\`${topTransitionCurve}\`` : null; const durationVarsForGuide = Object.entries(cssVarsForGuide) .filter(([k, v]) => /speed|duration/i.test(k) && /\d+(?:ms|s)/.test(v as string) && !(v as string).includes('cubic-bezier')) .map(([, v]) => (v as string).replace(/(? v.match(/(\d+(?:\.\d+)?)(ms|s)/g) || []) .map(v => { const m = v.match(/^(\d+(?:\.\d+)?)(ms|s)$/); if (!m) return 0; return m[2] === 'ms' ? parseFloat(m[1]) : parseFloat(m[1]) * 1000; }) .filter(d => d > 0 && d <= 5000) // sanity cap: UI transitions ≤ 5s (filters aberrant values) .sort((a, b) => a - b); const fastDuration = durationVarsForGuide[0]; const normalDuration = durationVarsForGuide[Math.floor(durationVarsForGuide.length / 2)]; if (easingLabel) { const durationNote = (fastDuration && normalDuration) ? ` Timing: ${Math.round(fastDuration)}ms for micro-interactions, ${Math.round(normalDuration)}ms for layout changes.` : ''; iterationGuide.push(`**Motion**: All transitions use ${easingLabel}.${durationNote} Never use linear for UI transitions — preserve the brand's easing personality.`); } // 6. Shape language const shapeRadii = (() => { const btnR = normalizeRadius(rawData.componentVariants?.buttons?.[0]?.styles?.borderRadius || ''); const cardR = normalizeRadius(rawData.elements?.card?.styles?.borderRadius || ''); return { btn: btnR, card: cardR }; })(); if (shapeRadii.btn || shapeRadii.card) { const parts: string[] = []; if (shapeRadii.btn) parts.push(`CTAs at \`${shapeRadii.btn}\``); if (shapeRadii.card) parts.push(`cards at \`${shapeRadii.card}\``); iterationGuide.push(`**Shape**: ${parts.join(', ')}. Apply border-radius from the extracted scale only — don't invent intermediate values.`); } // 7. Spacing discipline const spacingXs2 = parsePixels(tokens.spacing?.xs || '4px'); const spacingXl2 = parsePixels(tokens.spacing?.xl || '32px'); iterationGuide.push(`**Spacing**: Use the \`--ca-space-*\` token scale (${Math.round(spacingXs2)}px–${Math.round(spacingXl2)}px). All padding, margin, and gap values are multiples from this scale — no magic numbers.`); // 8. CSS tokens iterationGuide.push(`**Tokens**: Import from §11 CSS Export (\`--ca-*\` vars). Never hardcode hex values — always reference a token so theming remains consistent.`); return { quickRef, prompts, iterationGuide }; } // ── Visual Theme Narrative Generator ──────────────────────────────── function generateThemeNarrative(rawData: any, tokens: any, domain: string): string { const elements = rawData.elements; const fontFacesList = rawData.fontFaces || []; // Use resolved background from tokens (handles transparent body) const resolvedBgColor = tokens.colors?.background?.primary || elements.body?.styles?.backgroundColor || 'rgb(255,255,255)'; const resolvedBgRgb = parseRgb(resolvedBgColor); // v2.6 Fix-1/5 — derive dark/light from the TOKENIZED canvas (tokens.colors.background.primary), // not raw elements.body.bg. tokenize already resolves transparent bodies AND rescues the dark-body- // /light-content case (notion: body #191918 but white content → canvas white). Using raw body bg // here produced the contradiction "dark surface (`#ffffff`)". Single source of truth = the canvas. const bgRgb = resolvedBgRgb; const bgInfo = proseColorName(resolvedBgColor); // Font: prefer what is actually APPLIED to (the real brand font), // NOT just the first font-face declaration (which can be KaTeX/math/icons // loaded for a docs section, not the brand typeface). // Fallback to fontFaces only if body's font is generic. const fontFacesList2 = fontFacesList.filter((ff: any) => isRealFont(ff.family)); // First: what body actually uses const bodyFontStack = elements.body?.styles?.fontFamily || ''; const bodyFontParts = bodyFontStack.split(',').map((f: string) => f.trim().replace(/['"]/g, '')); const bodyPrimaryFont = bodyFontParts.find((f: string) => f && !GENERIC_DISPLAY_FONTS.has(f.toLowerCase()) && !f.startsWith('__') ); // If body uses a custom font, prefer that. Otherwise fallback to fontFaces. // v2.6 Fix-3 — single source of truth: use the tokenized primary font so the narrative can NEVER // contradict the YAML (the audit found miro narrative "Open Sans" vs YAML font — a trust-killer). const font = tokens.typography?.fontFamily?.primary || bodyPrimaryFont || fontFacesList2[0]?.family || extractFontName(bodyFontStack); // Text color: use resolved const resolvedTextColor = tokens.colors?.text?.primary || elements.body?.styles?.color || 'rgb(0,0,0)'; const txtInfo = proseColorName(resolvedTextColor); const isDark = bgRgb ? luminance(bgRgb) < 0.3 : false; // isWarm: R > B by at least 3 (catches #f7f7f4 = R247 G247 B244 as warm-tinted) const isWarm = bgRgb ? (!isDark && bgRgb[0] > bgRgb[2] && (bgRgb[0] - bgRgb[2]) >= 3) : false; const isPureWhite = bgRgb ? (bgRgb[0] === 255 && bgRgb[1] === 255 && bgRgb[2] === 255) : false; // Heading analysis const h1 = elements.heading?.styles; const h1Size = h1 ? parsePixels(h1.fontSize) : 0; const h1Weight = h1 ? parseInt(h1.fontWeight) : 0; const h1LS = h1?.letterSpacing; const hasNegativeTracking = h1LS && h1LS !== 'normal' && parsePixels(h1LS) < 0; // Shadow system const shadows = rawData.allShadows?.filter((s: string) => s !== 'none') || []; const hasMultiLayer = shadows.some((s: string) => (s.match(/,/g) || []).length > 1); const hasInset = shadows.some((s: string) => s.includes('inset')); // Accent detection const accentInfo = proseColorName(tokens.colors?.accent?.primary || 'rgb(128,128,128)'); // ── Data-driven signals for richer narrative ── // OpenType features (cv01, ss03, etc.) — strong typographic signature when present const elementsWithFeatures: string[] = []; for (const [name, el] of Object.entries(elements)) { const ffs = (el as any)?.styles?.fontFeatureSettings; if (ffs && ffs !== 'normal' && !elementsWithFeatures.includes(ffs)) { elementsWithFeatures.push(ffs); } } const hasOpenTypeFeatures = elementsWithFeatures.length > 0; // Custom weight scale (non-standard values like 510, 590, 680) const cssVars = rawData.cssCustomProperties || {}; const fontWeightVars = Object.entries(cssVars) .filter(([k, v]) => /font-?weight/i.test(k) && typeof v === 'string' && /^\d+$/.test((v as string).trim())) .map(([k, v]) => parseInt(v as string)); const hasCustomWeights = fontWeightVars.some(w => w !== 100 && w !== 200 && w !== 300 && w !== 400 && w !== 500 && w !== 600 && w !== 700 && w !== 800 && w !== 900); const maxCustomWeight = fontWeightVars.length > 0 ? Math.max(...fontWeightVars) : 0; // Translucent borders — signature of dark-mode systems const allColorStrings: string[] = rawData.allColors || []; const hasTranslucentBorders = allColorStrings.some(c => /rgba\(\s*255\s*,\s*255\s*,\s*255\s*,\s*0?\.0[0-9]\)/i.test(c) || /rgba\(\s*255\s*,\s*255\s*,\s*255\s*,\s*0?\.1\d?\)/i.test(c) ); // Contrast ratio (rough) const bgLum = bgRgb ? luminance(bgRgb) : 1; const txtRgbForContrast = parseRgb(resolvedTextColor || ''); const textLum = txtRgbForContrast ? luminance(txtRgbForContrast) : 0; // v2.6 Fix-5 — proper WCAG contrast formula (L1+0.05)/(L2+0.05), which caps at 21:1. // The old ratio omitted the +0.05 offset and printed impossible values like "100:1"/"94.1:1". const contrastRatio = (Math.max(bgLum, textLum) + 0.05) / (Math.min(bgLum, textLum) + 0.05); const isHighContrast = contrastRatio > 12; // Saturation of accent const accentRgb = parseRgb(tokens.colors?.accent?.primary || ''); const accentSat = accentRgb ? Math.max(...accentRgb) - Math.min(...accentRgb) : 0; const hasVibrantAccent = accentSat > 100; // Color count (palette richness) const distinctColors = new Set(allColorStrings.map(c => c.replace(/\s/g, ''))).size; // Build the narrative — paragraph 1 (canvas & atmosphere) let narrative = ''; if (isDark) { const bgDescriptor = bgLum < 0.02 ? 'near-pure-black' : bgLum < 0.05 ? 'inky black' : 'dark'; const bgArticle = /^[aeiou]/i.test(bgDescriptor) ? 'an' : 'a'; narrative += `${domain} commits fully to dark-mode as the native medium, not as a toggled alternative. The canvas is ${bgArticle} ${bgDescriptor} surface (\`${bgInfo.displayValue}\`) `; if (hasTranslucentBorders) { narrative += `where elevation is communicated through subtle white-opacity gradations rather than traditional shadows — semi-transparent white borders act as the primary depth indicator, like wireframes etched in moonlight. `; } else { narrative += `where surfaces are layered through stepped luminance — each elevated level is slightly less dark than the one beneath it, creating depth without color shifts. `; } } else if (isPureWhite) { narrative += `${domain} uses a pure-white canvas (\`#ffffff\`), letting typography, color and imagery carry the visual weight. `; } else if (isWarm) { narrative += `${domain}'s canvas is a warm, intentionally-tinted surface (\`${bgInfo.displayValue}\`) — a refusal of the default white that immediately signals editorial intent. This isn't sterility, it's an invitation to read. `; } else { narrative += `${domain} sits on a ${bgInfo.name.toLowerCase()} canvas (\`${bgInfo.displayValue}\`), a neutral foundation calibrated for sustained reading and component contrast. `; } narrative += '\n\n'; // Paragraph 2 — typography signature // v2.7 B.8 — lead with the DISPLAY signature (serif-vs-sans is the single most recognizable // thing about a design, and the old narrative hid it by anchoring on the BODY font). const dispSig = (rawData as any).displaySignature; if (dispSig && dispSig.family && dispSig.family.toLowerCase() !== (font || '').toLowerCase()) { const kind = dispSig.isSerif ? (dispSig.isItalic ? 'serif italic' : 'serif') : (dispSig.isItalic ? 'italic sans' : 'sans-serif'); narrative += `Headlines are set in **${dispSig.family}**, a ${kind} display face (the dominant type signature, e.g. "${dispSig.sample}" at ${dispSig.fontSize}). `; } const isGenericNarrativeFont = !font || /^(system-ui|-apple-system|sans-serif|serif|monospace|inherit|ui-sans-serif|ui-serif)$/i.test(font); if (!isGenericNarrativeFont) { narrative += `${dispSig && dispSig.family && dispSig.family.toLowerCase() !== (font || '').toLowerCase() ? 'Body text is set in' : 'Typography is anchored in'} **${font}**`; if (hasOpenTypeFeatures) { const _featStr = elementsWithFeatures[0] || ''; const _featCodes = (_featStr.match(/"([a-z0-9]+)"/gi) || []).map(f => f.replace(/"/g, '').toLowerCase()); const _UTILITY_OT = new Set(['tnum', 'onum', 'lnum', 'pnum', 'frac', 'kern', 'liga', 'sups', 'subs', 'smcp', 'c2sc']); const _hasSignatureFeatures = _featCodes.some(f => !_UTILITY_OT.has(f)); if (_hasSignatureFeatures) { narrative += `, deployed with OpenType features enabled globally (\`${_featStr}\`). These aren't decorative — they're load-bearing: without them, the typeface reverts to its generic state and the design loses its specific texture. `; } else { const _utilLabel = _featCodes[0] === 'tnum' ? 'tabular number alignment' : _featCodes[0] === 'onum' ? 'oldstyle numerals' : `OpenType utility features (\`${_featStr}\`)`; narrative += ` with ${_utilLabel} for data-dense contexts. `; } } else { narrative += `. `; } if (hasCustomWeights && maxCustomWeight > 0) { narrative += `The weight scale rejects the standard 100/200/.../900 ladder in favor of custom values (max **${maxCustomWeight}** in this system) — a deliberate signal that this design system is precisely tuned, not assembled from defaults. `; } else if (h1Weight >= 100 && h1Weight <= 400) { // v2.7 A.2 — guard against garbage computed reads: weight 0/NaN is a bad extraction, not "light". narrative += `Headlines run at a remarkably light weight (${h1Weight}) — restraint over assertion, creating an editorial calm that invites reading rather than demanding it. `; } else if (h1Weight >= 600) { narrative += `Headlines use weight ${h1Weight} for confident, assertive presence — the brand wants to be heard. `; } if (hasNegativeTracking) { narrative += `Negative letter-spacing at display sizes (${h1LS} at ${h1?.fontSize}) compresses headlines into engineered blocks, dense and ${isDark ? 'authoritative' : 'precise'}. `; } } else { narrative += `The system font stack (${font || 'native UI fonts'}) trades brand voice for performance and platform familiarity. `; } narrative += '\n\n'; // Paragraph 3 — text, accent, depth const txtRgb = parseRgb(resolvedTextColor || ''); narrative += `Body text reads in ${txtInfo.name} (\`${txtInfo.displayValue}\`)`; if (isDark && txtRgb) { if (luminance(txtRgb) < 0.85) narrative += ` — slightly under-bright, preventing eye strain on the dark surface`; } else if (!isDark && txtRgb && txtRgb[0] !== 0 && luminance(txtRgb) < 0.2) { narrative += ` — softened from pure black, a small but deliberate detail that lowers reading friction`; } if (isHighContrast) narrative += ` (contrast ratio ${contrastRatio.toFixed(1)}:1, well above WCAG AA)`; narrative += `. `; if (accentInfo.hex !== '#808080') { if (hasVibrantAccent) { narrative += `**${accentInfo.name}** (\`${accentInfo.displayValue}\`) is the single high-saturation color in the system — it earns its presence only on interactive elements, making CTAs and links the unmistakable focal points against the otherwise neutral palette. `; } else { narrative += `**${accentInfo.name}** (\`${accentInfo.displayValue}\`) provides interactive distinction without visual aggression. `; } } if (hasMultiLayer) { narrative += `Elevation is built from multi-layer shadow stacks — each level combines several blurred layers at varying offsets to simulate how light falls on stacked surfaces. `; } else if (hasInset) { narrative += `Inset shadows replace the usual drop-shadows, giving interactive elements a tactile, pressed-into-surface feel — interactions push *into* the canvas rather than float above it. `; } else if (shadows.length === 0) { narrative += `Shadow is absent by design — depth is conveyed through ${hasTranslucentBorders ? 'translucent borders and luminance steps' : 'borders, spacing, and color differentiation'} rather than light simulation. `; } narrative += '\n'; // Border radius philosophy const allRadii = rawData.allBorderRadii || []; const hasFullRadius = allRadii.some((r: string) => parsePixels(r) >= 999); const hasMedRadius = allRadii.some((r: string) => { const p = parsePixels(r); return p >= 8 && p <= 20; }); const hasSharpRadius = allRadii.filter((r: string) => parsePixels(r) === 0).length > allRadii.length * 0.6; if (hasFullRadius && hasMedRadius) { narrative += `Border radius varies from pill-shaped (fully rounded) to moderately rounded elements, creating visual rhythm. `; } else if (hasSharpRadius) { narrative += `Sharp, zero-radius corners throughout reinforce a structured, grid-based aesthetic. `; } // Animation/motion const allTransitions = rawData.allTransitions || []; const hasSmooth = allTransitions.some((t: string) => t.includes('ease') || t.includes('cubic-bezier')); const hasQuick = allTransitions.some((t: string) => { const ms = t.match(/(\d+)ms/); return ms ? parseInt(ms[1]) < 200 : false; }); if (hasSmooth && hasQuick) { narrative += `Interactions are tight and responsive — fast transitions (sub-200ms) with smooth easing keep the UI feeling snappy. `; } else if (hasSmooth) { narrative += `Motion design uses smooth easing curves for polished state transitions. `; } // Variable font mention const variableAxes = rawData.variableAxes || []; if (variableAxes.length > 0) { narrative += `The site uses a variable font with ${variableAxes.join(' + ')} axes, enabling precise weight/width control across the type hierarchy. `; } return narrative; } // ══════════════════════════════════════════════════════════════════════ // YAML FRONTMATTER + TOKEN REFS (Sprint A — spec alpha compatible) // ══════════════════════════════════════════════════════════════════════ interface YamlResult { yaml: string; /** hex → "colors.primary" — used to inject {token.refs} in prose */ colorTokenMap: Map; /** slot → "typography.button" */ typoTokenMap: Map; /** slot → "rounded.md" */ roundedTokenMap: Map; } /** Build canonical color key names (canvas, ink, primary…) from tokens */ function buildColorKeys(tokens: any, isDarkSite: boolean, rawData?: any): Array<{ key: string; hex: string; raw: string }> { const c = tokens.colors || {}; const bg = c.background || {}; const tx = c.text || {}; const ac = c.accent || {}; const sem = c.semantic || {}; const entries: Array<{ key: string; hex: string; raw: string }> = []; const cssVars: Record = rawData?.cssCustomProperties || {}; const componentStates = rawData?.componentStates || {}; function add(key: string, raw: string) { if (!raw) return; // Reject inputs that resolve to pure black — typically a default shadow value, not a real brand color const rawNorm = raw.replace(/\s/g, '').toLowerCase(); if (raw === '#000000' || raw === '#000' || /^rgba?\(0,0,0/.test(rawNorm)) return; const info = nameColor(raw); if (info.hex === '#000000') return; if (info.hex || raw.startsWith('#')) { entries.push({ key, hex: info.hex, raw }); } } // Canvas / backgrounds add(isDarkSite ? 'canvas' : 'background', bg.primary); add('surface-1', bg.secondary); add('surface-2', bg.tertiary); // Text add('ink', tx.primary); add('ink-muted', tx.secondary); add('ink-subtle', tx.muted); // Accent if (ac.primary) { add('primary', ac.primary); add('on-primary', '#ffffff'); // default on-primary; will be overridden by contrast check } // primary-hover: CSS vars first (true hover), ac.secondary last resort (may be unrelated 2nd accent) const hoverRaw = Object.entries(cssVars as Record).find(([k, v]) => /^--color-(accent|brand|primary|interactive|action)-hover/i.test(k) && /^(#|rgb)/.test(v) )?.[1] || Object.entries(cssVars as Record).find(([k, v]) => /-hover$/i.test(k) && /^--color-(accent|brand|primary)/i.test(k) && /^(#|rgb)/.test(v) )?.[1] || Object.entries(cssVars as Record).find(([k, v]) => /button.*hover|hover.*button/i.test(k) && /^(#|rgb)/.test(v) )?.[1] || ac.secondary; // last resort: second most-frequent accent (may differ from CTA hover) if (hoverRaw) add('primary-hover', hoverRaw); // Border if (c.border) add('hairline', c.border); // primary-focus: from CSS vars (--focus-ring-color, --colors-focusOutline, etc.) const focusRaw = Object.entries(cssVars).find(([k, v]) => /focus.?ring.?color|focusOutline|focus-outline-color|focus-color/i.test(k) && /^(#|rgb)/.test(v) )?.[1] || Object.entries(cssVars).find(([k, v]) => /^--color-(focus|ring)-/i.test(k) && /^(#|rgb)/.test(v) )?.[1] // also from componentStates button focus outline color || ((): string | undefined => { const focusState = componentStates?.button?.focus; if (!focusState) return undefined; // box-shadow on focus state often contains the ring color as last color const shadow = focusState.boxShadow || ''; if (!shadow || shadow === 'none') return focusState.outline?.match(/(#[0-9a-f]{3,8}|rgb[a]?\([^)]+\))/i)?.[1]; // Extract last rgb/hex from shadow (ring color is typically the outer ring) const shadowColors = shadow.match(/(#[0-9a-f]{3,8}|rgb[a]?\([^)]+\))/gi) || []; return shadowColors[shadowColors.length - 1]; })(); if (focusRaw) add('primary-focus', focusRaw); // accent-glow: from CSS vars containing glow/halo, or from button default shadow color if accent-tinted const glowRaw = Object.entries(cssVars).find(([k, v]) => /glow|halo|shine|glimmer/i.test(k) && /^(#|rgb)/.test(v) )?.[1] || ((): string | undefined => { const defaultState = componentStates?.button?.default; if (!defaultState) return undefined; const shadow = defaultState.boxShadow || ''; if (!shadow || shadow === 'none') return undefined; // Only include if the shadow color is not near-black/near-white (i.e. it's a colored glow) const shadowColors = shadow.match(/rgba?\([^)]+\)/gi) || []; for (const sc of shadowColors) { const rgb = parseRgb(sc); if (!rgb) continue; const [r, g, b] = rgb; const lum = luminance(rgb); const maxCh = Math.max(r, g, b); const minCh = Math.min(r, g, b); // Colored: not near-black (lum > 0.05), not near-white (lum < 0.85), and has hue (spread > 30) if (lum > 0.05 && lum < 0.85 && maxCh - minCh > 30) return sc; } return undefined; })(); if (glowRaw) add('accent-glow', glowRaw); // Semantic if (sem.error) add('semantic-error', sem.error); if (sem.success) add('semantic-success', sem.success); if (sem.warning) add('semantic-warning', sem.warning); if (sem.info) add('semantic-info', sem.info); // Compute on-primary: white or black based on accent contrast const onPrimaryIdx = entries.findIndex(e => e.key === 'on-primary'); const primaryEntry = entries.find(e => e.key === 'primary'); if (onPrimaryIdx >= 0 && primaryEntry) { const rgb = parseRgb(primaryEntry.raw); if (rgb) { const lum = luminance(rgb); const onColor = lum < 0.4 ? '#ffffff' : '#111111'; entries[onPrimaryIdx] = { key: 'on-primary', hex: nameColor(onColor).hex, raw: onColor }; } } return entries.filter((e, i, arr) => e.hex && arr.findIndex(x => x.key === e.key) === i); } /** Map TypoRole.role → YAML spec key */ function mapTypoKey(role: string): string { const r = role.toLowerCase(); if (r.includes('display') && (r.includes('hero') || r.includes('xl') || r.includes('largest') || r.includes('main'))) return 'display-xl'; if (r.includes('display') || r.includes('hero')) return 'display-xl'; if (r.includes('section') || r.includes('h2') || r.includes('display-lg')) return 'display-lg'; if (r.includes('sub') && r.includes('head')) return 'display-md'; if (r.includes('label') || r.includes('h4')) return 'headline'; if (r.includes('eyebrow') || r.includes('overline')) return 'eyebrow'; if (r.includes('body') && (r.includes('lg') || r.includes('large'))) return 'body-lg'; if (r.includes('link')) return 'body-sm'; if (r.includes('input') || r.includes('mono')) return 'mono'; if (r.includes('button') || r.includes('cta')) return 'button'; if (r.includes('caption') || r.includes('badge')) return 'caption'; if (r.includes('tiny') || r.includes('micro')) return 'caption-sm'; if (r.includes('body')) return 'body'; return 'body'; } /** Build YAML rounded keys from tokens.borderRadius */ function buildRoundedKeys(tokens: any): Array<{ key: string; value: string }> { const br = tokens.borderRadius || {}; const entries: Array<{ key: string; value: string }> = []; const map: Record = { none: br.none || '0px', xs: br.xs || '4px', sm: br.sm || '6px', md: br.md || '8px', lg: br.lg || '12px', xl: br.xl || '16px', xxl: '24px', pill: br.full || '9999px', full: br.full || '9999px', }; // Only include values that exist in the design if (br.none !== undefined || br.none === '0px') entries.push({ key: 'none', value: map.none }); if (br.xs) entries.push({ key: 'xs', value: br.xs }); if (br.sm) entries.push({ key: 'sm', value: br.sm }); if (br.md) entries.push({ key: 'md', value: br.md }); if (br.lg) entries.push({ key: 'lg', value: br.lg }); if (br.xl) entries.push({ key: 'xl', value: br.xl }); if (parsePixels(br.xl || '') < 24) entries.push({ key: 'xxl', value: '24px' }); if (br.full && br.full !== br.xl) entries.push({ key: 'pill', value: br.full }); return entries; } /** Build YAML spacing keys from tokens.spacing */ function buildSpacingKeys(tokens: any): Array<{ key: string; value: string }> { const sp = tokens.spacing || {}; const entries: Array<{ key: string; value: string }> = []; const keys = ['xxs', 'xs', 'sm', 'md', 'base', 'lg', 'xl', '2xl', '3xl'] as const; const aliases: Record = { base: 'md', '2xl': 'xl', '3xl': 'xxl' }; const seen = new Set(); for (const k of keys) { const v = sp[k]; if (!v) continue; const canonical = aliases[k] || k; if (!seen.has(canonical)) { seen.add(canonical); entries.push({ key: canonical, value: v }); } } // Add section-level spacing entries.push({ key: 'section', value: '80px' }); return entries; } /** Resolve a hex to a {colors.X} token ref if known, else return the hex */ function resolveColorRef(hex: string, colorTokenMap: Map): string { const ref = colorTokenMap.get(hex.toLowerCase()); return ref ? `{${ref}}` : hex; } /** Resolve a component variant's colors to token refs */ function componentToYaml( v: any, name: string, colorTokenMap: Map, typoTokenMap: Map, roundedTokenMap: Map, ): string { const lines: string[] = []; const key = name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); // Map bg hex → {colors.X} ref const bgHex = v.background ? nameColor(v.background).hex : null; const txtHex = v.text ? nameColor(v.text).hex : null; const bgRef = bgHex ? resolveColorRef(bgHex, colorTokenMap) : null; const txtRef = txtHex ? resolveColorRef(txtHex, colorTokenMap) : null; // Find best typography match by font size const sizePx = parsePixels(v.fontSize || '16px'); let bestTypoKey = 'body'; let bestDist = Infinity; for (const [slot, specs] of typoTokenMap.entries()) { if (!specs) continue; // slot is like "body", key stored as "typography.body" — we need to parse the size from typoRoles // typoTokenMap maps "typography.button" → fontSize string const fsPx = parsePixels(specs); if (Math.abs(fsPx - sizePx) < bestDist) { bestDist = Math.abs(fsPx - sizePx); bestTypoKey = slot; } } // Find best rounded match const radiusPx = parsePixels(v.radius || '8px'); let bestRoundedKey = 'md'; let bestRoundedDist = Infinity; for (const [slot, val] of roundedTokenMap.entries()) { const px = parsePixels(val); if (Math.abs(px - radiusPx) < bestRoundedDist) { bestRoundedDist = Math.abs(px - radiusPx); bestRoundedKey = slot; } } lines.push(` ${key}:`); if (bgRef) lines.push(` backgroundColor: "${bgRef}"`); if (txtRef) lines.push(` textColor: "${txtRef}"`); if (bestTypoKey) lines.push(` typography: "{typography.${bestTypoKey}}"`); lines.push(` rounded: "{rounded.${bestRoundedKey}}"`); if (v.padding && v.padding !== '0px') lines.push(` padding: ${v.padding}`); return lines.join('\n'); } // v5-V1-T1: Brand narrative auto-generator (no LLM, pattern-based) function buildBrandNarrative(desktopData: any, tokens: any, domain: string): string { if (!desktopData) return ''; const bg = tokens?.colors?.background?.primary || '#ffffff'; const accent = tokens?.colors?.accent?.primary || ''; const radiusValues = Object.values(tokens?.borderRadius || {}) as string[]; const fonts = tokens?.typography?.fontFamily || {}; const primaryFont = fonts.primary || ''; // Parse RGB safely const parseRgb = (c: string): [number, number, number] | null => { if (!c) return null; if (c.startsWith('#')) { const h = c.slice(1); if (h.length === 6) return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; } const m = c.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); if (m) return [+m[1], +m[2], +m[3]]; return null; }; const luminance = (rgb: [number, number, number]) => (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) / 255; const saturation = (rgb: [number, number, number]) => Math.max(...rgb) - Math.min(...rgb); // Detect patterns const bgRgb = parseRgb(bg); const accentRgb = parseRgb(accent); const bgLum = bgRgb ? luminance(bgRgb) : 0.5; const accentSat = accentRgb ? saturation(accentRgb) : 0; // Phase 5.3.2 — Factual canvas description. Always cite the actual hex/rgb. // Avoid qualitative claims like "commits fully", "deliberate refusal" — those lock // in stances even when bg detection is wrong (Notion/NVIDIA/MongoDB false dark). let canvasDesc = ''; if (bgLum < 0.15) { canvasDesc = `a near-black canvas (${bg})`; } else if (bgLum > 0.95 && bgRgb && bgRgb[0] === bgRgb[1] && bgRgb[1] === bgRgb[2]) { canvasDesc = `a pure-white canvas (${bg})`; } else if (bgLum > 0.92 && bgRgb && bgRgb[0] > bgRgb[2]) { canvasDesc = `a warm, off-white canvas (${bg})`; } else if (bgLum > 0.9) { canvasDesc = `a soft, near-white canvas (${bg})`; } else if (bgLum > 0.5) { canvasDesc = `a mid-light canvas (${bg})`; } else if (bgLum > 0.25) { canvasDesc = `a mid-dark canvas (${bg})`; } else { canvasDesc = `a dark canvas (${bg})`; } // Phase 5.3.2 — Accent description. Skip entirely if no accent (tokens.colors.accent.primary === null // after Phase 5.2.1 generic-default rejection). Avoid inventing a "vibrant brand presence" for // sites where no real brand accent was extracted (akiflow case, akin to "Don't use #0000ee"). let accentDesc = ''; if (accent && accentSat > 150) { accentDesc = `${accent} as the high-saturation accent for primary actions`; } else if (accent && accentSat > 80) { accentDesc = `${accent} as the accent for primary actions and brand signals`; } else if (accent && accentSat < 30 && accentSat > 0) { accentDesc = `${accent} as a near-neutral accent (low saturation)`; } else if (accent) { accentDesc = `${accent} as the singular interactive color`; } // If !accent (null/empty), accentDesc stays empty → narrative skips the accent sentence. // Phase 5.3.1 — Radius personality based on ACTUAL BUTTON radius (not max of all radii) // Previously, ANY pill (avatar circle, badge) would trigger "pill-shaped CTAs" narrative. // Now: cite the real button radius. If no button radius found, skip the shape sentence entirely. let shapeDesc = ''; const buttonRadiusRaw: string | undefined = (tokens?.components?.button?.borderRadius as string | undefined) || (desktopData?.componentVariants?.buttons?.[0]?.styles?.borderRadius as string | undefined) || (desktopData?.elements?.button?.styles?.borderRadius as string | undefined) || (tokens?.borderRadius?.button as string | undefined); const buttonRadiusPx = (() => { if (!buttonRadiusRaw) return -1; const m = String(buttonRadiusRaw).match(/(\d+(\.\d+)?)/); return m ? parseFloat(m[1]) : -1; })(); if (buttonRadiusPx >= 999) { shapeDesc = `fully-rounded CTAs (${buttonRadiusRaw}) signal a friendly, tactile interaction model`; } else if (buttonRadiusPx >= 16) { shapeDesc = `generously rounded CTAs (${buttonRadiusRaw}) suggest approachable, content-first design`; } else if (buttonRadiusPx >= 8) { shapeDesc = `moderately rounded CTAs (${buttonRadiusRaw}) — modern SaaS standard, neither sharp nor pill`; } else if (buttonRadiusPx >= 1 && buttonRadiusPx <= 4) { shapeDesc = `crisp CTAs (${buttonRadiusRaw} radius) reinforce a precise, technical aesthetic`; } else if (buttonRadiusPx === 0) { shapeDesc = 'sharp 0px corners on CTAs — architectural restraint, engineering precision'; } // If buttonRadiusPx === -1 (no button found), shapeDesc stays empty — narrative skips this sentence. // Typography flavor let typoDesc = ''; if (/serif|charter|tiempos|ggsans|noe/i.test(primaryFont) && !/sans-serif/i.test(primaryFont)) { typoDesc = `editorial serif type (${primaryFont}) — voice of authority`; } else if (/mono|courier|berkeley|jetbrains|consolas/i.test(primaryFont)) { typoDesc = `monospace typography (${primaryFont}) — developer-first identity`; } else if (primaryFont) { typoDesc = `${primaryFont} as the primary typeface`; } // v9-narrative: enrich the brand voice with MORE real signal CA already extracts but // never surfaced in prose. getdesign.md's editorial edge is breadth (it weaves in depth, // motion, type pairing); CA had the data but only narrated canvas+accent+shape+font. // Every clause below is gated on actual extracted values — no qualitative claim without a // measured number behind it (R26), same discipline as the canvas/accent guards above. // Type pairing — only when a genuinely distinct, non-fallback secondary font exists. const secondaryFont: string = (fonts.secondary || '').replace(/['"]/g, '').trim(); const isFallbackFont = /^(ui-|system-ui|-apple-system|sans-serif|serif|monospace|inherit|BlinkMac)/i.test(secondaryFont); let pairingDesc = ''; if (secondaryFont && !isFallbackFont && secondaryFont.toLowerCase() !== String(primaryFont).toLowerCase()) { pairingDesc = `${primaryFont} is paired with ${secondaryFont} for secondary roles`; } // Depth — from the count of distinct, real (non-"none") box-shadows. const realShadows = (desktopData.allShadows || []).filter((s: string) => s && s !== 'none'); const shadowCount = new Set(realShadows.map((s: string) => String(s).trim())).size; let depthDesc = ''; if (shadowCount === 0) { depthDesc = 'depth comes from borders and surface contrast rather than shadows — a flat, structural elevation model'; } else if (shadowCount <= 3) { depthDesc = `a restrained elevation system (${shadowCount} distinct shadow${shadowCount > 1 ? 's' : ''}) — shadows reserved for the few elements that must lift off the page`; } else { depthDesc = `a layered elevation system (${shadowCount} distinct shadows) building a clear front-to-back hierarchy`; } // Motion — from transitions + keyframes actually captured. const realTransitions = (desktopData.allTransitions || []).filter((t: string) => t && t !== 'none' && t !== 'all 0s ease 0s'); const keyframeCount = Object.keys(desktopData.keyframes || {}).length; let motionDesc = ''; if (keyframeCount >= 5) { motionDesc = `motion is a first-class concern — ${keyframeCount} keyframe animations plus transition-driven interactions`; } else if (realTransitions.length >= 5) { motionDesc = `interactions are smoothed by transitions across many elements, with little or no keyframe animation`; } else if (realTransitions.length > 0) { motionDesc = 'motion is minimal — a few targeted transitions, no decorative animation'; } // Assemble narrative — richer, still grounded sentence-by-sentence in measured data. const sentences: string[] = []; sentences.push(`${domain} is built on ${canvasDesc}.`); if (accentDesc) sentences.push(`The system uses ${accentDesc}.`); if (shapeDesc || typoDesc) { const detail = [shapeDesc, typoDesc].filter(Boolean).join(' alongside '); if (detail) sentences.push(detail.charAt(0).toUpperCase() + detail.slice(1) + '.'); } if (pairingDesc) sentences.push(pairingDesc.charAt(0).toUpperCase() + pairingDesc.slice(1) + '.'); if (depthDesc) sentences.push(depthDesc.charAt(0).toUpperCase() + depthDesc.slice(1) + '.'); if (motionDesc) sentences.push(motionDesc.charAt(0).toUpperCase() + motionDesc.slice(1) + '.'); return sentences.join('\n'); } // v5-V1-T2: TL;DR header for AI agents (top of DESIGN.md body) function buildAgentTldr(tokens: any, typoRoles: any[], componentSpecs: any[], rawData: any): string { const bg = tokens?.colors?.background?.primary || '#ffffff'; const accent = tokens?.colors?.accent?.primary || 'N/A'; const bodyToken = typoRoles.find((r: any) => /body/i.test(r.role))?.size || '16px'; const sectionSpacing = tokens?.spacing?.['2xl'] || tokens?.spacing?.xl || '64px'; const radiusButton = tokens?.borderRadius?.sm || '6px'; const hasShadows = (rawData?.allShadows || []).filter((s: string) => s && s !== 'none').length > 0; const forbidden: string[] = []; if (!hasShadows) forbidden.push('Do NOT introduce heavy box-shadows — this system relies on borders for separation'); if (Object.values(tokens?.colors?.accent || {}).length <= 1) forbidden.push('Do NOT introduce secondary accent colors — single accent is intentional'); let tldr = '> **🤖 TL;DR for AI agents** (read this first — saves you scanning the full doc):\n>\n'; tldr += `> - **Canvas**: \`${bg}\` is the page background. Every component composits against this.\n`; tldr += `> - **Accent**: \`${accent}\` for primary CTAs ONLY. Never decorative.\n`; tldr += `> - **Body type**: ${bodyToken} (token: \`typography.body\`). Default for ALL text not in a heading role.\n`; tldr += `> - **Section rhythm**: ${sectionSpacing} between major bands. Card padding: ${tokens?.spacing?.lg || '24px'}.\n`; tldr += `> - **Default radius**: ${radiusButton} on buttons/inputs. Pills (\`9999px\`) only for icon-buttons or status chips.\n`; if (forbidden.length > 0) tldr += `> - **Forbidden**: ${forbidden.join(' · ')}\n`; tldr += '>\n'; tldr += '> **Before generating UI**: import this DESIGN.md, scan §2 (Color Palette) + §4 (Components) + §10 (Agent Guide). Skip §11–12 unless extending.\n\n'; return tldr; } // v5-V1-T3: Component Behaviors Matrix (state transitions structured) /** * Build §9a "Responsive Narrative" (Phase 4.5) — describe HOW widgets collapse * across breakpoints, not just WHICH values change. Gives LLM agents the * *intent* behind responsive behavior, not just the metrics. */ function buildResponsiveNarrative(desktopData: any, mobileData: any): string { if (!mobileData) return ''; const dw = desktopData?.widgets; const mw = mobileData?.widgets; if (!dw && !mw) return ''; let md = '### Collapsing Narrative\n\n'; md += 'How the structural patterns adapt from desktop to mobile (extracted from both viewport extractions):\n\n'; let narrativeLines = 0; // Hero composition change if (dw?.hero && mw?.hero) { const dComp = dw.hero.composition; const mComp = mw.hero.composition; if (dComp !== mComp) { md += `- **Hero collapses ${dComp} → ${mComp}** — `; if (dComp.startsWith('split') && mComp === 'centered') { md += `the side-by-side layout stacks vertically, with media moving above the text.\n`; } else if (dComp === 'centered' && mComp.startsWith('split')) { md += `centered desktop becomes split on mobile (rare — verify extraction).\n`; } else { md += `composition shifts (verify on actual mobile render).\n`; } narrativeLines++; } if (dw.hero.ctaCount !== mw.hero.ctaCount) { md += `- **Hero CTAs:** ${dw.hero.ctaCount} on desktop → ${mw.hero.ctaCount} on mobile`; if (mw.hero.ctaCount < dw.hero.ctaCount) md += ` (secondary CTAs dropped on small screens)`; md += `.\n`; narrativeLines++; } } // Nav collapse if (dw?.navigation && mw?.navigation) { const dNav = dw.navigation; const mNav = mw.navigation; if (dNav.navItemCount > 0 && mNav.navItemCount < dNav.navItemCount * 0.5) { md += `- **Navigation collapses to hamburger** — ${dNav.navItemCount} links visible on desktop, ${mNav.navItemCount} on mobile (the rest hide behind a menu toggle).\n`; narrativeLines++; } else if (dNav.position !== mNav.position) { md += `- **Nav position changes:** ${dNav.position} (desktop) → ${mNav.position} (mobile).\n`; narrativeLines++; } if (mNav.ctaCount < dNav.ctaCount) { md += `- **Nav CTAs reduced** on mobile (${dNav.ctaCount} → ${mNav.ctaCount}) — the design prioritizes content over conversion buttons at small sizes.\n`; narrativeLines++; } } // Card grid collapse if (dw?.cardGrid && mw?.cardGrid) { const dCols = dw.cardGrid.columnsDesktop; const mCols = mw.cardGrid.columnsDesktop; if (dCols > mCols) { md += `- **Card grid: ${dCols}-up → ${mCols}-up** — cards stack as viewport narrows`; if (mCols === 1) md += `, becoming a vertical list on mobile`; md += `.\n`; narrativeLines++; } } // Pricing collapse if (dw?.pricingTable && mw?.pricingTable) { const dLayout = dw.pricingTable.layout; const mLayout = mw.pricingTable.layout; if (dLayout === 'side-by-side' && mLayout === 'vertical-stack') { md += `- **Pricing collapses to vertical stack** — desktop comparison becomes scrollable tier-by-tier on mobile.\n`; narrativeLines++; } } // Footer if (dw?.footer && mw?.footer) { const dCols = dw.footer.columnCount; const mCols = mw.footer.columnCount; if (dCols > mCols && dCols > 1) { md += `- **Footer columns collapse:** ${dCols} columns (desktop) → ${mCols} column${mCols > 1 ? 's' : ''} (mobile).\n`; narrativeLines++; } } if (narrativeLines === 0) { md += '*No major structural differences detected between desktop and mobile widgets. The same blueprints scale rather than collapse — verify by inspecting actual mobile render.*\n'; } md += '\n'; return md; } /** * Build §5c "Widget Library" (Phase 4.1) — structural blueprints for the page. * Describes hero composition, navigation pattern, card grids, pricing tables, * testimonials, FAQ, CTA banners, footer layout. Gives LLM agents the * architectural skeleton, not just CSS values. */ function buildWidgetSection(rawData: any): string { const w = rawData?.widgets; if (!w || w.detectedCount === 0) return ''; let md = '## 5c. Widget & Structure Library\n\n'; md += `Structural patterns extracted from the page DOM — these are the **blueprints** an agent should follow to reproduce the page architecture (not just CSS values).\n\n`; md += `**${w.detectedCount}/8 structural patterns detected.**\n\n`; // Hero if (w.hero) { const h = w.hero; md += '### Hero Pattern\n\n'; const compositionDesc: Record = { 'centered': 'Centered composition — heading + subheading + CTAs stacked vertically, content centered horizontally. Common for SaaS marketing pages.', 'split-left-text': 'Split layout, text-left/media-right — heading + body in left column, image/illustration in right column. Common for product showcases.', 'split-right-text': 'Split layout, media-left/text-right — image in left column, heading + body in right column. Less common variant.', 'full-bleed-image': 'Full-bleed image hero — image fills the viewport, text overlaid. High emotional impact.', 'background-pattern': 'Background pattern/gradient — decorative background fills the section, content overlaid on top.', }; md += `- **Composition:** ${h.composition} — ${compositionDesc[h.composition] || ''}\n`; md += `- **Viewport coverage:** ${h.isFullViewport ? 'full-viewport hero (≥70% screen height)' : `${h.heightPx}px tall (not full-viewport)`}\n`; md += `- **Heading:** "${(h.headingText || '').slice(0, 100)}" — ${Math.round(h.headingFontSize)}px ${h.contentAlignment}-aligned\n`; if (h.subheadingText) md += `- **Subheading:** "${(h.subheadingText || '').slice(0, 150)}"\n`; if (h.hasMedia) md += `- **Media:** present, positioned ${h.mediaPosition}\n`; if (h.ctaCount > 0) { md += `- **CTAs:** ${h.ctaCount}`; if (h.ctaPrimaryText) md += ` (primary: "${h.ctaPrimaryText}")`; md += '\n'; } else { md += `- **CTAs:** none detected in hero (unusual — agent should verify)\n`; } md += `- **Clone instruction:** Reproduce the **${h.composition}** layout. Use the heading font-size token at the documented size. ${h.hasMedia ? 'Place media ' + h.mediaPosition + ' of text.' : 'No imagery needed for hero — text-driven.'}\n\n`; } // Navigation if (w.navigation) { const n = w.navigation; md += '### Navigation Pattern\n\n'; const layoutDesc: Record = { 'logo-left-links-right': 'Logo at far left, primary navigation links right-aligned', 'logo-center-links-split': 'Logo centered, links split on either side', 'logo-left-links-left': 'Logo and links both on the left (CTAs typically right)', 'unknown': 'Layout not classified', }; md += `- **Position:** ${n.position}${n.position === 'sticky' || n.position === 'fixed' ? ' (stays visible during scroll)' : ' (scrolls with page)'}\n`; md += `- **Layout:** ${n.layout} — ${layoutDesc[n.layout] || ''}\n`; md += `- **Height:** ${n.heightPx}px\n`; md += `- **Logo:** ${n.hasLogo ? '✓ present' : '✗ absent'}\n`; md += `- **Nav links:** ${n.navItemCount} primary items\n`; md += `- **CTAs in nav:** ${n.ctaCount}${n.ctaCount === 0 ? ' (no buttons — links only)' : ''}\n`; if (n.hasSearchBar) md += `- **Search:** ✓ search input visible in nav\n`; if (n.hasDarkMode) md += `- **Theme toggle:** ✓ dark/light mode switcher present\n`; md += `- **Clone instruction:** Build a ${n.position} ${n.layout.replace(/-/g, ' ')} navigation. ${n.ctaCount > 0 ? `Reserve ${n.ctaCount} slot${n.ctaCount > 1 ? 's' : ''} for primary CTA${n.ctaCount > 1 ? 's' : ''}.` : 'Link-only — no button styling needed.'}\n\n`; } // Card Grid if (w.cardGrid) { const c = w.cardGrid; md += '### Card Grid Pattern\n\n'; md += `- **Card count:** ${c.cardCount} cards detected\n`; md += `- **Columns (desktop):** ${c.columnsDesktop}-up grid\n`; md += `- **Card dimensions:** ${c.averageCardWidthPx}×${c.averageCardHeightPx}px (aspect ${c.cardAspectRatio}:1)\n`; md += `- **Gap:** ${c.gapPx}px between cards\n`; md += `- **Content:** ${c.cardHasImage ? '✓ image' : '✗ no image'}${c.cardHasIcon ? ' + icon' : ''}${c.cardHasCta ? ' + CTA' : ''}\n`; if (c.cardHasImage) md += `- **Image position:** ${c.imagePosition}\n`; md += `- **Clone instruction:** Build a ${c.columnsDesktop}-column grid (desktop) with cards at ~${c.cardAspectRatio}:1 aspect ratio. ${c.cardHasImage ? 'Each card has an image at the ' + c.imagePosition + '.' : 'Text/icon-only cards (no photography).'}\n\n`; } // Pricing if (w.pricingTable) { const p = w.pricingTable; md += '### Pricing Table Pattern\n\n'; md += `- **Tier count:** ${p.tierCount} pricing tiers\n`; md += `- **Layout:** ${p.layout === 'side-by-side' ? 'side-by-side (horizontal comparison)' : 'vertical stacked'}\n`; md += `- **Columns (desktop):** ${p.columnsDesktop}-up\n`; if (p.hasFeatureList) md += `- **Feature list:** ✓ (median ~${p.featuresPerTier} feature points per tier)\n`; if (p.highlightedTierIndex >= 0) md += `- **Highlighted tier:** index ${p.highlightedTierIndex} (visual emphasis via border/scale)\n`; md += `- **Clone instruction:** Build a ${p.tierCount}-column ${p.layout} pricing comparison${p.highlightedTierIndex >= 0 ? `, with the ${ordinal(p.highlightedTierIndex + 1)} tier visually highlighted (border/elevation)` : ''}.\n\n`; } // Testimonials if (w.testimonials) { const t = w.testimonials; md += '### Testimonial Pattern\n\n'; md += `- **Count:** ${t.count} testimonial blocks\n`; md += `- **Layout:** ${t.layout}\n`; md += `- **Avg quote length:** ${t.averageQuoteLength} chars (${t.averageQuoteLength > 200 ? 'long-form story' : t.averageQuoteLength > 80 ? 'medium quote' : 'short blurb'})\n`; md += `- **Includes:** ${t.hasAvatar ? '✓ avatars' : '✗ no avatars'} | ${t.hasCompanyLogo ? '✓ company logos' : '✗ no logos'} | ${t.hasRating ? '✓ star ratings' : '✗ no ratings'}\n`; md += `- **Clone instruction:** Build a ${t.layout} of ${t.count} testimonials. ${t.hasAvatar ? 'Each includes an avatar.' : ''} ${t.hasCompanyLogo ? 'Include source company logos.' : ''} ${t.hasRating ? 'Display ratings prominently.' : ''}\n\n`; } // FAQ if (w.faq) { const f = w.faq; md += '### FAQ Pattern\n\n'; md += `- **Item count:** ${f.itemCount} questions\n`; md += `- **Format:** ${f.isAccordion ? 'accordion (expand/collapse)' : 'static list'}\n`; md += `- **Layout:** ${f.layout}\n`; md += `- **Avg question length:** ${f.averageQuestionLength} chars\n`; md += `- **Clone instruction:** Build ${f.itemCount} ${f.isAccordion ? 'expandable accordion items' : 'static Q&A blocks'} in a ${f.layout} layout.\n\n`; } // CTA Banner if (w.ctaBanner) { const c = w.ctaBanner; md += '### CTA Banner Pattern\n\n'; md += `- **Banner count:** ${c.count}\n`; md += `- **Position:** ${c.position}\n`; md += `- **Visual treatment:** ${c.hasContrastBg ? '✓ contrasting background' : ''}${c.hasGradient ? '✓ gradient fill' : ''}${!c.hasContrastBg && !c.hasGradient ? 'minimal styling' : ''}\n`; if (c.primaryButtonText) md += `- **Primary CTA text:** "${c.primaryButtonText}"\n`; md += `- **Clone instruction:** Place ${c.count > 1 ? 'multiple CTA banners across page sections' : 'a single CTA banner ' + c.position}. ${c.hasGradient ? 'Use gradient background.' : c.hasContrastBg ? 'Use contrasting solid background.' : 'Keep visually quiet — text-only.'}\n\n`; } // Footer if (w.footer) { const f = w.footer; md += '### Footer Pattern\n\n'; md += `- **Columns:** ${f.columnCount}\n`; md += `- **Links:** ${f.linkCount} total\n`; md += `- **Height:** ${f.heightPx}px\n`; md += `- **Includes:** ${f.hasNewsletter ? '✓ newsletter signup' : '✗ no newsletter'} | ${f.hasSocialIcons ? '✓ social icons' : '✗ no social'} | ${f.hasLanguageSwitcher ? '✓ language switcher' : ''} ${f.hasCopyrightLine ? '✓ copyright' : ''}\n`; md += `- **Clone instruction:** Build a ${f.columnCount}-column footer with ~${Math.round(f.linkCount / Math.max(1, f.columnCount))} links per column${f.hasNewsletter ? ', newsletter signup' : ''}${f.hasSocialIcons ? ', social media links' : ''}${f.hasCopyrightLine ? ', and a copyright line' : ''}.\n\n`; } return md; } function ordinal(n: number): string { const suffixes = ['th', 'st', 'nd', 'rd']; const v = n % 100; return n + (suffixes[(v - 20) % 10] || suffixes[v] || suffixes[0]); } /** * Build §9b "Visual Tone & Photography" — characterizes the imagery profile * of the site (lifestyle photos vs product mockups vs abstract gradients vs illustration). * Helps LLMs generate visually-coherent clones beyond just CSS. */ function buildPhotographySection(rawData: any): string { const profile = rawData?.imageryProfile; if (!profile) return ''; // pre-Phase-0.2 extractions have no profile let md = '## 9b. Visual Tone & Photography\n\n'; // Tone classification let tone: string; let toneDescription: string; if (profile.illustrationHeavy) { tone = 'Illustration-driven'; toneDescription = 'The site relies primarily on illustrations, vector art, and decorative SVG elements rather than photography. Use commissioned illustrations, icon sets, or generated graphics for clones — stock photos will feel off-brand.'; } else if (profile.photoHeavy) { tone = 'Photography-driven'; toneDescription = 'The site is dominated by photography (JPG/WebP). Photos carry the brand voice as much as the typography. Use authentic, brand-appropriate imagery — generic stock will degrade quality.'; } else if (profile.formats.svg > 50 && profile.totalImages < 10) { tone = 'Minimal-imagery / Text-driven'; toneDescription = 'Few photographic images, but a high count of inline SVG (icons, logos, decorative geometry). The visual narrative comes from typography + layout + occasional accent illustrations.'; } else if (profile.totalImages === 0 && profile.formats.svg < 20) { tone = 'No imagery'; toneDescription = 'No detectable photographic content. The site composes entirely with typography, color blocks, and minimal vector graphics. Clone strategy: lean on type hierarchy + layout rhythm rather than visual media.'; } else { tone = 'Mixed photography + illustration'; toneDescription = 'A balanced mix of photographic content and vector graphics. Photos likely carry product/lifestyle context while SVG handles icons, logos, and decorative accents.'; } md += `**Tone:** ${tone}\n\n`; md += `${toneDescription}\n\n`; // OG image (canonical brand visual) if (profile.ogImage) { md += '**Canonical Brand Image (OG):**\n'; md += `- ${profile.ogImage}`; if (profile.ogImageWidth && profile.ogImageHeight) { md += ` (${profile.ogImageWidth}×${profile.ogImageHeight}px)`; } md += '\n *This is the image shown when the site is shared on social media — represents the brand visual essence.*\n\n'; } // Hero image if (profile.heroImage) { const h = profile.heroImage; const aspect = h.aspectRatio; let ratioLabel = ''; if (aspect > 2.3) ratioLabel = 'ultra-wide cinematic'; else if (aspect > 1.5) ratioLabel = 'landscape (16:9-ish)'; else if (aspect > 1.15) ratioLabel = 'landscape'; else if (aspect > 0.87) ratioLabel = 'square / portrait'; else ratioLabel = 'portrait / vertical'; md += '**Hero Image (above-fold dominant visual):**\n'; md += `- Aspect: ${aspect}:1 (${ratioLabel})\n`; md += `- Rendered size: ${h.width}×${h.height}px\n`; if (h.alt) md += `- Alt text: "${h.alt.slice(0, 120)}"\n`; md += `- *Use this aspect ratio + composition for your clone's hero — mimicking the proportion preserves the visual hierarchy.*\n\n`; } // Aspect ratio distribution const totalAR = profile.aspectRatioBuckets.landscape + profile.aspectRatioBuckets.portrait + profile.aspectRatioBuckets.square + profile.aspectRatioBuckets.ultrawide; if (totalAR > 0) { md += '**Aspect Ratio Distribution:**\n'; const pct = (n: number) => `${Math.round((n / totalAR) * 100)}%`; md += `- Landscape (1.15–2.3): ${profile.aspectRatioBuckets.landscape} (${pct(profile.aspectRatioBuckets.landscape)})\n`; md += `- Portrait (<0.87): ${profile.aspectRatioBuckets.portrait} (${pct(profile.aspectRatioBuckets.portrait)})\n`; md += `- Square (0.87–1.15): ${profile.aspectRatioBuckets.square} (${pct(profile.aspectRatioBuckets.square)})\n`; if (profile.aspectRatioBuckets.ultrawide > 0) { md += `- Ultra-wide (>2.3): ${profile.aspectRatioBuckets.ultrawide} (${pct(profile.aspectRatioBuckets.ultrawide)})\n`; } md += '\n'; } // Format distribution md += '**Media Format Mix:**\n'; const fmt = profile.formats; const totalF = fmt.png + fmt.jpg + fmt.webp + fmt.svg + fmt.gif + fmt.other; if (totalF > 0) { if (fmt.jpg > 0) md += `- JPG: ${fmt.jpg} (typically photographic content)\n`; if (fmt.webp > 0) md += `- WebP: ${fmt.webp} (optimized photo/illustration hybrid)\n`; if (fmt.png > 0) md += `- PNG: ${fmt.png} (logos, transparent UI elements, mockups)\n`; if (fmt.svg > 0) md += `- SVG: ${fmt.svg} (icons, illustrations, decorative geometry)\n`; if (fmt.gif > 0) md += `- GIF: ${fmt.gif} (animated content)\n`; } else { md += '- *No detectable images.*\n'; } md += '\n'; // Average size if (profile.avgImageSize.width > 0) { md += `**Average rendered image size:** ${profile.avgImageSize.width}×${profile.avgImageSize.height}px\n`; md += `**Above-fold image count:** ${profile.totalAboveFold} / ${profile.totalImages}\n\n`; } // Decorative patterns (Phase 4.4) const dec = profile.decorativePatterns; if (dec && (dec.multiStopGradients > 0 || dec.radialGradients > 0 || dec.largeSvgShapes > 0 || dec.hasGlassmorphism || dec.hasNoise)) { md += '**Decorative Patterns Detected:**\n'; if (dec.multiStopGradients > 0) md += `- ${dec.multiStopGradients} multi-stop linear gradient${dec.multiStopGradients > 1 ? 's' : ''} (mesh-like, 3+ color stops) — the brand leans on rich color transitions for visual depth\n`; if (dec.radialGradients > 0) md += `- ${dec.radialGradients} radial gradient${dec.radialGradients > 1 ? 's' : ''} — circular color blooms used for spotlight/glow effects\n`; if (dec.largeSvgShapes > 0) md += `- ${dec.largeSvgShapes} large decorative SVG shape${dec.largeSvgShapes > 1 ? 's' : ''} (≥200×200px, non-icon) — likely blob/illustration accents\n`; if (dec.backgroundImagePatterns > 0) md += `- ${dec.backgroundImagePatterns} background pattern${dec.backgroundImagePatterns > 1 ? 's' : ''} (non-photo url() backgrounds) — textures/illustrations as backdrops\n`; if (dec.hasGlassmorphism) md += `- **Glassmorphism** detected (\`backdrop-filter: blur\`) — layered transparent surfaces with blur, modern OS-like aesthetic\n`; if (dec.hasNoise) md += `- **Noise/grain texture** detected — adds tactile film grain to flat backgrounds\n`; md += '\n**Clone instruction for decorative work:** '; if (dec.multiStopGradients >= 3 || dec.radialGradients >= 2) { md += 'This is a gradient-rich design — invest in multi-stop CSS gradients (3+ color stops), not solid backgrounds.\n'; } else if (dec.largeSvgShapes >= 3) { md += 'Decorative SVG accents are part of the brand — generate or source blob/illustration shapes to scatter behind content.\n'; } else if (dec.hasGlassmorphism) { md += 'Glassmorphism is a brand signature — use `backdrop-filter: blur(20px)` on overlapping surfaces.\n'; } else { md += 'Decorative details are subtle — don\'t over-style; the design relies on restraint.\n'; } md += '\n'; } // Guidance md += '**Imagery Guidance for clones:**\n'; if (profile.photoHeavy) { md += '- Source authentic photography (or licensed stock that matches the brand mood) — placeholders will visibly degrade the clone.\n'; md += '- Match the dominant aspect ratio (see above) so card grids and hero compositions hold.\n'; } else if (profile.illustrationHeavy) { md += '- Use vector illustration as the primary visual language — photos will read as off-brand.\n'; md += '- Maintain consistent illustration style (line weight, color palette) across the site.\n'; } else if (tone === 'No imagery') { md += '- Don\'t add stock imagery just to "fill space" — this site\'s clarity comes from typographic restraint.\n'; md += '- Use generous whitespace, strong type hierarchy, and color blocks for visual rhythm.\n'; } else { md += '- Mix photography and illustration intentionally: photos for product/context, vectors for icons + decorative accents.\n'; md += '- Respect the format split — replacing JPGs with illustrations (or vice versa) will shift the brand tone.\n'; } md += '\n'; return md; } function buildComponentBehaviorsSection(rawData: any): string { const states = rawData?.componentStates || {}; const comps = Object.keys(states); if (comps.length === 0) return ''; let md = '\n## 4b. Component Behaviors (State Matrix)\n\n'; // Phase 4.2 — Prose narrative BEFORE the YAML matrix // Describe what changes between states in plain English for LLM consumption md += '### Interaction State Narrative\n\n'; for (const comp of comps) { const cs = states[comp] as any; if (!cs || typeof cs !== 'object') continue; const def = cs.default || cs.normal; if (!def) continue; const transitions: string[] = []; for (const stateName of ['hover', 'active', 'pressed', 'focus', 'disabled']) { const state = cs[stateName]; if (!state || typeof state !== 'object') continue; const changes: string[] = []; if (state.backgroundColor && state.backgroundColor !== def.backgroundColor) { const dInfo = nameColor(def.backgroundColor || ''); const sInfo = nameColor(state.backgroundColor); changes.push(`bg ${dInfo.displayValue} → ${sInfo.displayValue}`); } if (state.color && state.color !== def.color) { const dInfo = nameColor(def.color || ''); const sInfo = nameColor(state.color); changes.push(`text ${dInfo.displayValue} → ${sInfo.displayValue}`); } if (state.boxShadow && state.boxShadow !== def.boxShadow) { if (state.boxShadow === 'none') changes.push(`shadow removed`); else if (!def.boxShadow || def.boxShadow === 'none') changes.push(`shadow added`); else changes.push(`shadow level changes`); } if (state.border && state.border !== def.border) { changes.push(`border changes`); } if (state.transform && state.transform !== 'none' && state.transform !== def.transform) { if (state.transform.includes('translateY') || state.transform.includes('translate3d')) { changes.push(`element lifts (translateY)`); } else if (state.transform.includes('scale')) { changes.push(`scale transform`); } else { changes.push(`transform applied`); } } if (state.opacity && parseFloat(state.opacity) < 1) { changes.push(`opacity ${state.opacity}`); } if (changes.length > 0) { transitions.push(` - **${stateName}:** ${changes.join(', ')}`); } } if (transitions.length > 0) { const compLabel = comp.charAt(0).toUpperCase() + comp.slice(1); md += `- **${compLabel}**:\n${transitions.join('\n')}\n`; } } md += '\n'; md += '*State transitions extracted via Playwright simulation — exact values in the YAML matrix below.*\n\n'; md += '```yaml\ncomponent-behaviors:\n'; for (const comp of comps) { const cs = states[comp]; if (!cs || typeof cs !== 'object') continue; md += ` ${comp}:\n`; for (const [state, props] of Object.entries(cs)) { if (!props || typeof props !== 'object') continue; const p = props as any; const bg = p.backgroundColor || 'transparent'; const fg = p.color || 'inherit'; const border = p.border || 'none'; const shadow = (p.boxShadow || 'none').slice(0, 60); md += ` ${state}:\n`; md += ` bg: "${bg}"\n`; md += ` fg: "${fg}"\n`; if (border !== 'none' && !border.startsWith('0px')) md += ` border: "${border}"\n`; if (shadow !== 'none') md += ` shadow: "${shadow}"\n`; } } md += '```\n\n'; md += '**Agent usage**: Validate generated components against this matrix. If your output\'s `:hover` state changes properties not listed here, you are off-brand.\n\n'; return md; } // v5-V1-T4: Enforceable Brand Rules (testable Do/Don't with WHY + test) function buildBrandRulesSection(tokens: any, rawData: any): string { const bg = tokens?.colors?.background?.primary || '#ffffff'; const accent = tokens?.colors?.accent?.primary || ''; const isDark = (() => { const m = bg.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i); if (!m) return false; const lum = (parseInt(m[1], 16) * 0.2126 + parseInt(m[2], 16) * 0.7152 + parseInt(m[3], 16) * 0.0722) / 255; return lum < 0.3; })(); const hasShadows = (rawData?.allShadows || []).some((s: string) => s && s !== 'none'); const rules: Array<{ id: string; type: 'do' | 'dont'; rule: string; why: string; test: string }> = []; rules.push({ id: 'brand-canvas-001', type: 'do', rule: `Always use the page background \`${bg}\` as the base canvas`, why: 'Every component is designed to composit against this exact tone', test: `document.body.style.backgroundColor === '${bg}'`, }); if (isDark) { rules.push({ id: 'brand-canvas-002', type: 'dont', rule: 'Do NOT use pure white (`#ffffff`) for surfaces', why: 'This is a dark-mode-native system — white surfaces break the tonal contract', test: `!querySelectorAll('[style*="background: #fff"], [style*="background: white"]').length`, }); } if (accent) { rules.push({ id: 'brand-accent-001', type: 'do', rule: `Reserve \`${accent}\` for primary CTAs and brand-mark signals only`, why: 'Single chromatic assertion device — overuse dilutes meaning', test: `querySelectorAll('[style*="${accent}"]').length <= 3 per fold`, }); } if (!hasShadows) { rules.push({ id: 'brand-depth-001', type: 'dont', rule: 'Do NOT introduce drop shadows on cards or sections', why: 'Containment is achieved via borders, not elevation — adding shadows breaks the depth contract', test: `getComputedStyle(card).boxShadow === 'none'`, }); } if (rules.length === 0) return ''; let md = '\n## 7b. Enforceable Brand Rules\n\n'; md += '*Structured Do/Don\'t with reasoning and validation tests. Agents can programmatically verify generated UI.*\n\n'; md += '```yaml\nrules:\n'; for (const r of rules) { md += ` - id: ${r.id}\n`; md += ` type: ${r.type}\n`; md += ` rule: "${r.rule.replace(/"/g, '\\"')}"\n`; md += ` why: "${r.why}"\n`; md += ` test: "${r.test.replace(/"/g, '\\"')}"\n`; } md += '```\n\n'; return md; } // v5-V1-T5: Component Vocabulary Inventory (closed system) function buildComponentVocabularySection(componentSpecs: any[]): string { if (!componentSpecs || componentSpecs.length === 0) return ''; let md = '\n## 4c. Component Vocabulary (Closed System)\n\n'; md += '*This brand uses ONLY the following component vocabulary. Introducing new variants without justification breaks the system\'s tonal coherence.*\n\n'; for (const spec of componentSpecs.slice(0, 8)) { const name = spec.name || 'Component'; const variants = spec.variants || []; md += `### ${name}\n`; if (variants.length === 0) { md += '- *(no variants extracted)*\n'; } else { // v2.6 Fix-3 — dedup by name so the vocabulary doesn't read "Primary Brand, Primary Brand, Primary Brand" const distinctNames: string[] = [...new Set(variants.map((v: any) => String(v.name || 'variant')))]; md += `- **${distinctNames.length} shape(s)** in this system: `; md += distinctNames.slice(0, 5).map((n: string) => `\`${n}\``).join(', '); md += '\n'; md += `- **DO NOT introduce new ${name.toLowerCase()} variants** without justification\n`; } md += '\n'; } return md; } // v5-V1-T6: Spacing Rhythm Names (named scale with use cases) function buildSpacingRhythmSection(tokens: any): string { const sp = tokens?.spacing || {}; if (Object.keys(sp).length === 0) return ''; const rhythm: Record = { hero: { value: sp['3xl'] || sp['2xl'] || '96px', use: 'Top/bottom of full-bleed hero sections' }, section: { value: sp['xl'] || sp.lg || '48px', use: 'Between major bands (color change, content shift)' }, subsection: { value: sp.lg || sp.md || '24px', use: 'Within a section, between content groups' }, card: { value: sp.md || sp.base || '16px', use: 'Card padding, list-item spacing' }, compact: { value: sp.sm || '8px', use: 'Form fields, tight clusters, badge padding' }, micro: { value: sp.xs || '4px', use: 'Icon-text gap, tag inner padding' }, }; let md = '\n## 5b. Spacing Rhythm Names\n\n'; md += '*Named spacing tokens with explicit use cases. Use these names in YOUR code instead of raw pixels for consistency.*\n\n'; md += '| Name | Value | Use case |\n|------|-------|----------|\n'; for (const [name, info] of Object.entries(rhythm)) { md += `| **${name}** | \`${info.value}\` | ${info.use} |\n`; } md += '\n**Agent rule**: NEVER hardcode raw pixel values for spacing in generated UI. Always reference these named tokens (`spacing-rhythm.section`, etc.).\n\n'; return md; } // ═══════════════════════════════════════════════════════════════════════ // v8 — BRIDGE TOKENS → SITE: extract layout/copy/assets so AI agents can // actually REPRODUCE the source site, not just have the design tokens. // ═══════════════════════════════════════════════════════════════════════ // v8-T2: Page Structure Skeleton — describe section layout for AI reproduction function buildPageSkeletonSection(rawData: any): string { if (!rawData?.sections || rawData.sections.length === 0) return ''; // Filter meaningful sections (avoid tiny accessibility nav, hidden elements) const sections = (rawData.sections as any[]) .filter(s => s.rect.height >= 80 && s.rect.width >= 200) .filter(s => s.estimatedPurpose !== 'unknown' || s.childCount > 0) .slice(0, 25); if (sections.length === 0) return ''; let md = '\n## 13. Page Structure Skeleton\n\n'; md += '*The actual layout the source site uses. Reproduce these sections in order to match the site structure.*\n\n'; md += '```yaml\n'; md += 'page-skeleton:\n'; for (const s of sections) { const layout = s.styles.gridTemplateColumns !== 'none' ? `grid: ${s.styles.gridTemplateColumns.split(' ').length}-col${s.styles.gap ? ' gap=' + s.styles.gap : ''}` : s.styles.display === 'flex' ? `flex-${s.styles.flexDirection}${s.styles.justifyContent ? ' justify=' + s.styles.justifyContent : ''}` : 'block'; md += ` - section: ${s.estimatedPurpose}\n`; md += ` tag: ${s.tag}\n`; // Filter CSS module hashes (e.g., "hero_aX3kP9", "Monitor_container__N0_Qy", "_module_xyz") // These hashes are unique per build and useless for AI agents reproducing the layout const meaningfulClasses = s.classes.filter((c: string) => { // Reject CSS-modules pattern: anything containing __ followed by short identifier // (catches Module_class__hashSuffix, even with mixed underscores/alphanumerics) if (/__[a-zA-Z0-9_]{2,}$/.test(c)) return false; // Reject CSS-in-JS pattern: lowercase word + _ + mixedCase identifier // ex: "css-12abc34", "jss345", "sc-bjUoiQ" if (/^(css-|jss|sc-|emotion-|css_)/.test(c)) return false; // Reject classes starting with underscore (CSS module prefix) if (/^_/.test(c)) return false; // Reject classes containing 4+ consecutive alphanum hash suffix at end (build hash) if (/[_-][a-zA-Z0-9]{6,}$/.test(c) && !/[a-z]{4,}-[a-z]{4,}$/.test(c)) return false; // Reject all-numeric or very short cryptic classes if (/^[a-z]{1,2}\d{2,}$/.test(c)) return false; return true; }); if (meaningfulClasses.length > 0) md += ` classes: [${meaningfulClasses.slice(0, 4).map((c: string) => `"${c}"`).join(', ')}]\n`; md += ` height: ${s.rect.height}px\n`; md += ` width: ${s.rect.width === 1440 ? 'full-bleed' : s.rect.width + 'px'}\n`; md += ` layout: ${layout}\n`; md += ` children: ${s.childCount}\n`; if (s.styles.backgroundColor && s.styles.backgroundColor !== 'rgba(0, 0, 0, 0)' && s.styles.backgroundColor !== 'transparent') { md += ` bg: "${s.styles.backgroundColor}"\n`; } // v2.7 A.4 — surface the background ATMOSPHERE (mesh/gradient/image + dark band) the build was blind to if (s.bgTreatment && s.bgTreatment !== 'flat') md += ` bg-treatment: ${s.bgTreatment}\n`; if (s.isDark) md += ` tone: dark\n`; if (s.styles.padding && s.styles.padding !== '0px') md += ` padding: "${s.styles.padding}"\n`; } md += '```\n\n'; md += '**Agent rule**: rebuild sections in this order (sorted by vertical position). Match the layout primitive (grid N-col / flex direction / block).\n\n'; return md; } // v8-T3: Copy Library — capture real text content for brand-voice fidelity function buildCopyLibrarySection(rawData: any): string { const elements = rawData?.elements || {}; const variants = rawData?.componentVariants || {}; const links = rawData?.links || []; // Collect heading texts (h1/h2/h3) from componentVariants — each variant has textContent const headings = { h1: (elements.heading?.textContent || '').trim(), h2: (variants.headingH2 || []).slice(0, 6).map((v: any) => (v.textContent || v.text || '').trim()).filter(Boolean), h3: (variants.headingH3 || []).slice(0, 6).map((v: any) => (v.textContent || v.text || '').trim()).filter(Boolean), }; // CTA texts from buttons const buttonTexts = (variants.buttons || []) .map((b: any) => (b.textContent || b.text || '').trim()) .filter((t: string) => t && t.length > 1 && t.length < 60) .slice(0, 10); const uniqueBtnTexts: string[] = Array.from(new Set(buttonTexts as string[])); // Nav items const navTexts = (links as any[]) .filter(l => l.isNav && l.text && l.text.length > 1 && l.text.length < 40) .map(l => l.text.trim()) .filter(t => !/^Aller (?:à|au)|Basculer le mode|^(?:#|http)/i.test(t)) // skip skip-links .slice(0, 12); const uniqueNavTexts = [...new Set(navTexts)]; // Footer links const footerLinkTexts = (variants.footerLinks || []) .slice(0, 15) .map((l: any) => (l.textContent || l.text || '').trim()) .filter(Boolean); if (!headings.h1 && headings.h2.length === 0 && uniqueBtnTexts.length === 0) return ''; let md = '\n## 14. Copy Library (Real Content from Source)\n\n'; md += '*Actual text content extracted from the live page. Use these strings verbatim when reproducing the site — they carry the brand voice.*\n\n'; md += '```yaml\ncopy:\n'; if (headings.h1) md += ` hero-h1: "${headings.h1.replace(/"/g, '\\"').slice(0, 200)}"\n`; if (headings.h2.length > 0) { md += ' section-headings:\n'; for (const h of headings.h2.slice(0, 6)) md += ` - "${h.replace(/"/g, '\\"').slice(0, 100)}"\n`; } if (headings.h3.length > 0) { md += ' sub-headings:\n'; for (const h of headings.h3.slice(0, 6)) md += ` - "${h.replace(/"/g, '\\"').slice(0, 100)}"\n`; } if (uniqueBtnTexts.length > 0) { md += ' ctas:\n'; for (const t of uniqueBtnTexts.slice(0, 8)) md += ` - "${t.replace(/"/g, '\\"')}"\n`; } if (uniqueNavTexts.length > 0) { md += ' navigation:\n'; for (const t of uniqueNavTexts.slice(0, 10)) md += ` - "${t.replace(/"/g, '\\"')}"\n`; } if (footerLinkTexts.length > 0) { md += ' footer-links:\n'; for (const t of footerLinkTexts.slice(0, 10)) md += ` - "${t.replace(/"/g, '\\"').slice(0, 60)}"\n`; } md += '```\n\n'; md += '**Agent rule**: reuse these exact strings when generating UI. Do NOT translate or rephrase — brand voice is preserved through the original wording.\n\n'; return md; } // v8-T4: Asset Inventory — classify images by role (logo/hero/product/thumbnail/etc) function buildAssetInventorySection(rawData: any): string { const images = (rawData?.images || []) as any[]; if (images.length === 0) return ''; // Classify by aspect ratio + size const buckets = { logo: [] as any[], hero: [] as any[], product: [] as any[], editorial: [] as any[], thumbnail: [] as any[], icon: [] as any[], }; for (const img of images) { if (!img.width || !img.height) continue; const ratio = img.width / img.height; const area = img.width * img.height; // Logo: small wordmark-like ratio (>2:1) under 200px wide if (img.width < 200 && ratio > 1.5 && ratio < 5) buckets.logo.push(img); // Hero: wide (ratio > 2) + large width else if (img.width > 1000 && ratio > 1.8) buckets.hero.push(img); // Editorial portrait: tall (9:16-ish) else if (ratio < 0.7 && img.height > 300) buckets.editorial.push(img); // Product: square-ish (0.85-1.2) medium size else if (ratio > 0.85 && ratio < 1.2 && img.width > 150 && img.width < 800) buckets.product.push(img); // Thumbnail: small square else if (ratio > 0.85 && ratio < 1.2 && img.width <= 150) buckets.thumbnail.push(img); // Icon: tiny else if (img.width < 40 && img.height < 40) buckets.icon.push(img); } let md = '\n## 15. Asset Inventory (Image Roles & Ratios)\n\n'; md += '*Image inventory classified by role and aspect ratio. When reproducing the site, allocate this number of assets per role.*\n\n'; md += '```yaml\nassets:\n'; for (const [role, items] of Object.entries(buckets)) { if (items.length === 0) continue; md += ` ${role}:\n`; md += ` count: ${items.length}\n`; // Average ratio const avgRatio = items.reduce((s, i) => s + (i.width / i.height), 0) / items.length; md += ` avg-ratio: ${avgRatio.toFixed(2)}\n`; // Common aspect const aspectName = avgRatio > 2.5 ? '~16:6 banner' : avgRatio > 1.5 ? '~16:9 landscape' : avgRatio > 0.85 ? '~1:1 square' : avgRatio > 0.5 ? '~9:16 portrait' : '~9:21 tall'; md += ` common-aspect: "${aspectName}"\n`; // Sample alts const sampleAlts = items.slice(0, 3).map(i => i.alt).filter(Boolean); if (sampleAlts.length > 0) { md += ` sample-alts:\n`; for (const alt of sampleAlts) md += ` - "${(alt as string).replace(/"/g, '\\"').slice(0, 60)}"\n`; } } md += '```\n\n'; md += '**Agent rule**: when generating placeholder images, match these counts and aspect ratios. Use neutral placeholder backgrounds for `product` (e.g. `#f5f5f5`), full-color photography hints for `hero` and `editorial`.\n\n'; return md; } // v8-T5: Component Templates — output HTML structure exemples for key patterns function buildComponentTemplatesSection(rawData: any): string { const variants = rawData?.componentVariants || {}; // v2.6 Fix-3 — the e-commerce product-card template hardcodes invented chrome (SALE badge, // ★★★★★ rating, price-range). Only emit it for a genuine product LISTING grid (≥4 cards) so // it stops being stamped on non-commerce sites like stripe (2 false-positive "cards") / notion. const hasProductCard = variants.productCard && variants.productCard.length >= 4; const hasButton = variants.buttons && variants.buttons.length > 0; const hasCard = variants.cards && variants.cards.length > 0; if (!hasProductCard && !hasButton && !hasCard) return ''; let md = '\n## 16. Component HTML Templates\n\n'; md += '*Suggested HTML markup for reproducing the key components — based on what was detected in the source DOM.*\n\n'; if (hasButton) { const btn = variants.buttons[0]; md += '### Button (primary action)\n```html\n'; md += '\n'; md += '```\n'; md += `_Source pattern detected: padding \`${btn.styles?.padding || '8px 16px'}\` · radius \`${btn.styles?.borderRadius || '6px'}\` · weight \`${btn.styles?.fontWeight || '600'}\`_\n\n`; } if (hasProductCard) { md += '### Product Card (e-commerce)\n```html\n'; md += '
\n'; md += '
SALE
\n'; md += '
\n'; md += ' ...\n'; md += '
\n'; md += '
\n'; md += '

{brand}

\n'; md += '

{name}

\n'; md += '
★★★★★ {n}
\n'; md += '

{price-range OR single}

\n'; md += '
\n'; md += '
\n```\n\n'; } if (hasCard) { const card = variants.cards[0]; md += '### Generic Card\n```html\n'; md += '
\n'; md += ' \n'; md += '
\n'; md += '```\n'; md += `_Source pattern detected: padding \`${card.styles?.padding || '16px'}\` · radius \`${card.styles?.borderRadius || '8px'}\` · shadow \`${(card.styles?.boxShadow || 'none').slice(0, 60)}\`_\n\n`; } md += '**Agent rule**: use these markup skeletons as the structural baseline. Apply tokens from §2-3 for visual styling.\n\n'; return md; } /** Main YAML frontmatter builder */ function buildYamlFrontmatter( tokens: any, domain: string, desktopData: any, typoRoles: TypoRole[], componentSpecs: ComponentSpec[], isDarkSite: boolean, ): YamlResult { const colorKeys = buildColorKeys(tokens, isDarkSite, desktopData); const roundedKeys = buildRoundedKeys(tokens); const spacingKeys = buildSpacingKeys(tokens); // Build reverse map: hex.toLowerCase() → "colors.primary" const colorTokenMap = new Map(); for (const { key, hex } of colorKeys) { if (hex) colorTokenMap.set(hex.toLowerCase(), `colors.${key}`); } // Build typography YAML — dedupe by key, keep first occurrence const typoMap = new Map(); // key → role for (const role of typoRoles) { const k = mapTypoKey(role.role); if (!typoMap.has(k)) typoMap.set(k, role); } // Ensure button + caption + body are always present if (!typoMap.has('button')) { const btn = typoRoles.find(r => r.role.toLowerCase().includes('button')); if (btn) typoMap.set('button', btn); } // typoTokenMap: "display-xl" → "64px" (the fontSize value for matching) const typoTokenMap = new Map(); for (const [k, role] of typoMap.entries()) { typoTokenMap.set(k, role.size); } // Build rounded reverse map: key → value const roundedTokenMap = new Map(); for (const { key, value } of roundedKeys) { roundedTokenMap.set(key, value); } // Build description from tokens const bgEntry = colorKeys.find(c => c.key === 'canvas' || c.key === 'background'); const primaryEntry = colorKeys.find(c => c.key === 'primary'); const inkEntry = colorKeys.find(c => c.key === 'ink'); // Prefer font applied to , then filtered fontFaces (excludes KaTeX/icon/math fonts) const _bodyFontStack = desktopData.elements?.body?.styles?.fontFamily || ''; const _bodyPrimaryFont = _bodyFontStack.split(',') .map((f: string) => f.trim().replace(/['"]/g, '')) .find((f: string) => f && !GENERIC_DISPLAY_FONTS.has(f.toLowerCase()) && !f.startsWith('__')); const primaryFont = _bodyPrimaryFont || (desktopData.fontFaces || []).find((ff: any) => isRealFont(ff.family))?.family || tokens.typography?.fontFamily?.primary || 'system-ui'; const bgHex = bgEntry?.hex || '#ffffff'; const primaryHex = primaryEntry?.hex || ''; const h1 = desktopData.elements?.heading?.styles; const displaySize = h1?.fontSize || '48px'; const displayWeight = h1?.fontWeight || '600'; const description = [ isDarkSite ? `Dark-canvas product system built on ${bgHex}${primaryHex ? ` with ${primaryHex} as the single brand accent` : ''}.` : `Light product system built on ${bgHex}${primaryHex ? ` with ${primaryHex} as the primary CTA accent` : ''}.`, `${primaryFont ? `Type anchored in ${primaryFont}` : 'System typography'} at ${displaySize} / weight ${displayWeight}.`, `Extracted automatically from https://${domain}/ — rendered styles via getComputedStyle() + CSS custom properties (token-only values may not be painted).`, ].join(' '); // v5-V1-T1: Auto-generate brand narrative from extraction patterns // (bg luminance + accent saturation + radius type + typo flavor + photo coverage) const brandNarrative = buildBrandNarrative(desktopData, tokens, domain); // ── Assemble YAML ── let yaml = '---\n'; yaml += `version: alpha\n`; yaml += `name: ${domain}\n`; yaml += `description: "${description.replace(/"/g, "'")}"\n`; if (brandNarrative) { yaml += `narrative: |\n${brandNarrative.split('\n').map(l => ' ' + l).join('\n')}\n`; } yaml += '\n'; // Colors yaml += 'colors:\n'; for (const { key, hex, raw } of colorKeys) { // Use raw value if it has alpha (rgba), else use hex const val = raw && /^rgba/i.test(raw) ? raw : hex; if (val) yaml += ` ${key}: "${val}"\n`; } yaml += '\n'; // Typography yaml += 'typography:\n'; const typoOrder = ['display-xl', 'display-lg', 'display-md', 'headline', 'card-title', 'subhead', 'body-lg', 'body', 'body-sm', 'caption', 'caption-sm', 'button', 'eyebrow', 'mono']; for (const k of typoOrder) { const role = typoMap.get(k); if (!role) continue; yaml += ` ${k}:\n`; yaml += ` fontFamily: "${role.font}"\n`; yaml += ` fontSize: ${role.size}\n`; yaml += ` fontWeight: ${role.weight}\n`; yaml += ` lineHeight: ${role.lineHeight || '1.5'}\n`; yaml += ` letterSpacing: ${role.letterSpacing === 'normal' ? '0' : role.letterSpacing}\n`; } yaml += '\n'; // Rounded yaml += 'rounded:\n'; for (const { key, value } of roundedKeys) { yaml += ` ${key}: ${value}\n`; } yaml += '\n'; // Spacing yaml += 'spacing:\n'; for (const { key, value } of spacingKeys) { yaml += ` ${key}: ${value}\n`; } yaml += '\n'; // Components yaml += 'components:\n'; const componentLines: string[] = []; const seenKeys = new Map(); // v2.6 Fix-3 — guarantee unique YAML keys (no silent override) for (const spec of componentSpecs) { // Canonical spec prefix: "Buttons" → "button", "Cards & Containers" → "card", "Inputs & Forms" → "input" const specPrefix = spec.name.toLowerCase() .replace(/&.*/g, '').trim() // drop "& ..." suffix .replace(/s\s*$/, '') // strip trailing s (Buttons → button) .replace(/\s+/g, '-') .replace(/[^a-z0-9-]/g, ''); for (const v of spec.variants.slice(0, 3)) { // max 3 variants per spec to keep compact const variantSuffix = v.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); let variantName = `${specPrefix}-${variantSuffix}`; const dupN = (seenKeys.get(variantName) || 0) + 1; seenKeys.set(variantName, dupN); if (dupN > 1) variantName = `${variantName}-${dupN}`; // button-primary-brand, button-primary-brand-2, … // Extract font size from v.font string like "13px weight 400" const fontSizeMatch = v.font?.match(/(\d+(?:\.\d+)?px)/); const lines = componentToYaml( { background: v.backgroundHex, text: v.textHex, padding: v.padding, radius: v.radius, fontSize: fontSizeMatch?.[1] || '16px' }, variantName, colorTokenMap, typoTokenMap, roundedTokenMap, ); componentLines.push(lines); } } yaml += componentLines.join('\n') + '\n'; // Completeness score placeholder — replaced after all sections are assembled yaml += '\nextracted_at: "' + new Date().toISOString() + '"\n'; yaml += 'completeness: __SCORE__\n'; yaml += '\n---\n'; return { yaml, colorTokenMap, typoTokenMap, roundedTokenMap }; } // ── Main Generator ────────────────────────────────────────────────── // v2.6 Fix-4 — Embedded third-party logo colors that leak into allColors via OAuth buttons, // customer-logo walls and social icons. They're real getComputedStyle values but NOT brand colors, // so they pollute the "Full Extracted Palette". Filtered from the extra dump (never from the // categorized §2 roles, so a brand whose real color happens to match is unaffected there). const THIRD_PARTY_HEX = new Set([ '#34a853', '#4285f4', '#fbbc04', '#fbbc05', '#ea4335', // Google '#76b900', // NVIDIA '#95bf47', '#5e8e3e', // Shopify '#5865f2', // Discord '#0a66c2', '#0077b5', // LinkedIn '#1da1f2', '#1d9bf0', // Twitter/X '#1877f2', '#4267b2', // Facebook/Meta '#25d366', // WhatsApp '#e60023', // Pinterest ]); // v2.6 Fix-2 — Wrong-surface detector. CA sometimes captures an app shell / interactive demo // (e.g. posthog's in-page code-editor widget: button labels "home.mdx", "1", "Switch to website // mode") instead of the marketing page. Flag low-confidence when ≥1 STRONG editor signal fires AND // ≥2 signals total — so the doc warns the agent instead of silently presenting editor chrome as brand. function detectWrongSurface(rawData: any): { flagged: boolean; reasons: string[] } { const reasons: string[] = []; const buttons: any[] = rawData.componentVariants?.buttons || []; const labels: string[] = buttons.map(b => String(b?.text || '').trim()).filter(Boolean); const fileLike = labels.filter(l => /\.(mdx?|tsx?|jsx?|css|scss|json|ya?ml|py|rb|go|rs|java|php|html?|vue|svelte|mov|mp4|png|jpe?g|svg|sh)$/i.test(l)); const numLike = labels.filter(l => /^\d{1,4}$/.test(l)); const editorWords = labels.filter(l => /\b(switch to (?:website|preview) mode|website mode|file explorer|new file|run code|open editor|ask a question)\b/i.test(l)); const strong = [fileLike.length >= 1, numLike.length >= 2, editorWords.length >= 1].filter(Boolean).length; if (fileLike.length >= 1) reasons.push(`button labels look like filenames (${fileLike.slice(0, 3).join(', ')})`); if (numLike.length >= 2) reasons.push(`${numLike.length} bare-number buttons (likely editor line numbers)`); if (editorWords.length >= 1) reasons.push(`editor-chrome labels (${editorWords.slice(0, 2).join(', ')})`); const cards: any[] = rawData.componentVariants?.cards || []; if (cards.length <= 1) reasons.push(`only ${cards.length} card component detected`); const footerLinks: any[] = rawData.componentVariants?.footerLinks || []; if (footerLinks.length === 0) reasons.push('footer has 0 links'); // Require ≥1 strong (editor-specific) signal so minimal-but-legit marketing pages don't false-flag. return { flagged: strong >= 1 && reasons.length >= 2, reasons }; } async function generateDesignMd(domain: string): Promise { const baseDir = join(process.cwd(), 'extractions', domain); console.log(`\n📝 Generating DESIGN.md for ${domain}...`); // Reset color name deduplication (factory stateful — cf RFC D) colorNamer.reset(); // Load all data sources const rawData = JSON.parse(await readFile(join(baseDir, 'raw-css.json'), 'utf-8')); const tokens = JSON.parse(await readFile(join(baseDir, 'tokens.json'), 'utf-8')); const desktopData = rawData.desktop; const mobileData = rawData.mobile; if (!desktopData) { console.error('No desktop data found in raw-css.json'); process.exit(1); } // Set module-level CSS vars FIRST — resolveColorName + nameColorUnique pass them to nameColor() // for brand-aware semantic naming. Without this, nameColor falls back to nearest-neighbor. _cssVars = desktopData.cssCustomProperties || {}; // Build brand color overrides — palette-WORD short names override semantic results when both apply _brandOverrides = buildPaletteColorOverrides(_cssVars); // Reset colorNamer state so each generation starts fresh (avoid name collision counters carrying over) colorNamer.reset(); // ── Section 1: Visual Theme & Atmosphere ── const themeNarrative = generateThemeNarrative(desktopData, tokens, domain); // Key characteristics const elements = desktopData.elements; const fontFacesEarly = desktopData.fontFaces || []; const bodyFont = extractFontName(elements.body?.styles?.fontFamily || ''); // Resolve background — handle transparent body const resolvedBg = tokens.colors?.background?.primary || elements.body?.styles?.backgroundColor || 'rgb(255,255,255)'; const bgInfo = resolveColorName(resolvedBg); const bgRgbForCheck = parseRgb(resolvedBg); const isDarkSiteCheck = bgRgbForCheck ? luminance(bgRgbForCheck) < 0.3 : false; // Primary text — on dark sites, body.color is often the CSS default (black), use the real visible text let primaryTextColor = elements.body?.styles?.color || 'rgb(0,0,0)'; if (isDarkSiteCheck) { // On dark sites: find the first clearly-light color in allColors (luminance > 0.6) const allColors = desktopData.allColors || []; const lightText = allColors.find((c: string) => { const rgb = parseRgb(c); return rgb && luminance(rgb) > 0.6 && !c.includes('rgba(0') && !c.startsWith('rgba(255, 255, 255, 0'); }); if (lightText) primaryTextColor = lightText; } const txtInfo = resolveColorName(primaryTextColor); const accentInfo = resolveColorName(tokens.colors?.accent?.primary || 'rgb(128,128,128)'); const keyChars: string[] = []; // ── Background signal (always) ── keyChars.push(`Background: ${proseName(resolvedBg)} (\`${bgInfo.displayValue}\`)`); // ── Typeface — filter KaTeX/math/icon fonts via isRealFont() ── const customFontName = fontFacesEarly.find((ff: any) => isRealFont(ff.family))?.family; const displayFont = customFontName || (bodyFont !== 'system-ui' ? bodyFont : null); if (displayFont) keyChars.push(`Primary typeface: ${displayFont}`); // ── Text color — only show if not pure black ── const txtRgbKc = parseRgb(primaryTextColor); const isPureBlackTxt = txtRgbKc && txtRgbKc[0] === 0 && txtRgbKc[1] === 0 && txtRgbKc[2] === 0; if (!isPureBlackTxt) { keyChars.push(`Primary text: ${proseName(primaryTextColor)} (\`${txtInfo.displayValue}\`)`); } // ── Accent ── keyChars.push(`Accent: ${proseName(tokens.colors?.accent?.primary || 'rgb(128,128,128)')} (\`${accentInfo.displayValue}\`)`); // ── Display heading — only if distinctive (negative tracking, unusual weight, large size) ── const h1 = elements.heading?.styles; if (h1) { const h1Size = parsePixels(h1.fontSize || ''); const h1Weight = parseInt(h1.fontWeight || '400'); const h1LS = h1.letterSpacing; const hasNegTracking = h1LS && h1LS !== 'normal' && parsePixels(h1LS) < -0.2; const hasUnusualWeight = h1Weight !== 400 && h1Weight !== 600 && h1Weight !== 700; const isLargeDisplay = h1Size >= 48; if (hasNegTracking || hasUnusualWeight || isLargeDisplay) { keyChars.push(`Display: ${h1.fontSize} weight ${h1.fontWeight}${h1LS !== 'normal' ? `, letter-spacing ${h1LS}` : ''}`); } } // ── Border — only if it's a border-dominant design (no shadows, has visible borders) ── const borderInfo = resolveColorName(tokens.colors?.border || 'rgb(229,231,235)'); const shadows = desktopData.allShadows?.filter((s: string) => s !== 'none') || []; const hasBorderDesign = (elements.card?.styles?.border && !elements.card?.styles?.border.includes('0px')) && shadows.length < 2; if (hasBorderDesign) keyChars.push(`Border: ${proseName(tokens.colors?.border || 'rgb(229,231,235)')} (\`${borderInfo.displayValue}\`) — border-based containment`); // ── Shadow system — only if distinctive (multi-layer or > 2 levels) ── const isDistinctiveShadow = shadows.length >= 3 || shadows.some((s: string) => (s.match(/,/g) || []).length > 1); if (isDistinctiveShadow) keyChars.push(`Shadow system: ${shadows.length} distinct elevation levels`); // ── Custom fonts — filtered (no KaTeX, icons, math) ── const fontFaces = desktopData.fontFaces || []; const filteredFontNames = fontFaces .map((f: any) => f.family) .filter((v: string, i: number, a: string[]) => a.indexOf(v) === i) .filter((family: string) => isRealFont(family)); if (filteredFontNames.length > 0) { keyChars.push(`Custom fonts loaded: ${filteredFontNames.join(', ')}`); } // ── Section 2: Color Palette & Roles ── const allColors = desktopData.allColors || []; const colorCategories: { category: string; colors: { name: string; hex: string; displayValue: string; raw: string; use: string }[]; }[] = []; // Resolve background — use token (handles transparent body rgba(0,0,0,0)) const resolvedPageBg = tokens.colors?.background?.primary || (elements.body?.styles?.backgroundColor !== 'rgba(0, 0, 0, 0)' ? elements.body?.styles?.backgroundColor : null) || 'rgb(255,255,255)'; const resolvedPageBgRgb = parseRgb(resolvedPageBg); // Detect dark site using resolved background const isDarkSite = resolvedPageBgRgb ? luminance(resolvedPageBgRgb) < 0.3 : false; // Build semantic color maps from CSS custom properties (priority 1 source of truth) const semantic = buildSemanticColorMaps(desktopData.cssCustomProperties || {}); // Build brand name overrides from --palette-WORD / --color-WORD CSS vars (e.g. Rausch, Luxe, Babu) const paletteOverrides = _brandOverrides; // already built at function start // Union of all semantic hexes — used to skip parasitic colors in fallback passes // (e.g. a hex that's already classified as 'border' shouldn't be re-added as 'background') const semanticClassified = new Set([ ...semantic.bg.keys(), ...semantic.text.keys(), ...semantic.accent.keys(), ...semantic.border.keys(), ]); // Apply brand name override if available, otherwise use generic nameColor result function applyBrandOverride(rawColor: string): { name: string; hex: string; displayValue: string } { const info = nameColor(rawColor); const override = paletteOverrides.get(info.hex); return override ? { name: override, hex: info.hex, displayValue: info.displayValue } : info; } // Cross-category deduplication: tracks ALL hexes assigned to ANY category // so the same color can't appear in both Background AND Accent simultaneously. const alreadySeen = new Set(); // Helper: build a color category — semantic-first, heuristic fallback function buildColorCategory( semanticMap: Map, semanticUse: string, fallbackPredicate: (lum: number, rgb: [number, number, number]) => boolean, fallbackUse: string, maxColors: number, seedColor?: { color: string; use: string }, ): { name: string; hex: string; displayValue: string; raw: string; use: string }[] { const result: { name: string; hex: string; displayValue: string; raw: string; use: string }[] = []; const seen = new Set(); // Seed (e.g. body bg, body text) — always first if (seedColor) { const info = applyBrandOverride(seedColor.color); result.push({ name: info.name, hex: info.hex, displayValue: info.displayValue, raw: seedColor.color, use: seedColor.use }); seen.add(info.hex); alreadySeen.add(info.hex); } // PASS 1 — semantic: iterate over CSS-var entries (not allColors) so we // include vars that aren't applied to a rendered element. Use the entry's // .raw value to preserve alpha (the hex map key loses translucency info). for (const [hex, entry] of semanticMap) { if (result.length >= maxColors) break; if (seen.has(hex)) continue; // Cross-category dedup: skip if already claimed by a higher-priority category // (prevents same color appearing in both Background and Accent) if (alreadySeen.has(hex)) continue; const info = applyBrandOverride(entry.raw); // brand override > semantic role name > generic nameColor seen.add(hex); alreadySeen.add(hex); // Use brand name override (e.g. "Rausch") over CSS var role (e.g. "Palette Bg Primary Core") const displayName = paletteOverrides.get(hex) || entry.role; result.push({ name: displayName, hex, displayValue: info.displayValue, raw: entry.raw, use: semanticUse }); } // PASS 2 — heuristic fallback: only for colors NOT classified in any other semantic map. // Use plain nameColor (not nameColorUnique) — dedup is already enforced via `seen` Set, // so the "(#xxxxxx)" suffix from nameColorUnique would just produce ugly duplicate names. for (const c of allColors) { if (result.length >= maxColors) break; const rgb = parseRgb(c); if (!rgb) continue; const info = applyBrandOverride(c); if (seen.has(info.hex)) continue; if (alreadySeen.has(info.hex)) continue; // Skip if this color is semantically classified elsewhere (not in our map) if (semanticClassified.has(info.hex) && !semanticMap.has(info.hex)) continue; const lum = luminance(rgb); if (fallbackPredicate(lum, rgb)) { seen.add(info.hex); alreadySeen.add(info.hex); result.push({ name: info.name, hex: info.hex, displayValue: info.displayValue, raw: c, use: fallbackUse }); } } return result; } // Background & Surface — pre-filter semantic.bg to only keep true surface colors (near-white/near-black) // Airbnb's --palette-bg-* namespace also includes hover/disabled state colors (#6a6a6a, #b0b0b0) // that are visually borders/text, not surfaces. Exclude them so they can land in the correct category. const bgLumThreshold = isDarkSite ? 0.12 : 0.88; const bgSemanticFiltered = new Map( [...semantic.bg].filter(([hex]) => { const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); if (isNaN(r)) return true; const lum = luminance([r, g, b]); return isDarkSite ? lum <= bgLumThreshold : lum >= bgLumThreshold; }) ); const bgColors = buildColorCategory( bgSemanticFiltered, 'Surface / elevated background (CSS var)', (lum) => isDarkSite ? (lum < 0.3) : (lum > 0.85), 'Surface / elevated background', 5, { color: resolvedPageBg, use: 'Page background (primary)' }, ); if (bgColors.length > 0) colorCategories.push({ category: 'Background & Surface', colors: bgColors }); // Text & Content — pre-filter: text colors should be clearly readable (dark on light, light on dark) // Disabled/inverse state colors like #dddddd (text-primary-disabled) are not readable text — exclude. const textLumMax = isDarkSite ? 1.0 : 0.65; const textSemanticFiltered = new Map( [...semantic.text].filter(([hex]) => { const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); if (isNaN(r)) return true; // pass rgba() through const lum = luminance([r, g, b]); return isDarkSite ? lum >= 0.40 : lum <= textLumMax; }) ); const bodyTxtRaw = elements.body?.styles?.color || 'rgb(0,0,0)'; const txtColors = buildColorCategory( textSemanticFiltered, 'Text (CSS var)', (lum) => isDarkSite ? (lum > 0.5 && lum < 0.85) : (lum < 0.25), 'Secondary text', 5, { color: bodyTxtRaw, use: 'Primary body text' }, ); if (txtColors.length > 0) colorCategories.push({ category: 'Text & Content', colors: txtColors }); // Accent & Interactive const accentColors = buildColorCategory( semantic.accent, 'Accent / interactive (CSS var)', (lum, rgb) => { const sat = Math.max(rgb[0], rgb[1], rgb[2]) - Math.min(rgb[0], rgb[1], rgb[2]); return lum >= 0.15 && lum <= 0.92 && sat > 20; }, 'Interactive / accent', 7, ); if (accentColors.length > 0) colorCategories.push({ category: 'Accent & Interactive', colors: accentColors }); // Border & Divider const bdrColors = buildColorCategory( semantic.border, 'Border (CSS var)', (lum, rgb) => { const sat = Math.max(rgb[0], rgb[1], rgb[2]) - Math.min(rgb[0], rgb[1], rgb[2]); // Dark sites: borders are dark grays (0.05-0.30). Light sites: light grays (0.70-0.92). const inRange = isDarkSite ? (lum > 0.05 && lum < 0.30) : (lum > 0.70 && lum < 0.92); return inRange && sat < 30; }, 'Borders / dividers', 6, ); if (bdrColors.length > 0) colorCategories.push({ category: 'Border & Divider', colors: bdrColors }); // ── Section 3: Typography ── const typoRoles = detectTypoRoles(desktopData); // Font families: custom web fonts first, then system fallbacks — filter generic fonts const GENERIC_FONT_NAMES = new Set([ 'times new roman', 'times', 'georgia', 'palatino', 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'arial', 'helvetica', 'verdana', 'tahoma', 'trebuchet ms', 'apple color emoji', 'segoe ui emoji', ]); const fontFaceFamilies = (desktopData.fontFaces || []) .map((ff: any) => ff.family) .filter((f: string, i: number, arr: string[]) => arr.indexOf(f) === i) .filter((f: string) => !f.startsWith('__') && !GENERIC_FONT_NAMES.has(f.toLowerCase().replace(/"/g, ''))); const rawFamilies = (desktopData.allFontFamilies || []) as string[]; const extraFamilies = rawFamilies .map(stack => stack.split(',')[0].trim().replace(/"/g, '')) .filter(f => f && !GENERIC_FONT_NAMES.has(f.toLowerCase()) && !f.startsWith('__')); // PRIMARY = body's first non-generic font (the brand's actual primary typeface) // NOT the first @font-face declaration order (which can be alphabetical or import order) const bodyFontStack = (desktopData.elements?.body?.styles?.fontFamily || '') as string; const bodyFirstFont = bodyFontStack.split(',') .map((s: string) => s.trim().replace(/"/g, '')) .find((f: string) => f && !GENERIC_FONT_NAMES.has(f.toLowerCase()) && !f.startsWith('__')); // Also check heading/display elements for brand font (sometimes body uses sans, headers use brand) const headingFontStack = (desktopData.elements?.heading?.styles?.fontFamily || desktopData.elements?.h1?.styles?.fontFamily || '') as string; const headingFirstFont = headingFontStack.split(',') .map((s: string) => s.trim().replace(/"/g, '')) .find((f: string) => f && !GENERIC_FONT_NAMES.has(f.toLowerCase()) && !f.startsWith('__')); const allFamiliesOrdered: string[] = []; // Priority 1: body font (real primary) if (bodyFirstFont) allFamiliesOrdered.push(bodyFirstFont); // Priority 2: heading font if different if (headingFirstFont && headingFirstFont.toLowerCase() !== bodyFirstFont?.toLowerCase()) { allFamiliesOrdered.push(headingFirstFont); } // Priority 3: other fonts from @font-face declarations for (const f of fontFaceFamilies) { if (!allFamiliesOrdered.some(x => x.toLowerCase() === f.toLowerCase())) { allFamiliesOrdered.push(f); } } // Priority 4: other fonts found in any stack for (const f of extraFamilies) { if (!allFamiliesOrdered.some(x => x.toLowerCase() === f.toLowerCase())) { allFamiliesOrdered.push(f); } } // Fallback: if nothing found, include system fonts if (allFamiliesOrdered.length === 0) { allFamiliesOrdered.push(...rawFamilies.slice(0, 3).map(s => s.split(',')[0].trim().replace(/"/g, ''))); } const fontFamilies = [...new Set(allFamiliesOrdered)].slice(0, 5); // ── Section 4: Components ── const componentSpecs = buildComponentSpecs(desktopData, tokens); // ── Section 5: Layout ── const layout = analyzeLayout(desktopData, tokens); // ── Section 6: Depth & Elevation ── const shadowSystem = analyzeShadows(desktopData, tokens); // ── Section 7: Motion & Interaction ── const motionSection = generateMotionSection(desktopData); // ── Section 8: Do's and Don'ts ── const { dos, donts } = generateDosAndDonts(desktopData, tokens); // ── Section 9: Responsive ── const responsive = analyzeResponsive(desktopData, mobileData, ); // ── Section 10: Agent Guide ── const agentGuide = generateAgentGuide(tokens, desktopData, domain); // ════════════════════════════════════════════════════════════════════ // ASSEMBLE THE DESIGN.MD // ════════════════════════════════════════════════════════════════════ // ── YAML Frontmatter (spec alpha — getdesign.md compatible) ── const { yaml: yamlFrontmatter, colorTokenMap, typoTokenMap, roundedTokenMap } = buildYamlFrontmatter( tokens, domain, desktopData, typoRoles, componentSpecs, isDarkSite, ); let md = yamlFrontmatter + '\n'; md += `# Design System — ${domain}\n`; md += `> Extracted automatically by Clone Architect from ${desktopData.url}\n`; md += `> Date: ${new Date().toISOString().split('T')[0]}\n`; md += `> Viewport: Desktop ${desktopData.viewport?.width || 1440}x${desktopData.viewport?.height || 900} + Mobile 390x844\n`; md += `> Values are extracted, not hand-written: rendered styles via getComputedStyle() + colors declared in CSS custom properties (token-only values are marked \`(token)\` and may not be painted).\n`; md += '\n'; // v2.6 Fix-2: warn upfront if we likely captured an app shell / demo instead of the marketing page const surfaceCheck = detectWrongSurface(desktopData); if (surfaceCheck.flagged) { md += `> ⚠️ **LOW-CONFIDENCE CAPTURE** — this extraction likely sampled an app shell / interactive demo, **not** the marketing page. Signals: ${surfaceCheck.reasons.join('; ')}. Treat the component, color and copy values below with caution, and re-extract the canonical marketing URL if fidelity matters.\n\n`; } // v5-V1-T2: TL;DR header for AI agents (scannable summary at top) md += buildAgentTldr(tokens, typoRoles, componentSpecs, desktopData); // ── 1. Visual Theme & Atmosphere ── md += '## 1. Visual Theme & Atmosphere\n\n'; md += themeNarrative + '\n\n'; md += '**Key Characteristics:**\n'; for (const kc of keyChars) { md += `- ${kc}\n`; } md += '\n'; // ── 2. Color Palette & Roles ── md += '## 2. Color Palette & Roles\n\n'; for (const cat of colorCategories) { md += `### ${cat.category}\n`; for (const c of cat.colors) { md += `- **${c.name}** (\`${c.displayValue}\`): ${c.use}\n`; } md += '\n'; } // ── Full Extracted Palette (v9-color) ────────────────────────────── // The categorized lists above cap each role (bg≤5, text≤5, accent≤6, border…), // so a colorful brand like mongodb/starbucks surfaces only ~18 of its ~45 real // colors — getdesign.md curates more, and the comparison scorer counts distinct // documented hexes. This block lists every remaining real color from the live // getComputedStyle() capture (allColors), hex-normalized via applyBrandOverride, // skipping ones already shown above (alreadySeen) and transparent/invalid values. // No invented values — purely surfacing data CA already extracted (R26). { // v2.6 Fix-4 — Separate RENDERED colors (Source A, allColors) from token-only colors // (Source B, CSS var values) so the headline count is honest, and filter third-party logo noise. const renderedHexSet = new Set(); for (const c of allColors as string[]) { const s = String(c).trim(); if (!/^#[0-9a-fA-F]{3,8}$/.test(s) && !/^rgba?\(/.test(s)) continue; const h = applyBrandOverride(s).hex; if (/^#[0-9a-fA-F]{6}$/.test(h)) renderedHexSet.add(h.toLowerCase()); } const paletteExtra: { hex: string; displayValue: string; name: string; rendered: boolean }[] = []; const paletteSeen = new Set(alreadySeen); // Source A — allColors (rendered element colors). Source B — color values inside // CSS custom properties: sites like theverge (14 allColors / 1898 vars) and // starbucks (13 / 188) keep most of their palette in design-token vars, not on // rendered elements, so allColors alone undercounts. Harvest hex + rgb()/rgba() // literals from var VALUES too (var refs / non-color values are ignored by parseRgb). const cssVarColorValues: string[] = []; for (const v of Object.values(desktopData.cssCustomProperties || {})) { const val = String(v).trim(); if (!val || val.startsWith('var(')) continue; const matches = val.match(/#[0-9a-fA-F]{6}\b|rgba?\([^)]+\)/g); if (matches) cssVarColorValues.push(...matches); } for (const c of [...allColors, ...cssVarColorValues]) { const s = String(c).trim(); // Accept hex (#rgb/#rgba/#rrggbb/#rrggbbaa) OR rgb()/rgba(). The in-scope parseRgb // (css-helpers) matches rgb() ONLY — it would reject the 160+ pure-hex token vars on // sites like theverge. This test admits both; 'transparent'/'inherit'/var()/junk fail it. if (!/^#[0-9a-fA-F]{3,8}$/.test(s) && !/^rgba?\(/.test(s)) continue; const info = applyBrandOverride(s); if (!/^#[0-9a-fA-F]{6}$/.test(info.hex)) continue; // applyBrandOverride normalizes; reject anything not a solid hex if (THIRD_PARTY_HEX.has(info.hex.toLowerCase())) continue; // drop embedded third-party logo colors if (paletteSeen.has(info.hex)) continue; paletteSeen.add(info.hex); paletteExtra.push({ hex: info.hex, displayValue: info.displayValue, name: info.name, rendered: renderedHexSet.has(info.hex.toLowerCase()) }); } if (paletteExtra.length > 0) { const renderedCount = [...paletteSeen].filter(h => renderedHexSet.has(String(h).toLowerCase())).length; const tokenOnlyCount = paletteSeen.size - renderedCount; md += `### Full Extracted Palette\n`; md += `**${renderedCount} colors rendered on the page**`; if (tokenOnlyCount > 0) md += ` + **${tokenOnlyCount}** more declared in design tokens (CSS custom properties — not necessarily painted)`; md += `. (${alreadySeen.size} categorized above + ${paletteExtra.length} additional below.)\n`; md += `Rendered values are read via \`getComputedStyle()\`; token values come from CSS variable declarations. Third-party logo colors are filtered out.\n\n`; for (const c of paletteExtra.slice(0, 32)) { md += `- \`${c.displayValue}\` — ${c.name}${c.rendered ? '' : ' *(token)*'}\n`; } if (paletteExtra.length > 32) { md += `- *…+${paletteExtra.length - 32} more in \`raw-css.json\` (\`desktop.allColors\`).*\n`; } md += '\n'; } } // CSS Custom Properties — semantic mapping const cssVars = desktopData.cssCustomProperties || {}; // Semantic role detection from CSS variable names const SEMANTIC_PATTERNS: Array<{ pattern: RegExp; role: string }> = [ { pattern: /color-(primary|brand|accent|main)/i, role: 'Primary Brand Color' }, { pattern: /color-(secondary|muted|sub)/i, role: 'Secondary Color' }, { pattern: /color-(bg|background|surface|canvas)/i, role: 'Background' }, { pattern: /color-(text|foreground|content)/i, role: 'Text Color' }, { pattern: /color-(border|stroke|outline|divider)/i, role: 'Border' }, { pattern: /color-(error|danger|red)/i, role: 'Error / Destructive' }, { pattern: /color-(success|green)/i, role: 'Success' }, { pattern: /color-(warning|yellow|amber)/i, role: 'Warning' }, { pattern: /font-(family|face|typeface)/i, role: 'Font Family Token' }, { pattern: /font-(size|scale|type)/i, role: 'Font Size Token' }, { pattern: /font-(weight|bold)/i, role: 'Font Weight Token' }, { pattern: /spacing|gap|space|padding/i, role: 'Spacing Token' }, { pattern: /radius|rounded/i, role: 'Border Radius Token' }, { pattern: /shadow/i, role: 'Shadow Token' }, { pattern: /z-index|z-level/i, role: 'Z-Index Token' }, { pattern: /transition|animation|duration|springs|easing|spring-|motion-/i, role: 'Motion Token' }, ]; function detectSemanticRole(varName: string): string | null { for (const { pattern, role } of SEMANTIC_PATTERNS) { if (pattern.test(varName)) return role; } return null; } // Group CSS vars by semantic role const semanticGroups: Record> = {}; const ungroupedVars: Array<[string, string]> = []; for (const [k, v] of Object.entries(cssVars)) { const val = v as string; const isMotionVar = /motion|spring|easing|curve|transition/i.test(k); if (val.length > 80 && !isMotionVar) continue; // skip huge values (but allow motion curves) if (val.trim().startsWith('var(')) continue; // skip unresolved references const role = detectSemanticRole(k); if (role) { if (!semanticGroups[role]) semanticGroups[role] = []; const groupLimit = role === 'Motion Token' ? 3 : 4; if (semanticGroups[role].length < groupLimit) semanticGroups[role].push([k, val]); } else { // Still include if it looks meaningful const key = k.toLowerCase(); if ((key.includes('color') || key.includes('bg') || key.includes('text') || key.includes('primary') || key.includes('accent') || key.includes('font')) && val.length < 60) { ungroupedVars.push([k, val]); } } } const hasSemanticGroups = Object.keys(semanticGroups).length > 0; const totalMeaningfulVars = Object.values(semanticGroups).reduce((s, g) => s + g.length, 0) + ungroupedVars.length; // ── Gradient Palette ── // backgroundImage values captured from key elements + CSS gradient vars const allGradients: { source: string; value: string }[] = []; const seenGradients = new Set(); // From CSS custom properties: vars with "gradient" in name for (const [k, v] of Object.entries(cssVars as Record)) { const val = v as string; if (/gradient/i.test(k) && val.includes('gradient(')) { const normalized = val.replace(/\s+/g, ' ').trim().slice(0, 120); if (!seenGradients.has(normalized)) { seenGradients.add(normalized); allGradients.push({ source: k, value: val }); } } } // From computed backgroundImage on key elements for (const [elName, el] of Object.entries(desktopData.elements || {})) { const bgImg = (el as any)?.styles?.backgroundImage; if (bgImg && bgImg !== 'none' && bgImg.includes('gradient(')) { const normalized = bgImg.replace(/\s+/g, ' ').trim().slice(0, 120); if (!seenGradients.has(normalized)) { seenGradients.add(normalized); allGradients.push({ source: elName, value: bgImg }); } } } if (allGradients.length > 0) { md += '### Gradients & Decorative Fills\n\n'; for (const g of allGradients.slice(0, 8)) { const short = g.value.length > 100 ? g.value.slice(0, 100) + '…' : g.value; md += `- \`${short}\` — (${g.source})\n`; } md += '\n'; } if (totalMeaningfulVars > 0) { md += '### CSS Custom Properties (Design Tokens)\n\n'; if (hasSemanticGroups) { // Show grouped by semantic role for (const [role, vars] of Object.entries(semanticGroups)) { md += `**${role}**\n`; for (const [k, v] of vars) { const displayVal = v.length > 100 ? v.slice(0, 97) + '...' : v; md += `- \`${k}\`: \`${displayVal}\`\n`; } md += '\n'; } // Show ungrouped remainder (max 8) if (ungroupedVars.length > 0) { md += '**Other tokens**\n'; for (const [k, v] of ungroupedVars.slice(0, 8)) { md += `- \`${k}\`: \`${v}\`\n`; } md += '\n'; } } else { // Flat list fallback const allVars = [...ungroupedVars].slice(0, 15); for (const [k, v] of allVars) { md += `- \`${k}\`: \`${v}\`\n`; } md += '\n'; } } // ── 3. Typography Rules ── md += '## 3. Typography Rules\n\n'; md += '### Font Families\n'; for (let i = 0; i < Math.min(fontFamilies.length, 5); i++) { const label = i === 0 ? 'Primary' : i === 1 ? 'Secondary' : `Font ${i + 1}`; md += `- **${label}**: \`${fontFamilies[i]}\`\n`; } md += '\n'; if (fontFaces.length > 0) { md += '### Custom Fonts Loaded\n'; const seenFonts = new Set(); for (const ff of fontFaces) { const key = `${ff.family}-${ff.weight}`; if (seenFonts.has(key)) continue; seenFonts.add(key); md += `- **${ff.family}** weight ${ff.weight} (${ff.style})\n`; } md += '\n'; } md += '### Typography Hierarchy\n\n'; md += '| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes |\n'; md += '|------|------|------|--------|-------------|----------------|-------|\n'; for (const role of typoRoles) { md += `| ${role.role} | ${role.font} | ${role.size} | ${role.weight} | ${role.lineHeight} | ${role.letterSpacing} | ${role.notes} |\n`; } md += '\n'; // ── Font Weight Scale (from CSS custom properties) ── const cssWeightVarsList = Object.entries(cssVars as Record) .filter(([k, v]) => /font-?weight/i.test(k) && /^\d+$/.test((v as string).trim())) .map(([k, v]) => ({ name: k, value: parseInt(v as string) })) .sort((a, b) => a.value - b.value); if (cssWeightVarsList.length > 1) { const _WEIGHT_LABELS: Record = { 100: 'Thin', 200: 'ExtraLight', 300: 'Light', 400: 'Regular', 500: 'Medium', 600: 'SemiBold', 700: 'Bold', 800: 'ExtraBold', 900: 'Black' }; md += '### Font Weight Scale\n\n'; md += `CSS custom properties define ${cssWeightVarsList.length} explicit weight steps:\n`; for (const { name, value } of cssWeightVarsList) { const nearestStandard = [100,200,300,400,500,600,700,800,900].reduce((a, b) => Math.abs(b - value) < Math.abs(a - value) ? b : a); const label = _WEIGHT_LABELS[nearestStandard] || `${value}`; const isCustom = !_WEIGHT_LABELS[value]; md += `- \`${name}\`: \`${value}\` — ${label}${isCustom ? ` (custom, ~${nearestStandard})` : ''}\n`; } md += '\n'; } // ── Full Font Size Scale ── // The hierarchy above only covers sizes attached to a SAMPLED element variant // (heading h1-h6, body, button, link, badge, input). Many design systems use // additional display sizes (72/56/40/38px etc.) that aren't on the sampled // elements but ARE part of the type ramp. We list all unique sizes detected // anywhere in the page so consumers see the complete scale. const allSizesPx: number[] = (desktopData.allFontSizes || []) .map((s: string) => parsePixels(s)) .filter((p: number) => p >= 8 && p <= 200); const uniqueSizesPx: number[] = [...new Set(allSizesPx)].sort((a, b) => b - a); const coveredSizes = new Set(typoRoles.map(r => parsePixels(r.size))); const orphanSizes: number[] = uniqueSizesPx.filter(p => !coveredSizes.has(p)); if (uniqueSizesPx.length > 0) { md += '### Full Font Size Scale\n\n'; md += `Sizes detected in the design (descending): `; md += uniqueSizesPx.map(p => p % 1 === 0 ? `\`${p}px\`` : `\`${p.toFixed(2)}px\``).join(', '); md += '\n\n'; if (orphanSizes.length > 0) { md += `> ${orphanSizes.length} size(s) detected outside the sampled hierarchy `; md += `(${orphanSizes.map(p => `${p}px`).join(', ')}). `; md += `These appear on elements not in the sampled set — likely additional display sizes, marketing-section overrides, or third-party-widget styles.\n\n`; } } // OpenType & Variable Font features const openTypeFeatures: string[] = desktopData.openTypeFeatures || []; const variableAxes: string[] = desktopData.variableAxes || []; const FEATURE_LABELS: Record = { liga: 'Standard Ligatures (liga) — `on`', kern: 'Kerning (kern) — `on`', ss01: 'Stylistic Set 1 (ss01) — alternate glyph forms', ss02: 'Stylistic Set 2 (ss02) — alternate glyph forms', ss03: 'Stylistic Set 3 (ss03) — alternate glyph forms', tnum: 'Tabular Numbers (tnum) — fixed-width digits for data tables', onum: 'Oldstyle Numerals (onum) — lowercase-style digits', pnum: 'Proportional Numbers (pnum)', frac: 'Fractions (frac) — diagonal fraction formatting', sups: 'Superscripts (sups)', subs: 'Subscripts (subs)', smcp: 'Small Caps (smcp)', c2sc: 'All Small Caps (c2sc)', zero: 'Slashed Zero (zero) — disambiguates 0 vs O', ordn: 'Ordinals (ordn) — ordinal indicators', swsh: 'Swash (swsh) — decorative alternates', hist: 'Historical Forms (hist)', cv01: 'Character Variant 1 (cv01)', cv02: 'Character Variant 2 (cv02)', calt: 'Contextual Alternates (calt)', dlig: 'Discretionary Ligatures (dlig)', }; const AXIS_LABELS: Record = { wght: 'Weight axis (wght) — variable font weight range', wdth: 'Width axis (wdth) — variable condensed/expanded width', ital: 'Italic axis (ital) — interpolated italic', slnt: 'Slant axis (slnt) — oblique angle', opsz: 'Optical Size axis (opsz) — size-specific optimizations', GRAD: 'Grade axis (GRAD) — weight without layout change', XHGT: 'X-Height axis (XHGT) — x-height adjustment', }; if (openTypeFeatures.length > 0 || variableAxes.length > 0) { md += '### OpenType & Variable Font Features\n\n'; // Phase 4.3 — Narrative summary BEFORE technical list (LLM-friendly) const sophisticated: string[] = []; if (openTypeFeatures.includes('tnum')) sophisticated.push('**tabular figures** (`tnum`) — numbers align vertically, essential for pricing tables and dashboards'); if (openTypeFeatures.includes('lnum')) sophisticated.push('**lining figures** (`lnum`) — full-height numbers, more authoritative than old-style'); if (openTypeFeatures.includes('onum')) sophisticated.push('**old-style figures** (`onum`) — varying-height numerals, editorial feel'); if (openTypeFeatures.includes('ss01') || openTypeFeatures.includes('ss02') || openTypeFeatures.includes('ss03')) sophisticated.push('**stylistic sets** (`ss0X`) — alternate letterforms enabled (often for brand-specific glyph variants)'); if (openTypeFeatures.includes('calt')) sophisticated.push('**contextual alternates** (`calt`) — connections between letters refined contextually'); if (openTypeFeatures.includes('case')) sophisticated.push('**case-sensitive forms** (`case`) — punctuation height adjusted for uppercase contexts'); if (openTypeFeatures.includes('cpsp')) sophisticated.push('**capital spacing** (`cpsp`) — added tracking on uppercase for breathing room'); if (openTypeFeatures.includes('liga') || openTypeFeatures.includes('dlig')) sophisticated.push('**ligatures** — common letter pairs replaced with refined glyphs (fi, fl, etc.)'); if (openTypeFeatures.includes('locl')) sophisticated.push('**localized forms** (`locl`) — glyphs swapped based on script/locale'); if (openTypeFeatures.includes('zero')) sophisticated.push('**slashed zero** (`zero`) — distinguishes 0 from O, useful for code/data'); if (sophisticated.length > 0) { md += 'The typography uses these OpenType features intentionally — this is a sophisticated type system, not a default fallback:\n\n'; for (const s of sophisticated) md += `- ${s}\n`; md += '\n**Clone instruction:** Enable these in your CSS:\n'; md += '```css\n'; md += 'body {\n'; md += ` font-feature-settings: ${openTypeFeatures.map(f => `"${f}"`).join(', ')};\n`; md += '}\n'; md += '```\n\n'; } else if (openTypeFeatures.length > 0) { md += '**Active OpenType features:**\n'; for (const feat of openTypeFeatures) { const label = FEATURE_LABELS[feat] || `\`${feat}\``; md += `- ${label}\n`; } md += '\n'; } if (variableAxes.length > 0) { md += '**Variable font axes:**\n'; for (const axis of variableAxes) { const label = AXIS_LABELS[axis] || `${axis} axis`; md += `- ${label}\n`; } if (variableAxes.includes('wght')) { md += '\n*The weight axis lets the design system fine-tune type weight per role (e.g., 380 for body, 580 for headings) rather than relying on discrete 400/500/600/700 steps.*\n'; } md += '\n'; } } // ── 4. Component Stylings ── md += '## 4. Component Stylings\n\n'; // Map component spec names → componentStates keys const stateMap: Record = { 'Buttons': 'button', 'Cards & Containers': 'card', 'Inputs & Forms': 'input', 'Navigation': 'navLink', 'Tabs': 'tab', 'Badges': 'badge', 'Status Badges': 'badge', 'Footer Links': 'footerLink', // Extended coverage for v2.1+ 'Search Bar': 'input', 'Pricing Cards': 'card', 'CTA Banners': 'button', 'Testimonials': 'card', 'Alerts': 'card', 'Product Cards': 'card', 'Property Cards': 'card', 'Links': 'link', }; // Helper: annotate a hex/rgb display value with its {token.ref} if known function withTokenRef(displayValue: string, hex: string): string { if (!hex) return displayValue; const ref = colorTokenMap.get(hex.toLowerCase()); return ref ? `${displayValue} \`{${ref}}\`` : displayValue; } for (const spec of componentSpecs) { md += `### ${spec.name}\n\n`; for (const v of spec.variants) { md += `**${v.name}**\n`; md += `- Background: ${withTokenRef(v.background, v.backgroundHex)}\n`; if (v.text) md += `- Text: ${withTokenRef(v.text, v.textHex)}\n`; md += `- Padding: ${v.padding}\n`; md += `- Radius: ${v.radius}\n`; if (v.border !== 'none') md += `- Border: ${v.border}\n`; if (v.shadow !== 'none') md += `- Shadow: \`${v.shadow.length > 80 ? v.shadow.substring(0, 80) + '...' : v.shadow}\`\n`; if (v.font) md += `- Font: ${v.font}\n`; md += `- Use: ${v.use}\n`; // Add interaction states if available — show for all variants (not just named ones) const stateKey = stateMap[spec.name]; if (stateKey) { const states = desktopData.componentStates?.[stateKey]; if (states) { if (states.hover && Object.keys(states.hover).length > 0) { const hoverDiffs: string[] = []; for (const [k, val] of Object.entries(states.hover)) { if (states.default && val !== states.default[k]) { const colorProps = ['backgroundColor', 'color', 'borderColor']; const display = colorProps.includes(k) ? nameColor(val as string).hex : val; hoverDiffs.push(`${k}: \`${display}\``); } } if (hoverDiffs.length > 0) md += `- Hover: ${hoverDiffs.slice(0, 3).join(', ')}\n`; } if (states.focus && Object.keys(states.focus).length > 0) { const focusDiffs: string[] = []; for (const [k, val] of Object.entries(states.focus)) { if (states.default && val !== states.default[k]) { const colorProps = ['backgroundColor', 'color', 'borderColor', 'outline']; const display = colorProps.includes(k) ? val : val; focusDiffs.push(`${k}: \`${(display as string).slice(0, 60)}\``); } } if (focusDiffs.length > 0) md += `- Focus: ${focusDiffs.slice(0, 3).join(', ')}\n`; } if (states.active && Object.keys(states.active).length > 0) { const activeDiffs: string[] = []; for (const [k, val] of Object.entries(states.active)) { if (states.default && val !== states.default[k]) { activeDiffs.push(`${k}: \`${(val as string).slice(0, 40)}\``); } } if (activeDiffs.length > 0) md += `- Active: ${activeDiffs.slice(0, 3).join(', ')}\n`; } } } md += '\n'; } } // v5-V1-T3 + V1-T5: Component Behaviors Matrix + Vocabulary Inventory md += buildComponentBehaviorsSection(desktopData); md += buildComponentVocabularySection(componentSpecs); // ── 5. Layout Principles ── md += '## 5. Layout Principles\n\n'; md += `### Layout Type\n**${layout.type}**\n\n`; md += `### Grid\n${layout.grid}\n\n`; md += `### Max Width\n${layout.maxWidth}\n\n`; md += `### Spacing System\n`; md += `${layout.spacingPhilosophy}\n\n`; md += '| Token | Value |\n'; md += '|-------|-------|\n'; for (const s of layout.spacingScale) { const [key, val] = s.split(': '); md += `| ${key} | ${val} |\n`; } md += '\n'; md += '### Border Radius Scale\n'; md += '| Name | Value | Use |\n'; md += '|------|-------|-----|\n'; for (const r of layout.radiusScale) { md += `| ${r.name} | ${r.value} | ${r.use} |\n`; } md += '\n'; // ── Shape Language ───────────────────────────────────────────────── { // Cross-reference: component type → actual extracted border-radius const componentRadiusCrossRef: Array<{ component: string; radius: string; px: number }> = []; const seenComponents = new Set(); for (const spec of componentSpecs) { const firstRadius = spec.variants?.[0]?.radius; if (!firstRadius || seenComponents.has(spec.name)) continue; const px = parsePixels(firstRadius); if (!isNaN(px)) { componentRadiusCrossRef.push({ component: spec.name, radius: firstRadius, px }); seenComponents.add(spec.name); } } // Also pull from specific elements if not covered by componentSpecs const elementMap: Record = { 'Page Body': desktopData.elements?.body?.styles?.borderRadius || '', 'Navigation': desktopData.elements?.nav?.styles?.borderRadius || '', 'Primary Input': desktopData.elements?.input?.styles?.borderRadius || '', }; for (const [compName, rawRadius] of Object.entries(elementMap)) { if (!rawRadius || seenComponents.has(compName)) continue; const norm = normalizeRadius(rawRadius); const px = parsePixels(rawRadius); if (norm && !isNaN(px)) { componentRadiusCrossRef.push({ component: compName, radius: norm, px }); seenComponents.add(compName); } } if (componentRadiusCrossRef.length > 0) { md += '### Shape Language\n\n'; md += '| Component | Border Radius |\n'; md += '|-----------|---------------|\n'; for (const { component, radius } of componentRadiusCrossRef.slice(0, 10)) { md += `| ${component} | \`${radius}\` |\n`; } md += '\n'; // Shape personality classification const pxValues = componentRadiusCrossRef.map(r => r.px).filter(p => !isNaN(p)); const minPx = Math.min(...pxValues); const maxPx = Math.max(...pxValues); const hasPill = pxValues.some(p => p >= 50); const allRadii = desktopData.allBorderRadii || []; const allPxValues: number[] = (allRadii as string[]) .map((r: string) => parsePixels(r)) .filter((p: number) => !isNaN(p) && p >= 0 && p <= 9999); const spread = maxPx - minPx; const shapePersonality = hasPill && minPx <= 4 ? 'Sharp + Pill Contrast — Angular precision with pill-shaped accents (status badges, tags). Creates strong visual hierarchy.' : hasPill && minPx > 4 ? 'Soft + Pill Accents — Rounded components with fully-circular pill elements for tags and CTAs.' : maxPx <= 2 ? 'Sharp & Angular — No rounding. Precise, editorial, enterprise feel.' : maxPx <= 5 ? 'Subtly Rounded — Minimal corner rounding (1–4px). Professional restraint.' : spread <= 3 ? `Consistent Radius — Uniform ~${Math.round((minPx + maxPx) / 2)}px across all components. Cohesive, systematic.` : maxPx >= 16 ? 'Generously Rounded — Soft, friendly corners (8px+). Approachable, consumer-facing feel.' : 'Moderate Rounding — Medium corners (4–12px). Modern SaaS standard, neither sharp nor pill.'; md += `**Shape Personality**: ${shapePersonality}\n\n`; // Radius range summary if (allPxValues.length > 0) { const uniquePx = [...new Set(allPxValues)].sort((a, b) => a - b); const displayRadii = uniquePx.slice(0, 8).map(p => p === 9999 || p >= 1000 ? 'full/pill' : `${p}px`); md += `**Full Radius Spectrum**: ${displayRadii.join(', ')} (from \`allBorderRadii\` scan)\n\n`; } } } // v5-V1-T6: Spacing Rhythm Names (named scale with use cases) md += buildSpacingRhythmSection(tokens); // ── 5c. Widget & Structure Library (Phase 4.1) ── md += buildWidgetSection(desktopData); // ── 6. Depth & Elevation ── md += '## 6. Depth & Elevation\n\n'; md += '| Level | Treatment | Use |\n'; md += '|-------|-----------|-----|\n'; for (const l of shadowSystem.levels) { md += `| ${l.level} | ${l.treatment} | ${l.use} |\n`; } md += '\n'; md += `**Shadow Philosophy**: ${shadowSystem.philosophy}\n\n`; // Elevation scale from CSS custom properties (e.g. --elevation-elevation0..5) const elevCssVars = Object.entries(desktopData.cssCustomProperties || {}) .filter(([k]) => /--elevation-/i.test(k)) .filter(([, v]) => typeof v === 'string' && (v as string).length > 0 && !(v as string).trim().startsWith('var(')); if (elevCssVars.length > 0) { md += '### Elevation Scale (CSS Tokens)\n\n'; md += '| Level | Shadow Value |\n'; md += '|-------|-------------|\n'; for (const [k, v] of elevCssVars.slice(0, 6)) { const level = k.replace(/--elevation-/i, '').replace(/elevation/i, '').trim() || k; const displayVal = (v as string).length > 90 ? (v as string).slice(0, 87) + '...' : v as string; md += `| ${level} | \`${displayVal}\` |\n`; } md += '\n'; } // ── 7. Motion & Interaction ── if (motionSection) md += motionSection; // ── 8. Do's and Don'ts ── md += "## 8. Do's and Don'ts\n\n"; md += '### Do\n'; for (const d of dos) { md += `- ${d}\n`; } md += '\n### Don\'t\n'; for (const d of donts) { md += `- ${d}\n`; } md += '\n'; // v5-V1-T4: Enforceable Brand Rules (structured, testable) md += buildBrandRulesSection(tokens, desktopData); // ── 9. Responsive Behavior ── md += '## 9. Responsive Behavior\n\n'; md += '### Breakpoints\n'; md += '| Name | Width | Key Changes |\n'; md += '|------|-------|-------------|\n'; for (const bp of responsive.breakpoints) { md += `| ${bp.name} | ${bp.width} | ${bp.changes} |\n`; } md += '\n'; // Token diff table (desktop vs mobile) — only shown if real differences detected if (responsive.tokenDiff.length > 0) { md += '### Token Diff — Desktop vs Mobile\n\n'; md += '| Element | Property | Desktop | Mobile |\n'; md += '|---------|----------|---------|--------|\n'; for (const diff of responsive.tokenDiff) { const propLabel = diff.property.replace(/([A-Z])/g, '-$1').toLowerCase(); md += `| ${diff.element} | ${propLabel} | \`${diff.desktop}\` | \`${diff.mobile}\` |\n`; } md += '\n'; } md += '### Collapsing Strategy\n'; for (const c of responsive.collapsingStrategy) { md += `- ${c}\n`; } md += '\n'; md += '### Touch Targets\n'; for (const t of responsive.touchTargets) { md += `- ${t}\n`; } md += '\n'; // ── 9a. Responsive Narrative (Phase 4.5) ── // Describe HOW components collapse, not just WHAT changes md += buildResponsiveNarrative(desktopData, mobileData); // ── 9b. Visual Tone & Photography ── md += buildPhotographySection(desktopData); // ── 10. Agent Prompt Guide ── md += '## 10. Agent Prompt Guide\n\n'; md += '### Quick Reference\n'; for (const r of agentGuide.quickRef) { md += `- ${r}\n`; } md += '\n'; md += '### Example Component Prompts\n'; for (const p of agentGuide.prompts) { md += `- ${p}\n`; } md += '\n'; md += '### Iteration Guide\n'; for (let i = 0; i < agentGuide.iterationGuide.length; i++) { md += `${i + 1}. ${agentGuide.iterationGuide[i]}\n`; } md += '\n'; // ── 11. CSS Design Tokens Raw Export ── { const cssExportLines: string[] = []; // Colors block const colorExportLines: string[] = []; // Re-use colorKeys (already computed for YAML) via colorTokenMap entries for (const [hex, tokenPath] of colorTokenMap.entries()) { const key = tokenPath.replace('colors.', ''); // Lookup raw value from colorKeys (by matching hex) const raw = hex.startsWith('#') ? hex : hex; colorExportLines.push(` --ca-${key}: ${raw};`); } if (colorExportLines.length > 0) { cssExportLines.push('/* Colors */'); cssExportLines.push(':root {'); colorExportLines.forEach(l => cssExportLines.push(l)); cssExportLines.push('}'); } // Typography block const fontFamilyPrimary = typoRoles[0]?.font || ''; if (fontFamilyPrimary) { cssExportLines.push('\n/* Typography */'); cssExportLines.push(':root {'); if (fontFamilyPrimary) cssExportLines.push(` --ca-font-primary: "${fontFamilyPrimary}", system-ui, sans-serif;`); const typoVarMap: Record = { 'display-xl': 'text-display', 'display-lg': 'text-heading', 'body': 'text-body', 'body-sm': 'text-small', 'caption': 'text-caption', 'button': 'text-button', 'mono': 'text-mono', }; for (const [typoKey, cssVar] of Object.entries(typoVarMap)) { const size = typoTokenMap.get(typoKey); if (size) cssExportLines.push(` --ca-${cssVar}: ${size};`); } cssExportLines.push('}'); } // Spacing + radius block const spacingEntries = Object.entries(tokens.spacing || {}); const radiusEntries2 = Object.entries(tokens.borderRadius || {}); if (spacingEntries.length > 0 || radiusEntries2.length > 0) { cssExportLines.push('\n/* Spacing & Radius */'); cssExportLines.push(':root {'); for (const [k, v] of spacingEntries) { cssExportLines.push(` --ca-space-${k}: ${v};`); } for (const [k, v] of radiusEntries2.slice(0, 8)) { cssExportLines.push(` --ca-radius-${k}: ${v};`); } cssExportLines.push('}'); } // Motion block (easing + duration vars from CSS) const cssVarsAll = desktopData.cssCustomProperties || {}; const easingVarsExport = Object.entries(cssVarsAll as Record).filter(([k, v]) => /^--(speed|ease|duration|transition-timing|motion-ease)/i.test(k) && (v.includes('cubic-bezier') || v.includes('linear(') || /\d+(ms|s)\b/.test(v)) ); if (easingVarsExport.length > 0) { cssExportLines.push('\n/* Motion */'); cssExportLines.push(':root {'); for (const [k, v] of easingVarsExport.slice(0, 10)) { // Re-export under --ca- prefix for portability const shortKey = k.replace(/^--/, ''); cssExportLines.push(` --ca-${shortKey}: ${v};`); } cssExportLines.push('}'); } if (cssExportLines.length > 0) { md += '## 11. CSS Design Tokens Raw Export\n\n'; md += '*Copy-paste ready `:root {}` block — all values extracted directly from the live site.*\n\n'; md += '```css\n'; md += cssExportLines.join('\n'); md += '\n```\n\n'; } } // ── Known Gaps (auto-generated from extraction signals) ── const knownGaps: string[] = []; // CSS-in-JS detection: class names like .css-abc123 signal runtime-injected styles const hasCssInJs = Object.keys(desktopData.elements || {}).some((k) => { const el = (desktopData.elements as any)[k]; return el?.attributes?.class && /\bcss-[a-zA-Z0-9]{5,}\b/.test(el.attributes.class); }); if (hasCssInJs) knownGaps.push('**CSS-in-JS detected** — class names like `.css-abc123` indicate runtime-injected styles (emotion/styled-components/Stitches). Token values may change on each build; extracted tokens are a best-effort snapshot.'); // Font DRM: any fontFace with a URL but no local copy const drmFonts = (desktopData.fontFaces || []).filter((ff: any) => ff.src && !ff.src.startsWith('local')); if (drmFonts.length > 0) { const names = [...new Set(drmFonts.map((ff: any) => ff.family as string))].slice(0, 3).join(', '); knownGaps.push(`**Licensed web fonts (${names})** — detected but not downloaded. Substitute with your licensed copy or a close fallback (see Typography section for metrics).`); } // Canvas/WebGL: if a canvas element was observed if ((desktopData.elements as any)?.canvas) knownGaps.push('**Canvas/WebGL content** — screenshot only. Visual elements rendered via `` or WebGL are not reconstructable from CSS extraction alone.'); // Sparse breakpoints if (responsive.breakpoints.length <= 2) knownGaps.push('**Limited breakpoint data** — fewer than 3 media query breakpoints detected. The site may use fluid CSS (clamp/vw) or container queries for responsiveness rather than traditional breakpoints.'); // Low component count if (componentSpecs.length < 5) knownGaps.push('**Sparse component extraction** — fewer than 5 component types detected. Highly dynamic sections (modals, datepickers, carousels) require interaction to render and are not captured in a static pass.'); // Parasitic shadow detection: bluish/colored shadows likely from third-party injected elements const allShadowsRaw = desktopData.allShadows || []; const parasiticShadows = (allShadowsRaw as string[]).filter(s => { // Detect non-black shadows (colored RGBA components — likely Grammarly/chat widgets/extension overlays) const m = s.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/g); if (!m) return false; return m.some(c => { const parts = c.match(/\d+/g)!.map(Number); const [r, g, b] = parts; // If NOT a near-black shadow (all channels > 50 and not all equal → colored) return r > 50 && g > 50 && b > 50 && Math.max(r,g,b) - Math.min(r,g,b) > 30; }); }); if (parasiticShadows.length > 0) { knownGaps.push(`**${parasiticShadows.length} parasitic shadow(s) detected** — colored box-shadows (not near-black) found in allShadows. These likely originate from browser extensions or third-party injected widgets (e.g. Grammarly, chat overlays), not from the site's own design system. Discard them when building.`); } // v8: Bridge tokens → site — inject layout + copy + assets + templates BEFORE Known Gaps md += buildPageSkeletonSection(desktopData); md += buildCopyLibrarySection(desktopData); md += buildAssetInventorySection(desktopData); md += buildComponentTemplatesSection(desktopData); if (knownGaps.length > 0) { md += '\n## 12. Known Extraction Gaps\n\n'; md += '*Auto-detected limitations of this extraction — understand before building:*\n\n'; for (const gap of knownGaps) md += `- ${gap}\n`; md += '\n'; } // ── Completeness Score computation ──────────────────────────────── // Scored out of 100: Colors(25) + Typography(20) + Components(20) + Motion(15) + CSSVars(10) + Breakpoints(5) + VarFonts(5) const totalColorsNamed = colorCategories.reduce((acc, c) => acc + c.colors.length, 0); const cssVarsTotal = Object.keys(desktopData.cssCustomProperties || {}).length; const hasKeyframesForScore = Object.keys(desktopData.keyframes || {}).length > 0; const hasEasingVarsForScore = Object.entries(desktopData.cssCustomProperties || {}).some(([k, v]) => /easing|ease|speed/i.test(k) && typeof v === 'string' && v.includes('cubic-bezier') ); const variableAxesForScore: string[] = desktopData.variableAxes || []; // ── Scoring v2 (calibrated 2026-05-25) ──────────────────────────── // Divisors derived from 75th percentile of 9 stress-test brands // (claude/miro/cohere/expo/warp/mintlify/mistral/starbucks/revolut). // Previous v1 divisors (20/10/8/100) were too ambitious — best sites scored only 87/100. // v2 divisors recalibrated so well-extracted sites can reach ≥90. const SCORE_DIVISORS = { colors: 18, // observed: 6-19, p75 ≈ 18 typoRoles: 8, // observed: 4-11, p75 ≈ 8 components: 5, // observed: 3-6, p75 ≈ 5 cssVars: 200, // observed: 63-549, p75 ≈ 200 breakpoints: 5, // observed: 1-7, p75 ≈ 5 (unchanged) }; // v4-V1-T4: also score raw transitions (timing-function + duration), not only @keyframes. // 70% of marketing sites have CSS transitions without @keyframes — they were scoring 0/15 on Motion. const allTransitionsForScore: string[] = (desktopData.allTransitions || []) as string[]; const hasTransitionEasing = allTransitionsForScore.some(t => /cubic-bezier\([^)]+\)|ease-in|ease-out|ease-in-out|linear|ease/.test(t) && /\d+(ms|s)/.test(t) ); const scoreColors = Math.min(totalColorsNamed / SCORE_DIVISORS.colors, 1) * 25; const scoreTypo = Math.min(typoRoles.length / SCORE_DIVISORS.typoRoles, 1) * 20; const scoreComponents = Math.min(componentSpecs.length / SCORE_DIVISORS.components, 1) * 20; // Motion = 10pt for keyframes + 3pt for easing-vars + 2pt for raw transitions (max 15) const scoreMotion = Math.min( (hasKeyframesForScore ? 10 : 0) + (hasEasingVarsForScore ? 3 : 0) + (hasTransitionEasing ? 2 : 0), 15 ); const scoreCssVars = Math.min(cssVarsTotal / SCORE_DIVISORS.cssVars, 1) * 10; const scoreBreakpoints = Math.min(responsive.breakpoints.length / SCORE_DIVISORS.breakpoints, 1) * 5; const scoreVarFonts = variableAxesForScore.length > 0 ? 5 : 0; const completenessScore = Math.round( scoreColors + scoreTypo + scoreComponents + scoreMotion + scoreCssVars + scoreBreakpoints + scoreVarFonts ); // Inject score + version into YAML frontmatter md = md.replace('completeness: __SCORE__', `completeness: ${completenessScore}\nscoreVersion: "v2"`); // Coverage table appended to footer area const scoreGrade = completenessScore >= 90 ? 'A' : completenessScore >= 75 ? 'B' : completenessScore >= 60 ? 'C' : 'D'; md += '\n---\n'; md += `## Extraction Completeness: ${completenessScore}/100 (${scoreGrade})\n\n`; md += '| Category | Score | Max | Detail |\n'; md += '|----------|-------|-----|--------|\n'; md += `| Colors & Palette | ${Math.round(scoreColors)} | 25 | ${totalColorsNamed} named colors |\n`; md += `| Typography | ${Math.round(scoreTypo)} | 20 | ${typoRoles.length} roles defined |\n`; md += `| Components | ${Math.round(scoreComponents)} | 20 | ${componentSpecs.length} specs extracted |\n`; md += `| Motion & Interaction | ${Math.round(scoreMotion)} | 15 | ${hasKeyframesForScore ? 'Keyframes present' : 'No keyframes'}${hasEasingVarsForScore ? ', easing vars' : ''} |\n`; md += `| CSS Custom Properties | ${Math.round(scoreCssVars)} | 10 | ${cssVarsTotal} vars |\n`; md += `| Responsive Breakpoints | ${Math.round(scoreBreakpoints)} | 5 | ${responsive.breakpoints.length} breakpoints |\n`; md += `| Variable Fonts | ${Math.round(scoreVarFonts)} | 5 | ${variableAxesForScore.length > 0 ? variableAxesForScore.join(', ') + ' axes' : 'None detected'} |\n`; md += '\n'; // ── §12 Known Gaps & Confidence (Phase 0.4) ── // Surface what we COULDN'T extract so LLM agents can fill those gaps explicitly // rather than hallucinating values. md += '## 12. Known Gaps & Confidence\n\n'; md += 'Explicit list of what this extraction could NOT capture. Agents should not invent values for these — either skip the feature or use the documented baseline + heuristic fallback.\n\n'; const gaps: Array<{ category: string; gap: string; fallback: string }> = []; // Photography gap const imgProfile = desktopData?.imageryProfile; if (!imgProfile || imgProfile.totalImages === 0) { gaps.push({ category: 'Photography', gap: 'No photographic content detected on this page', fallback: "Don't add stock imagery; clarity comes from type + color blocks", }); } else if (!imgProfile.heroImage) { gaps.push({ category: 'Hero imagery', gap: 'No dominant hero image detected above the fold', fallback: 'Use the canonical brand visual from §9b (OG image) for hero composition', }); } // Animation gap const hasKeyframes = Object.keys(desktopData?.keyframes || {}).length > 0; const hasTransitions = (desktopData?.allTransitions || []).length > 0; if (!hasKeyframes && !hasTransitions) { gaps.push({ category: 'Motion / Animation', gap: 'No @keyframes or transitions detected', fallback: 'Either keep clones strictly static, or apply a single 150ms ease-out on hover/focus universally', }); } else if (!hasKeyframes) { gaps.push({ category: 'Keyframe animations', gap: 'No @keyframes declarations found (transitions only)', fallback: 'Use CSS transitions for state changes; avoid complex scroll-based animations', }); } // Component states gap (hover/focus only desktop) const compStates = desktopData?.componentStates || {}; if (Object.keys(compStates).length === 0) { gaps.push({ category: 'Component states', gap: 'Hover / focus / active states not captured', fallback: 'Darken background ~10% on hover, add 2px focus ring matching accent token, +50% opacity for disabled', }); } // Dark mode gap (no detection of dark-mode variants) const hasDarkModeVars = Object.keys(desktopData?.cssCustomProperties || {}) .some((k: string) => /dark|night|mode/i.test(k)); if (!hasDarkModeVars) { gaps.push({ category: 'Dark mode', gap: 'No dark-mode CSS variables or media queries detected', fallback: 'Light mode is canonical; do not generate dark-mode variants unless explicitly requested', }); } // Form validation states const inputElement = desktopData?.elements?.input; if (!inputElement) { gaps.push({ category: 'Form inputs', gap: 'No text input fields detected on this page', fallback: 'Use the documented border-radius + accent border on focus; 12-16px padding inside', }); } // CSS var coverage (if low, semantic naming will be poor) const cssVarCount = Object.keys(desktopData?.cssCustomProperties || {}).length; if (cssVarCount < 20) { gaps.push({ category: 'Design tokens', gap: `Only ${cssVarCount} CSS custom properties (low signal for semantic naming)`, fallback: 'Color names are nearest-neighbor approximations; verify against brand guidelines if available', }); } if (gaps.length === 0) { md += '*✅ No critical extraction gaps detected — high-confidence reproduction expected.*\n\n'; } else { md += '| Category | What\'s missing | Fallback strategy |\n'; md += '|----------|----------------|-------------------|\n'; for (const g of gaps) { md += `| **${g.category}** | ${g.gap} | ${g.fallback} |\n`; } md += '\n'; } // Confidence per section md += '### Per-section confidence\n\n'; // Re-derive body bg check from already-resolved tokens (in scope at this point in the function) const _section12_bodyBgKnown = !!(tokens.colors?.background?.primary); md += '| Section | Confidence | Reason |\n'; md += '|---------|------------|--------|\n'; md += `| §1 Visual Theme | ${_section12_bodyBgKnown ? 'High' : 'Medium'} | ${_section12_bodyBgKnown ? 'Body bg extracted via getComputedStyle()' : 'Body bg transparent — using fallback'} |\n`; md += `| §2 Colors | ${cssVarCount > 50 ? 'High' : cssVarCount > 10 ? 'Medium' : 'Low'} | ${cssVarCount} CSS vars (semantic naming requires ≥20 for high) |\n`; md += `| §3 Typography | ${typoRoles.length >= 5 ? 'High' : typoRoles.length >= 3 ? 'Medium' : 'Low'} | ${typoRoles.length} typography roles inferred |\n`; md += `| §4 Components | ${componentSpecs.length >= 5 ? 'High' : componentSpecs.length >= 3 ? 'Medium' : 'Low'} | ${componentSpecs.length} component variants captured |\n`; md += `| §7 Motion | ${hasKeyframes ? 'High' : hasTransitions ? 'Medium' : 'Low'} | ${hasKeyframes ? 'Keyframes + transitions' : hasTransitions ? 'Transitions only' : 'No motion data'} |\n`; md += `| §8 Do's/Don'ts | ${dos.length + donts.length >= 8 ? 'High' : 'Medium'} | ${dos.length + donts.length} rules generated; design-decisions.json has evidence per rule |\n`; md += `| §9 Responsive | ${responsive.breakpoints.length >= 3 ? 'High' : 'Medium'} | ${responsive.breakpoints.length} breakpoints detected |\n`; md += `| §9b Photography | ${imgProfile?.heroImage ? 'High' : imgProfile?.totalImages > 0 ? 'Medium' : 'Low'} | ${imgProfile?.totalImages || 0} images, hero ${imgProfile?.heroImage ? 'present' : 'not detected'} |\n`; md += '\n'; // ── Footer ── md += '---\n'; md += `*Generated by Clone Architect — automated Playwright extraction + design analysis.*\n`; md += `*Source: ${desktopData.url} | ${new Date().toISOString()}*\n`; md += `*Values extracted via getComputedStyle() (rendered) + CSS custom properties (tokens, marked \`(token)\`). Token-only values may not be painted — verify against the live site before shipping.*\n`; // ── Write output ── const outputPath = join(baseDir, 'DESIGN.md'); await writeFile(outputPath, md); // ── Write design-decisions.json (Phase 0.3) — structured evidence-based rules ── const decisionsRecord = buildDecisionsRecord(desktopData, tokens); const decisionsPath = join(baseDir, 'design-decisions.json'); await writeFile(decisionsPath, JSON.stringify(decisionsRecord, null, 2)); const lineCount = md.split('\n').length; console.log(`✅ DESIGN.md saved to ${outputPath}`); console.log(`✅ design-decisions.json saved (${decisionsRecord.decisions.length} evidence-based rules)`); console.log(` ${lineCount} lines — ${motionSection ? '10' : '9'} sections (${motionSection ? '+ Motion & Interaction' : 'no motion data'}) | Completeness: ${completenessScore}/100`); console.log(` Colors: ${colorCategories.reduce((acc, c) => acc + c.colors.length, 0)} named`); console.log(` Typography: ${typoRoles.length} roles defined`); console.log(` Components: ${componentSpecs.length} specs`); console.log(` Breakpoints: ${responsive.breakpoints.length} detected`); } // ── CLI ── const domain = process.argv[2]; if (!domain) { console.error('Usage: npm run generate-design -- '); console.error('Example: npm run generate-design -- linear.app'); process.exit(1); } generateDesignMd(domain).catch(err => { console.error('Error generating DESIGN.md:', err); process.exit(1); });