Files
pi-skill/skills/design/scripts/preflight-scan.py
2026-05-25 16:41:08 +07:00

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()