djatadougbewilfried-star f23267105a
fix(design-system): make the skill work outside a vendored checkout and on Windows (#460)
* fix(design-system): resolve project root from cwd, not __file__

fetch-background.py and html-token-validator.py derived PROJECT_ROOT with five
.parent hops, which only reaches the project root when the skill is vendored at
<project>/.claude/skills/design-system/scripts/. Installed at user level
(~/.claude/skills/) or as a plugin, PROJECT_ROOT pointed outside the project, so
both scripts silently ran against no tokens at all.

Resolve from the working directory instead, matching generate-tokens.cjs and
validate-tokens.cjs which already use process.cwd(). DESIGN_SYSTEM_PROJECT_ROOT
overrides it when the project root cannot be inferred.

slide_search_core.py is left alone: it resolves skill-relative data, which is
the correct use of __file__.

Refs #459

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(design-system): stop findProjectRoot from hanging on Windows

embed-tokens.cjs walked up the tree with `while (dir !== '/')`. On Windows the
filesystem root is 'C:\', so that condition is never true, and path.dirname('C:\')
returns 'C:\' unchanged -- the loop spins forever at 100% CPU instead of erroring
out, whenever assets/design-tokens.css is not found above the cwd.

Stop when dirname stops changing, which terminates on every platform.

Refs #459

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(design-system): force UTF-8 stdout so emoji output works on cp1252 consoles

search-slides.py --context and html-token-validator.py print emoji. On a Windows
console the default encoding is cp1252, so the first emoji raises
UnicodeEncodeError and the command dies with a traceback instead of output --
this takes out --context, the entry point of the contextual slide system.

Reuse the guard already shipped in src/ui-ux-pro-max/scripts/search.py.

Refs #459

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(design-system): decode subprocess output as UTF-8 in validate-tokens tests

test_validate_tokens.py drives validate-tokens.cjs through subprocess.run with
text=True but no explicit encoding, so Python decodes the pipe with the locale
codec. On Windows (cp1252) the validator's emoji output raises UnicodeDecodeError
inside the reader thread, result.stdout comes back as None, and the assertion
fails with a confusing `TypeError: argument of type 'NoneType' is not a
container` -- this suite cannot pass on Windows at all today.

Pin the pipe and the fixture write to UTF-8. The validator itself was never at
fault: run by hand it flags the hardcoded hex correctly.

Note: brand/scripts/tests/test_sync_brand_to_tokens.py uses the same
text=True-without-encoding pattern and is one emoji away from failing the same
way. Left alone to keep this PR scoped to design-system.

Refs #459

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 01:42:36 +07:00

360 lines
13 KiB
Python

#!/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 <script> for Chart.js)
3. No hardcoded fonts
4. Uses var(--token-name) pattern
"""
result = ValidationResult(file_path)
# 1. Check for design-tokens.css import
if 'design-tokens.css' not in content:
result.add_error("Missing design-tokens.css import")
# 2. Check for forbidden patterns in CSS
for pattern, description in FORBIDDEN_PATTERNS:
for match in re.finditer(pattern, content):
match_text = match.group()
match_pos = match.start()
context = get_context(content, match_pos)
# Skip if in <script> block (Chart.js allowed)
if is_inside_block(content, match_pos, '<script', '</script>'):
if verbose:
result.add_warning(f"Allowed in <script>: {match_text}")
continue
# Skip if in allowed exception context (external URLs)
if is_allowed_exception(context):
if verbose:
result.add_warning(f"Allowed external: {match_text}")
continue
# Skip rgba using brand colors (needed for transparency effects)
if description == 'rgba color' and is_allowed_rgba(match_text):
if verbose:
result.add_warning(f"Allowed brand rgba: {match_text}")
continue
# Skip if part of var() reference (false positive)
if 'var(' in context and match_text in context:
# Check if it's a fallback value in var()
var_pattern = rf'var\([^)]*{re.escape(match_text)}[^)]*\)'
if re.search(var_pattern, context):
continue
# Error if in <style> or inline style
if is_inside_block(content, match_pos, '<style', '</style>'):
result.add_error(f"Hardcoded {description} in <style>: {match_text}")
elif 'style="' in context:
result.add_error(f"Hardcoded {description} in inline style: {match_text}")
# 3. Check for required var() usage indicators
token_patterns = [
r'var\(--color-',
r'var\(--primitive-',
r'var\(--typography-',
r'var\(--card-',
r'var\(--button-',
]
token_count = sum(len(re.findall(p, content)) for p in token_patterns)
if token_count < 5:
result.add_warning(f"Low token usage ({token_count} var() references). Consider using more design tokens.")
return result
def validate_file(file_path: Path, verbose: bool = False) -> ValidationResult:
"""Validate a single HTML file."""
if not file_path.exists():
result = ValidationResult(file_path)
result.add_error("File not found")
return result
content = file_path.read_text()
return validate_html(content, file_path, verbose)
def validate_directory(dir_path: Path, verbose: bool = False) -> List[ValidationResult]:
"""Validate all HTML files in a directory."""
results = []
if dir_path.exists():
for html_file in sorted(dir_path.glob('*.html')):
results.append(validate_file(html_file, verbose))
return results
def print_result(result: ValidationResult, verbose: bool = False):
"""Print validation result for a file."""
status = "" if result.passed else ""
print(f" {status} {result.file_path.name}")
if result.errors:
for error in result.errors[:5]: # Limit output
print(f" ├─ {error}")
if len(result.errors) > 5:
print(f" └─ ... and {len(result.errors) - 5} more errors")
if verbose and result.warnings:
for warning in result.warnings[:3]:
print(f" [warn] {warning}")
def print_summary(all_results: Dict[str, List[ValidationResult]]):
"""Print summary of all validation results."""
total_files = 0
total_passed = 0
total_errors = 0
print("\n" + "=" * 60)
print("HTML DESIGN TOKEN VALIDATION SUMMARY")
print("=" * 60)
for asset_type, results in all_results.items():
if not results:
continue
passed = sum(1 for r in results if r.passed)
failed = len(results) - passed
errors = sum(len(r.errors) for r in results)
total_files += len(results)
total_passed += passed
total_errors += errors
status = "" if failed == 0 else ""
print(f"\n{status} {asset_type.upper()}: {passed}/{len(results)} passed")
for result in results:
if not result.passed:
print_result(result)
print("\n" + "-" * 60)
if total_errors == 0:
print(f"✓ ALL PASSED: {total_passed}/{total_files} files valid")
else:
print(f"✗ FAILED: {total_files - total_passed}/{total_files} files have issues ({total_errors} total errors)")
print("-" * 60)
return total_errors == 0
def main():
"""CLI entry point."""
import argparse
parser = argparse.ArgumentParser(
description='Validate HTML assets for design token compliance',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s # Validate all HTML assets
%(prog)s --type slides # Validate only slides
%(prog)s --type infographics # Validate only infographics
%(prog)s path/to/file.html # Validate specific file
%(prog)s --colors # Show brand colors from tokens
"""
)
parser.add_argument('files', nargs='*', help='Specific HTML files to validate')
parser.add_argument('-t', '--type', choices=['slides', 'infographics', 'all'],
default='all', help='Asset type to validate')
parser.add_argument('-v', '--verbose', action='store_true', help='Show warnings')
parser.add_argument('--colors', action='store_true', help='Print CSS variables from tokens')
parser.add_argument('--fix', action='store_true', help='Auto-fix issues (experimental)')
args = parser.parse_args()
# Show colors mode
if args.colors:
variables = load_css_variables()
print("\nDesign Tokens (from design-tokens.css):")
print("-" * 40)
for name, value in sorted(variables.items())[:30]:
print(f" {name}: {value}")
if len(variables) > 30:
print(f" ... and {len(variables) - 30} more")
return
all_results: Dict[str, List[ValidationResult]] = {}
# Validate specific files
if args.files:
results = []
for file_path in args.files:
path = Path(file_path)
if path.exists():
results.append(validate_file(path, args.verbose))
else:
result = ValidationResult(path)
result.add_error("File not found")
results.append(result)
all_results['specified'] = results
else:
# Validate by type
types_to_check = ASSET_DIRS.keys() if args.type == 'all' else [args.type]
for asset_type in types_to_check:
if asset_type in ASSET_DIRS:
results = validate_directory(ASSET_DIRS[asset_type], args.verbose)
all_results[asset_type] = results
# Print results
success = print_summary(all_results)
if not success:
sys.exit(1)
if __name__ == '__main__':
main()