diff --git a/.gitignore b/.gitignore index f650315..cfa1e15 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,9 @@ yarn-error.log* # typescript *.tsbuildinfo -next-env.d.ts \ No newline at end of file +next-env.d.ts +*storybook.log +storybook-static + +# llms +vibedocs/* diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 0000000..51fe116 --- /dev/null +++ b/.storybook/main.ts @@ -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; diff --git a/.storybook/preview.ts b/.storybook/preview.ts new file mode 100644 index 0000000..d380385 --- /dev/null +++ b/.storybook/preview.ts @@ -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 diff --git a/README.md b/README.md index f27ad74..2bb717f 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,269 @@ # 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/ # AI skills +│ ├── SKILL.md # Design system reference (auto-generated) +│ ├── AGENTS.md # Project instructions (auto-generated) +│ ├── BRAND.md # Voice/tone/messaging (hand-curated, opt-in) +│ └── install.sh # Installer (supports --brand-skill flag) +├── 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 four things for AI agents: + +| File | What it is | Where it goes | +|------|-----------|---------------| +| **SKILL.md** | Full design system reference (tokens, all components, composition rules, extension protocol) | `.claude/skills/` — works in Claude Code, Cursor, OpenCode, Anigravity, etc. | +| **AGENTS.md** | Short project instructions telling the AI *how* to use the design system (follows the [agents.md](https://agents.md) convention) | Project root — copy as `AGENTS.md`, `CLAUDE.md`, `.cursorrules`, or `.github/copilot-instructions.md` | +| **BRAND.md** *(opt-in)* | Voice/tone/messaging rules for generating marketing copy, CTAs, product explanations. Hand-curated from the brand guidelines. | `.claude/skills/greyhaven-brand.md` — opt in via `--brand-skill` flag | +| **MCP Server** | Runtime tools for looking up components, validating colors, suggesting components, fetching brand rules, validating copy | Configured in `.mcp.json` | + +SKILL.md and AGENTS.md are auto-generated from `tokens/*.json` and `lib/catalog.ts`. BRAND.md is hand-curated from `vibedocs/greyhaven-brand-system.md`. + +### Quick Install (all at once) + +```bash +# Default: SKILL.md + AGENTS.md + fonts +./skill/install.sh /path/to/your/project + +# With brand skill: also install BRAND.md + logo SVGs +./skill/install.sh /path/to/your/project --brand-skill +``` + +This **copies** (not symlinks) the following into your project: +1. `SKILL.md` → `.claude/skills/greyhaven-design-system.md` (full reference) +2. `AGENTS.md` → project root (project-level instructions) +3. Aspekta font files → `public/fonts/` + +With `--brand-skill`, additionally: + +4. `BRAND.md` → `.claude/skills/greyhaven-brand.md` (voice/tone/messaging) +5. Logo SVGs → `public/logos/` (file names normalized: `gh-logo-positive-full-black.svg`, `gh-symbol-full-black.svg`, etc.) + +The script also prints the CSS `@font-face` block and MCP server config to add next. + +**Re-run the script after design system updates** to refresh your copies. + +### SKILL.md (full reference) + +The skill file gives any AI agent full design system context — every token, every component with props/variants/examples, composition rules, font setup, and the extension protocol. + +**It's a global standard** — works with Claude Code, Cursor, OpenCode, Anigravity, and any tool that reads skill files. + +```bash +# Via install script (recommended — also handles fonts + AGENTS.md) +./skill/install.sh /path/to/your/project + +# Or copy manually +mkdir -p /path/to/your/project/.claude/skills +cp /path/to/greyhaven-design-system/skill/SKILL.md \ + /path/to/your/project/.claude/skills/greyhaven-design-system.md +``` + +### AGENTS.md (project instructions) + +Short, directive instructions that tell the AI agent *how* to work in the project — use TypeScript, use semantic tokens, reference the MCP tools, etc. Follows the [agents.md](https://agents.md) convention so it works with most AI coding tools out of the box. + +**Copy it to your project root** under whichever name your tool reads: + +```bash +# Standard (agents.md convention — Cursor, OpenCode, Windsurf, Aider, etc.) +cp /path/to/greyhaven-design-system/skill/AGENTS.md /path/to/your/project/AGENTS.md + +# Claude Code +cp /path/to/greyhaven-design-system/skill/AGENTS.md /path/to/your/project/CLAUDE.md + +# Cursor (legacy) +cp /path/to/greyhaven-design-system/skill/AGENTS.md /path/to/your/project/.cursorrules + +# GitHub Copilot +mkdir -p /path/to/your/project/.github +cp /path/to/greyhaven-design-system/skill/AGENTS.md /path/to/your/project/.github/copilot-instructions.md +``` + +Or use the install script, which copies `AGENTS.md` to the project root automatically. + +### BRAND.md (voice, tone, messaging) + +BRAND.md is an **opt-in** skill for projects that generate user-facing content — marketing copy, landing pages, CTAs, product explanations, emails. It codifies the Greyhaven brand voice: direct, plain-spoken, engineering-flavored, no hype, no sales language. + +It's **not installed by default** because most projects only need the design system components, not brand voice rules. + +**Install via the `--brand-skill` flag:** + +```bash +./skill/install.sh /path/to/your/project --brand-skill +``` + +This adds: +- `skill/BRAND.md` → `.claude/skills/greyhaven-brand.md` (brand skill) +- Greyhaven logo SVGs → `public/logos/` (full logos + symbol-only + product lockups, in black and white variants, file names normalized) + +Once installed, AI agents in your project can reference the brand skill when generating copy. The skill covers: +- Core positioning and the three brand axes (containment, human-centered, engineered) +- Tone of voice rules +- Writing patterns (plain-language engineering, no hype) +- Reasoning patterns (cause→effect, constraint→outcome, observation→explanation, finite scope→concrete result) +- CTA guidance (good vs. bad patterns) +- Logo usage rules +- A self-check list to run before shipping any copy + +### Option C: MCP Server + +The MCP server provides 7 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 | +| `get_brand_rules(section?)` | Returns brand voice/tone/messaging rules. Section can be: `positioning`, `axes`, `tone`, `writing-rules`, `reasoning-patterns`, `cta`, `logo`, `self-check`, or `all` | +| `validate_copy(text)` | Lints marketing copy for hype words, sales language, vague superlatives, urgency framing, and exclamation marks | + +Plus resources: `tokens://all`, `component://{name}` for each component, and `brand://guidelines` for the full brand skill. + +**Run directly:** + +```bash +pnpm mcp:start +``` + +**Install in Claude Code (`.mcp.json` in your project root):** + +```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//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 and skill/AGENTS.md from tokens + catalog | +| `pnpm mcp:start` | Start the MCP server (stdio transport) | +| `pnpm mcp:build` | Type-check MCP server | +| `pnpm lint` | Run ESLint | diff --git a/app/globals.css b/app/globals.css index a0cb00b..abcc99e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,12 +1,28 @@ @import 'tailwindcss'; @import 'tw-animate-css'; +@import './tokens/tokens-light.css'; +@import './tokens/tokens-dark.css'; + +/* Aspekta — self-hosted sans font (canonical UI typeface) */ +@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 100; font-display: swap; src: url('/fonts/Aspekta-100.woff2') format('woff2'); } +@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 200; font-display: swap; src: url('/fonts/Aspekta-200.woff2') format('woff2'); } +@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 300; font-display: swap; src: url('/fonts/Aspekta-300.woff2') format('woff2'); } +@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 400; font-display: swap; src: url('/fonts/Aspekta-400.woff2') format('woff2'); } +@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 500; font-display: swap; src: url('/fonts/Aspekta-500.woff2') format('woff2'); } +@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 600; font-display: swap; src: url('/fonts/Aspekta-600.woff2') format('woff2'); } +@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 700; font-display: swap; src: url('/fonts/Aspekta-700.woff2') format('woff2'); } +@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 800; font-display: swap; src: url('/fonts/Aspekta-800.woff2') format('woff2'); } +@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 900; font-display: swap; src: url('/fonts/Aspekta-900.woff2') format('woff2'); } @custom-variant dark (&:is(.dark *)); /* ============================================================================= 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 +30,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 (self-hosted) is the canonical sans font */ + --font-sans: 'Aspekta', ui-sans-serif, system-ui, sans-serif; + --font-serif: var(--font-source-serif, 'Source Serif 4'), 'Source Serif Pro', Georgia, serif; --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - - /* 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)); @@ -178,6 +75,7 @@ --radius-md: var(--radius); --radius-lg: calc(var(--radius) + 2px); --radius-xl: calc(var(--radius) + 4px); + --color-hero-bg: rgb(var(--hero-bg)); --color-sidebar: rgb(var(--sidebar)); --color-sidebar-foreground: rgb(var(--sidebar-foreground)); --color-sidebar-primary: rgb(var(--sidebar-primary)); @@ -186,7 +84,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; diff --git a/app/layout.tsx b/app/layout.tsx index b9a7b25..2aa863b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,23 +1,18 @@ import type { Metadata } from 'next' -import { Source_Serif_4, Inter } from 'next/font/google' +import { Source_Serif_4 } from 'next/font/google' import './globals.css' // Primary typeface: Source Serif Pro (using Source Serif 4 which is the updated version) // Used for headings, body text, and reading content -const sourceSerif = Source_Serif_4({ +const sourceSerif = Source_Serif_4({ subsets: ["latin"], variable: '--font-source-serif', display: 'swap', }) -// Secondary typeface: Inter (Aspekta alternative from Google Fonts) -// Aspekta is the brand typeface, Inter is a suitable system alternative +// Secondary typeface: Aspekta (self-hosted in public/fonts/) +// Loaded via @font-face in globals.css — no Next.js font loader needed // Used for UI labels, nav, buttons, small utility text -const inter = Inter({ - subsets: ["latin"], - variable: '--font-inter', - display: 'swap', -}) export const metadata: Metadata = { title: 'Greyhaven Design System', @@ -48,7 +43,7 @@ export default function RootLayout({ children: React.ReactNode }>) { return ( - + {children} diff --git a/app/tokens/TOKENS.md b/app/tokens/TOKENS.md new file mode 100644 index 0000000..fcc82c4 --- /dev/null +++ b/app/tokens/TOKENS.md @@ -0,0 +1,170 @@ +# 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.hero-bg` | `#ddddd7` | Hero banner background | +| `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` | `#2f2f2c` | Dark muted — grey.8 (distinct from grey.7 border so outlines on bg-muted surfaces remain visible) | +| `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.hero-bg` | `#2f2f2c` | Dark hero banner background | +| `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` | `2.5rem` | Vertical padding inside sections (py-10) | +| `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, ui-sans-serif, system-ui, sans-serif` | UI labels, buttons, nav, forms — Aspekta self-hosted | +| `typography.fontFamily.serif` | `'Source Serif 4', 'Source Serif Pro', Georgia, serif` | Headings, body content, reading — Source Serif primary | +| `typography.fontFamily.mono` | `ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace` | Code blocks and monospaced content | +| `typography.fontSize.xs` | `0.75rem` | 12px — metadata, fine print | +| `typography.fontSize.sm` | `0.875rem` | 14px — captions, nav, labels, buttons | +| `typography.fontSize.base` | `1rem` | 16px — body text | +| `typography.fontSize.lg` | `1.125rem` | 18px — large body, subtitles | +| `typography.fontSize.xl` | `1.25rem` | 20px — H3 | +| `typography.fontSize.2xl` | `1.5rem` | 24px — H2 | +| `typography.fontSize.3xl` | `1.875rem` | 30px — large H2 | +| `typography.fontSize.4xl` | `2.25rem` | 36px — H1 | +| `typography.fontSize.5xl` | `3rem` | 48px — hero heading | +| `typography.fontWeight.normal` | `400` | Regular body text | +| `typography.fontWeight.medium` | `500` | H3, labels, nav items | +| `typography.fontWeight.semibold` | `600` | H1, H2, buttons | +| `typography.fontWeight.bold` | `700` | Strong emphasis | +| `typography.lineHeight.tight` | `1.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 | diff --git a/app/tokens/tokens-dark.css b/app/tokens/tokens-dark.css new file mode 100644 index 0000000..51ee151 --- /dev/null +++ b/app/tokens/tokens-dark.css @@ -0,0 +1,72 @@ +/* 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 — grey.8 (distinct from grey.7 border so outlines on bg-muted surfaces remain visible) */ + --muted: 47 47 44; + /* 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 hero banner background */ + --hero-bg: 47 47 44; + /* 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; +} \ No newline at end of file diff --git a/app/tokens/tokens-light.css b/app/tokens/tokens-light.css new file mode 100644 index 0000000..7debb89 --- /dev/null +++ b/app/tokens/tokens-light.css @@ -0,0 +1,72 @@ +/* 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; + /* Hero banner background */ + --hero-bg: 221 221 215; + /* 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; +} \ No newline at end of file diff --git a/app/tokens/tokens.ts b/app/tokens/tokens.ts new file mode 100644 index 0000000..10b9013 --- /dev/null +++ b/app/tokens/tokens.ts @@ -0,0 +1,146 @@ +// 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.hero-bg': '#ddddd7', + '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': '#2f2f2c', + '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.hero-bg': '#2f2f2c', + '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': '2.5rem', + '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; diff --git a/components/theme-provider.tsx b/components/theme-provider.tsx index 55c2f6e..1cdda07 100644 --- a/components/theme-provider.tsx +++ b/components/theme-provider.tsx @@ -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 {children} +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(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(() => { + 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 ( + + {children} + + ) +} + +export function useTheme() { + const context = React.useContext(ThemeContext) + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider') + } + return context } diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx index 3ed8811..9473c3d 100644 --- a/components/ui/badge.tsx +++ b/components/ui/badge.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/utils' const badgeVariants = cva( - 'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden', + 'inline-flex items-center justify-center rounded-md border font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden', { variants: { variant: { @@ -43,9 +43,15 @@ const badgeVariants = cva( platform: 'border-transparent bg-[#f97316] text-white [a&]:hover:bg-[#f97316]/90', }, + size: { + sm: 'text-xs px-1.5 py-0', + default: 'text-xs px-2 py-0.5', + lg: 'text-sm px-3 py-1 [&>svg]:size-3.5', + }, }, defaultVariants: { variant: 'default', + size: 'default', }, }, ) @@ -53,6 +59,7 @@ const badgeVariants = cva( function Badge({ className, variant, + size, asChild = false, ...props }: React.ComponentProps<'span'> & @@ -62,7 +69,7 @@ function Badge({ return ( ) diff --git a/components/ui/code.tsx b/components/ui/code.tsx new file mode 100644 index 0000000..d967010 --- /dev/null +++ b/components/ui/code.tsx @@ -0,0 +1,63 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const codeVariants = cva( + 'bg-muted border border-border font-mono text-foreground', + { + variants: { + variant: { + inline: 'rounded text-xs px-1.5 py-0.5', + block: 'block rounded-md text-sm px-4 py-3 leading-relaxed break-all whitespace-pre-wrap', + }, + }, + defaultVariants: { + variant: 'inline', + }, + }, +) + +interface CodeProps + extends React.ComponentProps<'code'>, + VariantProps { + /** + * Optional language hint for future syntax-highlighting support. + * Emitted as `data-language` and as a `language-{lang}` class so + * highlighters like Prism/Shiki can pick it up later. + */ + language?: string +} + +function Code({ + className, + variant, + language, + ...props +}: CodeProps) { + const element = ( + + ) + + // For block variant, wrap in
 so copy-paste preserves whitespace
