251 lines
8.2 KiB
TypeScript
251 lines
8.2 KiB
TypeScript
#!/usr/bin/env npx tsx
|
|
|
|
/**
|
|
* Generates skill/SKILL.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 SKILL.md always stays in sync.
|
|
*/
|
|
|
|
import * as fs from 'fs'
|
|
import * as path from 'path'
|
|
import { fileURLToPath } from 'url'
|
|
import {
|
|
COMPONENT_CATALOG,
|
|
getTokens,
|
|
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 hex(token: FlatToken): string {
|
|
const v = token.value
|
|
return typeof v === 'string' ? v : JSON.stringify(v)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Build sections
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function buildHeader(): 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.
|
|
`
|
|
}
|
|
|
|
function buildDesignPhilosophy(): string {
|
|
return `
|
|
---
|
|
|
|
## 1. 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/Inter (sans) 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 buildTokenReference(): string {
|
|
const lines: string[] = []
|
|
lines.push('\n---\n')
|
|
lines.push('## 2. 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('\n---\n')
|
|
lines.push(`## 3. Component Catalog (${componentCount()} components)\n`)
|
|
lines.push('All components live in `components/ui/`. Import with `@/components/ui/<name>`.\n')
|
|
|
|
// Group by category
|
|
const categories = new Map<string, typeof COMPONENT_CATALOG>()
|
|
for (const c of COMPONENT_CATALOG) {
|
|
const cat = c.category
|
|
if (!categories.has(cat)) categories.set(cat, [])
|
|
categories.get(cat)!.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 `
|
|
---
|
|
|
|
## 4. 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 `
|
|
---
|
|
|
|
## 5. 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 }
|
|
\`\`\`
|
|
`
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function main() {
|
|
const skill = [
|
|
buildHeader(),
|
|
buildDesignPhilosophy(),
|
|
buildTokenReference(),
|
|
buildComponentCatalog(),
|
|
buildCompositionRules(),
|
|
buildExtensionProtocol(),
|
|
].join('\n')
|
|
|
|
const outPath = path.join(ROOT, 'skill', 'SKILL.md')
|
|
fs.mkdirSync(path.dirname(outPath), { recursive: true })
|
|
fs.writeFileSync(outPath, skill, 'utf-8')
|
|
|
|
const lineCount = skill.split('\n').length
|
|
console.log(`skill/SKILL.md generated (${lineCount} lines, ${componentCount()} components)`)
|
|
}
|
|
|
|
main()
|