design system token v0.1

This commit is contained in:
Juan
2026-04-13 15:33:00 -05:00
parent 52b4156653
commit c3215945f2
63 changed files with 11562 additions and 181 deletions

7
.gitignore vendored
View File

@@ -24,4 +24,9 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
next-env.d.ts
*storybook.log
storybook-static
# llms
vibedocs/*

17
.storybook/main.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { StorybookConfig } from '@storybook/nextjs-vite';
const config: StorybookConfig = {
stories: [
'../stories/**/*.mdx',
'../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)',
],
addons: [
'@storybook/addon-docs',
'@storybook/addon-a11y',
'@chromatic-com/storybook',
],
framework: '@storybook/nextjs-vite',
staticDirs: ['../public'],
};
export default config;

44
.storybook/preview.ts Normal file
View File

@@ -0,0 +1,44 @@
import type { Preview } from '@storybook/nextjs-vite'
import '../app/globals.css'
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
a11y: {
test: 'todo',
},
backgrounds: { disable: true },
},
globalTypes: {
theme: {
description: 'Theme',
toolbar: {
title: 'Theme',
icon: 'paintbrush',
items: [
{ value: 'light', title: 'Light', icon: 'sun' },
{ value: 'dark', title: 'Dark', icon: 'moon' },
],
dynamicTitle: true,
},
},
},
initialGlobals: {
theme: 'light',
},
decorators: [
(Story, context) => {
const theme = context.globals.theme || 'light'
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(theme)
return Story()
},
],
}
export default preview

212
README.md
View File

