Files
greyhaven-design-system/scripts/generate-skill.ts
2026-04-13 15:46:38 -05:00

324 lines
12 KiB
TypeScript

#!/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
- **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/<name>`.\n')
const categories = new Map<string, typeof COMPONENT_CATALOG>()
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-16\` between major page sections
- **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**: Orange ONLY for primary actions and key emphasis -- never decorative
- **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<typeof myComponentVariants>) {
return (
<div
data-slot="my-component"
className={cn(myComponentVariants({ variant, size, className }))}
{...props}
/>
)
}
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.
`,
'---\n',
buildDesignPhilosophy(),
'---\n',
buildFontSetup(),
'---\n',
buildTokenReference(),
'---\n',
buildComponentCatalog(),
'---\n',
buildCompositionRules(),
'---\n',
buildExtensionProtocol(),
].join('\n')
}
// ---------------------------------------------------------------------------
// AGENT.md (non-Claude AI agents: Cursor, Copilot, Windsurf, etc.)
// ---------------------------------------------------------------------------
function generateAgent(): string {
return [
`# Greyhaven Design System
> **Auto-generated** by \`scripts/generate-skill.ts\` -- DO NOT EDIT by hand.
> Re-generate: \`pnpm skill:build\` in the design system repo.
>
> This file provides AI coding assistants (Cursor, GitHub Copilot, Windsurf,
> Codeium, etc.) with full context about the Greyhaven Design System.
## How to Use This
When building UI in this project, follow the Greyhaven Design System:
- Import components from \`components/ui/\` (or \`@/components/ui/\` with alias)
- Use semantic Tailwind classes (\`bg-primary\`, \`text-foreground\`, \`border-border\`) -- never raw hex colors
- Use \`font-sans\` (Aspekta) for UI elements, \`font-serif\` (Source Serif) for content
- Orange (\`#D95E2A\` / \`bg-primary\`) is the ONLY accent color -- use sparingly
- All components are framework-agnostic React (no Next.js imports)
`,
'---\n',
buildDesignPhilosophy(),
'---\n',
buildFontSetup(),
'---\n',
buildTokenReference(),
'---\n',
buildComponentCatalog(),
'---\n',
buildCompositionRules(),
'---\n',
buildExtensionProtocol(),
].join('\n')
}
// ---------------------------------------------------------------------------
// 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()