#!/usr/bin/env python3 """ Pre-flight Scanner โ€” Design System Detection Analyzes a codebase for existing design signals before generating new design output. Prevents stomping on established palettes, font stacks, and component systems. Usage: python3 preflight-scan.py [directory] python3 preflight-scan.py . --format json """ import json import os import re import sys from pathlib import Path from dataclasses import dataclass, asdict from typing import Optional @dataclass class PreflightFindings: """Results of pre-flight scan.""" # Design system presence design_md: bool = False design_md_path: Optional[str] = None # Font stack fonts: list = None font_sources: dict = None # Color palette palette_format: Optional[str] = None # oklch, hex, hsl, rgb, tailwind colors: dict = None palette_sources: list = None # Motion/interaction motion_library: Optional[str] = None # framer-motion, gsap, motion, lenis, lottie motion_stance: str = "unknown" # on, off, unknown # Spacing spacing_scale: list = None spacing_format: Optional[str] = None # tailwind, css-var, fixed # Framework framework: Optional[str] = None # next, astro, vue, svelte, remix, react, vanilla framework_version: Optional[str] = None # Component library component_lib: Optional[str] = None # shadcn, mui, chakra, radix tailwind_config: bool = False tailwind_version: Optional[str] = None # Project memory hallmark_log: bool = False design_log: bool = False def __post_init__(self): if self.fonts is None: self.fonts = [] if self.font_sources is None: self.font_sources = {} if self.colors is None: self.colors = {} if self.palette_sources is None: self.palette_sources = [] if self.spacing_scale is None: self.spacing_scale = [] def to_dict(self) -> dict: return asdict(self) def format_markdown(self) -> str: """Format findings as readable markdown.""" lines = ["## Pre-flight Findings\n"] if not any([self.design_md, self.fonts, self.colors, self.component_lib, self.framework]): return "## Pre-flight Findings\n\n*No design signals detected โ€” proceeding with full design system.*\n" if self.framework: lines.append(f"- **Framework:** {self.framework}" + (f" {self.framework_version}" if self.framework_version else "")) if self.design_md: lines.append(f"- **Design System:** `design.md` found at `{self.design_md_path}` โ€” *this overrides everything*") if self.fonts: font_list = ", ".join(f"`{f}`" for f in self.fonts[:5]) if len(self.fonts) > 5: font_list += f" (+{len(self.fonts) - 5} more)" lines.append(f"- **Fonts:** {font_list}") for src, fonts in self.font_sources.items(): lines.append(f" - {src}: {', '.join(fonts)}") if self.palette_format: color_count = len(self.colors) lines.append(f"- **Palette:** {self.palette_format.upper()} ({color_count} colors)") for src in self.palette_sources: lines.append(f" - Source: `{src}`") if self.motion_stance != "unknown": stance = "Motion ON" if self.motion_stance == "on" else "Motion OFF" library = f" ({self.motion_library})" if self.motion_library else "" lines.append(f"- **Motion stance:** {stance}{library}") if self.spacing_scale: scale_str = " ยท ".join(str(s) for s in self.spacing_scale[:6]) if len(self.spacing_scale) > 6: scale_str += f" ... (+{len(self.spacing_scale) - 6} more)" lines.append(f"- **Spacing:** {self.spacing_format or 'custom'} ({scale_str})") if self.component_lib: lines.append(f"- **Component library:** {self.component_lib}") if self.tailwind_config: v = f" v{self.tailwind_version}" if self.tailwind_version else "" lines.append(f"- **Tailwind CSS:** detected{v}") # Summary lines.append("\n**Design will preserve:** " + ", ".join(self.preserved_items())) lines.append("\n**Design will introduce:** macrostructure, component system, slop-test gates.") return "\n".join(lines) def preserved_items(self) -> list: """List items that will be preserved from existing system.""" items = [] if self.design_md: items.append("design.md system") if self.fonts: items.append("font stack") if self.colors: items.append("palette") if self.motion_stance != "unknown": items.append("motion stance") if self.spacing_scale: items.append("spacing scale") if self.component_lib: items.append("component library") if not items: items.append("nothing (fresh project)") return items class PreflightScanner: """Scans codebase for design system signals.""" FONT_PATTERNS = [ # next/font (r'next/font', r'next/font/(\w+)', 'next/font'), # @fontsource (r'@fontsource/(\w+)', r'@fontsource/(\w+)', 'fontsource'), # Google Fonts link (r'href=["\'].*?fonts\.googleapis\.com.*?family=([^"\']+)', None, 'google-fonts'), # CSS @import (r'@import\s+url\(["\'].*?fonts\.googleapis\.com.*?family=([^"\']+)', None, 'google-fonts'), # font-family declarations (r'font-family:\s*["\']([^"\']+)["\']', None, 'css-declaration'), # Tailwind fontFamily (r'fontFamily:\s*\{([^}]+)\}', None, 'tailwind'), ] COLOR_PATTERNS = [ # OKLCH (r'oklch\(\s*[\d.]+%?\s+[\d.]+\s+[\d.]+', 'oklch'), # HSL (r'hsl\(\s*[\d.]+\s+[\d.]+%?\s+[\d.]+%?', 'hsl'), # Hex (r'#[0-9a-fA-F]{3,8}', 'hex'), # CSS custom properties (r'--[\w-]+:\s*(#[0-9a-fA-F]{3,8}|oklch|hsl|rgb)', 'css-var'), # Tailwind colors (r'colors:\s*\{', 'tailwind'), ] MOTION_LIBRARIES = { 'framer-motion': 'framer-motion', 'motion': 'motion', 'gsap': 'gsap', 'lenis': 'lenis', 'lottie-react': 'lottie', '@react-spring/web': 'react-spring', } FRAMEWORKS = { 'package.json': { '"next"': 'next', '"astro"': 'astro', '"vue"': 'vue', '"svelte"': 'svelte', '"@sveltejs/kit"': 'sveltekit', '"@remix-run/': 'remix', '"react"': 'react', }, 'next.config': 'next', 'astro.config': 'astro', 'svelte.config': 'svelte', 'vite.config': 'vite', } COMPONENT_LIBS = { 'shadcn': ['components/ui', 'components.json'], '@mui/material': 'mui', '@chakra-ui': 'chakra', '@radix-ui': 'radix', } def __init__(self, root_dir: str = "."): self.root = Path(root_dir).resolve() self.findings = PreflightFindings() def scan(self) -> PreflightFindings: """Run all scan methods.""" self._scan_design_md() self._scan_package_json() self._scan_css_files() self._scan_config_files() self._scan_project_memory() return self.findings def _scan_design_md(self): """Check for design.md at project root.""" candidates = ['design.md', 'DESIGN.md', '.design.md', 'docs/design.md'] for name in candidates: path = self.root / name if path.exists(): self.findings.design_md = True self.findings.design_md_path = str(path.relative_to(self.root)) break def _scan_package_json(self): """Scan package.json for dependencies.""" pkg_path = self.root / 'package.json' if not pkg_path.exists(): return try: with open(pkg_path) as f: pkg = json.load(f) except json.JSONDecodeError: return deps = {**pkg.get('dependencies', {}), **pkg.get('devDependencies', {})} # Framework detection for marker, framework in self.FRAMEWORKS.get('package.json', {}).items(): if marker.strip('"\'') in deps: self.findings.framework = framework self.findings.framework_version = deps[marker.strip('"\' ')] break # Motion library detection for lib, name in self.MOTION_LIBRARIES.items(): if lib in deps: self.findings.motion_library = name self.findings.motion_stance = "on" break # Component library detection for marker, lib in self.COMPONENT_LIBS.items(): if marker in deps: self.findings.component_lib = lib if isinstance(lib, str) else marker break # Tailwind detection if 'tailwindcss' in deps: self.findings.tailwind_config = True self.findings.tailwind_version = deps.get('tailwindcss', '').lstrip('^~>') def _scan_css_files(self): """Scan CSS files for colors and fonts.""" css_files = list(self.root.rglob('*.css')) + list(self.root.rglob('*.scss')) css_files = [f for f in css_files if 'node_modules' not in str(f)] fonts_found = set() colors_found = {} spacing_found = [] palette_format = None spacing_format = None for css_file in css_files: try: content = css_file.read_text(errors='ignore') rel_path = str(css_file.relative_to(self.root)) # Font scanning for pattern, match_pattern, source in self.FONT_PATTERNS: if re.search(pattern, content, re.IGNORECASE): if source == 'google-fonts': # Extract font names from Google Fonts URL matches = re.findall(r'family=([^\'"&]+)', pattern) for match in matches: font = match.replace('+', ' ') fonts_found.add(font) self.findings.font_sources.setdefault(rel_path, []).append(font) elif source == 'tailwind': # Extract Tailwind fontFamily keys match = re.search(pattern, content) if match: fonts = re.findall(r'["\']?(\w+)["\']?\s*:', match.group(1)) for font in fonts: fonts_found.add(font) self.findings.font_sources.setdefault(rel_path, []).append(f"tailwind:{font}") else: match = re.search(match_pattern, content) if match_pattern else None if match: font = match.group(1) if match.groups() else pattern fonts_found.add(font) self.findings.font_sources.setdefault(rel_path, []).append(font) # Color scanning for pattern, color_format in self.COLOR_PATTERNS: if re.search(pattern, content, re.IGNORECASE): if palette_format is None: palette_format = color_format # Count occurrences matches = re.findall(pattern, content, re.IGNORECASE) colors_found[rel_path] = len(matches) self.findings.palette_sources.append(rel_path) break # Spacing scanning space_pattern = r'--space-(\w+):\s*(\d+)px' for match in re.finditer(space_pattern, content): name, value = match.groups() spacing_found.append((name, int(value))) if spacing_found and spacing_format is None: spacing_format = 'css-var' except (PermissionError, OSError): continue self.findings.fonts = sorted(set(f.replace('tailwind:', '') for f in fonts_found)) self.findings.colors = colors_found self.findings.palette_format = palette_format self.findings.spacing_scale = [v for _, v in sorted(set(spacing_found), key=lambda x: x[1])] self.findings.spacing_format = spacing_format def _scan_config_files(self): """Scan config files for Tailwind, design tokens, etc.""" # Tailwind config for config_name in ['tailwind.config.js', 'tailwind.config.ts', 'tailwind.config.cjs']: config_path = self.root / config_name if config_path.exists(): try: content = config_path.read_text() self.findings.tailwind_config = True # Extract version hint if 'tailwindcss' in content.lower(): self.findings.tailwind_version = 'custom' # Extract fontFamily ff_match = re.search(r'fontFamily:\s*\{([^}]+)\}', content) if ff_match: fonts = re.findall(r'["\']?(\w+)["\']?\s*:', ff_match.group(1)) for font in fonts: self.findings.fonts.append(f"tailwind:{font}") # Extract colors colors_match = re.search(r'colors:\s*\{([^}]+)\}', content) if colors_match: self.findings.palette_format = 'tailwind' self.findings.palette_sources.append(str(config_path.relative_to(self.root))) # Extract spacing spacing_match = re.search(r'spacing:\s*\{([^}]+)\}', content) if spacing_match: spacing_format = 'tailwind' spaces = re.findall(r'["\']?(\w+)["\']?\s*:\s*[\d.]+', spacing_match.group(1)) self.findings.spacing_scale = spaces self.findings.spacing_format = spacing_format except (PermissionError, OSError): continue # Design tokens files for token_name in ['tokens.json', 'design-tokens.json', 'tokens.yml', 'design-system.json']: token_path = self.root / token_name if token_path.exists(): self.findings.palette_format = 'dtcg' self.findings.palette_sources.append(str(token_path.relative_to(self.root))) def _scan_project_memory(self): """Check for Hallmark/design log files.""" hallmark_log = self.root / '.hallmark' / 'log.json' design_log = self.root / '.design' / 'log.json' self.findings.hallmark_log = hallmark_log.exists() self.findings.design_log = design_log.exists() def main(): import argparse parser = argparse.ArgumentParser(description='Pre-flight design system scanner') parser.add_argument('directory', nargs='?', default='.', help='Directory to scan') parser.add_argument('--format', choices=['markdown', 'json'], default='markdown', help='Output format') parser.add_argument('--output', help='Output file path') args = parser.parse_args() scanner = PreflightScanner(args.directory) findings = scanner.scan() if args.format == 'json': output = json.dumps(findings.to_dict(), indent=2) else: output = findings.format_markdown() if args.output: Path(args.output).write_text(output) print(f"Written to {args.output}") else: print(output) if __name__ == '__main__': main()