@@ -1,47 +1,201 @@
# Greyhaven Design System
A modern design system built with Next.js, shadcn/ui, and Radix UI primitives.
A framework-agnostic React component library built on Radix UI, Tailwind CSS v4, and shadcn/ui patterns. Designed for LLM consumption with a Claude Skill, MCP server, and Storybook documentation.
![Screenshot](docs/screenshot.png)
## Getting Started
## Quick Start
```bash
# Install dependencies
pnpm install
# Start development server
pnpm dev
# Build for production
pnpm build
# Start production server
pnpm start
pnpm install # Install dependencies
pnpm dev # Start showcase dev server (Next.js)
pnpm build # Tokens + SKILL.md + production build
pnpm storybook # Component catalog on http://localhost:6006
```
## Project Structure
```
greyhaven-design-system/
├── app/ # Next.js app directory
│ ├── layout.tsx # Root layout with fonts
│ ├── page.tsx # Design system showcase
── globals.css # Global styles
├── components/
│ ├── ui/ # Reusable UI components (57 components)
│ ├── design-system/ # Showcase components
│ └── theme-provider.tsx # Theme context
├── hooks/ # Custom React hooks
├── components/ui/ # 37+ framework-agnostic React components
├── tokens/ # W3C DTCG design tokens (source of truth)
│ ├── color.json
── typography.json
│ ├── spacing.json
│ ├── radii.json
│ ├── shadows.json
│ └── motion.json
├── skill/ # Claude Skill (auto-generated)
│ ├── SKILL.md
│ └── install.sh
├── mcp/ # MCP server for AI agents
│ └── server.ts
├── stories/ # Storybook stories (23 files, 8 categories)
├── lib/
── utils.ts # Utility functions
├── styles/ # Additional styles
── public/ # Static assets
── utils.ts # cn() utility
│ └── catalog.ts # Shared component catalog (used by MCP + SKILL.md)
── scripts/
│ └── generate-skill.ts # SKILL.md generator
├── app/ # Next.js showcase app (demo only)
└── style-dictionary.config.mjs
```
## Tech Stack
- **Framework:** Next.js 16, React 19, TypeScript
- **Styling:** Tailwind CSS 4, shadcn/ui, Radix UI
- **Forms:** React Hook Form, Zod
- **Theming:** next-themes (light/dark mode)
- **Components**: React 19, Radix UI, Tailwind CSS 4, CVA, tailwind-merge, clsx
- **Icons**: Lucide React
- **Forms**: React Hook Form + Zod
- **Tokens**: Style Dictionary v4 (W3C DTCG format)
- **Docs**: Storybook 10, auto-generated SKILL.md
- **AI Integration**: MCP server, Claude Skill
> **Framework-agnostic**: Components have zero Next.js imports. They work with Vite, Remix, Astro, CRA, or any React framework.
---
## Using the Design System with AI
The design system provides two 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 |
Both read from the same sources (`tokens/*.json` and `lib/catalog.ts`), so they always agree.
### 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.
**Install into a consuming project:**
```bash
# From the design system repo
./skill/install.sh /path/to/your/project
# Or manually
mkdir -p /path/to/your/project/.claude/skills
ln -sf /absolute/path/to/greyhaven-design-system/skill/SKILL.md \
/path/to/your/project/.claude/skills/greyhaven.md
```
**Regenerate after changes:**
```bash
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
The MCP server provides 5 tools for programmatic access:
| Tool | Description |
|------|-------------|
| `get_tokens(category?)` | Returns token values (all, or filtered by: color, typography, spacing, radii, shadows, motion) |
| `get_component(name)` | Returns full component spec + source code |
| `list_components(category?)` | Lists components (all, or by: primitives, layout, overlay, navigation, data, feedback, form, composition) |
| `validate_colors(code)` | Checks code for raw hex values that should use design tokens |
| `suggest_component(description)` | Suggests components for a described UI need |
Plus resources: `tokens://all` and `component://{name}` for each component.
**Run directly:**
```bash
pnpm mcp:start
```
**Install in Claude Code (`~/.claude/settings.json`):**
```json
{
"mcpServers": {
"greyhaven": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/greyhaven-design-system/mcp/server.ts"]
}
}
}
```
**Install in Claude Desktop (`claude_desktop_config.json`):**
```json
{
"mcpServers": {
"greyhaven": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/greyhaven-design-system/mcp/server.ts"]
}
}
}
```
After adding, restart Claude Code / Claude Desktop. The tools will appear automatically.
**Test it works:**
```bash
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' | pnpm mcp:start
```
You should see a JSON response with `"serverInfo":{"name":"greyhaven-design-system"}`.
---
## Design Tokens
Tokens are defined in `tokens/*.json` using the [W3C Design Token Community Group](https://tr.designtokens.org/format/) format. Style Dictionary v4 generates:
| Output | Path | Purpose |
|--------|------|---------|
| CSS (light) | `app/tokens/tokens-light.css` | `:root` CSS custom properties |
| CSS (dark) | `app/tokens/tokens-dark.css` | `.dark` CSS custom properties |
| TypeScript | `app/tokens/tokens.ts` | Type-safe token constants |
| Markdown | `app/tokens/TOKENS.md` | Reference doc |
```bash
pnpm tokens:build # Regenerate all outputs from tokens/*.json
```
---
## Storybook
23 story files across 8 categories with autodocs, theme switching (light/dark via toolbar), and all component variants.
```bash
pnpm storybook # Dev server on http://localhost:6006
pnpm build-storybook # Static build
```
---
## Adding a New Component
1. Create `components/ui/my-component.tsx` following the CVA pattern (see `button.tsx`)
2. Add it to the catalog in `lib/catalog.ts`
3. Create a story in `stories/<Category>/MyComponent.stories.tsx`
4. Run `pnpm skill:build` to regenerate SKILL.md (or just `pnpm build`)
5. The MCP server picks it up automatically (reads `lib/catalog.ts` at runtime)
---
## Scripts Reference
| Script | Description |
|--------|-------------|
| `pnpm dev` | Start Next.js showcase dev server |
| `pnpm build` | Full build: tokens + SKILL.md + Next.js |
| `pnpm storybook` | Storybook dev server on :6006 |
| `pnpm build-storybook` | Static Storybook build |
| `pnpm tokens:build` | Regenerate CSS/TS/MD from token JSON files |
| `pnpm skill:build` | Regenerate skill/SKILL.md from tokens + catalog |
| `pnpm mcp:start` | Start the MCP server (stdio transport) |
| `pnpm mcp:build` | Type-check MCP server |
| `pnpm lint` | Run ESLint |

View File

@@ -1,12 +1,17 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import './tokens/tokens-light.css';
@import './tokens/tokens-dark.css';
@custom-variant dark (&:is(.dark *));
/* =============================================================================
GREYHAVEN DESIGN TOKENS
Based on Greyhaven Brand Guidelines v1.0
Token values are auto-generated by Style Dictionary from tokens/*.json
(W3C DTCG format). DO NOT hand-edit :root or .dark — edit the source
token files and run `pnpm tokens:build` instead.
Color Philosophy:
- Neutral and minimal scheme
- Off-black, warm grey, and white form the base
@@ -14,142 +19,23 @@
- No gradients, no decorative color
============================================================================= */
/* Radius — not color, kept inline */
:root {
/* Background & Foreground */
--background: 240 240 236; /* Grey 1 #F0F0EC */
--foreground: 22 22 20; /* Off-black #161614 */
/* Card */
--card: 249 249 247; /* Off-white #F9F9F7 - elevated from background */
--card-foreground: 22 22 20;
/* Popover */
--popover: 249 249 247;
--popover-foreground: 22 22 20;
/* Primary - Orange accent (used sparingly) */
--primary: 217 94 42; /* Orange #D95E2A */
--primary-foreground: 249 249 247; /* Off-white */
/* Secondary */
--secondary: 240 240 236; /* Grey 1 #F0F0EC */
--secondary-foreground: 47 47 44; /* Grey 8 #2F2F2C */
/* Muted */
--muted: 240 240 236; /* Grey 1 #F0F0EC */
--muted-foreground: 87 87 83; /* Grey 7 #575753 */
/* Accent - Subtle grey for hover states */
--accent: 221 221 215; /* Grey 2 #DDDDD7 */
--accent-foreground: 22 22 20; /* Off-black */
/* Destructive */
--destructive: 180 50 50; /* Muted red for destructive actions */
--destructive-foreground: 249 249 247;
/* Borders & Inputs */
--border: 196 196 189; /* Grey 3 #C4C4BD */
--input: 196 196 189; /* Grey 3 #C4C4BD */
/* Focus Ring */
--ring: 217 94 42; /* Orange for focus states */
/* Charts - Warm neutral palette with orange accent */
--chart-1: 217 94 42; /* Orange accent */
--chart-2: 87 87 83; /* Grey 7 */
--chart-3: 127 127 121; /* Grey 5 */
--chart-4: 166 166 159; /* Grey 4 */
--chart-5: 47 47 44; /* Grey 8 */
/* Radius - Tightened, no playful rounding */
--radius: 0.375rem;
/* Sidebar */
--sidebar: 240 240 236;
--sidebar-foreground: 22 22 20;
--sidebar-primary: 217 94 42;
--sidebar-primary-foreground: 249 249 247;
--sidebar-accent: 196 196 189;
--sidebar-accent-foreground: 22 22 20;
--sidebar-border: 196 196 189;
--sidebar-ring: 217 94 42;
}
/* =============================================================================
DARK THEME
Guidelines: Negative/reverse usage via off-black and greys
============================================================================= */
.dark {
/* Background & Foreground */
--background: 22 22 20; /* Off-black #161614 */
--foreground: 249 249 247; /* Off-white #F9F9F7 */
/* Card */
--card: 47 47 44; /* Grey 8 #2F2F2C */
--card-foreground: 249 249 247;
/* Popover */
--popover: 47 47 44;
--popover-foreground: 249 249 247;
/* Primary - Orange accent (same in dark mode) */
--primary: 217 94 42; /* Orange #D95E2A */
--primary-foreground: 249 249 247;
/* Secondary */
--secondary: 87 87 83; /* Grey 7 #575753 */
--secondary-foreground: 249 249 247;
/* Muted */
--muted: 87 87 83; /* Grey 7 #575753 */
--muted-foreground: 196 196 189; /* Grey 3 #C4C4BD */
/* Accent - Subtle grey for hover states */
--accent: 87 87 83; /* Grey 7 #575753 */
--accent-foreground: 249 249 247;
/* Destructive */
--destructive: 180 50 50;
--destructive-foreground: 249 249 247;
/* Borders & Inputs */
--border: 87 87 83; /* Grey 7 */
--input: 87 87 83;
/* Focus Ring */
--ring: 217 94 42;
/* Charts */
--chart-1: 217 94 42;
--chart-2: 196 196 189;
--chart-3: 166 166 159;
--chart-4: 127 127 121;
--chart-5: 240 240 236;
/* Sidebar */
--sidebar: 47 47 44;
--sidebar-foreground: 249 249 247;
--sidebar-primary: 217 94 42;
--sidebar-primary-foreground: 249 249 247;
--sidebar-accent: 87 87 83;
--sidebar-accent-foreground: 249 249 247;
--sidebar-border: 87 87 83;
--sidebar-ring: 217 94 42;
}
/* =============================================================================
THEME CONFIGURATION
Typography: Source Serif Pro (primary) + Aspekta (secondary/UI)
Typography: Source Serif Pro (primary/serif) + Aspekta (secondary/UI/sans)
============================================================================= */
@theme inline {
/* Typography - Using CSS variables from Next.js font loading */
--font-sans: var(--font-inter), 'Inter', ui-sans-serif, system-ui, sans-serif;
--font-serif: var(--font-source-serif), 'Source Serif 4', 'Source Serif Pro', Georgia, serif;
/* Typography — Aspekta is the canonical sans font, Inter is fallback */
--font-sans: 'Aspekta', var(--font-inter, 'Inter'), 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;
/* Color mappings */
/* Color mappings — maps CSS var RGB triplets to Tailwind utility classes */
--color-background: rgb(var(--background));
--color-foreground: rgb(var(--foreground));
--color-card: rgb(var(--card));
@@ -186,7 +72,7 @@
--color-sidebar-accent-foreground: rgb(var(--sidebar-accent-foreground));
--color-sidebar-border: rgb(var(--sidebar-border));
--color-sidebar-ring: rgb(var(--sidebar-ring));
/* Greyhaven-specific colors for direct use */
--color-greyhaven-orange: #D95E2A;
--color-greyhaven-offblack: #161614;

168
app/tokens/TOKENS.md Normal file
View File

@@ -0,0 +1,168 @@
# Greyhaven Design Tokens Reference
> Auto-generated by Style Dictionary — DO NOT EDIT
> Source: `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` | `#f0f0ec` | Page background |
| `color.semantic.foreground` | `#161614` | Primary text |
| `color.semantic.card` | `#f9f9f7` | Card/elevated surface background |
| `color.semantic.card-foreground` | `#161614` | Card text |
| `color.semantic.popover` | `#f9f9f7` | Popover background |
| `color.semantic.popover-foreground` | `#161614` | Popover text |
| `color.semantic.primary` | `#d95e2a` | Primary accent — buttons, links, focus rings |
| `color.semantic.primary-foreground` | `#f9f9f7` | Text on primary accent |
| `color.semantic.secondary` | `#f0f0ec` | Secondary button/surface |
| `color.semantic.secondary-foreground` | `#2f2f2c` | Text on secondary surface |
| `color.semantic.muted` | `#f0f0ec` | Muted/subdued background |
| `color.semantic.muted-foreground` | `#575753` | Muted/subdued text |
| `color.semantic.accent` | `#ddddd7` | Subtle hover state |
| `color.semantic.accent-foreground` | `#161614` | Text on accent hover |
| `color.semantic.destructive` | `#b43232` | Destructive/error actions |
| `color.semantic.destructive-foreground` | `#f9f9f7` | Text on destructive |
| `color.semantic.border` | `#c4c4bd` | Default border |
| `color.semantic.input` | `#c4c4bd` | Input border |
| `color.semantic.ring` | `#d95e2a` | Focus ring |
| `color.semantic.chart.1` | `#d95e2a` | Chart accent |
| `color.semantic.chart.2` | `#575753` | Chart secondary |
| `color.semantic.chart.3` | `#7f7f79` | Chart tertiary |
| `color.semantic.chart.4` | `#a6a69f` | Chart quaternary |
| `color.semantic.chart.5` | `#2f2f2c` | Chart quinary |
| `color.semantic.sidebar.background` | `#f0f0ec` | Sidebar background |
| `color.semantic.sidebar.foreground` | `#161614` | Sidebar text |
| `color.semantic.sidebar.primary` | `#d95e2a` | Sidebar primary accent |
| `color.semantic.sidebar.primary-foreground` | `#f9f9f7` | Sidebar primary text |
| `color.semantic.sidebar.accent` | `#c4c4bd` | Sidebar accent/hover |
| `color.semantic.sidebar.accent-foreground` | `#161614` | Sidebar accent text |
| `color.semantic.sidebar.border` | `#c4c4bd` | Sidebar border |
| `color.semantic.sidebar.ring` | `#d95e2a` | Sidebar focus ring |
| `color.dark.background` | `#161614` | Dark page background |
| `color.dark.foreground` | `#f9f9f7` | Dark primary text |
| `color.dark.card` | `#2f2f2c` | Dark card surface |
| `color.dark.card-foreground` | `#f9f9f7` | Dark card text |
| `color.dark.popover` | `#2f2f2c` | Dark popover |
| `color.dark.popover-foreground` | `#f9f9f7` | Dark popover text |
| `color.dark.primary` | `#d95e2a` | Same orange in dark mode |
| `color.dark.primary-foreground` | `#f9f9f7` | Dark primary foreground |
| `color.dark.secondary` | `#575753` | Dark secondary |
| `color.dark.secondary-foreground` | `#f9f9f7` | Dark secondary text |
| `color.dark.muted` | `#575753` | Dark muted |
| `color.dark.muted-foreground` | `#c4c4bd` | Dark muted text |
| `color.dark.accent` | `#575753` | Dark accent/hover |
| `color.dark.accent-foreground` | `#f9f9f7` | Dark accent text |
| `color.dark.destructive` | `#b43232` | Same destructive in dark mode |
| `color.dark.destructive-foreground` | `#f9f9f7` | Dark destructive text |
| `color.dark.border` | `#575753` | Dark border |
| `color.dark.input` | `#575753` | Dark input border |
| `color.dark.ring` | `#d95e2a` | Dark focus ring |
| `color.dark.chart.1` | `#d95e2a` | Dark chart accent |
| `color.dark.chart.2` | `#c4c4bd` | Dark chart secondary |
| `color.dark.chart.3` | `#a6a69f` | Dark chart tertiary |
| `color.dark.chart.4` | `#7f7f79` | Dark chart quaternary |
| `color.dark.chart.5` | `#f0f0ec` | Dark chart quinary |
| `color.dark.sidebar.background` | `#2f2f2c` | Dark sidebar background |
| `color.dark.sidebar.foreground` | `#f9f9f7` | Dark sidebar text |
| `color.dark.sidebar.primary` | `#d95e2a` | Dark sidebar primary |
| `color.dark.sidebar.primary-foreground` | `#f9f9f7` | Dark sidebar primary text |
| `color.dark.sidebar.accent` | `#575753` | Dark sidebar accent |
| `color.dark.sidebar.accent-foreground` | `#f9f9f7` | Dark sidebar accent text |
| `color.dark.sidebar.border` | `#575753` | Dark sidebar border |
| `color.dark.sidebar.ring` | `#d95e2a` | Dark sidebar ring |
## 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` | `cubic-bezier(0.4, 0, 0.2, 1)` | Standard ease-in-out |
| `motion.easing.in` | `cubic-bezier(0.4, 0, 1, 1)` | Ease-in for exits |
| `motion.easing.out` | `cubic-bezier(0, 0, 0.2, 1)` | Ease-out for entrances |
## 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) |
## Shadow
| Token | Value | Description |
|-------|-------|-------------|
| `shadow.xs` | `0 1px 2px 0 rgba(22, 22, 20, 0.05)` | Subtle shadow for buttons, inputs |
| `shadow.sm` | `0 1px 3px 0 rgba(22, 22, 20, 0.1)` | Small shadow for cards |
| `shadow.md` | `0 4px 6px -1px rgba(22, 22, 20, 0.1)` | Medium shadow for dropdowns, popovers |
| `shadow.lg` | `0 10px 15px -3px rgba(22, 22, 20, 0.1)` | Large shadow for dialogs, modals |
## 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) |
## Typography
| 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.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.25rem` | Headings |
| `typography.lineHeight.normal` | `1.5rem` | Default |
| `typography.lineHeight.relaxed` | `1.625rem` | 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 |

View File

@@ -0,0 +1,70 @@
/* Greyhaven Design Tokens — Dark Theme
Auto-generated by Style Dictionary — DO NOT EDIT
Source: tokens/color.json */
.dark {
/* Dark page background */
--background: 22 22 20;
/* Dark primary text */
--foreground: 249 249 247;
/* Dark card surface */
--card: 47 47 44;
/* Dark card text */
--card-foreground: 249 249 247;
/* Dark popover */
--popover: 47 47 44;
/* Dark popover text */
--popover-foreground: 249 249 247;
/* Same orange in dark mode */
--primary: 217 94 42;
/* Dark primary foreground */
--primary-foreground: 249 249 247;
/* Dark secondary */
--secondary: 87 87 83;
/* Dark secondary text */
--secondary-foreground: 249 249 247;
/* Dark muted */
--muted: 87 87 83;
/* Dark muted text */
--muted-foreground: 196 196 189;
/* Dark accent/hover */
--accent: 87 87 83;
/* Dark accent text */
--accent-foreground: 249 249 247;
/* Same destructive in dark mode */
--destructive: 180 50 50;
/* Dark destructive text */
--destructive-foreground: 249 249 247;
/* Dark border */
--border: 87 87 83;
/* Dark input border */
--input: 87 87 83;
/* Dark focus ring */
--ring: 217 94 42;
/* Dark chart accent */
--chart-1: 217 94 42;
/* Dark chart secondary */
--chart-2: 196 196 189;
/* Dark chart tertiary */
--chart-3: 166 166 159;
/* Dark chart quaternary */
--chart-4: 127 127 121;
/* Dark chart quinary */
--chart-5: 240 240 236;
/* Dark sidebar background */
--sidebar: 47 47 44;
/* Dark sidebar text */
--sidebar-foreground: 249 249 247;
/* Dark sidebar primary */
--sidebar-primary: 217 94 42;
/* Dark sidebar primary text */
--sidebar-primary-foreground: 249 249 247;
/* Dark sidebar accent */
--sidebar-accent: 87 87 83;
/* Dark sidebar accent text */
--sidebar-accent-foreground: 249 249 247;
/* Dark sidebar border */
--sidebar-border: 87 87 83;
/* Dark sidebar ring */
--sidebar-ring: 217 94 42;
}

View File

@@ -0,0 +1,70 @@
/* Greyhaven Design Tokens — Light Theme
Auto-generated by Style Dictionary — DO NOT EDIT
Source: tokens/color.json */
:root {
/* Page background */
--background: 240 240 236;
/* Primary text */
--foreground: 22 22 20;
/* Card/elevated surface background */
--card: 249 249 247;
/* Card text */
--card-foreground: 22 22 20;
/* Popover background */
--popover: 249 249 247;
/* Popover text */
--popover-foreground: 22 22 20;
/* Primary accent — buttons, links, focus rings */
--primary: 217 94 42;
/* Text on primary accent */
--primary-foreground: 249 249 247;
/* Secondary button/surface */
--secondary: 240 240 236;
/* Text on secondary surface */
--secondary-foreground: 47 47 44;
/* Muted/subdued background */
--muted: 240 240 236;
/* Muted/subdued text */
--muted-foreground: 87 87 83;
/* Subtle hover state */
--accent: 221 221 215;
/* Text on accent hover */
--accent-foreground: 22 22 20;
/* Destructive/error actions */
--destructive: 180 50 50;
/* Text on destructive */
--destructive-foreground: 249 249 247;
/* Default border */
--border: 196 196 189;
/* Input border */
--input: 196 196 189;
/* Focus ring */
--ring: 217 94 42;
/* Chart accent */
--chart-1: 217 94 42;
/* Chart secondary */
--chart-2: 87 87 83;
/* Chart tertiary */
--chart-3: 127 127 121;
/* Chart quaternary */
--chart-4: 166 166 159;
/* Chart quinary */
--chart-5: 47 47 44;
/* Sidebar background */
--sidebar: 240 240 236;
/* Sidebar text */
--sidebar-foreground: 22 22 20;
/* Sidebar primary accent */
--sidebar-primary: 217 94 42;
/* Sidebar primary text */
--sidebar-primary-foreground: 249 249 247;
/* Sidebar accent/hover */
--sidebar-accent: 196 196 189;
/* Sidebar accent text */
--sidebar-accent-foreground: 22 22 20;
/* Sidebar border */
--sidebar-border: 196 196 189;
/* Sidebar focus ring */
--sidebar-ring: 217 94 42;
}

144
app/tokens/tokens.ts Normal file
View File

@@ -0,0 +1,144 @@
// Auto-generated by Style Dictionary — DO NOT EDIT
// Source: tokens/*.json (W3C DTCG format)
export const ColorTokens = {
'primitive.off-white': '#f9f9f7',
'primitive.off-black': '#161614',
'primitive.orange': '#d95e2a',
'primitive.destructive-red': '#b43232',
'primitive.grey.1': '#f0f0ec',
'primitive.grey.2': '#ddddd7',
'primitive.grey.3': '#c4c4bd',
'primitive.grey.4': '#a6a69f',
'primitive.grey.5': '#7f7f79',
'primitive.grey.7': '#575753',
'primitive.grey.8': '#2f2f2c',
'semantic.background': '#f0f0ec',
'semantic.foreground': '#161614',
'semantic.card': '#f9f9f7',
'semantic.card-foreground': '#161614',
'semantic.popover': '#f9f9f7',
'semantic.popover-foreground': '#161614',
'semantic.primary': '#d95e2a',
'semantic.primary-foreground': '#f9f9f7',
'semantic.secondary': '#f0f0ec',
'semantic.secondary-foreground': '#2f2f2c',
'semantic.muted': '#f0f0ec',
'semantic.muted-foreground': '#575753',
'semantic.accent': '#ddddd7',
'semantic.accent-foreground': '#161614',
'semantic.destructive': '#b43232',
'semantic.destructive-foreground': '#f9f9f7',
'semantic.border': '#c4c4bd',
'semantic.input': '#c4c4bd',
'semantic.ring': '#d95e2a',
'semantic.chart.1': '#d95e2a',
'semantic.chart.2': '#575753',
'semantic.chart.3': '#7f7f79',
'semantic.chart.4': '#a6a69f',
'semantic.chart.5': '#2f2f2c',
'semantic.sidebar.background': '#f0f0ec',
'semantic.sidebar.foreground': '#161614',
'semantic.sidebar.primary': '#d95e2a',
'semantic.sidebar.primary-foreground': '#f9f9f7',
'semantic.sidebar.accent': '#c4c4bd',
'semantic.sidebar.accent-foreground': '#161614',
'semantic.sidebar.border': '#c4c4bd',
'semantic.sidebar.ring': '#d95e2a',
'dark.background': '#161614',
'dark.foreground': '#f9f9f7',
'dark.card': '#2f2f2c',
'dark.card-foreground': '#f9f9f7',
'dark.popover': '#2f2f2c',
'dark.popover-foreground': '#f9f9f7',
'dark.primary': '#d95e2a',
'dark.primary-foreground': '#f9f9f7',
'dark.secondary': '#575753',
'dark.secondary-foreground': '#f9f9f7',
'dark.muted': '#575753',
'dark.muted-foreground': '#c4c4bd',
'dark.accent': '#575753',
'dark.accent-foreground': '#f9f9f7',
'dark.destructive': '#b43232',
'dark.destructive-foreground': '#f9f9f7',
'dark.border': '#575753',
'dark.input': '#575753',
'dark.ring': '#d95e2a',
'dark.chart.1': '#d95e2a',
'dark.chart.2': '#c4c4bd',
'dark.chart.3': '#a6a69f',
'dark.chart.4': '#7f7f79',
'dark.chart.5': '#f0f0ec',
'dark.sidebar.background': '#2f2f2c',
'dark.sidebar.foreground': '#f9f9f7',
'dark.sidebar.primary': '#d95e2a',
'dark.sidebar.primary-foreground': '#f9f9f7',
'dark.sidebar.accent': '#575753',
'dark.sidebar.accent-foreground': '#f9f9f7',
'dark.sidebar.border': '#575753',
'dark.sidebar.ring': '#d95e2a',
} as const;
export const MotionTokens = {
'duration.fast': '150ms',
'duration.normal': '200ms',
'duration.slow': '300ms',
} as const;
export const RadiiTokens = {
'base': '0.375rem',
'sm': 'calc(0.375rem - 2px)',
'md': '0.375rem',
'lg': 'calc(0.375rem + 2px)',
'xl': 'calc(0.375rem + 4px)',
'full': '9999px',
} as const;
export const ShadowTokens = {
} as const;
export const SpacingTokens = {
'0': '0',
'1': '0.25rem',
'2': '0.5rem',
'3': '0.75rem',
'4': '1rem',
'5': '1.25rem',
'6': '1.5rem',
'8': '2rem',
'10': '2.5rem',
'12': '3rem',
'16': '4rem',
'20': '5rem',
'24': '6rem',
'0.5': '0.125rem',
'1.5': '0.375rem',
'component.card-padding': '1.5rem',
'component.card-gap': '1.5rem',
'component.section-padding': '4rem',
'component.form-gap': '1rem',
'component.button-padding-x': '1rem',
'component.navbar-height': '4rem',
} as const;
export const TypographyTokens = {
'fontSize.xs': '0.75rem',
'fontSize.sm': '0.875rem',
'fontSize.base': '1rem',
'fontSize.lg': '1.125rem',
'fontSize.xl': '1.25rem',
'fontSize.2xl': '1.5rem',
'fontSize.3xl': '1.875rem',
'fontSize.4xl': '2.25rem',
'fontSize.5xl': '3rem',
'fontWeight.normal': '400',
'fontWeight.medium': '500',
'fontWeight.semibold': '600',
'fontWeight.bold': '700',
'lineHeight.tight': '1.25rem',
'lineHeight.normal': '1.5rem',
'lineHeight.relaxed': '1.625rem',
'letterSpacing.tight': '-0.025em',
'letterSpacing.normal': '0em',
'letterSpacing.wide': '0.05em',
} as const;

View File

@@ -1,11 +1,87 @@
'use client'
import * as React from 'react'
import {
ThemeProvider as NextThemesProvider,
type ThemeProviderProps,
} from 'next-themes'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
type Theme = 'light' | 'dark' | 'system'
interface ThemeProviderProps {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
attribute?: string
}
interface ThemeContextValue {
theme: Theme
resolvedTheme: 'light' | 'dark'
setTheme: (theme: Theme) => void
}
const ThemeContext = React.createContext<ThemeContextValue | undefined>(undefined)
function getSystemTheme(): 'light' | 'dark' {
if (typeof window === 'undefined') return 'light'
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
}
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'greyhaven-theme',
attribute = 'class',
}: ThemeProviderProps) {
const [theme, setThemeState] = React.useState<Theme>(() => {
if (typeof window === 'undefined') return defaultTheme
return (localStorage.getItem(storageKey) as Theme) || defaultTheme
})
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme
const setTheme = React.useCallback(
(newTheme: Theme) => {
setThemeState(newTheme)
if (typeof window !== 'undefined') {
localStorage.setItem(storageKey, newTheme)
}
},
[storageKey],
)
React.useEffect(() => {
const root = document.documentElement
if (attribute === 'class') {
root.classList.remove('light', 'dark')
root.classList.add(resolvedTheme)
} else {
root.setAttribute(attribute, resolvedTheme)
}
}, [resolvedTheme, attribute])
// Listen for system theme changes when theme is 'system'
React.useEffect(() => {
if (theme !== 'system') return
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handler = () => setThemeState((prev) => (prev === 'system' ? 'system' : prev))
mediaQuery.addEventListener('change', handler)
return () => mediaQuery.removeEventListener('change', handler)
}, [theme])
return (
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const context = React.useContext(ThemeContext)
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider')
}
return context
}

View File

@@ -0,0 +1,86 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const ctaSectionVariants = cva('py-16 px-6', {
variants: {
variant: {
centered: 'text-center',
'left-aligned': 'text-left',
},
background: {
default: 'bg-background',
muted: 'bg-muted',
accent: 'bg-primary text-primary-foreground',
subtle: 'bg-primary/5',
},
},
defaultVariants: {
variant: 'centered',
background: 'muted',
},
})
interface CTASectionProps
extends React.ComponentProps<'section'>,
VariantProps<typeof ctaSectionVariants> {
heading: React.ReactNode
description?: React.ReactNode
actions?: React.ReactNode
}
function CTASection({
className,
variant,
background,
heading,
description,
actions,
children,
...props
}: CTASectionProps) {
return (
<section
data-slot="cta-section"
className={cn(ctaSectionVariants({ variant, background, className }))}
{...props}
>
<div
className={cn(
'max-w-3xl',
variant === 'centered' && 'mx-auto',
)}
>
<h2 className="font-serif text-2xl sm:text-3xl font-semibold tracking-tight mb-4">
{heading}
</h2>
{description && (
<p
className={cn(
'text-base font-sans mb-8 leading-relaxed',
background === 'accent'
? 'text-primary-foreground/80'
: 'text-muted-foreground',
)}
>
{description}
</p>
)}
{actions && (
<div
className={cn(
'flex flex-wrap gap-4',
variant === 'centered' && 'justify-center',
)}
>
{actions}
</div>
)}
{children}
</div>
</section>
)
}
export { CTASection, ctaSectionVariants }

109
components/ui/footer.tsx Normal file
View File

@@ -0,0 +1,109 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const footerVariants = cva(
'border-t border-border bg-background font-sans',
{
variants: {
variant: {
minimal: 'py-8',
full: 'py-12',
},
},
defaultVariants: {
variant: 'minimal',
},
},
)
interface FooterLinkGroup {
title: string
links: { label: string; href: string }[]
}
interface FooterProps
extends React.ComponentProps<'footer'>,
VariantProps<typeof footerVariants> {
logo?: React.ReactNode
copyright?: React.ReactNode
linkGroups?: FooterLinkGroup[]
actions?: React.ReactNode
}
function Footer({
className,
variant,
logo,
copyright,
linkGroups,
actions,
children,
...props
}: FooterProps) {
if (variant === 'full' && linkGroups) {
return (
<footer
data-slot="footer"
className={cn(footerVariants({ variant, className }))}
{...props}
>
<div className="container mx-auto px-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 mb-8">
{logo && (
<div className="col-span-2 md:col-span-1">
{logo}
</div>
)}
{linkGroups.map((group) => (
<div key={group.title}>
<h4 className="text-sm font-semibold mb-4">{group.title}</h4>
<ul className="space-y-2">
{group.links.map((link) => (
<li key={link.href}>
<a
href={link.href}
className="text-sm text-muted-foreground hover:text-primary transition-colors"
>
{link.label}
</a>
</li>
))}
</ul>
</div>
))}
</div>
<div className="border-t border-border pt-8 flex flex-col md:flex-row items-center justify-between gap-4">
{copyright && (
<p className="text-sm text-muted-foreground">{copyright}</p>
)}
{actions}
</div>
{children}
</div>
</footer>
)
}
return (
<footer
data-slot="footer"
className={cn(footerVariants({ variant, className }))}
{...props}
>
<div className="container mx-auto px-6">
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
{logo && <div>{logo}</div>}
{copyright && (
<p className="text-sm text-muted-foreground">{copyright}</p>
)}
{actions}
{children}
</div>
</div>
</footer>
)
}
export { Footer, footerVariants }

94
components/ui/hero.tsx Normal file
View File

@@ -0,0 +1,94 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const heroVariants = cva('py-24 px-6', {
variants: {
variant: {
centered: 'text-center',
'left-aligned': 'text-left',
split: 'text-left',
},
background: {
default: 'bg-background',
muted: 'bg-muted',
accent: 'bg-primary/5',
dark: 'bg-foreground text-background',
},
},
defaultVariants: {
variant: 'centered',
background: 'default',
},
})
interface HeroProps
extends React.ComponentProps<'section'>,
VariantProps<typeof heroVariants> {
heading: React.ReactNode
subheading?: React.ReactNode
actions?: React.ReactNode
media?: React.ReactNode
}
function Hero({
className,
variant,
background,
heading,
subheading,
actions,
media,
children,
...props
}: HeroProps) {
const isSplit = variant === 'split'
return (
<section
data-slot="hero"
className={cn(heroVariants({ variant, background, className }))}
{...props}
>
<div
className={cn(
'max-w-7xl mx-auto',
isSplit && 'grid grid-cols-1 lg:grid-cols-2 gap-12 items-center',
)}
>
<div
className={cn(
variant === 'centered' && 'max-w-3xl mx-auto',
!isSplit && 'max-w-3xl',
)}
>
<h1 className="font-serif text-4xl sm:text-5xl font-semibold tracking-tight mb-6">
{heading}
</h1>
{subheading && (
<p className="text-lg text-muted-foreground font-sans mb-8 leading-relaxed">
{subheading}
</p>
)}
{actions && (
<div
className={cn(
'flex flex-wrap gap-4',
variant === 'centered' && 'justify-center',
)}
>
{actions}
</div>
)}
{children}
</div>
{isSplit && media && (
<div className="flex items-center justify-center">{media}</div>
)}
</div>
</section>
)
}
export { Hero, heroVariants }

92
components/ui/logo.tsx Normal file
View File

@@ -0,0 +1,92 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const logoVariants = cva('inline-block', {
variants: {
size: {
sm: 'h-6 w-auto',
md: 'h-8 w-auto',
lg: 'h-10 w-auto',
xl: 'h-14 w-auto',
},
variant: {
color: '',
monochrome: '',
},
},
defaultVariants: {
size: 'md',
variant: 'color',
},
})
function Logo({
className,
size,
variant,
...props
}: React.ComponentProps<'svg'> &
VariantProps<typeof logoVariants>) {
return (
<svg
data-slot="logo"
viewBox="0 0 1818 448"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={cn(logoVariants({ size, variant, className }))}
{...props}
>
<g clipPath="url(#greyhaven-logo-clip)">
<path
d="M625.8 313.476L623.156 286.907C614.009 302.244 592.449 317.924 559.067 317.924C504.436 317.924 455.996 277.76 455.996 208.662C455.996 139.564 507.08 99.7111 561.4 99.7111C612.204 99.7111 644.684 128.956 655.884 163.489L622.502 176.182C615.409 152.569 594.751 132.471 561.369 132.471C527.987 132.471 491.991 156.676 491.991 208.662C491.991 260.649 525.062 285.444 561.089 285.444C603.307 285.444 619.267 256.511 621.04 238.498H551.942V207.48H654.422V313.476H625.769H625.8Z"
className={variant === 'monochrome' ? 'fill-foreground' : 'fill-foreground'}
/>
<path
d="M771.649 203.622C767.822 203.031 763.964 202.751 760.418 202.751C733.849 202.751 721.747 218.089 721.747 244.969V313.476H687.493V169.68H720.876V192.702C727.658 177.053 743.618 167.907 762.502 167.907C766.64 167.907 770.187 168.498 771.649 168.778V203.622Z"
className={variant === 'monochrome' ? 'fill-foreground' : 'fill-foreground'}
/>
<path
d="M919.582 272.44C911.898 297.547 889.156 317.924 854.622 317.924C815.64 317.924 781.107 289.582 781.107 240.862C781.107 195.378 814.769 165.262 851.076 165.262C895.378 165.262 921.356 194.507 921.356 239.96C921.356 245.56 920.764 250.289 920.453 250.88H815.329C816.2 272.72 833.342 288.369 854.591 288.369C875.84 288.369 885.889 277.449 890.618 263.262L919.551 272.409L919.582 272.44ZM886.822 225.773C886.231 208.942 875 193.884 851.387 193.884C829.827 193.884 817.444 210.404 816.262 225.773H886.822Z"
className={variant === 'monochrome' ? 'fill-foreground' : 'fill-foreground'}
/>
<path
d="M949.542 371.653L984.107 296.364L922.693 169.68H961.365L1002.71 260.618L1041.38 169.68H1077.69L986.16 371.653H949.542Z"
className={variant === 'monochrome' ? 'fill-foreground' : 'fill-foreground'}
/>
<path
d="M1128.09 313.476H1093.84V99.68H1128.09V183.556C1137.83 170.862 1154.07 165.542 1169.12 165.542C1204.56 165.542 1221.67 190.929 1221.67 222.538V313.476H1187.42V228.418C1187.42 210.684 1179.45 196.529 1157.89 196.529C1139.01 196.529 1128.65 210.684 1128.06 229.009V313.476H1128.09Z"
className={variant === 'monochrome' ? 'fill-foreground' : 'fill-foreground'}
/>
<path
d="M1288.56 231.093L1325.46 225.493C1333.73 224.311 1336.1 220.173 1336.1 215.164C1336.1 203.062 1327.82 193.324 1308.94 193.324C1290.05 193.324 1280.88 204.836 1279.41 219.302L1248.12 212.209C1250.76 187.413 1273.22 165.262 1308.66 165.262C1352.96 165.262 1369.79 190.369 1369.79 218.991V290.453C1369.79 303.458 1371.28 312.013 1371.56 313.476H1339.68C1339.4 312.573 1338.21 306.693 1338.21 295.151C1331.43 306.071 1317.24 317.893 1293.91 317.893C1263.8 317.893 1245.19 297.236 1245.19 274.493C1245.19 248.796 1264.08 234.64 1288.59 231.093H1288.56ZM1336.1 253.836V247.333L1298.61 252.933C1287.97 254.707 1279.41 260.618 1279.41 272.44C1279.41 282.178 1286.79 291.044 1300.38 291.044C1319.58 291.044 1336.1 281.898 1336.1 253.836Z"
className={variant === 'monochrome' ? 'fill-foreground' : 'fill-foreground'}
/>
<path
d="M1465.52 313.476H1431.27L1372.81 169.68H1410.61L1448.69 272.44L1485.9 169.68H1521.92L1465.52 313.476Z"
className={variant === 'monochrome' ? 'fill-foreground' : 'fill-foreground'}
/>
<path
d="M1663.08 272.44C1655.39 297.547 1632.65 317.924 1598.12 317.924C1559.13 317.924 1524.6 289.582 1524.6 240.862C1524.6 195.378 1558.26 165.262 1594.57 165.262C1638.87 165.262 1664.85 194.507 1664.85 239.96C1664.85 245.56 1664.26 250.289 1663.95 250.88H1558.82C1559.69 272.72 1576.84 288.369 1598.08 288.369C1619.33 288.369 1629.38 277.449 1634.11 263.262L1663.04 272.409L1663.08 272.44ZM1630.28 225.773C1629.69 208.942 1618.46 193.884 1594.85 193.884C1573.29 193.884 1560.91 210.404 1559.72 225.773H1630.28Z"
className={variant === 'monochrome' ? 'fill-foreground' : 'fill-foreground'}
/>
<path
d="M1724.12 313.476H1689.86V169.68H1723.24V188.876C1732.7 172.356 1749.81 165.542 1765.77 165.542C1800.9 165.542 1817.73 190.929 1817.73 222.538V313.476H1783.48V228.418C1783.48 210.684 1775.51 196.529 1753.95 196.529C1734.48 196.529 1724.12 211.587 1724.12 230.471V313.444V313.476Z"
className={variant === 'monochrome' ? 'fill-foreground' : 'fill-foreground'}
/>
<path
d="M345.582 100.551L287.498 66.4533H284.356L232.462 96.9111V34.0978L174.378 0H171.236L113.151 34.0978V96.9111L61.2578 66.4533H58.1156L0 100.551V102.791V347.013L58.0844 381.609H61.2578L113.12 350.747V413.467L171.204 448.062H174.378L232.462 413.467V350.747L284.324 381.609H287.498L345.582 347.013V100.551ZM59.6711 72.7378L111.627 103.258L59.6711 134.182L7.71556 103.258L59.6711 72.7378ZM6.22222 109.604L56.56 139.564V308.436L6.22222 337.991V109.604ZM56.56 315.653V373.427L7.71556 344.338L56.56 315.653ZM62.7822 373.427V315.653L111.627 344.338L62.7822 373.427ZM113.12 337.991L62.7822 308.436V139.564L113.12 109.604V337.991ZM226.24 102.791V163.333L175.902 133.778V73.1422L226.24 43.1822V102.791ZM172.791 200.604L120.836 169.68L172.791 139.16L224.747 169.68L172.791 200.604ZM175.902 65.8933V8.12L224.747 36.8044L175.902 65.8933ZM169.68 8.12V65.8933L120.836 36.8044L169.68 8.12ZM119.342 43.1511L169.68 73.1111V133.747L119.342 163.302V43.1511ZM119.342 176.027L169.68 205.987V374.858L119.342 404.413V176.027ZM169.68 382.076V439.849L120.836 410.76L169.68 382.076ZM175.902 439.849V382.076L224.747 410.76L175.902 439.849ZM226.24 404.413L175.902 374.858V205.987L226.24 176.027V404.413ZM289.022 74.5733L337.867 103.258L289.022 132.347V74.5733ZM282.8 74.5733V132.347L233.956 103.258L282.8 74.5733ZM232.462 109.604L282.8 139.564V308.436L232.462 337.991V109.604ZM285.911 375.293L233.956 344.338L285.911 313.818L337.867 344.338L285.911 375.293ZM339.36 337.991L289.022 308.436V139.564L339.36 109.604V337.991Z"
className={variant === 'monochrome' ? 'fill-foreground' : 'fill-primary'}
/>
</g>
<defs>
<clipPath id="greyhaven-logo-clip">
<rect width="1817.73" height="448" fill="white" />
</clipPath>
</defs>
</svg>
)
}
export { Logo, logoVariants }

131
components/ui/navbar.tsx Normal file
View File

@@ -0,0 +1,131 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { MenuIcon, XIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
const navbarVariants = cva(
'fixed top-0 left-0 right-0 z-50 h-16 font-sans',
{
variants: {
variant: {
solid: 'bg-background border-b border-border',
transparent: 'bg-transparent',
minimal: 'bg-background/80 backdrop-blur-sm border-b border-border/50',
},
},
defaultVariants: {
variant: 'solid',
},
},
)
interface NavbarProps
extends React.ComponentProps<'header'>,
VariantProps<typeof navbarVariants> {
logo?: React.ReactNode
actions?: React.ReactNode
}
function Navbar({
className,
variant,
logo,
actions,
children,
...props
}: NavbarProps) {
const [mobileOpen, setMobileOpen] = React.useState(false)
return (
<header
data-slot="navbar"
className={cn(navbarVariants({ variant, className }))}
{...props}
>
<div className="container mx-auto px-6 h-full flex items-center justify-between">
{/* Logo slot — left */}
{logo && (
<div data-slot="navbar-logo" className="flex-shrink-0">
{logo}
</div>
)}
{/* Desktop nav — center */}
<nav
data-slot="navbar-nav"
className="hidden md:flex items-center gap-1 text-sm font-medium"
>
{children}
</nav>
{/* Actions slot — right */}
<div className="flex items-center gap-2">
{actions && (
<div
data-slot="navbar-actions"
className="hidden md:flex items-center gap-2"
>
{actions}
</div>
)}
{/* Mobile menu toggle */}
<Button
variant="ghost"
size="icon"
className="md:hidden"
onClick={() => setMobileOpen(!mobileOpen)}
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
aria-expanded={mobileOpen}
>
{mobileOpen ? (
<XIcon className="size-5" />
) : (
<MenuIcon className="size-5" />
)}
</Button>
</div>
</div>
{/* Mobile nav */}
{mobileOpen && (
<div
data-slot="navbar-mobile"
className="md:hidden border-b border-border bg-background"
>
<nav className="container mx-auto px-6 py-4 flex flex-col gap-2 text-sm font-medium">
{children}
</nav>
{actions && (
<div className="container mx-auto px-6 pb-4 flex flex-col gap-2">
{actions}
</div>
)}
</div>
)}
</header>
)
}
function NavbarLink({
className,
active,
...props
}: React.ComponentProps<'a'> & { active?: boolean }) {
return (
<a
data-slot="navbar-link"
data-active={active || undefined}
className={cn(
'px-3 py-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors',
'data-[active]:text-foreground data-[active]:font-semibold',
className,
)}
{...props}
/>
)
}
export { Navbar, NavbarLink, navbarVariants }

View File

@@ -0,0 +1,52 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
interface PageLayoutProps extends React.ComponentProps<'div'> {
navbar?: React.ReactNode
sidebar?: React.ReactNode
footer?: React.ReactNode
}
function PageLayout({
className,
navbar,
sidebar,
footer,
children,
...props
}: PageLayoutProps) {
return (
<div
data-slot="page-layout"
className={cn('min-h-screen flex flex-col bg-background text-foreground', className)}
{...props}
>
{navbar}
<div
className={cn(
'flex flex-1',
navbar && 'pt-16', // offset for fixed navbar
)}
>
{sidebar && (
<aside
data-slot="page-layout-sidebar"
className="hidden lg:block w-64 border-r border-border bg-background flex-shrink-0"
>
{sidebar}
</aside>
)}
<main
data-slot="page-layout-main"
className="flex-1 min-w-0"
>
{children}
</main>
</div>
{footer}
</div>
)
}
export { PageLayout }

69
components/ui/section.tsx Normal file
View File

@@ -0,0 +1,69 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const sectionVariants = cva('py-16', {
variants: {
variant: {
default: '',
highlighted: 'bg-muted',
accent: 'bg-primary/5',
},
width: {
narrow: 'max-w-3xl mx-auto',
default: 'max-w-5xl mx-auto',
wide: 'max-w-7xl mx-auto',
full: 'w-full',
},
},
defaultVariants: {
variant: 'default',
width: 'default',
},
})
interface SectionProps
extends React.ComponentProps<'section'>,
VariantProps<typeof sectionVariants> {
title?: string
description?: string
}
function Section({
className,
variant,
width,
title,
description,
children,
...props
}: SectionProps) {
return (
<section
data-slot="section"
className={cn(sectionVariants({ variant, width, className }))}
{...props}
>
<div className="px-6">
{(title || description) && (
<div className="mb-8">
{title && (
<h2 className="font-serif text-3xl font-semibold tracking-tight mb-3">
{title}
</h2>
)}
{description && (
<p className="text-muted-foreground font-sans text-base max-w-2xl">
{description}
</p>
)}
</div>
)}
{children}
</div>
</section>
)
}
export { Section, sectionVariants }

View File

@@ -1,14 +1,14 @@
'use client'
import { useTheme } from 'next-themes'
import { useTheme } from '@/components/theme-provider'
import { Toaster as Sonner, ToasterProps } from 'sonner'
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme()
const { resolvedTheme } = useTheme()
return (
<Sonner
theme={theme as ToasterProps['theme']}
theme={resolvedTheme as ToasterProps['theme']}
className="toaster group"
style={
{

430
lib/catalog.ts Normal file
View File

@@ -0,0 +1,430 @@
/**
* Greyhaven Design System — Shared Component Catalog & Token Utilities
*
* Single source of truth consumed by:
* - MCP server (mcp/server.ts)
* - SKILL.md generator (scripts/generate-skill.ts)
*/
import * as fs from 'fs'
import * as path from 'path'
// ---------------------------------------------------------------------------
// Token utilities
// ---------------------------------------------------------------------------
export const TOKEN_CATEGORIES = ['color', 'typography', 'spacing', 'radii', 'shadows', 'motion'] as const
export type TokenCategory = (typeof TOKEN_CATEGORIES)[number]
export interface FlatToken {
path: string
value: unknown
type?: string
description?: string
}
export function loadTokenFile(root: string, name: string): Record<string, unknown> {
const filePath = path.join(root, 'tokens', `${name}.json`)
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
}
export function flattenTokens(
obj: Record<string, unknown>,
prefix = '',
): FlatToken[] {
const results: FlatToken[] = []
for (const [key, val] of Object.entries(obj)) {
if (key.startsWith('$')) continue
const currentPath = prefix ? `${prefix}.${key}` : key
const node = val as Record<string, unknown>
if (node && typeof node === 'object' && '$value' in node) {
results.push({
path: currentPath,
value: node.$value,
type: node.$type as string | undefined,
description: node.$description as string | undefined,
})
} else if (node && typeof node === 'object') {
results.push(...flattenTokens(node, currentPath))
}
}
return results
}
export function getTokens(root: string, category?: string): FlatToken[] {
if (category && TOKEN_CATEGORIES.includes(category as TokenCategory)) {
const data = loadTokenFile(root, category)
return flattenTokens(data)
}
const all: FlatToken[] = []
for (const cat of TOKEN_CATEGORIES) {
try {
const data = loadTokenFile(root, cat)
all.push(...flattenTokens(data))
} catch {
// skip missing files
}
}
return all
}
// ---------------------------------------------------------------------------
// Component catalog
// ---------------------------------------------------------------------------
export interface ComponentSpec {
name: string
file: string
category: string
exports: string[]
description: string
props: string
example: string
}
export const COMPONENT_CATALOG: ComponentSpec[] = [
// ── Primitives ──────────────────────────────────────────────────────────
{
name: 'Button',
file: 'components/ui/button.tsx',
category: 'primitives',
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: '<Button variant="default" size="default">Click me</Button>',
},
{
name: 'Badge',
file: 'components/ui/badge.tsx',
category: 'primitives',
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: '<Badge variant="success">Active</Badge>',
},
{
name: 'Input',
file: 'components/ui/input.tsx',
category: 'primitives',
exports: ['Input'],
description: 'Text input field with focus ring, disabled, and aria-invalid states.',
props: 'All standard HTML input props',
example: '<Input type="email" placeholder="you@example.com" />',
},
{
name: 'Textarea',
file: 'components/ui/textarea.tsx',
category: 'primitives',
exports: ['Textarea'],
description: 'Multi-line text input.',
props: 'All standard HTML textarea props',
example: '<Textarea placeholder="Write your message..." />',
},
{
name: 'Label',
file: 'components/ui/label.tsx',
category: 'primitives',
exports: ['Label'],
description: 'Form label using Radix Label primitive.',
props: 'All standard HTML label props + Radix Label props',
example: '<Label htmlFor="email">Email</Label>',
},
{
name: 'Checkbox',
file: 'components/ui/checkbox.tsx',
category: 'primitives',
exports: ['Checkbox'],
description: 'Checkbox using Radix Checkbox primitive.',
props: 'checked?: boolean; onCheckedChange?: (checked: boolean) => void',
example: '<Checkbox id="terms" />',
},
{
name: 'Switch',
file: 'components/ui/switch.tsx',
category: 'primitives',
exports: ['Switch'],
description: 'Toggle switch using Radix Switch primitive.',
props: 'checked?: boolean; onCheckedChange?: (checked: boolean) => void',
example: '<Switch id="dark-mode" />',
},
{
name: 'Select',
file: 'components/ui/select.tsx',
category: 'primitives',
exports: ['Select', 'SelectContent', 'SelectGroup', 'SelectItem', 'SelectLabel', 'SelectTrigger', 'SelectValue'],
description: 'Dropdown select using Radix Select.',
props: 'value?: string; onValueChange?: (value: string) => void',
example: '<Select><SelectTrigger><SelectValue placeholder="Choose..." /></SelectTrigger><SelectContent><SelectItem value="a">Option A</SelectItem></SelectContent></Select>',
},
{
name: 'RadioGroup',
file: 'components/ui/radio-group.tsx',
category: 'primitives',
exports: ['RadioGroup', 'RadioGroupItem'],
description: 'Radio button group using Radix RadioGroup.',
props: 'value?: string; onValueChange?: (value: string) => void',
example: '<RadioGroup defaultValue="a"><RadioGroupItem value="a" /><RadioGroupItem value="b" /></RadioGroup>',
},
{
name: 'Toggle',
file: 'components/ui/toggle.tsx',
category: 'primitives',
exports: ['Toggle', 'toggleVariants'],
description: 'Toggle button. Variants: default, outline.',
props: 'variant?: "default" | "outline"; size?: "default" | "sm" | "lg"; pressed?: boolean',
example: '<Toggle aria-label="Bold"><BoldIcon /></Toggle>',
},
// ── Layout ──────────────────────────────────────────────────────────────
{
name: 'Card',
file: 'components/ui/card.tsx',
category: 'layout',
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: '<Card><CardHeader><CardTitle>Title</CardTitle><CardDescription>Description</CardDescription></CardHeader><CardContent>Content</CardContent></Card>',
},
{
name: 'Accordion',
file: 'components/ui/accordion.tsx',
category: 'layout',
exports: ['Accordion', 'AccordionItem', 'AccordionTrigger', 'AccordionContent'],
description: 'Expandable sections using Radix Accordion.',
props: 'type: "single" | "multiple"; collapsible?: boolean',
example: '<Accordion type="single" collapsible><AccordionItem value="item-1"><AccordionTrigger>Section 1</AccordionTrigger><AccordionContent>Content</AccordionContent></AccordionItem></Accordion>',
},
{
name: 'Tabs',
file: 'components/ui/tabs.tsx',
category: 'layout',
exports: ['Tabs', 'TabsList', 'TabsTrigger', 'TabsContent'],
description: 'Tab navigation using Radix Tabs. Pill-style triggers.',
props: 'value?: string; onValueChange?: (value: string) => void',
example: '<Tabs defaultValue="tab1"><TabsList><TabsTrigger value="tab1">Tab 1</TabsTrigger></TabsList><TabsContent value="tab1">Content</TabsContent></Tabs>',
},
{
name: 'Separator',
file: 'components/ui/separator.tsx',
category: 'layout',
exports: ['Separator'],
description: 'Visual divider line. Horizontal or vertical.',
props: 'orientation?: "horizontal" | "vertical"; decorative?: boolean',
example: '<Separator />',
},
// ── Overlay ─────────────────────────────────────────────────────────────
{
name: 'Dialog',
file: 'components/ui/dialog.tsx',
category: 'overlay',
exports: ['Dialog', 'DialogTrigger', 'DialogContent', 'DialogHeader', 'DialogTitle', 'DialogDescription', 'DialogFooter', 'DialogClose'],
description: 'Modal dialog using Radix Dialog.',
props: 'open?: boolean; onOpenChange?: (open: boolean) => void',
example: '<Dialog><DialogTrigger asChild><Button>Open</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>Title</DialogTitle></DialogHeader></DialogContent></Dialog>',
},
{
name: 'AlertDialog',
file: 'components/ui/alert-dialog.tsx',
category: 'overlay',
exports: ['AlertDialog', 'AlertDialogTrigger', 'AlertDialogContent', 'AlertDialogHeader', 'AlertDialogTitle', 'AlertDialogDescription', 'AlertDialogFooter', 'AlertDialogAction', 'AlertDialogCancel'],
description: 'Confirmation dialog requiring user action.',
props: 'open?: boolean; onOpenChange?: (open: boolean) => void',
example: '<AlertDialog><AlertDialogTrigger asChild><Button variant="destructive">Delete</Button></AlertDialogTrigger><AlertDialogContent>...</AlertDialogContent></AlertDialog>',
},
{
name: 'Tooltip',
file: 'components/ui/tooltip.tsx',
category: 'overlay',
exports: ['Tooltip', 'TooltipTrigger', 'TooltipContent', 'TooltipProvider'],
description: 'Tooltip popup (0ms delay) using Radix Tooltip.',
props: 'Standard Radix Tooltip props',
example: '<TooltipProvider><Tooltip><TooltipTrigger>Hover me</TooltipTrigger><TooltipContent>Tooltip text</TooltipContent></Tooltip></TooltipProvider>',
},
{
name: 'Popover',
file: 'components/ui/popover.tsx',
category: 'overlay',
exports: ['Popover', 'PopoverTrigger', 'PopoverContent'],
description: 'Floating content panel using Radix Popover.',
props: 'open?: boolean; onOpenChange?: (open: boolean) => void',
example: '<Popover><PopoverTrigger asChild><Button>Open</Button></PopoverTrigger><PopoverContent>Content</PopoverContent></Popover>',
},
{
name: 'Drawer',
file: 'components/ui/drawer.tsx',
category: 'overlay',
exports: ['Drawer', 'DrawerTrigger', 'DrawerContent', 'DrawerHeader', 'DrawerTitle', 'DrawerDescription', 'DrawerFooter', 'DrawerClose'],
description: 'Bottom sheet drawer using Vaul.',
props: 'open?: boolean; onOpenChange?: (open: boolean) => void',
example: '<Drawer><DrawerTrigger asChild><Button>Open</Button></DrawerTrigger><DrawerContent><DrawerHeader><DrawerTitle>Title</DrawerTitle></DrawerHeader></DrawerContent></Drawer>',
},
// ── Navigation ──────────────────────────────────────────────────────────
{
name: 'Navbar',
file: 'components/ui/navbar.tsx',
category: 'navigation',
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: '<Navbar variant="solid" logo={<Logo size="sm" />}><NavbarLink href="/">Home</NavbarLink></Navbar>',
},
{
name: 'Breadcrumb',
file: 'components/ui/breadcrumb.tsx',
category: 'navigation',
exports: ['Breadcrumb', 'BreadcrumbList', 'BreadcrumbItem', 'BreadcrumbLink', 'BreadcrumbPage', 'BreadcrumbSeparator', 'BreadcrumbEllipsis'],
description: 'Breadcrumb navigation trail.',
props: 'Standard list composition',
example: '<Breadcrumb><BreadcrumbList><BreadcrumbItem><BreadcrumbLink href="/">Home</BreadcrumbLink></BreadcrumbItem><BreadcrumbSeparator /><BreadcrumbItem><BreadcrumbPage>Current</BreadcrumbPage></BreadcrumbItem></BreadcrumbList></Breadcrumb>',
},
{
name: 'Pagination',
file: 'components/ui/pagination.tsx',
category: 'navigation',
exports: ['Pagination', 'PaginationContent', 'PaginationItem', 'PaginationLink', 'PaginationPrevious', 'PaginationNext', 'PaginationEllipsis'],
description: 'Page navigation controls.',
props: 'Standard list composition with PaginationLink items',
example: '<Pagination><PaginationContent><PaginationItem><PaginationPrevious href="#" /></PaginationItem><PaginationItem><PaginationLink href="#">1</PaginationLink></PaginationItem><PaginationItem><PaginationNext href="#" /></PaginationItem></PaginationContent></Pagination>',
},
// ── Data Display ────────────────────────────────────────────────────────
{
name: 'Table',
file: 'components/ui/table.tsx',
category: 'data',
exports: ['Table', 'TableHeader', 'TableBody', 'TableRow', 'TableHead', 'TableCell', 'TableCaption', 'TableFooter'],
description: 'Data table with header, body, footer.',
props: 'Standard HTML table element composition',
example: '<Table><TableHeader><TableRow><TableHead>Name</TableHead></TableRow></TableHeader><TableBody><TableRow><TableCell>John</TableCell></TableRow></TableBody></Table>',
},
{
name: 'Progress',
file: 'components/ui/progress.tsx',
category: 'data',
exports: ['Progress'],
description: 'Progress bar using Radix Progress.',
props: 'value?: number (0-100)',
example: '<Progress value={60} />',
},
{
name: 'Avatar',
file: 'components/ui/avatar.tsx',
category: 'data',
exports: ['Avatar', 'AvatarImage', 'AvatarFallback'],
description: 'User avatar with image and fallback.',
props: 'Standard Radix Avatar composition',
example: '<Avatar><AvatarImage src="/avatar.jpg" /><AvatarFallback>JD</AvatarFallback></Avatar>',
},
{
name: 'Calendar',
file: 'components/ui/calendar.tsx',
category: 'data',
exports: ['Calendar'],
description: 'Date picker calendar using react-day-picker.',
props: 'mode?: "single" | "range" | "multiple"; selected?: Date; onSelect?: (date: Date) => void',
example: '<Calendar mode="single" selected={date} onSelect={setDate} />',
},
// ── Feedback ────────────────────────────────────────────────────────────
{
name: 'Alert',
file: 'components/ui/alert.tsx',
category: 'feedback',
exports: ['Alert', 'AlertTitle', 'AlertDescription'],
description: 'Inline alert message. Variants: default, destructive.',
props: 'variant?: "default" | "destructive"',
example: '<Alert><AlertTitle>Heads up!</AlertTitle><AlertDescription>This is an alert.</AlertDescription></Alert>',
},
{
name: 'Skeleton',
file: 'components/ui/skeleton.tsx',
category: 'feedback',
exports: ['Skeleton'],
description: 'Loading placeholder with pulse animation.',
props: 'Standard div props (set dimensions with className)',
example: '<Skeleton className="h-4 w-[250px]" />',
},
{
name: 'Spinner',
file: 'components/ui/spinner.tsx',
category: 'feedback',
exports: ['Spinner'],
description: 'Loading spinner (Loader2Icon with spin animation).',
props: 'Standard SVG icon props',
example: '<Spinner />',
},
{
name: 'Empty',
file: 'components/ui/empty.tsx',
category: 'feedback',
exports: ['Empty'],
description: 'Empty state placeholder with header/media/title/description.',
props: 'Standard composition with sub-components',
example: '<Empty><EmptyTitle>No results</EmptyTitle><EmptyDescription>Try a different search</EmptyDescription></Empty>',
},
// ── Form ────────────────────────────────────────────────────────────────
{
name: 'Form',
file: 'components/ui/form.tsx',
category: 'form',
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: '<Form {...form}><FormField name="email" render={({field}) => (<FormItem><FormLabel>Email</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>)} /></Form>',
},
// ── Composition ─────────────────────────────────────────────────────────
{
name: 'Logo',
file: 'components/ui/logo.tsx',
category: 'composition',
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: '<Logo size="md" variant="color" />',
},
{
name: 'Hero',
file: 'components/ui/hero.tsx',
category: 'composition',
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: '<Hero variant="centered" heading="Build something great" subheading="With the Greyhaven Design System" actions={<Button>Get Started</Button>} />',
},
{
name: 'CTASection',
file: 'components/ui/cta-section.tsx',
category: 'composition',
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: '<CTASection heading="Ready to start?" description="Join thousands of developers" actions={<Button>Sign up free</Button>} />',
},
{
name: 'Section',
file: 'components/ui/section.tsx',
category: 'composition',
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: '<Section title="Features" description="What we offer" width="wide">Content</Section>',
},
{
name: 'Footer',
file: 'components/ui/footer.tsx',
category: 'composition',
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: '<Footer variant="minimal" copyright="&copy; 2024 Greyhaven" />',
},
{
name: 'PageLayout',
file: 'components/ui/page-layout.tsx',
category: 'composition',
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: '<PageLayout navbar={<Navbar />} footer={<Footer />}>Main content</PageLayout>',
},
]

304
mcp/server.ts Normal file
View File

@@ -0,0 +1,304 @@
#!/usr/bin/env node
/**
* Greyhaven Design System MCP Server
*
* Provides programmatic access to design tokens, component specs, and
* validation tools for any MCP-compatible AI agent.
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { z } from 'zod'
import * as fs from 'fs'
import * as path from 'path'
import { fileURLToPath } from 'url'
import {
COMPONENT_CATALOG,
getTokens,
type ComponentSpec,
} from '../lib/catalog.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const ROOT = path.resolve(__dirname, '..')
// ---------------------------------------------------------------------------
// Server setup
// ---------------------------------------------------------------------------
const server = new McpServer({
name: 'greyhaven-design-system',
version: '1.0.0',
})
// Tool: get_tokens
server.tool(
'get_tokens',
'Returns design token values. Optionally filter by category: color, typography, spacing, radii, shadows, motion.',
{ category: z.string().optional().describe('Token category to filter by') },
async ({ category }) => {
const tokens = getTokens(ROOT, category)
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(tokens, null, 2),
},
],
}
},
)
// Tool: get_component
server.tool(
'get_component',
'Returns the full spec for a named component: props, variants, usage example, and when to use it.',
{ name: z.string().describe('Component name (case-insensitive)') },
async ({ name }) => {
const component = COMPONENT_CATALOG.find(
(c) => c.name.toLowerCase() === name.toLowerCase(),
)
if (!component) {
return {
content: [
{
type: 'text' as const,
text: `Component "${name}" not found. Use list_components to see available components.`,
},
],
isError: true,
}
}
let source = ''
try {
source = fs.readFileSync(path.join(ROOT, component.file), 'utf-8')
} catch {
source = '(source file not readable)'
}
return {
content: [
{
type: 'text' as const,
text: JSON.stringify({ ...component, source }, null, 2),
},
],
}
},
)
// Tool: list_components
server.tool(
'list_components',
'Lists all available design system components, optionally filtered by category.',
{
category: z
.string()
.optional()
.describe(
'Category filter: primitives, layout, overlay, navigation, data, feedback, form, composition',
),
},
async ({ category }) => {
let components = COMPONENT_CATALOG
if (category) {
components = components.filter(
(c) => c.category.toLowerCase() === category.toLowerCase(),
)
}
const summary = components.map((c) => ({
name: c.name,
category: c.category,
file: c.file,
description: c.description,
}))
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(summary, null, 2),
},
],
}
},
)
// Tool: validate_colors
server.tool(
'validate_colors',
'Checks if a code snippet uses valid design system colors. Returns warnings for raw hex values that should use tokens instead.',
{ code: z.string().describe('Code string to validate') },
async ({ code }) => {
const hexRegex = /#[0-9a-fA-F]{3,8}/g
const matches = code.match(hexRegex) || []
const validHexValues = new Set([
'#f9f9f7', '#F9F9F7', '#161614', '#d95e2a', '#D95E2A',
'#b43232', '#B43232', '#f0f0ec', '#F0F0EC', '#ddddd7', '#DDDDD7',
'#c4c4bd', '#C4C4BD', '#a6a69f', '#A6A69F', '#7f7f79', '#7F7F79',
'#575753', '#2f2f2c', '#2F2F2C',
'#fff', '#FFF', '#ffffff', '#FFFFFF', '#000', '#000000',
])
const warnings: string[] = []
const seen = new Set<string>()
for (const hex of matches) {
const lower = hex.toLowerCase()
if (seen.has(lower)) continue
seen.add(lower)
if (validHexValues.has(hex)) {
warnings.push(
`${hex} -- valid Greyhaven primitive, but prefer semantic CSS variables (e.g., var(--primary)).`,
)
} else {
warnings.push(
`${hex} -- NOT a Greyhaven design token. Use semantic tokens: bg-primary, text-foreground, border-border, etc.`,
)
}
}
if (warnings.length === 0) {
return {
content: [{
type: 'text' as const,
text: 'No raw hex colors found. The code uses design system tokens correctly.',
}],
}
}
return {
content: [{
type: 'text' as const,
text: `Found ${matches.length} hex color(s):\n\n${warnings.join('\n')}`,
}],
}
},
)
// Tool: suggest_component
server.tool(
'suggest_component',
'Suggests the best Greyhaven component(s) for a described UI need.',
{ description: z.string().describe('Natural language description of what UI you need') },
async ({ description }) => {
const desc = description.toLowerCase()
const suggestions: ComponentSpec[] = []
const keywords: Record<string, string[]> = {
button: ['button', 'click', 'action', 'submit', 'cta'],
card: ['card', 'container', 'box', 'panel', 'tile'],
dialog: ['dialog', 'modal', 'popup', 'overlay', 'confirm'],
input: ['input', 'text field', 'textbox', 'form field'],
table: ['table', 'grid', 'data', 'list', 'rows', 'columns'],
navbar: ['navbar', 'navigation bar', 'header', 'top bar', 'nav'],
hero: ['hero', 'banner', 'landing', 'splash', 'headline'],
'cta-section': ['cta', 'call to action', 'signup', 'sign up'],
footer: ['footer', 'bottom', 'copyright'],
form: ['form', 'registration', 'login', 'sign in', 'contact'],
select: ['select', 'dropdown', 'choose', 'pick'],
tabs: ['tabs', 'tab', 'sections', 'switch between'],
accordion: ['accordion', 'expandable', 'collapsible', 'faq'],
alert: ['alert', 'warning', 'error', 'notification', 'message'],
badge: ['badge', 'tag', 'label', 'status', 'chip'],
avatar: ['avatar', 'profile', 'user image', 'photo'],
tooltip: ['tooltip', 'hint', 'hover info'],
progress: ['progress', 'loading', 'bar', 'percentage'],
skeleton: ['skeleton', 'loading', 'placeholder', 'shimmer'],
drawer: ['drawer', 'sheet', 'bottom sheet', 'slide'],
popover: ['popover', 'floating', 'popup content'],
separator: ['separator', 'divider', 'line', 'hr'],
breadcrumb: ['breadcrumb', 'trail', 'path'],
pagination: ['pagination', 'pages', 'next', 'previous'],
section: ['section', 'content block', 'area'],
'page-layout': ['layout', 'page', 'shell', 'scaffold', 'template'],
logo: ['logo', 'brand', 'greyhaven'],
calendar: ['calendar', 'date', 'date picker'],
}
for (const [componentName, kw] of Object.entries(keywords)) {
if (kw.some((k) => desc.includes(k))) {
const match = COMPONENT_CATALOG.find(
(c) => c.name.toLowerCase() === componentName.toLowerCase() ||
c.name.toLowerCase().replace(/\s+/g, '-') === componentName,
)
if (match) suggestions.push(match)
}
}
if (suggestions.length === 0) {
return {
content: [{
type: 'text' as const,
text: `No strong match for "${description}". Use list_components() to browse all ${COMPONENT_CATALOG.length} components.`,
}],
}
}
return {
content: [{
type: 'text' as const,
text: JSON.stringify(
suggestions.map((s) => ({
name: s.name,
category: s.category,
description: s.description,
example: s.example,
})),
null, 2,
),
}],
}
},
)
// ---------------------------------------------------------------------------
// Resources
// ---------------------------------------------------------------------------
server.resource('tokens://all', 'tokens://all', async (uri) => {
const tokens = getTokens(ROOT)
return {
contents: [{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify(tokens, null, 2),
}],
}
})
for (const component of COMPONENT_CATALOG) {
const uri = `component://${component.name.toLowerCase()}`
server.resource(uri, uri, async (resourceUri) => {
let source = ''
try {
source = fs.readFileSync(path.join(ROOT, component.file), 'utf-8')
} catch {
source = '(source not readable)'
}
return {
contents: [{
uri: resourceUri.href,
mimeType: 'application/json',
text: JSON.stringify({ ...component, source }, null, 2),
}],
}
})
}
// ---------------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------------
async function main() {
const transport = new StdioServerTransport()
await server.connect(transport)
}
main().catch(console.error)

14
mcp/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "..",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
},
"include": ["server.ts", "../lib/catalog.ts"]
}

