fix: protect existing Tailwind and token configurations (#501)

Squash-merged by github-maintain cron after maintainer approval (review 2026-09-18) and follow-up gap-closing commit 83ad973. Closes #496.
This commit is contained in:
Ravikoti Deekshith 2026-09-19 06:28:38 +05:30 committed by GitHub
parent 15de38fb70
commit de5f12b400
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 962 additions and 14 deletions

View File

@ -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

View File

@ -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

View File

@ -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}`);

View File

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

View File

@ -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

View File

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

View File

@ -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"))

View File

@ -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

View File

@ -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

View File

@ -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}`);

View File

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

View File

@ -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

View File

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

View File

@ -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"))