423 lines
16 KiB
Python
423 lines
16 KiB
Python
#!/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()
|