View File

@@ -6,6 +6,9 @@ const nextConfig = {
images: {
unoptimized: true,
},
turbopack: {
root: '.',
},
}
export default nextConfig

View File

@@ -3,10 +3,16 @@
"version": "0.1.0",
"private": true,
"scripts": {
"build": "next build",
"tokens:build": "npx style-dictionary build --config style-dictionary.config.mjs",
"skill:build": "npx tsx scripts/generate-skill.ts",
"build": "pnpm tokens:build && pnpm skill:build && next build",
"dev": "next dev",
"lint": "eslint .",
"start": "next start"
"start": "next start",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"mcp:build": "npx tsc -p mcp/tsconfig.json",
"mcp:start": "npx tsx mcp/server.ts"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
@@ -61,13 +67,28 @@
"zod": "3.25.76"
},
"devDependencies": {
"@chromatic-com/storybook": "^5.1.2",
"@modelcontextprotocol/sdk": "^1.29.0",
"@storybook/addon-a11y": "^10.3.5",
"@storybook/addon-docs": "^10.3.5",
"@storybook/addon-onboarding": "^10.3.5",
"@storybook/addon-vitest": "^10.3.5",
"@storybook/nextjs-vite": "^10.3.5",
"@tailwindcss/postcss": "^4.1.9",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitest/browser-playwright": "^4.1.4",
"@vitest/coverage-v8": "^4.1.4",
"playwright": "^1.59.1",
"postcss": "^8.5",
"storybook": "^10.3.5",
"style-dictionary": "^4.4.0",
"tailwindcss": "^4.1.9",
"tsx": "^4.21.0",
"tw-animate-css": "1.3.3",
"typescript": "^5"
"typescript": "^5",
"vite": "^8.0.8",
"vitest": "^4.1.4"
}
}
}

