diff --git a/.claude/skills/brand/SKILL.md b/.claude/skills/brand/SKILL.md index 48d912f..3eddf30 100644 --- a/.claude/skills/brand/SKILL.md +++ b/.claude/skills/brand/SKILL.md @@ -53,6 +53,13 @@ node scripts/sync-brand-to-tokens.cjs node scripts/inject-brand-context.cjs --json | head -20 ``` +The sync stops when it detects existing token files, `:root` custom properties +or Tailwind v4 `@theme` variables in common CSS entry points and their local +CSS imports, or Tailwind theme colors and presets. Review the reported source +before proceeding. If the detected files are the managed +`assets/design-tokens.*` outputs from an earlier sync and replacing them is +intentional, re-run with `--force`. + **Files synced:** - `docs/brand-guidelines.md` → Source of truth - `assets/design-tokens.json` → Token definitions diff --git a/.claude/skills/brand/references/update.md b/.claude/skills/brand/references/update.md index 25ed7f3..23d3978 100644 --- a/.claude/skills/brand/references/update.md +++ b/.claude/skills/brand/references/update.md @@ -49,6 +49,17 @@ Run the sync script: node scripts/sync-brand-to-tokens.cjs ``` +If the script reports an existing token source, inspect the named file before +continuing. Detection covers `:root` custom properties, Tailwind v4 `@theme` +variables in common CSS entry points and their local imports, and Tailwind +theme colors or presets. Do not create a parallel token system beside those +sources. Use `--force` only when the detected source is the managed +`assets/design-tokens.*` output from an earlier sync and replacing it is +intentional: +```bash +node scripts/sync-brand-to-tokens.cjs --force +``` + This will: - Update `assets/design-tokens.json` with new color names and values - Regenerate `assets/design-tokens.css` with correct CSS variables diff --git a/.claude/skills/brand/scripts/sync-brand-to-tokens.cjs b/.claude/skills/brand/scripts/sync-brand-to-tokens.cjs index 75e8430..f64feb0 100644 --- a/.claude/skills/brand/scripts/sync-brand-to-tokens.cjs +++ b/.claude/skills/brand/scripts/sync-brand-to-tokens.cjs @@ -7,6 +7,7 @@ * Usage: * node sync-brand-to-tokens.cjs * node sync-brand-to-tokens.cjs --dry-run + * node sync-brand-to-tokens.cjs --force */ const fs = require('fs'); @@ -17,11 +18,97 @@ const { execFileSync } = require('child_process'); const BRAND_GUIDELINES = 'docs/brand-guidelines.md'; const DESIGN_TOKENS_JSON = 'assets/design-tokens.json'; const DESIGN_TOKENS_CSS = 'assets/design-tokens.css'; +const CSS_TOKEN_SOURCES = [ + 'src/index.css', + 'src/globals.css', + 'src/styles/globals.css', + 'src/styles/tokens.css', + 'src/app/globals.css', + 'app/globals.css', + 'styles/globals.css', + 'styles/tokens.css' +]; +const TAILWIND_CONFIGS = [ + 'tailwind.config.js', + 'tailwind.config.cjs', + 'tailwind.config.mjs', + 'tailwind.config.ts' +]; // Sibling sub-skill, resolved from this file's location so it works in every // install context (plugin cache, project or --global CLI install), not only // when the process runs from a project root that contains .claude/skills/. const GENERATE_TOKENS_SCRIPT = path.resolve(__dirname, '..', '..', 'design-system', 'scripts', 'generate-tokens.cjs'); +/** + * Find project files that already act as design-token sources. + */ +function findExistingTokenSources(projectRoot) { + const sources = new Set(); + const addIfPresent = (relativePath) => { + if (fs.existsSync(path.resolve(projectRoot, relativePath))) { + sources.add(relativePath); + } + }; + + const scanCssSource = (absolutePath, visited = new Set()) => { + const normalizedPath = path.resolve(absolutePath); + const relativePath = path.relative(projectRoot, normalizedPath); + if ( + visited.has(normalizedPath) || + relativePath.startsWith('..') || + path.isAbsolute(relativePath) || + !fs.existsSync(normalizedPath) + ) { + return; + } + + visited.add(normalizedPath); + const content = fs.readFileSync(normalizedPath, 'utf-8'); + const uncommented = content.replace(/\/\*[\s\S]*?\*\//g, ''); + const hasRootTokens = /:root\b[^{}]*\{[^}]*--[A-Za-z0-9_-]+\s*:/.test(uncommented); + const hasTailwindTheme = /@theme(?:\s+[A-Za-z-]+)?\s*\{[^}]*--[A-Za-z0-9_-]+\s*:/.test(uncommented); + if (hasRootTokens || hasTailwindTheme) { + sources.add(relativePath.split(path.sep).join('/')); + } + + const importPattern = /@import\s+(?:url\(\s*)?(['"])([^'"]+)\1\s*\)?[^;]*;/g; + for (const match of uncommented.matchAll(importPattern)) { + const importTarget = match[2].split(/[?#]/, 1)[0]; + let importedPath; + if (importTarget.startsWith('.')) { + importedPath = path.resolve(path.dirname(normalizedPath), importTarget); + } else if (importTarget.startsWith('/')) { + importedPath = path.resolve(projectRoot, `.${importTarget}`); + } else { + continue; + } + scanCssSource(importedPath, visited); + } + }; + + addIfPresent(DESIGN_TOKENS_JSON); + addIfPresent(DESIGN_TOKENS_CSS); + + for (const relativePath of CSS_TOKEN_SOURCES) { + const absolutePath = path.resolve(projectRoot, relativePath); + scanCssSource(absolutePath); + } + + for (const relativePath of TAILWIND_CONFIGS) { + const absolutePath = path.resolve(projectRoot, relativePath); + if (!fs.existsSync(absolutePath)) continue; + const content = fs.readFileSync(absolutePath, 'utf-8'); + const hasInlineColors = /\btheme\s*:\s*\{[\s\S]*?\bcolors\s*:/.test(content); + const hasPreset = /\bpresets\s*:/.test(content); + const hasThemeSpread = /\btheme\s*:\s*\{[\s\S]*?\.\.\.[A-Za-z_$]/.test(content); + if (hasInlineColors || hasPreset || hasThemeSpread) { + sources.add(relativePath); + } + } + + return [...sources]; +} + /** * Extract color info from brand guidelines markdown */ @@ -211,17 +298,33 @@ function updateDesignTokens(tokens, colors) { */ function main() { const dryRun = process.argv.includes('--dry-run'); + const force = process.argv.includes('--force'); + const projectRoot = process.cwd(); console.log('🔄 Syncing brand guidelines → design tokens\n'); // Read brand guidelines - const guidelinesPath = path.resolve(process.cwd(), BRAND_GUIDELINES); + const guidelinesPath = path.resolve(projectRoot, BRAND_GUIDELINES); if (!fs.existsSync(guidelinesPath)) { console.error(`❌ Brand guidelines not found: ${guidelinesPath}`); process.exit(1); } const guidelinesContent = fs.readFileSync(guidelinesPath, 'utf-8'); + const existingSources = findExistingTokenSources(projectRoot); + if (existingSources.length > 0 && !force) { + const details = existingSources.map(source => ` - ${source}`).join('\n'); + const message = + `Existing design-token source${existingSources.length === 1 ? '' : 's'} detected:\n${details}\n` + + 'Refusing to create or replace token files. Review the detected source and re-run with --force only if replacement is intentional.'; + if (dryRun) { + console.warn(`⚠️ ${message}\n`); + } else { + console.error(`❌ ${message}`); + process.exit(1); + } + } + // Extract colors const colors = extractColorsFromMarkdown(guidelinesContent); console.log('📊 Extracted colors:'); @@ -230,7 +333,7 @@ function main() { console.log(` Accent: ${colors.accent.name} (${colors.accent.base})\n`); // Read existing tokens - const tokensPath = path.resolve(process.cwd(), DESIGN_TOKENS_JSON); + const tokensPath = path.resolve(projectRoot, DESIGN_TOKENS_JSON); let tokens = {}; if (fs.existsSync(tokensPath)) { tokens = JSON.parse(fs.readFileSync(tokensPath, 'utf-8')); @@ -247,6 +350,7 @@ function main() { } // Write updated tokens + fs.mkdirSync(path.dirname(tokensPath), { recursive: true }); fs.writeFileSync(tokensPath, JSON.stringify(tokens, null, 2)); console.log(`✅ Updated: ${DESIGN_TOKENS_JSON}`); diff --git a/.claude/skills/brand/scripts/tests/test_sync_brand_to_tokens.py b/.claude/skills/brand/scripts/tests/test_sync_brand_to_tokens.py index aed7946..7e621e1 100644 --- a/.claude/skills/brand/scripts/tests/test_sync_brand_to_tokens.py +++ b/.claude/skills/brand/scripts/tests/test_sync_brand_to_tokens.py @@ -24,12 +24,12 @@ TOKENS_STARTER = ( ) -def _run(tmp_path: Path) -> subprocess.CompletedProcess: +def _run(tmp_path: Path, *args: str) -> subprocess.CompletedProcess: node = shutil.which("node") if not node: pytest.skip("node not available") return subprocess.run( - [node, str(SCRIPT)], + [node, str(SCRIPT), *args], cwd=tmp_path, capture_output=True, text=True, @@ -50,7 +50,7 @@ def test_sync_parses_bundled_starter_template(tmp_path): shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md") shutil.copy(TOKENS_STARTER, tmp_path / "assets" / "design-tokens.json") - result = _run(tmp_path) + result = _run(tmp_path, "--force") # Must not crash (the bug raised an unhandled TypeError). assert "TypeError" not in result.stderr, result.stderr @@ -92,7 +92,7 @@ def test_dark_base_color_does_not_collapse_shades_to_black(tmp_path): "| Accent Color | #6B8F71 |\n" ) - result = _run(tmp_path) + result = _run(tmp_path, "--force") assert result.returncode == 0, result.stderr + result.stdout tokens = json.loads((tmp_path / "assets" / "design-tokens.json").read_text()) @@ -120,3 +120,198 @@ def test_reports_missing_guidelines_without_breaking_the_harness(tmp_path): assert result.returncode == 1 assert result.stderr is not None assert "Brand guidelines not found" in result.stderr + + +def test_creates_default_output_directory_when_missing(tmp_path): + """A first sync should create assets/ instead of failing with ENOENT.""" + (tmp_path / "docs").mkdir() + shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md") + + result = _run(tmp_path) + + assert result.returncode == 0, result.stderr + result.stdout + assert (tmp_path / "assets" / "design-tokens.json").exists() + assert (tmp_path / "assets" / "design-tokens.css").exists() + + +def test_refuses_existing_design_tokens_without_force(tmp_path): + """The script must not silently replace its own existing token source.""" + (tmp_path / "docs").mkdir() + (tmp_path / "assets").mkdir() + shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md") + tokens_path = tmp_path / "assets" / "design-tokens.json" + existing = '{"existing": true}\n' + tokens_path.write_text(existing) + + result = _run(tmp_path) + + assert result.returncode == 1 + assert "assets/design-tokens.json" in result.stderr + assert "--force" in result.stderr + assert tokens_path.read_text() == existing + + +def test_refuses_css_custom_property_source_without_force(tmp_path): + """Common app CSS token sources must be named instead of duplicated.""" + (tmp_path / "docs").mkdir() + (tmp_path / "src").mkdir() + shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md") + (tmp_path / "src" / "index.css").write_text( + ":root {\n --primary: #2563eb;\n --foreground: #0f172a;\n}\n" + ) + + result = _run(tmp_path) + + assert result.returncode == 1 + assert "src/index.css" in result.stderr + assert "--force" in result.stderr + assert not (tmp_path / "assets" / "design-tokens.json").exists() + + +def test_refuses_grouped_root_selector_without_force(tmp_path): + """A :root selector list is still an existing project token source.""" + (tmp_path / "docs").mkdir() + (tmp_path / "src").mkdir() + shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md") + (tmp_path / "src" / "index.css").write_text( + ':root, [data-theme="light"] {\n --primary: #2563eb;\n}\n' + ) + + result = _run(tmp_path) + + assert result.returncode == 1 + assert "src/index.css" in result.stderr + assert not (tmp_path / "assets" / "design-tokens.json").exists() + + +def test_ignores_commented_root_custom_properties(tmp_path): + """Commented examples must not block a first token sync.""" + (tmp_path / "docs").mkdir() + (tmp_path / "src").mkdir() + shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md") + (tmp_path / "src" / "index.css").write_text( + "/* Example only:\n:root {\n --primary: #2563eb;\n}\n*/\n" + ) + + result = _run(tmp_path) + + assert result.returncode == 0, result.stderr + result.stdout + assert (tmp_path / "assets" / "design-tokens.json").exists() + + +def test_ignores_custom_properties_outside_root(tmp_path): + """Component-local variables alone are not a project token source.""" + (tmp_path / "docs").mkdir() + (tmp_path / "src").mkdir() + shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md") + (tmp_path / "src" / "index.css").write_text( + ":root {\n color-scheme: light;\n}\n\n" + ".progress {\n --progress-value: 50%;\n}\n" + ) + + result = _run(tmp_path) + + assert result.returncode == 0, result.stderr + result.stdout + assert (tmp_path / "assets" / "design-tokens.json").exists() + + +def test_refuses_tailwind_theme_colors_without_force(tmp_path): + """Tailwind theme colors are an existing project token source.""" + (tmp_path / "docs").mkdir() + (tmp_path / "assets").mkdir() + shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md") + (tmp_path / "tailwind.config.js").write_text( + "module.exports = { theme: { extend: { colors: { brand: '#2563eb' } } } }\n" + ) + + result = _run(tmp_path) + + assert result.returncode == 1 + assert "tailwind.config.js" in result.stderr + assert "--force" in result.stderr + assert not (tmp_path / "assets" / "design-tokens.json").exists() + + +def test_refuses_tailwind_v4_theme_source_without_force(tmp_path): + """Tailwind v4 @theme variables are an existing project token source.""" + (tmp_path / "docs").mkdir() + (tmp_path / "src").mkdir() + shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md") + (tmp_path / "src" / "index.css").write_text( + "@theme {\n --color-brand-500: #2563eb;\n}\n" + ) + + result = _run(tmp_path) + + assert result.returncode == 1 + assert "src/index.css" in result.stderr + assert not (tmp_path / "assets" / "design-tokens.json").exists() + + +def test_refuses_token_source_imported_by_common_css_entry(tmp_path): + """Local CSS imports must be followed to their actual token source.""" + (tmp_path / "docs").mkdir() + (tmp_path / "src" / "styles").mkdir(parents=True) + shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md") + (tmp_path / "src" / "index.css").write_text( + '@import "./styles/theme.css";\n' + ) + (tmp_path / "src" / "styles" / "theme.css").write_text( + "@theme {\n --color-brand-500: #2563eb;\n}\n" + ) + + result = _run(tmp_path) + + assert result.returncode == 1 + assert "src/styles/theme.css" in result.stderr + assert not (tmp_path / "assets" / "design-tokens.json").exists() + + +def test_ignores_external_css_imports(tmp_path): + """Remote and package imports are not project-owned token sources.""" + (tmp_path / "docs").mkdir() + (tmp_path / "src").mkdir() + shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md") + (tmp_path / "src" / "index.css").write_text( + '@import "https://example.com/theme.css";\n' + '@import "tailwindcss";\n' + ) + + result = _run(tmp_path) + + assert result.returncode == 0, result.stderr + result.stdout + assert (tmp_path / "assets" / "design-tokens.json").exists() + + +def test_refuses_tailwind_config_with_sibling_preset_without_force(tmp_path): + """A delegated Tailwind theme must not be treated as token-free.""" + (tmp_path / "docs").mkdir() + shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md") + (tmp_path / "tailwind.config.js").write_text( + "const preset = require('./tailwind.preset');\n" + "module.exports = { presets: [preset] };\n" + ) + (tmp_path / "tailwind.preset.js").write_text( + "module.exports = { theme: { colors: { brand: '#2563eb' } } };\n" + ) + + result = _run(tmp_path) + + assert result.returncode == 1 + assert "tailwind.config.js" in result.stderr + assert not (tmp_path / "assets" / "design-tokens.json").exists() + + +def test_force_allows_sync_with_existing_css_token_source(tmp_path): + """The explicit force flag overrides token-source detection.""" + (tmp_path / "docs").mkdir() + (tmp_path / "src").mkdir() + shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md") + (tmp_path / "src" / "index.css").write_text( + ":root {\n --primary: #2563eb;\n}\n" + ) + + result = _run(tmp_path, "--force") + + assert result.returncode == 0, result.stderr + result.stdout + assert (tmp_path / "assets" / "design-tokens.json").exists() diff --git a/.claude/skills/ui-styling/SKILL.md b/.claude/skills/ui-styling/SKILL.md index 3f86abc..b5a9acb 100644 --- a/.claude/skills/ui-styling/SKILL.md +++ b/.claude/skills/ui-styling/SKILL.md @@ -228,6 +228,14 @@ Generate tailwind.config.js with custom theme: python scripts/tailwind_config_gen.py --colors brand:blue --fonts display:Inter ``` +The generator refuses to create or replace a config when any sibling +`tailwind.config.js`, `.cjs`, `.mjs`, or `.ts` file already exists. Review the +reported config first, then pass `--force` only when the competing output is +intentional: +```bash +python scripts/tailwind_config_gen.py --colors brand:blue --force +``` + ## Best Practices 1. **Component Composition**: Build complex UIs from simple, composable primitives diff --git a/.claude/skills/ui-styling/scripts/tailwind_config_gen.py b/.claude/skills/ui-styling/scripts/tailwind_config_gen.py index 093c625..8dc6a0f 100644 --- a/.claude/skills/ui-styling/scripts/tailwind_config_gen.py +++ b/.claude/skills/ui-styling/scripts/tailwind_config_gen.py @@ -17,6 +17,12 @@ from typing import Any, Dict, List, Optional # optional subpath. Only allows alphanumeric, hyphens, dots, underscores, # and forward slashes — no quotes, parens, or semicolons. _VALID_PLUGIN_NAME = re.compile(r'^(@[a-zA-Z0-9_-]+/)?[a-zA-Z0-9_-]+(/[a-zA-Z0-9_.-]+)*$') +_TAILWIND_CONFIG_NAMES = ( + "tailwind.config.js", + "tailwind.config.cjs", + "tailwind.config.mjs", + "tailwind.config.ts", +) class TailwindConfigGenerator: @@ -27,6 +33,7 @@ class TailwindConfigGenerator: typescript: bool = True, framework: str = "react", output_path: Optional[Path] = None, + force: bool = False, ): """ Initialize generator. @@ -35,10 +42,12 @@ class TailwindConfigGenerator: typescript: If True, generate .ts config, else .js framework: Framework name (react, vue, svelte, nextjs) output_path: Output file path (default: auto-detect) + force: If True, allow replacing an existing output file """ self.typescript = typescript self.framework = framework self.output_path = output_path or self._default_output_path() + self.force = force self.config: Dict[str, Any] = self._base_config() def _default_output_path(self) -> Path: @@ -272,6 +281,25 @@ module.exports = {{ Tuple of (success, message) """ try: + existing_paths = [] + if self.output_path.name in _TAILWIND_CONFIG_NAMES: + existing_paths = [ + self.output_path.parent / name + for name in _TAILWIND_CONFIG_NAMES + if (self.output_path.parent / name).exists() + ] + elif self.output_path.exists(): + existing_paths = [self.output_path] + + if existing_paths and not self.force: + existing = ", ".join(str(path) for path in existing_paths) + return ( + False, + f"Tailwind configuration already exists: {existing}. " + "Refusing to create or overwrite a competing config; " + "re-run with --force only if this is intentional.", + ) + config_content = self.generate_config_string() self.output_path.write_text(config_content) @@ -382,6 +410,12 @@ Examples: help="Validate config without writing file", ) + parser.add_argument( + "--force", + action="store_true", + help="Overwrite an existing output file", + ) + args = parser.parse_args() # Initialize generator @@ -389,6 +423,7 @@ Examples: typescript=not args.js, framework=args.framework, output_path=args.output, + force=args.force, ) # Add custom colors @@ -465,7 +500,7 @@ Examples: # Write config success, message = generator.write_config() - print(message) + print(message, file=sys.stdout if success else sys.stderr) sys.exit(0 if success else 1) diff --git a/.claude/skills/ui-styling/scripts/tests/test_tailwind_config_gen.py b/.claude/skills/ui-styling/scripts/tests/test_tailwind_config_gen.py index 2facdbd..4eb06de 100644 --- a/.claude/skills/ui-styling/scripts/tests/test_tailwind_config_gen.py +++ b/.claude/skills/ui-styling/scripts/tests/test_tailwind_config_gen.py @@ -278,6 +278,120 @@ class TestTailwindConfigGenerator: assert "import type { Config }" in content assert "brand" in content + def test_write_config_refuses_to_overwrite_existing_file(self, tmp_path): + """Existing project configuration must be preserved by default.""" + output_path = tmp_path / "tailwind.config.ts" + existing = "// existing project config\nexport default { theme: {} }\n" + output_path.write_text(existing) + generator = TailwindConfigGenerator(output_path=output_path) + + success, message = generator.write_config() + + assert success is False + assert "already exists" in message + assert "--force" in message + assert output_path.read_text() == existing + + def test_write_config_force_overwrites_existing_file(self, tmp_path): + """An explicit force opt-in permits replacing an existing config.""" + output_path = tmp_path / "tailwind.config.ts" + output_path.write_text("// existing project config\n") + generator = TailwindConfigGenerator(output_path=output_path, force=True) + generator.add_colors({"brand": "#3b82f6"}) + + success, message = generator.write_config() + + assert success is True + assert "written to" in message + assert "brand" in output_path.read_text() + + def test_cli_refuses_existing_config_without_force(self, tmp_path): + """The CLI must return non-zero and preserve an existing default target.""" + output_path = tmp_path / "tailwind.config.ts" + existing = "// existing project config\n" + output_path.write_text(existing) + script = Path(__file__).parent.parent / "tailwind_config_gen.py" + + result = subprocess.run( + [sys.executable, str(script), "--colors", "brand:#3b82f6"], + cwd=tmp_path, + capture_output=True, + text=True, + ) + + assert result.returncode == 1 + assert "already exists" in result.stderr + assert "--force" in result.stderr + assert output_path.read_text() == existing + + def test_cli_refuses_sibling_config_extension_without_force(self, tmp_path): + """A default .ts write must not create a second config beside .js.""" + existing_path = tmp_path / "tailwind.config.js" + existing = "// existing JavaScript project config\nmodule.exports = {}\n" + existing_path.write_text(existing) + output_path = tmp_path / "tailwind.config.ts" + script = Path(__file__).parent.parent / "tailwind_config_gen.py" + + result = subprocess.run( + [sys.executable, str(script), "--colors", "brand:#3b82f6"], + cwd=tmp_path, + capture_output=True, + text=True, + ) + + assert result.returncode == 1 + assert "tailwind.config.js" in result.stderr + assert "--force" in result.stderr + assert existing_path.read_text() == existing + assert not output_path.exists() + + def test_cli_force_allows_target_beside_sibling_config(self, tmp_path): + """The explicit force opt-in also overrides cross-extension detection.""" + existing_path = tmp_path / "tailwind.config.js" + existing = "// existing JavaScript project config\nmodule.exports = {}\n" + existing_path.write_text(existing) + output_path = tmp_path / "tailwind.config.ts" + script = Path(__file__).parent.parent / "tailwind_config_gen.py" + + result = subprocess.run( + [ + sys.executable, + str(script), + "--colors", + "brand:#3b82f6", + "--force", + ], + cwd=tmp_path, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + assert existing_path.read_text() == existing + assert "brand" in output_path.read_text() + + def test_cli_force_overwrites_existing_config(self, tmp_path): + """The CLI must wire --force through to the generator.""" + output_path = tmp_path / "tailwind.config.ts" + output_path.write_text("// existing project config\n") + script = Path(__file__).parent.parent / "tailwind_config_gen.py" + + result = subprocess.run( + [ + sys.executable, + str(script), + "--colors", + "brand:#3b82f6", + "--force", + ], + cwd=tmp_path, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + assert "brand" in output_path.read_text() + def test_write_config_invalid_path(self): """Test writing config to invalid path.""" generator = TailwindConfigGenerator(output_path=Path("/invalid/path/config.ts")) diff --git a/cli/assets/skills/brand/SKILL.md b/cli/assets/skills/brand/SKILL.md index 48d912f..3eddf30 100644 --- a/cli/assets/skills/brand/SKILL.md +++ b/cli/assets/skills/brand/SKILL.md @@ -53,6 +53,13 @@ node scripts/sync-brand-to-tokens.cjs node scripts/inject-brand-context.cjs --json | head -20 ``` +The sync stops when it detects existing token files, `:root` custom properties +or Tailwind v4 `@theme` variables in common CSS entry points and their local +CSS imports, or Tailwind theme colors and presets. Review the reported source +before proceeding. If the detected files are the managed +`assets/design-tokens.*` outputs from an earlier sync and replacing them is +intentional, re-run with `--force`. + **Files synced:** - `docs/brand-guidelines.md` → Source of truth - `assets/design-tokens.json` → Token definitions diff --git a/cli/assets/skills/brand/references/update.md b/cli/assets/skills/brand/references/update.md index 25ed7f3..23d3978 100644 --- a/cli/assets/skills/brand/references/update.md +++ b/cli/assets/skills/brand/references/update.md @@ -49,6 +49,17 @@ Run the sync script: node scripts/sync-brand-to-tokens.cjs ``` +If the script reports an existing token source, inspect the named file before +continuing. Detection covers `:root` custom properties, Tailwind v4 `@theme` +variables in common CSS entry points and their local imports, and Tailwind +theme colors or presets. Do not create a parallel token system beside those +sources. Use `--force` only when the detected source is the managed +`assets/design-tokens.*` output from an earlier sync and replacing it is +intentional: +```bash +node scripts/sync-brand-to-tokens.cjs --force +``` + This will: - Update `assets/design-tokens.json` with new color names and values - Regenerate `assets/design-tokens.css` with correct CSS variables diff --git a/cli/assets/skills/brand/scripts/sync-brand-to-tokens.cjs b/cli/assets/skills/brand/scripts/sync-brand-to-tokens.cjs index 75e8430..f64feb0 100644 --- a/cli/assets/skills/brand/scripts/sync-brand-to-tokens.cjs +++ b/cli/assets/skills/brand/scripts/sync-brand-to-tokens.cjs @@ -7,6 +7,7 @@ * Usage: * node sync-brand-to-tokens.cjs * node sync-brand-to-tokens.cjs --dry-run + * node sync-brand-to-tokens.cjs --force */ const fs = require('fs'); @@ -17,11 +18,97 @@ const { execFileSync } = require('child_process'); const BRAND_GUIDELINES = 'docs/brand-guidelines.md'; const DESIGN_TOKENS_JSON = 'assets/design-tokens.json'; const DESIGN_TOKENS_CSS = 'assets/design-tokens.css'; +const CSS_TOKEN_SOURCES = [ + 'src/index.css', + 'src/globals.css', + 'src/styles/globals.css', + 'src/styles/tokens.css', + 'src/app/globals.css', + 'app/globals.css', + 'styles/globals.css', + 'styles/tokens.css' +]; +const TAILWIND_CONFIGS = [ + 'tailwind.config.js', + 'tailwind.config.cjs', + 'tailwind.config.mjs', + 'tailwind.config.ts' +]; // Sibling sub-skill, resolved from this file's location so it works in every // install context (plugin cache, project or --global CLI install), not only // when the process runs from a project root that contains .claude/skills/. const GENERATE_TOKENS_SCRIPT = path.resolve(__dirname, '..', '..', 'design-system', 'scripts', 'generate-tokens.cjs'); +/** + * Find project files that already act as design-token sources. + */ +function findExistingTokenSources(projectRoot) { + const sources = new Set(); + const addIfPresent = (relativePath) => { + if (fs.existsSync(path.resolve(projectRoot, relativePath))) { + sources.add(relativePath); + } + }; + + const scanCssSource = (absolutePath, visited = new Set()) => { + const normalizedPath = path.resolve(absolutePath); + const relativePath = path.relative(projectRoot, normalizedPath); + if ( + visited.has(normalizedPath) || + relativePath.startsWith('..') || + path.isAbsolute(relativePath) || + !fs.existsSync(normalizedPath) + ) { + return; + } + + visited.add(normalizedPath); + const content = fs.readFileSync(normalizedPath, 'utf-8'); + const uncommented = content.replace(/\/\*[\s\S]*?\*\//g, ''); + const hasRootTokens = /:root\b[^{}]*\{[^}]*--[A-Za-z0-9_-]+\s*:/.test(uncommented); + const hasTailwindTheme = /@theme(?:\s+[A-Za-z-]+)?\s*\{[^}]*--[A-Za-z0-9_-]+\s*:/.test(uncommented); + if (hasRootTokens || hasTailwindTheme) { + sources.add(relativePath.split(path.sep).join('/')); + } + + const importPattern = /@import\s+(?:url\(\s*)?(['"])([^'"]+)\1\s*\)?[^;]*;/g; + for (const match of uncommented.matchAll(importPattern)) { + const importTarget = match[2].split(/[?#]/, 1)[0]; + let importedPath; + if (importTarget.startsWith('.')) { + importedPath = path.resolve(path.dirname(normalizedPath), importTarget); + } else if (importTarget.startsWith('/')) { + importedPath = path.resolve(projectRoot, `.${importTarget}`); + } else { + continue; + } + scanCssSource(importedPath, visited); + } + }; + + addIfPresent(DESIGN_TOKENS_JSON); + addIfPresent(DESIGN_TOKENS_CSS); + + for (const relativePath of CSS_TOKEN_SOURCES) { + const absolutePath = path.resolve(projectRoot, relativePath); + scanCssSource(absolutePath); + } + + for (const relativePath of TAILWIND_CONFIGS) { + const absolutePath = path.resolve(projectRoot, relativePath); + if (!fs.existsSync(absolutePath)) continue; + const content = fs.readFileSync(absolutePath, 'utf-8'); + const hasInlineColors = /\btheme\s*:\s*\{[\s\S]*?\bcolors\s*:/.test(content); + const hasPreset = /\bpresets\s*:/.test(content); + const hasThemeSpread = /\btheme\s*:\s*\{[\s\S]*?\.\.\.[A-Za-z_$]/.test(content); + if (hasInlineColors || hasPreset || hasThemeSpread) { + sources.add(relativePath); + } + } + + return [...sources]; +} + /** * Extract color info from brand guidelines markdown */ @@ -211,17 +298,33 @@ function updateDesignTokens(tokens, colors) { */ function main() { const dryRun = process.argv.includes('--dry-run'); + const force = process.argv.includes('--force'); + const projectRoot = process.cwd(); console.log('🔄 Syncing brand guidelines → design tokens\n'); // Read brand guidelines - const guidelinesPath = path.resolve(process.cwd(), BRAND_GUIDELINES); + const guidelinesPath = path.resolve(projectRoot, BRAND_GUIDELINES); if (!fs.existsSync(guidelinesPath)) { console.error(`❌ Brand guidelines not found: ${guidelinesPath}`); process.exit(1); } const guidelinesContent = fs.readFileSync(guidelinesPath, 'utf-8'); + const existingSources = findExistingTokenSources(projectRoot); + if (existingSources.length > 0 && !force) { + const details = existingSources.map(source => ` - ${source}`).join('\n'); + const message = + `Existing design-token source${existingSources.length === 1 ? '' : 's'} detected:\n${details}\n` + + 'Refusing to create or replace token files. Review the detected source and re-run with --force only if replacement is intentional.'; + if (dryRun) { + console.warn(`⚠️ ${message}\n`); + } else { + console.error(`❌ ${message}`); + process.exit(1); + } + } + // Extract colors const colors = extractColorsFromMarkdown(guidelinesContent); console.log('📊 Extracted colors:'); @@ -230,7 +333,7 @@ function main() { console.log(` Accent: ${colors.accent.name} (${colors.accent.base})\n`); // Read existing tokens - const tokensPath = path.resolve(process.cwd(), DESIGN_TOKENS_JSON); + const tokensPath = path.resolve(projectRoot, DESIGN_TOKENS_JSON); let tokens = {}; if (fs.existsSync(tokensPath)) { tokens = JSON.parse(fs.readFileSync(tokensPath, 'utf-8')); @@ -247,6 +350,7 @@ function main() { } // Write updated tokens + fs.mkdirSync(path.dirname(tokensPath), { recursive: true }); fs.writeFileSync(tokensPath, JSON.stringify(tokens, null, 2)); console.log(`✅ Updated: ${DESIGN_TOKENS_JSON}`); diff --git a/cli/assets/skills/brand/scripts/tests/test_sync_brand_to_tokens.py b/cli/assets/skills/brand/scripts/tests/test_sync_brand_to_tokens.py index aed7946..7e621e1 100644 --- a/cli/assets/skills/brand/scripts/tests/test_sync_brand_to_tokens.py +++ b/cli/assets/skills/brand/scripts/tests/test_sync_brand_to_tokens.py @@ -24,12 +24,12 @@ TOKENS_STARTER = ( ) -def _run(tmp_path: Path) -> subprocess.CompletedProcess: +def _run(tmp_path: Path, *args: str) -> subprocess.CompletedProcess: node = shutil.which("node") if not node: pytest.skip("node not available") return subprocess.run( - [node, str(SCRIPT)], + [node, str(SCRIPT), *args], cwd=tmp_path, capture_output=True, text=True, @@ -50,7 +50,7 @@ def test_sync_parses_bundled_starter_template(tmp_path): shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md") shutil.copy(TOKENS_STARTER, tmp_path / "assets" / "design-tokens.json") - result = _run(tmp_path) + result = _run(tmp_path, "--force") # Must not crash (the bug raised an unhandled TypeError). assert "TypeError" not in result.stderr, result.stderr @@ -92,7 +92,7 @@ def test_dark_base_color_does_not_collapse_shades_to_black(tmp_path): "| Accent Color | #6B8F71 |\n" ) - result = _run(tmp_path) + result = _run(tmp_path, "--force") assert result.returncode == 0, result.stderr + result.stdout tokens = json.loads((tmp_path / "assets" / "design-tokens.json").read_text()) @@ -120,3 +120,198 @@ def test_reports_missing_guidelines_without_breaking_the_harness(tmp_path): assert result.returncode == 1 assert result.stderr is not None assert "Brand guidelines not found" in result.stderr + + +def test_creates_default_output_directory_when_missing(tmp_path): + """A first sync should create assets/ instead of failing with ENOENT.""" + (tmp_path / "docs").mkdir() + shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md") + + result = _run(tmp_path) + + assert result.returncode == 0, result.stderr + result.stdout + assert (tmp_path / "assets" / "design-tokens.json").exists() + assert (tmp_path / "assets" / "design-tokens.css").exists() + + +def test_refuses_existing_design_tokens_without_force(tmp_path): + """The script must not silently replace its own existing token source.""" + (tmp_path / "docs").mkdir() + (tmp_path / "assets").mkdir() + shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md") + tokens_path = tmp_path / "assets" / "design-tokens.json" + existing = '{"existing": true}\n' + tokens_path.write_text(existing) + + result = _run(tmp_path) + + assert result.returncode == 1 + assert "assets/design-tokens.json" in result.stderr + assert "--force" in result.stderr + assert tokens_path.read_text() == existing + + +def test_refuses_css_custom_property_source_without_force(tmp_path): + """Common app CSS token sources must be named instead of duplicated.""" + (tmp_path / "docs").mkdir() + (tmp_path / "src").mkdir() + shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md") + (tmp_path / "src" / "index.css").write_text( + ":root {\n --primary: #2563eb;\n --foreground: #0f172a;\n}\n" + ) + + result = _run(tmp_path) + + assert result.returncode == 1 + assert "src/index.css" in result.stderr + assert "--force" in result.stderr + assert not (tmp_path / "assets" / "design-tokens.json").exists() + + +def test_refuses_grouped_root_selector_without_force(tmp_path): + """A :root selector list is still an existing project token source.""" + (tmp_path / "docs").mkdir() + (tmp_path / "src").mkdir() + shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md") + (tmp_path / "src" / "index.css").write_text( + ':root, [data-theme="light"] {\n --primary: #2563eb;\n}\n' + ) + + result = _run(tmp_path) + + assert result.returncode == 1 + assert "src/index.css" in result.stderr + assert not (tmp_path / "assets" / "design-tokens.json").exists() + + +def test_ignores_commented_root_custom_properties(tmp_path): + """Commented examples must not block a first token sync.""" + (tmp_path / "docs").mkdir() + (tmp_path / "src").mkdir() + shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md") + (tmp_path / "src" / "index.css").write_text( + "/* Example only:\n:root {\n --primary: #2563eb;\n}\n*/\n" + ) + + result = _run(tmp_path) + + assert result.returncode == 0, result.stderr + result.stdout + assert (tmp_path / "assets" / "design-tokens.json").exists() + + +def test_ignores_custom_properties_outside_root(tmp_path): + """Component-local variables alone are not a project token source.""" + (tmp_path / "docs").mkdir() + (tmp_path / "src").mkdir() + shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md") + (tmp_path / "src" / "index.css").write_text( + ":root {\n color-scheme: light;\n}\n\n" + ".progress {\n --progress-value: 50%;\n}\n" + ) + + result = _run(tmp_path) + + assert result.returncode == 0, result.stderr + result.stdout + assert (tmp_path / "assets" / "design-tokens.json").exists() + + +def test_refuses_tailwind_theme_colors_without_force(tmp_path): + """Tailwind theme colors are an existing project token source.""" + (tmp_path / "docs").mkdir() + (tmp_path / "assets").mkdir() + shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md") + (tmp_path / "tailwind.config.js").write_text( + "module.exports = { theme: { extend: { colors: { brand: '#2563eb' } } } }\n" + ) + + result = _run(tmp_path) + + assert result.returncode == 1 + assert "tailwind.config.js" in result.stderr + assert "--force" in result.stderr + assert not (tmp_path / "assets" / "design-tokens.json").exists() + + +def test_refuses_tailwind_v4_theme_source_without_force(tmp_path): + """Tailwind v4 @theme variables are an existing project token source.""" + (tmp_path / "docs").mkdir() + (tmp_path / "src").mkdir() + shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md") + (tmp_path / "src" / "index.css").write_text( + "@theme {\n --color-brand-500: #2563eb;\n}\n" + ) + + result = _run(tmp_path) + + assert result.returncode == 1 + assert "src/index.css" in result.stderr + assert not (tmp_path / "assets" / "design-tokens.json").exists() + + +def test_refuses_token_source_imported_by_common_css_entry(tmp_path): + """Local CSS imports must be followed to their actual token source.""" + (tmp_path / "docs").mkdir() + (tmp_path / "src" / "styles").mkdir(parents=True) + shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md") + (tmp_path / "src" / "index.css").write_text( + '@import "./styles/theme.css";\n' + ) + (tmp_path / "src" / "styles" / "theme.css").write_text( + "@theme {\n --color-brand-500: #2563eb;\n}\n" + ) + + result = _run(tmp_path) + + assert result.returncode == 1 + assert "src/styles/theme.css" in result.stderr + assert not (tmp_path / "assets" / "design-tokens.json").exists() + + +def test_ignores_external_css_imports(tmp_path): + """Remote and package imports are not project-owned token sources.""" + (tmp_path / "docs").mkdir() + (tmp_path / "src").mkdir() + shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md") + (tmp_path / "src" / "index.css").write_text( + '@import "https://example.com/theme.css";\n' + '@import "tailwindcss";\n' + ) + + result = _run(tmp_path) + + assert result.returncode == 0, result.stderr + result.stdout + assert (tmp_path / "assets" / "design-tokens.json").exists() + + +def test_refuses_tailwind_config_with_sibling_preset_without_force(tmp_path): + """A delegated Tailwind theme must not be treated as token-free.""" + (tmp_path / "docs").mkdir() + shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md") + (tmp_path / "tailwind.config.js").write_text( + "const preset = require('./tailwind.preset');\n" + "module.exports = { presets: [preset] };\n" + ) + (tmp_path / "tailwind.preset.js").write_text( + "module.exports = { theme: { colors: { brand: '#2563eb' } } };\n" + ) + + result = _run(tmp_path) + + assert result.returncode == 1 + assert "tailwind.config.js" in result.stderr + assert not (tmp_path / "assets" / "design-tokens.json").exists() + + +def test_force_allows_sync_with_existing_css_token_source(tmp_path): + """The explicit force flag overrides token-source detection.""" + (tmp_path / "docs").mkdir() + (tmp_path / "src").mkdir() + shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md") + (tmp_path / "src" / "index.css").write_text( + ":root {\n --primary: #2563eb;\n}\n" + ) + + result = _run(tmp_path, "--force") + + assert result.returncode == 0, result.stderr + result.stdout + assert (tmp_path / "assets" / "design-tokens.json").exists() diff --git a/cli/assets/skills/ui-styling/SKILL.md b/cli/assets/skills/ui-styling/SKILL.md index 3f86abc..b5a9acb 100644 --- a/cli/assets/skills/ui-styling/SKILL.md +++ b/cli/assets/skills/ui-styling/SKILL.md @@ -228,6 +228,14 @@ Generate tailwind.config.js with custom theme: python scripts/tailwind_config_gen.py --colors brand:blue --fonts display:Inter ``` +The generator refuses to create or replace a config when any sibling +`tailwind.config.js`, `.cjs`, `.mjs`, or `.ts` file already exists. Review the +reported config first, then pass `--force` only when the competing output is +intentional: +```bash +python scripts/tailwind_config_gen.py --colors brand:blue --force +``` + ## Best Practices 1. **Component Composition**: Build complex UIs from simple, composable primitives diff --git a/cli/assets/skills/ui-styling/scripts/tailwind_config_gen.py b/cli/assets/skills/ui-styling/scripts/tailwind_config_gen.py index 093c625..8dc6a0f 100644 --- a/cli/assets/skills/ui-styling/scripts/tailwind_config_gen.py +++ b/cli/assets/skills/ui-styling/scripts/tailwind_config_gen.py @@ -17,6 +17,12 @@ from typing import Any, Dict, List, Optional # optional subpath. Only allows alphanumeric, hyphens, dots, underscores, # and forward slashes — no quotes, parens, or semicolons. _VALID_PLUGIN_NAME = re.compile(r'^(@[a-zA-Z0-9_-]+/)?[a-zA-Z0-9_-]+(/[a-zA-Z0-9_.-]+)*$') +_TAILWIND_CONFIG_NAMES = ( + "tailwind.config.js", + "tailwind.config.cjs", + "tailwind.config.mjs", + "tailwind.config.ts", +) class TailwindConfigGenerator: @@ -27,6 +33,7 @@ class TailwindConfigGenerator: typescript: bool = True, framework: str = "react", output_path: Optional[Path] = None, + force: bool = False, ): """ Initialize generator. @@ -35,10 +42,12 @@ class TailwindConfigGenerator: typescript: If True, generate .ts config, else .js framework: Framework name (react, vue, svelte, nextjs) output_path: Output file path (default: auto-detect) + force: If True, allow replacing an existing output file """ self.typescript = typescript self.framework = framework self.output_path = output_path or self._default_output_path() + self.force = force self.config: Dict[str, Any] = self._base_config() def _default_output_path(self) -> Path: @@ -272,6 +281,25 @@ module.exports = {{ Tuple of (success, message) """ try: + existing_paths = [] + if self.output_path.name in _TAILWIND_CONFIG_NAMES: + existing_paths = [ + self.output_path.parent / name + for name in _TAILWIND_CONFIG_NAMES + if (self.output_path.parent / name).exists() + ] + elif self.output_path.exists(): + existing_paths = [self.output_path] + + if existing_paths and not self.force: + existing = ", ".join(str(path) for path in existing_paths) + return ( + False, + f"Tailwind configuration already exists: {existing}. " + "Refusing to create or overwrite a competing config; " + "re-run with --force only if this is intentional.", + ) + config_content = self.generate_config_string() self.output_path.write_text(config_content) @@ -382,6 +410,12 @@ Examples: help="Validate config without writing file", ) + parser.add_argument( + "--force", + action="store_true", + help="Overwrite an existing output file", + ) + args = parser.parse_args() # Initialize generator @@ -389,6 +423,7 @@ Examples: typescript=not args.js, framework=args.framework, output_path=args.output, + force=args.force, ) # Add custom colors @@ -465,7 +500,7 @@ Examples: # Write config success, message = generator.write_config() - print(message) + print(message, file=sys.stdout if success else sys.stderr) sys.exit(0 if success else 1) diff --git a/cli/assets/skills/ui-styling/scripts/tests/test_tailwind_config_gen.py b/cli/assets/skills/ui-styling/scripts/tests/test_tailwind_config_gen.py index 2facdbd..4eb06de 100644 --- a/cli/assets/skills/ui-styling/scripts/tests/test_tailwind_config_gen.py +++ b/cli/assets/skills/ui-styling/scripts/tests/test_tailwind_config_gen.py @@ -278,6 +278,120 @@ class TestTailwindConfigGenerator: assert "import type { Config }" in content assert "brand" in content + def test_write_config_refuses_to_overwrite_existing_file(self, tmp_path): + """Existing project configuration must be preserved by default.""" + output_path = tmp_path / "tailwind.config.ts" + existing = "// existing project config\nexport default { theme: {} }\n" + output_path.write_text(existing) + generator = TailwindConfigGenerator(output_path=output_path) + + success, message = generator.write_config() + + assert success is False + assert "already exists" in message + assert "--force" in message + assert output_path.read_text() == existing + + def test_write_config_force_overwrites_existing_file(self, tmp_path): + """An explicit force opt-in permits replacing an existing config.""" + output_path = tmp_path / "tailwind.config.ts" + output_path.write_text("// existing project config\n") + generator = TailwindConfigGenerator(output_path=output_path, force=True) + generator.add_colors({"brand": "#3b82f6"}) + + success, message = generator.write_config() + + assert success is True + assert "written to" in message + assert "brand" in output_path.read_text() + + def test_cli_refuses_existing_config_without_force(self, tmp_path): + """The CLI must return non-zero and preserve an existing default target.""" + output_path = tmp_path / "tailwind.config.ts" + existing = "// existing project config\n" + output_path.write_text(existing) + script = Path(__file__).parent.parent / "tailwind_config_gen.py" + + result = subprocess.run( + [sys.executable, str(script), "--colors", "brand:#3b82f6"], + cwd=tmp_path, + capture_output=True, + text=True, + ) + + assert result.returncode == 1 + assert "already exists" in result.stderr + assert "--force" in result.stderr + assert output_path.read_text() == existing + + def test_cli_refuses_sibling_config_extension_without_force(self, tmp_path): + """A default .ts write must not create a second config beside .js.""" + existing_path = tmp_path / "tailwind.config.js" + existing = "// existing JavaScript project config\nmodule.exports = {}\n" + existing_path.write_text(existing) + output_path = tmp_path / "tailwind.config.ts" + script = Path(__file__).parent.parent / "tailwind_config_gen.py" + + result = subprocess.run( + [sys.executable, str(script), "--colors", "brand:#3b82f6"], + cwd=tmp_path, + capture_output=True, + text=True, + ) + + assert result.returncode == 1 + assert "tailwind.config.js" in result.stderr + assert "--force" in result.stderr + assert existing_path.read_text() == existing + assert not output_path.exists() + + def test_cli_force_allows_target_beside_sibling_config(self, tmp_path): + """The explicit force opt-in also overrides cross-extension detection.""" + existing_path = tmp_path / "tailwind.config.js" + existing = "// existing JavaScript project config\nmodule.exports = {}\n" + existing_path.write_text(existing) + output_path = tmp_path / "tailwind.config.ts" + script = Path(__file__).parent.parent / "tailwind_config_gen.py" + + result = subprocess.run( + [ + sys.executable, + str(script), + "--colors", + "brand:#3b82f6", + "--force", + ], + cwd=tmp_path, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + assert existing_path.read_text() == existing + assert "brand" in output_path.read_text() + + def test_cli_force_overwrites_existing_config(self, tmp_path): + """The CLI must wire --force through to the generator.""" + output_path = tmp_path / "tailwind.config.ts" + output_path.write_text("// existing project config\n") + script = Path(__file__).parent.parent / "tailwind_config_gen.py" + + result = subprocess.run( + [ + sys.executable, + str(script), + "--colors", + "brand:#3b82f6", + "--force", + ], + cwd=tmp_path, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + assert "brand" in output_path.read_text() + def test_write_config_invalid_path(self): """Test writing config to invalid path.""" generator = TailwindConfigGenerator(output_path=Path("/invalid/path/config.ts"))