Initial: pi-skill — 68 skills, 43 extensions, 11 themes for Pi
This commit is contained in:
422
skills/design/scripts/preflight-scan.py
Normal file
422
skills/design/scripts/preflight-scan.py
Normal file
@@ -0,0 +1,422 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user