4403
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

250
scripts/generate-skill.ts Normal file
View File

@@ -0,0 +1,250 @@
#!/usr/bin/env npx tsx
/**
* Generates skill/SKILL.md from the shared component catalog and
* W3C DTCG token files. Run via `pnpm skill:build`.
*
* Both the MCP server and this script read from lib/catalog.ts and
* tokens/*.json, so SKILL.md always stays in sync.
*/
import * as fs from 'fs'
import * as path from 'path'
import { fileURLToPath } from 'url'
import {
COMPONENT_CATALOG,
getTokens,
loadTokenFile,
flattenTokens,
TOKEN_CATEGORIES,
type FlatToken,
} from '../lib/catalog.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const ROOT = path.resolve(__dirname, '..')
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function hex(token: FlatToken): string {
const v = token.value
return typeof v === 'string' ? v : JSON.stringify(v)
}
function tokenTable(tokens: FlatToken[]): string {
const lines = ['| Token | Value | Description |', '|-------|-------|-------------|']
for (const t of tokens) {
const val = typeof t.value === 'object' ? JSON.stringify(t.value) : String(t.value)
lines.push(`| \`${t.path}\` | \`${val}\` | ${t.description || ''} |`)
}
return lines.join('\n')
}
function componentCount(): number {
return COMPONENT_CATALOG.length
}
// ---------------------------------------------------------------------------
// Build sections
// ---------------------------------------------------------------------------
function buildHeader(): string {
return `# Greyhaven Design System -- Claude Skill
> **Auto-generated** by \`scripts/generate-skill.ts\` -- DO NOT EDIT by hand.
> Re-generate: \`pnpm skill:build\`
>
> **Components**: ${componentCount()} | **Style**: shadcn/ui "new-york"
> **Stack**: React 19, Radix UI, Tailwind CSS v4, CVA, tailwind-merge, clsx, Lucide icons
> **Framework-agnostic**: No Next.js imports. Works with Vite, Remix, Astro, CRA, or any React setup.
This skill gives you full context to generate pixel-perfect, on-brand UI using the Greyhaven Design System. Every component lives in \`components/ui/\`. Use semantic tokens, never raw colors. Follow the patterns exactly.
`
}
function buildDesignPhilosophy(): string {
return `
---
## 1. Design Philosophy
- **Minimal and restrained**: Off-white + off-black + warm greys. One single accent color (orange). No gradients, no decorative color, no multiple accent hues.
- **Typography-driven**: Source Serif 4/Pro (serif) for headings and body content. Aspekta/Inter (sans) for UI labels, buttons, navigation, and form elements.
- **Calm, professional aesthetic**: Tight border-radii, subtle shadows, generous whitespace.
- **Accessibility-first**: Built on Radix UI primitives for keyboard navigation, focus management, screen reader support. Visible focus rings, disabled states, ARIA attributes.
- **Dark mode native**: Thoughtful dark theme using inverted warm greys. Orange accent persists across both modes. Toggled via \`.dark\` class.
- **Framework-agnostic**: Pure React + Radix + Tailwind. No Next.js, no framework-specific imports.
`
}
function buildTokenReference(): string {
const lines: string[] = []
lines.push('\n---\n')
lines.push('## 2. Token Quick Reference\n')
lines.push('Source of truth: `tokens/*.json` (W3C DTCG format).\n')
for (const cat of TOKEN_CATEGORIES) {
try {
const data = loadTokenFile(ROOT, cat)
const tokens = flattenTokens(data)
if (tokens.length === 0) continue
const title = cat.charAt(0).toUpperCase() + cat.slice(1)
lines.push(`### ${title}\n`)
lines.push(tokenTable(tokens))
lines.push('')
} catch {
// skip missing
}
}
return lines.join('\n')
}
function buildComponentCatalog(): string {
const lines: string[] = []
lines.push('\n---\n')
lines.push(`## 3. Component Catalog (${componentCount()} components)\n`)
lines.push('All components live in `components/ui/`. Import with `@/components/ui/<name>`.\n')
// Group by category
const categories = new Map<string, typeof COMPONENT_CATALOG>()
for (const c of COMPONENT_CATALOG) {
const cat = c.category
if (!categories.has(cat)) categories.set(cat, [])
categories.get(cat)!.push(c)
}
const categoryOrder = ['primitives', 'layout', 'overlay', 'navigation', 'data', 'feedback', 'form', 'composition']
for (const cat of categoryOrder) {
const components = categories.get(cat)
if (!components) continue
const title = cat.charAt(0).toUpperCase() + cat.slice(1)
lines.push(`### ${title}\n`)
for (const c of components) {
lines.push(`#### ${c.name}`)
lines.push(`- **File**: \`${c.file}\``)
lines.push(`- **Exports**: \`${c.exports.join('`, `')}\``)
lines.push(`- **Description**: ${c.description}`)
lines.push(`- **Props**: \`${c.props}\``)
lines.push(`- **Example**:`)
lines.push('```tsx')
lines.push(c.example)
lines.push('```')
lines.push('')
}
}
return lines.join('\n')
}
function buildCompositionRules(): string {
return `
---
## 4. Composition Rules
- **Card spacing**: \`gap-6\` between cards, \`p-6\` internal padding
- **Section rhythm**: \`py-16\` between major page sections
- **Button placement**: Primary action right, secondary left
- **Form layout**: Vertical stack with \`gap-4\`, labels above inputs
- **Navbar**: Fixed top, \`z-50\`, \`h-16\`, logo left, nav center, actions right
- **Typography pairing**: Serif (\`font-serif\`) for content headings, sans (\`font-sans\`) for UI labels/buttons
- **Color restraint**: Orange ONLY for primary actions and key emphasis -- never decorative
- **Focus pattern**: \`focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]\`
- **Disabled pattern**: \`disabled:pointer-events-none disabled:opacity-50\`
- **Aria-invalid pattern**: \`aria-invalid:ring-destructive/20 aria-invalid:border-destructive\`
- **Slot naming**: All components use \`data-slot="component-name"\`
- **Icon sizing**: \`[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0\`
`
}
function buildExtensionProtocol(): string {
return `
---
## 5. Extension Protocol
When adding new components to the system:
1. **Use CVA** for variants (\`class-variance-authority\`)
2. **Accept HTML element props** via spread: \`React.ComponentProps<'div'>\`
3. **Use \`data-slot\`** attribute: \`data-slot="component-name"\`
4. **Use \`cn()\`** from \`@/lib/utils\` for class merging
5. **Follow focus/disabled/aria patterns** from existing components
6. **Use semantic tokens only** -- never raw hex colors
7. **Support \`asChild\`** via \`@radix-ui/react-slot\` for polymorphism where appropriate
8. **Add to Storybook** with \`tags: ['autodocs']\` and all variant stories
9. **Add to \`lib/catalog.ts\`** so MCP server and SKILL.md pick it up automatically
10. **Run \`pnpm skill:build\`** to regenerate this file
### Template
\`\`\`tsx
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const myComponentVariants = cva('base-classes', {
variants: {
variant: {
default: 'default-classes',
},
size: {
default: 'size-classes',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
})
function MyComponent({
className,
variant,
size,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof myComponentVariants>) {
return (
<div
data-slot="my-component"
className={cn(myComponentVariants({ variant, size, className }))}
{...props}
/>
)
}
export { MyComponent, myComponentVariants }
\`\`\`
`
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
const skill = [
buildHeader(),
buildDesignPhilosophy(),
buildTokenReference(),
buildComponentCatalog(),
buildCompositionRules(),
buildExtensionProtocol(),
].join('\n')
const outPath = path.join(ROOT, 'skill', 'SKILL.md')
fs.mkdirSync(path.dirname(outPath), { recursive: true })
fs.writeFileSync(outPath, skill, 'utf-8')
const lineCount = skill.split('\n').length
console.log(`skill/SKILL.md generated (${lineCount} lines, ${componentCount()} components)`)
}
main()

662
skill/SKILL.md Normal file
View File

@@ -0,0 +1,662 @@
# Greyhaven Design System -- Claude Skill
> **Auto-generated** by `scripts/generate-skill.ts` -- DO NOT EDIT by hand.
> Re-generate: `pnpm skill:build`
>
> **Components**: 37 | **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.
---
## 1. Design Philosophy
- **Minimal and restrained**: Off-white + off-black + warm greys. One single accent color (orange). No gradients, no decorative color, no multiple accent hues.
- **Typography-driven**: Source Serif 4/Pro (serif) for headings and body content. Aspekta/Inter (sans) for UI labels, buttons, navigation, and form elements.
- **Calm, professional aesthetic**: Tight border-radii, subtle shadows, generous whitespace.
- **Accessibility-first**: Built on Radix UI primitives for keyboard navigation, focus management, screen reader support. Visible focus rings, disabled states, ARIA attributes.
- **Dark mode native**: Thoughtful dark theme using inverted warm greys. Orange accent persists across both modes. Toggled via `.dark` class.
- **Framework-agnostic**: Pure React + Radix + Tailwind. No Next.js, no framework-specific imports.
---
## 2. 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","Inter","ui-sans-serif","system-ui","sans-serif"]` | UI labels, buttons, nav, forms — Aspekta primary, Inter fallback |
| `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 |
---
## 3. 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>
```
---
## 4. Composition Rules
- **Card spacing**: `gap-6` between cards, `p-6` internal padding
- **Section rhythm**: `py-16` between major page sections
- **Button placement**: Primary action right, secondary left
- **Form layout**: Vertical stack with `gap-4`, labels above inputs
- **Navbar**: Fixed top, `z-50`, `h-16`, logo left, nav center, actions right
- **Typography pairing**: Serif (`font-serif`) for content headings, sans (`font-sans`) for UI labels/buttons
- **Color restraint**: Orange ONLY for primary actions and key emphasis -- never decorative
- **Focus pattern**: `focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]`
- **Disabled pattern**: `disabled:pointer-events-none disabled:opacity-50`
- **Aria-invalid pattern**: `aria-invalid:ring-destructive/20 aria-invalid:border-destructive`
- **Slot naming**: All components use `data-slot="component-name"`
- **Icon sizing**: `[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0`
---
## 5. Extension Protocol
When adding new components to the system:
1. **Use CVA** for variants (`class-variance-authority`)
2. **Accept HTML element props** via spread: `React.ComponentProps<'div'>`
3. **Use `data-slot`** attribute: `data-slot="component-name"`
4. **Use `cn()`** from `@/lib/utils` for class merging
5. **Follow focus/disabled/aria patterns** from existing components
6. **Use semantic tokens only** -- never raw hex colors
7. **Support `asChild`** via `@radix-ui/react-slot` for polymorphism where appropriate
8. **Add to Storybook** with `tags: ['autodocs']` and all variant stories
9. **Add to `lib/catalog.ts`** so MCP server and SKILL.md pick it up automatically
10. **Run `pnpm skill:build`** to regenerate this file
### Template
```tsx
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const myComponentVariants = cva('base-classes', {
variants: {
variant: {
default: 'default-classes',
},
size: {
default: 'size-classes',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
})
function MyComponent({
className,
variant,
size,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof myComponentVariants>) {
return (
<div
data-slot="my-component"
className={cn(myComponentVariants({ variant, size, className }))}
{...props}
/>
)
}
export { MyComponent, myComponentVariants }
```

