#!/usr/bin/env python3 """ HTML Design Token Validator Ensures all HTML assets (slides, infographics, etc.) use design tokens. Source of truth: assets/design-tokens.css Usage: python html-token-validator.py # Validate all HTML assets python html-token-validator.py --type slides # Validate only slides python html-token-validator.py --type infographics # Validate only infographics python html-token-validator.py path/to/file.html # Validate specific file python html-token-validator.py --fix # Auto-fix issues (WIP) """ import re import json import sys import os from pathlib import Path from typing import Dict, List, Tuple, Optional # The skill can be installed outside the project it operates on (user-level # ~/.claude/skills/, or as a plugin), so the project root cannot be derived from # this file's location. Resolve it from the working directory instead -- the same # convention generate-tokens.cjs and validate-tokens.cjs already use via # process.cwd(). DESIGN_SYSTEM_PROJECT_ROOT overrides it explicitly. def _find_project_root(): override = os.environ.get('DESIGN_SYSTEM_PROJECT_ROOT') if override: return Path(override).resolve() start = Path.cwd().resolve() markers = ( Path('assets') / 'design-tokens.json', Path('assets') / 'design-tokens.css', Path('package.json'), Path('.git'), ) for candidate in (start, *start.parents): if any((candidate / marker).exists() for marker in markers): return candidate return start PROJECT_ROOT = _find_project_root() # Force UTF-8 on stdout/stderr: this script prints emoji, which raises # UnicodeEncodeError on a Windows console (cp1252). Same guard as # src/ui-ux-pro-max/scripts/search.py. import io if sys.stdout.encoding and sys.stdout.encoding.lower() != 'utf-8': sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') if sys.stderr.encoding and sys.stderr.encoding.lower() != 'utf-8': sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8') TOKENS_JSON_PATH = PROJECT_ROOT / 'assets' / 'design-tokens.json' TOKENS_CSS_PATH = PROJECT_ROOT / 'assets' / 'design-tokens.css' # Asset directories to validate ASSET_DIRS = { 'slides': PROJECT_ROOT / 'assets' / 'designs' / 'slides', 'infographics': PROJECT_ROOT / 'assets' / 'infographics', } # Patterns that indicate hardcoded values (should use tokens) FORBIDDEN_PATTERNS = [ (r'#[0-9A-Fa-f]{3,8}\b', 'hex color'), (r'rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)', 'rgb color'), (r'rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*[\d.]+\s*\)', 'rgba color'), (r'hsl\([^)]+\)', 'hsl color'), (r"font-family:\s*'[^v][^a][^r][^']*',", 'hardcoded font'), # Exclude var() (r'font-family:\s*"[^v][^a][^r][^"]*",', 'hardcoded font'), ] # Allowed rgba patterns (brand colors with transparency - CSS limitation) # These are derived from brand tokens but need rgba for transparency ALLOWED_RGBA_PATTERNS = [ r'rgba\(\s*59\s*,\s*130\s*,\s*246', # --color-primary (#3B82F6) r'rgba\(\s*245\s*,\s*158\s*,\s*11', # --color-secondary (#F59E0B) r'rgba\(\s*16\s*,\s*185\s*,\s*129', # --color-accent (#10B981) r'rgba\(\s*20\s*,\s*184\s*,\s*166', # --color-accent alt (#14B8A6) r'rgba\(\s*0\s*,\s*0\s*,\s*0', # black transparency (common) r'rgba\(\s*255\s*,\s*255\s*,\s*255', # white transparency (common) r'rgba\(\s*15\s*,\s*23\s*,\s*42', # --color-surface (#0F172A) r'rgba\(\s*7\s*,\s*11\s*,\s*20', # --color-background (#070B14) ] # Allowed exceptions (external images, etc.) ALLOWED_EXCEPTIONS = [ 'pexels.com', 'unsplash.com', 'youtube.com', 'ytimg.com', 'googlefonts', 'fonts.googleapis.com', 'fonts.gstatic.com', ] class ValidationResult: """Validation result for a single file.""" def __init__(self, file_path: Path): self.file_path = file_path self.errors: List[str] = [] self.warnings: List[str] = [] self.passed = True def add_error(self, msg: str): self.errors.append(msg) self.passed = False def add_warning(self, msg: str): self.warnings.append(msg) def load_css_variables() -> Dict[str, str]: """Load CSS variables from design-tokens.css.""" variables = {} if TOKENS_CSS_PATH.exists(): content = TOKENS_CSS_PATH.read_text() # Extract --var-name: value patterns for match in re.finditer(r'(--[\w-]+):\s*([^;]+);', content): variables[match.group(1)] = match.group(2).strip() return variables def is_inside_block(content: str, match_pos: int, open_tag: str, close_tag: str) -> bool: """Check if position is inside a specific HTML block.""" pre = content[:match_pos] tag_open = pre.rfind(open_tag) tag_close = pre.rfind(close_tag) return tag_open > tag_close def is_allowed_exception(context: str) -> bool: """Check if the hardcoded value is in an allowed exception context.""" context_lower = context.lower() return any(exc in context_lower for exc in ALLOWED_EXCEPTIONS) def is_allowed_rgba(match_text: str) -> bool: """Check if rgba pattern uses brand colors (allowed for transparency).""" return any(re.match(pattern, match_text) for pattern in ALLOWED_RGBA_PATTERNS) def get_context(content: str, pos: int, chars: int = 100) -> str: """Get surrounding context for a match position.""" start = max(0, pos - chars) end = min(len(content), pos + chars) return content[start:end] def validate_html(content: str, file_path: Path, verbose: bool = False) -> ValidationResult: """ Validate HTML content for design token compliance. Checks: 1. design-tokens.css import present 2. No hardcoded colors in CSS (except in '): if verbose: result.add_warning(f"Allowed in