#!/usr/bin/env npx tsx /** * Generates skill/SKILL.md and skill/AGENT.md from the shared component * catalog and W3C DTCG token files. Run via `pnpm skill:build`. * * Both the MCP server and this script read from lib/catalog.ts and * tokens/*.json, so all outputs stay in sync. * * Outputs: * skill/SKILL.md — Claude Code skill (loaded via .claude/skills/) * skill/AGENT.md — Generic AI agent instructions (Cursor, Copilot, etc.) */ import * as fs from 'fs' import * as path from 'path' import { fileURLToPath } from 'url' import { COMPONENT_CATALOG, loadTokenFile, flattenTokens, TOKEN_CATEGORIES, type FlatToken, } from '../lib/catalog.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const ROOT = path.resolve(__dirname, '..') // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function tokenTable(tokens: FlatToken[]): string { const lines = ['| Token | Value | Description |', '|-------|-------|-------------|'] for (const t of tokens) { const val = typeof t.value === 'object' ? JSON.stringify(t.value) : String(t.value) lines.push(`| \`${t.path}\` | \`${val}\` | ${t.description || ''} |`) } return lines.join('\n') } function componentCount(): number { return COMPONENT_CATALOG.length } // --------------------------------------------------------------------------- // Shared content blocks (used by both SKILL.md and AGENT.md) // --------------------------------------------------------------------------- function buildDesignPhilosophy(): string { return `## Design Philosophy - **TypeScript only**: All code MUST be written in TypeScript (\`.tsx\` / \`.ts\`). Never generate plain JavaScript (\`.jsx\` / \`.js\`). - **Minimal and restrained**: Off-white + off-black + warm greys. One single accent color (orange). No gradients, no decorative color, no multiple accent hues. - **Typography-driven**: Source Serif 4/Pro (serif) for headings and body content. Aspekta (sans, self-hosted) for UI labels, buttons, navigation, and form elements. - **Calm, professional aesthetic**: Tight border-radii, subtle shadows, generous whitespace. - **Accessibility-first**: Built on Radix UI primitives for keyboard navigation, focus management, screen reader support. Visible focus rings, disabled states, ARIA attributes. - **Dark mode native**: Thoughtful dark theme using inverted warm greys. Orange accent persists across both modes. Toggled via \`.dark\` class. - **Framework-agnostic**: Pure React + Radix + Tailwind. No Next.js, no framework-specific imports. ` } function buildFontSetup(): string { return `## Font Setup This design system uses two typefaces: | Role | Font | Usage | |------|------|-------| | **Sans (UI)** | Aspekta (self-hosted) | Buttons, nav, labels, forms, metadata | | **Serif (Content)** | Source Serif 4/Pro | Headings, body text, reading content | ### Aspekta (required) Aspekta font files live in \`public/fonts/\`. Add \`@font-face\` declarations to your global CSS: \`\`\`css /* Minimum set (covers font-weight 400-700) */ @font-face { font-family: 'Aspekta'; font-weight: 400; font-display: swap; src: url('/fonts/Aspekta-400.woff2') format('woff2'); } @font-face { font-family: 'Aspekta'; font-weight: 500; font-display: swap; src: url('/fonts/Aspekta-500.woff2') format('woff2'); } @font-face { font-family: 'Aspekta'; font-weight: 600; font-display: swap; src: url('/fonts/Aspekta-600.woff2') format('woff2'); } @font-face { font-family: 'Aspekta'; font-weight: 700; font-display: swap; src: url('/fonts/Aspekta-700.woff2') format('woff2'); } /* Or import all weights: */ @import url('/fonts/font-face.css'); \`\`\` ### Font stack CSS variables \`\`\`css --font-sans: 'Aspekta', ui-sans-serif, system-ui, sans-serif; --font-serif: 'Source Serif 4', 'Source Serif Pro', Georgia, serif; \`\`\` ### Tailwind usage - \`font-sans\` — Aspekta (UI elements) - \`font-serif\` — Source Serif (content) Install fonts via: \`./skill/install.sh /path/to/your/project\` ` } function buildTokenReference(): string { const lines: string[] = [] lines.push('## Token Quick Reference\n') lines.push('Source of truth: `tokens/*.json` (W3C DTCG format).\n') for (const cat of TOKEN_CATEGORIES) { try { const data = loadTokenFile(ROOT, cat) const tokens = flattenTokens(data) if (tokens.length === 0) continue const title = cat.charAt(0).toUpperCase() + cat.slice(1) lines.push(`### ${title}\n`) lines.push(tokenTable(tokens)) lines.push('') } catch { // skip missing } } return lines.join('\n') } function buildComponentCatalog(): string { const lines: string[] = [] lines.push(`## Component Catalog (${componentCount()} components)\n`) lines.push('All components live in `components/ui/`. Import with `@/components/ui/`.\n') const categories = new Map() for (const c of COMPONENT_CATALOG) { if (!categories.has(c.category)) categories.set(c.category, []) categories.get(c.category)!.push(c) } const categoryOrder = ['primitives', 'layout', 'overlay', 'navigation', 'data', 'feedback', 'form', 'composition'] for (const cat of categoryOrder) { const components = categories.get(cat) if (!components) continue const title = cat.charAt(0).toUpperCase() + cat.slice(1) lines.push(`### ${title}\n`) for (const c of components) { lines.push(`#### ${c.name}`) lines.push(`- **File**: \`${c.file}\``) lines.push(`- **Exports**: \`${c.exports.join('`, `')}\``) lines.push(`- **Description**: ${c.description}`) lines.push(`- **Props**: \`${c.props}\``) lines.push(`- **Example**:`) lines.push('```tsx') lines.push(c.example) lines.push('```') lines.push('') } } return lines.join('\n') } function buildCompositionRules(): string { return `## Composition Rules - **Card spacing**: \`gap-6\` between cards, \`p-6\` internal padding - **Section rhythm**: \`py-10\` internal padding per section. Colored sections add \`my-8\` to detach from neighbors - **Button placement**: Primary action right, secondary left - **Form layout**: Vertical stack with \`gap-4\`, labels above inputs - **Navbar**: Fixed top, \`z-50\`, \`h-16\`, logo left, nav center, actions right - **Typography pairing**: Serif (\`font-serif\`) for content headings, sans (\`font-sans\`) for UI labels/buttons - **Color restraint**: Trust the default component variants for orange accent -- they apply it at the right scale. Don't apply \`bg-primary\` to large surfaces, containers, or section backgrounds - **Focus pattern**: \`focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]\` - **Disabled pattern**: \`disabled:pointer-events-none disabled:opacity-50\` - **Aria-invalid pattern**: \`aria-invalid:ring-destructive/20 aria-invalid:border-destructive\` - **Slot naming**: All components use \`data-slot="component-name"\` - **Icon sizing**: \`[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0\` ` } function buildExtensionProtocol(): string { return `## Extension Protocol When adding new components to the system: 1. **Use CVA** for variants (\`class-variance-authority\`) 2. **Accept HTML element props** via spread: \`React.ComponentProps<'div'>\` 3. **Use \`data-slot\`** attribute: \`data-slot="component-name"\` 4. **Use \`cn()\`** from \`@/lib/utils\` for class merging 5. **Follow focus/disabled/aria patterns** from existing components 6. **Use semantic tokens only** -- never raw hex colors 7. **Support \`asChild\`** via \`@radix-ui/react-slot\` for polymorphism where appropriate 8. **Add to Storybook** with \`tags: ['autodocs']\` and all variant stories 9. **Add to \`lib/catalog.ts\`** so MCP server and SKILL.md pick it up automatically 10. **Run \`pnpm skill:build\`** to regenerate this file ### Template \`\`\`tsx import * as React from 'react' import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/utils' const myComponentVariants = cva('base-classes', { variants: { variant: { default: 'default-classes' }, size: { default: 'size-classes' }, }, defaultVariants: { variant: 'default', size: 'default' }, }) function MyComponent({ className, variant, size, ...props }: React.ComponentProps<'div'> & VariantProps) { return (
) } export { MyComponent, myComponentVariants } \`\`\` ` } // --------------------------------------------------------------------------- // SKILL.md (Claude Code) // --------------------------------------------------------------------------- function generateSkill(): string { return [ `# Greyhaven Design System -- Claude Skill > **Auto-generated** by \`scripts/generate-skill.ts\` -- DO NOT EDIT by hand. > Re-generate: \`pnpm skill:build\` > > **Components**: ${componentCount()} | **Style**: shadcn/ui "new-york" > **Stack**: React 19, Radix UI, Tailwind CSS v4, CVA, tailwind-merge, clsx, Lucide icons > **Framework-agnostic**: No Next.js imports. Works with Vite, Remix, Astro, CRA, or any React setup. This skill gives you full context to generate pixel-perfect, on-brand UI using the Greyhaven Design System. Every component lives in \`components/ui/\`. Use semantic tokens, never raw colors. Follow the patterns exactly. **ALWAYS use TypeScript (.tsx/.ts) — never plain JavaScript.** `, '---\n', buildDesignPhilosophy(), '---\n', buildFontSetup(), '---\n', buildTokenReference(), '---\n', buildComponentCatalog(), '---\n', buildCompositionRules(), '---\n', buildExtensionProtocol(), ].join('\n') } // --------------------------------------------------------------------------- // AGENT.md (project-level instructions for non-Claude AI agents) // --------------------------------------------------------------------------- function generateAgent(): string { // Count components by category for the summary const categories = new Map() for (const c of COMPONENT_CATALOG) { categories.set(c.category, (categories.get(c.category) || 0) + 1) } const categorySummary = Array.from(categories.entries()) .map(([cat, count]) => `${cat} (${count})`) .join(', ') return `# Project Instructions > **Auto-generated** by the Greyhaven Design System. > Re-generate: \`pnpm skill:build\` in the design system repo. > > Copy this file to your project root as \`AGENT.md\`, \`.cursorrules\`, > or \`.github/copilot-instructions.md\` depending on your AI tool. This project uses the **Greyhaven Design System**. ## Rules - **ALWAYS use TypeScript** (\`.tsx\` / \`.ts\`). NEVER generate plain JavaScript (\`.jsx\` / \`.js\`). - Use the \`greyhaven\` SKILL.md for full design system context (tokens, components, composition rules). It should be installed at \`.claude/skills/greyhaven-design-system.md\` or accessible to your AI tool. - If the \`greyhaven\` MCP server is available, use its tools: - \`list_components()\` to find the right component for a UI need - \`get_component(name)\` to get exact props, variants, and usage examples - \`validate_colors(code)\` to check code for off-brand colors - \`suggest_component(description)\` to get recommendations - Import components from \`components/ui/\` (or \`@/components/ui/\` with path alias) - Never use raw hex colors -- use semantic Tailwind classes (\`bg-primary\`, \`text-foreground\`, \`border-border\`, etc.) - Use \`font-sans\` (Aspekta) for UI elements: buttons, nav, labels, forms - Use \`font-serif\` (Source Serif) for content: headings, body text - Trust the design system's default component variants for accent -- they apply orange at the right scale. Don't apply \`bg-primary\` to large surfaces, containers, or section backgrounds - All components are framework-agnostic React (no Next.js, no framework-specific imports) - Dark mode is toggled via the \`.dark\` class -- use semantic tokens that adapt automatically ## Component Summary ${componentCount()} components across ${categories.size} categories: ${categorySummary}. For full component specs, props, and examples, refer to the SKILL.md file or use the MCP \`get_component(name)\` tool. ## Key Patterns - **CVA variants**: Components use \`class-variance-authority\` for variant props - **Slot composition**: Components use \`data-slot="name"\` attributes - **Class merging**: Always use \`cn()\` from \`@/lib/utils\` (clsx + tailwind-merge) - **Focus rings**: \`focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]\` - **Disabled**: \`disabled:pointer-events-none disabled:opacity-50\` - **Card spacing**: \`gap-6\` between cards, \`p-6\` internal padding - **Section rhythm**: \`py-16\` between major sections - **Form layout**: Vertical stack with \`gap-4\`, labels above inputs ## Font Setup If fonts aren't loaded yet, add to your global CSS: \`\`\`css @font-face { font-family: 'Aspekta'; font-weight: 400; font-display: swap; src: url('/fonts/Aspekta-400.woff2') format('woff2'); } @font-face { font-family: 'Aspekta'; font-weight: 500; font-display: swap; src: url('/fonts/Aspekta-500.woff2') format('woff2'); } @font-face { font-family: 'Aspekta'; font-weight: 600; font-display: swap; src: url('/fonts/Aspekta-600.woff2') format('woff2'); } @font-face { font-family: 'Aspekta'; font-weight: 700; font-display: swap; src: url('/fonts/Aspekta-700.woff2') format('woff2'); } \`\`\` ` } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- function main() { const outDir = path.join(ROOT, 'skill') fs.mkdirSync(outDir, { recursive: true }) // SKILL.md const skill = generateSkill() const skillPath = path.join(outDir, 'SKILL.md') fs.writeFileSync(skillPath, skill, 'utf-8') const skillLines = skill.split('\n').length console.log(`skill/SKILL.md generated (${skillLines} lines, ${componentCount()} components)`) // AGENT.md const agent = generateAgent() const agentPath = path.join(outDir, 'AGENT.md') fs.writeFileSync(agentPath, agent, 'utf-8') const agentLines = agent.split('\n').length console.log(`skill/AGENT.md generated (${agentLines} lines, ${componentCount()} components)`) } main()