68
skill/install.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/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.
#
# 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
set -euo pipefail
# Resolve the absolute path of the SKILL.md file relative to this script
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_FILE="${SCRIPT_DIR}/SKILL.md"
# 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"
else
echo "Usage: $0 <target-project-directory>"
echo ""
echo "Example:"
echo " $0 /path/to/my-app"
echo " $0 ."
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"
# Create the symlink
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"
fi
ln -s "$SKILL_FILE" "$LINK_PATH"
echo "Done! Greyhaven Design System skill installed."
echo ""
echo " Symlink: ${LINK_PATH}"
echo " Target: ${SKILL_FILE}"
echo ""
echo "Any Claude Code session in ${TARGET_PROJECT} will now have"
echo "full Greyhaven Design System context available."

View File

@@ -0,0 +1,111 @@
import type { Meta, StoryObj } from '@storybook/react'
import { CTASection } from '@/components/ui/cta-section'
import { Button } from '@/components/ui/button'
const meta = {
title: 'Composition/CTASection',
component: CTASection,
tags: ['autodocs'],
parameters: { layout: 'fullscreen' },
argTypes: {
variant: {
control: 'select',
options: ['centered', 'left-aligned'],
},
background: {
control: 'select',
options: ['default', 'muted', 'accent', 'subtle'],
},
},
} satisfies Meta<typeof CTASection>
export default meta
type Story = StoryObj<typeof meta>
const defaultActions = (
<>
<Button size="lg">Get Started</Button>
<Button size="lg" variant="outline">Contact Sales</Button>
</>
)
export const Default: Story = {
args: {
heading: 'Ready to get started?',
description: 'Join thousands of teams building better products with our design system.',
actions: defaultActions,
},
}
export const Centered: Story = {
args: {
variant: 'centered',
background: 'muted',
heading: 'Start building today',
description: 'Free for open source. Affordable for teams.',
actions: defaultActions,
},
}
export const LeftAligned: Story = {
args: {
variant: 'left-aligned',
background: 'muted',
heading: 'Need help getting started?',
description: 'Our team is ready to help you integrate the design system into your project.',
actions: (
<>
<Button size="lg">Talk to us</Button>
</>
),
},
}
export const AccentBackground: Story = {
args: {
variant: 'centered',
background: 'accent',
heading: 'Upgrade your workflow',
description: 'Take your team to the next level with our premium plan.',
actions: (
<>
<Button size="lg" variant="secondary">Start Free Trial</Button>
<Button
size="lg"
variant="outline"
className="border-primary-foreground/20 text-primary-foreground hover:bg-primary-foreground/10"
>
Learn More
</Button>
</>
),
},
}
export const SubtleBackground: Story = {
args: {
variant: 'centered',
background: 'subtle',
heading: 'Stay in the loop',
description: 'Subscribe to our newsletter for the latest updates and releases.',
actions: (
<Button size="lg">Subscribe</Button>
),
},
}
export const DefaultBackground: Story = {
args: {
variant: 'centered',
background: 'default',
heading: 'Questions? We have answers.',
description: 'Check out our documentation or reach out to our support team.',
actions: (
<>
<Button size="lg">View Docs</Button>
<Button size="lg" variant="ghost">Contact Support</Button>
</>
),
},
}

View File

@@ -0,0 +1,92 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Footer } from '@/components/ui/footer'
import { Logo } from '@/components/ui/logo'
import { Button } from '@/components/ui/button'
const meta = {
title: 'Composition/Footer',
component: Footer,
tags: ['autodocs'],
parameters: { layout: 'fullscreen' },
argTypes: {
variant: {
control: 'select',
options: ['minimal', 'full'],
},
},
} satisfies Meta<typeof Footer>
export default meta
type Story = StoryObj<typeof meta>
export const Minimal: Story = {
args: {
variant: 'minimal',
logo: <Logo size="sm" />,
copyright: <>&copy; 2026 Greyhaven. All rights reserved.</>,
actions: (
<div className="flex gap-4 text-sm text-muted-foreground">
<a href="#" className="hover:text-foreground transition-colors">Privacy</a>
<a href="#" className="hover:text-foreground transition-colors">Terms</a>
<a href="#" className="hover:text-foreground transition-colors">Contact</a>
</div>
),
},
}
export const Full: Story = {
args: {
variant: 'full',
logo: <Logo size="md" />,
copyright: <>&copy; 2026 Greyhaven. All rights reserved.</>,
linkGroups: [
{
title: 'Product',
links: [
{ label: 'Features', href: '#' },
{ label: 'Pricing', href: '#' },
{ label: 'Changelog', href: '#' },
{ label: 'Docs', href: '#' },
],
},
{
title: 'Company',
links: [
{ label: 'About', href: '#' },
{ label: 'Blog', href: '#' },
{ label: 'Careers', href: '#' },
{ label: 'Contact', href: '#' },
],
},
{
title: 'Legal',
links: [
{ label: 'Privacy Policy', href: '#' },
{ label: 'Terms of Service', href: '#' },
{ label: 'Cookie Policy', href: '#' },
],
},
],
actions: (
<div className="flex gap-2">
<Button variant="ghost" size="sm">Twitter</Button>
<Button variant="ghost" size="sm">GitHub</Button>
<Button variant="ghost" size="sm">Discord</Button>
</div>
),
},
}
export const MinimalNoCopyright: Story = {
args: {
variant: 'minimal',
logo: <Logo size="sm" />,
actions: (
<div className="flex gap-4 text-sm text-muted-foreground">
<a href="#" className="hover:text-foreground transition-colors">Docs</a>
<a href="#" className="hover:text-foreground transition-colors">GitHub</a>
</div>
),
},
}

View File

@@ -0,0 +1,111 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Hero } from '@/components/ui/hero'
import { Button } from '@/components/ui/button'
const meta = {
title: 'Composition/Hero',
component: Hero,
tags: ['autodocs'],
parameters: { layout: 'fullscreen' },
argTypes: {
variant: {
control: 'select',
options: ['centered', 'left-aligned', 'split'],
},
background: {
control: 'select',
options: ['default', 'muted', 'accent', 'dark'],
},
},
} satisfies Meta<typeof Hero>
export default meta
type Story = StoryObj<typeof meta>
const defaultActions = (
<>
<Button size="lg">Get Started</Button>
<Button size="lg" variant="outline">Learn More</Button>
</>
)
export const Centered: Story = {
args: {
variant: 'centered',
heading: 'Build better products with Greyhaven',
subheading:
'A modern design system that helps you create consistent, accessible, and beautiful user interfaces.',
actions: defaultActions,
},
}
export const LeftAligned: Story = {
args: {
variant: 'left-aligned',
heading: 'Ship faster with confidence',
subheading:
'Pre-built components, design tokens, and patterns so your team can focus on what matters.',
actions: defaultActions,
},
}
export const Split: Story = {
args: {
variant: 'split',
heading: 'Design meets engineering',
subheading:
'Bridging the gap between design and code with a shared language of components and tokens.',
actions: defaultActions,
media: (
<div className="w-full aspect-video bg-muted rounded-lg flex items-center justify-center text-muted-foreground">
Image / Media Placeholder
</div>
),
},
}
export const MutedBackground: Story = {
args: {
variant: 'centered',
background: 'muted',
heading: 'Welcome to the platform',
subheading: 'Everything you need to build and scale your project.',
actions: defaultActions,
},
}
export const AccentBackground: Story = {
args: {
variant: 'centered',
background: 'accent',
heading: 'Start building today',
subheading: 'Join thousands of developers using our design system.',
actions: defaultActions,
},
}
export const DarkBackground: Story = {
args: {
variant: 'centered',
background: 'dark',
heading: 'The future of design systems',
subheading: 'A bold new approach to building consistent user interfaces at scale.',
actions: (
<>
<Button size="lg" variant="secondary">Get Started</Button>
<Button size="lg" variant="outline" className="border-background/20 text-background hover:bg-background/10">
Learn More
</Button>
</>
),
},
}
export const WithoutActions: Story = {
args: {
variant: 'centered',
heading: 'A hero section without action buttons',
subheading: 'Sometimes you just need a heading and description.',
},
}

View File

@@ -0,0 +1,167 @@
import type { Meta, StoryObj } from '@storybook/react'
import { PageLayout } from '@/components/ui/page-layout'
import { Navbar, NavbarLink } from '@/components/ui/navbar'
import { Footer } from '@/components/ui/footer'
import { Hero } from '@/components/ui/hero'
import { Section } from '@/components/ui/section'
import { CTASection } from '@/components/ui/cta-section'
import { Logo } from '@/components/ui/logo'
import { Button } from '@/components/ui/button'
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from '@/components/ui/card'
const meta = {
title: 'Composition/PageLayout',
component: PageLayout,
tags: ['autodocs'],
parameters: { layout: 'fullscreen' },
} satisfies Meta<typeof PageLayout>
export default meta
type Story = StoryObj<typeof meta>
const navLinks = (
<>
<NavbarLink href="#" active>Home</NavbarLink>
<NavbarLink href="#">Features</NavbarLink>
<NavbarLink href="#">Pricing</NavbarLink>
<NavbarLink href="#">Docs</NavbarLink>
</>
)
const navActions = (
<>
<Button variant="ghost" size="sm">Log in</Button>
<Button size="sm">Sign up</Button>
</>
)
const sampleNavbar = (
<Navbar
variant="solid"
logo={<Logo size="sm" />}
actions={navActions}
>
{navLinks}
</Navbar>
)
const sampleFooter = (
<Footer
variant="minimal"
logo={<Logo size="sm" />}
copyright={<>&copy; 2026 Greyhaven. All rights reserved.</>}
actions={
<div className="flex gap-4 text-sm text-muted-foreground">
<a href="#" className="hover:text-foreground transition-colors">Privacy</a>
<a href="#" className="hover:text-foreground transition-colors">Terms</a>
</div>
}
/>
)
export const FullPage: Story = {
args: {
navbar: sampleNavbar,
footer: sampleFooter,
children: (
<>
<Hero
variant="centered"
heading="Build something great"
subheading="A complete design system for modern web applications."
actions={
<>
<Button size="lg">Get Started</Button>
<Button size="lg" variant="outline">View Docs</Button>
</>
}
/>
<Section
title="Features"
description="Everything you need to build beautiful interfaces."
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{['Components', 'Tokens', 'Patterns'].map((title) => (
<Card key={title}>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>
Pre-built {title.toLowerCase()} for rapid development.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Fully customizable {title.toLowerCase()} that follow best practices.
</p>
</CardContent>
</Card>
))}
</div>
</Section>
<CTASection
background="muted"
heading="Ready to start?"
description="Get up and running in minutes."
actions={<Button size="lg">Get Started Free</Button>}
/>
</>
),
},
}
export const WithSidebar: Story = {
args: {
navbar: sampleNavbar,
footer: sampleFooter,
sidebar: (
<nav className="p-4 space-y-2">
<h3 className="font-semibold text-sm mb-4">Navigation</h3>
{['Dashboard', 'Projects', 'Team', 'Settings'].map((item) => (
<a
key={item}
href="#"
className="block px-3 py-2 text-sm rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
{item}
</a>
))}
</nav>
),
children: (
<Section title="Dashboard" description="Overview of your workspace.">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{['Revenue', 'Users', 'Orders', 'Growth'].map((metric) => (
<Card key={metric}>
<CardHeader>
<CardTitle>{metric}</CardTitle>
<CardDescription>Last 30 days</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">1,234</p>
</CardContent>
</Card>
))}
</div>
</Section>
),
},
}
export const ContentOnly: Story = {
args: {
children: (
<Section title="Standalone Content" description="A page layout with no navbar or footer.">
<p className="text-muted-foreground">
This demonstrates the PageLayout component with only content, no navbar, sidebar, or footer.
</p>
</Section>
),
},
}

View File

@@ -0,0 +1,135 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Section } from '@/components/ui/section'
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from '@/components/ui/card'
const meta = {
title: 'Composition/Section',
component: Section,
tags: ['autodocs'],
parameters: { layout: 'fullscreen' },
argTypes: {
variant: {
control: 'select',
options: ['default', 'highlighted', 'accent'],
},
width: {
control: 'select',
options: ['narrow', 'default', 'wide', 'full'],
},
},
} satisfies Meta<typeof Section>
export default meta
type Story = StoryObj<typeof meta>
const sampleCards = (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{['Design', 'Develop', 'Deploy'].map((title) => (
<Card key={title}>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>Description for the {title.toLowerCase()} phase.</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Content explaining the {title.toLowerCase()} process in detail.
</p>
</CardContent>
</Card>
))}
</div>
)
export const Default: Story = {
args: {
title: 'Our Process',
description: 'How we build great products from concept to delivery.',
children: sampleCards,
},
}
export const Highlighted: Story = {
args: {
variant: 'highlighted',
title: 'Featured Section',
description: 'This section uses a highlighted background to stand out.',
children: sampleCards,
},
}
export const Accent: Story = {
args: {
variant: 'accent',
title: 'Accent Section',
description: 'A subtle accent background to differentiate this area.',
children: sampleCards,
},
}
export const Narrow: Story = {
args: {
width: 'narrow',
title: 'Narrow Section',
description: 'Constrained width for focused reading.',
children: (
<p className="text-muted-foreground">
This is a narrow section with max-w-3xl. Useful for text-heavy content that
benefits from shorter line lengths for readability.
</p>
),
},
}
export const Wide: Story = {
args: {
width: 'wide',
title: 'Wide Section',
description: 'Extended width for content-rich layouts.',
children: sampleCards,
},
}
export const Full: Story = {
args: {
width: 'full',
variant: 'highlighted',
title: 'Full Width Section',
description: 'Spans the full width of the viewport.',
children: sampleCards,
},
}
export const NoHeader: Story = {
args: {
children: sampleCards,
},
}
export const AllCombinations: Story = {
render: () => (
<div>
{(['default', 'highlighted', 'accent'] as const).map((variant) =>
(['narrow', 'default', 'wide'] as const).map((width) => (
<Section
key={`${variant}-${width}`}
variant={variant}
width={width}
title={`${variant} / ${width}`}
description={`Section with variant="${variant}" and width="${width}".`}
>
<div className="h-20 rounded-lg border-2 border-dashed border-muted-foreground/25 flex items-center justify-center text-sm text-muted-foreground">
Content area
</div>
</Section>
)),
)}
</div>
),
}

View File

@@ -0,0 +1,88 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Progress } from '@/components/ui/progress'
const meta = {
title: 'Data/Progress',
component: Progress,
tags: ['autodocs'],
parameters: { layout: 'centered' },
argTypes: {
value: {
control: { type: 'range', min: 0, max: 100, step: 1 },
},
},
decorators: [
(Story) => (
<div className="w-100">
<Story />
</div>
),
],
} satisfies Meta<typeof Progress>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
value: 60,
},
}
export const Empty: Story = {
args: {
value: 0,
},
}
export const Quarter: Story = {
args: {
value: 25,
},
}
export const Half: Story = {
args: {
value: 50,
},
}
export const ThreeQuarters: Story = {
args: {
value: 75,
},
}
export const Complete: Story = {
args: {
value: 100,
},
}
export const AllStages: Story = {
render: () => (
<div className="flex flex-col gap-4 w-100">
<div className="space-y-1">
<span className="text-sm text-muted-foreground">0%</span>
<Progress value={0} />
</div>
<div className="space-y-1">
<span className="text-sm text-muted-foreground">25%</span>
<Progress value={25} />
</div>
<div className="space-y-1">
<span className="text-sm text-muted-foreground">50%</span>
<Progress value={50} />
</div>
<div className="space-y-1">
<span className="text-sm text-muted-foreground">75%</span>
<Progress value={75} />
</div>
<div className="space-y-1">
<span className="text-sm text-muted-foreground">100%</span>
<Progress value={100} />
</div>
</div>
),
}

View File

@@ -0,0 +1,117 @@
import type { Meta, StoryObj } from '@storybook/react'
import {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
} from '@/components/ui/table'
const meta = {
title: 'Data/Table',
component: Table,
tags: ['autodocs'],
parameters: { layout: 'centered' },
} satisfies Meta<typeof Table>
export default meta
type Story = StoryObj<typeof meta>
const invoices = [
{ invoice: 'INV001', status: 'Paid', method: 'Credit Card', amount: '$250.00' },
{ invoice: 'INV002', status: 'Pending', method: 'PayPal', amount: '$150.00' },
{ invoice: 'INV003', status: 'Unpaid', method: 'Bank Transfer', amount: '$350.00' },
{ invoice: 'INV004', status: 'Paid', method: 'Credit Card', amount: '$450.00' },
{ invoice: 'INV005', status: 'Paid', method: 'PayPal', amount: '$550.00' },
]
export const Default: Story = {
render: () => (
<div className="w-150">
<Table>
<TableCaption>A list of your recent invoices.</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-25">Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead>Method</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoices.map((invoice) => (
<TableRow key={invoice.invoice}>
<TableCell className="font-medium">{invoice.invoice}</TableCell>
<TableCell>{invoice.status}</TableCell>
<TableCell>{invoice.method}</TableCell>
<TableCell className="text-right">{invoice.amount}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={3}>Total</TableCell>
<TableCell className="text-right">$1,750.00</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
),
}
export const Simple: Story = {
render: () => (
<div className="w-100">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Role</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>Alice</TableCell>
<TableCell>Engineer</TableCell>
</TableRow>
<TableRow>
<TableCell>Bob</TableCell>
<TableCell>Designer</TableCell>
</TableRow>
<TableRow>
<TableCell>Charlie</TableCell>
<TableCell>Manager</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
),
}
export const Empty: Story = {
render: () => (
<div className="w-100">
<Table>
<TableCaption>No data available.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground h-24">
No results found.
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
),
}

View File

@@ -0,0 +1,71 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Terminal, AlertCircle } from 'lucide-react'
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'
const meta = {
title: 'Feedback/Alert',
component: Alert,
tags: ['autodocs'],
parameters: { layout: 'centered' },
argTypes: {
variant: {
control: 'select',
options: ['default', 'destructive'],
},
},
decorators: [
(Story) => (
<div className="w-125">
<Story />
</div>
),
],
} satisfies Meta<typeof Alert>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<Alert>
<Terminal className="size-4" />
<AlertTitle>Heads up!</AlertTitle>
<AlertDescription>
You can add components to your app using the CLI.
</AlertDescription>
</Alert>
),
}
export const Destructive: Story = {
render: () => (
<Alert variant="destructive">
<AlertCircle className="size-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
Your session has expired. Please log in again.
</AlertDescription>
</Alert>
),
}
export const WithoutIcon: Story = {
render: () => (
<Alert>
<AlertTitle>Note</AlertTitle>
<AlertDescription>
This alert has no icon, just a title and description.
</AlertDescription>
</Alert>
),
}
export const TitleOnly: Story = {
render: () => (
<Alert>
<Terminal className="size-4" />
<AlertTitle>A simple alert with only a title.</AlertTitle>
</Alert>
),
}

View File

@@ -0,0 +1,63 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Skeleton } from '@/components/ui/skeleton'
const meta = {
title: 'Feedback/Skeleton',
component: Skeleton,
tags: ['autodocs'],
parameters: { layout: 'centered' },
} satisfies Meta<typeof Skeleton>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
className: 'h-4 w-62.5',
},
}
export const Circle: Story = {
args: {
className: 'size-12 rounded-full',
},
}
export const CardSkeleton: Story = {
render: () => (
<div className="flex items-center space-x-4">
<Skeleton className="size-12 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-4 w-62.5" />
<Skeleton className="h-4 w-50" />
</div>
</div>
),
}
export const FormSkeleton: Story = {
render: () => (
<div className="space-y-4 w-75">
<div className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-9 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-30" />
<Skeleton className="h-9 w-full" />
</div>
<Skeleton className="h-9 w-25" />
</div>
),
}
export const TextBlock: Story = {
render: () => (
<div className="space-y-2 w-87.5">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
),
}

View File