+  // and screen readers announce it as a code block.
+  if (variant === 'block') {
+    return (
+      
+        {element}
+      
+ ) + } + + return element +} + +export { Code, codeVariants } diff --git a/components/ui/cta-section.tsx b/components/ui/cta-section.tsx new file mode 100644 index 0000000..5951dd1 --- /dev/null +++ b/components/ui/cta-section.tsx @@ -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 { + heading: React.ReactNode + description?: React.ReactNode + actions?: React.ReactNode +} + +function CTASection({ + className, + variant, + background, + heading, + description, + actions, + children, + ...props +}: CTASectionProps) { + return ( +
+
+

+ {heading} +

+ {description && ( +

+ {description} +

+ )} + {actions && ( +
+ {actions} +
+ )} + {children} +
+
+ ) +} + +export { CTASection, ctaSectionVariants } diff --git a/components/ui/footer.tsx b/components/ui/footer.tsx new file mode 100644 index 0000000..349c73c --- /dev/null +++ b/components/ui/footer.tsx @@ -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 { + 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 ( +
+
+
+ {logo && ( +
+ {logo} +
+ )} + {linkGroups.map((group) => ( +
+

{group.title}

+ +
+ ))} +
+
+ {copyright && ( +

{copyright}

+ )} + {actions} +
+ {children} +
+
+ ) + } + + return ( +
+
+
+ {logo &&
{logo}
} + {copyright && ( +

{copyright}

+ )} + {actions} + {children} +
+
+
+ ) +} + +export { Footer, footerVariants } diff --git a/components/ui/hero.tsx b/components/ui/hero.tsx new file mode 100644 index 0000000..7dcdffd --- /dev/null +++ b/components/ui/hero.tsx @@ -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-hero-bg', + muted: 'bg-muted', + accent: 'bg-primary/5', + dark: 'bg-foreground text-background', + }, + }, + defaultVariants: { + variant: 'centered', + background: 'default', + }, +}) + +interface HeroProps + extends React.ComponentProps<'section'>, + VariantProps { + 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 ( +
+
+
+

