Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 52 additions & 70 deletions .claude/skills/brand/scripts/sync-brand-to-tokens.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,61 +29,47 @@ function extractColorsFromMarkdown(content) {
accent: { name: 'accent', shades: {} }
};

// Extract primary color name and hex from Quick Reference table
const quickRefMatch = content.match(/Primary Color\s*\|\s*#([A-Fa-f0-9]{6})\s*\(([^)]+)\)/);
if (quickRefMatch) {
colors.primary.name = quickRefMatch[2].toLowerCase().replace(/\s+/g, '-');
colors.primary.base = `#${quickRefMatch[1]}`;
}

const secondaryMatch = content.match(/Secondary Color\s*\|\s*#([A-Fa-f0-9]{6})\s*\(([^)]+)\)/);
if (secondaryMatch) {
colors.secondary.name = secondaryMatch[2].toLowerCase().replace(/\s+/g, '-');
colors.secondary.base = `#${secondaryMatch[1]}`;
}

const accentMatch = content.match(/Accent Color\s*\|\s*#([A-Fa-f0-9]{6})\s*\(([^)]+)\)/);
if (accentMatch) {
colors.accent.name = accentMatch[2].toLowerCase().replace(/\s+/g, '-');
colors.accent.base = `#${accentMatch[1]}`;
}

// Extract all shades from Primary Colors table
const primarySection = content.match(/### Primary Colors[\s\S]*?\|[\s\S]*?(?=###|$)/i);
if (primarySection) {
const hexMatches = primarySection[0].matchAll(/\*\*([^*]+)\*\*\s*\|\s*#([A-Fa-f0-9]{6})/g);
for (const match of hexMatches) {
const name = match[1].trim().toLowerCase();
const hex = `#${match[2]}`;
if (name.includes('dark')) colors.primary.dark = hex;
else if (name.includes('light')) colors.primary.light = hex;
else colors.primary.base = hex;
}
// Match a "| Label | #hex |" markdown table row. Bold around the label
// (**Label**) is optional, so this handles both the bundled starter template
// ("| Primary Blue | #2563EB |") and bolded variants.
const rowRe = /\|\s*\*{0,2}([^*|]+?)\*{0,2}\s*\|\s*#([A-Fa-f0-9]{6})\b/g;

// 1) Quick Reference table — hex only, no parenthesized name required.
const quickRef = {
primary: /Primary Color\s*\|\s*#([A-Fa-f0-9]{6})/i,
secondary: /Secondary Color\s*\|\s*#([A-Fa-f0-9]{6})/i,
accent: /Accent Color\s*\|\s*#([A-Fa-f0-9]{6})/i
};
for (const key of Object.keys(quickRef)) {
const m = content.match(quickRef[key]);
if (m) colors[key].base = `#${m[1]}`;
}

// Extract secondary shades
const secondarySection = content.match(/### Secondary Colors[\s\S]*?\|[\s\S]*?(?=###|$)/i);
if (secondarySection) {
const hexMatches = secondarySection[0].matchAll(/\*\*([^*]+)\*\*\s*\|\s*#([A-Fa-f0-9]{6})/g);
for (const match of hexMatches) {
const name = match[1].trim().toLowerCase();
const hex = `#${match[2]}`;
if (name.includes('dark')) colors.secondary.dark = hex;
else if (name.includes('light')) colors.secondary.light = hex;
else colors.secondary.base = hex;
// 2) Dedicated "### <Role> Colors" tables — assign base/dark/light by the
// row label keyword.
const assignFromSection = (heading, target) => {
const section = content.match(new RegExp(`### ${heading}[\\s\\S]*?(?=\\n###|$)`, 'i'));
if (!section) return;
for (const m of section[0].matchAll(rowRe)) {
const label = m[1].trim().toLowerCase();
const hex = `#${m[2]}`;
if (label.includes('dark')) target.dark = hex;
else if (label.includes('light')) target.light = hex;
else if (!target.base) target.base = hex;
}
}

// Extract accent shades
const accentSection = content.match(/### Accent Colors[\s\S]*?\|[\s\S]*?(?=###|$)/i);
if (accentSection) {
const hexMatches = accentSection[0].matchAll(/\*\*([^*]+)\*\*\s*\|\s*#([A-Fa-f0-9]{6})/g);
for (const match of hexMatches) {
const name = match[1].trim().toLowerCase();
const hex = `#${match[2]}`;
if (name.includes('dark')) colors.accent.dark = hex;
else if (name.includes('light')) colors.accent.light = hex;
else colors.accent.base = hex;
};
assignFromSection('Primary Colors', colors.primary);
assignFromSection('Secondary Colors', colors.secondary);
assignFromSection('Accent Colors', colors.accent);

// 3) Fallback: an accent swatch may live in another table (the starter
// lists "Accent Green" under Secondary Colors).
if (!colors.accent.base) {
for (const m of content.matchAll(rowRe)) {
if (m[1].trim().toLowerCase().includes('accent')) {
colors.accent.base = `#${m[2]}`;
break;
}
}
}

Expand Down Expand Up @@ -113,6 +99,7 @@ function generateColorScale(baseHex, darkHex, lightHex) {
* Adjust hex color brightness
*/
function adjustBrightness(hex, percent) {
if (typeof hex !== 'string') return '#000000';
const num = parseInt(hex.replace('#', ''), 16);
const r = Math.min(255, Math.max(0, (num >> 16) + Math.round(255 * percent)));
const g = Math.min(255, Math.max(0, ((num >> 8) & 0x00FF) + Math.round(255 * percent)));
Expand All @@ -129,29 +116,24 @@ function updateDesignTokens(tokens, colors) {
tokens.brand = brandName;

// Update primitive colors with new names
const primitiveColors = tokens.primitive?.color || {};
tokens.primitive = tokens.primitive || {};
const primitiveColors = tokens.primitive.color || {};

// Remove old color keys, add new ones
delete primitiveColors.coral;
delete primitiveColors.purple;
delete primitiveColors.mint;

// Add new named colors
primitiveColors[colors.primary.name] = generateColorScale(
colors.primary.base,
colors.primary.dark,
colors.primary.light
);
primitiveColors[colors.secondary.name] = generateColorScale(
colors.secondary.base,
colors.secondary.dark,
colors.secondary.light
);
primitiveColors[colors.accent.name] = generateColorScale(
colors.accent.base,
colors.accent.dark,
colors.accent.light
);
// Add new named colors. Skip any role with no base hex rather than crashing
// on an unexpected guidelines format.
for (const role of ['primary', 'secondary', 'accent']) {
const c = colors[role];
if (!c.base) {
console.warn(`⚠️ No base hex found for ${role} color — skipping its token scale.`);
continue;
}
primitiveColors[c.name] = generateColorScale(c.base, c.dark, c.light);
}

tokens.primitive.color = primitiveColors;

Expand Down Expand Up @@ -191,7 +173,7 @@ function updateDesignTokens(tokens, colors) {
}

// Update component references (button uses primary color with opacity)
if (tokens.component?.button?.secondary) {
if (tokens.component?.button?.secondary && colors.primary.base) {
const primaryBase = colors.primary.base;
tokens.component.button.secondary['bg-hover'] = {
"$value": `${primaryBase}1A`,
Expand Down
52 changes: 52 additions & 0 deletions .claude/skills/brand/scripts/tests/test_sync_brand_to_tokens.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Regression test for sync-brand-to-tokens.cjs.

The color parser required a parenthesized name in the Quick Reference row
(`#2563EB (name)`) and a bolded label in the color tables (`**Primary Blue**`),
neither of which the bundled starter template uses. As a result the base hex
came back `undefined` and `adjustBrightness(undefined)` threw a TypeError —
i.e. the script crashed on its own documented happy path. This test runs the
sync against the bundled starter template and asserts it completes and writes
the expected base colors. It is pytest-based so the existing pytest CI runs it.
"""

import json
import shutil
import subprocess
from pathlib import Path

import pytest

SCRIPTS = Path(__file__).resolve().parent.parent
SCRIPT = SCRIPTS / "sync-brand-to-tokens.cjs"
BRAND_STARTER = SCRIPTS.parent / "templates" / "brand-guidelines-starter.md"
TOKENS_STARTER = (
SCRIPTS.parent.parent / "design-system" / "templates" / "design-tokens-starter.json"
)


def test_sync_parses_bundled_starter_template(tmp_path):
node = shutil.which("node")
if not node:
pytest.skip("node not available")

(tmp_path / "docs").mkdir()
(tmp_path / "assets").mkdir()
shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md")
shutil.copy(TOKENS_STARTER, tmp_path / "assets" / "design-tokens.json")

result = subprocess.run(
[node, str(SCRIPT)],
cwd=tmp_path,
capture_output=True,
text=True,
)

# Must not crash (the bug raised an unhandled TypeError).
assert "TypeError" not in result.stderr, result.stderr
assert result.returncode == 0, result.stderr + result.stdout

tokens = json.loads((tmp_path / "assets" / "design-tokens.json").read_text())
primitive = tokens["primitive"]["color"]
assert primitive["primary"]["500"]["$value"] == "#2563EB"
assert primitive["secondary"]["500"]["$value"] == "#8B5CF6"
assert primitive["accent"]["500"]["$value"] == "#10B981"
48 changes: 48 additions & 0 deletions .claude/skills/design-system/scripts/tests/test_validate_tokens.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Regression tests for validate-tokens.cjs.

The validator used to skip any line containing ``var(--`` outright, so a
hardcoded value sharing a line with a token reference (extremely common in
real CSS, and universal in minified CSS where everything is one line) went
undetected. These tests drive the CLI via ``node`` and assert it flags such
cases. They are pytest-based so the repository's existing pytest CI runs them.
"""

import shutil
import subprocess
from pathlib import Path

import pytest

SCRIPT = Path(__file__).resolve().parent.parent / "validate-tokens.cjs"


def _run(tmp_path: Path, css: str) -> subprocess.CompletedProcess:
node = shutil.which("node")
if not node:
pytest.skip("node not available")
(tmp_path / "sample.css").write_text(css)
return subprocess.run(
[node, str(SCRIPT), "--dir", str(tmp_path)],
capture_output=True,
text=True,
)


def test_flags_hardcoded_hex_sharing_line_with_token(tmp_path):
"""A hardcoded hex on the same line as a var() token is still a violation."""
result = _run(
tmp_path,
".btn { background: #FF6B6B; color: var(--color-primary); }\n",
)
assert "#FF6B6B" in result.stdout, result.stdout
assert result.returncode == 1


def test_token_only_line_reports_no_violation(tmp_path):
"""A line that references only tokens produces no false positives."""
result = _run(
tmp_path,
".btn { background: var(--color-bg); color: var(--color-primary); }\n",
)
assert "No token violations" in result.stdout, result.stdout
assert result.returncode == 0
5 changes: 0 additions & 5 deletions .claude/skills/design-system/scripts/validate-tokens.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -137,11 +137,6 @@ function scanFile(filePath) {
return;
}

// Skip lines that already use CSS variables
if (line.includes('var(--')) {
return;
}

for (const [name, pattern] of Object.entries(patterns)) {
const matches = line.match(pattern.regex);
if (matches) {
Expand Down
4 changes: 2 additions & 2 deletions .claude/skills/ui-styling/scripts/tailwind_config_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ def _generate_typescript(self) -> str:
return f"""import type {{ Config }} from 'tailwindcss'

const config: Config = {{
{self._indent_json(config_json, 1)}
{self._indent_json(config_json, 1)},
plugins: [{plugins_str}],
}}

Expand All @@ -224,7 +224,7 @@ def _generate_javascript(self) -> str:

return f"""/** @type {{import('tailwindcss').Config}} */
module.exports = {{
{self._indent_json(config_json, 1)}
{self._indent_json(config_json, 1)},
plugins: [{plugins_str}],
}}
"""
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Tests for tailwind_config_gen.py"""

import shutil
import subprocess
from pathlib import Path

import pytest
Expand Down Expand Up @@ -334,3 +336,59 @@ def test_full_configuration_javascript(self, tmp_path):
assert "module.exports" in content
assert "primary" in content
assert "@tailwindcss/forms" in content


def _strip_to_object(config_str: str) -> str:
"""Reduce a generated TS/JS config to a bare assignable object so it can be
handed to `node --check` without a TypeScript loader."""
lines = []
for line in config_str.splitlines():
if line.startswith("import type"):
continue
if line.strip() == "export default config":
continue
line = line.replace("const config: Config =", "const config =")
line = line.replace("module.exports =", "const config =")
lines.append(line)
return "\n".join(lines)


class TestGeneratedConfigIsValidJs:
"""Regression guard for the missing-comma bug between the ``theme`` block and
``plugins`` that produced syntactically invalid config files. The data-shape
tests above all passed while the emitted string was unparseable, so these
tests validate the serialized output itself."""

@pytest.mark.parametrize("typescript", [True, False])
def test_property_before_plugins_is_comma_terminated(self, typescript):
"""The property preceding ``plugins`` must end with a comma (pure-Python
check, so the regression is caught even where node is unavailable)."""
generator = TailwindConfigGenerator(typescript=typescript)
generator.add_colors({"brand": "#6366F1"})
generator.add_breakpoints({"3xl": "1920px"})
config = generator.generate_config_string()

assert "}\n plugins:" not in config, "missing comma before plugins"
assert "},\n plugins:" in config

@pytest.mark.parametrize("typescript", [True, False])
def test_node_check_parses_generated_config(self, typescript, tmp_path):
"""The emitted config parses as valid JS via ``node --check``."""
node = shutil.which("node")
if not node:
pytest.skip("node not available")

generator = TailwindConfigGenerator(typescript=typescript)
generator.add_colors({"brand": "#6366F1", "accent": "#10B981"})
generator.add_fonts({"sans": ["Inter"]})
generator.add_breakpoints({"3xl": "1920px"})
generator.add_plugins(["tailwindcss-animate"])

snippet = _strip_to_object(generator.generate_config_string())
path = tmp_path / "config.cjs"
path.write_text(snippet)

result = subprocess.run(
[node, "--check", str(path)], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
1 change: 0 additions & 1 deletion .claude/skills/ui-ux-pro-max/data

This file was deleted.

Loading