@@ -0,0 +1,54 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Spinner } from '@/components/ui/spinner'
const meta = {
title: 'Feedback/Spinner',
component: Spinner,
tags: ['autodocs'],
parameters: { layout: 'centered' },
} satisfies Meta<typeof Spinner>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Small: Story = {
args: {
className: 'size-3 animate-spin',
},
}
export const Large: Story = {
args: {
className: 'size-8 animate-spin',
},
}
export const ExtraLarge: Story = {
args: {
className: 'size-12 animate-spin',
},
}
export const WithText: Story = {
render: () => (
<div className="flex items-center gap-2">
<Spinner />
<span className="text-sm text-muted-foreground">Loading...</span>
</div>
),
}
export const Sizes: Story = {
render: () => (
<div className="flex items-center gap-4">
<Spinner className="size-3 animate-spin" />
<Spinner />
<Spinner className="size-6 animate-spin" />
<Spinner className="size-8 animate-spin" />
<Spinner className="size-12 animate-spin" />
</div>
),
}

View File

@@ -0,0 +1,211 @@
import React from 'react'
import type { Meta, StoryObj } from '@storybook/react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
const meta = {
title: 'Form/Form',
component: Form,
tags: ['autodocs'],
parameters: { layout: 'centered' },
} satisfies Meta<typeof Form>
export default meta
type Story = StoryObj<typeof meta>
const profileSchema = z.object({
username: z
.string()
.min(2, { message: 'Username must be at least 2 characters.' })
.max(30, { message: 'Username must not be longer than 30 characters.' }),
email: z.string().email({ message: 'Please enter a valid email address.' }),
})
type ProfileValues = z.infer<typeof profileSchema>
function ProfileForm() {
const form = useForm<ProfileValues>({
resolver: zodResolver(profileSchema),
defaultValues: {
username: '',
email: '',
},
})
function onSubmit(data: ProfileValues) {
alert(JSON.stringify(data, null, 2))
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="w-100 space-y-6">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="johndoe" {...field} />
</FormControl>
<FormDescription>
This is your public display name.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="john@example.com" {...field} />
</FormControl>
<FormDescription>
We will never share your email with anyone.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
)
}
export const Default: Story = {
render: () => <ProfileForm />,
}
const loginSchema = z.object({
email: z.string().email({ message: 'Invalid email address.' }),
password: z
.string()
.min(8, { message: 'Password must be at least 8 characters.' }),
})
type LoginValues = z.infer<typeof loginSchema>
function LoginForm() {
const form = useForm<LoginValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: '',
password: '',
},
})
function onSubmit(data: LoginValues) {
alert(JSON.stringify(data, null, 2))
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="w-100 space-y-6">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="you@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" placeholder="Enter password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full">
Sign in
</Button>
</form>
</Form>
)
}
export const Login: Story = {
render: () => <LoginForm />,
}
function PrefilledErrorForm() {
const form = useForm<ProfileValues>({
resolver: zodResolver(profileSchema),
defaultValues: {
username: 'a',
email: 'not-an-email',
},
})
// Trigger validation on mount
React.useEffect(() => {
form.trigger()
}, [form])
return (
<Form {...form}>
<form className="w-100 space-y-6">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
)
}
export const WithErrors: Story = {
render: () => <PrefilledErrorForm />,
}

View File

@@ -0,0 +1,87 @@
import type { Meta, StoryObj } from '@storybook/react'
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from '@/components/ui/accordion'
const meta = {
title: 'Layout/Accordion',
component: Accordion,
tags: ['autodocs'],
parameters: { layout: 'centered' },
} satisfies Meta<typeof Accordion>
export default meta
type Story = StoryObj<typeof meta>
export const Single: Story = {
render: () => (
<Accordion type="single" collapsible className="w-100">
<AccordionItem value="item-1">
<AccordionTrigger>Is it accessible?</AccordionTrigger>
<AccordionContent>
Yes. It adheres to the WAI-ARIA design pattern.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Is it styled?</AccordionTrigger>
<AccordionContent>
Yes. It comes with default styles that match the other components.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger>Is it animated?</AccordionTrigger>
<AccordionContent>
Yes. It uses CSS animations for smooth open and close transitions.
</AccordionContent>
</AccordionItem>
</Accordion>
),
}
export const Multiple: Story = {
render: () => (
<Accordion type="multiple" className="w-100">
<AccordionItem value="item-1">
<AccordionTrigger>What is Greyhaven?</AccordionTrigger>
<AccordionContent>
Greyhaven is a design system built with Radix UI and Tailwind CSS.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>How do I install it?</AccordionTrigger>
<AccordionContent>
You can install it via npm or pnpm. Check the documentation for details.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger>Can I customize themes?</AccordionTrigger>
<AccordionContent>
Absolutely. The design system uses CSS custom properties for full theme control.
</AccordionContent>
</AccordionItem>
</Accordion>
),
}
export const DefaultOpen: Story = {
render: () => (
<Accordion type="single" defaultValue="item-1" collapsible className="w-100">
<AccordionItem value="item-1">
<AccordionTrigger>Open by default</AccordionTrigger>
<AccordionContent>
This accordion item is open by default.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Click to open</AccordionTrigger>
<AccordionContent>
This accordion item starts closed.
</AccordionContent>
</AccordionItem>
</Accordion>
),
}

View File

@@ -0,0 +1,97 @@
import type { Meta, StoryObj } from '@storybook/react'
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
CardAction,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
const meta = {
title: 'Layout/Card',
component: Card,
tags: ['autodocs'],
parameters: { layout: 'centered' },
} satisfies Meta<typeof Card>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<Card className="w-87.5">
<CardHeader>
<CardTitle>Card Title</CardTitle>
<CardDescription>Card description goes here.</CardDescription>
</CardHeader>
<CardContent>
<p>Card content with some example text to demonstrate the layout.</p>
</CardContent>
<CardFooter>
<Button>Action</Button>
</CardFooter>
</Card>
),
}
export const WithAction: Story = {
render: () => (
<Card className="w-87.5">
<CardHeader>
<CardTitle>Notifications</CardTitle>
<CardDescription>You have 3 unread messages.</CardDescription>
<CardAction>
<Button variant="outline" size="sm">Mark all read</Button>
</CardAction>
</CardHeader>
<CardContent>
<p>Here are your latest notifications.</p>
</CardContent>
</Card>
),
}
export const Simple: Story = {
render: () => (
<Card className="w-87.5">
<CardHeader>
<CardTitle>Simple Card</CardTitle>
</CardHeader>
<CardContent>
<p>A card with just a title and content.</p>
</CardContent>
</Card>
),
}
export const WithFooter: Story = {
render: () => (
<Card className="w-87.5">
<CardHeader>
<CardTitle>Create project</CardTitle>
<CardDescription>Deploy your new project in one click.</CardDescription>
</CardHeader>
<CardContent>
<p>Configure your project settings below.</p>
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline">Cancel</Button>
<Button>Deploy</Button>
</CardFooter>
</Card>
),
}
export const ContentOnly: Story = {
render: () => (
<Card className="w-87.5">
<CardContent>
<p>A minimal card with only content, no header or footer.</p>
</CardContent>
</Card>
),
}

View File

@@ -0,0 +1,59 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Separator } from '@/components/ui/separator'
const meta = {
title: 'Layout/Separator',
component: Separator,
tags: ['autodocs'],
parameters: { layout: 'centered' },
argTypes: {
orientation: {
control: 'select',
options: ['horizontal', 'vertical'],
},
decorative: { control: 'boolean' },
},
} satisfies Meta<typeof Separator>
export default meta
type Story = StoryObj<typeof meta>
export const Horizontal: Story = {
args: {
orientation: 'horizontal',
},
decorators: [
(Story) => (
<div className="w-75">
<div className="space-y-1">
<h4 className="text-sm font-medium leading-none">Greyhaven Design System</h4>
<p className="text-sm text-muted-foreground">An open-source UI component library.</p>
</div>
<Story />
<div className="flex h-5 items-center space-x-4 text-sm">
<div>Blog</div>
<div>Docs</div>
<div>Source</div>
</div>
</div>
),
],
}
export const Vertical: Story = {
args: {
orientation: 'vertical',
},
decorators: [
(Story) => (
<div className="flex h-5 items-center space-x-4 text-sm">
<div>Blog</div>
<Story />
<div>Docs</div>
<Story />
<div>Source</div>
</div>
),
],
}

View File

@@ -0,0 +1,109 @@
import type { Meta, StoryObj } from '@storybook/react'
import {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
} from '@/components/ui/breadcrumb'
const meta = {
title: 'Navigation/Breadcrumb',
component: Breadcrumb,
tags: ['autodocs'],
parameters: { layout: 'centered' },
} satisfies Meta<typeof Breadcrumb>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#">Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#">Components</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Breadcrumb</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
),
}
export const WithEllipsis: Story = {
render: () => (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#">Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbEllipsis />
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#">Components</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Breadcrumb</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
),
}
export const TwoLevels: Story = {
render: () => (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#">Dashboard</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Settings</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
),
}
export const DeepNesting: Story = {
render: () => (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#">Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#">Products</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#">Electronics</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#">Laptops</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>MacBook Pro</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
),
}

View File

@@ -0,0 +1,94 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Navbar, NavbarLink } from '@/components/ui/navbar'
import { Logo } from '@/components/ui/logo'
import { Button } from '@/components/ui/button'
const meta = {
title: 'Navigation/Navbar',
component: Navbar,
tags: ['autodocs'],
parameters: { layout: 'fullscreen' },
argTypes: {
variant: {
control: 'select',
options: ['solid', 'transparent', 'minimal'],
},
},
decorators: [
(Story) => (
<div className="min-h-[200px]">
<Story />
</div>
),
],
} satisfies Meta<typeof Navbar>
export default meta
type Story = StoryObj<typeof meta>
const navLinks = (
<>
<NavbarLink href="#" active>Home</NavbarLink>
<NavbarLink href="#">About</NavbarLink>
<NavbarLink href="#">Services</NavbarLink>
<NavbarLink href="#">Contact</NavbarLink>
</>
)
const navActions = (
<>
<Button variant="ghost" size="sm">Log in</Button>
<Button size="sm">Sign up</Button>
</>
)
export const Solid: Story = {
args: {
variant: 'solid',
logo: <Logo size="sm" />,
actions: navActions,
children: navLinks,
},
}
export const Transparent: Story = {
args: {
variant: 'transparent',
logo: <Logo size="sm" />,
actions: navActions,
children: navLinks,
},
decorators: [
(Story) => (
<div className="min-h-[200px] bg-gradient-to-br from-primary/20 to-primary/5">
<Story />
</div>
),
],
}
export const Minimal: Story = {
args: {
variant: 'minimal',
logo: <Logo size="sm" />,
actions: navActions,
children: navLinks,
},
}
export const WithoutActions: Story = {
args: {
variant: 'solid',
logo: <Logo size="sm" />,
children: navLinks,
},
}
export const LogoOnly: Story = {
args: {
variant: 'solid',
logo: <Logo size="sm" />,
actions: navActions,
},
}

View File

@@ -0,0 +1,72 @@
import type { Meta, StoryObj } from '@storybook/react'
import {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogAction,
AlertDialogCancel,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
const meta = {
title: 'Overlay/AlertDialog',
component: AlertDialog,
tags: ['autodocs'],
parameters: { layout: 'centered' },
} satisfies Meta<typeof AlertDialog>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline">Delete Account</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete your account
and remove your data from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction>Continue</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
),
}
export const Destructive: Story = {
render: () => (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">Delete Project</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete project?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the project and all associated data.
This action cannot be reversed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction className="bg-destructive text-white hover:bg-destructive/90">
Yes, delete project
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
),
}

View File

@@ -0,0 +1,106 @@
import type { Meta, StoryObj } from '@storybook/react'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
const meta = {
title: 'Overlay/Dialog',
component: Dialog,
tags: ['autodocs'],
parameters: { layout: 'centered' },
} satisfies Meta<typeof Dialog>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Open Dialog</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Dialog Title</DialogTitle>
<DialogDescription>
This is a dialog description. It provides context about the dialog content.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<p>Dialog body content goes here.</p>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button>Confirm</Button>
</DialogFooter>
</DialogContent>
</Dialog>
),
}
export const WithForm: Story = {
render: () => (
<Dialog>
<DialogTrigger asChild>
<Button>Edit Profile</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit profile</DialogTitle>
<DialogDescription>
Make changes to your profile here. Click save when you are done.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Input id="name" defaultValue="John Doe" />
</div>
<div className="grid gap-2">
<Label htmlFor="username">Username</Label>
<Input id="username" defaultValue="@johndoe" />
</div>
</div>
<DialogFooter>
<Button type="submit">Save changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
),
}
export const NoCloseButton: Story = {
render: () => (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Open (no close button)</Button>
</DialogTrigger>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>No Close Button</DialogTitle>
<DialogDescription>
This dialog has no close button in the corner.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button>Got it</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
),
}

View File

@@ -0,0 +1,99 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Plus } from 'lucide-react'
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from '@/components/ui/tooltip'
import { Button } from '@/components/ui/button'
const meta = {
title: 'Overlay/Tooltip',
component: Tooltip,
tags: ['autodocs'],
parameters: { layout: 'centered' },
} satisfies Meta<typeof Tooltip>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline">Hover me</Button>
</TooltipTrigger>
<TooltipContent>
<p>This is a tooltip</p>
</TooltipContent>
</Tooltip>
),
}
export const Top: Story = {
render: () => (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline">Top</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Tooltip on top</p>
</TooltipContent>
</Tooltip>
),
}
export const Right: Story = {
render: () => (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline">Right</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Tooltip on right</p>
</TooltipContent>
</Tooltip>
),
}
export const Bottom: Story = {
render: () => (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline">Bottom</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Tooltip on bottom</p>
</TooltipContent>
</Tooltip>
),
}
export const Left: Story = {
render: () => (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline">Left</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Tooltip on left</p>
</TooltipContent>
</Tooltip>
),
}
export const WithIconButton: Story = {
render: () => (
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="outline">
<Plus className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Add item</p>
</TooltipContent>
</Tooltip>
),
}

View File

@@ -0,0 +1,170 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Badge } from '@/components/ui/badge'
const meta = {
title: 'Primitives/Badge',
component: Badge,
tags: ['autodocs'],
parameters: { layout: 'centered' },
argTypes: {
variant: {
control: 'select',
options: [
'default',
'secondary',
'muted',
'destructive',
'outline',
'success',
'warning',
'info',
'tag',
'value',
'whatsapp',
'email',
'telegram',
'zulip',
'platform',
],
},
},
} satisfies Meta<typeof Badge>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
children: 'Badge',
variant: 'default',
},
}
export const Secondary: Story = {
args: {
children: 'Secondary',
variant: 'secondary',
},
}
export const Muted: Story = {
args: {
children: 'Muted',
variant: 'muted',
},
}
export const Destructive: Story = {
args: {
children: 'Destructive',
variant: 'destructive',
},
}
export const Outline: Story = {
args: {
children: 'Outline',
variant: 'outline',
},
}
export const Success: Story = {
args: {
children: 'Success',
variant: 'success',
},
}
export const Warning: Story = {
args: {
children: 'Warning',
variant: 'warning',
},
}
export const Info: Story = {
args: {
children: 'Info',
variant: 'info',
},
}
export const Tag: Story = {
args: {
children: 'Tag',
variant: 'tag',
},
}
export const Value: Story = {
args: {
children: '42',
variant: 'value',
},
}
export const Whatsapp: Story = {
args: {
children: 'WhatsApp',
variant: 'whatsapp',
},
}
export const Email: Story = {
args: {
children: 'Email',
variant: 'email',
},
}
export const Telegram: Story = {
args: {
children: 'Telegram',
variant: 'telegram',
},
}
export const Zulip: Story = {
args: {
children: 'Zulip',
variant: 'zulip',
},
}
export const Platform: Story = {
args: {
children: 'Platform',
variant: 'platform',
},
}
export const AllVariants: Story = {
render: () => (
<div className="flex flex-wrap gap-2">
{(
[
'default',
'secondary',
'muted',
'destructive',
'outline',
'success',
'warning',
'info',
'tag',
'value',
'whatsapp',
'email',
'telegram',
'zulip',
'platform',
] as const
).map((variant) => (
<Badge key={variant} variant={variant}>
{variant}
</Badge>
))}
</div>
),
}

View File

@@ -0,0 +1,171 @@
import type { Meta, StoryObj } from '@storybook/react'
import { ChevronRight, Mail, Loader2, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
const meta = {
title: 'Primitives/Button',
component: Button,
tags: ['autodocs'],
parameters: { layout: 'centered' },
argTypes: {
variant: {
control: 'select',
options: ['default', 'secondary', 'outline', 'ghost', 'link', 'destructive'],
},
size: {
control: 'select',
options: ['default', 'sm', 'lg', 'icon', 'icon-sm', 'icon-lg'],
},
disabled: { control: 'boolean' },
asChild: { control: 'boolean' },
},
} satisfies Meta<typeof Button>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
children: 'Button',
variant: 'default',
size: 'default',
},
}
export const Secondary: Story = {
args: {
children: 'Secondary',
variant: 'secondary',
},
}
export const Outline: Story = {
args: {
children: 'Outline',
variant: 'outline',
},
}
export const Ghost: Story = {
args: {
children: 'Ghost',
variant: 'ghost',
},
}
export const Link: Story = {
args: {
children: 'Link',
variant: 'link',
},
}
export const Destructive: Story = {
args: {
children: 'Delete',
variant: 'destructive',
},
}
export const Small: Story = {
args: {
children: 'Small',
size: 'sm',
},
}
export const Large: Story = {
args: {
children: 'Large',
size: 'lg',
},
}
export const Icon: Story = {
args: {
size: 'icon',
children: <Plus className="size-4" />,
'aria-label': 'Add',
},
}
export const IconSmall: Story = {
args: {
size: 'icon-sm',
children: <Plus className="size-4" />,
'aria-label': 'Add',
},
}
export const IconLarge: Story = {
args: {
size: 'icon-lg',
children: <Plus className="size-4" />,
'aria-label': 'Add',
},
}
export const WithIcon: Story = {
args: {
children: (
<>
<Mail /> Login with Email
</>
),
},
}
export const WithTrailingIcon: Story = {
args: {
children: (
<>
Next <ChevronRight />
</>
),
},
}
export const Loading: Story = {
args: {
disabled: true,
children: (
<>
<Loader2 className="animate-spin" /> Please wait
</>
),
},
}
export const Disabled: Story = {
args: {
children: 'Disabled',
disabled: true,
},
}
export const AsChild: Story = {
args: {
asChild: true,
children: <a href="#">Link styled as Button</a>,
},
}
export const AllVariants: Story = {
render: () => (
<div className="flex flex-col gap-4">
{(['default', 'secondary', 'outline', 'ghost', 'link', 'destructive'] as const).map(
(variant) => (
<div key={variant} className="flex items-center gap-2">
<span className="w-24 text-sm text-muted-foreground">{variant}</span>
<Button variant={variant} size="sm">Small</Button>
<Button variant={variant} size="default">Default</Button>
<Button variant={variant} size="lg">Large</Button>
<Button variant={variant} size="icon"><Plus className="size-4" /></Button>
<Button variant={variant} disabled>Disabled</Button>
</div>
),
)}
</div>
),
}

View File

@@ -0,0 +1,88 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
const meta = {
title: 'Primitives/Input',
component: Input,
tags: ['autodocs'],
parameters: { layout: 'centered' },
argTypes: {
type: {
control: 'select',
options: ['text', 'email', 'password', 'number', 'search', 'tel', 'url', 'file'],
},
disabled: { control: 'boolean' },
placeholder: { control: 'text' },
},
} satisfies Meta<typeof Input>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
placeholder: 'Enter text...',
type: 'text',
},
}
export const Email: Story = {
args: {
type: 'email',
placeholder: 'email@example.com',
},
}
export const Password: Story = {
args: {
type: 'password',
placeholder: 'Enter password...',
},
}
export const File: Story = {
args: {
type: 'file',
},
}
export const Disabled: Story = {
args: {
placeholder: 'Disabled input',
disabled: true,
},
}
export const WithLabel: Story = {
render: () => (
<div className="grid w-full max-w-sm gap-2">
<Label htmlFor="with-label-input">Email</Label>
<Input id="with-label-input" type="email" placeholder="email@example.com" />
</div>
),
}
export const ErrorState: Story = {
render: () => (
<div className="grid w-full max-w-sm gap-2">
<Label htmlFor="error-input">Email</Label>
<Input
id="error-input"
type="email"
placeholder="email@example.com"
aria-invalid="true"
defaultValue="invalid-email"
/>
<p className="text-sm text-destructive">Please enter a valid email address.</p>
</div>
),
}
export const WithDefaultValue: Story = {
args: {
type: 'text',
defaultValue: 'Hello world',
},
}

View File

