diff --git a/README.md b/README.md
index 73ce57f..2a63c0b 100644
--- a/README.md
+++ b/README.md
@@ -55,20 +55,34 @@ greyhaven-design-system/
## Using the Design System with AI
-The design system provides two complementary ways for AI agents to consume it:
+The design system provides three complementary ways for AI agents to consume it:
-| | Claude Skill (SKILL.md) | MCP Server |
-|---|---|---|
-| **What it is** | A single markdown file with all tokens, components, and rules | A running process that reads source files in real-time |
-| **Stays in sync** | Yes -- auto-generated from the same token files and component catalog | Yes -- reads source at runtime |
-| **Setup** | Copy/symlink one file | Start a server process |
-| **Best for** | Claude Code sessions, quick context | Programmatic access, validation, any MCP-compatible agent |
+| | Claude Skill (SKILL.md) | AGENT.md | MCP Server |
+|---|---|---|---|
+| **What it is** | Claude Code skill file | Generic AI agent instructions | Running process with tools |
+| **Stays in sync** | Yes -- auto-generated | Yes -- auto-generated | Yes -- reads source at runtime |
+| **Setup** | Symlink to `.claude/skills/` | Symlink to project root | Configure in settings |
+| **Best for** | Claude Code | Cursor, Copilot, Windsurf, Codeium | Programmatic access, validation |
-Both read from the same sources (`tokens/*.json` and `lib/catalog.ts`), so they always agree.
+All three read from the same sources (`tokens/*.json` and `lib/catalog.ts`), so they always agree.
+
+### Quick Install (all at once)
+
+The install script sets up everything -- Claude Skill, AGENT.md, and fonts:
+
+```bash
+./skill/install.sh /path/to/your/project
+```
+
+This will:
+1. Symlink `SKILL.md` into `.claude/skills/` (for Claude Code)
+2. Symlink `AGENT.md` into the project root (for Cursor, Copilot, etc.)
+3. Copy Aspekta font files into `public/fonts/`
+4. Print CSS import instructions
### Option A: Claude Skill (SKILL.md)
-The skill file gives any Claude Code session full design system context -- tokens, all components with props/examples, composition rules, and the extension protocol.
+The skill file gives any Claude Code session full design system context -- tokens, all components with props/examples, composition rules, font setup, and the extension protocol.
**Install into a consuming project:**
@@ -90,7 +104,24 @@ pnpm skill:build
This is run automatically as part of `pnpm build`. If you add a component, add it to `lib/catalog.ts` and regenerate.
-### Option B: MCP Server
+### Option B: AGENT.md (non-Claude AI agents)
+
+For Cursor, GitHub Copilot, Windsurf, Codeium, and other AI coding assistants that read project-root markdown files:
+
+```bash
+# Via install script (also sets up fonts + Claude Skill)
+./skill/install.sh /path/to/your/project
+
+# Or manually
+ln -sf /absolute/path/to/greyhaven-design-system/skill/AGENT.md \
+ /path/to/your/project/AGENT.md
+```
+
+Some tools use different file names. You can also symlink as:
+- `.cursorrules` (Cursor)
+- `.github/copilot-instructions.md` (GitHub Copilot)
+
+### Option C: MCP Server
The MCP server provides 5 tools for programmatic access:
diff --git a/app/globals.css b/app/globals.css
index 49be907..57d21c2 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -3,6 +3,17 @@
@import './tokens/tokens-light.css';
@import './tokens/tokens-dark.css';
+/* Aspekta — self-hosted sans font (canonical UI typeface) */
+@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 100; font-display: swap; src: url('/fonts/Aspekta-100.woff2') format('woff2'); }
+@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 200; font-display: swap; src: url('/fonts/Aspekta-200.woff2') format('woff2'); }
+@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 300; font-display: swap; src: url('/fonts/Aspekta-300.woff2') format('woff2'); }
+@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 400; font-display: swap; src: url('/fonts/Aspekta-400.woff2') format('woff2'); }
+@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 500; font-display: swap; src: url('/fonts/Aspekta-500.woff2') format('woff2'); }
+@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 600; font-display: swap; src: url('/fonts/Aspekta-600.woff2') format('woff2'); }
+@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 700; font-display: swap; src: url('/fonts/Aspekta-700.woff2') format('woff2'); }
+@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 800; font-display: swap; src: url('/fonts/Aspekta-800.woff2') format('woff2'); }
+@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 900; font-display: swap; src: url('/fonts/Aspekta-900.woff2') format('woff2'); }
+
@custom-variant dark (&:is(.dark *));
/* =============================================================================
@@ -30,8 +41,8 @@
============================================================================= */
@theme inline {
- /* Typography — Aspekta is the canonical sans font, Inter is fallback */
- --font-sans: 'Aspekta', var(--font-inter, 'Inter'), ui-sans-serif, system-ui, sans-serif;
+ /* Typography — Aspekta (self-hosted) is the canonical sans font */
+ --font-sans: 'Aspekta', ui-sans-serif, system-ui, sans-serif;
--font-serif: var(--font-source-serif, 'Source Serif 4'), 'Source Serif Pro', Georgia, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
diff --git a/app/layout.tsx b/app/layout.tsx
index b9a7b25..2aa863b 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,23 +1,18 @@
import type { Metadata } from 'next'
-import { Source_Serif_4, Inter } from 'next/font/google'
+import { Source_Serif_4 } from 'next/font/google'
import './globals.css'
// Primary typeface: Source Serif Pro (using Source Serif 4 which is the updated version)
// Used for headings, body text, and reading content
-const sourceSerif = Source_Serif_4({
+const sourceSerif = Source_Serif_4({
subsets: ["latin"],
variable: '--font-source-serif',
display: 'swap',
})
-// Secondary typeface: Inter (Aspekta alternative from Google Fonts)
-// Aspekta is the brand typeface, Inter is a suitable system alternative
+// Secondary typeface: Aspekta (self-hosted in public/fonts/)
+// Loaded via @font-face in globals.css — no Next.js font loader needed
// Used for UI labels, nav, buttons, small utility text
-const inter = Inter({
- subsets: ["latin"],
- variable: '--font-inter',
- display: 'swap',
-})
export const metadata: Metadata = {
title: 'Greyhaven Design System',
@@ -48,7 +43,7 @@ export default function RootLayout({
children: React.ReactNode
}>) {
return (
-
+
{children}
diff --git a/app/tokens/TOKENS.md b/app/tokens/TOKENS.md
index de5168b..76956f3 100644
--- a/app/tokens/TOKENS.md
+++ b/app/tokens/TOKENS.md
@@ -144,7 +144,7 @@
| Token | Value | Description |
|-------|-------|-------------|
-| `typography.fontFamily.sans` | `Aspekta, Inter, ui-sans-serif, system-ui, sans-serif` | UI labels, buttons, nav, forms — Aspekta primary, Inter fallback |
+| `typography.fontFamily.sans` | `Aspekta, ui-sans-serif, system-ui, sans-serif` | UI labels, buttons, nav, forms — Aspekta self-hosted |
| `typography.fontFamily.serif` | `'Source Serif 4', 'Source Serif Pro', Georgia, serif` | Headings, body content, reading — Source Serif primary |
| `typography.fontFamily.mono` | `ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace` | Code blocks and monospaced content |
| `typography.fontSize.xs` | `0.75rem` | 12px — metadata, fine print |
diff --git a/public/fonts/Aspekta-100.woff2 b/public/fonts/Aspekta-100.woff2
new file mode 100644
index 0000000..1a64458
Binary files /dev/null and b/public/fonts/Aspekta-100.woff2 differ
diff --git a/public/fonts/Aspekta-1000.woff2 b/public/fonts/Aspekta-1000.woff2
new file mode 100644
index 0000000..56a8de5
Binary files /dev/null and b/public/fonts/Aspekta-1000.woff2 differ
diff --git a/public/fonts/Aspekta-150.woff2 b/public/fonts/Aspekta-150.woff2
new file mode 100644
index 0000000..9dbf773
Binary files /dev/null and b/public/fonts/Aspekta-150.woff2 differ
diff --git a/public/fonts/Aspekta-200.woff2 b/public/fonts/Aspekta-200.woff2
new file mode 100644
index 0000000..47fbe73
Binary files /dev/null and b/public/fonts/Aspekta-200.woff2 differ
diff --git a/public/fonts/Aspekta-250.woff2 b/public/fonts/Aspekta-250.woff2
new file mode 100644
index 0000000..aa4b382
Binary files /dev/null and b/public/fonts/Aspekta-250.woff2 differ
diff --git a/public/fonts/Aspekta-300.woff2 b/public/fonts/Aspekta-300.woff2
new file mode 100644
index 0000000..455e882
Binary files /dev/null and b/public/fonts/Aspekta-300.woff2 differ
diff --git a/public/fonts/Aspekta-350.woff2 b/public/fonts/Aspekta-350.woff2
new file mode 100644
index 0000000..f2e25ea
Binary files /dev/null and b/public/fonts/Aspekta-350.woff2 differ
diff --git a/public/fonts/Aspekta-400.woff2 b/public/fonts/Aspekta-400.woff2
new file mode 100644
index 0000000..764b50c
Binary files /dev/null and b/public/fonts/Aspekta-400.woff2 differ
diff --git a/public/fonts/Aspekta-450.woff2 b/public/fonts/Aspekta-450.woff2
new file mode 100644
index 0000000..ca48199
Binary files /dev/null and b/public/fonts/Aspekta-450.woff2 differ
diff --git a/public/fonts/Aspekta-50.woff2 b/public/fonts/Aspekta-50.woff2
new file mode 100644
index 0000000..fb1e140
Binary files /dev/null and b/public/fonts/Aspekta-50.woff2 differ
diff --git a/public/fonts/Aspekta-500.woff2 b/public/fonts/Aspekta-500.woff2
new file mode 100644
index 0000000..ba95918
Binary files /dev/null and b/public/fonts/Aspekta-500.woff2 differ
diff --git a/public/fonts/Aspekta-550.woff2 b/public/fonts/Aspekta-550.woff2
new file mode 100644
index 0000000..5d84c2c
Binary files /dev/null and b/public/fonts/Aspekta-550.woff2 differ
diff --git a/public/fonts/Aspekta-600.woff2 b/public/fonts/Aspekta-600.woff2
new file mode 100644
index 0000000..ea5728e
Binary files /dev/null and b/public/fonts/Aspekta-600.woff2 differ
diff --git a/public/fonts/Aspekta-650.woff2 b/public/fonts/Aspekta-650.woff2
new file mode 100644
index 0000000..8f72f51
Binary files /dev/null and b/public/fonts/Aspekta-650.woff2 differ
diff --git a/public/fonts/Aspekta-700.woff2 b/public/fonts/Aspekta-700.woff2
new file mode 100644
index 0000000..b9342d8
Binary files /dev/null and b/public/fonts/Aspekta-700.woff2 differ
diff --git a/public/fonts/Aspekta-750.woff2 b/public/fonts/Aspekta-750.woff2
new file mode 100644
index 0000000..57e36c9
Binary files /dev/null and b/public/fonts/Aspekta-750.woff2 differ
diff --git a/public/fonts/Aspekta-800.woff2 b/public/fonts/Aspekta-800.woff2
new file mode 100644
index 0000000..72fea9f
Binary files /dev/null and b/public/fonts/Aspekta-800.woff2 differ
diff --git a/public/fonts/Aspekta-850.woff2 b/public/fonts/Aspekta-850.woff2
new file mode 100644
index 0000000..df40fbf
Binary files /dev/null and b/public/fonts/Aspekta-850.woff2 differ
diff --git a/public/fonts/Aspekta-900.woff2 b/public/fonts/Aspekta-900.woff2
new file mode 100644
index 0000000..6e97574
Binary files /dev/null and b/public/fonts/Aspekta-900.woff2 differ
diff --git a/public/fonts/Aspekta-950.woff2 b/public/fonts/Aspekta-950.woff2
new file mode 100644
index 0000000..a463756
Binary files /dev/null and b/public/fonts/Aspekta-950.woff2 differ
diff --git a/public/fonts/font-face.css b/public/fonts/font-face.css
new file mode 100644
index 0000000..b116bb2
--- /dev/null
+++ b/public/fonts/font-face.css
@@ -0,0 +1,161 @@
+/*! Aspekta | OFL v1.1 License | Ivo Dolenc (c) 2025 | https://github.com/ivodolenc/aspekta */
+
+@font-face {
+ font-family: 'Aspekta';
+ font-style: normal;
+ font-weight: 50;
+ font-display: swap;
+ src: url('Aspekta-50.woff2') format('woff2');
+}
+
+@font-face {
+ font-family: 'Aspekta';
+ font-style: normal;
+ font-weight: 100;
+ font-display: swap;
+ src: url('Aspekta-100.woff2') format('woff2');
+}
+
+@font-face {
+ font-family: 'Aspekta';
+ font-style: normal;
+ font-weight: 150;
+ font-display: swap;
+ src: url('Aspekta-150.woff2') format('woff2');
+}
+
+@font-face {
+ font-family: 'Aspekta';
+ font-style: normal;
+ font-weight: 200;
+ font-display: swap;
+ src: url('Aspekta-200.woff2') format('woff2');
+}
+
+@font-face {
+ font-family: 'Aspekta';
+ font-style: normal;
+ font-weight: 250;
+ font-display: swap;
+ src: url('Aspekta-250.woff2') format('woff2');
+}
+
+@font-face {
+ font-family: 'Aspekta';
+ font-style: normal;
+ font-weight: 300;
+ font-display: swap;
+ src: url('Aspekta-300.woff2') format('woff2');
+}
+
+@font-face {
+ font-family: 'Aspekta';
+ font-style: normal;
+ font-weight: 350;
+ font-display: swap;
+ src: url('Aspekta-350.woff2') format('woff2');
+}
+
+@font-face {
+ font-family: 'Aspekta';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url('Aspekta-400.woff2') format('woff2');
+}
+
+@font-face {
+ font-family: 'Aspekta';
+ font-style: normal;
+ font-weight: 450;
+ font-display: swap;
+ src: url('Aspekta-450.woff2') format('woff2');
+}
+
+@font-face {
+ font-family: 'Aspekta';
+ font-style: normal;
+ font-weight: 500;
+ font-display: swap;
+ src: url('Aspekta-500.woff2') format('woff2');
+}
+
+@font-face {
+ font-family: 'Aspekta';
+ font-style: normal;
+ font-weight: 550;
+ font-display: swap;
+ src: url('Aspekta-550.woff2') format('woff2');
+}
+
+@font-face {
+ font-family: 'Aspekta';
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url('Aspekta-600.woff2') format('woff2');
+}
+
+@font-face {
+ font-family: 'Aspekta';
+ font-style: normal;
+ font-weight: 650;
+ font-display: swap;
+ src: url('Aspekta-650.woff2') format('woff2');
+}
+
+@font-face {
+ font-family: 'Aspekta';
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src: url('Aspekta-700.woff2') format('woff2');
+}
+
+@font-face {
+ font-family: 'Aspekta';
+ font-style: normal;
+ font-weight: 750;
+ font-display: swap;
+ src: url('Aspekta-750.woff2') format('woff2');
+}
+
+@font-face {
+ font-family: 'Aspekta';
+ font-style: normal;
+ font-weight: 800;
+ font-display: swap;
+ src: url('Aspekta-800.woff2') format('woff2');
+}
+
+@font-face {
+ font-family: 'Aspekta';
+ font-style: normal;
+ font-weight: 850;
+ font-display: swap;
+ src: url('Aspekta-850.woff2') format('woff2');
+}
+
+@font-face {
+ font-family: 'Aspekta';
+ font-style: normal;
+ font-weight: 900;
+ font-display: swap;
+ src: url('Aspekta-900.woff2') format('woff2');
+}
+
+@font-face {
+ font-family: 'Aspekta';
+ font-style: normal;
+ font-weight: 950;
+ font-display: swap;
+ src: url('Aspekta-950.woff2') format('woff2');
+}
+
+@font-face {
+ font-family: 'Aspekta';
+ font-style: normal;
+ font-weight: 1000;
+ font-display: swap;
+ src: url('Aspekta-1000.woff2') format('woff2');
+}
diff --git a/scripts/generate-skill.ts b/scripts/generate-skill.ts
index 6df10cc..511faa1 100644
--- a/scripts/generate-skill.ts
+++ b/scripts/generate-skill.ts
@@ -1,11 +1,15 @@
#!/usr/bin/env npx tsx
/**
- * Generates skill/SKILL.md from the shared component catalog and
- * W3C DTCG token files. Run via `pnpm skill:build`.
+ * 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 SKILL.md always stays in sync.
+ * 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'
@@ -13,7 +17,6 @@ import * as path from 'path'
import { fileURLToPath } from 'url'
import {
COMPONENT_CATALOG,
- getTokens,
loadTokenFile,
flattenTokens,
TOKEN_CATEGORIES,
@@ -28,11 +31,6 @@ 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) {
@@ -47,31 +45,14 @@ function componentCount(): number {
}
// ---------------------------------------------------------------------------
-// Build sections
+// Shared content blocks (used by both SKILL.md and AGENT.md)
// ---------------------------------------------------------------------------
-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
+ 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/Inter (sans) for UI labels, buttons, navigation, and form elements.
+- **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.
@@ -79,10 +60,50 @@ function buildDesignPhilosophy(): string {
`
}
+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('\n---\n')
- lines.push('## 2. Token Quick Reference\n')
+ lines.push('## Token Quick Reference\n')
lines.push('Source of truth: `tokens/*.json` (W3C DTCG format).\n')
for (const cat of TOKEN_CATEGORIES) {
@@ -105,16 +126,13 @@ function buildTokenReference(): string {
function buildComponentCatalog(): string {
const lines: string[] = []
- lines.push('\n---\n')
- lines.push(`## 3. Component Catalog (${componentCount()} components)\n`)
+ lines.push(`## Component Catalog (${componentCount()} components)\n`)
lines.push('All components live in `components/ui/`. Import with `@/components/ui/`.\n')
- // Group by category
const categories = new Map()
for (const c of COMPONENT_CATALOG) {
- const cat = c.category
- if (!categories.has(cat)) categories.set(cat, [])
- categories.get(cat)!.push(c)
+ 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']
@@ -144,10 +162,7 @@ function buildComponentCatalog(): string {
}
function buildCompositionRules(): string {
- return `
----
-
-## 4. Composition Rules
+ return `## Composition Rules
- **Card spacing**: \`gap-6\` between cards, \`p-6\` internal padding
- **Section rhythm**: \`py-16\` between major page sections
@@ -165,10 +180,7 @@ function buildCompositionRules(): string {
}
function buildExtensionProtocol(): string {
- return `
----
-
-## 5. Extension Protocol
+ return `## Extension Protocol
When adding new components to the system:
@@ -192,24 +204,14 @@ import { cn } from '@/lib/utils'
const myComponentVariants = cva('base-classes', {
variants: {
- variant: {
- default: 'default-classes',
- },
- size: {
- default: 'size-classes',
- },
- },
- defaultVariants: {
- variant: 'default',
- size: 'default',
+ variant: { default: 'default-classes' },
+ size: { default: 'size-classes' },
},
+ defaultVariants: { variant: 'default', size: 'default' },
})
function MyComponent({
- className,
- variant,
- size,
- ...props
+ className, variant, size, ...props
}: React.ComponentProps<'div'> & VariantProps) {
return (
**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 skill = [
- buildHeader(),
- buildDesignPhilosophy(),
- buildTokenReference(),
- buildComponentCatalog(),
- buildCompositionRules(),
- buildExtensionProtocol(),
- ].join('\n')
+ const outDir = path.join(ROOT, 'skill')
+ fs.mkdirSync(outDir, { recursive: true })
- const outPath = path.join(ROOT, 'skill', 'SKILL.md')
- fs.mkdirSync(path.dirname(outPath), { recursive: true })
- fs.writeFileSync(outPath, skill, 'utf-8')
+ // 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)`)
- const lineCount = skill.split('\n').length
- console.log(`skill/SKILL.md generated (${lineCount} 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()
diff --git a/skill/AGENT.md b/skill/AGENT.md
new file mode 100644
index 0000000..44b90bb
--- /dev/null
+++ b/skill/AGENT.md
@@ -0,0 +1,693 @@
+# 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)
+
+---
+
+## 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.
+
+---
+
+## 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`
+
+---
+
+## Token Quick Reference
+
+Source of truth: `tokens/*.json` (W3C DTCG format).
+
+### Color
+
+| Token | Value | Description |
+|-------|-------|-------------|
+| `color.primitive.off-white` | `#F9F9F7` | Primary light surface — cards, elevated areas |
+| `color.primitive.off-black` | `#161614` | Primary dark — foreground text, dark mode background |
+| `color.primitive.orange` | `#D95E2A` | Only accent color — used sparingly for primary actions and emphasis |
+| `color.primitive.destructive-red` | `#B43232` | Error/danger states |
+| `color.primitive.grey.1` | `#F0F0EC` | 5% — Subtle backgrounds, secondary, muted |
+| `color.primitive.grey.2` | `#DDDDD7` | 10% — Accent hover, light borders |
+| `color.primitive.grey.3` | `#C4C4BD` | 20% — Border, input |
+| `color.primitive.grey.4` | `#A6A69F` | 50% — Mid-tone |
+| `color.primitive.grey.5` | `#7F7F79` | 60% — Mid-dark |
+| `color.primitive.grey.7` | `#575753` | 70% — Secondary foreground, muted foreground |
+| `color.primitive.grey.8` | `#2F2F2C` | 80% — Dark mode card, dark surfaces |
+| `color.semantic.background` | `{color.primitive.grey.1}` | Page background |
+| `color.semantic.foreground` | `{color.primitive.off-black}` | Primary text |
+| `color.semantic.card` | `{color.primitive.off-white}` | Card/elevated surface background |
+| `color.semantic.card-foreground` | `{color.primitive.off-black}` | Card text |
+| `color.semantic.popover` | `{color.primitive.off-white}` | Popover background |
+| `color.semantic.popover-foreground` | `{color.primitive.off-black}` | Popover text |
+| `color.semantic.primary` | `{color.primitive.orange}` | Primary accent — buttons, links, focus rings |
+| `color.semantic.primary-foreground` | `{color.primitive.off-white}` | Text on primary accent |
+| `color.semantic.secondary` | `{color.primitive.grey.1}` | Secondary button/surface |
+| `color.semantic.secondary-foreground` | `{color.primitive.grey.8}` | Text on secondary surface |
+| `color.semantic.muted` | `{color.primitive.grey.1}` | Muted/subdued background |
+| `color.semantic.muted-foreground` | `{color.primitive.grey.7}` | Muted/subdued text |
+| `color.semantic.accent` | `{color.primitive.grey.2}` | Subtle hover state |
+| `color.semantic.accent-foreground` | `{color.primitive.off-black}` | Text on accent hover |
+| `color.semantic.destructive` | `{color.primitive.destructive-red}` | Destructive/error actions |
+| `color.semantic.destructive-foreground` | `{color.primitive.off-white}` | Text on destructive |
+| `color.semantic.border` | `{color.primitive.grey.3}` | Default border |
+| `color.semantic.input` | `{color.primitive.grey.3}` | Input border |
+| `color.semantic.ring` | `{color.primitive.orange}` | Focus ring |
+| `color.semantic.chart.1` | `{color.primitive.orange}` | Chart accent |
+| `color.semantic.chart.2` | `{color.primitive.grey.7}` | Chart secondary |
+| `color.semantic.chart.3` | `{color.primitive.grey.5}` | Chart tertiary |
+| `color.semantic.chart.4` | `{color.primitive.grey.4}` | Chart quaternary |
+| `color.semantic.chart.5` | `{color.primitive.grey.8}` | Chart quinary |
+| `color.semantic.sidebar.background` | `{color.primitive.grey.1}` | Sidebar background |
+| `color.semantic.sidebar.foreground` | `{color.primitive.off-black}` | Sidebar text |
+| `color.semantic.sidebar.primary` | `{color.primitive.orange}` | Sidebar primary accent |
+| `color.semantic.sidebar.primary-foreground` | `{color.primitive.off-white}` | Sidebar primary text |
+| `color.semantic.sidebar.accent` | `{color.primitive.grey.3}` | Sidebar accent/hover |
+| `color.semantic.sidebar.accent-foreground` | `{color.primitive.off-black}` | Sidebar accent text |
+| `color.semantic.sidebar.border` | `{color.primitive.grey.3}` | Sidebar border |
+| `color.semantic.sidebar.ring` | `{color.primitive.orange}` | Sidebar focus ring |
+| `color.dark.background` | `{color.primitive.off-black}` | Dark page background |
+| `color.dark.foreground` | `{color.primitive.off-white}` | Dark primary text |
+| `color.dark.card` | `{color.primitive.grey.8}` | Dark card surface |
+| `color.dark.card-foreground` | `{color.primitive.off-white}` | Dark card text |
+| `color.dark.popover` | `{color.primitive.grey.8}` | Dark popover |
+| `color.dark.popover-foreground` | `{color.primitive.off-white}` | Dark popover text |
+| `color.dark.primary` | `{color.primitive.orange}` | Same orange in dark mode |
+| `color.dark.primary-foreground` | `{color.primitive.off-white}` | Dark primary foreground |
+| `color.dark.secondary` | `{color.primitive.grey.7}` | Dark secondary |
+| `color.dark.secondary-foreground` | `{color.primitive.off-white}` | Dark secondary text |
+| `color.dark.muted` | `{color.primitive.grey.7}` | Dark muted |
+| `color.dark.muted-foreground` | `{color.primitive.grey.3}` | Dark muted text |
+| `color.dark.accent` | `{color.primitive.grey.7}` | Dark accent/hover |
+| `color.dark.accent-foreground` | `{color.primitive.off-white}` | Dark accent text |
+| `color.dark.destructive` | `{color.primitive.destructive-red}` | Same destructive in dark mode |
+| `color.dark.destructive-foreground` | `{color.primitive.off-white}` | Dark destructive text |
+| `color.dark.border` | `{color.primitive.grey.7}` | Dark border |
+| `color.dark.input` | `{color.primitive.grey.7}` | Dark input border |
+| `color.dark.ring` | `{color.primitive.orange}` | Dark focus ring |
+| `color.dark.chart.1` | `{color.primitive.orange}` | Dark chart accent |
+| `color.dark.chart.2` | `{color.primitive.grey.3}` | Dark chart secondary |
+| `color.dark.chart.3` | `{color.primitive.grey.4}` | Dark chart tertiary |
+| `color.dark.chart.4` | `{color.primitive.grey.5}` | Dark chart quaternary |
+| `color.dark.chart.5` | `{color.primitive.grey.1}` | Dark chart quinary |
+| `color.dark.sidebar.background` | `{color.primitive.grey.8}` | Dark sidebar background |
+| `color.dark.sidebar.foreground` | `{color.primitive.off-white}` | Dark sidebar text |
+| `color.dark.sidebar.primary` | `{color.primitive.orange}` | Dark sidebar primary |
+| `color.dark.sidebar.primary-foreground` | `{color.primitive.off-white}` | Dark sidebar primary text |
+| `color.dark.sidebar.accent` | `{color.primitive.grey.7}` | Dark sidebar accent |
+| `color.dark.sidebar.accent-foreground` | `{color.primitive.off-white}` | Dark sidebar accent text |
+| `color.dark.sidebar.border` | `{color.primitive.grey.7}` | Dark sidebar border |
+| `color.dark.sidebar.ring` | `{color.primitive.orange}` | Dark sidebar ring |
+
+### Typography
+
+| Token | Value | Description |
+|-------|-------|-------------|
+| `typography.fontFamily.sans` | `["Aspekta","ui-sans-serif","system-ui","sans-serif"]` | UI labels, buttons, nav, forms — Aspekta self-hosted |
+| `typography.fontFamily.serif` | `["Source Serif 4","Source Serif Pro","Georgia","serif"]` | Headings, body content, reading — Source Serif primary |
+| `typography.fontFamily.mono` | `["ui-monospace","SFMono-Regular","Menlo","Monaco","Consolas","monospace"]` | Code blocks and monospaced content |
+| `typography.fontSize.xs` | `0.75rem` | 12px — metadata, fine print |
+| `typography.fontSize.sm` | `0.875rem` | 14px — captions, nav, labels, buttons |
+| `typography.fontSize.base` | `1rem` | 16px — body text |
+| `typography.fontSize.lg` | `1.125rem` | 18px — large body, subtitles |
+| `typography.fontSize.xl` | `1.25rem` | 20px — H3 |
+| `typography.fontSize.2xl` | `1.5rem` | 24px — H2 |
+| `typography.fontSize.3xl` | `1.875rem` | 30px — large H2 |
+| `typography.fontSize.4xl` | `2.25rem` | 36px — H1 |
+| `typography.fontSize.5xl` | `3rem` | 48px — hero heading |
+| `typography.fontWeight.normal` | `400` | Regular body text |
+| `typography.fontWeight.medium` | `500` | H3, labels, nav items |
+| `typography.fontWeight.semibold` | `600` | H1, H2, buttons |
+| `typography.fontWeight.bold` | `700` | Strong emphasis |
+| `typography.lineHeight.tight` | `1.25` | Headings |
+| `typography.lineHeight.normal` | `1.5` | Default |
+| `typography.lineHeight.relaxed` | `1.625` | Body content for readability |
+| `typography.letterSpacing.tight` | `-0.025em` | Headings — tracking-tight |
+| `typography.letterSpacing.normal` | `0em` | Body text |
+| `typography.letterSpacing.wide` | `0.05em` | Uppercase labels |
+
+### Spacing
+
+| Token | Value | Description |
+|-------|-------|-------------|
+| `spacing.0` | `0` | None |
+| `spacing.1` | `0.25rem` | 4px — tight gaps |
+| `spacing.2` | `0.5rem` | 8px — card header gap, form description spacing |
+| `spacing.3` | `0.75rem` | 12px |
+| `spacing.4` | `1rem` | 16px — form field gap, button padding |
+| `spacing.5` | `1.25rem` | 20px |
+| `spacing.6` | `1.5rem` | 24px — card padding, card internal gap |
+| `spacing.8` | `2rem` | 32px — section margin-bottom |
+| `spacing.10` | `2.5rem` | 40px |
+| `spacing.12` | `3rem` | 48px |
+| `spacing.16` | `4rem` | 64px — major section padding (py-16) |
+| `spacing.20` | `5rem` | 80px |
+| `spacing.24` | `6rem` | 96px — hero padding |
+| `spacing.0.5` | `0.125rem` | 2px — micro spacing |
+| `spacing.1.5` | `0.375rem` | 6px |
+| `spacing.component.card-padding` | `1.5rem` | Card internal padding (px-6) |
+| `spacing.component.card-gap` | `1.5rem` | Gap between cards (gap-6) |
+| `spacing.component.section-padding` | `4rem` | Vertical padding between major sections (py-16) |
+| `spacing.component.form-gap` | `1rem` | Gap between form fields (gap-4) |
+| `spacing.component.button-padding-x` | `1rem` | Button horizontal padding (px-4) |
+| `spacing.component.navbar-height` | `4rem` | Navbar height (h-16) |
+
+### Radii
+
+| Token | Value | Description |
+|-------|-------|-------------|
+| `radii.base` | `0.375rem` | 6px — base radius |
+| `radii.sm` | `calc(0.375rem - 2px)` | 4px — small variant |
+| `radii.md` | `0.375rem` | 6px — medium (same as base) |
+| `radii.lg` | `calc(0.375rem + 2px)` | 8px — large variant |
+| `radii.xl` | `calc(0.375rem + 4px)` | 10px — extra large variant (cards) |
+| `radii.full` | `9999px` | Fully round (pills, avatars) |
+
+### Shadows
+
+| Token | Value | Description |
+|-------|-------|-------------|
+| `shadow.xs` | `{"offsetX":"0","offsetY":"1px","blur":"2px","spread":"0","color":"rgba(22, 22, 20, 0.05)"}` | Subtle shadow for buttons, inputs |
+| `shadow.sm` | `{"offsetX":"0","offsetY":"1px","blur":"3px","spread":"0","color":"rgba(22, 22, 20, 0.1)"}` | Small shadow for cards |
+| `shadow.md` | `{"offsetX":"0","offsetY":"4px","blur":"6px","spread":"-1px","color":"rgba(22, 22, 20, 0.1)"}` | Medium shadow for dropdowns, popovers |
+| `shadow.lg` | `{"offsetX":"0","offsetY":"10px","blur":"15px","spread":"-3px","color":"rgba(22, 22, 20, 0.1)"}` | Large shadow for dialogs, modals |
+
+### Motion
+
+| Token | Value | Description |
+|-------|-------|-------------|
+| `motion.duration.fast` | `150ms` | Quick transitions — tooltips, hover states |
+| `motion.duration.normal` | `200ms` | Default transitions — most UI interactions |
+| `motion.duration.slow` | `300ms` | Deliberate transitions — modals, drawers, accordions |
+| `motion.easing.default` | `[0.4,0,0.2,1]` | Standard ease-in-out |
+| `motion.easing.in` | `[0.4,0,1,1]` | Ease-in for exits |
+| `motion.easing.out` | `[0,0,0.2,1]` | Ease-out for entrances |
+
+---
+
+## Component Catalog (37 components)
+
+All components live in `components/ui/`. Import with `@/components/ui/`.
+
+### Primitives
+
+#### Button
+- **File**: `components/ui/button.tsx`
+- **Exports**: `Button`, `buttonVariants`
+- **Description**: Primary interactive element. 6 variants: default (orange), secondary, outline, ghost, link, destructive. Sizes: default (h-9), sm (h-8), lg (h-10), icon (size-9).
+- **Props**: `variant?: "default" | "secondary" | "outline" | "ghost" | "link" | "destructive"; size?: "default" | "sm" | "lg" | "icon" | "icon-sm" | "icon-lg"; asChild?: boolean`
+- **Example**:
+```tsx
+
+```
+
+#### Badge
+- **File**: `components/ui/badge.tsx`
+- **Exports**: `Badge`, `badgeVariants`
+- **Description**: Status indicator / tag. Variants include default, secondary, outline, destructive, success, warning, info, plus channel pills (WhatsApp, Email, Telegram, Zulip).
+- **Props**: `variant?: "default" | "secondary" | "destructive" | "outline" | "success" | "warning" | "info" | ...; asChild?: boolean`
+- **Example**:
+```tsx
+Active
+```
+
+#### Input
+- **File**: `components/ui/input.tsx`
+- **Exports**: `Input`
+- **Description**: Text input field with focus ring, disabled, and aria-invalid states.
+- **Props**: `All standard HTML input props`
+- **Example**:
+```tsx
+
+```
+
+#### Textarea
+- **File**: `components/ui/textarea.tsx`
+- **Exports**: `Textarea`
+- **Description**: Multi-line text input.
+- **Props**: `All standard HTML textarea props`
+- **Example**:
+```tsx
+
+```
+
+#### Label
+- **File**: `components/ui/label.tsx`
+- **Exports**: `Label`
+- **Description**: Form label using Radix Label primitive.
+- **Props**: `All standard HTML label props + Radix Label props`
+- **Example**:
+```tsx
+
+```
+
+#### Checkbox
+- **File**: `components/ui/checkbox.tsx`
+- **Exports**: `Checkbox`
+- **Description**: Checkbox using Radix Checkbox primitive.
+- **Props**: `checked?: boolean; onCheckedChange?: (checked: boolean) => void`
+- **Example**:
+```tsx
+
+```
+
+#### Switch
+- **File**: `components/ui/switch.tsx`
+- **Exports**: `Switch`
+- **Description**: Toggle switch using Radix Switch primitive.
+- **Props**: `checked?: boolean; onCheckedChange?: (checked: boolean) => void`
+- **Example**:
+```tsx
+
+```
+
+#### Select
+- **File**: `components/ui/select.tsx`
+- **Exports**: `Select`, `SelectContent`, `SelectGroup`, `SelectItem`, `SelectLabel`, `SelectTrigger`, `SelectValue`
+- **Description**: Dropdown select using Radix Select.
+- **Props**: `value?: string; onValueChange?: (value: string) => void`
+- **Example**:
+```tsx
+
+```
+
+#### RadioGroup
+- **File**: `components/ui/radio-group.tsx`
+- **Exports**: `RadioGroup`, `RadioGroupItem`
+- **Description**: Radio button group using Radix RadioGroup.
+- **Props**: `value?: string; onValueChange?: (value: string) => void`
+- **Example**:
+```tsx
+
+```
+
+#### Toggle
+- **File**: `components/ui/toggle.tsx`
+- **Exports**: `Toggle`, `toggleVariants`
+- **Description**: Toggle button. Variants: default, outline.
+- **Props**: `variant?: "default" | "outline"; size?: "default" | "sm" | "lg"; pressed?: boolean`
+- **Example**:
+```tsx
+
+```
+
+### Layout
+
+#### Card
+- **File**: `components/ui/card.tsx`
+- **Exports**: `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardAction`, `CardContent`, `CardFooter`
+- **Description**: Container with header/content/footer slots. Off-white bg, rounded-xl, subtle shadow.
+- **Props**: `Standard div props. Compose with CardHeader, CardTitle, CardDescription, CardContent, CardFooter sub-components.`
+- **Example**:
+```tsx
+TitleDescriptionContent
+```
+
+#### Accordion
+- **File**: `components/ui/accordion.tsx`
+- **Exports**: `Accordion`, `AccordionItem`, `AccordionTrigger`, `AccordionContent`
+- **Description**: Expandable sections using Radix Accordion.
+- **Props**: `type: "single" | "multiple"; collapsible?: boolean`
+- **Example**:
+```tsx
+Section 1Content
+```
+
+#### Tabs
+- **File**: `components/ui/tabs.tsx`
+- **Exports**: `Tabs`, `TabsList`, `TabsTrigger`, `TabsContent`
+- **Description**: Tab navigation using Radix Tabs. Pill-style triggers.
+- **Props**: `value?: string; onValueChange?: (value: string) => void`
+- **Example**:
+```tsx
+Tab 1Content
+```
+
+#### Separator
+- **File**: `components/ui/separator.tsx`
+- **Exports**: `Separator`
+- **Description**: Visual divider line. Horizontal or vertical.
+- **Props**: `orientation?: "horizontal" | "vertical"; decorative?: boolean`
+- **Example**:
+```tsx
+
+```
+
+### Overlay
+
+#### Dialog
+- **File**: `components/ui/dialog.tsx`
+- **Exports**: `Dialog`, `DialogTrigger`, `DialogContent`, `DialogHeader`, `DialogTitle`, `DialogDescription`, `DialogFooter`, `DialogClose`
+- **Description**: Modal dialog using Radix Dialog.
+- **Props**: `open?: boolean; onOpenChange?: (open: boolean) => void`
+- **Example**:
+```tsx
+
+```
+
+#### AlertDialog
+- **File**: `components/ui/alert-dialog.tsx`
+- **Exports**: `AlertDialog`, `AlertDialogTrigger`, `AlertDialogContent`, `AlertDialogHeader`, `AlertDialogTitle`, `AlertDialogDescription`, `AlertDialogFooter`, `AlertDialogAction`, `AlertDialogCancel`
+- **Description**: Confirmation dialog requiring user action.
+- **Props**: `open?: boolean; onOpenChange?: (open: boolean) => void`
+- **Example**:
+```tsx
+...
+```
+
+#### Tooltip
+- **File**: `components/ui/tooltip.tsx`
+- **Exports**: `Tooltip`, `TooltipTrigger`, `TooltipContent`, `TooltipProvider`
+- **Description**: Tooltip popup (0ms delay) using Radix Tooltip.
+- **Props**: `Standard Radix Tooltip props`
+- **Example**:
+```tsx
+Hover meTooltip text
+```
+
+#### Popover
+- **File**: `components/ui/popover.tsx`
+- **Exports**: `Popover`, `PopoverTrigger`, `PopoverContent`
+- **Description**: Floating content panel using Radix Popover.
+- **Props**: `open?: boolean; onOpenChange?: (open: boolean) => void`
+- **Example**:
+```tsx
+Content
+```
+
+#### Drawer
+- **File**: `components/ui/drawer.tsx`
+- **Exports**: `Drawer`, `DrawerTrigger`, `DrawerContent`, `DrawerHeader`, `DrawerTitle`, `DrawerDescription`, `DrawerFooter`, `DrawerClose`
+- **Description**: Bottom sheet drawer using Vaul.
+- **Props**: `open?: boolean; onOpenChange?: (open: boolean) => void`
+- **Example**:
+```tsx
+Title
+```
+
+### Navigation
+
+#### Navbar
+- **File**: `components/ui/navbar.tsx`
+- **Exports**: `Navbar`, `NavbarLink`, `navbarVariants`
+- **Description**: Top navigation bar. Fixed top, z-50, h-16. Variants: solid, transparent, minimal. Logo left, nav center, actions right. Mobile hamburger.
+- **Props**: `variant?: "solid" | "transparent" | "minimal"; logo?: ReactNode; actions?: ReactNode`
+- **Example**:
+```tsx
+}>Home
+```
+
+#### Breadcrumb
+- **File**: `components/ui/breadcrumb.tsx`
+- **Exports**: `Breadcrumb`, `BreadcrumbList`, `BreadcrumbItem`, `BreadcrumbLink`, `BreadcrumbPage`, `BreadcrumbSeparator`, `BreadcrumbEllipsis`
+- **Description**: Breadcrumb navigation trail.
+- **Props**: `Standard list composition`
+- **Example**:
+```tsx
+HomeCurrent
+```
+
+#### Pagination
+- **File**: `components/ui/pagination.tsx`
+- **Exports**: `Pagination`, `PaginationContent`, `PaginationItem`, `PaginationLink`, `PaginationPrevious`, `PaginationNext`, `PaginationEllipsis`
+- **Description**: Page navigation controls.
+- **Props**: `Standard list composition with PaginationLink items`
+- **Example**:
+```tsx
+1
+```
+
+### Data
+
+#### Table
+- **File**: `components/ui/table.tsx`
+- **Exports**: `Table`, `TableHeader`, `TableBody`, `TableRow`, `TableHead`, `TableCell`, `TableCaption`, `TableFooter`
+- **Description**: Data table with header, body, footer.
+- **Props**: `Standard HTML table element composition`
+- **Example**:
+```tsx
+
NameJohn
+```
+
+#### Progress
+- **File**: `components/ui/progress.tsx`
+- **Exports**: `Progress`
+- **Description**: Progress bar using Radix Progress.
+- **Props**: `value?: number (0-100)`
+- **Example**:
+```tsx
+
+```
+
+#### Avatar
+- **File**: `components/ui/avatar.tsx`
+- **Exports**: `Avatar`, `AvatarImage`, `AvatarFallback`
+- **Description**: User avatar with image and fallback.
+- **Props**: `Standard Radix Avatar composition`
+- **Example**:
+```tsx
+JD
+```
+
+#### Calendar
+- **File**: `components/ui/calendar.tsx`
+- **Exports**: `Calendar`
+- **Description**: Date picker calendar using react-day-picker.
+- **Props**: `mode?: "single" | "range" | "multiple"; selected?: Date; onSelect?: (date: Date) => void`
+- **Example**:
+```tsx
+
+```
+
+### Feedback
+
+#### Alert
+- **File**: `components/ui/alert.tsx`
+- **Exports**: `Alert`, `AlertTitle`, `AlertDescription`
+- **Description**: Inline alert message. Variants: default, destructive.
+- **Props**: `variant?: "default" | "destructive"`
+- **Example**:
+```tsx
+Heads up!This is an alert.
+```
+
+#### Skeleton
+- **File**: `components/ui/skeleton.tsx`
+- **Exports**: `Skeleton`
+- **Description**: Loading placeholder with pulse animation.
+- **Props**: `Standard div props (set dimensions with className)`
+- **Example**:
+```tsx
+
+```
+
+#### Spinner
+- **File**: `components/ui/spinner.tsx`
+- **Exports**: `Spinner`
+- **Description**: Loading spinner (Loader2Icon with spin animation).
+- **Props**: `Standard SVG icon props`
+- **Example**:
+```tsx
+
+```
+
+#### Empty
+- **File**: `components/ui/empty.tsx`
+- **Exports**: `Empty`
+- **Description**: Empty state placeholder with header/media/title/description.
+- **Props**: `Standard composition with sub-components`
+- **Example**:
+```tsx
+No resultsTry a different search
+```
+
+### Form
+
+#### Form
+- **File**: `components/ui/form.tsx`
+- **Exports**: `Form`, `FormField`, `FormItem`, `FormLabel`, `FormControl`, `FormDescription`, `FormMessage`
+- **Description**: Form wrapper using react-hook-form. Provides field-level validation and error display via Zod.
+- **Props**: `Wraps react-hook-form useForm return value. FormField takes name + render prop.`
+- **Example**:
+```tsx
+
+```
+
+### Composition
+
+#### Logo
+- **File**: `components/ui/logo.tsx`
+- **Exports**: `Logo`, `logoVariants`
+- **Description**: Greyhaven logo SVG. Size: sm/md/lg/xl. Variant: color (orange icon + foreground text) or monochrome (all foreground).
+- **Props**: `size?: "sm" | "md" | "lg" | "xl"; variant?: "color" | "monochrome"`
+- **Example**:
+```tsx
+
+```
+
+#### Hero
+- **File**: `components/ui/hero.tsx`
+- **Exports**: `Hero`, `heroVariants`
+- **Description**: Full-width hero section. Variants: centered, left-aligned, split (text + media). Heading in Source Serif, subheading in sans.
+- **Props**: `variant?: "centered" | "left-aligned" | "split"; background?: "default" | "muted" | "accent" | "dark"; heading: ReactNode; subheading?: ReactNode; actions?: ReactNode; media?: ReactNode`
+- **Example**:
+```tsx
+Get Started} />
+```
+
+#### CTASection
+- **File**: `components/ui/cta-section.tsx`
+- **Exports**: `CTASection`, `ctaSectionVariants`
+- **Description**: Call-to-action section block. Centered or left-aligned, with heading, description, and action buttons.
+- **Props**: `variant?: "centered" | "left-aligned"; background?: "default" | "muted" | "accent" | "subtle"; heading: ReactNode; description?: ReactNode; actions?: ReactNode`
+- **Example**:
+```tsx
+Sign up free} />
+```
+
+#### Section
+- **File**: `components/ui/section.tsx`
+- **Exports**: `Section`, `sectionVariants`
+- **Description**: Titled content section with spacing. py-16 between sections.
+- **Props**: `variant?: "default" | "highlighted" | "accent"; width?: "narrow" | "default" | "wide" | "full"; title?: string; description?: string`
+- **Example**:
+```tsx
+Content
+```
+
+#### Footer
+- **File**: `components/ui/footer.tsx`
+- **Exports**: `Footer`, `footerVariants`
+- **Description**: Page footer. Minimal (single row) or full (multi-column with link groups).
+- **Props**: `variant?: "minimal" | "full"; logo?: ReactNode; copyright?: ReactNode; linkGroups?: FooterLinkGroup[]; actions?: ReactNode`
+- **Example**:
+```tsx
+
+```
+
+#### PageLayout
+- **File**: `components/ui/page-layout.tsx`
+- **Exports**: `PageLayout`
+- **Description**: Full page shell composing Navbar + main content + optional sidebar + Footer. Auto-offsets for fixed navbar.
+- **Props**: `navbar?: ReactNode; sidebar?: ReactNode; footer?: ReactNode`
+- **Example**:
+```tsx
+} footer={}>Main content
+```
+
+---
+
+## 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`
+
+---
+
+## 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 }
+```
diff --git a/skill/SKILL.md b/skill/SKILL.md
index bc68b7e..78f1c06 100644
--- a/skill/SKILL.md
+++ b/skill/SKILL.md
@@ -9,22 +9,60 @@
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.
-
---
-## 1. Design Philosophy
+## 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.
+- **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.
+---
+
+## 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`
---
-## 2. Token Quick Reference
+## Token Quick Reference
Source of truth: `tokens/*.json` (W3C DTCG format).
@@ -112,7 +150,7 @@ Source of truth: `tokens/*.json` (W3C DTCG format).
| Token | Value | Description |
|-------|-------|-------------|
-| `typography.fontFamily.sans` | `["Aspekta","Inter","ui-sans-serif","system-ui","sans-serif"]` | UI labels, buttons, nav, forms — Aspekta primary, Inter fallback |
+| `typography.fontFamily.sans` | `["Aspekta","ui-sans-serif","system-ui","sans-serif"]` | UI labels, buttons, nav, forms — Aspekta self-hosted |
| `typography.fontFamily.serif` | `["Source Serif 4","Source Serif Pro","Georgia","serif"]` | Headings, body content, reading — Source Serif primary |
| `typography.fontFamily.mono` | `["ui-monospace","SFMono-Regular","Menlo","Monaco","Consolas","monospace"]` | Code blocks and monospaced content |
| `typography.fontSize.xs` | `0.75rem` | 12px — metadata, fine print |
@@ -192,10 +230,9 @@ Source of truth: `tokens/*.json` (W3C DTCG format).
| `motion.easing.in` | `[0.4,0,1,1]` | Ease-in for exits |
| `motion.easing.out` | `[0,0,0.2,1]` | Ease-out for entrances |
-
---
-## 3. Component Catalog (37 components)
+## Component Catalog (37 components)
All components live in `components/ui/`. Import with `@/components/ui/`.
@@ -585,10 +622,9 @@ All components live in `components/ui/`. Import with `@/components/ui/`.
} footer={}>Main content
```
-
---
-## 4. Composition Rules
+## Composition Rules
- **Card spacing**: `gap-6` between cards, `p-6` internal padding
- **Section rhythm**: `py-16` between major page sections
@@ -603,10 +639,9 @@ All components live in `components/ui/`. Import with `@/components/ui/`.
- **Slot naming**: All components use `data-slot="component-name"`
- **Icon sizing**: `[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0`
-
---
-## 5. Extension Protocol
+## Extension Protocol
When adding new components to the system:
@@ -630,24 +665,14 @@ import { cn } from '@/lib/utils'
const myComponentVariants = cva('base-classes', {
variants: {
- variant: {
- default: 'default-classes',
- },
- size: {
- default: 'size-classes',
- },
- },
- defaultVariants: {
- variant: 'default',
- size: 'default',
+ variant: { default: 'default-classes' },
+ size: { default: 'size-classes' },
},
+ defaultVariants: { variant: 'default', size: 'default' },
})
function MyComponent({
- className,
- variant,
- size,
- ...props
+ className, variant, size, ...props
}: React.ComponentProps<'div'> & VariantProps) {
return (
"
echo ""
@@ -33,36 +29,82 @@ else
exit 1
fi
-# Resolve to absolute path
-TARGET_PROJECT="$(cd "$TARGET_PROJECT" && pwd)"
-
-# Validate target directory exists
if [ ! -d "$TARGET_PROJECT" ]; then
echo "Error: Directory not found: ${TARGET_PROJECT}"
exit 1
fi
-# Create .claude/skills/ directory in target project if it doesn't exist
-SKILLS_DIR="${TARGET_PROJECT}/.claude/skills"
-mkdir -p "$SKILLS_DIR"
+echo "Installing Greyhaven Design System into ${TARGET_PROJECT}"
+echo ""
-# Create the symlink
-LINK_PATH="${SKILLS_DIR}/greyhaven-design-system.md"
+# ── 1. Claude Skill ────────────────────────────────────────────────────────
+if [ -f "$SKILL_FILE" ]; then
+ SKILLS_DIR="${TARGET_PROJECT}/.claude/skills"
+ mkdir -p "$SKILLS_DIR"
+ LINK_PATH="${SKILLS_DIR}/greyhaven-design-system.md"
-if [ -L "$LINK_PATH" ]; then
- echo "Updating existing symlink at ${LINK_PATH}"
- rm "$LINK_PATH"
-elif [ -f "$LINK_PATH" ]; then
- echo "Warning: ${LINK_PATH} exists as a regular file. Backing up to ${LINK_PATH}.bak"
- mv "$LINK_PATH" "${LINK_PATH}.bak"
+ if [ -L "$LINK_PATH" ]; then
+ rm "$LINK_PATH"
+ elif [ -f "$LINK_PATH" ]; then
+ mv "$LINK_PATH" "${LINK_PATH}.bak"
+ fi
+
+ ln -s "$SKILL_FILE" "$LINK_PATH"
+ echo "[ok] Claude Skill: ${LINK_PATH} -> ${SKILL_FILE}"
+else
+ echo "[skip] SKILL.md not found — run 'pnpm skill:build' first"
fi
-ln -s "$SKILL_FILE" "$LINK_PATH"
+# ── 2. AGENT.md ────────────────────────────────────────────────────────────
+if [ -f "$AGENT_FILE" ]; then
+ AGENT_LINK="${TARGET_PROJECT}/AGENT.md"
-echo "Done! Greyhaven Design System skill installed."
+ if [ -L "$AGENT_LINK" ]; then
+ rm "$AGENT_LINK"
+ elif [ -f "$AGENT_LINK" ]; then
+ mv "$AGENT_LINK" "${AGENT_LINK}.bak"
+ fi
+
+ ln -s "$AGENT_FILE" "$AGENT_LINK"
+ echo "[ok] AGENT.md: ${AGENT_LINK} -> ${AGENT_FILE}"
+else
+ echo "[skip] AGENT.md not found — run 'pnpm skill:build' first"
+fi
+
+# ── 3. Fonts ───────────────────────────────────────────────────────────────
+if [ -d "$FONTS_DIR" ]; then
+ TARGET_FONTS="${TARGET_PROJECT}/public/fonts"
+ mkdir -p "$TARGET_FONTS"
+
+ # Copy only Aspekta woff2 files and the font-face CSS
+ copied=0
+ for f in "$FONTS_DIR"/Aspekta-*.woff2; do
+ [ -f "$f" ] || continue
+ cp -n "$f" "$TARGET_FONTS/" 2>/dev/null && copied=$((copied + 1)) || true
+ done
+
+ # Copy font-face.css
+ if [ -f "$FONTS_DIR/font-face.css" ]; then
+ cp -n "$FONTS_DIR/font-face.css" "$TARGET_FONTS/" 2>/dev/null || true
+ fi
+
+ echo "[ok] Fonts: ${copied} Aspekta woff2 files copied to ${TARGET_FONTS}/"
+else
+ echo "[skip] Fonts dir not found at ${FONTS_DIR}"
+fi
+
+# ── Done ───────────────────────────────────────────────────────────────────
echo ""
-echo " Symlink: ${LINK_PATH}"
-echo " Target: ${SKILL_FILE}"
+echo "Done! Next steps for your project's CSS entry point:"
echo ""
-echo "Any Claude Code session in ${TARGET_PROJECT} will now have"
-echo "full Greyhaven Design System context available."
+echo " /* Add these @font-face declarations to your global CSS */"
+echo " @font-face { font-family: 'Aspekta'; font-weight: 400; font-display: swap; src: url('/fonts/Aspekta-400.woff2') format('woff2'); }"
+echo " @font-face { font-family: 'Aspekta'; font-weight: 500; font-display: swap; src: url('/fonts/Aspekta-500.woff2') format('woff2'); }"
+echo " @font-face { font-family: 'Aspekta'; font-weight: 600; font-display: swap; src: url('/fonts/Aspekta-600.woff2') format('woff2'); }"
+echo " @font-face { font-family: 'Aspekta'; font-weight: 700; font-display: swap; src: url('/fonts/Aspekta-700.woff2') format('woff2'); }"
+echo ""
+echo " /* Or import the full set: */"
+echo " @import url('/fonts/font-face.css');"
+echo ""
+echo " /* Set your font stack: */"
+echo " --font-sans: 'Aspekta', ui-sans-serif, system-ui, sans-serif;"
diff --git a/tokens/typography.json b/tokens/typography.json
index c0b10ec..4fbdbf1 100644
--- a/tokens/typography.json
+++ b/tokens/typography.json
@@ -4,8 +4,8 @@
"fontFamily": {
"sans": {
"$type": "fontFamily",
- "$value": ["Aspekta", "Inter", "ui-sans-serif", "system-ui", "sans-serif"],
- "$description": "UI labels, buttons, nav, forms — Aspekta primary, Inter fallback"
+ "$value": ["Aspekta", "ui-sans-serif", "system-ui", "sans-serif"],
+ "$description": "UI labels, buttons, nav, forms — Aspekta self-hosted"
},
"serif": {
"$type": "fontFamily",