design system token v0.2

This commit is contained in:
Juan
2026-04-13 15:46:38 -05:00
parent c3215945f2
commit c9209a6271
30 changed files with 1190 additions and 159 deletions

View File

@@ -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:

View File

@@ -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;

View File

@@ -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 (
<html lang="en" className={`${sourceSerif.variable} ${inter.variable}`}>
<html lang="en" className={sourceSerif.variable}>
<body className="font-sans antialiased bg-background text-foreground">
{children}
</body>

View File

@@ -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 |

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

161
public/fonts/font-face.css Normal file
View File

@@ -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');
}

View File

@@ -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/<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)
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<typeof myComponentVariants>) {
return (
<div
@@ -225,26 +227,97 @@ 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 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()

693
skill/AGENT.md Normal file
View File

@@ -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/<name>`.
### 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
<Button variant="default" size="default">Click me</Button>
```
#### 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
<Badge variant="success">Active</Badge>
```
#### 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
<Input type="email" placeholder="you@example.com" />
```
#### Textarea
- **File**: `components/ui/textarea.tsx`
- **Exports**: `Textarea`
- **Description**: Multi-line text input.
- **Props**: `All standard HTML textarea props`
- **Example**:
```tsx
<Textarea placeholder="Write your message..." />
```
#### 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
<Label htmlFor="email">Email</Label>
```
#### Checkbox
- **File**: `components/ui/checkbox.tsx`
- **Exports**: `Checkbox`
- **Description**: Checkbox using Radix Checkbox primitive.
- **Props**: `checked?: boolean; onCheckedChange?: (checked: boolean) => void`
- **Example**:
```tsx
<Checkbox id="terms" />
```
#### Switch
- **File**: `components/ui/switch.tsx`
- **Exports**: `Switch`
- **Description**: Toggle switch using Radix Switch primitive.
- **Props**: `checked?: boolean; onCheckedChange?: (checked: boolean) => void`
- **Example**:
```tsx
<Switch id="dark-mode" />
```
#### 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
<Select><SelectTrigger><SelectValue placeholder="Choose..." /></SelectTrigger><SelectContent><SelectItem value="a">Option A</SelectItem></SelectContent></Select>
```
#### 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
<RadioGroup defaultValue="a"><RadioGroupItem value="a" /><RadioGroupItem value="b" /></RadioGroup>
```
#### 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
<Toggle aria-label="Bold"><BoldIcon /></Toggle>
```
### 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
<Card><CardHeader><CardTitle>Title</CardTitle><CardDescription>Description</CardDescription></CardHeader><CardContent>Content</CardContent></Card>
```
#### 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
<Accordion type="single" collapsible><AccordionItem value="item-1"><AccordionTrigger>Section 1</AccordionTrigger><AccordionContent>Content</AccordionContent></AccordionItem></Accordion>
```
#### 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
<Tabs defaultValue="tab1"><TabsList><TabsTrigger value="tab1">Tab 1</TabsTrigger></TabsList><TabsContent value="tab1">Content</TabsContent></Tabs>
```
#### Separator
- **File**: `components/ui/separator.tsx`
- **Exports**: `Separator`
- **Description**: Visual divider line. Horizontal or vertical.
- **Props**: `orientation?: "horizontal" | "vertical"; decorative?: boolean`
- **Example**:
```tsx
<Separator />
```
### 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
<Dialog><DialogTrigger asChild><Button>Open</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>Title</DialogTitle></DialogHeader></DialogContent></Dialog>
```
#### 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
<AlertDialog><AlertDialogTrigger asChild><Button variant="destructive">Delete</Button></AlertDialogTrigger><AlertDialogContent>...</AlertDialogContent></AlertDialog>
```
#### 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
<TooltipProvider><Tooltip><TooltipTrigger>Hover me</TooltipTrigger><TooltipContent>Tooltip text</TooltipContent></Tooltip></TooltipProvider>
```
#### 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
<Popover><PopoverTrigger asChild><Button>Open</Button></PopoverTrigger><PopoverContent>Content</PopoverContent></Popover>
```
#### 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
<Drawer><DrawerTrigger asChild><Button>Open</Button></DrawerTrigger><DrawerContent><DrawerHeader><DrawerTitle>Title</DrawerTitle></DrawerHeader></DrawerContent></Drawer>
```
### 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
<Navbar variant="solid" logo={<Logo size="sm" />}><NavbarLink href="/">Home</NavbarLink></Navbar>
```
#### Breadcrumb
- **File**: `components/ui/breadcrumb.tsx`
- **Exports**: `Breadcrumb`, `BreadcrumbList`, `BreadcrumbItem`, `BreadcrumbLink`, `BreadcrumbPage`, `BreadcrumbSeparator`, `BreadcrumbEllipsis`
- **Description**: Breadcrumb navigation trail.
- **Props**: `Standard list composition`
- **Example**:
```tsx
<Breadcrumb><BreadcrumbList><BreadcrumbItem><BreadcrumbLink href="/">Home</BreadcrumbLink></BreadcrumbItem><BreadcrumbSeparator /><BreadcrumbItem><BreadcrumbPage>Current</BreadcrumbPage></BreadcrumbItem></BreadcrumbList></Breadcrumb>
```
#### 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
<Pagination><PaginationContent><PaginationItem><PaginationPrevious href="#" /></PaginationItem><PaginationItem><PaginationLink href="#">1</PaginationLink></PaginationItem><PaginationItem><PaginationNext href="#" /></PaginationItem></PaginationContent></Pagination>
```
### 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
<Table><TableHeader><TableRow><TableHead>Name</TableHead></TableRow></TableHeader><TableBody><TableRow><TableCell>John</TableCell></TableRow></TableBody></Table>
```
#### Progress
- **File**: `components/ui/progress.tsx`
- **Exports**: `Progress`
- **Description**: Progress bar using Radix Progress.
- **Props**: `value?: number (0-100)`
- **Example**:
```tsx
<Progress value={60} />
```
#### Avatar
- **File**: `components/ui/avatar.tsx`
- **Exports**: `Avatar`, `AvatarImage`, `AvatarFallback`
- **Description**: User avatar with image and fallback.
- **Props**: `Standard Radix Avatar composition`
- **Example**:
```tsx
<Avatar><AvatarImage src="/avatar.jpg" /><AvatarFallback>JD</AvatarFallback></Avatar>
```
#### 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
<Calendar mode="single" selected={date} onSelect={setDate} />
```
### Feedback
#### Alert
- **File**: `components/ui/alert.tsx`
- **Exports**: `Alert`, `AlertTitle`, `AlertDescription`
- **Description**: Inline alert message. Variants: default, destructive.
- **Props**: `variant?: "default" | "destructive"`
- **Example**:
```tsx
<Alert><AlertTitle>Heads up!</AlertTitle><AlertDescription>This is an alert.</AlertDescription></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
<Skeleton className="h-4 w-[250px]" />
```
#### Spinner
- **File**: `components/ui/spinner.tsx`
- **Exports**: `Spinner`
- **Description**: Loading spinner (Loader2Icon with spin animation).
- **Props**: `Standard SVG icon props`
- **Example**:
```tsx
<Spinner />
```
#### 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
<Empty><EmptyTitle>No results</EmptyTitle><EmptyDescription>Try a different search</EmptyDescription></Empty>
```
### 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
<Form {...form}><FormField name="email" render={({field}) => (<FormItem><FormLabel>Email</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>)} /></Form>
```
### 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
<Logo size="md" variant="color" />
```
#### 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
<Hero variant="centered" heading="Build something great" subheading="With the Greyhaven Design System" actions={<Button>Get Started</Button>} />
```
#### 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
<CTASection heading="Ready to start?" description="Join thousands of developers" actions={<Button>Sign up free</Button>} />
```
#### 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
<Section title="Features" description="What we offer" width="wide">Content</Section>
```
#### 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
<Footer variant="minimal" copyright="&copy; 2024 Greyhaven" />
```
#### 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
<PageLayout navbar={<Navbar />} footer={<Footer />}>Main content</PageLayout>
```
---
## 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<typeof myComponentVariants>) {
return (
<div
data-slot="my-component"
className={cn(myComponentVariants({ variant, size, className }))}
{...props}
/>
)
}
export { MyComponent, myComponentVariants }
```

View File

@@ -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/<name>`.
@@ -585,10 +622,9 @@ All components live in `components/ui/`. Import with `@/components/ui/<name>`.
<PageLayout navbar={<Navbar />} footer={<Footer />}>Main content</PageLayout>
```
---
## 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/<name>`.
- **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<typeof myComponentVariants>) {
return (
<div

View File

@@ -1,29 +1,25 @@
#!/usr/bin/env bash
# install.sh — Symlink the Greyhaven Design System SKILL.md into a consuming project's
# .claude/skills/ directory so that any Claude Code session gets full design system context.
# install.sh — Install the Greyhaven Design System into a consuming project.
#
# What it does:
# 1. Symlinks SKILL.md into .claude/skills/ (for Claude Code)
# 2. Symlinks AGENT.md into the project root (for non-Claude AI agents)
# 3. Copies Aspekta font files + @font-face CSS into public/fonts/
# 4. Prints CSS import instructions for the consuming project
#
# Usage:
# From the greyhaven-design-system repo:
# ./skill/install.sh /path/to/your/project
#
# Or from any directory:
# /path/to/greyhaven-design-system/skill/install.sh /path/to/your/project
# ./skill/install.sh /path/to/your/project
set -euo pipefail
# Resolve the absolute path of the SKILL.md file relative to this script
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SKILL_FILE="${SCRIPT_DIR}/SKILL.md"
AGENT_FILE="${SCRIPT_DIR}/AGENT.md"
FONTS_DIR="${REPO_ROOT}/public/fonts"
# Validate SKILL.md exists
if [ ! -f "$SKILL_FILE" ]; then
echo "Error: SKILL.md not found at ${SKILL_FILE}"
exit 1
fi
# Get target project directory from argument or prompt
if [ $# -ge 1 ]; then
TARGET_PROJECT="$1"
TARGET_PROJECT="$(cd "$1" && pwd)"
else
echo "Usage: $0 <target-project-directory>"
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;"

View File

@@ -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",