@@ -0,0 +1,103 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Bold, Italic, Underline } from 'lucide-react'
import { Toggle } from '@/components/ui/toggle'
const meta = {
title: 'Primitives/Toggle',
component: Toggle,
tags: ['autodocs'],
parameters: { layout: 'centered' },
argTypes: {
variant: {
control: 'select',
options: ['default', 'outline'],
},
size: {
control: 'select',
options: ['default', 'sm', 'lg'],
},
disabled: { control: 'boolean' },
},
} satisfies Meta<typeof Toggle>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
children: <Bold className="size-4" />,
'aria-label': 'Toggle bold',
},
}
export const Outline: Story = {
args: {
variant: 'outline',
children: <Italic className="size-4" />,
'aria-label': 'Toggle italic',
},
}
export const Small: Story = {
args: {
size: 'sm',
children: <Bold className="size-4" />,
'aria-label': 'Toggle bold',
},
}
export const Large: Story = {
args: {
size: 'lg',
children: <Bold className="size-4" />,
'aria-label': 'Toggle bold',
},
}
export const WithText: Story = {
args: {
children: (
<>
<Italic className="size-4" />
Italic
</>
),
'aria-label': 'Toggle italic',
},
}
export const Disabled: Story = {
args: {
disabled: true,
children: <Bold className="size-4" />,
'aria-label': 'Toggle bold',
},
}
export const Pressed: Story = {
args: {
defaultPressed: true,
children: <Bold className="size-4" />,
'aria-label': 'Toggle bold',
},
}
export const AllVariants: Story = {
render: () => (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<span className="w-20 text-sm text-muted-foreground">default</span>
<Toggle size="sm" aria-label="Bold"><Bold className="size-4" /></Toggle>
<Toggle size="default" aria-label="Italic"><Italic className="size-4" /></Toggle>
<Toggle size="lg" aria-label="Underline"><Underline className="size-4" /></Toggle>
</div>
<div className="flex items-center gap-2">
<span className="w-20 text-sm text-muted-foreground">outline</span>
<Toggle variant="outline" size="sm" aria-label="Bold"><Bold className="size-4" /></Toggle>
<Toggle variant="outline" size="default" aria-label="Italic"><Italic className="size-4" /></Toggle>
<Toggle variant="outline" size="lg" aria-label="Underline"><Underline className="size-4" /></Toggle>
</div>
</div>
),
}

190
style-dictionary.config.mjs Normal file
View File

@@ -0,0 +1,190 @@
import StyleDictionary from 'style-dictionary';
// Custom format: CSS custom properties with RGB triplets for Tailwind v4 compatibility
// Outputs `--background: 240 240 236;` format that existing components expect
StyleDictionary.registerFormat({
name: 'css/rgb-variables',
format: ({ dictionary, options }) => {
const selector = options.selector || ':root';
const header = options.header || '';
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) return null;
return `${parseInt(result[1], 16)} ${parseInt(result[2], 16)} ${parseInt(result[3], 16)}`;
}
// Map semantic token paths to CSS variable names
function getCssVarName(token) {
const path = token.path;
// color.semantic.X or color.dark.X → --X
if (path[0] === 'color' && (path[1] === 'semantic' || path[1] === 'dark')) {
const rest = path.slice(2);
// Handle nested like chart.1, sidebar.background
if (rest[0] === 'chart') return `--chart-${rest[1]}`;
if (rest[0] === 'sidebar') {
const subParts = rest.slice(1);
if (subParts.length === 1 && subParts[0] === 'background') return '--sidebar';
return `--sidebar-${subParts.join('-')}`;
}
return `--${rest.join('-')}`;
}
return null;
}
const lines = [];
dictionary.allTokens.forEach((token) => {
const varName = getCssVarName(token);
if (!varName) return;
const value = token.value || token.$value;
const rgb = hexToRgb(value);
if (rgb) {
const desc = token.$description || token.description;
if (desc) lines.push(` /* ${desc} */`);
lines.push(` ${varName}: ${rgb};`);
}
});
return `${header}\n${selector} {\n${lines.join('\n')}\n}`;
},
});
// Custom format: TypeScript constants
StyleDictionary.registerFormat({
name: 'typescript/constants',
format: ({ dictionary }) => {
const lines = [
'// Auto-generated by Style Dictionary — DO NOT EDIT',
'// Source: tokens/*.json (W3C DTCG format)',
'',
];
// Group tokens by top-level category
const groups = {};
dictionary.allTokens.forEach((token) => {
const group = token.path[0];
if (!groups[group]) groups[group] = [];
groups[group].push(token);
});
for (const [group, tokens] of Object.entries(groups)) {
const constName = group.charAt(0).toUpperCase() + group.slice(1) + 'Tokens';
lines.push(`export const ${constName} = {`);
tokens.forEach((token) => {
const key = token.path.slice(1).join('.');
const value = token.value || token.$value;
if (typeof value === 'string' || typeof value === 'number') {
lines.push(` '${key}': '${value}',`);
}
});
lines.push('} as const;');
lines.push('');
}
return lines.join('\n');
},
});
// Custom format: Markdown reference
StyleDictionary.registerFormat({
name: 'markdown/reference',
format: ({ dictionary }) => {
const lines = [
'# Greyhaven Design Tokens Reference',
'',
'> Auto-generated by Style Dictionary — DO NOT EDIT',
'> Source: `tokens/*.json` (W3C DTCG format)',
'',
];
const groups = {};
dictionary.allTokens.forEach((token) => {
const group = token.path[0];
if (!groups[group]) groups[group] = [];
groups[group].push(token);
});
for (const [group, tokens] of Object.entries(groups)) {
lines.push(`## ${group.charAt(0).toUpperCase() + group.slice(1)}`);
lines.push('');
lines.push('| Token | Value | Description |');
lines.push('|-------|-------|-------------|');
tokens.forEach((token) => {
const name = token.path.join('.');
const value = token.value || token.$value;
const desc = token.$description || token.description || '';
const displayValue = typeof value === 'object' ? JSON.stringify(value) : value;
lines.push(`| \`${name}\` | \`${displayValue}\` | ${desc} |`);
});
lines.push('');
}
return lines.join('\n');
},
});
export default {
source: ['tokens/**/*.json'],
preprocessors: ['tokens-studio'],
platforms: {
// CSS custom properties for light theme (semantic tokens)
cssLight: {
transformGroup: 'css',
buildPath: 'app/tokens/',
files: [
{
destination: 'tokens-light.css',
format: 'css/rgb-variables',
filter: (token) => {
return token.path[0] === 'color' && token.path[1] === 'semantic';
},
options: {
selector: ':root',
header: '/* Greyhaven Design Tokens — Light Theme\n Auto-generated by Style Dictionary — DO NOT EDIT\n Source: tokens/color.json */\n',
},
},
],
},
// CSS custom properties for dark theme
cssDark: {
transformGroup: 'css',
buildPath: 'app/tokens/',
files: [
{
destination: 'tokens-dark.css',
format: 'css/rgb-variables',
filter: (token) => {
return token.path[0] === 'color' && token.path[1] === 'dark';
},
options: {
selector: '.dark',
header: '/* Greyhaven Design Tokens — Dark Theme\n Auto-generated by Style Dictionary — DO NOT EDIT\n Source: tokens/color.json */\n',
},
},
],
},
// TypeScript constants
ts: {
transformGroup: 'js',
buildPath: 'app/tokens/',
files: [
{
destination: 'tokens.ts',
format: 'typescript/constants',
},
],
},
// Markdown reference
docs: {
transformGroup: 'css',
buildPath: 'app/tokens/',
files: [
{
destination: 'TOKENS.md',
format: 'markdown/reference',
},
],
},
},
};

168
tokens/build/TOKENS.md Normal file
View File

