install skill

This commit is contained in:
phaichayon
2026-07-02 09:45:34 +07:00
parent ed28ff6b50
commit f7c9037ba4
147 changed files with 29394 additions and 1 deletions

View File

@@ -0,0 +1,341 @@
#!/usr/bin/env node
/**
* extract-colors.cjs
*
* Extract dominant colors from an image and compare against brand palette.
* Uses pure Node.js without external image processing dependencies.
*
* For full color extraction from images, integrate with ai-multimodal skill
* or use ImageMagick via shell commands.
*
* Usage:
* node extract-colors.cjs <image-path>
* node extract-colors.cjs <image-path> --brand-file <path>
* node extract-colors.cjs --palette # Show brand palette from guidelines
*
* Integration:
* For image color analysis, use: ai-multimodal skill or ImageMagick
* magick <image> -colors 10 -depth 8 -format "%c" histogram:info:
*/
const fs = require("fs");
const path = require("path");
// Default brand guidelines path
const DEFAULT_GUIDELINES_PATH = "docs/brand-guidelines.md";
/**
* Extract hex colors from markdown content
*/
function extractHexColors(text) {
const hexPattern = /#[0-9A-Fa-f]{6}\b/g;
return [...new Set(text.match(hexPattern) || [])];
}
/**
* Parse brand guidelines for color palette
*/
function parseBrandColors(guidelinesPath) {
const resolvedPath = path.isAbsolute(guidelinesPath)
? guidelinesPath
: path.join(process.cwd(), guidelinesPath);
if (!fs.existsSync(resolvedPath)) {
return null;
}
const content = fs.readFileSync(resolvedPath, "utf-8");
const palette = {
primary: [],
secondary: [],
neutral: [],
semantic: [],
all: [],
};
// Extract colors from different sections
const sections = [
{ name: "primary", regex: /### Primary[\s\S]*?(?=###|##|$)/i },
{ name: "secondary", regex: /### Secondary[\s\S]*?(?=###|##|$)/i },
{ name: "neutral", regex: /### Neutral[\s\S]*?(?=###|##|$)/i },
{ name: "semantic", regex: /### Semantic[\s\S]*?(?=###|##|$)/i },
];
sections.forEach(({ name, regex }) => {
const match = content.match(regex);
if (match) {
const colors = extractHexColors(match[0]);
palette[name] = colors;
palette.all.push(...colors);
}
});
// Dedupe all
palette.all = [...new Set(palette.all)];
return palette;
}
/**
* Convert hex to RGB
*/
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: null;
}
/**
* Convert RGB to hex
*/
function rgbToHex(r, g, b) {
return (
"#" +
[r, g, b]
.map((x) => {
const hex = Math.round(x).toString(16);
return hex.length === 1 ? "0" + hex : hex;
})
.join("")
.toUpperCase()
);
}
/**
* Calculate color distance (Euclidean in RGB space)
*/
function colorDistance(color1, color2) {
const rgb1 = typeof color1 === "string" ? hexToRgb(color1) : color1;
const rgb2 = typeof color2 === "string" ? hexToRgb(color2) : color2;
if (!rgb1 || !rgb2) return Infinity;
return Math.sqrt(
Math.pow(rgb1.r - rgb2.r, 2) +
Math.pow(rgb1.g - rgb2.g, 2) +
Math.pow(rgb1.b - rgb2.b, 2)
);
}
/**
* Find nearest brand color
*/
function findNearestBrandColor(color, brandColors) {
let nearest = null;
let minDistance = Infinity;
brandColors.forEach((brandColor) => {
const distance = colorDistance(color, brandColor);
if (distance < minDistance) {
minDistance = distance;
nearest = brandColor;
}
});
return { color: nearest, distance: minDistance };
}
/**
* Calculate brand compliance percentage
* Distance threshold: 50 (out of max ~441 for RGB)
*/
function calculateCompliance(extractedColors, brandColors, threshold = 50) {
if (!extractedColors || extractedColors.length === 0) return 100;
if (!brandColors || brandColors.length === 0) return 0;
let matchCount = 0;
extractedColors.forEach((color) => {
const nearest = findNearestBrandColor(color, brandColors);
if (nearest.distance <= threshold) {
matchCount++;
}
});
return Math.round((matchCount / extractedColors.length) * 100);
}
/**
* Generate ImageMagick command for color extraction
*/
function generateImageMagickCommand(imagePath, numColors = 10) {
return `magick "${imagePath}" -colors ${numColors} -depth 8 -format "%c" histogram:info:`;
}
/**
* Parse ImageMagick histogram output to extract colors
*/
function parseImageMagickOutput(output) {
const colors = [];
const lines = output.trim().split("\n");
lines.forEach((line) => {
// Match pattern like: 12345: (255,128,64) #FF8040 srgb(255,128,64)
const hexMatch = line.match(/#([0-9A-Fa-f]{6})/);
const countMatch = line.match(/^\s*(\d+):/);
if (hexMatch) {
colors.push({
hex: "#" + hexMatch[1].toUpperCase(),
count: countMatch ? parseInt(countMatch[1]) : 0,
});
}
});
// Sort by count (most common first)
colors.sort((a, b) => b.count - a.count);
return colors;
}
/**
* Display brand palette
*/
function displayPalette(palette) {
console.log("\n" + "=".repeat(50));
console.log("BRAND COLOR PALETTE");
console.log("=".repeat(50));
if (palette.primary.length > 0) {
console.log("\nPrimary Colors:");
palette.primary.forEach((c) => console.log(` ${c}`));
}
if (palette.secondary.length > 0) {
console.log("\nSecondary Colors:");
palette.secondary.forEach((c) => console.log(` ${c}`));
}
if (palette.neutral.length > 0) {
console.log("\nNeutral Colors:");
palette.neutral.forEach((c) => console.log(` ${c}`));
}
if (palette.semantic.length > 0) {
console.log("\nSemantic Colors:");
palette.semantic.forEach((c) => console.log(` ${c}`));
}
console.log("\n" + "=".repeat(50));
console.log(`Total: ${palette.all.length} colors in brand palette`);
console.log("=".repeat(50) + "\n");
}
/**
* Main function
*/
function main() {
const args = process.argv.slice(2);
const jsonOutput = args.includes("--json");
const showPalette = args.includes("--palette");
const brandFileIdx = args.indexOf("--brand-file");
const brandFile =
brandFileIdx !== -1 ? args[brandFileIdx + 1] : DEFAULT_GUIDELINES_PATH;
const brandFileValue = brandFileIdx !== -1 ? args[brandFileIdx + 1] : null;
const imagePath = args.find(
(a) => !a.startsWith("--") && a !== brandFileValue
);
// Load brand palette
const brandPalette = parseBrandColors(brandFile);
if (!brandPalette) {
console.error(`Brand guidelines not found at: ${brandFile}`);
console.error(`Create brand guidelines or specify path with --brand-file`);
process.exit(1);
}
// Show palette mode
if (showPalette || !imagePath) {
if (jsonOutput) {
console.log(JSON.stringify(brandPalette, null, 2));
} else {
displayPalette(brandPalette);
if (!imagePath) {
console.log("To extract colors from an image:");
console.log(" node extract-colors.cjs <image-path>");
console.log("\nOr use ImageMagick directly:");
console.log(' magick image.png -colors 10 -depth 8 -format "%c" histogram:info:');
}
}
return;
}
// Resolve image path
const resolvedPath = path.isAbsolute(imagePath)
? imagePath
: path.join(process.cwd(), imagePath);
if (!fs.existsSync(resolvedPath)) {
console.error(`Image not found: ${resolvedPath}`);
process.exit(1);
}
// Generate extraction instructions
const result = {
image: resolvedPath,
brandPalette: brandPalette,
extractionCommand: generateImageMagickCommand(resolvedPath),
instructions: [
"1. Run the ImageMagick command to extract colors:",
` ${generateImageMagickCommand(resolvedPath)}`,
"",
"2. Or use the ai-multimodal skill:",
` python .claude/skills/ai-multimodal/scripts/gemini_batch_process.py \\`,
` --files "${resolvedPath}" \\`,
` --task analyze \\`,
` --prompt "Extract the 10 most dominant colors as hex values"`,
"",
"3. Then compare extracted colors against brand palette",
],
complianceCheck: {
threshold: 50,
description:
"Colors within distance 50 (RGB space) are considered brand-compliant",
brandColors: brandPalette.all,
},
};
if (jsonOutput) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log("\n" + "=".repeat(60));
console.log("COLOR EXTRACTION HELPER");
console.log("=".repeat(60));
console.log(`\nImage: ${result.image}`);
console.log(`\nBrand Colors: ${brandPalette.all.length} colors loaded`);
console.log("\nTo extract colors from this image:\n");
result.instructions.forEach((line) => console.log(line));
console.log("\n" + "=".repeat(60));
// Show brand palette for reference
console.log("\nBrand Palette Reference:");
console.log(` Primary: ${brandPalette.primary.join(", ") || "none"}`);
console.log(` Secondary: ${brandPalette.secondary.join(", ") || "none"}`);
console.log(` Neutral: ${brandPalette.neutral.join(", ") || "none"}`);
console.log("=".repeat(60) + "\n");
}
}
// Export functions for use as module
module.exports = {
parseBrandColors,
hexToRgb,
rgbToHex,
colorDistance,
findNearestBrandColor,
calculateCompliance,
parseImageMagickOutput,
};
// Run if called directly
if (require.main === module) {
main();
}

View File

@@ -0,0 +1,349 @@
#!/usr/bin/env node
/**
* inject-brand-context.cjs
*
* Extracts brand context from markdown brand guidelines
* and outputs a formatted system prompt addition.
*
* Usage:
* node inject-brand-context.cjs [path-to-guidelines]
* node inject-brand-context.cjs --json [path-to-guidelines]
*
* Default path: docs/brand-guidelines.md
*/
const fs = require("fs");
const path = require("path");
// Default brand guidelines path
const DEFAULT_GUIDELINES_PATH = "docs/brand-guidelines.md";
/**
* Extract hex colors from text
*/
function extractHexColors(text) {
const hexPattern = /#[0-9A-Fa-f]{6}\b/g;
return [...new Set(text.match(hexPattern) || [])];
}
/**
* Extract color data from markdown table
*/
function extractColorsFromTable(content) {
const colors = {
primary: [],
secondary: [],
neutral: [],
semantic: [],
};
// Find color tables
const primaryMatch = content.match(
/### Primary Colors[\s\S]*?\|[\s\S]*?(?=###|$)/i
);
const secondaryMatch = content.match(
/### Secondary Colors[\s\S]*?\|[\s\S]*?(?=###|$)/i
);
const neutralMatch = content.match(
/### Neutral[\s\S]*?\|[\s\S]*?(?=###|$)/i
);
const semanticMatch = content.match(
/### Semantic[\s\S]*?\|[\s\S]*?(?=###|$)/i
);
if (primaryMatch) colors.primary = extractHexColors(primaryMatch[0]);
if (secondaryMatch) colors.secondary = extractHexColors(secondaryMatch[0]);
if (neutralMatch) colors.neutral = extractHexColors(neutralMatch[0]);
if (semanticMatch) colors.semantic = extractHexColors(semanticMatch[0]);
return colors;
}
/**
* Extract typography info
*/
function extractTypography(content) {
const typography = {
heading: null,
body: null,
mono: null,
};
// Look for font definitions
const headingMatch = content.match(/--font-heading:\s*['"]([^'"]+)['"]/);
const bodyMatch = content.match(/--font-body:\s*['"]([^'"]+)['"]/);
const monoMatch = content.match(/--font-mono:\s*['"]([^'"]+)['"]/);
// Fallback: look in tables
const fontStackMatch = content.match(/### Font Stack[\s\S]*?(?=###|##|$)/i);
if (fontStackMatch) {
const stackText = fontStackMatch[0];
const headingAlt = stackText.match(/heading[^']*['"]([^'"]+)['"]/i);
const bodyAlt = stackText.match(/body[^']*['"]([^'"]+)['"]/i);
if (headingAlt) typography.heading = headingAlt[1];
if (bodyAlt) typography.body = bodyAlt[1];
}
if (headingMatch) typography.heading = headingMatch[1];
if (bodyMatch) typography.body = bodyMatch[1];
if (monoMatch) typography.mono = monoMatch[1];
return typography;
}
/**
* Extract voice/tone information
*/
function extractVoice(content) {
const voice = {
traits: [],
prohibited: [],
personality: "",
};
// Extract personality traits from table
const personalityMatch = content.match(
/### Brand Personality[\s\S]*?\|[\s\S]*?(?=###|##|$)/i
);
if (personalityMatch) {
const traits = personalityMatch[0].match(
/\*\*([^*]+)\*\*\s*\|\s*([^|]+)/g
);
if (traits) {
voice.traits = traits.map((t) => {
const match = t.match(/\*\*([^*]+)\*\*/);
return match ? match[1].trim() : "";
}).filter(Boolean);
}
}
// Extract prohibited terms
const prohibitedMatch = content.match(
/### Prohibited[\s\S]*?(?=###|##|$)/i
);
if (prohibitedMatch) {
const terms = prohibitedMatch[0].match(/\|\s*([^|]+)\s*\|/g);
if (terms) {
voice.prohibited = terms
.map((t) => t.replace(/\|/g, "").trim())
.filter((t) => t && !t.includes("Avoid") && !t.includes("---"));
}
}
// Fallback: look for Forbidden Phrases
const forbiddenMatch = content.match(
/### Forbidden Phrases[\s\S]*?(?=###|##|$)/i
);
if (forbiddenMatch && voice.prohibited.length === 0) {
const items = forbiddenMatch[0].match(/-\s*["']?([^"'\n(]+)/g);
if (items) {
voice.prohibited = items
.map((item) => item.replace(/^-\s*["']?/, "").trim())
.filter(Boolean);
}
}
voice.personality = voice.traits.join(", ");
return voice;
}
/**
* Extract core attributes
*/
function extractCoreAttributes(content) {
const attributes = [];
const attributesMatch = content.match(
/### Core Attributes[\s\S]*?\|[\s\S]*?(?=###|##|$)/i
);
if (attributesMatch) {
const rows = attributesMatch[0].match(
/\|\s*\*\*([^*]+)\*\*\s*\|\s*([^|]+)\|/g
);
if (rows) {
rows.forEach((row) => {
const match = row.match(/\*\*([^*]+)\*\*\s*\|\s*([^|]+)/);
if (match) {
attributes.push({
name: match[1].trim(),
description: match[2].trim(),
});
}
});
}
}
return attributes;
}
/**
* Extract AI image generation context
*/
function extractImageStyle(content) {
const imageStyle = {
basePrompt: "",
keywords: [],
mood: [],
donts: [],
examplePrompts: [],
};
// Extract base prompt template (content between ``` blocks after "Base Prompt Template")
const basePromptMatch = content.match(
/### Base Prompt Template[\s\S]*?```\n?([\s\S]*?)```/i
);
if (basePromptMatch) {
imageStyle.basePrompt = basePromptMatch[1].trim().replace(/\n/g, " ");
}
// Extract style keywords from table
const keywordsMatch = content.match(
/### Style Keywords[\s\S]*?\|[\s\S]*?(?=###|##|$)/i
);
if (keywordsMatch) {
const keywordRows = keywordsMatch[0].match(/\|\s*\*\*[^*]+\*\*\s*\|\s*([^|]+)\|/g);
if (keywordRows) {
keywordRows.forEach((row) => {
const match = row.match(/\|\s*\*\*[^*]+\*\*\s*\|\s*([^|]+)\|/);
if (match) {
const keywords = match[1].split(",").map((k) => k.trim()).filter(Boolean);
imageStyle.keywords.push(...keywords);
}
});
}
}
// Extract visual mood descriptors (bullet points)
const moodMatch = content.match(
/### Visual Mood Descriptors[\s\S]*?(?=###|##|$)/i
);
if (moodMatch) {
const moodItems = moodMatch[0].match(/-\s*([^\n]+)/g);
if (moodItems) {
imageStyle.mood = moodItems.map((item) => item.replace(/^-\s*/, "").trim());
}
}
// Extract visual don'ts from table
const dontsMatch = content.match(
/### Visual Don'ts[\s\S]*?\|[\s\S]*?(?=###|##|$)/i
);
if (dontsMatch) {
const dontRows = dontsMatch[0].match(/\|\s*([^|]+)\s*\|\s*([^|]+)\s*\|/g);
if (dontRows) {
dontRows.forEach((row) => {
const match = row.match(/\|\s*([^|]+)\s*\|\s*([^|]+)\s*\|/);
if (match && !match[1].includes("Avoid") && !match[1].includes("---")) {
imageStyle.donts.push(match[1].trim());
}
});
}
}
// Extract example prompts (content between ``` blocks after specific headers)
const exampleMatch = content.match(/### Example Prompts[\s\S]*?(?=##|$)/i);
if (exampleMatch) {
const prompts = exampleMatch[0].match(/\*\*([^*]+)\*\*:\s*```\n?([\s\S]*?)```/g);
if (prompts) {
prompts.forEach((p) => {
const match = p.match(/\*\*([^*]+)\*\*:\s*```\n?([\s\S]*?)```/);
if (match) {
imageStyle.examplePrompts.push({
type: match[1].trim(),
prompt: match[2].trim().replace(/\n/g, " "),
});
}
});
}
}
return imageStyle;
}
/**
* Generate system prompt addition
*/
function generatePromptAddition(brandContext) {
const { colors, typography, voice, attributes, imageStyle } = brandContext;
let prompt = `
BRAND CONTEXT:
==============
VISUAL IDENTITY:
- Primary Colors: ${colors.primary.join(", ") || "Not specified"}
- Secondary Colors: ${colors.secondary.join(", ") || "Not specified"}
- Typography: ${typography.heading || typography.body || "System fonts"}
BRAND VOICE:
- Personality: ${voice.personality || "Professional"}
- Core Attributes: ${attributes.map((a) => a.name).join(", ") || "Not specified"}
CONTENT RULES:
- Prohibited Terms: ${voice.prohibited.join(", ") || "None specified"}
`;
// Add image style context if available
if (imageStyle && imageStyle.basePrompt) {
prompt += `
IMAGE GENERATION:
- Base Prompt: ${imageStyle.basePrompt}
- Style Keywords: ${imageStyle.keywords.slice(0, 10).join(", ") || "Not specified"}
- Visual Mood: ${imageStyle.mood.slice(0, 5).join("; ") || "Not specified"}
- Avoid: ${imageStyle.donts.join(", ") || "None specified"}
`;
}
prompt += `
Apply these brand guidelines to all generated content.
Maintain consistent voice, colors, and messaging.
`;
return prompt.trim();
}
/**
* Main function
*/
function main() {
const args = process.argv.slice(2);
const jsonOutput = args.includes("--json");
const guidelinesPath = args.find((a) => !a.startsWith("--")) || DEFAULT_GUIDELINES_PATH;
// Resolve path
const resolvedPath = path.isAbsolute(guidelinesPath)
? guidelinesPath
: path.join(process.cwd(), guidelinesPath);
// Check if file exists
if (!fs.existsSync(resolvedPath)) {
console.error(`Error: Brand guidelines not found at ${resolvedPath}`);
console.error(`Create brand guidelines at ${DEFAULT_GUIDELINES_PATH} or specify a path.`);
process.exit(1);
}
// Read file
const content = fs.readFileSync(resolvedPath, "utf-8");
// Extract brand context
const brandContext = {
colors: extractColorsFromTable(content),
typography: extractTypography(content),
voice: extractVoice(content),
attributes: extractCoreAttributes(content),
imageStyle: extractImageStyle(content),
source: resolvedPath,
extractedAt: new Date().toISOString(),
};
// Output
if (jsonOutput) {
console.log(JSON.stringify(brandContext, null, 2));
} else {
console.log(generatePromptAddition(brandContext));
}
}
main();

View File

@@ -0,0 +1,248 @@
#!/usr/bin/env node
/**
* sync-brand-to-tokens.cjs
*
* Syncs brand-guidelines.md colors → design-tokens.json → design-tokens.css
*
* Usage:
* node sync-brand-to-tokens.cjs
* node sync-brand-to-tokens.cjs --dry-run
*/
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
// Paths
const BRAND_GUIDELINES = 'docs/brand-guidelines.md';
const DESIGN_TOKENS_JSON = 'assets/design-tokens.json';
const DESIGN_TOKENS_CSS = 'assets/design-tokens.css';
const GENERATE_TOKENS_SCRIPT = '.claude/skills/design-system/scripts/generate-tokens.cjs';
/**
* Extract color info from brand guidelines markdown
*/
function extractColorsFromMarkdown(content) {
const colors = {
primary: { name: 'primary', shades: {} },
secondary: { name: 'secondary', shades: {} },
accent: { name: 'accent', shades: {} }
};
// 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]}`;
}
// 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;
}
};
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;
}
}
}
return colors;
}
/**
* Generate color scale from base color (simple approach)
*/
function generateColorScale(baseHex, darkHex, lightHex) {
// Use provided shades or generate approximations
return {
"50": { "$value": lightHex || adjustBrightness(baseHex, 0.9), "$type": "color" },
"100": { "$value": lightHex || adjustBrightness(baseHex, 0.8), "$type": "color" },
"200": { "$value": adjustBrightness(baseHex, 0.6), "$type": "color" },
"300": { "$value": adjustBrightness(baseHex, 0.4), "$type": "color" },
"400": { "$value": adjustBrightness(baseHex, 0.2), "$type": "color" },
"500": { "$value": baseHex, "$type": "color" },
"600": { "$value": darkHex || adjustBrightness(baseHex, -0.15), "$type": "color" },
"700": { "$value": adjustBrightness(baseHex, -0.3), "$type": "color" },
"800": { "$value": adjustBrightness(baseHex, -0.45), "$type": "color" },
"900": { "$value": adjustBrightness(baseHex, -0.6), "$type": "color" }
};
}
/**
* 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)));
const b = Math.min(255, Math.max(0, (num & 0x0000FF) + Math.round(255 * percent)));
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0').toUpperCase()}`;
}
/**
* Update design tokens JSON
*/
function updateDesignTokens(tokens, colors) {
// Update brand name
const brandName = `ClaudeKit Marketing - ${colors.primary.name.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')}`;
tokens.brand = brandName;
// Update primitive colors with new names
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. 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;
// Update ALL semantic color references
if (tokens.semantic?.color) {
const sem = tokens.semantic.color;
const p = colors.primary.name;
const s = colors.secondary.name;
const a = colors.accent.name;
// Primary variants
sem.primary = { "$value": `{primitive.color.${p}.500}`, "$type": "color" };
sem['primary-hover'] = { "$value": `{primitive.color.${p}.600}`, "$type": "color" };
sem['primary-active'] = { "$value": `{primitive.color.${p}.700}`, "$type": "color" };
sem['primary-light'] = { "$value": `{primitive.color.${p}.400}`, "$type": "color" };
sem['primary-lighter'] = { "$value": `{primitive.color.${p}.100}`, "$type": "color" };
sem['primary-dark'] = { "$value": `{primitive.color.${p}.600}`, "$type": "color" };
// Secondary variants
sem.secondary = { "$value": `{primitive.color.${s}.500}`, "$type": "color" };
sem['secondary-hover'] = { "$value": `{primitive.color.${s}.600}`, "$type": "color" };
sem['secondary-light'] = { "$value": `{primitive.color.${s}.300}`, "$type": "color" };
sem['secondary-dark'] = { "$value": `{primitive.color.${s}.600}`, "$type": "color" };
// Accent variants
sem.accent = { "$value": `{primitive.color.${a}.500}`, "$type": "color" };
sem['accent-hover'] = { "$value": `{primitive.color.${a}.600}`, "$type": "color" };
sem['accent-light'] = { "$value": `{primitive.color.${a}.300}`, "$type": "color" };
// Status colors (use accent for success, primary for error/info)
sem.success = { "$value": `{primitive.color.${a}.500}`, "$type": "color" };
sem['success-light'] = { "$value": `{primitive.color.${a}.300}`, "$type": "color" };
sem.error = { "$value": `{primitive.color.${p}.500}`, "$type": "color" };
sem['error-light'] = { "$value": `{primitive.color.${p}.300}`, "$type": "color" };
sem.info = { "$value": `{primitive.color.${s}.500}`, "$type": "color" };
sem['info-light'] = { "$value": `{primitive.color.${s}.300}`, "$type": "color" };
}
// Update component references (button uses primary color with opacity)
if (tokens.component?.button?.secondary && colors.primary.base) {
const primaryBase = colors.primary.base;
tokens.component.button.secondary['bg-hover'] = {
"$value": `${primaryBase}1A`,
"$type": "color"
};
}
return tokens;
}
/**
* Main
*/
function main() {
const dryRun = process.argv.includes('--dry-run');
console.log('🔄 Syncing brand guidelines → design tokens\n');
// Read brand guidelines
const guidelinesPath = path.resolve(process.cwd(), BRAND_GUIDELINES);
if (!fs.existsSync(guidelinesPath)) {
console.error(`❌ Brand guidelines not found: ${guidelinesPath}`);
process.exit(1);
}
const guidelinesContent = fs.readFileSync(guidelinesPath, 'utf-8');
// Extract colors
const colors = extractColorsFromMarkdown(guidelinesContent);
console.log('📊 Extracted colors:');
console.log(` Primary: ${colors.primary.name} (${colors.primary.base})`);
console.log(` Secondary: ${colors.secondary.name} (${colors.secondary.base})`);
console.log(` Accent: ${colors.accent.name} (${colors.accent.base})\n`);
// Read existing tokens
const tokensPath = path.resolve(process.cwd(), DESIGN_TOKENS_JSON);
let tokens = {};
if (fs.existsSync(tokensPath)) {
tokens = JSON.parse(fs.readFileSync(tokensPath, 'utf-8'));
}
// Update tokens
tokens = updateDesignTokens(tokens, colors);
if (dryRun) {
console.log('📋 Would update design-tokens.json:');
console.log(JSON.stringify(tokens.primitive.color, null, 2).slice(0, 500) + '...');
console.log('\n⏭ Dry run - no files changed');
return;
}
// Write updated tokens
fs.writeFileSync(tokensPath, JSON.stringify(tokens, null, 2));
console.log(`✅ Updated: ${DESIGN_TOKENS_JSON}`);
// Regenerate CSS
const generateScript = path.resolve(process.cwd(), GENERATE_TOKENS_SCRIPT);
if (fs.existsSync(generateScript)) {
try {
execFileSync('node', [generateScript, '--config', DESIGN_TOKENS_JSON, '-o', DESIGN_TOKENS_CSS], {
cwd: process.cwd(),
stdio: 'inherit'
});
console.log(`✅ Regenerated: ${DESIGN_TOKENS_CSS}`);
} catch (e) {
console.error('⚠️ Failed to regenerate CSS:', e.message);
}
}
console.log('\n✨ Brand sync complete!');
}
main();

View File

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

View File

@@ -0,0 +1,387 @@
#!/usr/bin/env node
/**
* validate-asset.cjs
*
* Validates marketing assets against brand guidelines.
* Checks: file naming, dimensions, file size, metadata.
*
* Usage:
* node validate-asset.cjs <asset-path>
* node validate-asset.cjs <asset-path> --json
* node validate-asset.cjs <asset-path> --fix
*
* For color validation of images, use with extract-colors.cjs
*/
const fs = require("fs");
const path = require("path");
// Validation rules
const RULES = {
naming: {
pattern: /^[a-z]+_[a-z0-9-]+_[a-z0-9-]+_\d{8}(_[a-z0-9-]+)?\.[a-z]+$/,
description:
"{type}_{campaign}_{description}_{timestamp}_{variant}.{ext}",
examples: [
"banner_claude-launch_hero-image_20251209.png",
"logo_brand-refresh_horizontal_20251209_dark.svg",
],
},
dimensions: {
banner: { minWidth: 600, minHeight: 300 },
logo: { minWidth: 100, minHeight: 100 },
design: { minWidth: 800, minHeight: 600 },
video: { minWidth: 640, minHeight: 480 },
default: { minWidth: 100, minHeight: 100 },
},
fileSize: {
image: { max: 5 * 1024 * 1024, recommended: 1 * 1024 * 1024 },
video: { max: 100 * 1024 * 1024, recommended: 50 * 1024 * 1024 },
svg: { max: 500 * 1024, recommended: 100 * 1024 },
},
formats: {
image: ["png", "jpg", "jpeg", "webp", "gif"],
vector: ["svg"],
video: ["mp4", "mov", "webm"],
document: ["pdf", "psd", "ai", "fig"],
},
};
/**
* Parse asset filename
*/
function parseFilename(filename) {
const parts = filename.replace(/\.[^.]+$/, "").split("_");
if (parts.length < 4) {
return null;
}
return {
type: parts[0],
campaign: parts[1],
description: parts[2],
timestamp: parts[3],
variant: parts.length > 4 ? parts[4] : null,
extension: path.extname(filename).slice(1).toLowerCase(),
};
}
/**
* Validate filename convention
*/
function validateFilename(filename) {
const issues = [];
const suggestions = [];
// Check pattern match
if (!RULES.naming.pattern.test(filename)) {
issues.push("Filename does not match naming convention");
suggestions.push(`Expected format: ${RULES.naming.description}`);
suggestions.push(`Examples: ${RULES.naming.examples.join(", ")}`);
}
// Parse and check components
const parsed = parseFilename(filename);
if (parsed) {
// Check timestamp format
if (!/^\d{8}$/.test(parsed.timestamp)) {
issues.push("Timestamp should be YYYYMMDD format");
}
// Check kebab-case for campaign and description
if (parsed.campaign && !/^[a-z0-9-]+$/.test(parsed.campaign)) {
issues.push("Campaign name should be kebab-case");
}
if (parsed.description && !/^[a-z0-9-]+$/.test(parsed.description)) {
issues.push("Description should be kebab-case");
}
// Check valid type
const validTypes = [
"banner",
"logo",
"design",
"video",
"infographic",
"icon",
"photo",
];
if (!validTypes.includes(parsed.type)) {
suggestions.push(`Consider using type: ${validTypes.join(", ")}`);
}
}
return { valid: issues.length === 0, issues, suggestions, parsed };
}
/**
* Validate file size
*/
function validateFileSize(filepath, extension) {
const issues = [];
const warnings = [];
const stats = fs.statSync(filepath);
const size = stats.size;
let limits;
if (RULES.formats.video.includes(extension)) {
limits = RULES.fileSize.video;
} else if (extension === "svg") {
limits = RULES.fileSize.svg;
} else {
limits = RULES.fileSize.image;
}
if (size > limits.max) {
issues.push(
`File size (${formatBytes(size)}) exceeds maximum (${formatBytes(
limits.max
)})`
);
} else if (size > limits.recommended) {
warnings.push(
`File size (${formatBytes(size)}) exceeds recommended (${formatBytes(
limits.recommended
)})`
);
}
return { valid: issues.length === 0, issues, warnings, size };
}
/**
* Validate file format
*/
function validateFormat(extension) {
const issues = [];
const info = { category: null };
const allFormats = [
...RULES.formats.image,
...RULES.formats.vector,
...RULES.formats.video,
...RULES.formats.document,
];
if (!allFormats.includes(extension)) {
issues.push(`Unsupported file format: .${extension}`);
return { valid: false, issues, info };
}
// Determine category
if (RULES.formats.image.includes(extension)) info.category = "image";
else if (RULES.formats.vector.includes(extension)) info.category = "vector";
else if (RULES.formats.video.includes(extension)) info.category = "video";
else if (RULES.formats.document.includes(extension))
info.category = "document";
return { valid: true, issues, info };
}
/**
* Check if asset exists in manifest
*/
function checkManifest(filepath) {
const manifestPath = path.join(process.cwd(), ".assets", "manifest.json");
if (!fs.existsSync(manifestPath)) {
return { registered: false, message: "Manifest not found" };
}
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
const relativePath = path.relative(process.cwd(), filepath);
const found = manifest.assets?.find(
(a) => a.path === relativePath || a.path === filepath
);
return {
registered: !!found,
message: found ? "Asset registered in manifest" : "Asset not in manifest",
asset: found,
};
} catch {
return { registered: false, message: "Error reading manifest" };
}
}
/**
* Generate suggested filename
*/
function suggestFilename(original, parsed) {
if (!parsed) return null;
const today = new Date().toISOString().slice(0, 10).replace(/-/g, "");
const type = parsed.type || "asset";
const campaign = parsed.campaign || "general";
const description = parsed.description || "untitled";
const ext = parsed.extension || "png";
return `${type}_${campaign}_${description}_${today}.${ext}`;
}
/**
* Format bytes to human readable
*/
function formatBytes(bytes) {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
/**
* Main validation function
*/
function validateAsset(assetPath) {
const results = {
path: assetPath,
filename: path.basename(assetPath),
valid: true,
issues: [],
warnings: [],
suggestions: [],
checks: {},
};
// Check file exists
if (!fs.existsSync(assetPath)) {
results.valid = false;
results.issues.push(`File not found: ${assetPath}`);
return results;
}
const filename = path.basename(assetPath);
const extension = path.extname(filename).slice(1).toLowerCase();
// 1. Validate filename
const filenameResult = validateFilename(filename);
results.checks.filename = filenameResult;
if (!filenameResult.valid) {
results.issues.push(...filenameResult.issues);
results.suggestions.push(...filenameResult.suggestions);
}
// 2. Validate format
const formatResult = validateFormat(extension);
results.checks.format = formatResult;
if (!formatResult.valid) {
results.issues.push(...formatResult.issues);
}
// 3. Validate file size
const sizeResult = validateFileSize(assetPath, extension);
results.checks.fileSize = sizeResult;
if (!sizeResult.valid) {
results.issues.push(...sizeResult.issues);
}
results.warnings.push(...sizeResult.warnings);
// 4. Check manifest registration
const manifestResult = checkManifest(assetPath);
results.checks.manifest = manifestResult;
if (!manifestResult.registered) {
results.warnings.push("Asset not registered in manifest.json");
results.suggestions.push(
"Register asset in .assets/manifest.json for tracking"
);
}
// 5. Suggest corrected filename if needed
if (!filenameResult.valid && filenameResult.parsed) {
const suggested = suggestFilename(filename, filenameResult.parsed);
if (suggested) {
results.suggestions.push(`Suggested filename: ${suggested}`);
}
}
// Overall validity
results.valid = results.issues.length === 0;
return results;
}
/**
* Format output for console
*/
function formatOutput(results) {
const lines = [];
lines.push("\n" + "=".repeat(60));
lines.push(`ASSET VALIDATION: ${results.filename}`);
lines.push("=".repeat(60));
lines.push(`\nStatus: ${results.valid ? "PASS" : "FAIL"}`);
lines.push(`Path: ${results.path}`);
if (results.issues.length > 0) {
lines.push("\nISSUES:");
results.issues.forEach((issue) => lines.push(` - ${issue}`));
}
if (results.warnings.length > 0) {
lines.push("\nWARNINGS:");
results.warnings.forEach((warning) => lines.push(` - ${warning}`));
}
if (results.suggestions.length > 0) {
lines.push("\nSUGGESTIONS:");
results.suggestions.forEach((suggestion) =>
lines.push(` - ${suggestion}`)
);
}
// File size info
if (results.checks.fileSize?.size) {
lines.push(`\nFile Size: ${formatBytes(results.checks.fileSize.size)}`);
}
lines.push("\n" + "=".repeat(60));
return lines.join("\n");
}
/**
* Main
*/
function main() {
const args = process.argv.slice(2);
const jsonOutput = args.includes("--json");
const assetPath = args.find((a) => !a.startsWith("--"));
if (!assetPath) {
console.error("Usage: node validate-asset.cjs <asset-path> [--json]");
console.error("\nExamples:");
console.error(
" node validate-asset.cjs assets/banners/social-media/banner_launch_hero_20251209.png"
);
console.error(
" node validate-asset.cjs assets/logos/icon-only/logo-icon.svg --json"
);
process.exit(1);
}
// Resolve path
const resolvedPath = path.isAbsolute(assetPath)
? assetPath
: path.join(process.cwd(), assetPath);
// Validate
const results = validateAsset(resolvedPath);
// Output
if (jsonOutput) {
console.log(JSON.stringify(results, null, 2));
} else {
console.log(formatOutput(results));
}
// Exit with appropriate code
process.exit(results.valid ? 0 : 1);
}
main();