Add sub-skills from ui-ux-pro-max-skill repo

Added:
- banner-design (new)
- brand (new)
- design-system (new)
- slides (new)
- ui-ux-pro-max data/scripts (from GitHub clone)
- ui-styling data/scripts (from GitHub clone)
This commit is contained in:
2026-04-16 18:33:32 +07:00
parent b26c8199a5
commit da5f964d9a
87 changed files with 18254 additions and 2 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,266 @@
#!/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 { execSync } = 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: {} }
};
// 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;
}
}
// 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;
}
}
// 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;
}
}
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) {
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
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
);
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) {
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 {
execSync(`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,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();