@@ -0,0 +1,168 @@
# Greyhaven Design Tokens Reference
> Auto-generated by Style Dictionary — DO NOT EDIT
> Source: `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` | `#f0f0ec` | Page background |
| `color.semantic.foreground` | `#161614` | Primary text |
| `color.semantic.card` | `#f9f9f7` | Card/elevated surface background |
| `color.semantic.card-foreground` | `#161614` | Card text |
| `color.semantic.popover` | `#f9f9f7` | Popover background |
| `color.semantic.popover-foreground` | `#161614` | Popover text |
| `color.semantic.primary` | `#d95e2a` | Primary accent — buttons, links, focus rings |
| `color.semantic.primary-foreground` | `#f9f9f7` | Text on primary accent |
| `color.semantic.secondary` | `#f0f0ec` | Secondary button/surface |
| `color.semantic.secondary-foreground` | `#2f2f2c` | Text on secondary surface |
| `color.semantic.muted` | `#f0f0ec` | Muted/subdued background |
| `color.semantic.muted-foreground` | `#575753` | Muted/subdued text |
| `color.semantic.accent` | `#ddddd7` | Subtle hover state |
| `color.semantic.accent-foreground` | `#161614` | Text on accent hover |
| `color.semantic.destructive` | `#b43232` | Destructive/error actions |
| `color.semantic.destructive-foreground` | `#f9f9f7` | Text on destructive |
| `color.semantic.border` | `#c4c4bd` | Default border |
| `color.semantic.input` | `#c4c4bd` | Input border |
| `color.semantic.ring` | `#d95e2a` | Focus ring |
| `color.semantic.chart.1` | `#d95e2a` | Chart accent |
| `color.semantic.chart.2` | `#575753` | Chart secondary |
| `color.semantic.chart.3` | `#7f7f79` | Chart tertiary |
| `color.semantic.chart.4` | `#a6a69f` | Chart quaternary |
| `color.semantic.chart.5` | `#2f2f2c` | Chart quinary |
| `color.semantic.sidebar.background` | `#f0f0ec` | Sidebar background |
| `color.semantic.sidebar.foreground` | `#161614` | Sidebar text |
| `color.semantic.sidebar.primary` | `#d95e2a` | Sidebar primary accent |
| `color.semantic.sidebar.primary-foreground` | `#f9f9f7` | Sidebar primary text |
| `color.semantic.sidebar.accent` | `#c4c4bd` | Sidebar accent/hover |
| `color.semantic.sidebar.accent-foreground` | `#161614` | Sidebar accent text |
| `color.semantic.sidebar.border` | `#c4c4bd` | Sidebar border |
| `color.semantic.sidebar.ring` | `#d95e2a` | Sidebar focus ring |
| `color.dark.background` | `#161614` | Dark page background |
| `color.dark.foreground` | `#f9f9f7` | Dark primary text |
| `color.dark.card` | `#2f2f2c` | Dark card surface |
| `color.dark.card-foreground` | `#f9f9f7` | Dark card text |
| `color.dark.popover` | `#2f2f2c` | Dark popover |
| `color.dark.popover-foreground` | `#f9f9f7` | Dark popover text |
| `color.dark.primary` | `#d95e2a` | Same orange in dark mode |
| `color.dark.primary-foreground` | `#f9f9f7` | Dark primary foreground |
| `color.dark.secondary` | `#575753` | Dark secondary |
| `color.dark.secondary-foreground` | `#f9f9f7` | Dark secondary text |
| `color.dark.muted` | `#575753` | Dark muted |
| `color.dark.muted-foreground` | `#c4c4bd` | Dark muted text |
| `color.dark.accent` | `#575753` | Dark accent/hover |
| `color.dark.accent-foreground` | `#f9f9f7` | Dark accent text |
| `color.dark.destructive` | `#b43232` | Same destructive in dark mode |
| `color.dark.destructive-foreground` | `#f9f9f7` | Dark destructive text |
| `color.dark.border` | `#575753` | Dark border |
| `color.dark.input` | `#575753` | Dark input border |
| `color.dark.ring` | `#d95e2a` | Dark focus ring |
| `color.dark.chart.1` | `#d95e2a` | Dark chart accent |
| `color.dark.chart.2` | `#c4c4bd` | Dark chart secondary |
| `color.dark.chart.3` | `#a6a69f` | Dark chart tertiary |
| `color.dark.chart.4` | `#7f7f79` | Dark chart quaternary |
| `color.dark.chart.5` | `#f0f0ec` | Dark chart quinary |
| `color.dark.sidebar.background` | `#2f2f2c` | Dark sidebar background |
| `color.dark.sidebar.foreground` | `#f9f9f7` | Dark sidebar text |
| `color.dark.sidebar.primary` | `#d95e2a` | Dark sidebar primary |
| `color.dark.sidebar.primary-foreground` | `#f9f9f7` | Dark sidebar primary text |
| `color.dark.sidebar.accent` | `#575753` | Dark sidebar accent |
| `color.dark.sidebar.accent-foreground` | `#f9f9f7` | Dark sidebar accent text |
| `color.dark.sidebar.border` | `#575753` | Dark sidebar border |
| `color.dark.sidebar.ring` | `#d95e2a` | Dark sidebar ring |
## 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` | `cubic-bezier(0.4, 0, 0.2, 1)` | Standard ease-in-out |
| `motion.easing.in` | `cubic-bezier(0.4, 0, 1, 1)` | Ease-in for exits |
| `motion.easing.out` | `cubic-bezier(0, 0, 0.2, 1)` | Ease-out for entrances |
## 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) |
## Shadow
| Token | Value | Description |
|-------|-------|-------------|
| `shadow.xs` | `0 1px 2px 0 rgba(22, 22, 20, 0.05)` | Subtle shadow for buttons, inputs |
| `shadow.sm` | `0 1px 3px 0 rgba(22, 22, 20, 0.1)` | Small shadow for cards |
| `shadow.md` | `0 4px 6px -1px rgba(22, 22, 20, 0.1)` | Medium shadow for dropdowns, popovers |
| `shadow.lg` | `0 10px 15px -3px rgba(22, 22, 20, 0.1)` | Large shadow for dialogs, modals |
## 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) |
## Typography
| 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.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.25rem` | Headings |
| `typography.lineHeight.normal` | `1.5rem` | Default |
| `typography.lineHeight.relaxed` | `1.625rem` | 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 |

View File

@@ -0,0 +1,70 @@
/* Greyhaven Design Tokens — Dark Theme
Auto-generated by Style Dictionary — DO NOT EDIT
Source: tokens/color.json */
.dark {
/* Dark page background */
--background: 22 22 20;
/* Dark primary text */
--foreground: 249 249 247;
/* Dark card surface */
--card: 47 47 44;
/* Dark card text */
--card-foreground: 249 249 247;
/* Dark popover */
--popover: 47 47 44;
/* Dark popover text */
--popover-foreground: 249 249 247;
/* Same orange in dark mode */
--primary: 217 94 42;
/* Dark primary foreground */
--primary-foreground: 249 249 247;
/* Dark secondary */
--secondary: 87 87 83;
/* Dark secondary text */
--secondary-foreground: 249 249 247;
/* Dark muted */
--muted: 87 87 83;
/* Dark muted text */
--muted-foreground: 196 196 189;
/* Dark accent/hover */
--accent: 87 87 83;
/* Dark accent text */
--accent-foreground: 249 249 247;
/* Same destructive in dark mode */
--destructive: 180 50 50;
/* Dark destructive text */
--destructive-foreground: 249 249 247;
/* Dark border */
--border: 87 87 83;
/* Dark input border */
--input: 87 87 83;
/* Dark focus ring */
--ring: 217 94 42;
/* Dark chart accent */
--chart-1: 217 94 42;
/* Dark chart secondary */
--chart-2: 196 196 189;
/* Dark chart tertiary */
--chart-3: 166 166 159;
/* Dark chart quaternary */
--chart-4: 127 127 121;
/* Dark chart quinary */
--chart-5: 240 240 236;
/* Dark sidebar background */
--sidebar: 47 47 44;
/* Dark sidebar text */
--sidebar-foreground: 249 249 247;
/* Dark sidebar primary */
--sidebar-primary: 217 94 42;
/* Dark sidebar primary text */
--sidebar-primary-foreground: 249 249 247;
/* Dark sidebar accent */
--sidebar-accent: 87 87 83;
/* Dark sidebar accent text */
--sidebar-accent-foreground: 249 249 247;
/* Dark sidebar border */
--sidebar-border: 87 87 83;
/* Dark sidebar ring */
--sidebar-ring: 217 94 42;
}

View File

@@ -0,0 +1,70 @@
/* Greyhaven Design Tokens — Light Theme
Auto-generated by Style Dictionary — DO NOT EDIT
Source: tokens/color.json */
:root {
/* Page background */
--background: 240 240 236;
/* Primary text */
--foreground: 22 22 20;
/* Card/elevated surface background */
--card: 249 249 247;
/* Card text */
--card-foreground: 22 22 20;
/* Popover background */
--popover: 249 249 247;
/* Popover text */
--popover-foreground: 22 22 20;
/* Primary accent — buttons, links, focus rings */
--primary: 217 94 42;
/* Text on primary accent */
--primary-foreground: 249 249 247;
/* Secondary button/surface */
--secondary: 240 240 236;
/* Text on secondary surface */
--secondary-foreground: 47 47 44;
/* Muted/subdued background */
--muted: 240 240 236;
/* Muted/subdued text */
--muted-foreground: 87 87 83;
/* Subtle hover state */
--accent: 221 221 215;
/* Text on accent hover */
--accent-foreground: 22 22 20;
/* Destructive/error actions */
--destructive: 180 50 50;
/* Text on destructive */
--destructive-foreground: 249 249 247;
/* Default border */
--border: 196 196 189;
/* Input border */
--input: 196 196 189;
/* Focus ring */
--ring: 217 94 42;
/* Chart accent */
--chart-1: 217 94 42;
/* Chart secondary */
--chart-2: 87 87 83;
/* Chart tertiary */
--chart-3: 127 127 121;
/* Chart quaternary */
--chart-4: 166 166 159;
/* Chart quinary */
--chart-5: 47 47 44;
/* Sidebar background */
--sidebar: 240 240 236;
/* Sidebar text */
--sidebar-foreground: 22 22 20;
/* Sidebar primary accent */
--sidebar-primary: 217 94 42;
/* Sidebar primary text */
--sidebar-primary-foreground: 249 249 247;
/* Sidebar accent/hover */
--sidebar-accent: 196 196 189;
/* Sidebar accent text */
--sidebar-accent-foreground: 22 22 20;
/* Sidebar border */
--sidebar-border: 196 196 189;
/* Sidebar focus ring */
--sidebar-ring: 217 94 42;
}

144
tokens/build/tokens.ts Normal file
View File

@@ -0,0 +1,144 @@
// Auto-generated by Style Dictionary — DO NOT EDIT
// Source: tokens/*.json (W3C DTCG format)
export const ColorTokens = {
'primitive.off-white': '#f9f9f7',
'primitive.off-black': '#161614',
'primitive.orange': '#d95e2a',
'primitive.destructive-red': '#b43232',
'primitive.grey.1': '#f0f0ec',
'primitive.grey.2': '#ddddd7',
'primitive.grey.3': '#c4c4bd',
'primitive.grey.4': '#a6a69f',
'primitive.grey.5': '#7f7f79',
'primitive.grey.7': '#575753',
'primitive.grey.8': '#2f2f2c',
'semantic.background': '#f0f0ec',
'semantic.foreground': '#161614',
'semantic.card': '#f9f9f7',
'semantic.card-foreground': '#161614',
'semantic.popover': '#f9f9f7',
'semantic.popover-foreground': '#161614',
'semantic.primary': '#d95e2a',
'semantic.primary-foreground': '#f9f9f7',
'semantic.secondary': '#f0f0ec',
'semantic.secondary-foreground': '#2f2f2c',
'semantic.muted': '#f0f0ec',
'semantic.muted-foreground': '#575753',
'semantic.accent': '#ddddd7',
'semantic.accent-foreground': '#161614',
'semantic.destructive': '#b43232',
'semantic.destructive-foreground': '#f9f9f7',
'semantic.border': '#c4c4bd',
'semantic.input': '#c4c4bd',
'semantic.ring': '#d95e2a',
'semantic.chart.1': '#d95e2a',
'semantic.chart.2': '#575753',
'semantic.chart.3': '#7f7f79',
'semantic.chart.4': '#a6a69f',
'semantic.chart.5': '#2f2f2c',
'semantic.sidebar.background': '#f0f0ec',
'semantic.sidebar.foreground': '#161614',
'semantic.sidebar.primary': '#d95e2a',
'semantic.sidebar.primary-foreground': '#f9f9f7',
'semantic.sidebar.accent': '#c4c4bd',
'semantic.sidebar.accent-foreground': '#161614',
'semantic.sidebar.border': '#c4c4bd',
'semantic.sidebar.ring': '#d95e2a',
'dark.background': '#161614',
'dark.foreground': '#f9f9f7',
'dark.card': '#2f2f2c',
'dark.card-foreground': '#f9f9f7',
'dark.popover': '#2f2f2c',
'dark.popover-foreground': '#f9f9f7',
'dark.primary': '#d95e2a',
'dark.primary-foreground': '#f9f9f7',
'dark.secondary': '#575753',
'dark.secondary-foreground': '#f9f9f7',
'dark.muted': '#575753',
'dark.muted-foreground': '#c4c4bd',
'dark.accent': '#575753',
'dark.accent-foreground': '#f9f9f7',
'dark.destructive': '#b43232',
'dark.destructive-foreground': '#f9f9f7',
'dark.border': '#575753',
'dark.input': '#575753',
'dark.ring': '#d95e2a',
'dark.chart.1': '#d95e2a',
'dark.chart.2': '#c4c4bd',
'dark.chart.3': '#a6a69f',
'dark.chart.4': '#7f7f79',
'dark.chart.5': '#f0f0ec',
'dark.sidebar.background': '#2f2f2c',
'dark.sidebar.foreground': '#f9f9f7',
'dark.sidebar.primary': '#d95e2a',
'dark.sidebar.primary-foreground': '#f9f9f7',
'dark.sidebar.accent': '#575753',
'dark.sidebar.accent-foreground': '#f9f9f7',
'dark.sidebar.border': '#575753',
'dark.sidebar.ring': '#d95e2a',
} as const;
export const MotionTokens = {
'duration.fast': '150ms',
'duration.normal': '200ms',
'duration.slow': '300ms',
} as const;
export const RadiiTokens = {
'base': '0.375rem',
'sm': 'calc(0.375rem - 2px)',
'md': '0.375rem',
'lg': 'calc(0.375rem + 2px)',
'xl': 'calc(0.375rem + 4px)',
'full': '9999px',
} as const;
export const ShadowTokens = {
} as const;
export const SpacingTokens = {
'0': '0',
'1': '0.25rem',
'2': '0.5rem',
'3': '0.75rem',
'4': '1rem',
'5': '1.25rem',
'6': '1.5rem',
'8': '2rem',
'10': '2.5rem',
'12': '3rem',
'16': '4rem',
'20': '5rem',
'24': '6rem',
'0.5': '0.125rem',
'1.5': '0.375rem',
'component.card-padding': '1.5rem',
'component.card-gap': '1.5rem',
'component.section-padding': '4rem',
'component.form-gap': '1rem',
'component.button-padding-x': '1rem',
'component.navbar-height': '4rem',
} as const;
export const TypographyTokens = {
'fontSize.xs': '0.75rem',
'fontSize.sm': '0.875rem',
'fontSize.base': '1rem',
'fontSize.lg': '1.125rem',
'fontSize.xl': '1.25rem',
'fontSize.2xl': '1.5rem',
'fontSize.3xl': '1.875rem',
'fontSize.4xl': '2.25rem',
'fontSize.5xl': '3rem',
'fontWeight.normal': '400',
'fontWeight.medium': '500',
'fontWeight.semibold': '600',
'fontWeight.bold': '700',
'lineHeight.tight': '1.25rem',
'lineHeight.normal': '1.5rem',
'lineHeight.relaxed': '1.625rem',
'letterSpacing.tight': '-0.025em',
'letterSpacing.normal': '0em',
'letterSpacing.wide': '0.05em',
} as const;

399
tokens/color.json Normal file
View File

@@ -0,0 +1,399 @@
{
"color": {
"$description": "Greyhaven Design System — Color Tokens",
"primitive": {
"$description": "Raw color values — reference only, use semantic tokens in components",
"off-white": {
"$type": "color",
"$value": "#F9F9F7",
"$description": "Primary light surface — cards, elevated areas"
},
"off-black": {
"$type": "color",
"$value": "#161614",
"$description": "Primary dark — foreground text, dark mode background"
},
"orange": {
"$type": "color",
"$value": "#D95E2A",
"$description": "Only accent color — used sparingly for primary actions and emphasis"
},
"destructive-red": {
"$type": "color",
"$value": "#B43232",
"$description": "Error/danger states"
},
"grey": {
"1": {
"$type": "color",
"$value": "#F0F0EC",
"$description": "5% — Subtle backgrounds, secondary, muted"
},
"2": {
"$type": "color",
"$value": "#DDDDD7",
"$description": "10% — Accent hover, light borders"
},
"3": {
"$type": "color",
"$value": "#C4C4BD",
"$description": "20% — Border, input"
},
"4": {
"$type": "color",
"$value": "#A6A69F",
"$description": "50% — Mid-tone"
},
"5": {
"$type": "color",
"$value": "#7F7F79",
"$description": "60% — Mid-dark"
},
"7": {
"$type": "color",
"$value": "#575753",
"$description": "70% — Secondary foreground, muted foreground"
},
"8": {
"$type": "color",
"$value": "#2F2F2C",
"$description": "80% — Dark mode card, dark surfaces"
}
}
},
"semantic": {
"$description": "Semantic color assignments for light theme",
"background": {
"$type": "color",
"$value": "{color.primitive.grey.1}",
"$description": "Page background"
},
"foreground": {
"$type": "color",
"$value": "{color.primitive.off-black}",
"$description": "Primary text"
},
"card": {
"$type": "color",
"$value": "{color.primitive.off-white}",
"$description": "Card/elevated surface background"
},
"card-foreground": {
"$type": "color",
"$value": "{color.primitive.off-black}",
"$description": "Card text"
},
"popover": {
"$type": "color",
"$value": "{color.primitive.off-white}",
"$description": "Popover background"
},
"popover-foreground": {
"$type": "color",
"$value": "{color.primitive.off-black}",
"$description": "Popover text"
},
"primary": {
"$type": "color",
"$value": "{color.primitive.orange}",
"$description": "Primary accent — buttons, links, focus rings"
},
"primary-foreground": {
"$type": "color",
"$value": "{color.primitive.off-white}",
"$description": "Text on primary accent"
},
"secondary": {
"$type": "color",
"$value": "{color.primitive.grey.1}",
"$description": "Secondary button/surface"
},
"secondary-foreground": {
"$type": "color",
"$value": "{color.primitive.grey.8}",
"$description": "Text on secondary surface"
},
"muted": {
"$type": "color",
"$value": "{color.primitive.grey.1}",
"$description": "Muted/subdued background"
},
"muted-foreground": {
"$type": "color",
"$value": "{color.primitive.grey.7}",
"$description": "Muted/subdued text"
},
"accent": {
"$type": "color",
"$value": "{color.primitive.grey.2}",
"$description": "Subtle hover state"
},
"accent-foreground": {
"$type": "color",
"$value": "{color.primitive.off-black}",
"$description": "Text on accent hover"
},
"destructive": {
"$type": "color",
"$value": "{color.primitive.destructive-red}",
"$description": "Destructive/error actions"
},
"destructive-foreground": {
"$type": "color",
"$value": "{color.primitive.off-white}",
"$description": "Text on destructive"
},
"border": {
"$type": "color",
"$value": "{color.primitive.grey.3}",
"$description": "Default border"
},
"input": {
"$type": "color",
"$value": "{color.primitive.grey.3}",
"$description": "Input border"
},
"ring": {
"$type": "color",
"$value": "{color.primitive.orange}",
"$description": "Focus ring"
},
"chart": {
"1": {
"$type": "color",
"$value": "{color.primitive.orange}",
"$description": "Chart accent"
},
"2": {
"$type": "color",
"$value": "{color.primitive.grey.7}",
"$description": "Chart secondary"
},
"3": {
"$type": "color",
"$value": "{color.primitive.grey.5}",
"$description": "Chart tertiary"
},
"4": {
"$type": "color",
"$value": "{color.primitive.grey.4}",
"$description": "Chart quaternary"
},
"5": {
"$type": "color",
"$value": "{color.primitive.grey.8}",
"$description": "Chart quinary"
}
},
"sidebar": {
"background": {
"$type": "color",
"$value": "{color.primitive.grey.1}",
"$description": "Sidebar background"
},
"foreground": {
"$type": "color",
"$value": "{color.primitive.off-black}",
"$description": "Sidebar text"
},
"primary": {
"$type": "color",
"$value": "{color.primitive.orange}",
"$description": "Sidebar primary accent"
},
"primary-foreground": {
"$type": "color",
"$value": "{color.primitive.off-white}",
"$description": "Sidebar primary text"
},
"accent": {
"$type": "color",
"$value": "{color.primitive.grey.3}",
"$description": "Sidebar accent/hover"
},
"accent-foreground": {
"$type": "color",
"$value": "{color.primitive.off-black}",
"$description": "Sidebar accent text"
},
"border": {
"$type": "color",
"$value": "{color.primitive.grey.3}",
"$description": "Sidebar border"
},
"ring": {
"$type": "color",
"$value": "{color.primitive.orange}",
"$description": "Sidebar focus ring"
}
}
},
"dark": {
"$description": "Dark theme overrides — inverted warm greys, same orange accent",
"background": {
"$type": "color",
"$value": "{color.primitive.off-black}",
"$description": "Dark page background"
},
"foreground": {
"$type": "color",
"$value": "{color.primitive.off-white}",
"$description": "Dark primary text"
},
"card": {
"$type": "color",
"$value": "{color.primitive.grey.8}",
"$description": "Dark card surface"
},
"card-foreground": {
"$type": "color",
"$value": "{color.primitive.off-white}",
"$description": "Dark card text"
},
"popover": {
"$type": "color",
"$value": "{color.primitive.grey.8}",
"$description": "Dark popover"
},
"popover-foreground": {
"$type": "color",
"$value": "{color.primitive.off-white}",
"$description": "Dark popover text"
},
"primary": {
"$type": "color",
"$value": "{color.primitive.orange}",
"$description": "Same orange in dark mode"
},
"primary-foreground": {
"$type": "color",
"$value": "{color.primitive.off-white}",
"$description": "Dark primary foreground"
},
"secondary": {
"$type": "color",
"$value": "{color.primitive.grey.7}",
"$description": "Dark secondary"
},
"secondary-foreground": {
"$type": "color",
"$value": "{color.primitive.off-white}",
"$description": "Dark secondary text"
},
"muted": {
"$type": "color",
"$value": "{color.primitive.grey.7}",
"$description": "Dark muted"
},
"muted-foreground": {
"$type": "color",
"$value": "{color.primitive.grey.3}",
"$description": "Dark muted text"
},
"accent": {
"$type": "color",
"$value": "{color.primitive.grey.7}",
"$description": "Dark accent/hover"
},
"accent-foreground": {
"$type": "color",
"$value": "{color.primitive.off-white}",
"$description": "Dark accent text"
},
"destructive": {
"$type": "color",
"$value": "{color.primitive.destructive-red}",
"$description": "Same destructive in dark mode"
},
"destructive-foreground": {
"$type": "color",
"$value": "{color.primitive.off-white}",
"$description": "Dark destructive text"
},
"border": {
"$type": "color",
"$value": "{color.primitive.grey.7}",
"$description": "Dark border"
},
"input": {
"$type": "color",
"$value": "{color.primitive.grey.7}",
"$description": "Dark input border"
},
"ring": {
"$type": "color",
"$value": "{color.primitive.orange}",
"$description": "Dark focus ring"
},
"chart": {
"1": {
"$type": "color",
"$value": "{color.primitive.orange}",
"$description": "Dark chart accent"
},
"2": {
"$type": "color",
"$value": "{color.primitive.grey.3}",
"$description": "Dark chart secondary"
},
"3": {
"$type": "color",
"$value": "{color.primitive.grey.4}",
"$description": "Dark chart tertiary"
},
"4": {
"$type": "color",
"$value": "{color.primitive.grey.5}",
"$description": "Dark chart quaternary"
},
"5": {
"$type": "color",
"$value": "{color.primitive.grey.1}",
"$description": "Dark chart quinary"
}
},
"sidebar": {
"background": {
"$type": "color",
"$value": "{color.primitive.grey.8}",
"$description": "Dark sidebar background"
},
"foreground": {
"$type": "color",
"$value": "{color.primitive.off-white}",
"$description": "Dark sidebar text"
},
"primary": {
"$type": "color",
"$value": "{color.primitive.orange}",
"$description": "Dark sidebar primary"
},
"primary-foreground": {
"$type": "color",
"$value": "{color.primitive.off-white}",
"$description": "Dark sidebar primary text"
},
"accent": {
"$type": "color",
"$value": "{color.primitive.grey.7}",
"$description": "Dark sidebar accent"
},
"accent-foreground": {
"$type": "color",
"$value": "{color.primitive.off-white}",
"$description": "Dark sidebar accent text"
},
"border": {
"$type": "color",
"$value": "{color.primitive.grey.7}",
"$description": "Dark sidebar border"
},
"ring": {
"$type": "color",
"$value": "{color.primitive.orange}",
"$description": "Dark sidebar ring"
}
}
}
}
}

72
tokens/motion.json Normal file
View File

@@ -0,0 +1,72 @@
{
"motion": {
"$description": "Greyhaven Design System — Animation & Motion Tokens",
"duration": {
"fast": {
"$type": "duration",
"$value": "150ms",
"$description": "Quick transitions — tooltips, hover states"
},
"normal": {
"$type": "duration",
"$value": "200ms",
"$description": "Default transitions — most UI interactions"
},
"slow": {
"$type": "duration",
"$value": "300ms",
"$description": "Deliberate transitions — modals, drawers, accordions"
}
},
"easing": {
"default": {
"$type": "cubicBezier",
"$value": [0.4, 0, 0.2, 1],
"$description": "Standard ease-in-out"
},
"in": {
"$type": "cubicBezier",
"$value": [0.4, 0, 1, 1],
"$description": "Ease-in for exits"
},
"out": {
"$type": "cubicBezier",
"$value": [0, 0, 0.2, 1],
"$description": "Ease-out for entrances"
}
},
"keyframes": {
"$description": "Named animation patterns used by components",
"accordion-down": {
"$description": "Accordion open — height 0 to auto"
},
"accordion-up": {
"$description": "Accordion close — height auto to 0"
},
"spin": {
"$description": "Spinner rotation — 360deg continuous"
},
"pulse": {
"$description": "Skeleton loading — opacity fade in/out"
},
"fade-in": {
"$description": "Generic entrance — opacity 0 to 1"
},
"fade-out": {
"$description": "Generic exit — opacity 1 to 0"
},
"slide-in-from-top": {
"$description": "Dropdown entrance — translate-y negative to 0"
},
"slide-in-from-bottom": {
"$description": "Drawer entrance — translate-y positive to 0"
},
"zoom-in": {
"$description": "Dialog entrance — scale 0.95 to 1 + fade"
},
"zoom-out": {
"$description": "Dialog exit — scale 1 to 0.95 + fade"
}
}
}
}

35
tokens/radii.json Normal file
View File

@@ -0,0 +1,35 @@
{
"radii": {
"$description": "Greyhaven Design System — Border Radius Tokens (tightened, no playful rounding)",
"base": {
"$type": "dimension",
"$value": "0.375rem",
"$description": "6px — base radius"
},
"sm": {
"$type": "dimension",
"$value": "calc(0.375rem - 2px)",
"$description": "4px — small variant"
},
"md": {
"$type": "dimension",
"$value": "0.375rem",
"$description": "6px — medium (same as base)"
},
"lg": {
"$type": "dimension",
"$value": "calc(0.375rem + 2px)",
"$description": "8px — large variant"
},
"xl": {
"$type": "dimension",
"$value": "calc(0.375rem + 4px)",
"$description": "10px — extra large variant (cards)"
},
"full": {
"$type": "dimension",
"$value": "9999px",
"$description": "Fully round (pills, avatars)"
}
}
}

49
tokens/shadows.json Normal file
View File

@@ -0,0 +1,49 @@
{
"shadow": {
"$description": "Greyhaven Design System — Shadow Tokens (subtle, warm-neutral)",
"xs": {
"$type": "shadow",
"$value": {
"offsetX": "0",
"offsetY": "1px",
"blur": "2px",
"spread": "0",
"color": "rgba(22, 22, 20, 0.05)"
},
"$description": "Subtle shadow for buttons, inputs"
},
"sm": {
"$type": "shadow",
"$value": {
"offsetX": "0",
"offsetY": "1px",
"blur": "3px",
"spread": "0",
"color": "rgba(22, 22, 20, 0.1)"
},
"$description": "Small shadow for cards"
},
"md": {
"$type": "shadow",
"$value": {
"offsetX": "0",
"offsetY": "4px",
"blur": "6px",
"spread": "-1px",
"color": "rgba(22, 22, 20, 0.1)"
},
"$description": "Medium shadow for dropdowns, popovers"
},
"lg": {
"$type": "shadow",
"$value": {
"offsetX": "0",
"offsetY": "10px",
"blur": "15px",
"spread": "-3px",
"color": "rgba(22, 22, 20, 0.1)"
},
"$description": "Large shadow for dialogs, modals"
}
}
}

113
tokens/spacing.json Normal file
View File

@@ -0,0 +1,113 @@
{
"spacing": {
"$description": "Greyhaven Design System — Spacing Tokens (derived from component usage patterns)",
"0": {
"$type": "dimension",
"$value": "0",
"$description": "None"
},
"0.5": {
"$type": "dimension",
"$value": "0.125rem",
"$description": "2px — micro spacing"
},
"1": {
"$type": "dimension",
"$value": "0.25rem",
"$description": "4px — tight gaps"
},
"1.5": {
"$type": "dimension",
"$value": "0.375rem",
"$description": "6px"
},
"2": {
"$type": "dimension",
"$value": "0.5rem",
"$description": "8px — card header gap, form description spacing"
},
"3": {
"$type": "dimension",
"$value": "0.75rem",
"$description": "12px"
},
"4": {
"$type": "dimension",
"$value": "1rem",
"$description": "16px — form field gap, button padding"
},
"5": {
"$type": "dimension",
"$value": "1.25rem",
"$description": "20px"
},
"6": {
"$type": "dimension",
"$value": "1.5rem",
"$description": "24px — card padding, card internal gap"
},
"8": {
"$type": "dimension",
"$value": "2rem",
"$description": "32px — section margin-bottom"
},
"10": {
"$type": "dimension",
"$value": "2.5rem",
"$description": "40px"
},
"12": {
"$type": "dimension",
"$value": "3rem",
"$description": "48px"
},
"16": {
"$type": "dimension",
"$value": "4rem",
"$description": "64px — major section padding (py-16)"
},
"20": {
"$type": "dimension",
"$value": "5rem",
"$description": "80px"
},
"24": {
"$type": "dimension",
"$value": "6rem",
"$description": "96px — hero padding"
},
"component": {
"$description": "Component-specific spacing patterns",
"card-padding": {
"$type": "dimension",
"$value": "1.5rem",
"$description": "Card internal padding (px-6)"
},
"card-gap": {
"$type": "dimension",
"$value": "1.5rem",
"$description": "Gap between cards (gap-6)"
},
"section-padding": {
"$type": "dimension",
"$value": "4rem",
"$description": "Vertical padding between major sections (py-16)"
},
"form-gap": {
"$type": "dimension",
"$value": "1rem",
"$description": "Gap between form fields (gap-4)"
},
"button-padding-x": {
"$type": "dimension",
"$value": "1rem",
"$description": "Button horizontal padding (px-4)"
},
"navbar-height": {
"$type": "dimension",
"$value": "4rem",
"$description": "Navbar height (h-16)"
}
}
}
}

155
tokens/typography.json Normal file
View File

@@ -0,0 +1,155 @@
{
"typography": {
"$description": "Greyhaven Design System — Typography Tokens",
"fontFamily": {
"sans": {
"$type": "fontFamily",
"$value": ["Aspekta", "Inter", "ui-sans-serif", "system-ui", "sans-serif"],
"$description": "UI labels, buttons, nav, forms — Aspekta primary, Inter fallback"
},
"serif": {
"$type": "fontFamily",
"$value": ["Source Serif 4", "Source Serif Pro", "Georgia", "serif"],
"$description": "Headings, body content, reading — Source Serif primary"
},
"mono": {
"$type": "fontFamily",
"$value": ["ui-monospace", "SFMono-Regular", "Menlo", "Monaco", "Consolas", "monospace"],
"$description": "Code blocks and monospaced content"
}
},
"fontSize": {
"xs": {
"$type": "dimension",
"$value": "0.75rem",
"$description": "12px — metadata, fine print"
},
"sm": {
"$type": "dimension",
"$value": "0.875rem",
"$description": "14px — captions, nav, labels, buttons"
},
"base": {
"$type": "dimension",
"$value": "1rem",
"$description": "16px — body text"
},
"lg": {
"$type": "dimension",
"$value": "1.125rem",
"$description": "18px — large body, subtitles"
},
"xl": {
"$type": "dimension",
"$value": "1.25rem",
"$description": "20px — H3"
},
"2xl": {
"$type": "dimension",
"$value": "1.5rem",
"$description": "24px — H2"
},
"3xl": {
"$type": "dimension",
"$value": "1.875rem",
"$description": "30px — large H2"
},
"4xl": {
"$type": "dimension",
"$value": "2.25rem",
"$description": "36px — H1"
},
"5xl": {
"$type": "dimension",
"$value": "3rem",
"$description": "48px — hero heading"
}
},
"fontWeight": {
"normal": {
"$type": "fontWeight",
"$value": 400,
"$description": "Regular body text"
},
"medium": {
"$type": "fontWeight",
"$value": 500,
"$description": "H3, labels, nav items"
},
"semibold": {
"$type": "fontWeight",
"$value": 600,
"$description": "H1, H2, buttons"
},
"bold": {
"$type": "fontWeight",
"$value": 700,
"$description": "Strong emphasis"
}
},
"lineHeight": {
"tight": {
"$type": "dimension",
"$value": "1.25",
"$description": "Headings"
},
"normal": {
"$type": "dimension",
"$value": "1.5",
"$description": "Default"
},
"relaxed": {
"$type": "dimension",
"$value": "1.625",
"$description": "Body content for readability"
}
},
"letterSpacing": {
"tight": {
"$type": "dimension",
"$value": "-0.025em",
"$description": "Headings — tracking-tight"
},
"normal": {
"$type": "dimension",
"$value": "0em",
"$description": "Body text"
},
"wide": {
"$type": "dimension",
"$value": "0.05em",
"$description": "Uppercase labels"
}
},
"composite": {
"$description": "Pre-composed typography styles for common use cases",
"h1": {
"$description": "Page heading — serif, text-4xl, font-semibold, tracking-tight"
},
"h2": {
"$description": "Section heading — serif, text-2xl, font-semibold, tracking-tight"
},
"h3": {
"$description": "Subsection heading — serif, text-xl, font-medium"
},
"body": {
"$description": "Body text — serif, text-base, leading-relaxed"
},
"caption": {
"$description": "Caption text — sans, text-sm"
},
"nav": {
"$description": "Navigation — sans, text-sm, font-medium"
},
"button": {
"$description": "Button label — sans, text-sm, font-semibold"
},
"label": {
"$description": "Form label — sans, text-sm, font-medium"
},
"metadata": {
"$description": "Metadata — sans, text-xs, font-medium"
}
}
}
}

36
vitest.config.ts Normal file
View File

@@ -0,0 +1,36 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
import { playwright } from '@vitest/browser-playwright';
const dirname =
typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
export default defineConfig({
test: {
projects: [
{
extends: true,
plugins: [
// The plugin will run tests for the stories defined in your Storybook config
// See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
storybookTest({ configDir: path.join(dirname, '.storybook') }),
],
test: {
name: 'storybook',
browser: {
enabled: true,
headless: true,
provider: playwright({}),
instances: [{ browser: 'chromium' }],
},
},
},
],
},
});

1
vitest.shims.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="@vitest/browser-playwright" />