+ {heading} +

+ {subheading && ( +

+ {subheading} +

+ )} + {actions && ( +
+ {actions} +
+ )} + {children} +
+ {isSplit && media && ( +
{media}
+ )} +
+
+ ) +} + +export { Hero, heroVariants } diff --git a/components/ui/logo.tsx b/components/ui/logo.tsx new file mode 100644 index 0000000..bfcc9b8 --- /dev/null +++ b/components/ui/logo.tsx @@ -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) { + return ( + + + + + + + + + + + + + + + + + + + + ) +} + +export { Logo, logoVariants } diff --git a/components/ui/navbar.tsx b/components/ui/navbar.tsx new file mode 100644 index 0000000..fe98753 --- /dev/null +++ b/components/ui/navbar.tsx @@ -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-[65px] font-sans', + { + variants: { + variant: { + solid: 'bg-card dark:bg-background border-b border-border', + transparent: 'bg-transparent', + minimal: 'bg-card/80 dark:bg-background/80 backdrop-blur-sm border-b border-border/50', + }, + }, + defaultVariants: { + variant: 'solid', + }, + }, +) + +interface NavbarProps + extends React.ComponentProps<'header'>, + VariantProps { + logo?: React.ReactNode + actions?: React.ReactNode +} + +function Navbar({ + className, + variant, + logo, + actions, + children, + ...props +}: NavbarProps) { + const [mobileOpen, setMobileOpen] = React.useState(false) + + return ( +
+
+ {/* Logo slot — left */} + {logo && ( +
+ {logo} +
+ )} + + {/* Desktop nav — center */} + + + {/* Actions slot — right */} +
+ {actions && ( +
+ {actions} +
+ )} + + {/* Mobile menu toggle */} + +
+
+ + {/* Mobile nav */} + {mobileOpen && ( +
+ + {actions && ( +
+ {actions} +
+ )} +
+ )} +
+ ) +} + +function NavbarLink({ + className, + active, + ...props +}: React.ComponentProps<'a'> & { active?: boolean }) { + return ( + + ) +} + +export { Navbar, NavbarLink, navbarVariants } diff --git a/components/ui/page-layout.tsx b/components/ui/page-layout.tsx new file mode 100644 index 0000000..f1bd2cc --- /dev/null +++ b/components/ui/page-layout.tsx @@ -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 ( +
+ {navbar} +
+ {sidebar && ( + + )} +
+ {children} +
+
+ {footer} +
+ ) +} + +export { PageLayout } diff --git a/components/ui/section.tsx b/components/ui/section.tsx new file mode 100644 index 0000000..9c76bbe --- /dev/null +++ b/components/ui/section.tsx @@ -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-10', { + variants: { + variant: { + default: '', + highlighted: 'bg-card my-8', + accent: 'bg-primary/5 my-8', + }, + 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 { + title?: string + description?: string +} + +function Section({ + className, + variant, + width, + title, + description, + children, + ...props +}: SectionProps) { + return ( +
+
+ {(title || description) && ( +
+ {title && ( +

+ {title} +

+ )} + {description && ( +

+ {description} +

+ )} +
+ )} + {children} +
+
+ ) +} + +export { Section, sectionVariants } diff --git a/components/ui/sonner.tsx b/components/ui/sonner.tsx index 0626caf..2bbca07 100644 --- a/components/ui/sonner.tsx +++ b/components/ui/sonner.tsx @@ -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 ( { + const filePath = path.join(root, 'tokens', `${name}.json`) + return JSON.parse(fs.readFileSync(filePath, 'utf-8')) +} + +export function flattenTokens( + obj: Record, + 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 + + 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: '', + }, + { + name: 'Badge', + file: 'components/ui/badge.tsx', + category: 'primitives', + exports: ['Badge', 'badgeVariants'], + description: 'Status indicator / tag. Variants: default, secondary, muted, outline, destructive, success, warning, info, tag, value, whatsapp, email, telegram, zulip, platform. Sizes: sm (dense data/tables), default (most uses), lg (hero-adjacent, near large type). NEVER override font-size or padding with className — pick a size variant instead. Anything below text-xs (12px) fails accessibility minimums.', + props: 'variant?: "default" | "secondary" | "muted" | "destructive" | "outline" | "success" | "warning" | "info" | "tag" | "value" | "whatsapp" | "email" | "telegram" | "zulip" | "platform"; size?: "sm" | "default" | "lg"; asChild?: boolean', + example: 'Active\n3 items\nNew feature', + }, + { + 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: '', + }, + { + name: 'Textarea', + file: 'components/ui/textarea.tsx', + category: 'primitives', + exports: ['Textarea'], + description: 'Multi-line text input.', + props: 'All standard HTML textarea props', + example: '