4 Commits

Author SHA1 Message Date
Joyce
0993fa5a91 import aspekta font 2026-04-10 11:22:36 -04:00
Joyce
9df702eaaf import brand images 2026-04-10 10:40:59 -04:00
Joyce
a9d0ac182d import brand images 2026-04-10 10:38:12 -04:00
Joyce
b6a2c908c6 feat: replace v0 defaults with Greyhaven brand — icons, fonts (Aspekta), colors, and type scale from brand guidelines 2026-04-10 09:41:14 -04:00
124 changed files with 3468 additions and 29298 deletions

7
.gitignore vendored
View File

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

View File

@@ -1,17 +0,0 @@
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;

View File

@@ -1,44 +0,0 @@
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

179
GAPS.md
View File

@@ -1,179 +0,0 @@
# HTMX CSS Generator — Known Gaps & Fixes
Running log of edge cases discovered while consuming `dist/greyhaven.htmx.css`
in framework-agnostic (HTMX / Go `html/template` / etc.) projects. Each entry
captures: the bug, its root cause, and the fix. Keep this file updated when
new gaps surface so consumers have a single place to check.
---
## Fixed
### 2026-04-24 — `toggle-group-item` missing Toggle base styles
**Symptom (consumer-side):** `data-slot="toggle-group-item"` renders as
unstyled inline text — no padding, no height, no border, and `data-state="on"`
produces no visual change.
**Root cause:** In React, `ToggleGroupItem` composes `toggleVariants()` with a
small set of segmented-group overrides:
```tsx
className={cn(
toggleVariants({ variant, size }), // base + variant + size classes
'min-w-0 flex-1 shrink-0 rounded-none ...'
)}
```
The generator's `extractSlot` only captured the first *string-literal* arg of
`cn(...)` — the segmented overrides — and never followed the
`toggleVariants(...)` call to pull in Toggle's base, variant, and size rules.
As a result, the emitted `[data-slot="toggle-group-item"]` had no padding,
hover, `disabled`, or — critically — the `data-[state=on]:bg-accent
data-[state=on]:text-accent-foreground` rule that drives the pressed state.
**Fix (`scripts/generate-htmx-css.ts`):** `SlotExtract` now carries a
`viaVariants: string[]` field. When processing `className={cn(xVariants(...),
'literal')}`, the extractor records every `*Variants` call it sees. The
emitter then merges each referenced CVA's base + variant rules under the
slot's own selector, so a slot that *composes* another component's variant
system inherits its full rule set.
**Generated output before:**
```css
[data-slot="toggle-group-item"] { /* only overrides */
min-w-0 flex-1 ... rounded-none first:rounded-l-md ...
}
```
**Generated output after:**
```css
[data-slot="toggle-group-item"] { /* full Toggle base + item overrides */
inline-flex items-center ... data-[state=on]:bg-accent ...
min-w-0 flex-1 ... rounded-none first:rounded-l-md ...
}
[data-slot="toggle-group-item"][data-variant="outline"] { /* inherited variant */ }
[data-slot="toggle-group-item"][data-size="sm"] { /* inherited size */ }
```
**Consumer impact:** existing HTMX demos do not use `toggle-group-item`, so no
screenshot-diff regression risk. Consumers previously working around this by
using `data-slot="toggle"` on each item (with manual rounded-none / border
overrides) can switch to the clean `toggle-group-item` form.
---
### 2026-04-24 — Toggle active state uses `primary` (orange), not `accent` (grey)
**Symptom:** Design review flagged the active-state grey (`bg-accent`) as too
muted — users expected the brand orange for selected items in a segmented
control, matching every other "selected" affordance in the system (primary
button, active nav link, focus ring).
**Fix:** `components/ui/toggle.tsx``toggleVariants` base class swapped from
`data-[state=on]:bg-accent data-[state=on]:text-accent-foreground` to
`data-[state=on]:bg-primary data-[state=on]:text-primary-foreground`. This
propagates to `ToggleGroupItem` automatically (composes `toggleVariants`) and
to the generated `[data-slot="toggle"]` and `[data-slot="toggle-group-item"]`
rules after `pnpm htmx-css:build && pnpm htmx-demo:build`.
**Rationale:** Greyhaven's palette reserves `accent` for hover hints and
subtle surface shifts; `primary` is the single brand-accent color, used
wherever a choice is committed. Selected-state for Toggle/ToggleGroup now
matches that convention.
### 2026-04-24 — `ToggleGroupItem` overflow: text escapes the bg box
**Symptom:** With segmented labels of uneven length ("System" / "Light" /
"Dark"), the longer label's text rendered outside its button's background
rectangle. Selected-state highlight appeared narrower than the text it was
supposed to cover, and hover state was clipped for longer labels.
**Root cause:** `ToggleGroupItem` layered `'min-w-0 flex-1 shrink-0'` on top
of `toggleVariants`' size-based `min-w-*`. Because tailwind-merge keeps the
later `min-w-0`, each item was allowed to shrink below its content. Combined
with `flex: 1 1 0%` and `w-fit` on the group, items ended up forced to equal
narrow columns sized to `container_width / N` — which for the "System" case
was ~37px, well below the text's ~45px intrinsic width. `whitespace-nowrap`
then let the text bleed out of the button's layout box.
**Fix:** `components/ui/toggle-group.tsx` — dropped `min-w-0 flex-1 shrink-0`
from `ToggleGroupItem`'s override string. Items now size to their content
(respecting the `min-w-*` floor from `toggleVariants`), the group's `w-fit`
sums them up, and padding is symmetric. Unequal-width items are the
intentional result (a segmented "System/Light/Dark" now shows "System" wider
than "Light"/"Dark"); if a consumer specifically wants equal-width columns,
they can re-apply `flex-1 basis-0` on each item via `className`.
**Consumer impact:** no regressions in the existing 21 sections; the new
`toggle-group` section passes at 99.98%.
### 2026-04-24 — Toggle horizontal padding aligned with Button
**Symptom:** Side-by-side, a `Toggle` (or `ToggleGroupItem`) looked cramped
compared to a regular `Button` of the same size — the active-state orange
bg sat tight against the label, while Button had comfortable breathing room
on both sides. Perceived as a visual-rhythm bug across any UI that mixed the
two in the same view.
**Root cause:** `Toggle`'s cva had roughly half the horizontal padding of
`Button` per size:
| size | Button | Toggle (old) |
|---------|---------------|---------------|
| default | `px-4` (16px) | `px-2` (8px) |
| sm | `px-3` (12px) | `px-1.5` (6px)|
| lg | `px-6` (24px) | `px-2.5` (10px)|
**Fix:** `components/ui/toggle.tsx``toggleVariants.size.*` horizontal
padding now matches Button exactly. Also added `has-[>svg]:px-*` for
icon-only toggles, mirroring Button's affordance. `min-w-*` kept as a floor
so very short labels (`A`, `B`) still render as balanced pills.
```ts
size: {
default: 'h-9 px-4 min-w-9 has-[>svg]:px-3',
sm: 'h-8 px-3 min-w-8 has-[>svg]:px-2.5',
lg: 'h-10 px-6 min-w-10 has-[>svg]:px-4',
},
```
## Known (not yet fixed)
*(Add entries here as they are discovered. Template:*
```
### YYYY-MM-DD — <slot or behavior>
**Symptom:** ...
**Root cause:** ...
**Workaround:** ...
**Proposed fix:** ...
```
*)*
### Radix primitive-only components (AlertDialogAction, AlertDialogCancel)
**Symptom:** These components have no static `data-slot` in their JSX — they
wrap `<AlertDialogPrimitive.Action>` which sets `data-slot` at runtime. The
AST extractor never sees them, so no CSS is emitted.
**Workaround:** Consumers should add `data-slot="button"` on the
corresponding HTML element. The visual contract matches Button exactly
(`cn(buttonVariants(), className)` in React).
**Proposed fix:** none yet — requires either parsing the Radix primitive
source, or adding explicit `data-slot` attributes upstream.
### Interactive components without a built-in JS bridge
**Symptom:** `data-slot="select-trigger"`, `data-slot="tooltip-content"`,
`data-slot="dialog-content"`, `data-slot="popover-content"` emit the CSS for
their open/closed states, but nothing drives them.
**Workaround:** Consumers must implement open/close + positioning themselves
(Alpine.js, plain JS, HTMX swap). `public/htmx.html` ships a ~60-line vanilla
bridge for checkbox / switch / tabs; select / tooltip / dialog remain
consumer concerns.
**Proposed fix:** ship an opt-in `greyhaven.htmx.js` companion with pointer
positioning (e.g. via Floating UI) and focus management.

357
README.md
View File

@@ -1,346 +1,47 @@
# Greyhaven Design System
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.
A modern design system built with Next.js, shadcn/ui, and Radix UI primitives.
![Screenshot](docs/screenshot.png)
## Quick Start
## Getting Started
```bash
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
# Install dependencies
pnpm install
# Start development server
pnpm dev
# Build for production
pnpm build
# Start production server
pnpm start
```
## Project Structure
```
greyhaven-design-system/
├── 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)
├── 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
├── lib/
── utils.ts # cn() utility
│ └── catalog.ts # Shared component catalog (used by MCP + SKILL.md)
── scripts/
│ ├── generate-skill.ts # SKILL.md generator
│ └── generate-htmx-css.ts # HTMX / framework-agnostic CSS generator
├── dist/
│ └── greyhaven.htmx.css # Auto-generated CSS for HTMX/server-rendered projects
├── app/ # Next.js showcase app (demo only)
└── style-dictionary.config.mjs
── utils.ts # Utility functions
├── styles/ # Additional styles
── public/ # Static assets
```
## Tech Stack
- **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.
> **Also works without React**: `dist/greyhaven.htmx.css` exposes every component via `data-slot` / `data-variant` / `data-size` attribute selectors. HTMX, Django, Rails, Go template, Astro SSR — any project that emits HTML can consume the visual layer. See [HTMX / server-rendered usage](#htmx--server-rendered-usage).
---
## 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
```
---
## HTMX / server-rendered usage
The React components assume a React runtime. For HTMX, Django templates, Rails ERB, Go `html/template`, Astro SSR, or any other server-rendered stack, consume the design system via the auto-generated CSS layer.
### What you get
`dist/greyhaven.htmx.css` is generated from `components/ui/*.tsx` (AST walk over `cva()` configs + static `className` strings on `data-slot` elements). It contains ~300 `@layer components` rules, one per data-slot, with attribute selectors for variants and sizes.
```css
[data-slot="card"] { @apply bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm; }
[data-slot="card-header"] { @apply grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6; }
[data-slot="card-title"] { @apply leading-none font-semibold; }
[data-slot="button"] { @apply inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ; }
[data-slot="button"]:not([data-variant]),
[data-slot="button"][data-variant="default"] { @apply bg-primary text-primary-foreground hover:bg-primary/90; }
[data-slot="button"][data-variant="outline"] { @apply border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground; }
[data-slot="button"][data-size="sm"] { @apply h-8 rounded-md gap-1.5 px-3; }
```
### Install
```bash
./skill/install.sh /path/to/your/project --htmx-css
```
This copies:
- `dist/greyhaven.htmx.css``public/css/greyhaven.htmx.css`
- Aspekta fonts → `public/fonts/`
Add to your Tailwind v4 input CSS:
```css
@import "tailwindcss";
@import "./tokens-light.css";
@import "./tokens-dark.css";
@import "./greyhaven.htmx.css";
```
### Consume
```html
<div data-slot="card">
<div data-slot="card-header">
<div data-slot="card-title">Requests Over Time</div>
<div data-slot="card-description">Last 24 hours</div>
</div>
<div data-slot="card-content"></div>
</div>
<button data-slot="button" data-variant="default">Save</button>
<button data-slot="button" data-variant="outline" data-size="sm">Cancel</button>
<span data-slot="badge" data-variant="success">Active</span>
```
### Scope
- **Static visual components** (Card, Button, Badge, Input, Label, Textarea, Table, Separator, Code, Kbd, Progress, Avatar, Skeleton, Alert, Pagination, Breadcrumb, Navbar, etc.) → fully driven by CSS, no JS needed.
- **Interactive components** (Dialog, Dropdown, Popover, Select, Combobox, Accordion, Tabs, Tooltip, etc.) → CSS emits their static styles, but open/close / positioning / focus management is the consumer's responsibility. Alpine.js pairs naturally with HTMX for these.
- **Native HTML alternatives**: `<details>` covers Accordion/Collapsible, `<dialog>` covers Dialog. The CSS rules apply to those too.
### Regenerate
```bash
pnpm htmx-css:build # Regenerate dist/greyhaven.htmx.css from components/ui/*.tsx
```
Re-runs of `./skill/install.sh --htmx-css` in consumer projects refresh their copy.
---
## 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 and skill/AGENTS.md from tokens + catalog |
| `pnpm htmx-css:build` | Regenerate dist/greyhaven.htmx.css from components/ui/*.tsx |
| `pnpm mcp:start` | Start the MCP server (stdio transport) |
| `pnpm mcp:build` | Type-check MCP server |
| `pnpm lint` | Run ESLint |
- **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)

View File

@@ -1,28 +1,44 @@
@font-face {
font-family: 'Aspekta';
src: url('/fonts/Aspekta-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Aspekta';
src: url('/fonts/Aspekta-500.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Aspekta';
src: url('/fonts/Aspekta-600.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Aspekta';
src: url('/fonts/Aspekta-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@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
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.
Based on Greyhaven Brand Guidelines v1.0
Color Philosophy:
- Neutral and minimal scheme
- Off-black, warm grey, and white form the base
@@ -30,23 +46,162 @@
- 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/serif) + Aspekta (secondary/UI/sans)
Typography: Source Serif Pro (primary) + Aspekta (secondary/UI)
============================================================================= */
@theme inline {
/* Typography — Aspekta (self-hosted) is the canonical sans font */
/* Typography - Calibrated to Slides 19-24 */
--font-serif: 'Source Serif Pro', 'Source Serif 4', 'Georgia', serif;
--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;
/* Institutional Type Scale - Hard-Calibrated to Slide 22 Scale Table */
--type-display: clamp(4rem, 12vw, 9.375rem); /* 150px Display */
--type-h1: clamp(2.5rem, 10vw, 6.25rem); /* 100px H1 */
--type-h2: clamp(2rem, 6vw, 4.6875rem); /* 75px H2 */
--type-h3: clamp(1.5rem, 4vw, 3.75rem); /* 60px H3 */
--type-h4: clamp(1.25rem, 2vw, 2.25rem); /* 36px H4 */
--type-body-lg: 1.125rem; /* 18px Body */
--type-body: 0.9375rem; /* 15px Body SM */
--type-caption: 10px; /* 10pt/px exact fallback */
/* Color mappings — maps CSS var RGB triplets to Tailwind utility classes */
/* Tracking - Slide 22 Table Definitions */
--type-tracking-heading: -0.01em; /* -1% for Display/H1/H2/H3/H4 */
--type-tracking-body: 0.02em; /* +2% for Body/Caption */
/* Institutional Spacing & Construction */
--border-institutional: 0.5px;
--radius-sm: 2px;
--radius-md: 4px;
--radius-lg: 6px;
/* Color mappings */
--color-background: rgb(var(--background));
--color-foreground: rgb(var(--foreground));
--color-card: rgb(var(--card));
@@ -66,36 +221,13 @@
--color-border: rgb(var(--border));
--color-input: rgb(var(--input));
--color-ring: rgb(var(--ring));
--color-chart-1: rgb(var(--chart-1));
--color-chart-2: rgb(var(--chart-2));
--color-chart-3: rgb(var(--chart-3));
--color-chart-4: rgb(var(--chart-4));
--color-chart-5: rgb(var(--chart-5));
--radius-sm: calc(var(--radius) - 2px);
--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));
--color-sidebar-primary-foreground: rgb(var(--sidebar-primary-foreground));
--color-sidebar-accent: rgb(var(--sidebar-accent));
--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;
--color-greyhaven-offwhite: #F9F9F7;
--color-greyhaven-grey1: #F0F0EC;
--color-greyhaven-grey2: #DDDDD7;
--color-greyhaven-grey3: #C4C4BD;
--color-greyhaven-grey4: #A6A69F;
--color-greyhaven-grey5: #7F7F79;
--color-greyhaven-grey7: #575753;
--color-greyhaven-grey8: #2F2F2C;
}
@layer base {
@@ -103,6 +235,23 @@
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
@apply bg-background text-foreground antialiased;
font-feature-settings: "ss01", "ss02", "cv01", "cv11";
}
h1, h2, h3, h4 {
letter-spacing: var(--type-tracking-heading);
font-weight: 500; /* Medium weight matching Slide 19 */
}
p, span, li {
letter-spacing: var(--type-tracking-body);
}
}
@layer utilities {
.institutional-grid {
background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M30 0l25.98 15v30L30 60 4.02 45V15z' fill-rule='evenodd' stroke='%23C4C4BD' stroke-width='0.5' fill='none' opacity='0.1'/%3E%3C/svg%3E");
background-size: 30px 52px;
}
}

View File

@@ -2,7 +2,7 @@ import type { Metadata } from 'next'
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)
// Primary typeface: Source Serif Pro (Source Serif 4 is the updated version)
// Used for headings, body text, and reading content
const sourceSerif = Source_Serif_4({
subsets: ["latin"],
@@ -10,14 +10,9 @@ const sourceSerif = Source_Serif_4({
display: 'swap',
})
// 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
export const metadata: Metadata = {
title: 'Greyhaven Design System',
description: 'Visual Identity and Brand Guidelines - Greyhaven',
generator: 'v0.app',
icons: {
icon: [
{
@@ -44,6 +39,7 @@ export default function RootLayout({
}>) {
return (
<html lang="en" className={sourceSerif.variable}>
<head />
<body className="font-sans antialiased bg-background text-foreground">
{children}
</body>

View File

@@ -73,7 +73,7 @@ export default function DesignSystemPage() {
<section className="mb-16">
<SectionHeader
title="Typography"
description="Source Serif Pro for explanation and human-readable detail. Aspekta (displayed as Inter) for structure, navigation, and UI."
description="Source Serif Pro for explanation and human-readable detail. Aspekta for structure, navigation, and UI."
/>
<TypographySamples />
</section>

View File

@@ -1,170 +0,0 @@
# 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 |

View File

@@ -1,72 +0,0 @@
/* 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;
}

View File

@@ -1,72 +0,0 @@
/* 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;
}

View File

@@ -1,146 +0,0 @@
// 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;

View File

@@ -1,18 +1,23 @@
export function ColorSwatches() {
const primaryColors = [
{ name: "Off-white", hex: "#F9F9F7", rgb: "249 249 247", token: "--card" },
{ name: "Off-black", hex: "#161614", rgb: "22 22 20", token: "--foreground", dark: true },
{ name: "Orange", hex: "#D95E2A", rgb: "217 94 42", token: "--primary", dark: true },
{ name: "Grey 1", hex: "#DDDDD7", rgb: "221 221 215", token: "--secondary", pantone: null },
{ name: "Grey 8", hex: "#2F2F2C", rgb: "47 47 44", token: "--card (dark)", dark: true, pantone: null },
{ name: "Off-black", hex: "#161614", rgb: "22 22 20", token: "--foreground", dark: true, pantone: "PANTONE Black 6 C" },
]
const accentColors = [
{ name: "Orange", hex: "#D95E2A", rgb: "217 94 42", token: "--primary", dark: true, pantone: "PANTONE 1595 C" },
{ name: "Off-white", hex: "#F9F9F7", rgb: "249 249 247", token: "--card", pantone: null },
]
const greyScale = [
{ name: "Grey 1 (5%)", hex: "#F0F0EC", rgb: "240 240 236" },
{ name: "Grey 2 (10%)", hex: "#DDDDD7", rgb: "221 221 215" },
{ name: "Grey 3 (20%)", hex: "#C4C4BD", rgb: "196 196 189" },
{ name: "Grey 4 (50%)", hex: "#A6A69F", rgb: "166 166 159" },
{ name: "Grey 5 (60%)", hex: "#7F7F79", rgb: "127 127 121", dark: true },
{ name: "Grey 7 (70%)", hex: "#575753", rgb: "87 87 83", dark: true },
{ name: "Grey 8 (80%)", hex: "#2F2F2C", rgb: "47 47 44", dark: true },
{ name: "Grey 1", hex: "#DDDDD7", rgb: "221 221 215" },
{ name: "Grey 2", hex: "#C4C4BD", rgb: "196 196 189" },
{ name: "Grey 3", hex: "#A6A69F", rgb: "166 166 159" },
{ name: "Grey 4", hex: "#7F7F79", rgb: "127 127 121", dark: true },
{ name: "Grey 5", hex: "#575753", rgb: "87 87 83", dark: true },
{ name: "Grey 6", hex: "#F0F0EC", rgb: "240 240 236" },
{ name: "Grey 8", hex: "#2F2F2C", rgb: "47 47 44", dark: true },
]
const semanticTokens = [
@@ -30,9 +35,12 @@ export function ColorSwatches() {
<div className="space-y-10">
{/* Primary Scheme */}
<div>
<h4 className="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-4">
<h4 className="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-1">
Primary Scheme
</h4>
<p className="font-sans text-xs text-muted-foreground mb-4">
Neutral and minimal. Off-black, warm grey, and white form the base. No gradients, no decorative color.
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{primaryColors.map((color) => (
<div key={color.name} className="border border-border rounded-md overflow-hidden">
@@ -47,6 +55,40 @@ export function ColorSwatches() {
<div className="p-3 bg-card">
<p className="font-sans text-sm font-medium">{color.name}</p>
<p className="font-mono text-xs text-muted-foreground">{color.token}</p>
{color.pantone && (
<p className="font-sans text-xs text-muted-foreground mt-0.5">{color.pantone}</p>
)}
</div>
</div>
))}
</div>
</div>
{/* Accent */}
<div>
<h4 className="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-1">
Accent
</h4>
<p className="font-sans text-xs text-muted-foreground mb-4">
Orange is the single accent used sparingly to mark what&apos;s active or important.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{accentColors.map((color) => (
<div key={color.name} className="border border-border rounded-md overflow-hidden">
<div
className="h-24 flex items-end p-3"
style={{ backgroundColor: color.hex }}
>
<span className={`font-sans text-xs font-medium ${color.dark ? "text-white" : "text-foreground"}`}>
{color.hex}
</span>
</div>
<div className="p-3 bg-card">
<p className="font-sans text-sm font-medium">{color.name}</p>
<p className="font-mono text-xs text-muted-foreground">{color.token}</p>
{color.pantone && (
<p className="font-sans text-xs text-muted-foreground mt-0.5">{color.pantone}</p>
)}
</div>
</div>
))}

View File

@@ -7,7 +7,6 @@ import { Checkbox } from "@/components/ui/checkbox"
import { Switch } from "@/components/ui/switch"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import {
Select,
SelectContent,
@@ -40,13 +39,125 @@ import {
export function ComponentMatrix() {
return (
<TooltipProvider>
<div className="space-y-10">
<div className="space-y-16">
{/* Typography Audit - Slide 22 */}
<div>
<h4 className="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-4">
Typography Audit Slide 22
</h4>
<div className="border-institutional border-border rounded-sm p-12 bg-brand-offwhite institutional-grid space-y-8">
<div className="space-y-1">
<span className="text-[10px] font-sans text-muted-foreground uppercase tracking-widest">Display Title (150px)</span>
<h1 className="text-display leading-none">Modular Systems</h1>
</div>
<div className="space-y-1">
<span className="text-[10px] font-sans text-muted-foreground uppercase tracking-widest">H1 Headline (100px)</span>
<h1 className="text-h1 leading-none">Sovereign AI Systems</h1>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-8 border-t border-border">
<div className="space-y-2">
<span className="text-[10px] font-sans text-muted-foreground uppercase tracking-widest">H2 Section (75px)</span>
<h2 className="text-h2 leading-tight">Institutional Architecture</h2>
</div>
<div className="space-y-2">
<span className="text-[10px] font-sans text-muted-foreground uppercase tracking-widest">H3 Label (60px)</span>
<h3 className="text-h3 leading-tight">Functional Design</h3>
</div>
</div>
</div>
</div>
{/* Key Visual - Slide 26 */}
<div>
<h4 className="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-4">
Key Visual Composition Slide 26
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-0 border-institutional border-border rounded-sm overflow-hidden min-h-[500px]">
<div className="p-12 bg-brand-offwhite flex flex-col justify-center space-y-6">
<h2 className="text-h1 leading-[0.9] max-w-md">Key visual: structures built from base geometry</h2>
<div className="space-y-4 text-muted-foreground max-w-sm">
<p className="text-body-lg font-serif">Greyhaven's key visual is constructed from a modular system derived from the same underlying geometry as the logo.</p>
<div className="pt-4 space-y-2">
<div className="flex gap-2 text-xs font-sans items-center uppercase tracking-wider">
<span className="w-1.5 h-1.5 bg-brand-orange"></span> Modular Stacking
</div>
<div className="flex gap-2 text-xs font-sans items-center uppercase tracking-wider">
<span className="w-1.5 h-1.5 bg-brand-orange"></span> Hexagonal Logic
</div>
</div>
</div>
</div>
<div className="bg-brand-black relative flex items-center justify-center p-12">
<div className="absolute inset-0 institutional-grid opacity-20"></div>
<img
src="/brand/modular-geometry.png"
alt="Modular Geometry"
className="relative z-10 object-contain w-full h-full scale-110"
/>
<div className="absolute left-6 top-6 bottom-6 w-[2px] bg-brand-orange/50"></div>
</div>
</div>
</div>
{/* Brand Identity Integration */}
<div>
<h4 className="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-4">
Institutional Brand Integration
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Modular Geometry Showcase */}
<div className="border-institutional border-border rounded-sm p-10 bg-[#F9F9F7] institutional-accent overflow-hidden relative min-h-[350px] flex flex-col justify-end">
<div className="absolute top-4 right-4 bottom-4 w-1/2 opacity-80">
<img
src="/brand/modular-geometry.png"
alt="Modular Geometry"
className="object-contain object-right h-full w-full"
/>
</div>
<div className="relative z-10 space-y-4">
<Badge className="bg-brand-orange text-white border-0 rounded-none text-[10px] uppercase tracking-widest px-2 py-0.5">Structural</Badge>
<h3 className="text-display text-5xl leading-[0.9] text-brand-offblack">Modular<br />Core</h3>
<p className="text-sm text-brand-grey7 max-w-[220px] font-sans">
Leveraging architectural logic through 3D modular geometry.
</p>
</div>
</div>
{/* Prompt A & B Treatments */}
<div className="space-y-6">
<div className="border-institutional border-border rounded-sm overflow-hidden bg-brand-offwhite text-brand-offblack flex flex-col">
<div className="aspect-[4/3] flex items-center justify-center overflow-hidden grayscale hover:grayscale-0 transition-all duration-500">
<img src="/brand/briefcase-lineart.png" alt="Lineart" className="w-full h-full object-cover" />
</div>
<div className="p-6">
<h5 className="font-sans text-xs font-bold uppercase tracking-widest text-brand-orange mb-1">Prompt A</h5>
<h4 className="text-body-lg font-serif mb-1">Institutional Lineart</h4>
<p className="text-xs text-brand-grey7">High-contrast structural lineart defined for documentation visuals.</p>
</div>
</div>
<div className="border-institutional border-border rounded-sm overflow-hidden bg-brand-black text-brand-offwhite flex flex-col">
<div className="aspect-[4/3] flex items-center justify-center overflow-hidden">
<img src="/brand/briefcase-silhouette.png" alt="Silhouette" className="w-full h-full object-cover" />
</div>
<div className="p-6">
<h5 className="font-sans text-xs font-bold uppercase tracking-widest text-[#D95E2A] mb-1">Prompt B</h5>
<h4 className="text-body-lg font-serif mb-1 italic">High-Contrast Silhouette</h4>
<p className="text-xs text-zinc-400">Bold graphic silhouettes used for high-impact social and poster assets.</p>
</div>
</div>
</div>
</div>
</div>
{/* Buttons - All Variants */}
<div>
<h4 className="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-4">
Buttons Variants
</h4>
<div className="border border-border rounded-md p-6 bg-card">
<div className="border-institutional border-border rounded-sm p-6 bg-card">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<div className="space-y-2">
<p className="font-sans text-xs text-muted-foreground">Primary</p>
@@ -81,7 +192,7 @@ export function ComponentMatrix() {
<h4 className="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-4">
Buttons Sizes
</h4>
<div className="border border-border rounded-md p-6 bg-card">
<div className="border-institutional border-border rounded-sm p-6 bg-card">
<div className="flex flex-wrap items-end gap-4">
<div className="space-y-2">
<p className="font-sans text-xs text-muted-foreground">Small</p>
@@ -104,7 +215,7 @@ export function ComponentMatrix() {
<h4 className="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-4">
Buttons States
</h4>
<div className="border border-border rounded-md p-6 bg-card">
<div className="border-institutional border-border rounded-sm p-6 bg-card">
<div className="space-y-6">
{/* Primary States */}
<div>
@@ -146,7 +257,7 @@ export function ComponentMatrix() {
<h4 className="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-4">
Icon Buttons
</h4>
<div className="border border-border rounded-md p-6 bg-card">
<div className="border-institutional border-border rounded-sm p-6 bg-card">
<div className="space-y-6">
{/* By Variant */}
<div>
@@ -186,7 +297,7 @@ export function ComponentMatrix() {
<h4 className="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-4">
Buttons with Icons
</h4>
<div className="border border-border rounded-md p-6 bg-card">
<div className="border-institutional border-border rounded-sm p-6 bg-card">
<div className="space-y-6">
{/* Leading Icons */}
<div>
@@ -225,7 +336,7 @@ export function ComponentMatrix() {
<h4 className="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-4">
Badges Core Variants
</h4>
<div className="border border-border rounded-md p-6 bg-card">
<div className="border-institutional border-border rounded-sm p-6 bg-card">
<div className="flex flex-wrap gap-3">
<Badge>Default</Badge>
<Badge variant="secondary">Secondary</Badge>
@@ -240,7 +351,7 @@ export function ComponentMatrix() {
<h4 className="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-4">
Badges Tag & Value
</h4>
<div className="border border-border rounded-md p-6 bg-card">
<div className="border-institutional border-border rounded-sm p-6 bg-card">
<div className="flex flex-wrap gap-3">
<Badge variant="tag">Tag</Badge>
<Badge variant="tag">Category</Badge>
@@ -256,7 +367,7 @@ export function ComponentMatrix() {
<h4 className="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-4">
Badges Semantic
</h4>
<div className="border border-border rounded-md p-6 bg-card">
<div className="border-institutional border-border rounded-sm p-6 bg-card">
<div className="flex flex-wrap gap-3">
<Badge variant="success">Success</Badge>
<Badge variant="warning">Warning</Badge>
@@ -271,7 +382,7 @@ export function ComponentMatrix() {
<h4 className="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-4">
Badges Channel Pills
</h4>
<div className="border border-border rounded-md p-6 bg-card">
<div className="border-institutional border-border rounded-sm p-6 bg-card">
<div className="flex flex-wrap gap-3">
<Badge variant="whatsapp">WhatsApp</Badge>
<Badge variant="email">Email</Badge>
@@ -304,7 +415,7 @@ export function ComponentMatrix() {
<h4 className="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-4">
Inputs
</h4>
<div className="border border-border rounded-md p-6 bg-card">
<div className="border-institutional border-border rounded-sm p-6 bg-card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-3">
<p className="font-sans text-xs text-muted-foreground">Default</p>
@@ -335,7 +446,7 @@ export function ComponentMatrix() {
<h4 className="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-4">
Select
</h4>
<div className="border border-border rounded-md p-6 bg-card">
<div className="border-institutional border-border rounded-sm p-6 bg-card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-3">
<p className="font-sans text-xs text-muted-foreground">Default</p>
@@ -383,7 +494,7 @@ export function ComponentMatrix() {
<h4 className="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-4">
Checkboxes & Switches
</h4>
<div className="border border-border rounded-md p-6 bg-card">
<div className="border-institutional border-border rounded-sm p-6 bg-card">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-4">
<p className="font-sans text-xs text-muted-foreground">Checkboxes</p>
@@ -429,7 +540,7 @@ export function ComponentMatrix() {
<h4 className="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-4">
Tabs
</h4>
<div className="border border-border rounded-md p-6 bg-card">
<div className="border-institutional border-border rounded-sm p-6 bg-card">
<Tabs defaultValue="overview" className="w-full">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
@@ -456,69 +567,6 @@ export function ComponentMatrix() {
</div>
</div>
{/* Toggle Group */}
<div id="sub-toggle-group">
<h4 className="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-4">
Toggle Group
</h4>
<div className="border border-border rounded-md p-6 bg-card">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-4">
<div>
<p className="font-sans text-xs text-muted-foreground mb-2">Single, outline</p>
<ToggleGroup type="single" variant="outline" defaultValue="light" aria-label="Theme">
<ToggleGroupItem value="system" aria-label="System">System</ToggleGroupItem>
<ToggleGroupItem value="light" aria-label="Light">Light</ToggleGroupItem>
<ToggleGroupItem value="dark" aria-label="Dark">Dark</ToggleGroupItem>
</ToggleGroup>
</div>
<div>
<p className="font-sans text-xs text-muted-foreground mb-2">Single, default</p>
<ToggleGroup type="single" defaultValue="grid" aria-label="Layout">
<ToggleGroupItem value="list" aria-label="List">List</ToggleGroupItem>
<ToggleGroupItem value="grid" aria-label="Grid">Grid</ToggleGroupItem>
<ToggleGroupItem value="board" aria-label="Board" disabled>Board</ToggleGroupItem>
</ToggleGroup>
</div>
<div>
<p className="font-sans text-xs text-muted-foreground mb-2">Multiple</p>
<ToggleGroup type="multiple" variant="outline" defaultValue={["bold"]} aria-label="Text formatting">
<ToggleGroupItem value="bold" aria-label="Bold">Bold</ToggleGroupItem>
<ToggleGroupItem value="italic" aria-label="Italic">Italic</ToggleGroupItem>
<ToggleGroupItem value="underline" aria-label="Underline">Underline</ToggleGroupItem>
</ToggleGroup>
</div>
</div>
<div className="space-y-4">
<div>
<p className="font-sans text-xs text-muted-foreground mb-2">Small</p>
<ToggleGroup type="single" variant="outline" size="sm" defaultValue="light" aria-label="Theme (sm)">
<ToggleGroupItem value="system">System</ToggleGroupItem>
<ToggleGroupItem value="light">Light</ToggleGroupItem>
<ToggleGroupItem value="dark">Dark</ToggleGroupItem>
</ToggleGroup>
</div>
<div>
<p className="font-sans text-xs text-muted-foreground mb-2">Default</p>
<ToggleGroup type="single" variant="outline" defaultValue="light" aria-label="Theme (default)">
<ToggleGroupItem value="system">System</ToggleGroupItem>
<ToggleGroupItem value="light">Light</ToggleGroupItem>
<ToggleGroupItem value="dark">Dark</ToggleGroupItem>
</ToggleGroup>
</div>
<div>
<p className="font-sans text-xs text-muted-foreground mb-2">Large</p>
<ToggleGroup type="single" variant="outline" size="lg" defaultValue="light" aria-label="Theme (lg)">
<ToggleGroupItem value="system">System</ToggleGroupItem>
<ToggleGroupItem value="light">Light</ToggleGroupItem>
<ToggleGroupItem value="dark">Dark</ToggleGroupItem>
</ToggleGroup>
</div>
</div>
</div>
</div>
</div>
{/* Tooltips */}
<div>
<h4 className="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-4">

View File

@@ -104,20 +104,67 @@ export function TypographySamples() {
</div>
</div>
{/* Type Scale Reference */}
<div className="border border-border rounded-md p-6 bg-card">
<h4 className="font-sans text-sm font-semibold text-muted-foreground mb-4 uppercase tracking-wide">
Type Scale Brand Guidelines v1.1
</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm font-sans">
<thead>
<tr className="border-b border-border text-left">
<th className="pb-2 pr-6 font-medium text-muted-foreground text-xs uppercase tracking-wide">Level</th>
<th className="pb-2 pr-6 font-medium text-muted-foreground text-xs uppercase tracking-wide">Print ref</th>
<th className="pb-2 pr-6 font-medium text-muted-foreground text-xs uppercase tracking-wide">Web token</th>
<th className="pb-2 font-medium text-muted-foreground text-xs uppercase tracking-wide">Tracking</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{[
{ level: "Display", print: "136pt", token: "--type-display", tracking: "1%" },
{ level: "H1", print: "68pt", token: "--type-h1", tracking: "1%" },
{ level: "H2", print: "51pt", token: "--type-h2", tracking: "1%" },
{ level: "H3", print: "40pt", token: "--type-h3", tracking: "1%" },
{ level: "H4", print: "36pt", token: "--type-h4", tracking: "1%" },
{ level: "Body", print: "18px", token: "--type-body-lg", tracking: "2%" },
{ level: "Body sm", print: "15px", token: "--type-body-sm", tracking: "2%" },
{ level: "Caption", print: "10pt", token: "--type-caption", tracking: "2%" },
].map((row) => (
<tr key={row.level}>
<td className="py-2 pr-6 font-medium">{row.level}</td>
<td className="py-2 pr-6 text-muted-foreground font-mono text-xs">{row.print}</td>
<td className="py-2 pr-6 text-muted-foreground font-mono text-xs">{row.token}</td>
<td className="py-2 text-muted-foreground font-mono text-xs">{row.tracking}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Font Stack Reference */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="border border-border rounded-md p-4 bg-card">
<p className="font-sans text-xs text-muted-foreground uppercase tracking-wide mb-2">Serif Stack</p>
<p className="font-sans text-xs text-muted-foreground uppercase tracking-wide mb-2">Primary Serif</p>
<p className="font-mono text-xs text-muted-foreground">
&apos;Source Serif Pro&apos;, &apos;Source Serif 4&apos;, Georgia, serif
</p>
</div>
<div className="border border-border rounded-md p-4 bg-card">
<p className="font-sans text-xs text-muted-foreground uppercase tracking-wide mb-2">Sans Stack</p>
<p className="font-sans text-xs text-muted-foreground uppercase tracking-wide mb-1">Secondary Sans</p>
<p className="font-mono text-xs text-muted-foreground">
&apos;Aspekta&apos;, ui-sans-serif, system-ui, sans-serif
</p>
</div>
<div className="border border-border rounded-md p-4 bg-card">
<p className="font-sans text-xs text-muted-foreground uppercase tracking-wide mb-1">Logo only</p>
<p className="font-mono text-xs text-muted-foreground mb-2">
Circular Medium
</p>
<p className="font-sans text-xs text-muted-foreground">
Used exclusively for the Greyhaven wordmark and product logos. Do not use in UI.
</p>
</div>
</div>
</div>
)

View File

@@ -1,87 +1,11 @@
'use client'
import * as React from 'react'
import {
ThemeProvider as NextThemesProvider,
type ThemeProviderProps,
} from 'next-themes'
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
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@@ -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 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 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',
{
variants: {
variant: {
@@ -43,15 +43,9 @@ 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',
},
},
)
@@ -59,7 +53,6 @@ const badgeVariants = cva(
function Badge({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'span'> &
@@ -69,7 +62,7 @@ function Badge({
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant, size }), className)}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)

View File

@@ -1,63 +0,0 @@
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<typeof codeVariants> {
/**
* 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 = (
<code
data-slot="code"
data-language={language}
className={cn(
codeVariants({ variant, className }),
language && `language-${language}`,
)}
{...props}
/>
)
// For block variant, wrap in <pre> so copy-paste preserves whitespace
// and screen readers announce it as a code block.
if (variant === 'block') {
return (
<pre data-slot="code-block" className="not-prose">
{element}
</pre>
)
}
return element
}
export { Code, codeVariants }

View File

@@ -1,86 +0,0 @@
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 }

View File

@@ -1,109 +0,0 @@
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 }

View File

@@ -1,94 +0,0 @@
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<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 }

View File

@@ -1,92 +0,0 @@
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 }

View File

@@ -1,131 +0,0 @@
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<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="shrink-0">
{logo}
</div>
)}
{/* Desktop nav — center */}
<nav
data-slot="navbar-nav"
className="hidden md:flex items-center gap-1 text-sm font-semibold"
>
{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-card dark:bg-background"
>
<nav className="container mx-auto px-6 py-4 flex flex-col gap-2 text-sm font-semibold">
{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 text-foreground transition-opacity hover:opacity-70',
'data-active:text-primary data-active:opacity-100',
className,
)}
{...props}
/>
)
}
export { Navbar, NavbarLink, navbarVariants }

View File

@@ -1,52 +0,0 @@
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.25', // offset for fixed 65px 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 }

View File

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

View File

@@ -60,7 +60,7 @@ function ToggleGroupItem({
variant: context.variant || variant,
size: context.size || size,
}),
'rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l',
'min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l',
className,
)}
{...props}

View File

@@ -7,7 +7,7 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
@@ -16,9 +16,9 @@ const toggleVariants = cva(
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-9 px-4 min-w-9 has-[>svg]:px-3',
sm: 'h-8 px-3 min-w-8 has-[>svg]:px-2.5',
lg: 'h-10 px-6 min-w-10 has-[>svg]:px-4',
default: 'h-9 px-2 min-w-9',
sm: 'h-8 px-1.5 min-w-8',
lg: 'h-10 px-2.5 min-w-10',
},
},
defaultVariants: {

View File

@@ -1,871 +0,0 @@
/*! Greyhaven Design System — HTMX / Framework-Agnostic CSS Layer
* Auto-generated from components/ui/*.tsx by scripts/generate-htmx-css.ts — DO NOT EDIT
*
* Usage:
* <link href="greyhaven.htmx.css" rel="stylesheet">
*
* Requires:
* - Tokens: import tokens-light.css + tokens-dark.css before this file
* - Tailwind v4: this file uses @apply against Tailwind utility classes.
* It must be processed by Tailwind v4 (e.g., via `tailwindcss -i input.css`).
* Your consumer Tailwind input should `@import "./greyhaven.htmx.css";`.
*
* Consume via data-slot / data-variant / data-size attributes:
* <div data-slot="card">
* <div data-slot="card-header"><div data-slot="card-title">Hello</div></div>
* <div data-slot="card-content">…</div>
* </div>
* <button data-slot="button" data-variant="outline" data-size="sm">Click</button>
* <span data-slot="badge" data-variant="success">Active</span>
*/
@layer utilities {
/* ── accordion-content ─────────────────────────────────────────── */
:where([data-slot="accordion-content"]) { @apply data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm; }
/* ── accordion-item ─────────────────────────────────────────── */
:where([data-slot="accordion-item"]) { @apply border-b last:border-b-0; }
/* ── accordion-trigger ─────────────────────────────────────────── */
:where([data-slot="accordion-trigger"]) { @apply focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180; }
/* ── alert ─────────────────────────────────────────── */
:where([data-slot="alert"]) { @apply relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current; }
:where([data-slot="alert"]):where(:not([data-variant])) { @apply bg-card text-card-foreground; }
:where([data-slot="alert"]):where([data-variant="default"]) { @apply bg-card text-card-foreground; }
:where([data-slot="alert"]):where([data-variant="destructive"]) { @apply text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90; }
/* ── alert-description ─────────────────────────────────────────── */
:where([data-slot="alert-description"]) { @apply text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed; }
/* ── alert-dialog-content ─────────────────────────────────────────── */
:where([data-slot="alert-dialog-content"]) { @apply bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg; }
/* ── alert-dialog-description ─────────────────────────────────────────── */
:where([data-slot="alert-dialog-description"]) { @apply text-muted-foreground text-sm; }
/* ── alert-dialog-footer ─────────────────────────────────────────── */
:where([data-slot="alert-dialog-footer"]) { @apply flex flex-col-reverse gap-2 sm:flex-row sm:justify-end; }
/* ── alert-dialog-header ─────────────────────────────────────────── */
:where([data-slot="alert-dialog-header"]) { @apply flex flex-col gap-2 text-center sm:text-left; }
/* ── alert-dialog-overlay ─────────────────────────────────────────── */
:where([data-slot="alert-dialog-overlay"]) { @apply data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50; }
/* ── alert-dialog-title ─────────────────────────────────────────── */
:where([data-slot="alert-dialog-title"]) { @apply text-lg font-semibold; }
/* ── alert-title ─────────────────────────────────────────── */
:where([data-slot="alert-title"]) { @apply col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight; }
/* ── avatar ─────────────────────────────────────────── */
:where([data-slot="avatar"]) { @apply relative flex size-8 shrink-0 overflow-hidden rounded-full; }
/* ── avatar-fallback ─────────────────────────────────────────── */
:where([data-slot="avatar-fallback"]) { @apply bg-muted flex size-full items-center justify-center rounded-full; }
/* ── avatar-image ─────────────────────────────────────────── */
:where([data-slot="avatar-image"]) { @apply aspect-square size-full; }
/* ── badge ─────────────────────────────────────────── */
:where([data-slot="badge"]) { @apply 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; }
:where([data-slot="badge"]):where(:not([data-variant])) { @apply border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90; }
:where([data-slot="badge"]):where([data-variant="default"]) { @apply border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90; }
:where([data-slot="badge"]):where([data-variant="secondary"]) { @apply border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90; }
:where([data-slot="badge"]):where([data-variant="muted"]) { @apply border-transparent bg-muted text-muted-foreground [a&]:hover:bg-muted/80; }
:where([data-slot="badge"]):where([data-variant="destructive"]) { @apply border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60; }
:where([data-slot="badge"]):where([data-variant="outline"]) { @apply text-foreground [a&]:hover:bg-accent/10 [a&]:hover:text-accent-foreground; }
:where([data-slot="badge"]):where([data-variant="success"]) { @apply border-transparent bg-[#1a7f37] text-white [a&]:hover:bg-[#1a7f37]/90; }
:where([data-slot="badge"]):where([data-variant="warning"]) { @apply border-transparent bg-[#9a6700] text-white [a&]:hover:bg-[#9a6700]/90; }
:where([data-slot="badge"]):where([data-variant="info"]) { @apply border-transparent bg-[#0969da] text-white [a&]:hover:bg-[#0969da]/90; }
:where([data-slot="badge"]):where([data-variant="tag"]) { @apply border-border bg-card text-foreground [a&]:hover:bg-muted; }
:where([data-slot="badge"]):where([data-variant="value"]) { @apply border-transparent bg-muted text-foreground font-mono [a&]:hover:bg-muted/80; }
:where([data-slot="badge"]):where([data-variant="whatsapp"]) { @apply border-transparent bg-[#22c55e] text-white [a&]:hover:bg-[#22c55e]/90; }
:where([data-slot="badge"]):where([data-variant="email"]) { @apply border-transparent bg-[#4b5563] text-white [a&]:hover:bg-[#4b5563]/90; }
:where([data-slot="badge"]):where([data-variant="telegram"]) { @apply border-transparent bg-[#3b82f6] text-white [a&]:hover:bg-[#3b82f6]/90; }
:where([data-slot="badge"]):where([data-variant="zulip"]) { @apply border-transparent bg-[#a855f7] text-white [a&]:hover:bg-[#a855f7]/90; }
:where([data-slot="badge"]):where([data-variant="platform"]) { @apply border-transparent bg-[#f97316] text-white [a&]:hover:bg-[#f97316]/90; }
:where([data-slot="badge"]):where([data-size="sm"]) { @apply text-xs px-1.5 py-0; }
:where([data-slot="badge"]):where(:not([data-size])) { @apply text-xs px-2 py-0.5; }
:where([data-slot="badge"]):where([data-size="default"]) { @apply text-xs px-2 py-0.5; }
:where([data-slot="badge"]):where([data-size="lg"]) { @apply text-sm px-3 py-1 [&>svg]:size-3.5; }
/* ── breadcrumb-ellipsis ─────────────────────────────────────────── */
:where([data-slot="breadcrumb-ellipsis"]) { @apply flex size-9 items-center justify-center; }
/* ── breadcrumb-item ─────────────────────────────────────────── */
:where([data-slot="breadcrumb-item"]) { @apply inline-flex items-center gap-1.5; }
/* ── breadcrumb-link ─────────────────────────────────────────── */
:where([data-slot="breadcrumb-link"]) { @apply hover:text-foreground transition-colors; }
/* ── breadcrumb-list ─────────────────────────────────────────── */
:where([data-slot="breadcrumb-list"]) { @apply text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5; }
/* ── breadcrumb-page ─────────────────────────────────────────── */
:where([data-slot="breadcrumb-page"]) { @apply text-foreground font-normal; }
/* ── breadcrumb-separator ─────────────────────────────────────────── */
:where([data-slot="breadcrumb-separator"]) { @apply [&>svg]:size-3.5; }
/* ── button ─────────────────────────────────────────── */
:where([data-slot="button"]) { @apply inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-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; }
:where([data-slot="button"]):where(:not([data-variant])) { @apply bg-primary text-primary-foreground hover:bg-primary/90; }
:where([data-slot="button"]):where([data-variant="default"]) { @apply bg-primary text-primary-foreground hover:bg-primary/90; }
:where([data-slot="button"]):where([data-variant="destructive"]) { @apply bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60; }
:where([data-slot="button"]):where([data-variant="outline"]) { @apply border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50; }
:where([data-slot="button"]):where([data-variant="secondary"]) { @apply bg-secondary text-secondary-foreground hover:bg-secondary/80; }
:where([data-slot="button"]):where([data-variant="ghost"]) { @apply hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50; }
:where([data-slot="button"]):where([data-variant="link"]) { @apply text-primary underline-offset-4 hover:underline; }
:where([data-slot="button"]):where(:not([data-size])) { @apply h-9 px-4 py-2 has-[>svg]:px-3; }
:where([data-slot="button"]):where([data-size="default"]) { @apply h-9 px-4 py-2 has-[>svg]:px-3; }
:where([data-slot="button"]):where([data-size="sm"]) { @apply h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5; }
:where([data-slot="button"]):where([data-size="lg"]) { @apply h-10 rounded-md px-6 has-[>svg]:px-4; }
:where([data-slot="button"]):where([data-size="icon"]) { @apply size-9; }
:where([data-slot="button"]):where([data-size="icon-sm"]) { @apply size-8; }
:where([data-slot="button"]):where([data-size="icon-lg"]) { @apply size-10; }
/* ── button-group ─────────────────────────────────────────── */
:where([data-slot="button-group"]) { @apply flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2; }
:where([data-slot="button-group"]):where(:not([data-orientation])) { @apply [&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none; }
:where([data-slot="button-group"]):where([data-orientation="horizontal"]) { @apply [&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none; }
:where([data-slot="button-group"]):where([data-orientation="vertical"]) { @apply flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none; }
/* ── button-group-separator ─────────────────────────────────────────── */
:where([data-slot="button-group-separator"]) { @apply bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto; }
/* ── card ─────────────────────────────────────────── */
:where([data-slot="card"]) { @apply bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm; }
/* ── card-action ─────────────────────────────────────────── */
:where([data-slot="card-action"]) { @apply col-start-2 row-span-2 row-start-1 self-start justify-self-end; }
/* ── card-content ─────────────────────────────────────────── */
:where([data-slot="card-content"]) { @apply px-6; }
/* ── card-description ─────────────────────────────────────────── */
:where([data-slot="card-description"]) { @apply text-muted-foreground text-sm; }
/* ── card-footer ─────────────────────────────────────────── */
:where([data-slot="card-footer"]) { @apply flex items-center px-6 [.border-t]:pt-6; }
/* ── card-header ─────────────────────────────────────────── */
:where([data-slot="card-header"]) { @apply @container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6; }
/* ── card-title ─────────────────────────────────────────── */
:where([data-slot="card-title"]) { @apply font-semibold; }
/* ── carousel ─────────────────────────────────────────── */
:where([data-slot="carousel"]) { @apply relative; }
/* ── carousel-content ─────────────────────────────────────────── */
:where([data-slot="carousel-content"]) { @apply overflow-hidden; }
/* ── carousel-item ─────────────────────────────────────────── */
:where([data-slot="carousel-item"]) { @apply min-w-0 shrink-0 grow-0 basis-full; }
/* ── carousel-next ─────────────────────────────────────────── */
:where([data-slot="carousel-next"]) { @apply absolute size-8 rounded-full; }
/* ── carousel-previous ─────────────────────────────────────────── */
:where([data-slot="carousel-previous"]) { @apply absolute size-8 rounded-full; }
/* ── chart ─────────────────────────────────────────── */
:where([data-slot="chart"]) { @apply [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden; }
/* ── checkbox ─────────────────────────────────────────── */
:where([data-slot="checkbox"]) { @apply border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50; }
/* ── checkbox-indicator ─────────────────────────────────────────── */
:where([data-slot="checkbox-indicator"]) { @apply flex items-center justify-center text-current transition-none; }
/* ── code ─────────────────────────────────────────── */
:where([data-slot="code"]) { @apply bg-muted border border-border font-mono text-foreground; }
:where([data-slot="code"]):where(:not([data-variant])) { @apply rounded text-xs px-1.5 py-0.5; }
:where([data-slot="code"]):where([data-variant="inline"]) { @apply rounded text-xs px-1.5 py-0.5; }
:where([data-slot="code"]):where([data-variant="block"]) { @apply block rounded-md text-sm px-4 py-3 break-all whitespace-pre-wrap; }
/* ── code-block ─────────────────────────────────────────── */
/* ── command ─────────────────────────────────────────── */
:where([data-slot="command"]) { @apply bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md; }
/* ── command-empty ─────────────────────────────────────────── */
:where([data-slot="command-empty"]) { @apply py-6 text-center text-sm; }
/* ── command-group ─────────────────────────────────────────── */
:where([data-slot="command-group"]) { @apply text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium; }
/* ── command-input ─────────────────────────────────────────── */
:where([data-slot="command-input"]) { @apply placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50; }
/* ── command-input-wrapper ─────────────────────────────────────────── */
:where([data-slot="command-input-wrapper"]) { @apply flex h-9 items-center gap-2 border-b px-3; }
/* ── command-item ─────────────────────────────────────────── */
:where([data-slot="command-item"]) { @apply data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
/* ── command-list ─────────────────────────────────────────── */
:where([data-slot="command-list"]) { @apply max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto; }
/* ── command-separator ─────────────────────────────────────────── */
:where([data-slot="command-separator"]) { @apply bg-border -mx-1 h-px; }
/* ── command-shortcut ─────────────────────────────────────────── */
:where([data-slot="command-shortcut"]) { @apply text-muted-foreground ml-auto text-xs tracking-widest; }
/* ── context-menu-checkbox-item ─────────────────────────────────────────── */
:where([data-slot="context-menu-checkbox-item"]) { @apply focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
/* ── context-menu-content ─────────────────────────────────────────── */
:where([data-slot="context-menu-content"]) { @apply bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md; }
/* ── context-menu-item ─────────────────────────────────────────── */
:where([data-slot="context-menu-item"]) { @apply focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
/* ── context-menu-label ─────────────────────────────────────────── */
:where([data-slot="context-menu-label"]) { @apply text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8; }
/* ── context-menu-radio-item ─────────────────────────────────────────── */
:where([data-slot="context-menu-radio-item"]) { @apply focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
/* ── context-menu-separator ─────────────────────────────────────────── */
:where([data-slot="context-menu-separator"]) { @apply bg-border -mx-1 my-1 h-px; }
/* ── context-menu-shortcut ─────────────────────────────────────────── */
:where([data-slot="context-menu-shortcut"]) { @apply text-muted-foreground ml-auto text-xs tracking-widest; }
/* ── context-menu-sub-content ─────────────────────────────────────────── */
:where([data-slot="context-menu-sub-content"]) { @apply bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg; }
/* ── context-menu-sub-trigger ─────────────────────────────────────────── */
:where([data-slot="context-menu-sub-trigger"]) { @apply focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
/* ── cta-section ─────────────────────────────────────────── */
:where([data-slot="cta-section"]) { @apply py-16 px-6; }
:where([data-slot="cta-section"]):where(:not([data-variant])) { @apply text-center; }
:where([data-slot="cta-section"]):where([data-variant="centered"]) { @apply text-center; }
:where([data-slot="cta-section"]):where([data-variant="left-aligned"]) { @apply text-left; }
:where([data-slot="cta-section"]):where([data-background="default"]) { @apply bg-background; }
:where([data-slot="cta-section"]):where(:not([data-background])) { @apply bg-muted; }
:where([data-slot="cta-section"]):where([data-background="muted"]) { @apply bg-muted; }
:where([data-slot="cta-section"]):where([data-background="accent"]) { @apply bg-primary text-primary-foreground; }
:where([data-slot="cta-section"]):where([data-background="subtle"]) { @apply bg-primary/5; }
/* ── dialog-close ─────────────────────────────────────────── */
:where([data-slot="dialog-close"]) { @apply ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
/* ── dialog-content ─────────────────────────────────────────── */
:where([data-slot="dialog-content"]) { @apply bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg; }
/* ── dialog-description ─────────────────────────────────────────── */
:where([data-slot="dialog-description"]) { @apply text-muted-foreground text-sm; }
/* ── dialog-footer ─────────────────────────────────────────── */
:where([data-slot="dialog-footer"]) { @apply flex flex-col-reverse gap-2 sm:flex-row sm:justify-end; }
/* ── dialog-header ─────────────────────────────────────────── */
:where([data-slot="dialog-header"]) { @apply flex flex-col gap-2 text-center sm:text-left; }
/* ── dialog-overlay ─────────────────────────────────────────── */
:where([data-slot="dialog-overlay"]) { @apply data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50; }
/* ── dialog-title ─────────────────────────────────────────── */
:where([data-slot="dialog-title"]) { @apply text-lg font-semibold; }
/* ── drawer-content ─────────────────────────────────────────── */
:where([data-slot="drawer-content"]) { @apply bg-background fixed z-50 flex h-auto flex-col; }
/* ── drawer-description ─────────────────────────────────────────── */
:where([data-slot="drawer-description"]) { @apply text-muted-foreground text-sm; }
/* ── drawer-footer ─────────────────────────────────────────── */
:where([data-slot="drawer-footer"]) { @apply mt-auto flex flex-col gap-2 p-4; }
/* ── drawer-header ─────────────────────────────────────────── */
:where([data-slot="drawer-header"]) { @apply flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left; }
/* ── drawer-overlay ─────────────────────────────────────────── */
:where([data-slot="drawer-overlay"]) { @apply data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50; }
/* ── drawer-title ─────────────────────────────────────────── */
:where([data-slot="drawer-title"]) { @apply text-foreground font-semibold; }
/* ── dropdown-menu-checkbox-item ─────────────────────────────────────────── */
:where([data-slot="dropdown-menu-checkbox-item"]) { @apply focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
/* ── dropdown-menu-content ─────────────────────────────────────────── */
:where([data-slot="dropdown-menu-content"]) { @apply bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md; }
/* ── dropdown-menu-item ─────────────────────────────────────────── */
:where([data-slot="dropdown-menu-item"]) { @apply focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
/* ── dropdown-menu-label ─────────────────────────────────────────── */
:where([data-slot="dropdown-menu-label"]) { @apply px-2 py-1.5 text-sm font-medium data-[inset]:pl-8; }
/* ── dropdown-menu-radio-item ─────────────────────────────────────────── */
:where([data-slot="dropdown-menu-radio-item"]) { @apply focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
/* ── dropdown-menu-separator ─────────────────────────────────────────── */
:where([data-slot="dropdown-menu-separator"]) { @apply bg-border -mx-1 my-1 h-px; }
/* ── dropdown-menu-shortcut ─────────────────────────────────────────── */
:where([data-slot="dropdown-menu-shortcut"]) { @apply text-muted-foreground ml-auto text-xs tracking-widest; }
/* ── dropdown-menu-sub-content ─────────────────────────────────────────── */
:where([data-slot="dropdown-menu-sub-content"]) { @apply bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg; }
/* ── dropdown-menu-sub-trigger ─────────────────────────────────────────── */
:where([data-slot="dropdown-menu-sub-trigger"]) { @apply focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
/* ── empty ─────────────────────────────────────────── */
:where([data-slot="empty"]) { @apply flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12; }
/* ── empty-content ─────────────────────────────────────────── */
:where([data-slot="empty-content"]) { @apply flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance; }
/* ── empty-description ─────────────────────────────────────────── */
:where([data-slot="empty-description"]) { @apply text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4; }
/* ── empty-header ─────────────────────────────────────────── */
:where([data-slot="empty-header"]) { @apply flex max-w-sm flex-col items-center gap-2 text-center; }
/* ── empty-media ─────────────────────────────────────────── */
:where([data-slot="empty-media"]) { @apply flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0; }
:where([data-slot="empty-media"]):where(:not([data-variant])) { @apply bg-transparent; }
:where([data-slot="empty-media"]):where([data-variant="default"]) { @apply bg-transparent; }
:where([data-slot="empty-media"]):where([data-variant="icon"]) { @apply bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6; }
/* ── empty-title ─────────────────────────────────────────── */
:where([data-slot="empty-title"]) { @apply text-lg font-medium tracking-tight; }
/* ── field ─────────────────────────────────────────── */
:where([data-slot="field"]) { @apply flex w-full gap-3 data-[invalid=true]:text-destructive; }
/* ── field-content ─────────────────────────────────────────── */
:where([data-slot="field-content"]) { @apply flex flex-1 flex-col gap-1.5; }
/* ── field-description ─────────────────────────────────────────── */
:where([data-slot="field-description"]) { @apply text-muted-foreground text-sm font-normal group-has-[[data-orientation=horizontal]]/field:text-balance; }
/* ── field-error ─────────────────────────────────────────── */
:where([data-slot="field-error"]) { @apply text-destructive text-sm font-normal; }
/* ── field-group ─────────────────────────────────────────── */
:where([data-slot="field-group"]) { @apply @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4; }
/* ── field-label ─────────────────────────────────────────── */
:where([data-slot="field-label"]) { @apply flex w-fit gap-2 group-data-[disabled=true]/field:opacity-50 flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50; }
/* ── field-legend ─────────────────────────────────────────── */
:where([data-slot="field-legend"]) { @apply mb-3 font-medium; }
/* ── field-separator ─────────────────────────────────────────── */
:where([data-slot="field-separator"]) { @apply relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2; }
/* ── field-separator-content ─────────────────────────────────────────── */
:where([data-slot="field-separator-content"]) { @apply bg-background text-muted-foreground relative mx-auto block w-fit px-2; }
/* ── field-set ─────────────────────────────────────────── */
:where([data-slot="field-set"]) { @apply flex flex-col gap-6; }
/* ── footer ─────────────────────────────────────────── */
:where([data-slot="footer"]) { @apply border-t border-border bg-background font-sans; }
:where([data-slot="footer"]):where(:not([data-variant])) { @apply py-8; }
:where([data-slot="footer"]):where([data-variant="minimal"]) { @apply py-8; }
:where([data-slot="footer"]):where([data-variant="full"]) { @apply py-12; }
/* ── form-description ─────────────────────────────────────────── */
:where([data-slot="form-description"]) { @apply text-muted-foreground text-sm; }
/* ── form-item ─────────────────────────────────────────── */
:where([data-slot="form-item"]) { @apply grid gap-2; }
/* ── form-label ─────────────────────────────────────────── */
:where([data-slot="form-label"]) { @apply data-[error=true]:text-destructive; }
/* ── form-message ─────────────────────────────────────────── */
:where([data-slot="form-message"]) { @apply text-destructive text-sm; }
/* ── hero ─────────────────────────────────────────── */
:where([data-slot="hero"]) { @apply py-24 px-6; }
:where([data-slot="hero"]):where(:not([data-variant])) { @apply text-center; }
:where([data-slot="hero"]):where([data-variant="centered"]) { @apply text-center; }
:where([data-slot="hero"]):where([data-variant="left-aligned"]) { @apply text-left; }
:where([data-slot="hero"]):where([data-variant="split"]) { @apply text-left; }
:where([data-slot="hero"]):where(:not([data-background])) { @apply bg-hero-bg; }
:where([data-slot="hero"]):where([data-background="default"]) { @apply bg-hero-bg; }
:where([data-slot="hero"]):where([data-background="muted"]) { @apply bg-muted; }
:where([data-slot="hero"]):where([data-background="accent"]) { @apply bg-primary/5; }
:where([data-slot="hero"]):where([data-background="dark"]) { @apply bg-foreground text-background; }
/* ── hover-card-content ─────────────────────────────────────────── */
:where([data-slot="hover-card-content"]) { @apply bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden; }
/* ── input ─────────────────────────────────────────── */
:where([data-slot="input"]) { @apply file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm; }
/* ── input-group ─────────────────────────────────────────── */
:where([data-slot="input-group"]) { @apply border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none; }
/* ── input-group-addon ─────────────────────────────────────────── */
:where([data-slot="input-group-addon"]) { @apply text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50; }
:where([data-slot="input-group-addon"]):where(:not([data-align])) { @apply order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]; }
:where([data-slot="input-group-addon"]):where([data-align="inline-start"]) { @apply order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]; }
:where([data-slot="input-group-addon"]):where([data-align="inline-end"]) { @apply order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]; }
:where([data-slot="input-group-addon"]):where([data-align="block-start"]) { @apply order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5; }
:where([data-slot="input-group-addon"]):where([data-align="block-end"]) { @apply order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5; }
/* ── input-group-button ─────────────────────────────────────────── */
:where([data-slot="input-group-button"]) { @apply text-sm shadow-none flex gap-2 items-center; }
:where([data-slot="input-group-button"]):where(:not([data-size])) { @apply h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2; }
:where([data-slot="input-group-button"]):where([data-size="xs"]) { @apply h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2; }
:where([data-slot="input-group-button"]):where([data-size="sm"]) { @apply h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5; }
:where([data-slot="input-group-button"]):where([data-size="icon-xs"]) { @apply size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0; }
:where([data-slot="input-group-button"]):where([data-size="icon-sm"]) { @apply size-8 p-0 has-[>svg]:p-0; }
/* ── input-group-control ─────────────────────────────────────────── */
:where([data-slot="input-group-control"]) { @apply flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent; }
/* ── input-otp ─────────────────────────────────────────── */
:where([data-slot="input-otp"]) { @apply disabled:cursor-not-allowed; }
/* ── input-otp-group ─────────────────────────────────────────── */
:where([data-slot="input-otp-group"]) { @apply flex items-center; }
/* ── input-otp-slot ─────────────────────────────────────────── */
:where([data-slot="input-otp-slot"]) { @apply data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]; }
/* ── item ─────────────────────────────────────────── */
:where([data-slot="item"]) { @apply flex items-center border border-transparent text-sm rounded-md transition-colors [a&]:hover:bg-accent/50 [a&]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]; }
:where([data-slot="item"]):where(:not([data-variant])) { @apply bg-transparent; }
:where([data-slot="item"]):where([data-variant="default"]) { @apply bg-transparent; }
:where([data-slot="item"]):where([data-variant="outline"]) { @apply border-border; }
:where([data-slot="item"]):where([data-variant="muted"]) { @apply bg-muted/50; }
:where([data-slot="item"]):where(:not([data-size])) { @apply p-4 gap-4; }
:where([data-slot="item"]):where([data-size="default"]) { @apply p-4 gap-4; }
:where([data-slot="item"]):where([data-size="sm"]) { @apply py-3 px-4 gap-2.5; }
/* ── item-actions ─────────────────────────────────────────── */
:where([data-slot="item-actions"]) { @apply flex items-center gap-2; }
/* ── item-content ─────────────────────────────────────────── */
:where([data-slot="item-content"]) { @apply flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none; }
/* ── item-description ─────────────────────────────────────────── */
:where([data-slot="item-description"]) { @apply text-muted-foreground line-clamp-2 text-sm font-normal text-balance; }
/* ── item-footer ─────────────────────────────────────────── */
:where([data-slot="item-footer"]) { @apply flex basis-full items-center justify-between gap-2; }
/* ── item-group ─────────────────────────────────────────── */
:where([data-slot="item-group"]) { @apply flex flex-col; }
/* ── item-header ─────────────────────────────────────────── */
:where([data-slot="item-header"]) { @apply flex basis-full items-center justify-between gap-2; }
/* ── item-media ─────────────────────────────────────────── */
:where([data-slot="item-media"]) { @apply flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5; }
:where([data-slot="item-media"]):where(:not([data-variant])) { @apply bg-transparent; }
:where([data-slot="item-media"]):where([data-variant="default"]) { @apply bg-transparent; }
:where([data-slot="item-media"]):where([data-variant="icon"]) { @apply size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4; }
:where([data-slot="item-media"]):where([data-variant="image"]) { @apply size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover; }
/* ── item-separator ─────────────────────────────────────────── */
:where([data-slot="item-separator"]) { @apply my-0; }
/* ── item-title ─────────────────────────────────────────── */
:where([data-slot="item-title"]) { @apply flex w-fit items-center gap-2 text-sm font-medium; }
/* ── kbd ─────────────────────────────────────────── */
:where([data-slot="kbd"]) { @apply bg-muted w-fit text-muted-foreground pointer-events-none inline-flex h-5 min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none; }
/* ── kbd-group ─────────────────────────────────────────── */
:where([data-slot="kbd-group"]) { @apply inline-flex items-center gap-1; }
/* ── label ─────────────────────────────────────────── */
:where([data-slot="label"]) { @apply flex items-center gap-2 text-sm font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50; }
/* ── logo ─────────────────────────────────────────── */
:where([data-slot="logo"]) { @apply inline-block; }
:where([data-slot="logo"]):where([data-size="sm"]) { @apply h-6 w-auto; }
:where([data-slot="logo"]):where(:not([data-size])) { @apply h-8 w-auto; }
:where([data-slot="logo"]):where([data-size="md"]) { @apply h-8 w-auto; }
:where([data-slot="logo"]):where([data-size="lg"]) { @apply h-10 w-auto; }
:where([data-slot="logo"]):where([data-size="xl"]) { @apply h-14 w-auto; }
/* ── menubar ─────────────────────────────────────────── */
:where([data-slot="menubar"]) { @apply bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs; }
/* ── menubar-checkbox-item ─────────────────────────────────────────── */
:where([data-slot="menubar-checkbox-item"]) { @apply focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
/* ── menubar-content ─────────────────────────────────────────── */
:where([data-slot="menubar-content"]) { @apply bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md; }
/* ── menubar-item ─────────────────────────────────────────── */
:where([data-slot="menubar-item"]) { @apply focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
/* ── menubar-label ─────────────────────────────────────────── */
:where([data-slot="menubar-label"]) { @apply px-2 py-1.5 text-sm font-medium data-[inset]:pl-8; }
/* ── menubar-radio-item ─────────────────────────────────────────── */
:where([data-slot="menubar-radio-item"]) { @apply focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
/* ── menubar-separator ─────────────────────────────────────────── */
:where([data-slot="menubar-separator"]) { @apply bg-border -mx-1 my-1 h-px; }
/* ── menubar-shortcut ─────────────────────────────────────────── */
:where([data-slot="menubar-shortcut"]) { @apply text-muted-foreground ml-auto text-xs tracking-widest; }
/* ── menubar-sub-content ─────────────────────────────────────────── */
:where([data-slot="menubar-sub-content"]) { @apply bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg; }
/* ── menubar-sub-trigger ─────────────────────────────────────────── */
:where([data-slot="menubar-sub-trigger"]) { @apply focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8; }
/* ── menubar-trigger ─────────────────────────────────────────── */
:where([data-slot="menubar-trigger"]) { @apply focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none; }
/* ── navbar ─────────────────────────────────────────── */
:where([data-slot="navbar"]) { @apply fixed top-0 left-0 right-0 z-50 h-[65px] font-sans; }
:where([data-slot="navbar"]):where(:not([data-variant])) { @apply bg-card dark:bg-background border-b border-border; }
:where([data-slot="navbar"]):where([data-variant="solid"]) { @apply bg-card dark:bg-background border-b border-border; }
:where([data-slot="navbar"]):where([data-variant="transparent"]) { @apply bg-transparent; }
:where([data-slot="navbar"]):where([data-variant="minimal"]) { @apply bg-card/80 dark:bg-background/80 backdrop-blur-sm border-b border-border/50; }
/* ── navbar-actions ─────────────────────────────────────────── */
:where([data-slot="navbar-actions"]) { @apply hidden md:flex items-center gap-2; }
/* ── navbar-link ─────────────────────────────────────────── */
:where([data-slot="navbar-link"]) { @apply px-3 py-2 text-foreground transition-opacity hover:opacity-70; }
/* ── navbar-logo ─────────────────────────────────────────── */
:where([data-slot="navbar-logo"]) { @apply shrink-0; }
/* ── navbar-mobile ─────────────────────────────────────────── */
:where([data-slot="navbar-mobile"]) { @apply md:hidden border-b border-border bg-card dark:bg-background; }
/* ── navbar-nav ─────────────────────────────────────────── */
:where([data-slot="navbar-nav"]) { @apply hidden md:flex items-center gap-1 text-sm font-semibold; }
/* ── navigation-menu ─────────────────────────────────────────── */
:where([data-slot="navigation-menu"]) { @apply relative flex max-w-max flex-1 items-center justify-center; }
/* ── navigation-menu-content ─────────────────────────────────────────── */
:where([data-slot="navigation-menu-content"]) { @apply data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto; }
/* ── navigation-menu-indicator ─────────────────────────────────────────── */
:where([data-slot="navigation-menu-indicator"]) { @apply data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden; }
/* ── navigation-menu-item ─────────────────────────────────────────── */
:where([data-slot="navigation-menu-item"]) { @apply relative; }
/* ── navigation-menu-link ─────────────────────────────────────────── */
:where([data-slot="navigation-menu-link"]) { @apply data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4; }
/* ── navigation-menu-list ─────────────────────────────────────────── */
:where([data-slot="navigation-menu-list"]) { @apply flex flex-1 list-none items-center justify-center gap-1; }
/* ── navigation-menu-trigger ─────────────────────────────────────────── */
/* ── navigation-menu-viewport ─────────────────────────────────────────── */
:where([data-slot="navigation-menu-viewport"]) { @apply bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]; }
/* ── page-layout ─────────────────────────────────────────── */
:where([data-slot="page-layout"]) { @apply min-h-screen flex flex-col bg-background text-foreground; }
/* ── page-layout-main ─────────────────────────────────────────── */
:where([data-slot="page-layout-main"]) { @apply flex-1 min-w-0; }
/* ── page-layout-sidebar ─────────────────────────────────────────── */
:where([data-slot="page-layout-sidebar"]) { @apply hidden lg:block w-64 border-r border-border bg-background flex-shrink-0; }
/* ── pagination ─────────────────────────────────────────── */
:where([data-slot="pagination"]) { @apply mx-auto flex w-full justify-center; }
/* ── pagination-content ─────────────────────────────────────────── */
:where([data-slot="pagination-content"]) { @apply flex flex-row items-center gap-1; }
/* ── pagination-ellipsis ─────────────────────────────────────────── */
:where([data-slot="pagination-ellipsis"]) { @apply flex size-9 items-center justify-center; }
/* ── popover-content ─────────────────────────────────────────── */
:where([data-slot="popover-content"]) { @apply bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden; }
/* ── progress ─────────────────────────────────────────── */
:where([data-slot="progress"]) { @apply bg-primary/20 relative h-2 w-full overflow-hidden rounded-full; }
/* ── progress-indicator ─────────────────────────────────────────── */
:where([data-slot="progress-indicator"]) { @apply bg-primary h-full w-full flex-1 transition-all; }
/* ── radio-group ─────────────────────────────────────────── */
:where([data-slot="radio-group"]) { @apply grid gap-3; }
/* ── radio-group-indicator ─────────────────────────────────────────── */
:where([data-slot="radio-group-indicator"]) { @apply relative flex items-center justify-center; }
/* ── radio-group-item ─────────────────────────────────────────── */
:where([data-slot="radio-group-item"]) { @apply border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50; }
/* ── resizable-handle ─────────────────────────────────────────── */
:where([data-slot="resizable-handle"]) { @apply bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90; }
/* ── resizable-panel-group ─────────────────────────────────────────── */
:where([data-slot="resizable-panel-group"]) { @apply flex h-full w-full data-[panel-group-direction=vertical]:flex-col; }
/* ── scroll-area ─────────────────────────────────────────── */
:where([data-slot="scroll-area"]) { @apply relative; }
/* ── scroll-area-scrollbar ─────────────────────────────────────────── */
:where([data-slot="scroll-area-scrollbar"]) { @apply flex touch-none p-px transition-colors select-none; }
/* ── scroll-area-thumb ─────────────────────────────────────────── */
:where([data-slot="scroll-area-thumb"]) { @apply bg-border relative flex-1 rounded-full; }
/* ── scroll-area-viewport ─────────────────────────────────────────── */
:where([data-slot="scroll-area-viewport"]) { @apply focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1; }
/* ── section ─────────────────────────────────────────── */
:where([data-slot="section"]) { @apply py-10; }
:where([data-slot="section"]):where([data-variant="highlighted"]) { @apply bg-card my-8; }
:where([data-slot="section"]):where([data-variant="accent"]) { @apply bg-primary/5 my-8; }
:where([data-slot="section"]):where([data-width="narrow"]) { @apply max-w-3xl mx-auto; }
:where([data-slot="section"]):where(:not([data-width])) { @apply max-w-5xl mx-auto; }
:where([data-slot="section"]):where([data-width="default"]) { @apply max-w-5xl mx-auto; }
:where([data-slot="section"]):where([data-width="wide"]) { @apply max-w-7xl mx-auto; }
:where([data-slot="section"]):where([data-width="full"]) { @apply w-full; }
/* ── select-content ─────────────────────────────────────────── */
:where([data-slot="select-content"]) { @apply bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md; }
/* ── select-item ─────────────────────────────────────────── */
:where([data-slot="select-item"]) { @apply focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2; }
/* ── select-label ─────────────────────────────────────────── */
:where([data-slot="select-label"]) { @apply text-muted-foreground px-2 py-1.5 text-xs; }
/* ── select-scroll-down-button ─────────────────────────────────────────── */
:where([data-slot="select-scroll-down-button"]) { @apply flex cursor-default items-center justify-center py-1; }
/* ── select-scroll-up-button ─────────────────────────────────────────── */
:where([data-slot="select-scroll-up-button"]) { @apply flex cursor-default items-center justify-center py-1; }
/* ── select-separator ─────────────────────────────────────────── */
:where([data-slot="select-separator"]) { @apply bg-border pointer-events-none -mx-1 my-1 h-px; }
/* ── select-trigger ─────────────────────────────────────────── */
:where([data-slot="select-trigger"]) { @apply border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
/* ── separator ─────────────────────────────────────────── */
:where([data-slot="separator"]) { @apply bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px; }
/* ── sheet-content ─────────────────────────────────────────── */
:where([data-slot="sheet-content"]) { @apply bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500; }
/* ── sheet-description ─────────────────────────────────────────── */
:where([data-slot="sheet-description"]) { @apply text-muted-foreground text-sm; }
/* ── sheet-footer ─────────────────────────────────────────── */
:where([data-slot="sheet-footer"]) { @apply mt-auto flex flex-col gap-2 p-4; }
/* ── sheet-header ─────────────────────────────────────────── */
:where([data-slot="sheet-header"]) { @apply flex flex-col gap-1.5 p-4; }
/* ── sheet-overlay ─────────────────────────────────────────── */
:where([data-slot="sheet-overlay"]) { @apply data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50; }
/* ── sheet-title ─────────────────────────────────────────── */
:where([data-slot="sheet-title"]) { @apply text-foreground font-semibold; }
/* ── sidebar ─────────────────────────────────────────── */
:where([data-slot="sidebar"]) { @apply bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden text-sidebar-foreground hidden md:block; }
/* ── sidebar-container ─────────────────────────────────────────── */
:where([data-slot="sidebar-container"]) { @apply fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex; }
/* ── sidebar-content ─────────────────────────────────────────── */
:where([data-slot="sidebar-content"]) { @apply flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden; }
/* ── sidebar-footer ─────────────────────────────────────────── */
:where([data-slot="sidebar-footer"]) { @apply flex flex-col gap-2 p-2; }
/* ── sidebar-gap ─────────────────────────────────────────── */
:where([data-slot="sidebar-gap"]) { @apply relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear; }
/* ── sidebar-group ─────────────────────────────────────────── */
:where([data-slot="sidebar-group"]) { @apply relative flex w-full min-w-0 flex-col p-2; }
/* ── sidebar-group-action ─────────────────────────────────────────── */
:where([data-slot="sidebar-group-action"]) { @apply text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0; }
/* ── sidebar-group-content ─────────────────────────────────────────── */
:where([data-slot="sidebar-group-content"]) { @apply w-full text-sm; }
/* ── sidebar-group-label ─────────────────────────────────────────── */
:where([data-slot="sidebar-group-label"]) { @apply text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0; }
/* ── sidebar-header ─────────────────────────────────────────── */
:where([data-slot="sidebar-header"]) { @apply flex flex-col gap-2 p-2; }
/* ── sidebar-inner ─────────────────────────────────────────── */
:where([data-slot="sidebar-inner"]) { @apply bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm; }
/* ── sidebar-input ─────────────────────────────────────────── */
:where([data-slot="sidebar-input"]) { @apply bg-background h-8 w-full shadow-none; }
/* ── sidebar-inset ─────────────────────────────────────────── */
:where([data-slot="sidebar-inset"]) { @apply bg-background relative flex w-full flex-1 flex-col; }
/* ── sidebar-menu ─────────────────────────────────────────── */
:where([data-slot="sidebar-menu"]) { @apply flex w-full min-w-0 flex-col gap-1; }
/* ── sidebar-menu-action ─────────────────────────────────────────── */
:where([data-slot="sidebar-menu-action"]) { @apply text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0; }
/* ── sidebar-menu-badge ─────────────────────────────────────────── */
:where([data-slot="sidebar-menu-badge"]) { @apply text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none; }
/* ── sidebar-menu-button ─────────────────────────────────────────── */
:where([data-slot="sidebar-menu-button"]) { @apply flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0; }
:where([data-slot="sidebar-menu-button"]):where(:not([data-variant])) { @apply hover:bg-sidebar-accent hover:text-sidebar-accent-foreground; }
:where([data-slot="sidebar-menu-button"]):where([data-variant="default"]) { @apply hover:bg-sidebar-accent hover:text-sidebar-accent-foreground; }
:where([data-slot="sidebar-menu-button"]):where([data-variant="outline"]) { @apply bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]; }
:where([data-slot="sidebar-menu-button"]):where(:not([data-size])) { @apply h-8 text-sm; }
:where([data-slot="sidebar-menu-button"]):where([data-size="default"]) { @apply h-8 text-sm; }
:where([data-slot="sidebar-menu-button"]):where([data-size="sm"]) { @apply h-7 text-xs; }
:where([data-slot="sidebar-menu-button"]):where([data-size="lg"]) { @apply h-12 text-sm group-data-[collapsible=icon]:p-0!; }
/* ── sidebar-menu-item ─────────────────────────────────────────── */
:where([data-slot="sidebar-menu-item"]) { @apply relative; }
/* ── sidebar-menu-skeleton ─────────────────────────────────────────── */
:where([data-slot="sidebar-menu-skeleton"]) { @apply flex h-8 items-center gap-2 rounded-md px-2; }
/* ── sidebar-menu-sub ─────────────────────────────────────────── */
:where([data-slot="sidebar-menu-sub"]) { @apply border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5; }
/* ── sidebar-menu-sub-button ─────────────────────────────────────────── */
:where([data-slot="sidebar-menu-sub-button"]) { @apply text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0; }
/* ── sidebar-menu-sub-item ─────────────────────────────────────────── */
:where([data-slot="sidebar-menu-sub-item"]) { @apply relative; }
/* ── sidebar-rail ─────────────────────────────────────────── */
:where([data-slot="sidebar-rail"]) { @apply hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex; }
/* ── sidebar-separator ─────────────────────────────────────────── */
:where([data-slot="sidebar-separator"]) { @apply bg-sidebar-border mx-2 w-auto; }
/* ── sidebar-trigger ─────────────────────────────────────────── */
:where([data-slot="sidebar-trigger"]) { @apply size-7; }
/* ── sidebar-wrapper ─────────────────────────────────────────── */
:where([data-slot="sidebar-wrapper"]) { @apply has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full; }
/* ── skeleton ─────────────────────────────────────────── */
:where([data-slot="skeleton"]) { @apply bg-accent animate-pulse rounded-md; }
/* ── slider ─────────────────────────────────────────── */
:where([data-slot="slider"]) { @apply relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col; }
/* ── slider-range ─────────────────────────────────────────── */
:where([data-slot="slider-range"]) { @apply bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full; }
/* ── slider-thumb ─────────────────────────────────────────── */
:where([data-slot="slider-thumb"]) { @apply border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50; }
/* ── slider-track ─────────────────────────────────────────── */
:where([data-slot="slider-track"]) { @apply bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5; }
/* ── switch ─────────────────────────────────────────── */
:where([data-slot="switch"]) { @apply data-[state=checked]:bg-primary data-[state=unchecked]:bg-[#7F7F79] focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50; }
/* ── switch-thumb ─────────────────────────────────────────── */
:where([data-slot="switch-thumb"]) { @apply bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0; }
/* ── table ─────────────────────────────────────────── */
:where([data-slot="table"]) { @apply w-full caption-bottom text-sm; }
/* ── table-body ─────────────────────────────────────────── */
:where([data-slot="table-body"]) { @apply [&_tr:last-child]:border-0; }
/* ── table-caption ─────────────────────────────────────────── */
:where([data-slot="table-caption"]) { @apply text-muted-foreground mt-4 text-sm; }
/* ── table-cell ─────────────────────────────────────────── */
:where([data-slot="table-cell"]) { @apply p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]; }
/* ── table-container ─────────────────────────────────────────── */
:where([data-slot="table-container"]) { @apply relative w-full overflow-x-auto; }
/* ── table-footer ─────────────────────────────────────────── */
:where([data-slot="table-footer"]) { @apply bg-muted/50 border-t font-medium [&>tr]:last:border-b-0; }
/* ── table-head ─────────────────────────────────────────── */
:where([data-slot="table-head"]) { @apply text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]; }
/* ── table-header ─────────────────────────────────────────── */
:where([data-slot="table-header"]) { @apply [&_tr]:border-b; }
/* ── table-row ─────────────────────────────────────────── */
:where([data-slot="table-row"]) { @apply hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors; }
/* ── tabs ─────────────────────────────────────────── */
:where([data-slot="tabs"]) { @apply flex flex-col gap-2; }
/* ── tabs-content ─────────────────────────────────────────── */
:where([data-slot="tabs-content"]) { @apply flex-1 outline-none; }
/* ── tabs-list ─────────────────────────────────────────── */
:where([data-slot="tabs-list"]) { @apply bg-[rgb(var(--border))] text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]; }
/* ── tabs-trigger ─────────────────────────────────────────── */
:where([data-slot="tabs-trigger"]) { @apply data-[state=active]:bg-card dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
/* ── textarea ─────────────────────────────────────────── */
:where([data-slot="textarea"]) { @apply border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm; }
/* ── toast ─────────────────────────────────────────── */
:where([data-slot="toast"]) { @apply pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full; }
:where([data-slot="toast"]):where(:not([data-variant])) { @apply border bg-background text-foreground; }
:where([data-slot="toast"]):where([data-variant="default"]) { @apply border bg-background text-foreground; }
:where([data-slot="toast"]):where([data-variant="destructive"]) { @apply border-destructive bg-destructive text-destructive-foreground; }
/* ── toggle ─────────────────────────────────────────── */
:where([data-slot="toggle"]) { @apply inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap; }
:where([data-slot="toggle"]):where(:not([data-variant])) { @apply bg-transparent; }
:where([data-slot="toggle"]):where([data-variant="default"]) { @apply bg-transparent; }
:where([data-slot="toggle"]):where([data-variant="outline"]) { @apply border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground; }
:where([data-slot="toggle"]):where(:not([data-size])) { @apply h-9 px-4 min-w-9 has-[>svg]:px-3; }
:where([data-slot="toggle"]):where([data-size="default"]) { @apply h-9 px-4 min-w-9 has-[>svg]:px-3; }
:where([data-slot="toggle"]):where([data-size="sm"]) { @apply h-8 px-3 min-w-8 has-[>svg]:px-2.5; }
:where([data-slot="toggle"]):where([data-size="lg"]) { @apply h-10 px-6 min-w-10 has-[>svg]:px-4; }
/* ── toggle-group ─────────────────────────────────────────── */
:where([data-slot="toggle-group"]) { @apply flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs; }
/* ── toggle-group-item ─────────────────────────────────────────── */
:where([data-slot="toggle-group-item"]) { @apply inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l; }
:where([data-slot="toggle-group-item"]):where(:not([data-variant])) { @apply bg-transparent; }
:where([data-slot="toggle-group-item"]):where([data-variant="default"]) { @apply bg-transparent; }
:where([data-slot="toggle-group-item"]):where([data-variant="outline"]) { @apply border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground; }
:where([data-slot="toggle-group-item"]):where(:not([data-size])) { @apply h-9 px-4 min-w-9 has-[>svg]:px-3; }
:where([data-slot="toggle-group-item"]):where([data-size="default"]) { @apply h-9 px-4 min-w-9 has-[>svg]:px-3; }
:where([data-slot="toggle-group-item"]):where([data-size="sm"]) { @apply h-8 px-3 min-w-8 has-[>svg]:px-2.5; }
:where([data-slot="toggle-group-item"]):where([data-size="lg"]) { @apply h-10 px-6 min-w-10 has-[>svg]:px-4; }
/* ── tooltip-content ─────────────────────────────────────────── */
:where([data-slot="tooltip-content"]) { @apply bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance; }
}
/* Extraction warnings:
* [components/ui/field.tsx] cva: fieldVariants.orientation.vertical: non-literal classes, skipping value
* [components/ui/field.tsx] cva: fieldVariants.orientation.horizontal: non-literal classes, skipping value
* [components/ui/field.tsx] cva: fieldVariants.orientation.responsive: non-literal classes, skipping value
* [components/ui/navigation-menu.tsx] cva: navigationMenuTriggerStyle: no config object, skipping
*/

View File

@@ -1,35 +0,0 @@
# HTMX Showcase — Validation Harness
Parallel to `app/page.tsx` (the React showcase), `public/htmx.html` is a plain HTML page that exercises the generated `dist/greyhaven.htmx.css` across every static component. Load it while running `pnpm dev` at `/htmx.html`.
## Purpose
Validate that `greyhaven.htmx.css` produces visually-equivalent output to the React components. The HTMX page only uses:
- `data-slot` / `data-variant` / `data-size` attributes
- Standard HTML tags (`<button>`, `<span>`, `<input>`, `<div>`)
- Inline SVGs for icons (no lucide-react)
No React, no JavaScript (apart from the theme toggle).
## Build
```bash
pnpm htmx-css:build # Regenerate dist/greyhaven.htmx.css from components/ui/*.tsx
pnpm htmx-demo:build # Compile htmx-demo/input.css + tokens + htmx.css → public/htmx.css
pnpm dev # Serves /htmx.html at http://localhost:3000/htmx.html
```
## What's covered
- Typography (H1/H2/H3 + body + UI label)
- Button — variants (6), sizes (3), states (5), icon sizes (3)
- Badge — core (4), tag/value (2), semantic (4), channel pills (5), on-muted-surface (6)
- Input + Textarea (default / with value / disabled) + Label
- Card (simple + with header/action/content/footer)
- Alert (default + destructive)
- Separator, Progress, Skeleton, Kbd
## What's intentionally out of scope
- Interactive components (Dialog, Dropdown, Popover, Select, Combobox, Accordion, Tabs, Tooltip) — their CSS rules exist in `greyhaven.htmx.css` but require Alpine.js or HTMX swap patterns for open/close state. Validate those in a separate runtime-integration test.
- Form Control primitives with JS state (Checkbox, Switch, RadioGroup, Slider) — Radix renders these with bespoke markup the CSS targets via `data-state=checked`. Native `<input type="checkbox">` won't match without additional bridging.

View File

@@ -1,69 +0,0 @@
#!/bin/bash
# Batch section-by-section comparison: React vs HTMX.
# Each entry: <label> <react-selector> <htmx-selector>
#
# Assumes `pnpm dev` is running and Charlotte MCP is unavailable from shell —
# so this script expects screenshots already captured via Charlotte by name.
# Run compare.py on each pair and emit a summary.
set -u
OUT="${OUT:-/home/tito/code/monadical/greyproxy/docs/screenshots}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CMP="$SCRIPT_DIR/compare.py"
SECTIONS=(
"colors"
"typo"
"btn-variants"
"btn-sizes"
"btn-states"
"icon-buttons"
"btn-with-icons"
"badges-core"
"badges-tag"
"badges-semantic"
"badges-channel"
"badges-muted"
"inputs"
"select"
"checkboxes-switches"
"tabs"
"toggle-group"
"tooltips"
"sample-form"
"settings-card"
"header"
"footer"
)
printf "%-25s %-12s %-12s %s\n" "section" "similarity" "differing" "notes"
printf "%-25s %-12s %-12s %s\n" "-------" "----------" "---------" "-----"
fail=0
for s in "${SECTIONS[@]}"; do
r="$OUT/$s-react.webp"
h="$OUT/$s-htmx.webp"
d="$OUT/$s-diff.webp"
if [ ! -f "$r" ] || [ ! -f "$h" ]; then
printf "%-25s %-12s %-12s %s\n" "$s" "-" "-" "missing ($([ ! -f "$r" ] && echo react) $([ ! -f "$h" ] && echo htmx))"
continue
fi
line=$(python3 "$CMP" "$r" "$h" --out "$d" 2>&1 | tail -1)
# " similarity = 99.97% (393 / 1436512 pixels differ > 12)"
sim=$(echo "$line" | sed -nE 's/.*similarity = ([0-9.]+)%.*/\1/p')
diff=$(echo "$line" | sed -nE 's/.*\(([0-9]+) \/ .*/\1/p')
# Threshold: 99.0%. Residual diffs under this threshold are driven by:
# - font sub-pixel anti-aliasing (~0.03%)
# - sticky-header overlay differences in Charlotte's selector screenshot
# when element rects happen to land at different viewport Y positions
# between React and HTMX (still has the same CSS, just different scroll).
if awk "BEGIN{exit !($sim>=99.0)}"; then
marker=PASS
else
marker=FAIL
fail=1
fi
printf "%-25s %-12s %-12s %s\n" "$s" "${sim}%" "$diff" "$marker"
done
exit $fail

View File

@@ -1,100 +0,0 @@
#!/usr/bin/env python3
"""
Image comparator for React vs HTMX showcase validation.
Compares two PNG/WEBP screenshots and produces:
1. Similarity percentage (pixels within tolerance / total pixels)
2. A diff image with mismatches highlighted magenta on a faded background
Tolerance is per-channel: anti-aliasing / sub-pixel hinting is accepted
(default 12 of 255 per channel, tweakable via --tol). Font / layout / color
changes produce large regions of divergence that will exceed the tolerance.
Usage:
python3 compare.py react.webp htmx.webp [--out diff.webp] [--tol 12]
"""
import argparse
import sys
from pathlib import Path
from PIL import Image, ImageChops, ImageDraw
def load(path):
img = Image.open(path).convert("RGB")
return img
def compare(react_path, htmx_path, out_path, tol):
a = load(react_path)
b = load(htmx_path)
if a.size != b.size:
# Pad the smaller one with transparent/white so we can still diff
w, h = max(a.width, b.width), max(a.height, b.height)
pad_a = Image.new("RGB", (w, h), (255, 255, 255))
pad_b = Image.new("RGB", (w, h), (255, 255, 255))
pad_a.paste(a, (0, 0))
pad_b.paste(b, (0, 0))
a, b = pad_a, pad_b
size_mismatch = True
else:
size_mismatch = False
diff = ImageChops.difference(a, b)
# Per-pixel max channel diff
total = a.width * a.height
differing = 0
mask = Image.new("L", a.size, 0)
mask_pixels = mask.load()
diff_pixels = diff.load()
for y in range(a.height):
for x in range(a.width):
r, g, bl = diff_pixels[x, y]
if max(r, g, bl) > tol:
differing += 1
mask_pixels[x, y] = 255
similarity = 100.0 * (total - differing) / total
# Build diff image: React screenshot faded 50%, with diffs in magenta
faded = Image.eval(a, lambda v: int(v * 0.4 + 0.6 * 255))
magenta = Image.new("RGB", a.size, (255, 0, 180))
out = Image.composite(magenta, faded, mask)
# Add a header text
draw = ImageDraw.Draw(out)
header = (
f"similarity={similarity:.2f}% "
f"differing={differing}/{total} "
f"tol={tol}"
+ (" (SIZE MISMATCH — padded)" if size_mismatch else "")
)
draw.rectangle([0, 0, a.width, 24], fill=(0, 0, 0))
draw.text((8, 4), header, fill=(255, 255, 255))
out.save(out_path)
return similarity, differing, total
def main():
p = argparse.ArgumentParser()
p.add_argument("react")
p.add_argument("htmx")
p.add_argument("--out", default="diff.webp")
p.add_argument("--tol", type=int, default=12)
args = p.parse_args()
sim, diff_px, total = compare(args.react, args.htmx, args.out, args.tol)
print(f"react = {args.react}")
print(f"htmx = {args.htmx}")
print(f"diff -> {args.out}")
print(f" similarity = {sim:.2f}% ({diff_px} / {total} pixels differ > {args.tol})")
if sim < 99.5:
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()

View File

@@ -1,88 +0,0 @@
/* Greyhaven HTMX Showcase — Tailwind v4 source
*
* Pairs the generated `dist/greyhaven.htmx.css` with the design system tokens
* so a plain HTML page (no React) can render every component via data-slot
* attribute selectors.
*
* Compiled output: public/htmx.css (served at /htmx.css)
*/
@import "tailwindcss";
@import "tw-animate-css";
@import "../app/tokens/tokens-light.css";
@import "../app/tokens/tokens-dark.css";
@import "../dist/greyhaven.htmx.css";
@source "./*.html";
@source "../public/htmx.html";
@custom-variant dark (&:is(.dark *));
/* Self-hosted Aspekta (served from /fonts/) */
@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'); }
:root {
--radius: 0.375rem;
}
@theme inline {
--font-sans: 'Aspekta', ui-sans-serif, system-ui, sans-serif;
/* Matches React's `var(--font-source-serif, 'Source Serif 4'), 'Source Serif Pro', Georgia, serif`.
* Next.js injects --font-source-serif via next/font/google. We load Source Serif 4 from
* Google Fonts directly in htmx.html <link>, so naming it here is enough. */
--font-serif: 'Source Serif 4', 'Source Serif Pro', Georgia, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--color-background: rgb(var(--background));
--color-foreground: rgb(var(--foreground));
--color-card: rgb(var(--card));
--color-card-foreground: rgb(var(--card-foreground));
--color-popover: rgb(var(--popover));
--color-popover-foreground: rgb(var(--popover-foreground));
--color-primary: rgb(var(--primary));
--color-primary-foreground: rgb(var(--primary-foreground));
--color-secondary: rgb(var(--secondary));
--color-secondary-foreground: rgb(var(--secondary-foreground));
--color-muted: rgb(var(--muted));
--color-muted-foreground: rgb(var(--muted-foreground));
--color-accent: rgb(var(--accent));
--color-accent-foreground: rgb(var(--accent-foreground));
--color-destructive: rgb(var(--destructive));
--color-destructive-foreground: rgb(var(--destructive-foreground));
--color-border: rgb(var(--border));
--color-input: rgb(var(--input));
--color-ring: rgb(var(--ring));
--color-chart-1: rgb(var(--chart-1));
--color-chart-2: rgb(var(--chart-2));
--color-chart-3: rgb(var(--chart-3));
--color-chart-4: rgb(var(--chart-4));
--color-chart-5: rgb(var(--chart-5));
--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));
--color-sidebar-primary-foreground: rgb(var(--sidebar-primary-foreground));
--color-sidebar-accent: rgb(var(--sidebar-accent));
--color-sidebar-accent-foreground: rgb(var(--sidebar-accent-foreground));
--color-sidebar-border: rgb(var(--sidebar-border));
--color-sidebar-ring: rgb(var(--sidebar-ring));
--radius-sm: calc(var(--radius) - 2px);
--radius-md: var(--radius);
--radius-lg: calc(var(--radius) + 2px);
--radius-xl: calc(var(--radius) + 4px);
}
@layer base {
* {
border-color: rgb(var(--border));
}
body {
background-color: rgb(var(--background));
color: rgb(var(--foreground));
font-family: var(--font-sans);
}
}

View File

@@ -1,439 +0,0 @@
/**
* 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: 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: '<Badge variant="success">Active</Badge>\n<Badge variant="secondary" size="sm">3 items</Badge>\n<Badge variant="default" size="lg">New feature</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>',
},
{
name: 'Code',
file: 'components/ui/code.tsx',
category: 'primitives',
exports: ['Code', 'codeVariants'],
description: 'Inline or block code snippet. Always use this instead of hand-rolling <code>/<pre> styling. Uses bg-muted + border-border so the outline stays visible in both light and dark modes. Block variant auto-wraps in <pre> for whitespace preservation and break-all for long commands.',
props: 'variant?: "inline" | "block"; language?: string (optional, for future syntax highlighting)',
example: '<p>Install with <Code>pnpm install</Code>.</p>\n\n<Code variant="block" language="bash">{`pnpm install\npnpm dev`}</Code>',
},
// ── 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-[65px]. Off-white bg (light) / off-black (dark). Font-semibold menu items. Hover: opacity-70 (no bg). Active links: orange (text-primary), full opacity. Variants: solid, transparent, minimal. Logo left, nav center, actions right. Mobile hamburger.',
props: 'variant?: "solid" | "transparent" | "minimal"; logo?: ReactNode; actions?: ReactNode. NavbarLink: active?: boolean',
example: '<Navbar variant="solid" logo={<Logo size="sm" />}><NavbarLink href="/" active>Home</NavbarLink><NavbarLink href="/about">About</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-10 internal padding. Colored variants (highlighted, accent) get my-8 vertical margin so they visually detach from adjacent sections; default has no margin so same-bg siblings flow seamlessly.',
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>',
},
]

View File

@@ -1,454 +0,0 @@
#!/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,
),
}],
}
},
)
// ---------------------------------------------------------------------------
// Brand tools & resources
// ---------------------------------------------------------------------------
const BRAND_SKILL_PATH = path.join(ROOT, 'skill', 'BRAND.md')
function readBrandSkill(): string {
try {
return fs.readFileSync(BRAND_SKILL_PATH, 'utf-8')
} catch {
return '(skill/BRAND.md not found — hand-curated brand skill is missing)'
}
}
server.tool(
'get_brand_rules',
'Returns the Greyhaven brand voice, tone, and messaging rules. Use this BEFORE generating any user-facing marketing copy, CTAs, landing page content, or product explanations. Covers positioning, brand axes, tone, writing rules, reasoning patterns, CTA guidance, logo usage, and a self-check list.',
{
section: z
.enum([
'all',
'positioning',
'axes',
'tone',
'writing-rules',
'reasoning-patterns',
'cta',
'logo',
'self-check',
])
.optional()
.describe('Optional section filter. Default returns the full brand skill.'),
},
async ({ section }) => {
const full = readBrandSkill()
if (!section || section === 'all') {
return {
content: [{ type: 'text' as const, text: full }],
}
}
// Section anchors in BRAND.md (markdown H2 headings)
const anchors: Record<string, RegExp> = {
positioning: /## 2\. Core Positioning[\s\S]*?(?=\n## |\n---)/,
axes: /## 3\. The Three Brand Axes[\s\S]*?(?=\n## |\n---)/,
tone: /## 4\. Tone of Voice[\s\S]*?(?=\n## |\n---)/,
'writing-rules': /## 5\. Writing Rules[\s\S]*?(?=\n## |\n---)/,
'reasoning-patterns': /## 6\. Patterns for Reasoning[\s\S]*?(?=\n## |\n---)/,
cta: /## 7\. CTA Guidance[\s\S]*?(?=\n## |\n---)/,
logo: /## 10\. Logo Usage[\s\S]*?(?=\n## |\n---)/,
'self-check': /## 11\. Self-check[\s\S]*?(?=\n## |\n---)/,
}
const re = anchors[section]
const match = re ? full.match(re) : null
if (!match) {
return {
content: [{
type: 'text' as const,
text: `Section "${section}" not found. Returning full brand skill instead.\n\n${full}`,
}],
}
}
return {
content: [{ type: 'text' as const, text: match[0] }],
}
},
)
server.tool(
'validate_copy',
'Checks a piece of user-facing copy against Greyhaven brand rules. Flags hype words, sales language, vague superlatives, and other brand violations. Use on marketing copy, CTAs, headlines, product descriptions before shipping.',
{ text: z.string().describe('The copy to validate') },
async ({ text }) => {
const lower = text.toLowerCase()
const bannedWords = [
'unleash', 'transform', 'revolutionary', 'revolutionize',
'seamless', 'seamlessly', 'game-changing', 'cutting-edge',
'next-gen', 'next-generation', 'leverage', 'synergy', 'unlock',
'supercharge', 'empower', 'empowered', 'unprecedented',
'best-in-class', 'industry-leading', 'world-class',
'lightning-fast', 'blazing fast',
]
const vagueSuperlatives = [
'amazing', 'incredible', 'awesome', 'stunning', 'beautiful',
'powerful', 'robust', 'cutting edge', 'state-of-the-art',
]
const urgencyPhrases = [
'limited time', 'act now', "don't miss out", 'hurry',
'last chance', 'today only',
]
const findings: string[] = []
for (const w of bannedWords) {
if (lower.includes(w)) {
findings.push(`⚠ Banned hype/sales word: "${w}"`)
}
}
for (const w of vagueSuperlatives) {
if (lower.includes(w)) {
findings.push(`⚠ Vague superlative: "${w}" — replace with specifics`)
}
}
for (const p of urgencyPhrases) {
if (lower.includes(p)) {
findings.push(`⚠ Urgency framing: "${p}" — Greyhaven does not use urgency`)
}
}
// Exclamation marks
const exclamations = (text.match(/!/g) || []).length
if (exclamations > 0) {
findings.push(`⚠ Found ${exclamations} exclamation mark(s) — Greyhaven copy does not use them`)
}
if (findings.length === 0) {
return {
content: [{
type: 'text' as const,
text: 'No obvious brand violations found. Still run the self-check list from get_brand_rules({section: "self-check"}) before shipping.',
}],
}
}
return {
content: [{
type: 'text' as const,
text: `Found ${findings.length} potential brand violation(s):\n\n${findings.join('\n')}\n\nFor detailed guidance, call get_brand_rules() or get_brand_rules({section: "tone"}).`,
}],
}
},
)
// ---------------------------------------------------------------------------
// Resources
// ---------------------------------------------------------------------------
server.resource('brand://guidelines', 'brand://guidelines', async (uri) => {
return {
contents: [{
uri: uri.href,
mimeType: 'text/markdown',
text: readBrandSkill(),
}],
}
})
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)

View File

@@ -1,14 +0,0 @@
{
"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,9 +6,6 @@ const nextConfig = {
images: {
unoptimized: true,
},
turbopack: {
root: '.',
},
}
export default nextConfig

11135
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,12 @@
{
"name": "my-v0-project",
"name": "greyhaven-design-system",
"version": "0.1.0",
"private": true,
"scripts": {
"tokens:build": "npx style-dictionary build --config style-dictionary.config.mjs",
"skill:build": "npx tsx scripts/generate-skill.ts",
"htmx-css:build": "npx tsx scripts/generate-htmx-css.ts",
"htmx-demo:build": "tailwindcss -i htmx-demo/input.css -o public/htmx.css --minify",
"build": "pnpm tokens:build && pnpm skill:build && pnpm htmx-css:build && pnpm htmx-demo:build && next build",
"build": "next build",
"dev": "next dev",
"lint": "eslint .",
"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"
"start": "next start"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
@@ -69,29 +61,13 @@
"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/cli": "^4.2.4",
"@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",
"vite": "^8.0.8",
"vitest": "^4.1.4"
"typescript": "^5"
}
}
}

7957
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

BIN
public/brand/cell-tower.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 999 KiB

BIN
public/brand/phone-coil.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,161 +0,0 @@
/*! Aspekta | OFL v1.1 License | Ivo Dolenc (c) 2025 | https://github.com/ivodolenc/aspekta */
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 50;
font-display: swap;
src: url('Aspekta-50.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 100;
font-display: swap;
src: url('Aspekta-100.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 150;
font-display: swap;
src: url('Aspekta-150.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 200;
font-display: swap;
src: url('Aspekta-200.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 250;
font-display: swap;
src: url('Aspekta-250.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('Aspekta-300.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 350;
font-display: swap;
src: url('Aspekta-350.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('Aspekta-400.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 450;
font-display: swap;
src: url('Aspekta-450.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('Aspekta-500.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 550;
font-display: swap;
src: url('Aspekta-550.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('Aspekta-600.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 650;
font-display: swap;
src: url('Aspekta-650.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('Aspekta-700.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 750;
font-display: swap;
src: url('Aspekta-750.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 800;
font-display: swap;
src: url('Aspekta-800.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 850;
font-display: swap;
src: url('Aspekta-850.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 900;
font-display: swap;
src: url('Aspekta-900.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 950;
font-display: swap;
src: url('Aspekta-950.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 1000;
font-display: swap;
src: url('Aspekta-1000.woff2') format('woff2');
}

View File

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="b" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 660.98 230.26"><g id="c"><path d="M239.51,143.89l-.85-8.54c-2.94,4.93-9.87,9.97-20.6,9.97-17.56,0-33.13-12.91-33.13-35.12s16.42-35.02,33.88-35.02c16.33,0,26.77,9.4,30.37,20.5l-10.73,4.08c-2.28-7.59-8.92-14.05-19.65-14.05s-22.3,7.78-22.3,24.49,10.63,24.68,22.21,24.68c13.57,0,18.7-9.3,19.27-15.09h-22.21v-9.97h32.94v34.07h-9.21Z" fill="#171715"/><path d="M286.39,108.58c-1.23-.19-2.47-.28-3.61-.28-8.54,0-12.43,4.93-12.43,13.57v22.02h-11.01v-46.22h10.73v7.4c2.18-5.03,7.31-7.97,13.38-7.97,1.33,0,2.47.19,2.94.28v11.2Z" fill="#171715"/><path d="M333.94,130.7c-2.47,8.07-9.78,14.62-20.88,14.62-12.53,0-23.63-9.11-23.63-24.77,0-14.62,10.82-24.3,22.49-24.3,14.24,0,22.59,9.4,22.59,24.01,0,1.8-.19,3.32-.29,3.51h-33.79c.28,7.02,5.79,12.05,12.62,12.05s10.06-3.51,11.58-8.07l9.3,2.94ZM323.41,115.7c-.19-5.41-3.8-10.25-11.39-10.25-6.93,0-10.91,5.31-11.29,10.25h22.68Z" fill="#171715"/><path d="M343.57,162.59l11.11-24.2-19.74-40.72h12.43l13.29,29.23,12.43-29.23h11.67l-29.42,64.92h-11.77Z" fill="#171715"/><path d="M400.95,143.89h-11.01v-68.72h11.01v26.96c3.13-4.08,8.35-5.79,13.19-5.79,11.39,0,16.89,8.16,16.89,18.32v29.23h-11.01v-27.34c0-5.7-2.56-10.25-9.49-10.25-6.07,0-9.4,4.55-9.59,10.44v27.15Z" fill="#171715"/><path d="M452.54,117.41l11.86-1.8c2.66-.38,3.42-1.71,3.42-3.32,0-3.89-2.66-7.02-8.73-7.02s-9.02,3.7-9.49,8.35l-10.06-2.28c.85-7.97,8.07-15.09,19.46-15.09,14.24,0,19.65,8.07,19.65,17.27v22.97c0,4.18.48,6.93.57,7.4h-10.25c-.09-.29-.47-2.18-.47-5.89-2.18,3.51-6.74,7.31-14.24,7.31-9.68,0-15.66-6.64-15.66-13.95,0-8.26,6.07-12.81,13.95-13.95ZM467.82,124.72v-2.09l-12.05,1.8c-3.42.57-6.17,2.47-6.17,6.27,0,3.13,2.37,5.98,6.74,5.98,6.17,0,11.48-2.94,11.48-11.96Z" fill="#171715"/><path d="M509.42,143.89h-11.01l-18.79-46.22h12.15l12.24,33.03,11.96-33.03h11.58l-18.13,46.22Z" fill="#171715"/><path d="M572.91,130.7c-2.47,8.07-9.78,14.62-20.88,14.62-12.53,0-23.63-9.11-23.63-24.77,0-14.62,10.82-24.3,22.49-24.3,14.24,0,22.59,9.4,22.59,24.01,0,1.8-.19,3.32-.29,3.51h-33.79c.28,7.02,5.79,12.05,12.62,12.05s10.06-3.51,11.58-8.07l9.3,2.94ZM562.38,115.7c-.19-5.41-3.8-10.25-11.39-10.25-6.93,0-10.91,5.31-11.29,10.25h22.68Z" fill="#171715"/><path d="M592.54,143.89h-11.01v-46.22h10.73v6.17c3.04-5.31,8.54-7.5,13.67-7.5,11.29,0,16.7,8.16,16.7,18.32v29.23h-11.01v-27.34c0-5.7-2.56-10.25-9.49-10.25-6.26,0-9.59,4.84-9.59,10.91v26.67Z" fill="#171715"/><path d="M149.44,75.45l-18.67-10.96h-1.01l-16.68,9.79v-20.19s-18.67-10.96-18.67-10.96h-1.01l-18.67,10.96v20.19s-16.68-9.79-16.68-9.79h-1.01l-18.67,10.96v.72s0,78.5,0,78.5l18.67,11.12h1.02l16.67-9.92v20.16s18.67,11.12,18.67,11.12h1.02l18.67-11.12v-20.16s16.67,9.92,16.67,9.92h1.02l18.67-11.12v-79.21ZM57.54,66.51l16.7,9.81-16.7,9.94-16.7-9.94,16.7-9.81ZM40.36,78.36l16.18,9.63v54.28l-16.18,9.5v-73.41ZM56.54,144.59v18.57l-15.7-9.35,15.7-9.22ZM58.54,163.16v-18.57l15.7,9.22-15.7,9.35ZM74.72,151.77l-16.18-9.5v-54.28l16.18-9.63v73.41ZM111.08,76.17v19.46l-16.18-9.5v-19.49l16.18-9.63v19.16ZM93.9,107.61l-16.7-9.94,16.7-9.81,16.7,9.81-16.7,9.94ZM94.9,64.31v-18.57l15.7,9.22-15.7,9.35ZM92.9,45.74v18.57l-15.7-9.35,15.7-9.22ZM76.72,57l16.18,9.63v19.49l-16.18,9.5v-38.63ZM76.72,99.71l16.18,9.63v54.28l-16.18,9.5v-73.41ZM92.9,165.94v18.57l-15.7-9.35,15.7-9.22ZM94.9,184.51v-18.57l15.7,9.22-15.7,9.35ZM111.08,173.12l-16.18-9.5v-54.28l16.18-9.63v73.41ZM131.26,67.09l15.7,9.22-15.7,9.35v-18.57ZM129.26,67.09v18.57l-15.7-9.35,15.7-9.22ZM113.08,78.36l16.18,9.63v54.28l-16.18,9.5v-73.41ZM130.26,163.75l-16.7-9.95,16.7-9.81,16.7,9.81-16.7,9.95ZM147.44,151.77l-16.18-9.5v-54.28l16.18-9.63v73.41Z" fill="#171715"/><rect x="0" width="660.98" height="230.26" fill="none"/></g></svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="b" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 660.98 230.26"><g id="c"><path d="M239.51,143.89l-.85-8.54c-2.94,4.93-9.87,9.97-20.6,9.97-17.56,0-33.13-12.91-33.13-35.12s16.42-35.02,33.88-35.02c16.33,0,26.77,9.4,30.37,20.5l-10.73,4.08c-2.28-7.59-8.92-14.05-19.65-14.05s-22.3,7.78-22.3,24.49,10.63,24.68,22.21,24.68c13.57,0,18.7-9.3,19.27-15.09h-22.21v-9.97h32.94v34.07h-9.21Z"/><path d="M286.39,108.58c-1.23-.19-2.47-.28-3.61-.28-8.54,0-12.43,4.93-12.43,13.57v22.02h-11.01v-46.22h10.73v7.4c2.18-5.03,7.31-7.97,13.38-7.97,1.33,0,2.47.19,2.94.28v11.2Z"/><path d="M333.94,130.7c-2.47,8.07-9.78,14.62-20.88,14.62-12.53,0-23.63-9.11-23.63-24.77,0-14.62,10.82-24.3,22.49-24.3,14.24,0,22.59,9.4,22.59,24.01,0,1.8-.19,3.32-.29,3.51h-33.79c.28,7.02,5.79,12.05,12.62,12.05s10.06-3.51,11.58-8.07l9.3,2.94ZM323.41,115.7c-.19-5.41-3.8-10.25-11.39-10.25-6.93,0-10.91,5.31-11.29,10.25h22.68Z"/><path d="M343.57,162.59l11.11-24.2-19.74-40.72h12.43l13.29,29.23,12.43-29.23h11.67l-29.42,64.92h-11.77Z"/><path d="M400.95,143.89h-11.01v-68.72h11.01v26.96c3.13-4.08,8.35-5.79,13.19-5.79,11.39,0,16.89,8.16,16.89,18.32v29.23h-11.01v-27.34c0-5.7-2.56-10.25-9.49-10.25-6.07,0-9.4,4.55-9.59,10.44v27.15Z"/><path d="M452.54,117.41l11.86-1.8c2.66-.38,3.42-1.71,3.42-3.32,0-3.89-2.66-7.02-8.73-7.02s-9.02,3.7-9.49,8.35l-10.06-2.28c.85-7.97,8.07-15.09,19.46-15.09,14.24,0,19.65,8.07,19.65,17.27v22.97c0,4.18.48,6.93.57,7.4h-10.25c-.09-.29-.47-2.18-.47-5.89-2.18,3.51-6.74,7.31-14.24,7.31-9.68,0-15.66-6.64-15.66-13.95,0-8.26,6.07-12.81,13.95-13.95ZM467.82,124.72v-2.09l-12.05,1.8c-3.42.57-6.17,2.47-6.17,6.27,0,3.13,2.37,5.98,6.74,5.98,6.17,0,11.48-2.94,11.48-11.96Z"/><path d="M509.42,143.89h-11.01l-18.79-46.22h12.15l12.24,33.03,11.96-33.03h11.58l-18.13,46.22Z"/><path d="M572.91,130.7c-2.47,8.07-9.78,14.62-20.88,14.62-12.53,0-23.63-9.11-23.63-24.77,0-14.62,10.82-24.3,22.49-24.3,14.24,0,22.59,9.4,22.59,24.01,0,1.8-.19,3.32-.29,3.51h-33.79c.28,7.02,5.79,12.05,12.62,12.05s10.06-3.51,11.58-8.07l9.3,2.94ZM562.38,115.7c-.19-5.41-3.8-10.25-11.39-10.25-6.93,0-10.91,5.31-11.29,10.25h22.68Z"/><path d="M592.54,143.89h-11.01v-46.22h10.73v6.17c3.04-5.31,8.54-7.5,13.67-7.5,11.29,0,16.7,8.16,16.7,18.32v29.23h-11.01v-27.34c0-5.7-2.56-10.25-9.49-10.25-6.26,0-9.59,4.84-9.59,10.91v26.67Z"/><path d="M149.44,75.45l-18.67-10.96h-1.01l-16.68,9.79v-20.19s-18.67-10.96-18.67-10.96h-1.01l-18.67,10.96v20.19s-16.68-9.79-16.68-9.79h-1.01l-18.67,10.96v.72s0,78.5,0,78.5l18.67,11.12h1.02l16.67-9.92v20.16s18.67,11.12,18.67,11.12h1.02l18.67-11.12v-20.16s16.67,9.92,16.67,9.92h1.02l18.67-11.12v-79.21ZM57.54,66.51l16.7,9.81-16.7,9.94-16.7-9.94,16.7-9.81ZM40.36,78.36l16.18,9.63v54.28l-16.18,9.5v-73.41ZM56.54,144.59v18.57l-15.7-9.35,15.7-9.22ZM58.54,163.16v-18.57l15.7,9.22-15.7,9.35ZM74.72,151.77l-16.18-9.5v-54.28l16.18-9.63v73.41ZM111.08,76.17v19.46l-16.18-9.5v-19.49l16.18-9.63v19.16ZM93.9,107.61l-16.7-9.94,16.7-9.81,16.7,9.81-16.7,9.94ZM94.9,64.31v-18.57l15.7,9.22-15.7,9.35ZM92.9,45.74v18.57l-15.7-9.35,15.7-9.22ZM76.72,57l16.18,9.63v19.49l-16.18,9.5v-38.63ZM76.72,99.71l16.18,9.63v54.28l-16.18,9.5v-73.41ZM92.9,165.94v18.57l-15.7-9.35,15.7-9.22ZM94.9,184.51v-18.57l15.7,9.22-15.7,9.35ZM111.08,173.12l-16.18-9.5v-54.28l16.18-9.63v73.41ZM131.26,67.09l15.7,9.22-15.7,9.35v-18.57ZM129.26,67.09v18.57l-15.7-9.35,15.7-9.22ZM113.08,78.36l16.18,9.63v54.28l-16.18,9.5v-73.41ZM130.26,163.75l-16.7-9.95,16.7-9.81,16.7,9.81-16.7,9.95ZM147.44,151.77l-16.18-9.5v-54.28l16.18-9.63v73.41Z"/><rect width="660.98" height="230.26" fill="none"/></g></svg>

Before

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="b" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 660.98 230.26"><g id="c"><path d="M239.51,143.89l-.85-8.54c-2.94,4.93-9.87,9.97-20.6,9.97-17.56,0-33.13-12.91-33.13-35.12s16.42-35.02,33.88-35.02c16.33,0,26.77,9.4,30.37,20.5l-10.73,4.08c-2.28-7.59-8.92-14.05-19.65-14.05s-22.3,7.78-22.3,24.49,10.63,24.68,22.21,24.68c13.57,0,18.7-9.3,19.27-15.09h-22.21v-9.97h32.94v34.07h-9.21Z" fill="#fff"/><path d="M286.39,108.58c-1.23-.19-2.47-.28-3.61-.28-8.54,0-12.43,4.93-12.43,13.57v22.02h-11.01v-46.22h10.73v7.4c2.18-5.03,7.31-7.97,13.38-7.97,1.33,0,2.47.19,2.94.28v11.2Z" fill="#fff"/><path d="M333.94,130.7c-2.47,8.07-9.78,14.62-20.88,14.62-12.53,0-23.63-9.11-23.63-24.77,0-14.62,10.82-24.3,22.49-24.3,14.24,0,22.59,9.4,22.59,24.01,0,1.8-.19,3.32-.29,3.51h-33.79c.28,7.02,5.79,12.05,12.62,12.05s10.06-3.51,11.58-8.07l9.3,2.94ZM323.41,115.7c-.19-5.41-3.8-10.25-11.39-10.25-6.93,0-10.91,5.31-11.29,10.25h22.68Z" fill="#fff"/><path d="M343.57,162.59l11.11-24.2-19.74-40.72h12.43l13.29,29.23,12.43-29.23h11.67l-29.42,64.92h-11.77Z" fill="#fff"/><path d="M400.95,143.89h-11.01v-68.72h11.01v26.96c3.13-4.08,8.35-5.79,13.19-5.79,11.39,0,16.89,8.16,16.89,18.32v29.23h-11.01v-27.34c0-5.7-2.56-10.25-9.49-10.25-6.07,0-9.4,4.55-9.59,10.44v27.15Z" fill="#fff"/><path d="M452.54,117.41l11.86-1.8c2.66-.38,3.42-1.71,3.42-3.32,0-3.89-2.66-7.02-8.73-7.02s-9.02,3.7-9.49,8.35l-10.06-2.28c.85-7.97,8.07-15.09,19.46-15.09,14.24,0,19.65,8.07,19.65,17.27v22.97c0,4.18.48,6.93.57,7.4h-10.25c-.09-.29-.47-2.18-.47-5.89-2.18,3.51-6.74,7.31-14.24,7.31-9.68,0-15.66-6.64-15.66-13.95,0-8.26,6.07-12.81,13.95-13.95ZM467.82,124.72v-2.09l-12.05,1.8c-3.42.57-6.17,2.47-6.17,6.27,0,3.13,2.37,5.98,6.74,5.98,6.17,0,11.48-2.94,11.48-11.96Z" fill="#fff"/><path d="M509.42,143.89h-11.01l-18.79-46.22h12.15l12.24,33.03,11.96-33.03h11.58l-18.13,46.22Z" fill="#fff"/><path d="M572.91,130.7c-2.47,8.07-9.78,14.62-20.88,14.62-12.53,0-23.63-9.11-23.63-24.77,0-14.62,10.82-24.3,22.49-24.3,14.24,0,22.59,9.4,22.59,24.01,0,1.8-.19,3.32-.29,3.51h-33.79c.28,7.02,5.79,12.05,12.62,12.05s10.06-3.51,11.58-8.07l9.3,2.94ZM562.38,115.7c-.19-5.41-3.8-10.25-11.39-10.25-6.93,0-10.91,5.31-11.29,10.25h22.68Z" fill="#fff"/><path d="M592.54,143.89h-11.01v-46.22h10.73v6.17c3.04-5.31,8.54-7.5,13.67-7.5,11.29,0,16.7,8.16,16.7,18.32v29.23h-11.01v-27.34c0-5.7-2.56-10.25-9.49-10.25-6.26,0-9.59,4.84-9.59,10.91v26.67Z" fill="#fff"/><path d="M149.44,75.45l-18.67-10.96h-1.01l-16.68,9.79v-20.19s-18.67-10.96-18.67-10.96h-1.01l-18.67,10.96v20.19s-16.68-9.79-16.68-9.79h-1.01l-18.67,10.96v.72s0,78.5,0,78.5l18.67,11.12h1.02l16.67-9.92v20.16s18.67,11.12,18.67,11.12h1.02l18.67-11.12v-20.16s16.67,9.92,16.67,9.92h1.02l18.67-11.12v-79.21ZM57.54,66.51l16.7,9.81-16.7,9.94-16.7-9.94,16.7-9.81ZM40.36,78.36l16.18,9.63v54.28l-16.18,9.5v-73.41ZM56.54,144.59v18.57l-15.7-9.35,15.7-9.22ZM58.54,163.16v-18.57l15.7,9.22-15.7,9.35ZM74.72,151.77l-16.18-9.5v-54.28l16.18-9.63v73.41ZM111.08,76.17v19.46l-16.18-9.5v-19.49l16.18-9.63v19.16ZM93.9,107.61l-16.7-9.94,16.7-9.81,16.7,9.81-16.7,9.94ZM94.9,64.31v-18.57l15.7,9.22-15.7,9.35ZM92.9,45.74v18.57l-15.7-9.35,15.7-9.22ZM76.72,57l16.18,9.63v19.49l-16.18,9.5v-38.63ZM76.72,99.71l16.18,9.63v54.28l-16.18,9.5v-73.41ZM92.9,165.94v18.57l-15.7-9.35,15.7-9.22ZM94.9,184.51v-18.57l15.7,9.22-15.7,9.35ZM111.08,173.12l-16.18-9.5v-54.28l16.18-9.63v73.41ZM131.26,67.09l15.7,9.22-15.7,9.35v-18.57ZM129.26,67.09v18.57l-15.7-9.35,15.7-9.22ZM113.08,78.36l16.18,9.63v54.28l-16.18,9.5v-73.41ZM130.26,163.75l-16.7-9.95,16.7-9.81,16.7,9.81-16.7,9.95ZM147.44,151.77l-16.18-9.5v-54.28l16.18-9.63v73.41Z" fill="#fff"/><rect width="660.98" height="230.26" fill="none"/></g></svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="b" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 151.08 184"><g id="c"><path d="M131.08,52.32l-18.67-10.96h-1.01l-16.68,9.79v-20.19s-18.67-10.96-18.67-10.96h-1.01l-18.67,10.96v20.19s-16.68-9.79-16.68-9.79h-1.01l-18.67,10.96v.72s0,78.5,0,78.5l18.67,11.12h1.02l16.67-9.92v20.16s18.67,11.12,18.67,11.12h1.02l18.67-11.12v-20.16s16.67,9.92,16.67,9.92h1.02l18.67-11.12V52.32ZM39.18,43.38l16.7,9.81-16.7,9.94-16.7-9.94,16.7-9.81ZM22,55.23l16.18,9.63v54.28l-16.18,9.5V55.23ZM38.18,121.46v18.57l-15.7-9.35,15.7-9.22ZM40.18,140.03v-18.57l15.7,9.22-15.7,9.35ZM56.36,128.64l-16.18-9.5v-54.28l16.18-9.63v73.41ZM92.72,53.04v19.46l-16.18-9.5v-19.49l16.18-9.63v19.16ZM75.54,84.48l-16.7-9.94,16.7-9.81,16.7,9.81-16.7,9.94ZM76.54,41.18v-18.57l15.7,9.22-15.7,9.35ZM74.54,22.61v18.57l-15.7-9.35,15.7-9.22ZM58.36,33.87l16.18,9.63v19.49l-16.18,9.5v-38.63ZM58.36,76.58l16.18,9.63v54.28l-16.18,9.5v-73.41ZM74.54,142.81v18.57l-15.7-9.35,15.7-9.22ZM76.54,161.38v-18.57l15.7,9.22-15.7,9.35ZM92.72,149.99l-16.18-9.5v-54.28l16.18-9.63v73.41ZM112.9,43.97l15.7,9.22-15.7,9.35v-18.57ZM110.9,43.97v18.57l-15.7-9.35,15.7-9.22ZM94.72,55.23l16.18,9.63v54.28l-16.18,9.5V55.23ZM111.9,140.63l-16.7-9.95,16.7-9.81,16.7,9.81-16.7,9.95ZM129.08,128.64l-16.18-9.5v-54.28l16.18-9.63v73.41Z"/><rect width="151.08" height="184" fill="none"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="b" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 151.08 184"><g id="c"><path d="M131.08,52.32l-18.67-10.96h-1.01l-16.68,9.79v-20.19s-18.67-10.96-18.67-10.96h-1.01l-18.67,10.96v20.19s-16.68-9.79-16.68-9.79h-1.01l-18.67,10.96v.72s0,78.5,0,78.5l18.67,11.12h1.02l16.67-9.92v20.16s18.67,11.12,18.67,11.12h1.02l18.67-11.12v-20.16s16.67,9.92,16.67,9.92h1.02l18.67-11.12V52.32ZM39.18,43.38l16.7,9.81-16.7,9.94-16.7-9.94,16.7-9.81ZM22,55.23l16.18,9.63v54.28l-16.18,9.5V55.23ZM38.18,121.46v18.57l-15.7-9.35,15.7-9.22ZM40.18,140.03v-18.57l15.7,9.22-15.7,9.35ZM56.36,128.64l-16.18-9.5v-54.28l16.18-9.63v73.41ZM92.72,53.04v19.46l-16.18-9.5v-19.49l16.18-9.63v19.16ZM75.54,84.48l-16.7-9.94,16.7-9.81,16.7,9.81-16.7,9.94ZM76.54,41.18v-18.57l15.7,9.22-15.7,9.35ZM74.54,22.61v18.57l-15.7-9.35,15.7-9.22ZM58.36,33.87l16.18,9.63v19.49l-16.18,9.5v-38.63ZM58.36,76.58l16.18,9.63v54.28l-16.18,9.5v-73.41ZM74.54,142.81v18.57l-15.7-9.35,15.7-9.22ZM76.54,161.38v-18.57l15.7,9.22-15.7,9.35ZM92.72,149.99l-16.18-9.5v-54.28l16.18-9.63v73.41ZM112.9,43.97l15.7,9.22-15.7,9.35v-18.57ZM110.9,43.97v18.57l-15.7-9.35,15.7-9.22ZM94.72,55.23l16.18,9.63v54.28l-16.18,9.5V55.23ZM111.9,140.63l-16.7-9.95,16.7-9.81,16.7,9.81-16.7,9.95ZM129.08,128.64l-16.18-9.5v-54.28l16.18-9.63v73.41Z" fill="#fff"/><rect width="151.08" height="184" fill="none"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="b" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 660.98 230.26"><g id="c"><path d="M153.83,75.45l-18.67-10.96h-1.01l-16.68,9.79v-20.19s-18.67-10.96-18.67-10.96h-1.01l-18.67,10.96v20.19s-16.68-9.79-16.68-9.79h-1.01l-18.67,10.96v.72s0,78.5,0,78.5l18.67,11.12h1.02l16.67-9.92v20.16s18.67,11.12,18.67,11.12h1.02l18.67-11.12v-20.16s16.67,9.92,16.67,9.92h1.02l18.67-11.12v-79.21ZM61.93,66.51l16.7,9.81-16.7,9.94-16.7-9.94,16.7-9.81ZM44.75,78.36l16.18,9.63v54.28l-16.18,9.5v-73.41ZM60.93,144.59v18.57l-15.7-9.35,15.7-9.22ZM62.93,163.16v-18.57l15.7,9.22-15.7,9.35ZM79.11,151.77l-16.18-9.5v-54.28l16.18-9.63v73.41ZM115.47,76.17v19.46l-16.18-9.5v-19.49l16.18-9.63v19.16ZM98.29,107.61l-16.7-9.94,16.7-9.81,16.7,9.81-16.7,9.94ZM99.29,64.31v-18.57l15.7,9.22-15.7,9.35ZM97.29,45.74v18.57l-15.7-9.35,15.7-9.22ZM81.11,57l16.18,9.63v19.49l-16.18,9.5v-38.63ZM81.11,99.71l16.18,9.63v54.28l-16.18,9.5v-73.41ZM97.29,165.94v18.57l-15.7-9.35,15.7-9.22ZM99.29,184.51v-18.57l15.7,9.22-15.7,9.35ZM115.47,173.12l-16.18-9.5v-54.28l16.18-9.63v73.41ZM135.66,67.09l15.7,9.22-15.7,9.35v-18.57ZM133.66,67.09v18.57l-15.7-9.35,15.7-9.22ZM117.47,78.36l16.18,9.63v54.28l-16.18,9.5v-73.41ZM134.66,163.75l-16.7-9.95,16.7-9.81,16.7,9.81-16.7,9.95ZM151.84,151.77l-16.18-9.5v-54.28l16.18-9.63v73.41Z"/><path d="M245.09,143.61l-.85-8.52c-2.93,4.92-9.84,9.93-20.53,9.93-17.5,0-33.02-12.87-33.02-35.01s16.37-34.92,33.78-34.92c16.27,0,26.68,9.37,30.28,20.44l-10.69,4.07c-2.27-7.57-8.89-14-19.59-14s-22.24,7.76-22.24,24.41,10.6,24.6,22.14,24.6c13.53,0,18.64-9.27,19.21-15.04h-22.14v-9.94h32.83v33.97h-9.18Z"/><path d="M291.54,108.41c-1.23-.19-2.46-.28-3.6-.28-8.52,0-12.39,4.92-12.39,13.53v21.95h-10.98v-46.08h10.69v7.38c2.18-5.01,7.29-7.95,13.34-7.95,1.33,0,2.46.19,2.93.28v11.17Z"/><path d="M338.36,130.46c-2.46,8.04-9.75,14.57-20.82,14.57-12.49,0-23.56-9.08-23.56-24.7,0-14.57,10.79-24.22,22.42-24.22,14.19,0,22.52,9.37,22.52,23.94,0,1.8-.19,3.31-.28,3.5h-33.68c.28,7,5.77,12.02,12.58,12.02s10.03-3.5,11.54-8.04l9.27,2.93ZM327.86,115.51c-.19-5.39-3.79-10.22-11.35-10.22-6.91,0-10.88,5.3-11.26,10.22h22.61Z"/><path d="M347.66,162.25l11.07-24.13-19.68-40.59h12.39l13.25,29.14,12.4-29.14h11.64l-29.33,64.72h-11.73Z"/><path d="M393.56,161.59v-64.06h10.6v6.25c2.27-3.97,7.57-7.29,14.67-7.29,13.62,0,21.29,10.41,21.29,24.03s-8.42,24.22-21.67,24.22c-6.62,0-11.64-2.84-14-6.34v23.18h-10.88ZM416.74,106.23c-7.19,0-12.39,5.68-12.39,14.29s5.2,14.48,12.39,14.48,12.4-5.68,12.4-14.48-5.01-14.29-12.4-14.29Z"/><path d="M472.94,108.41c-1.23-.19-2.46-.28-3.6-.28-8.52,0-12.39,4.92-12.39,13.53v21.95h-10.98v-46.08h10.69v7.38c2.18-5.01,7.29-7.95,13.34-7.95,1.33,0,2.46.19,2.93.28v11.17Z"/><path d="M521.49,120.52c0,14.19-10.12,24.51-23.94,24.51s-23.84-10.31-23.84-24.51,10.12-24.41,23.84-24.41,23.94,10.31,23.94,24.41ZM510.42,120.52c0-9.56-6.06-14.48-12.87-14.48s-12.87,4.92-12.87,14.48,6.15,14.67,12.87,14.67,12.87-5.01,12.87-14.67Z"/><path d="M538.37,120.33l-16.46-22.8h13.06c.85,1.42,9.27,13.44,10.12,14.76l10.03-14.76h12.49l-16.18,22.61,16.75,23.47h-12.87l-10.69-15.42c-.95,1.42-9.56,14-10.41,15.42h-12.58l16.75-23.28Z"/><path d="M577.17,162.25l11.07-24.13-19.68-40.59h12.4l13.25,29.14,12.39-29.14h11.64l-29.33,64.72h-11.73Z"/><rect width="660.98" height="230.26" fill="none"/></g></svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="b" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 630.98 230.26"><g id="c"><path d="M173.11,75.45l-18.67-10.96h-1.01l-16.68,9.79v-20.19s-18.67-10.96-18.67-10.96h-1.01l-18.67,10.96v20.19s-16.68-9.79-16.68-9.79h-1.01l-18.67,10.96v.72s0,78.5,0,78.5l18.67,11.12h1.02l16.67-9.92v20.16s18.67,11.12,18.67,11.12h1.02l18.67-11.12v-20.16s16.67,9.92,16.67,9.92h1.02l18.67-11.12v-79.21ZM81.21,66.51l16.7,9.81-16.7,9.94-16.7-9.94,16.7-9.81ZM64.03,78.36l16.18,9.63v54.28l-16.18,9.5v-73.41ZM80.21,144.59v18.57l-15.7-9.35,15.7-9.22ZM82.21,163.16v-18.57l15.7,9.22-15.7,9.35ZM98.39,151.77l-16.18-9.5v-54.28l16.18-9.63v73.41ZM134.76,76.17v19.46l-16.18-9.5v-19.49l16.18-9.63v19.16ZM117.58,107.61l-16.7-9.94,16.7-9.81,16.7,9.81-16.7,9.94ZM118.58,64.31v-18.57l15.7,9.22-15.7,9.35ZM116.58,45.74v18.57l-15.7-9.35,15.7-9.22ZM100.4,57l16.18,9.63v19.49l-16.18,9.5v-38.63ZM100.4,99.71l16.18,9.63v54.28l-16.18,9.5v-73.41ZM116.58,165.94v18.57l-15.7-9.35,15.7-9.22ZM118.58,184.51v-18.57l15.7,9.22-15.7,9.35ZM134.76,173.12l-16.18-9.5v-54.28l16.18-9.63v73.41ZM154.94,67.09l15.7,9.22-15.7,9.35v-18.57ZM152.94,67.09v18.57l-15.7-9.35,15.7-9.22ZM136.76,78.36l16.18,9.63v54.28l-16.18,9.5v-73.41ZM153.94,163.75l-16.7-9.95,16.7-9.81,16.7,9.81-16.7,9.95ZM171.12,151.77l-16.18-9.5v-54.28l16.18-9.63v73.41Z"/><path d="M264.37,143.61l-.85-8.52c-2.93,4.92-9.84,9.93-20.53,9.93-17.51,0-33.02-12.87-33.02-35.01s16.37-34.92,33.78-34.92c16.28,0,26.68,9.37,30.28,20.44l-10.69,4.07c-2.27-7.57-8.89-14-19.59-14s-22.24,7.76-22.24,24.41,10.6,24.6,22.14,24.6c13.53,0,18.64-9.27,19.21-15.04h-22.14v-9.94h32.83v33.97h-9.18Z" fill="#231f20"/><path d="M310.82,108.41c-1.23-.19-2.46-.28-3.6-.28-8.52,0-12.4,4.92-12.4,13.53v21.95h-10.98v-46.08h10.69v7.38c2.18-5.01,7.29-7.95,13.34-7.95,1.32,0,2.46.19,2.93.28v11.17Z" fill="#231f20"/><path d="M357.92,130.46c-2.46,8.04-9.75,14.57-20.82,14.57-12.49,0-23.56-9.08-23.56-24.7,0-14.57,10.79-24.22,22.43-24.22,14.19,0,22.52,9.37,22.52,23.94,0,1.8-.19,3.31-.28,3.5h-33.68c.28,7,5.77,12.02,12.58,12.02s10.03-3.5,11.54-8.04l9.27,2.93ZM347.42,115.51c-.19-5.39-3.79-10.22-11.36-10.22-6.91,0-10.88,5.3-11.26,10.22h22.62Z" fill="#231f20"/><path d="M367.23,162.25l11.07-24.13-19.68-40.59h12.4l13.25,29.14,12.39-29.14h11.64l-29.33,64.72h-11.73Z" fill="#231f20"/><path d="M454.23,97.53l10.88,31.89,9.18-31.89h11.17l-14.76,46.08h-10.98l-11.45-33.12-11.17,33.12h-11.26l-14.95-46.08h11.73l9.37,31.89,10.88-31.89h11.35Z" fill="#231f20"/><path d="M501.01,117.21l11.83-1.8c2.65-.38,3.41-1.7,3.41-3.31,0-3.88-2.65-7-8.7-7s-8.99,3.69-9.46,8.33l-10.03-2.27c.85-7.95,8.04-15.04,19.4-15.04,14.19,0,19.59,8.04,19.59,17.22v22.9c0,4.16.47,6.91.57,7.38h-10.22c-.1-.28-.47-2.18-.47-5.87-2.18,3.5-6.72,7.29-14.19,7.29-9.65,0-15.61-6.62-15.61-13.91,0-8.23,6.06-12.77,13.91-13.91ZM516.24,124.5v-2.08l-12.02,1.8c-3.41.57-6.15,2.46-6.15,6.25,0,3.12,2.37,5.96,6.72,5.96,6.15,0,11.45-2.93,11.45-11.92Z" fill="#231f20"/><path d="M536.91,143.61v-68.5h10.98v68.5h-10.98Z" fill="#231f20"/><path d="M557.97,143.61v-68.5h10.98v68.5h-10.98Z" fill="#231f20"/><rect width="630.98" height="230.26" fill="none"/></g></svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 585 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 566 B

After

Width:  |  Height:  |  Size: 986 B

View File

@@ -1,26 +1,9 @@
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg width="54" height="70" viewBox="0 0 54 70" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
@media (prefers-color-scheme: light) {
.background { fill: black; }
.foreground { fill: white; }
}
path { fill: #161614; }
@media (prefers-color-scheme: dark) {
.background { fill: white; }
.foreground { fill: black; }
path { fill: #ffffff; }
}
</style>
<g clip-path="url(#clip0_7960_43945)">
<rect class="background" width="180" height="180" rx="37" />
<g style="transform: scale(95%); transform-origin: center">
<path class="foreground"
d="M101.141 53H136.632C151.023 53 162.689 64.6662 162.689 79.0573V112.904H148.112V79.0573C148.112 78.7105 148.098 78.3662 148.072 78.0251L112.581 112.898C112.701 112.902 112.821 112.904 112.941 112.904H148.112V126.672H112.941C98.5504 126.672 86.5638 114.891 86.5638 100.5V66.7434H101.141V100.5C101.141 101.15 101.191 101.792 101.289 102.422L137.56 66.7816C137.255 66.7563 136.945 66.7434 136.632 66.7434H101.141V53Z" />
<path class="foreground"
d="M65.2926 124.136L14 66.7372H34.6355L64.7495 100.436V66.7372H80.1365V118.47C80.1365 126.278 70.4953 129.958 65.2926 124.136Z" />
</g>
</g>
<defs>
<clipPath id="clip0_7960_43945">
<rect width="180" height="180" fill="white" />
</clipPath>
</defs>
</svg>
<path d="M53.9972 15.7111L44.9215 10.3833H44.4306L36.3222 15.1424V5.32778L27.2465 0H26.7556L17.6799 5.32778V15.1424L9.57153 10.3833H9.08056L0 15.7111V16.0611V54.2208L9.07569 59.6264H9.57153L17.675 54.8042V64.6042L26.7507 70.0097H27.2465L36.3222 64.6042V54.8042L44.4257 59.6264H44.9215L53.9972 54.2208V15.7111ZM9.32361 11.3653L17.4417 16.134L9.32361 20.966L1.20556 16.134L9.32361 11.3653ZM0.972222 17.1257L8.8375 21.8069V48.1931L0.972222 52.8111V17.1257ZM8.8375 49.3208V58.3479L1.20556 53.8028L8.8375 49.3208ZM9.80972 58.3479V49.3208L17.4417 53.8028L9.80972 58.3479ZM17.675 52.8111L9.80972 48.1931V21.8069L17.675 17.1257V52.8111ZM35.35 16.0611V25.5208L27.4847 20.9028V11.4285L35.35 6.74722V16.0611ZM26.9986 31.3444L18.8806 26.5125L26.9986 21.7437L35.1167 26.5125L26.9986 31.3444ZM27.4847 10.2958V1.26875L35.1167 5.75069L27.4847 10.2958ZM26.5125 1.26875V10.2958L18.8806 5.75069L26.5125 1.26875ZM18.6472 6.74236L26.5125 11.4236V20.8979L18.6472 25.516V6.74236ZM18.6472 27.5042L26.5125 32.1854V58.5715L18.6472 63.1896V27.5042ZM26.5125 59.6993V68.7264L18.8806 64.1812L26.5125 59.6993ZM27.4847 68.7264V59.6993L35.1167 64.1812L27.4847 68.7264ZM35.35 63.1896L27.4847 58.5715V32.1854L35.35 27.5042V63.1896ZM45.1597 11.6521L52.7917 16.134L45.1597 20.6792V11.6521ZM44.1875 11.6521V20.6792L36.5556 16.134L44.1875 11.6521ZM36.3222 17.1257L44.1875 21.8069V48.1931L36.3222 52.8111V17.1257ZM44.6736 58.6396L36.5556 53.8028L44.6736 49.034L52.7917 53.8028L44.6736 58.6396ZM53.025 52.8111L45.1597 48.1931V21.8069L53.025 17.1257V52.8111Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

19
public/logo.svg Normal file
View File

@@ -0,0 +1,19 @@
<svg width="285" height="70" viewBox="0 0 285 70" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_0_17)">
<path d="M97.7812 48.9805L97.368 44.8291C95.9388 47.2257 92.5701 49.6757 87.3541 49.6757C78.818 49.6757 71.2493 43.4 71.2493 32.6034C71.2493 21.8069 79.2312 15.5798 87.7187 15.5798C95.6569 15.5798 100.732 20.1493 102.482 25.5451L97.2659 27.5284C96.1576 23.8389 92.9298 20.6986 87.7138 20.6986C82.4979 20.6986 76.8736 24.4805 76.8736 32.6034C76.8736 40.7264 82.0409 44.6007 87.6701 44.6007C94.2666 44.6007 96.7604 40.0798 97.0375 37.2653H86.2409V32.4187H102.253V48.9805H97.7763H97.7812Z" fill="#161614"/>
<path d="M120.57 31.8159C119.972 31.7235 119.37 31.6798 118.815 31.6798C114.664 31.6798 112.773 34.0763 112.773 38.2763V48.9805H107.421V26.5124H112.637V30.1097C113.697 27.6645 116.19 26.2354 119.141 26.2354C119.788 26.2354 120.342 26.3277 120.57 26.3715V31.8159Z" fill="#161614"/>
<path d="M143.685 42.5688C142.484 46.4917 138.93 49.6757 133.535 49.6757C127.444 49.6757 122.048 45.2473 122.048 37.6348C122.048 30.5278 127.308 25.8223 132.98 25.8223C139.903 25.8223 143.962 30.3917 143.962 37.4938C143.962 38.3688 143.869 39.1077 143.821 39.2H127.395C127.531 42.6125 130.21 45.0577 133.53 45.0577C136.85 45.0577 138.42 43.3514 139.159 41.1348L143.68 42.5639L143.685 42.5688ZM138.566 35.2771C138.474 32.6473 136.719 30.2945 133.029 30.2945C129.66 30.2945 127.726 32.8757 127.541 35.2771H138.566Z" fill="#161614"/>
<path d="M148.366 58.0708L153.767 46.3069L144.171 26.5125H150.213L156.674 40.7215L162.716 26.5125H168.389L154.088 58.0708H148.366Z" fill="#161614"/>
<path d="M176.264 48.9805H170.912V15.575H176.264V28.6805C177.785 26.6972 180.323 25.8659 182.676 25.8659C188.213 25.8659 190.886 29.8326 190.886 34.7715V48.9805H185.534V35.6902C185.534 32.9194 184.29 30.7076 180.921 30.7076C177.97 30.7076 176.351 32.9194 176.259 35.7826V48.9805H176.264Z" fill="#161614"/>
<path d="M201.337 36.1084L207.103 35.2334C208.396 35.0487 208.765 34.4021 208.765 33.6195C208.765 31.7285 207.472 30.207 204.521 30.207C201.571 30.207 200.137 32.0056 199.908 34.266L195.018 33.1577C195.431 29.2834 198.941 25.8223 204.478 25.8223C211.4 25.8223 214.03 29.7452 214.03 34.2174V45.3834C214.03 47.4153 214.263 48.7521 214.307 48.9806H209.324C209.28 48.8396 209.096 47.9209 209.096 46.1174C208.036 47.8237 205.819 49.6709 202.174 49.6709C197.468 49.6709 194.561 46.4431 194.561 42.8896C194.561 38.8744 197.512 36.6625 201.342 36.1084H201.337ZM208.765 39.6618V38.6459L202.908 39.5209C201.245 39.798 199.908 40.7216 199.908 42.5688C199.908 44.0903 201.06 45.4757 203.185 45.4757C206.184 45.4757 208.765 44.0466 208.765 39.6618Z" fill="#161614"/>
<path d="M228.988 48.9805H223.635L214.501 26.5125H220.408L226.358 42.5687L232.172 26.5125H237.801L228.988 48.9805Z" fill="#161614"/>
<path d="M259.856 42.5688C258.655 46.4917 255.101 49.6757 249.706 49.6757C243.615 49.6757 238.219 45.2473 238.219 37.6348C238.219 30.5278 243.478 25.8223 249.151 25.8223C256.074 25.8223 260.133 30.3917 260.133 37.4938C260.133 38.3688 260.04 39.1077 259.992 39.2H243.566C243.702 42.6125 246.381 45.0577 249.701 45.0577C253.021 45.0577 254.591 43.3514 255.33 41.1348L259.851 42.5639L259.856 42.5688ZM254.732 35.2771C254.64 32.6473 252.885 30.2945 249.195 30.2945C245.826 30.2945 243.892 32.8757 243.707 35.2771H254.732Z" fill="#161614"/>
<path d="M269.393 48.9806H264.041V26.5125H269.257V29.5118C270.735 26.9306 273.408 25.866 275.902 25.866C281.39 25.866 284.02 29.8326 284.02 34.7715V48.9806H278.668V35.6903C278.668 32.9194 277.424 30.7076 274.055 30.7076C271.012 30.7076 269.393 33.0604 269.393 36.0111V48.9757V48.9806Z" fill="#161614"/>
<path d="M53.9972 15.7111L44.9215 10.3833H44.4306L36.3222 15.1424V5.32778L27.2465 0H26.7556L17.6799 5.32778V15.1424L9.57153 10.3833H9.08056L0 15.7111V16.0611V54.2208L9.07569 59.6264H9.57153L17.675 54.8042V64.6042L26.7507 70.0097H27.2465L36.3222 64.6042V54.8042L44.4257 59.6264H44.9215L53.9972 54.2208V15.7111ZM9.32361 11.3653L17.4417 16.134L9.32361 20.966L1.20556 16.134L9.32361 11.3653ZM0.972222 17.1257L8.8375 21.8069V48.1931L0.972222 52.8111V17.1257ZM8.8375 49.3208V58.3479L1.20556 53.8028L8.8375 49.3208ZM9.80972 58.3479V49.3208L17.4417 53.8028L9.80972 58.3479ZM17.675 52.8111L9.80972 48.1931V21.8069L17.675 17.1257V52.8111ZM35.35 16.0611V25.5208L27.4847 20.9028V11.4285L35.35 6.74722V16.0611ZM26.9986 31.3444L18.8806 26.5125L26.9986 21.7437L35.1167 26.5125L26.9986 31.3444ZM27.4847 10.2958V1.26875L35.1167 5.75069L27.4847 10.2958ZM26.5125 1.26875V10.2958L18.8806 5.75069L26.5125 1.26875ZM18.6472 6.74236L26.5125 11.4236V20.8979L18.6472 25.516V6.74236ZM18.6472 27.5042L26.5125 32.1854V58.5715L18.6472 63.1896V27.5042ZM26.5125 59.6993V68.7264L18.8806 64.1812L26.5125 59.6993ZM27.4847 68.7264V59.6993L35.1167 64.1812L27.4847 68.7264ZM35.35 63.1896L27.4847 58.5715V32.1854L35.35 27.5042V63.1896ZM45.1597 11.6521L52.7917 16.134L45.1597 20.6792V11.6521ZM44.1875 11.6521V20.6792L36.5556 16.134L44.1875 11.6521ZM36.3222 17.1257L44.1875 21.8069V48.1931L36.3222 52.8111V17.1257ZM44.6736 58.6396L36.5556 53.8028L44.6736 49.034L52.7917 53.8028L44.6736 58.6396ZM53.025 52.8111L45.1597 48.1931V21.8069L53.025 17.1257V52.8111Z" fill="#161614"/>
</g>
<defs>
<clipPath id="clip0_0_17">
<rect width="284.02" height="70" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -1 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="215" height="48" fill="none"><path fill="#000" d="M57.588 9.6h6L73.828 38h-5.2l-2.36-6.88h-11.36L52.548 38h-5.2l10.24-28.4Zm7.16 17.16-4.16-12.16-4.16 12.16h8.32Zm23.694-2.24c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.486-7.72.12 3.4c.534-1.227 1.307-2.173 2.32-2.84 1.04-.693 2.267-1.04 3.68-1.04 1.494 0 2.76.387 3.8 1.16 1.067.747 1.827 1.813 2.28 3.2.507-1.44 1.294-2.52 2.36-3.24 1.094-.747 2.414-1.12 3.96-1.12 1.414 0 2.64.307 3.68.92s1.84 1.52 2.4 2.72c.56 1.2.84 2.667.84 4.4V38h-4.96V25.92c0-1.813-.293-3.187-.88-4.12-.56-.96-1.413-1.44-2.56-1.44-.906 0-1.68.213-2.32.64-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.84-.48 3.04V38h-4.56V25.92c0-1.2-.133-2.213-.4-3.04-.24-.827-.626-1.453-1.16-1.88-.506-.427-1.133-.64-1.88-.64-.906 0-1.68.227-2.32.68-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.827-.48 3V38h-4.96V16.8h4.48Zm26.723 10.6c0-2.24.427-4.187 1.28-5.84.854-1.68 2.067-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.84 0 3.494.413 4.96 1.24 1.467.827 2.64 2.08 3.52 3.76.88 1.653 1.347 3.693 1.4 6.12v1.32h-15.08c.107 1.813.614 3.227 1.52 4.24.907.987 2.134 1.48 3.68 1.48.987 0 1.88-.253 2.68-.76a4.803 4.803 0 0 0 1.84-2.2l5.08.36c-.64 2.027-1.84 3.64-3.6 4.84-1.733 1.173-3.733 1.76-6 1.76-2.08 0-3.906-.453-5.48-1.36-1.573-.907-2.786-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84Zm15.16-2.04c-.213-1.733-.76-3.013-1.64-3.84-.853-.827-1.893-1.24-3.12-1.24-1.44 0-2.6.453-3.48 1.36-.88.88-1.44 2.12-1.68 3.72h9.92ZM163.139 9.6V38h-5.04V9.6h5.04Zm8.322 7.2.24 5.88-.64-.36c.32-2.053 1.094-3.56 2.32-4.52 1.254-.987 2.787-1.48 4.6-1.48 2.32 0 4.107.733 5.36 2.2 1.254 1.44 1.88 3.387 1.88 5.84V38h-4.96V25.92c0-1.253-.12-2.28-.36-3.08-.24-.8-.64-1.413-1.2-1.84-.533-.427-1.253-.64-2.16-.64-1.44 0-2.573.48-3.4 1.44-.8.933-1.2 2.307-1.2 4.12V38h-4.96V16.8h4.48Zm30.003 7.72c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.443 8.16V38h-5.6v-5.32h5.6Z"/><path fill="#171717" fill-rule="evenodd" d="m7.839 40.783 16.03-28.054L20 6 0 40.783h7.839Zm8.214 0H40L27.99 19.894l-4.02 7.032 3.976 6.914H20.02l-3.967 6.943Z" clip-rule="evenodd"/></svg>
<svg width="285" height="70" viewBox="0 0 285 70" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_0_17)">
<path d="M97.7812 48.9805L97.368 44.8291C95.9388 47.2257 92.5701 49.6757 87.3541 49.6757C78.818 49.6757 71.2493 43.4 71.2493 32.6034C71.2493 21.8069 79.2312 15.5798 87.7187 15.5798C95.6569 15.5798 100.732 20.1493 102.482 25.5451L97.2659 27.5284C96.1576 23.8389 92.9298 20.6986 87.7138 20.6986C82.4979 20.6986 76.8736 24.4805 76.8736 32.6034C76.8736 40.7264 82.0409 44.6007 87.6701 44.6007C94.2666 44.6007 96.7604 40.0798 97.0375 37.2653H86.2409V32.4187H102.253V48.9805H97.7763H97.7812Z" fill="#161614"/>
<path d="M120.57 31.8159C119.972 31.7235 119.37 31.6798 118.815 31.6798C114.664 31.6798 112.773 34.0763 112.773 38.2763V48.9805H107.421V26.5124H112.637V30.1097C113.697 27.6645 116.19 26.2354 119.141 26.2354C119.788 26.2354 120.342 26.3277 120.57 26.3715V31.8159Z" fill="#161614"/>
<path d="M143.685 42.5688C142.484 46.4917 138.93 49.6757 133.535 49.6757C127.444 49.6757 122.048 45.2473 122.048 37.6348C122.048 30.5278 127.308 25.8223 132.98 25.8223C139.903 25.8223 143.962 30.3917 143.962 37.4938C143.962 38.3688 143.869 39.1077 143.821 39.2H127.395C127.531 42.6125 130.21 45.0577 133.53 45.0577C136.85 45.0577 138.42 43.3514 139.159 41.1348L143.68 42.5639L143.685 42.5688ZM138.566 35.2771C138.474 32.6473 136.719 30.2945 133.029 30.2945C129.66 30.2945 127.726 32.8757 127.541 35.2771H138.566Z" fill="#161614"/>
<path d="M148.366 58.0708L153.767 46.3069L144.171 26.5125H150.213L156.674 40.7215L162.716 26.5125H168.389L154.088 58.0708H148.366Z" fill="#161614"/>
<path d="M176.264 48.9805H170.912V15.575H176.264V28.6805C177.785 26.6972 180.323 25.8659 182.676 25.8659C188.213 25.8659 190.886 29.8326 190.886 34.7715V48.9805H185.534V35.6902C185.534 32.9194 184.29 30.7076 180.921 30.7076C177.97 30.7076 176.351 32.9194 176.259 35.7826V48.9805H176.264Z" fill="#161614"/>
<path d="M201.337 36.1084L207.103 35.2334C208.396 35.0487 208.765 34.4021 208.765 33.6195C208.765 31.7285 207.472 30.207 204.521 30.207C201.571 30.207 200.137 32.0056 199.908 34.266L195.018 33.1577C195.431 29.2834 198.941 25.8223 204.478 25.8223C211.4 25.8223 214.03 29.7452 214.03 34.2174V45.3834C214.03 47.4153 214.263 48.7521 214.307 48.9806H209.324C209.28 48.8396 209.096 47.9209 209.096 46.1174C208.036 47.8237 205.819 49.6709 202.174 49.6709C197.468 49.6709 194.561 46.4431 194.561 42.8896C194.561 38.8744 197.512 36.6625 201.342 36.1084H201.337ZM208.765 39.6618V38.6459L202.908 39.5209C201.245 39.798 199.908 40.7216 199.908 42.5688C199.908 44.0903 201.06 45.4757 203.185 45.4757C206.184 45.4757 208.765 44.0466 208.765 39.6618Z" fill="#161614"/>
<path d="M228.988 48.9805H223.635L214.501 26.5125H220.408L226.358 42.5687L232.172 26.5125H237.801L228.988 48.9805Z" fill="#161614"/>
<path d="M259.856 42.5688C258.655 46.4917 255.101 49.6757 249.706 49.6757C243.615 49.6757 238.219 45.2473 238.219 37.6348C238.219 30.5278 243.478 25.8223 249.151 25.8223C256.074 25.8223 260.133 30.3917 260.133 37.4938C260.133 38.3688 260.04 39.1077 259.992 39.2H243.566C243.702 42.6125 246.381 45.0577 249.701 45.0577C253.021 45.0577 254.591 43.3514 255.33 41.1348L259.851 42.5639L259.856 42.5688ZM254.732 35.2771C254.64 32.6473 252.885 30.2945 249.195 30.2945C245.826 30.2945 243.892 32.8757 243.707 35.2771H254.732Z" fill="#161614"/>
<path d="M269.393 48.9806H264.041V26.5125H269.257V29.5118C270.735 26.9306 273.408 25.866 275.902 25.866C281.39 25.866 284.02 29.8326 284.02 34.7715V48.9806H278.668V35.6903C278.668 32.9194 277.424 30.7076 274.055 30.7076C271.012 30.7076 269.393 33.0604 269.393 36.0111V48.9757V48.9806Z" fill="#161614"/>
<path d="M53.9972 15.7111L44.9215 10.3833H44.4306L36.3222 15.1424V5.32778L27.2465 0H26.7556L17.6799 5.32778V15.1424L9.57153 10.3833H9.08056L0 15.7111V16.0611V54.2208L9.07569 59.6264H9.57153L17.675 54.8042V64.6042L26.7507 70.0097H27.2465L36.3222 64.6042V54.8042L44.4257 59.6264H44.9215L53.9972 54.2208V15.7111ZM9.32361 11.3653L17.4417 16.134L9.32361 20.966L1.20556 16.134L9.32361 11.3653ZM0.972222 17.1257L8.8375 21.8069V48.1931L0.972222 52.8111V17.1257ZM8.8375 49.3208V58.3479L1.20556 53.8028L8.8375 49.3208ZM9.80972 58.3479V49.3208L17.4417 53.8028L9.80972 58.3479ZM17.675 52.8111L9.80972 48.1931V21.8069L17.675 17.1257V52.8111ZM35.35 16.0611V25.5208L27.4847 20.9028V11.4285L35.35 6.74722V16.0611ZM26.9986 31.3444L18.8806 26.5125L26.9986 21.7437L35.1167 26.5125L26.9986 31.3444ZM27.4847 10.2958V1.26875L35.1167 5.75069L27.4847 10.2958ZM26.5125 1.26875V10.2958L18.8806 5.75069L26.5125 1.26875ZM18.6472 6.74236L26.5125 11.4236V20.8979L18.6472 25.516V6.74236ZM18.6472 27.5042L26.5125 32.1854V58.5715L18.6472 63.1896V27.5042ZM26.5125 59.6993V68.7264L18.8806 64.1812L26.5125 59.6993ZM27.4847 68.7264V59.6993L35.1167 64.1812L27.4847 68.7264ZM35.35 63.1896L27.4847 58.5715V32.1854L35.35 27.5042V63.1896ZM45.1597 11.6521L52.7917 16.134L45.1597 20.6792V11.6521ZM44.1875 11.6521V20.6792L36.5556 16.134L44.1875 11.6521ZM36.3222 17.1257L44.1875 21.8069V48.1931L36.3222 52.8111V17.1257ZM44.6736 58.6396L36.5556 53.8028L44.6736 49.034L52.7917 53.8028L44.6736 58.6396ZM53.025 52.8111L45.1597 48.1931V21.8069L53.025 17.1257V52.8111Z" fill="#161614"/>
</g>
<defs>
<clipPath id="clip0_0_17">
<rect width="284.02" height="70" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -1,459 +0,0 @@
#!/usr/bin/env npx tsx
/**
* Generates dist/greyhaven.htmx.css — a framework-agnostic CSS companion
* to the React component library. Exposes every component via `data-slot`
* (+ `data-variant` / `data-size`) attribute selectors so HTMX / server-
* rendered projects can use the design system without React.
*
* Input:
* components/ui/*.tsx
*
* Output:
* dist/greyhaven.htmx.css
*
* Extraction strategy:
* 1. AST-walk each .tsx file
* 2. For `const xVariants = cva("base", { variants, defaultVariants })`,
* capture base + variants + defaults.
* 3. For any JSX element with `data-slot="X"`, capture the static string
* in its `className` (direct string, or first arg of cn(...)).
* 4. Merge the two: a slot with both a cva binding and static cn classes
* (rare) gets both; otherwise one or the other.
*
* Limitations:
* - Dynamic / conditional classes are dropped (logged as warnings).
* - Components relying on runtime state (data-state, Radix Portals) emit
* only their static visual rules. Open/close / focus / positioning JS
* is the consumer's problem.
*/
import * as ts from 'typescript'
import * as fs from 'fs'
import * as path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const ROOT = path.resolve(__dirname, '..')
const UI_DIR = path.join(ROOT, 'components/ui')
const OUT_FILE = path.join(ROOT, 'dist/greyhaven.htmx.css')
type CvaExtract = {
sourceFile: string
variableName: string
base: string
variants: Record<string, Record<string, string>>
defaultVariants: Record<string, string>
}
type SlotExtract = {
sourceFile: string
slot: string
classes: string
/**
* Names of CVA functions referenced inside the slot's `cn(...)` call — e.g.
* `cn(toggleVariants({variant,size}), 'rounded-none ...')` on
* ToggleGroupItem records `['toggleVariants']`. Used to emit the
* referenced CVA's base + variant rules under this slot's selector, so
* slots that *compose* another component's variant system (instead of
* declaring their own cva) still inherit its padding/height/states.
*/
viaVariants: string[]
}
type Warning = { sourceFile: string; message: string }
const cvaExtracts: CvaExtract[] = []
const slotExtracts: SlotExtract[] = []
const warnings: Warning[] = []
// ─── String extraction helpers ──────────────────────────────────────────
function getStringLiteral(node: ts.Node): string | null {
if (ts.isStringLiteral(node)) return node.text
if (ts.isNoSubstitutionTemplateLiteral(node)) return node.text
// Template with only literal parts: `${''}foo${''}` — rare, skip
return null
}
// Object-literal property shortcut: returns first ObjectLiteralExpression named `name`.
function getPropertyInitializer(
obj: ts.ObjectLiteralExpression,
name: string,
): ts.Expression | null {
for (const prop of obj.properties) {
if (ts.isPropertyAssignment(prop) && prop.name && ts.isIdentifier(prop.name) && prop.name.text === name) {
return prop.initializer
}
}
return null
}
// ─── CVA extractor ──────────────────────────────────────────────────────
function extractCva(
declList: ts.VariableDeclaration,
call: ts.CallExpression,
sourceFile: string,
): void {
if (!ts.isIdentifier(declList.name)) return
const variableName = declList.name.text
const baseArg = call.arguments[0]
const configArg = call.arguments[1]
const base = baseArg ? (getStringLiteral(baseArg) ?? '') : ''
if (!base) {
warnings.push({ sourceFile, message: `cva: ${variableName}: non-literal base, skipping` })
return
}
if (!configArg || !ts.isObjectLiteralExpression(configArg)) {
warnings.push({ sourceFile, message: `cva: ${variableName}: no config object, skipping` })
return
}
const variants: Record<string, Record<string, string>> = {}
const variantsNode = getPropertyInitializer(configArg, 'variants')
if (variantsNode && ts.isObjectLiteralExpression(variantsNode)) {
for (const prop of variantsNode.properties) {
if (!ts.isPropertyAssignment(prop)) continue
if (!prop.name || !ts.isIdentifier(prop.name)) continue
const axisName = prop.name.text
if (!ts.isObjectLiteralExpression(prop.initializer)) continue
const values: Record<string, string> = {}
for (const subProp of prop.initializer.properties) {
if (!ts.isPropertyAssignment(subProp)) continue
let key: string | null = null
if (subProp.name) {
if (ts.isIdentifier(subProp.name)) key = subProp.name.text
else if (ts.isStringLiteral(subProp.name)) key = subProp.name.text
}
if (!key) continue
const classes = getStringLiteral(subProp.initializer)
if (classes === null) {
warnings.push({
sourceFile,
message: `cva: ${variableName}.${axisName}.${key}: non-literal classes, skipping value`,
})
continue
}
values[key] = classes
}
variants[axisName] = values
}
}
const defaultVariants: Record<string, string> = {}
const defaultsNode = getPropertyInitializer(configArg, 'defaultVariants')
if (defaultsNode && ts.isObjectLiteralExpression(defaultsNode)) {
for (const prop of defaultsNode.properties) {
if (!ts.isPropertyAssignment(prop)) continue
if (!prop.name || !ts.isIdentifier(prop.name)) continue
const axisName = prop.name.text
const value = getStringLiteral(prop.initializer)
if (value !== null) defaultVariants[axisName] = value
}
}
cvaExtracts.push({ sourceFile, variableName, base, variants, defaultVariants })
}
// ─── Slot extractor ─────────────────────────────────────────────────────
function extractSlot(
element: ts.JsxOpeningLikeElement,
sourceFile: string,
): void {
let slot: string | null = null
let classes: string | null = null
const viaVariants: string[] = []
const attrs = element.attributes.properties
for (const attr of attrs) {
if (!ts.isJsxAttribute(attr)) continue
const attrName = attr.name.getText()
if (attrName === 'data-slot') {
if (attr.initializer && ts.isStringLiteral(attr.initializer)) {
slot = attr.initializer.text
}
} else if (attrName === 'className' && attr.initializer) {
if (ts.isStringLiteral(attr.initializer)) {
classes = attr.initializer.text
} else if (ts.isJsxExpression(attr.initializer) && attr.initializer.expression) {
const expr = attr.initializer.expression
if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) {
classes = (expr as ts.StringLiteral).text
} else if (ts.isCallExpression(expr)) {
const callName = expr.expression.getText()
if (callName === 'cn' || callName.endsWith('Variants')) {
// First string-literal arg is the static class baseline.
const first = expr.arguments.find((a) => getStringLiteral(a) !== null)
if (first) classes = getStringLiteral(first)
// Any *call* arg whose callee is a `xVariants` identifier means this
// slot inherits from another component's variant system (e.g.
// ToggleGroupItem: `cn(toggleVariants({variant,size}), 'rounded-none...')`).
// Record the names so the emitter can pull in those CVAs' base + variants.
for (const arg of expr.arguments) {
if (!ts.isCallExpression(arg)) continue
const argCallee = arg.expression
if (ts.isIdentifier(argCallee) && argCallee.text.endsWith('Variants')) {
viaVariants.push(argCallee.text)
}
}
}
}
}
}
}
if (slot) {
slotExtracts.push({ sourceFile, slot, classes: classes ?? '', viaVariants })
}
}
// ─── AST walker ─────────────────────────────────────────────────────────
function walkFile(filePath: string): void {
const relPath = path.relative(ROOT, filePath)
const source = fs.readFileSync(filePath, 'utf-8')
const sf = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
function visit(node: ts.Node): void {
// CVA binding: `const xVariants = cva(...)`
if (ts.isVariableDeclaration(node) && node.initializer && ts.isCallExpression(node.initializer)) {
const callee = node.initializer.expression
if (ts.isIdentifier(callee) && callee.text === 'cva') {
extractCva(node, node.initializer, relPath)
}
}
// JSX element with data-slot
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
extractSlot(node, relPath)
}
ts.forEachChild(node, visit)
}
visit(sf)
}
// ─── CSS emitter ────────────────────────────────────────────────────────
// Classes that can't be used with @apply in Tailwind v4.
// peer / group — sibling/parent markers, pure class hooks with no CSS
// peer/<name> / group/<name> — named variants of the above
// contents — reserved Tailwind escape
// not-prose — ships with @tailwindcss/typography (optional plugin)
// Consumers who need these can add them directly on the HTML element.
const NON_APPLIABLE_EXACT = new Set([
'peer', // sibling marker
'group', // parent marker
'contents', // reserved
'not-prose', // @tailwindcss/typography
'origin-top-center', // not a stock Tailwind v4 utility (upstream bug)
'destructive', // toast's own marker class (used with group-[.destructive]: selectors)
])
function isNonAppliable(cls: string): boolean {
if (NON_APPLIABLE_EXACT.has(cls)) return true
// Named peer/group markers: e.g., `group/drawer-content`, `peer/email`
if (/^(peer|group)\/[A-Za-z0-9_-]+$/.test(cls)) return true
return false
}
function uniqueClasses(s: string): string {
// Collapse whitespace; preserve order and arbitrary variants.
// Strip non-appliable marker classes (peer/group); consumers add them directly
// on the HTML element when they need sibling/parent state styling.
let tokens = s
.replace(/\s+/g, ' ')
.trim()
.split(' ')
.filter((c) => !isNonAppliable(c))
// Always strip `leading-*` utilities from @apply. Tailwind v4's `text-*`
// size utilities use `--tw-leading` as an override mechanism, but once
// `leading-*` is applied via @apply it sets `--tw-leading` on the element,
// and subsequent user-passed `text-sm`/`text-xl` classes still resolve
// line-height through that inherited variable — defeating the override.
// React + tailwind-merge removes `leading-*` at className-merge time when
// a text-size utility is passed; replicate that behavior by stripping it
// unconditionally so user `class="text-xl"` overrides produce the same
// line-height React ends up with.
const LEADING = /^leading-/
tokens = tokens.filter((t) => !LEADING.test(t))
return tokens.join(' ')
}
function emitCss(): string {
const lines: string[] = []
const header = `/*! Greyhaven Design System — HTMX / Framework-Agnostic CSS Layer
* Auto-generated from components/ui/*.tsx by scripts/generate-htmx-css.ts — DO NOT EDIT
*
* Usage:
* <link href="greyhaven.htmx.css" rel="stylesheet">
*
* Requires:
* - Tokens: import tokens-light.css + tokens-dark.css before this file
* - Tailwind v4: this file uses @apply against Tailwind utility classes.
* It must be processed by Tailwind v4 (e.g., via \`tailwindcss -i input.css\`).
* Your consumer Tailwind input should \`@import "./greyhaven.htmx.css";\`.
*
* Consume via data-slot / data-variant / data-size attributes:
* <div data-slot="card">
* <div data-slot="card-header"><div data-slot="card-title">Hello</div></div>
* <div data-slot="card-content">…</div>
* </div>
* <button data-slot="button" data-variant="outline" data-size="sm">Click</button>
* <span data-slot="badge" data-variant="success">Active</span>
*/
`
lines.push(header)
// Emit in @layer utilities so individual Tailwind utility classes on child
// elements (e.g. <svg class="h-3.5">) don't override our compound selectors
// by layer precedence alone. Within the same layer, specificity decides.
lines.push('@layer utilities {\n')
// Index slot → classes (dedupe: multiple JSX elements may declare the same slot).
const slotMap = new Map<string, Set<string>>()
for (const s of slotExtracts) {
if (!s.classes) continue
if (!slotMap.has(s.slot)) slotMap.set(s.slot, new Set())
slotMap.get(s.slot)!.add(uniqueClasses(s.classes))
}
// Index cva by slot-name heuristic: `xVariants` → component slot "x" when JSX
// in the same file uses `data-slot="x"`. If ambiguous, fall back to stripping
// "Variants" and kebab-casing.
const cvaBySlot = new Map<string, CvaExtract>()
const cvaByName = new Map<string, CvaExtract>()
for (const cva of cvaExtracts) {
cvaByName.set(cva.variableName, cva)
const stripped = cva.variableName.replace(/Variants$/, '')
const slot = stripped
.replace(/([a-z])([A-Z])/g, '$1-$2')
.toLowerCase()
cvaBySlot.set(slot, cva)
}
// Aggregate viaVariants per slot (a slot may appear in multiple JSX sites).
const slotVia = new Map<string, Set<string>>()
for (const s of slotExtracts) {
if (s.viaVariants.length === 0) continue
if (!slotVia.has(s.slot)) slotVia.set(s.slot, new Set())
for (const v of s.viaVariants) slotVia.get(s.slot)!.add(v)
}
// Emit slots in sorted order for stable output.
const allSlots = Array.from(
new Set([...slotMap.keys(), ...cvaBySlot.keys()]),
).sort()
for (const slot of allSlots) {
const selfCva = cvaBySlot.get(slot)
// Gather CVAs to apply under this slot's selector: its own (if any), plus
// any CVAs referenced via `cn(xVariants(...), ...)` in the JSX (e.g.
// ToggleGroupItem inherits from toggleVariants). Dedup.
const cvas: CvaExtract[] = []
if (selfCva) cvas.push(selfCva)
for (const name of slotVia.get(slot) ?? []) {
const aliasCva = cvaByName.get(name)
if (aliasCva && !cvas.includes(aliasCva)) cvas.push(aliasCva)
}
const staticSets = slotMap.get(slot)
const staticClasses = staticSets ? Array.from(staticSets).join(' ') : ''
lines.push(` /* ── ${slot} ─────────────────────────────────────────── */`)
// Emit selectors wrapped in :where() so the attribute selectors contribute
// zero specificity. This matches how React + tailwind-merge behave: user
// overrides passed as `className` (e.g., `class="bg-primary/90"`) must
// win over the variant defaults. If we used bare [data-slot][data-variant]
// selectors, their specificity (0,2,0) would beat a plain utility class
// (0,1,0) and silently drop user overrides.
const SLOT = `:where([data-slot="${slot}"])`
// Base rule: every contributing CVA's base + any static classes from JSX.
const basePieces: string[] = []
for (const c of cvas) if (c.base) basePieces.push(c.base)
if (staticClasses) basePieces.push(staticClasses)
const base = uniqueClasses(basePieces.join(' '))
if (base) {
lines.push(` ${SLOT} { @apply ${base}; }`)
}
// Emit variant rules for each contributing CVA, under this slot's selector.
// When a slot inherits (e.g. toggle-group-item via toggleVariants), its
// data-variant/data-size attributes on the DOM drive the inherited CVA's
// rules just like they drive the self-CVA's.
for (const c of cvas) {
for (const [axis, values] of Object.entries(c.variants)) {
const defaultValue = c.defaultVariants[axis]
const axisAttr = `data-${axis}`
for (const [key, classes] of Object.entries(values)) {
if (!classes.trim()) continue
if (key === defaultValue) {
// Default: apply when attr absent OR explicitly set to this key.
// Emit as TWO separate rules (no comma-joined selector list), because
// Tailwind v4 miscompiles arbitrary variants like `has-[>svg]:px-3`
// when @applied inside a rule with a comma-separated selector list
// (it emits a stray `)` and the resulting selector is invalid).
// Wrap :not() in :where() so it contributes zero specificity;
// otherwise plain utility classes like .bg-primary/90 would tie on
// specificity (both at 0,1,0) and lose by source order, breaking
// user className overrides.
lines.push(
` ${SLOT}:where(:not([${axisAttr}])) { @apply ${uniqueClasses(classes)}; }`,
)
lines.push(
` ${SLOT}:where([${axisAttr}="${key}"]) { @apply ${uniqueClasses(classes)}; }`,
)
} else {
lines.push(
` ${SLOT}:where([${axisAttr}="${key}"]) { @apply ${uniqueClasses(classes)}; }`,
)
}
}
}
}
lines.push('')
}
lines.push('}\n')
if (warnings.length > 0) {
lines.push('/* Extraction warnings:')
for (const w of warnings) {
lines.push(` * [${w.sourceFile}] ${w.message}`)
}
lines.push(' */\n')
}
return lines.join('\n')
}
// ─── Entry ──────────────────────────────────────────────────────────────
function main(): void {
const files = fs
.readdirSync(UI_DIR)
.filter((f) => f.endsWith('.tsx'))
.map((f) => path.join(UI_DIR, f))
for (const file of files) {
walkFile(file)
}
const css = emitCss()
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true })
fs.writeFileSync(OUT_FILE, css, 'utf-8')
console.log(`Parsed ${files.length} component files`)
console.log(` ${cvaExtracts.length} cva bindings`)
console.log(` ${slotExtracts.length} data-slot occurrences`)
console.log(` ${warnings.length} warnings`)
console.log(`Wrote ${path.relative(ROOT, OUT_FILE)} (${(fs.statSync(OUT_FILE).size / 1024).toFixed(1)} KB)`)
}
main()

View File

@@ -1,475 +0,0 @@
#!/usr/bin/env npx tsx
/**
* Generates skill/SKILL.md and skill/AGENTS.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 all outputs stay in sync.
*
* Outputs:
* skill/SKILL.md — Full design system reference (tokens, components, rules)
* skill/AGENTS.md — Project-level instructions for any AI coding agent
*/
import * as fs from 'fs'
import * as path from 'path'
import { fileURLToPath } from 'url'
import {
COMPONENT_CATALOG,
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 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
}
// ---------------------------------------------------------------------------
// Shared content blocks (used by both SKILL.md and AGENTS.md)
// ---------------------------------------------------------------------------
function buildDesignPhilosophy(): string {
return `## Design Philosophy
- **TypeScript only**: All code MUST be written in TypeScript (\`.tsx\` / \`.ts\`). Never generate plain JavaScript (\`.jsx\` / \`.js\`).
- **Minimal and restrained**: Off-white + off-black + warm greys. One single accent color (orange). No gradients, no decorative color, no multiple accent hues.
- **Typography-driven**: Source Serif 4/Pro (serif) for headings and body content. Aspekta (sans, self-hosted) for UI labels, buttons, navigation, and form elements.
- **Calm, professional aesthetic**: Tight border-radii, subtle shadows, generous whitespace.
- **Accessibility-first**: Built on Radix UI primitives for keyboard navigation, focus management, screen reader support. Visible focus rings, disabled states, ARIA attributes.
- **Dark mode native**: Thoughtful dark theme using inverted warm greys. Orange accent persists across both modes. Toggled via \`.dark\` class.
- **Framework-agnostic**: Pure React + Radix + Tailwind. No Next.js, no framework-specific imports.
`
}
function buildFontSetup(): string {
return `## Font Setup
This design system uses two typefaces:
| Role | Font | Usage |
|------|------|-------|
| **Sans (UI)** | Aspekta (self-hosted) | Buttons, nav, labels, forms, metadata |
| **Serif (Content)** | Source Serif 4/Pro | Headings, body text, reading content |
### Aspekta (required)
Aspekta font files live in \`public/fonts/\`. Add \`@font-face\` declarations to your global CSS:
\`\`\`css
/* Minimum set (covers font-weight 400-700) */
@font-face { font-family: 'Aspekta'; font-weight: 400; font-display: swap; src: url('/fonts/Aspekta-400.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-weight: 500; font-display: swap; src: url('/fonts/Aspekta-500.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-weight: 600; font-display: swap; src: url('/fonts/Aspekta-600.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-weight: 700; font-display: swap; src: url('/fonts/Aspekta-700.woff2') format('woff2'); }
/* Or import all weights: */
@import url('/fonts/font-face.css');
\`\`\`
### Font stack CSS variables
\`\`\`css
--font-sans: 'Aspekta', ui-sans-serif, system-ui, sans-serif;
--font-serif: 'Source Serif 4', 'Source Serif Pro', Georgia, serif;
\`\`\`
### Tailwind usage
- \`font-sans\` — Aspekta (UI elements)
- \`font-serif\` — Source Serif (content)
Install fonts via: \`./skill/install.sh /path/to/your/project\`
`
}
function buildTokenReference(): string {
const lines: string[] = []
lines.push('## 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(`## Component Catalog (${componentCount()} components)\n`)
lines.push('All components live in `components/ui/`. Import with `@/components/ui/<name>`.\n')
const categories = new Map<string, typeof COMPONENT_CATALOG>()
for (const c of COMPONENT_CATALOG) {
if (!categories.has(c.category)) categories.set(c.category, [])
categories.get(c.category)!.push(c)
}
const categoryOrder = ['primitives', 'layout', 'overlay', 'navigation', 'data', 'feedback', 'form', 'composition']
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 `## Composition Rules
- **Never override component sizing via \`className\`**: Each component exposes \`size\` / \`variant\` props for a reason. Reach for those first. Overriding font-size, padding, or height with arbitrary Tailwind classes (\`text-sm\`, \`px-3\`, \`py-1\`, etc.) fragments the design system. If no variant fits, add a new \`size\`/\`variant\` to the component — don't one-off patch it at the call site.
- **Minimum font size is \`text-xs\` (12px)**: Anything smaller fails accessibility/readability minimums. If you genuinely need smaller text for a specific reason (e.g., a data-dense legend), add an explicit \`// justification: ...\` comment at the call site. Default answer is: use \`text-xs\`.
- **Card spacing**: \`gap-6\` between cards, \`p-6\` internal padding
- **Section rhythm**: \`py-10\` internal padding per section. Colored sections add \`my-8\` to detach from neighbors
- **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**: Trust the default component variants for orange accent -- they apply it at the right scale. Don't apply \`bg-primary\` to large surfaces, containers, or section backgrounds
- **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 buildHtmxLayer(): string {
return `## HTMX / Server-Rendered Usage
For projects that cannot use React (HTMX, Django templates, Rails ERB, Go \`html/template\`, Astro SSR, etc.), the design system ships a framework-agnostic CSS layer: \`dist/greyhaven.htmx.css\`.
### What it is
An auto-generated stylesheet derived from \`components/ui/*.tsx\`. Every \`data-slot\` attribute gets a \`@layer components\` rule. \`cva\` variants become attribute selectors (\`[data-variant=...]\`, \`[data-size=...]\`). Default variants apply via \`:not([data-variant])\` so consumers can omit the attribute.
### Usage
1. Install: \`./skill/install.sh /path/to/project --htmx-css\`
2. Import in your Tailwind v4 input CSS: \`@import "./greyhaven.htmx.css";\`
3. Emit HTML with \`data-slot\` / \`data-variant\` / \`data-size\` attributes:
\`\`\`html
<div data-slot="card">
<div data-slot="card-header">
<div data-slot="card-title">Title</div>
<div data-slot="card-description">Description</div>
</div>
<div data-slot="card-content">Body</div>
</div>
<button data-slot="button" data-variant="default">Save</button>
<button data-slot="button" data-variant="outline" data-size="sm">Cancel</button>
<span data-slot="badge" data-variant="success">Active</span>
\`\`\`
### Scope
- **Fully static** (pure CSS, no JS): Card, Button, Badge, Input, Label, Textarea, Table, Separator, Code, Kbd, Progress, Avatar, Skeleton, Alert, Pagination, Breadcrumb, Navbar (solid variant), Spinner, AspectRatio, Empty, Hero, Section, Footer, CtaSection, ButtonGroup, InputGroup, Toast.
- **Visual-only** (CSS is correct but needs your own state JS): Dialog, Dropdown, Popover, Select, Combobox, Accordion, Tabs, Tooltip, Drawer, Sheet, Sidebar, Collapsible, NavigationMenu, Menubar, ContextMenu, HoverCard, Command, AlertDialog, InputOtp, Carousel. Pair with Alpine.js (\`x-data\`, \`x-show\`, \`@click\`) or native HTML primitives (\`<dialog>\`, \`<details>\`).
### Regenerate
\`\`\`bash
pnpm htmx-css:build
\`\`\`
`
}
function buildExtensionProtocol(): string {
return `## 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 }
\`\`\`
`
}
// ---------------------------------------------------------------------------
// SKILL.md (Claude Code)
// ---------------------------------------------------------------------------
function generateSkill(): string {
return [
`# Greyhaven Design System -- Claude Skill
> **Auto-generated** by \`scripts/generate-skill.ts\` -- DO NOT EDIT by hand.
> Re-generate: \`pnpm skill:build\`
>
> **Components**: ${componentCount()} | **Style**: shadcn/ui "new-york"
> **Stack**: React 19, Radix UI, Tailwind CSS v4, CVA, tailwind-merge, clsx, Lucide icons
> **Framework-agnostic**: No Next.js imports. Works with Vite, Remix, Astro, CRA, or any React setup.
This skill gives you full context to generate pixel-perfect, on-brand UI using the Greyhaven Design System. Every component lives in \`components/ui/\`. Use semantic tokens, never raw colors. Follow the patterns exactly. **ALWAYS use TypeScript (.tsx/.ts) — never plain JavaScript.**
`,
'---\n',
buildDesignPhilosophy(),
'---\n',
buildFontSetup(),
'---\n',
buildTokenReference(),
'---\n',
buildComponentCatalog(),
'---\n',
buildCompositionRules(),
'---\n',
buildHtmxLayer(),
'---\n',
buildExtensionProtocol(),
].join('\n')
}
// ---------------------------------------------------------------------------
// AGENTS.md (project-level instructions for non-Claude AI agents)
// ---------------------------------------------------------------------------
function generateAgent(): string {
// Count components by category for the summary
const categories = new Map<string, number>()
for (const c of COMPONENT_CATALOG) {
categories.set(c.category, (categories.get(c.category) || 0) + 1)
}
const categorySummary = Array.from(categories.entries())
.map(([cat, count]) => `${cat} (${count})`)
.join(', ')
return `# Project Instructions
> **Auto-generated** by the Greyhaven Design System.
> Re-generate: \`pnpm skill:build\` in the design system repo.
>
> Copy this file to your project root as \`AGENTS.md\` (standard), \`CLAUDE.md\`,
> \`.cursorrules\`, or \`.github/copilot-instructions.md\` depending on your AI tool.
This project uses the **Greyhaven Design System**.
## Rules
- **ALWAYS use TypeScript** (\`.tsx\` / \`.ts\`). NEVER generate plain JavaScript (\`.jsx\` / \`.js\`).
- Use the \`greyhaven\` SKILL.md for full design system context (tokens, components, composition rules). It should be installed at \`.claude/skills/greyhaven-design-system.md\` or accessible to your AI tool.
- If the \`greyhaven\` MCP server is available, use its tools:
- \`list_components()\` to find the right component for a UI need
- \`get_component(name)\` to get exact props, variants, and usage examples
- \`validate_colors(code)\` to check code for off-brand colors
- \`suggest_component(description)\` to get recommendations
- Import components from \`components/ui/\` (or \`@/components/ui/\` with path alias)
- Never use raw hex colors -- use semantic Tailwind classes (\`bg-primary\`, \`text-foreground\`, \`border-border\`, etc.)
- Use \`font-sans\` (Aspekta) for UI elements: buttons, nav, labels, forms
- Use \`font-serif\` (Source Serif) for content: headings, body text
- Trust the design system's default component variants for accent -- they apply orange at the right scale. Don't apply \`bg-primary\` to large surfaces, containers, or section backgrounds
- All components are framework-agnostic React (no Next.js, no framework-specific imports)
- Dark mode is toggled via the \`.dark\` class -- use semantic tokens that adapt automatically
## Component Summary
${componentCount()} components across ${categories.size} categories: ${categorySummary}.
For full component specs, props, and examples, refer to the SKILL.md file or use the MCP \`get_component(name)\` tool.
## Key Patterns
- **CVA variants**: Components use \`class-variance-authority\` for variant props
- **Slot composition**: Components use \`data-slot="name"\` attributes
- **Class merging**: Always use \`cn()\` from \`@/lib/utils\` (clsx + tailwind-merge)
- **Focus rings**: \`focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]\`
- **Disabled**: \`disabled:pointer-events-none disabled:opacity-50\`
- **Card spacing**: \`gap-6\` between cards, \`p-6\` internal padding
- **Section rhythm**: \`py-16\` between major sections
- **Form layout**: Vertical stack with \`gap-4\`, labels above inputs
## Font Setup
If fonts aren't loaded yet, add to your global CSS:
\`\`\`css
@font-face { font-family: 'Aspekta'; font-weight: 400; font-display: swap; src: url('/fonts/Aspekta-400.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-weight: 500; font-display: swap; src: url('/fonts/Aspekta-500.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-weight: 600; font-display: swap; src: url('/fonts/Aspekta-600.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-weight: 700; font-display: swap; src: url('/fonts/Aspekta-700.woff2') format('woff2'); }
\`\`\`
`
}
// ---------------------------------------------------------------------------
// Brand addendum (appended to AGENTS.md only when --brand-skill is installed)
// ---------------------------------------------------------------------------
function buildBrandAddendum(): string {
return `---
## Brand Voice and Messaging
This project generates user-facing content (marketing copy, CTAs, landing pages, product explanations, emails) and MUST follow the Greyhaven brand voice.
### Brand Rules
- **Before writing any user-facing copy**, read the brand skill:
- Claude Code / compatible tools: \`.claude/skills/greyhaven-brand.md\` (full voice/tone/messaging reference)
- Or via MCP: call \`get_brand_rules()\` (or a specific section: \`positioning\`, \`axes\`, \`tone\`, \`writing-rules\`, \`reasoning-patterns\`, \`cta\`, \`logo\`, \`self-check\`)
- **Before shipping any user-facing copy**, validate it:
- Via MCP: call \`validate_copy(text)\` to lint for hype words, vague superlatives, urgency framing, and exclamation marks
- Or manually run the 8-item self-check list from the brand skill
### Core Voice (memorize)
- **Direct. Plain-spoken technical.** Write like an engineer who explains systems cleanly, without mystique or theatrics.
- **No** hype adjectives (\`revolutionary\`, \`cutting-edge\`, \`seamless\`, \`game-changing\`, \`powerful\`).
- **No** evangelism verbs (\`unleash\`, \`transform\`, \`empower\`, \`supercharge\`, \`unlock\`).
- **No** sales language, urgency framing, exclamation marks.
- **No** jargon for its own sake. Prefer plain words: "where the data goes" over "data paths"; "things the system relies on" over "dependencies".
- **Yes** specifics, causal reasoning, concrete outcomes.
### The Three Brand Axes
Copy must land on the correct side of each:
1. **Containment** — systems run inside the perimeter, nothing leaks (not cloud/SaaS narratives)
2. **Human-centered** — built around how people actually work (not around model capabilities)
3. **Engineered** — from real deployments and constraints (not vision-first, theatrical, speculative)
### Reasoning Patterns to Use
Structure explanations as:
- **Cause → Effect**
- **Constraint → Outcome**
- **Observation → Explanation**
- **Finite Scope → Concrete Result**
### CTA Guidance
- **Good**: "Map your first process", "See how it runs in your environment", "Review the architecture", "Get a working prototype in 48 hours"
- **Avoid**: "Unleash the power of AI", "Transform your business", "Don't miss out!", "Get started today!"
### Logo Usage
Logos live in \`public/logos/\` after install. See the brand skill for the full rules (clearspace, minimum sizes, what to avoid).
- **Full logo** (symbol + wordmark): \`gh-logo-positive-full-black.svg\` (light bg), \`gh-logo-white.svg\` (dark bg), \`gh-logo-offblack.svg\` (warm-neutral)
- **Symbol only**: \`gh-symbol-full-black.svg\`, \`gh-symbol-full-white.svg\` — only when Greyhaven recognition is already established
- **Product lockups**: \`greyproxy-positive.svg\`, \`greywall-positive.svg\`
- **Never**: change opacity, apply new colors, stretch, rotate, apply gradients/shadows, alter the symbol-to-wordmark ratio
### One-Line Test
Before writing a sentence, ask: *Would an engineer who understands the system read this and feel it's accurate, direct, and free of hype?* If not, rewrite.
`
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
const outDir = path.join(ROOT, 'skill')
fs.mkdirSync(outDir, { recursive: true })
// SKILL.md
const skill = generateSkill()
const skillPath = path.join(outDir, 'SKILL.md')
fs.writeFileSync(skillPath, skill, 'utf-8')
const skillLines = skill.split('\n').length
console.log(`skill/SKILL.md generated (${skillLines} lines, ${componentCount()} components)`)
// AGENTS.md (design system only — default)
const agent = generateAgent()
const agentPath = path.join(outDir, 'AGENTS.md')
fs.writeFileSync(agentPath, agent, 'utf-8')
const agentLines = agent.split('\n').length
console.log(`skill/AGENTS.md generated (${agentLines} lines, ${componentCount()} components)`)
// AGENTS.brand.md (design system + brand voice addendum — installed via --brand-skill)
const agentBrand = agent + '\n' + buildBrandAddendum()
const agentBrandPath = path.join(outDir, 'AGENTS.brand.md')
fs.writeFileSync(agentBrandPath, agentBrand, 'utf-8')
const agentBrandLines = agentBrand.split('\n').length
console.log(`skill/AGENTS.brand.md generated (${agentBrandLines} lines, ${componentCount()} components)`)
}
main()

View File

@@ -1,110 +0,0 @@
# Project Instructions
> **Auto-generated** by the Greyhaven Design System.
> Re-generate: `pnpm skill:build` in the design system repo.
>
> Copy this file to your project root as `AGENTS.md` (standard), `CLAUDE.md`,
> `.cursorrules`, or `.github/copilot-instructions.md` depending on your AI tool.
This project uses the **Greyhaven Design System**.
## Rules
- **ALWAYS use TypeScript** (`.tsx` / `.ts`). NEVER generate plain JavaScript (`.jsx` / `.js`).
- Use the `greyhaven` SKILL.md for full design system context (tokens, components, composition rules). It should be installed at `.claude/skills/greyhaven-design-system.md` or accessible to your AI tool.
- If the `greyhaven` MCP server is available, use its tools:
- `list_components()` to find the right component for a UI need
- `get_component(name)` to get exact props, variants, and usage examples
- `validate_colors(code)` to check code for off-brand colors
- `suggest_component(description)` to get recommendations
- Import components from `components/ui/` (or `@/components/ui/` with path alias)
- Never use raw hex colors -- use semantic Tailwind classes (`bg-primary`, `text-foreground`, `border-border`, etc.)
- Use `font-sans` (Aspekta) for UI elements: buttons, nav, labels, forms
- Use `font-serif` (Source Serif) for content: headings, body text
- Trust the design system's default component variants for accent -- they apply orange at the right scale. Don't apply `bg-primary` to large surfaces, containers, or section backgrounds
- All components are framework-agnostic React (no Next.js, no framework-specific imports)
- Dark mode is toggled via the `.dark` class -- use semantic tokens that adapt automatically
## Component Summary
38 components across 8 categories: primitives (11), layout (4), overlay (5), navigation (3), data (4), feedback (4), form (1), composition (6).
For full component specs, props, and examples, refer to the SKILL.md file or use the MCP `get_component(name)` tool.
## Key Patterns
- **CVA variants**: Components use `class-variance-authority` for variant props
- **Slot composition**: Components use `data-slot="name"` attributes
- **Class merging**: Always use `cn()` from `@/lib/utils` (clsx + tailwind-merge)
- **Focus rings**: `focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]`
- **Disabled**: `disabled:pointer-events-none disabled:opacity-50`
- **Card spacing**: `gap-6` between cards, `p-6` internal padding
- **Section rhythm**: `py-16` between major sections
- **Form layout**: Vertical stack with `gap-4`, labels above inputs
## Font Setup
If fonts aren't loaded yet, add to your global CSS:
```css
@font-face { font-family: 'Aspekta'; font-weight: 400; font-display: swap; src: url('/fonts/Aspekta-400.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-weight: 500; font-display: swap; src: url('/fonts/Aspekta-500.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-weight: 600; font-display: swap; src: url('/fonts/Aspekta-600.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-weight: 700; font-display: swap; src: url('/fonts/Aspekta-700.woff2') format('woff2'); }
```
---
## Brand Voice and Messaging
This project generates user-facing content (marketing copy, CTAs, landing pages, product explanations, emails) and MUST follow the Greyhaven brand voice.
### Brand Rules
- **Before writing any user-facing copy**, read the brand skill:
- Claude Code / compatible tools: `.claude/skills/greyhaven-brand.md` (full voice/tone/messaging reference)
- Or via MCP: call `get_brand_rules()` (or a specific section: `positioning`, `axes`, `tone`, `writing-rules`, `reasoning-patterns`, `cta`, `logo`, `self-check`)
- **Before shipping any user-facing copy**, validate it:
- Via MCP: call `validate_copy(text)` to lint for hype words, vague superlatives, urgency framing, and exclamation marks
- Or manually run the 8-item self-check list from the brand skill
### Core Voice (memorize)
- **Direct. Plain-spoken technical.** Write like an engineer who explains systems cleanly, without mystique or theatrics.
- **No** hype adjectives (`revolutionary`, `cutting-edge`, `seamless`, `game-changing`, `powerful`).
- **No** evangelism verbs (`unleash`, `transform`, `empower`, `supercharge`, `unlock`).
- **No** sales language, urgency framing, exclamation marks.
- **No** jargon for its own sake. Prefer plain words: "where the data goes" over "data paths"; "things the system relies on" over "dependencies".
- **Yes** specifics, causal reasoning, concrete outcomes.
### The Three Brand Axes
Copy must land on the correct side of each:
1. **Containment** — systems run inside the perimeter, nothing leaks (not cloud/SaaS narratives)
2. **Human-centered** — built around how people actually work (not around model capabilities)
3. **Engineered** — from real deployments and constraints (not vision-first, theatrical, speculative)
### Reasoning Patterns to Use
Structure explanations as:
- **Cause → Effect**
- **Constraint → Outcome**
- **Observation → Explanation**
- **Finite Scope → Concrete Result**
### CTA Guidance
- **Good**: "Map your first process", "See how it runs in your environment", "Review the architecture", "Get a working prototype in 48 hours"
- **Avoid**: "Unleash the power of AI", "Transform your business", "Don't miss out!", "Get started today!"
### Logo Usage
Logos live in `public/logos/` after install. See the brand skill for the full rules (clearspace, minimum sizes, what to avoid).
- **Full logo** (symbol + wordmark): `gh-logo-positive-full-black.svg` (light bg), `gh-logo-white.svg` (dark bg), `gh-logo-offblack.svg` (warm-neutral)
- **Symbol only**: `gh-symbol-full-black.svg`, `gh-symbol-full-white.svg` — only when Greyhaven recognition is already established
- **Product lockups**: `greyproxy-positive.svg`, `greywall-positive.svg`
- **Never**: change opacity, apply new colors, stretch, rotate, apply gradients/shadows, alter the symbol-to-wordmark ratio
### One-Line Test
Before writing a sentence, ask: *Would an engineer who understands the system read this and feel it's accurate, direct, and free of hype?* If not, rewrite.

View File

@@ -1,53 +0,0 @@
# Project Instructions
> **Auto-generated** by the Greyhaven Design System.
> Re-generate: `pnpm skill:build` in the design system repo.
>
> Copy this file to your project root as `AGENTS.md` (standard), `CLAUDE.md`,
> `.cursorrules`, or `.github/copilot-instructions.md` depending on your AI tool.
This project uses the **Greyhaven Design System**.
## Rules
- **ALWAYS use TypeScript** (`.tsx` / `.ts`). NEVER generate plain JavaScript (`.jsx` / `.js`).
- Use the `greyhaven` SKILL.md for full design system context (tokens, components, composition rules). It should be installed at `.claude/skills/greyhaven-design-system.md` or accessible to your AI tool.
- If the `greyhaven` MCP server is available, use its tools:
- `list_components()` to find the right component for a UI need
- `get_component(name)` to get exact props, variants, and usage examples
- `validate_colors(code)` to check code for off-brand colors
- `suggest_component(description)` to get recommendations
- Import components from `components/ui/` (or `@/components/ui/` with path alias)
- Never use raw hex colors -- use semantic Tailwind classes (`bg-primary`, `text-foreground`, `border-border`, etc.)
- Use `font-sans` (Aspekta) for UI elements: buttons, nav, labels, forms
- Use `font-serif` (Source Serif) for content: headings, body text
- Trust the design system's default component variants for accent -- they apply orange at the right scale. Don't apply `bg-primary` to large surfaces, containers, or section backgrounds
- All components are framework-agnostic React (no Next.js, no framework-specific imports)
- Dark mode is toggled via the `.dark` class -- use semantic tokens that adapt automatically
## Component Summary
38 components across 8 categories: primitives (11), layout (4), overlay (5), navigation (3), data (4), feedback (4), form (1), composition (6).
For full component specs, props, and examples, refer to the SKILL.md file or use the MCP `get_component(name)` tool.
## Key Patterns
- **CVA variants**: Components use `class-variance-authority` for variant props
- **Slot composition**: Components use `data-slot="name"` attributes
- **Class merging**: Always use `cn()` from `@/lib/utils` (clsx + tailwind-merge)
- **Focus rings**: `focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]`
- **Disabled**: `disabled:pointer-events-none disabled:opacity-50`
- **Card spacing**: `gap-6` between cards, `p-6` internal padding
- **Section rhythm**: `py-16` between major sections
- **Form layout**: Vertical stack with `gap-4`, labels above inputs
## Font Setup
If fonts aren't loaded yet, add to your global CSS:
```css
@font-face { font-family: 'Aspekta'; font-weight: 400; font-display: swap; src: url('/fonts/Aspekta-400.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-weight: 500; font-display: swap; src: url('/fonts/Aspekta-500.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-weight: 600; font-display: swap; src: url('/fonts/Aspekta-600.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-weight: 700; font-display: swap; src: url('/fonts/Aspekta-700.woff2') format('woff2'); }
```

View File

@@ -1,273 +0,0 @@
# Greyhaven Brand Voice & Messaging -- Claude Skill
> **Source of truth**: `vibedocs/greyhaven-brand-system.md` (Brand Guidelines v1.1)
>
> This skill applies when generating ANY user-facing content for Greyhaven:
> marketing copy, landing pages, CTAs, product descriptions, documentation,
> email, README intros, explanations of how the product works, or any prose
> that will be read by a human. It does NOT apply to internal code comments,
> commit messages, or technical logs.
---
## 1. The One-Line Test
Before writing any sentence, ask:
> Would an engineer who understands the system read this and feel it's accurate, direct, and free of hype?
If no, rewrite. That single test catches 90% of brand drift.
---
## 2. Core Positioning (memorize)
**Greyhaven builds custom, contained AI systems that run entirely inside the client's environment, shaped by real operational constraints and deployed under the client's control.**
**Short form**: *Local-first AI systems shaped by real work. Built where work happens. Contained end to end.*
Powered by Monadical's internal, open-source stack, hardened over eight years.
---
## 3. The Three Brand Axes
Every sentence, heading, or visual choice should land on the correct side of these three axes. When in doubt, use them to explain *why* something is wrong without relying on taste.
| Axis | Greyhaven is on this side | NOT this side |
|------|---------------------------|---------------|
| **Containment** | Systems run inside the perimeter. Nothing leaks. | Cloud/SaaS narratives. "Connected everywhere." |
| **Human-centered** | Built around how people actually work. | Built around model capabilities or vendor features. |
| **Engineered** | From real deployments, constraints, operator reality. | Vision-first, theatrical, speculative, futuristic. |
If copy drifts toward **exposure, performance, or model-led thinking → it doesn't fit**.
---
## 4. Tone of Voice
**Direct. Plain-spoken technical. Explains difficult things in simple terms.**
Greyhaven speaks like an engineer who understands how systems work and can describe them cleanly -- without mystique or theatrics.
- **No** jargon for its own sake
- **No** oversimplification
- **No** sales language
- **No** hype adjectives ("revolutionary", "cutting-edge", "seamless", "powerful", "game-changing")
- **No** evangelism ("unleash", "empower", "transform")
- **No** emotional leverage or fear-mongering
- **Yes** calm, precise, explanatory
- **Yes** mechanical facts
- **Yes** specifics over superlatives
- **Yes** authority through clarity, not volume
---
## 5. Writing Rules
### 5.1 Explain clearly. Don't perform.
The goal is clarity, not persuasion. Readers have different levels of technical know-how. Describe what happens inside the environment, how data flows, which dependencies matter, what boundaries exist. If something is complex, break it down without dumbing it down.
### 5.2 Plain-language engineering
Use everyday words for technical realities. If a simpler word communicates the same thing, use it.
| Instead of | Prefer |
|-----------|--------|
| "data paths" | "where the data goes" |
| "surfaces" | "places where exposure/risk can happen" |
| "dependencies" | "things the system relies on" |
| "isolation" | "kept separate from the outside" |
| "logs" | "records of what happened" |
| "handoffs" | "when one person/system passes something to another" |
| "leverage" | "use" |
| "leverage AI to..." | "the system uses AI to..." |
| "synergy" | (don't use) |
| "cutting-edge solution" | (don't use) |
| "transform your workflow" | describe what the system does instead |
Don't assume the reader knows technical shorthand. The reader should leave with a clearer mental model, not an impressed feeling.
### 5.3 Human-first in how we describe work
Start from what operators actually do -- steps, judgment calls, knowledge. Explain operator behaviors the same way you explain systems: concretely and without dramatization.
### 5.4 Security, stated without drama
Mechanical facts, not alarmism.
- **Good**: "Running inside the perimeter restores finite boundaries."
- **Bad**: "Protect your data from devastating breaches!"
State causal reasoning. No emotional leverage.
### 5.5 Quiet confidence
State specifics. No hype adjectives. No evangelism. Authority comes from clarity, not volume.
- **Good**: "A working, testable prototype delivered in 24-48 hours."
- **Bad**: "Lightning-fast, industry-leading AI delivery!"
---
## 6. Patterns for Reasoning
Use these four patterns to structure explanations. They express engineering logic: minimal wording, direct causality, observable/verifiable outcomes.
### Cause → Effect
> "When work relies on external AI services, every step -- inputs, outputs, logs, metadata -- becomes part of someone else's infrastructure."
### Constraint → Outcome
> "No external APIs and no data leaving the environment. The system remains contained, and the client keeps full operational and security control."
### Observation → Explanation
> "We sit with the operators, map the steps, and build a system that mirrors what actually happens."
### Finite Scope → Concrete Result
> "One process at a time. A working, testable prototype delivered in 24-48 hours."
---
## 7. CTA Guidance
Greyhaven CTAs should be concrete and engineering-flavored, not aspirational or urgent.
**Good CTA patterns**:
- "See how it runs in your environment"
- "Map your first process"
- "Review the architecture"
- "Read how it's deployed"
- "Get a working prototype in 48 hours"
**Avoid**:
- "Unlock the power of AI"
- "Transform your business today"
- "Don't miss out!"
- "Revolutionary AI solutions await"
- Urgency/scarcity framing ("limited time", "hurry", "act now")
- Hype verbs ("unleash", "supercharge", "revolutionize")
---
## 8. Driving Ideas (use these to self-check)
A sentence, heading, or design choice should feel like one of these:
> **(System-)aware · Applied · Adaptable · Unblocking · Safe-to-experiment · Contained · Durable · Iterative**
If it doesn't land on any of them, or lands somewhere else (flashy, theatrical, aspirational), rewrite.
---
## 9. Typography Approach (for written-content UI)
Hierarchy is built through **tonal shifts**, not decorative treatments.
- Primary points stay **dark and controlled** (foreground text)
- Supporting detail **moves lighter** (muted-foreground)
- The orange accent is **reserved** for parts that require immediate attention -- never decorative
Do NOT establish hierarchy through:
- Multiple contrasting typefaces
- Decorative styles (italics for emphasis, ALL CAPS for drama, oversized type for style)
- Color variety
DO establish hierarchy through:
- Weight differences within the same family (serif for content, sans for UI)
- Shade shifts between foreground, muted-foreground, and the orange accent
- Spatial rhythm (section padding, line-height)
This keeps the system quiet, structured, and readable.
---
## 10. Logo Usage
### Available files (in `public/logos/` after install)
| File | Use when |
|------|----------|
| `gh-logo-positive-full-black.svg` | Full logo (symbol + wordmark) on light backgrounds |
| `gh-logo-white.svg` | Full logo on dark backgrounds |
| `gh-logo-offblack.svg` | Full logo in off-black (#161614) for warm-neutral contexts |
| `gh-symbol-full-black.svg` | Symbol only, light bg (use when name recognition is already established) |
| `gh-symbol-full-white.svg` | Symbol only, dark bg |
| `greyproxy-positive.svg` | Greyproxy product logo (Greyhaven symbol + product wordmark) |
| `greywall-positive.svg` | Greywall product logo (Greyhaven symbol + product wordmark) |
### Rules
- **Structure**: The logo is **Symbol + Wordmark**. Keep them locked together in most contexts. Use the Symbol alone only when Greyhaven name recognition is already assured.
- **Clearspace**: Minimum 1× (one grid module of the symbol) on all sides. Nothing -- text, images, other graphics -- enters this zone.
- **Minimum sizes**:
- Wordmark lockup: 20mm print / 120px digital
- Standalone symbol: 8mm print / 14px digital (22px preferred)
### What to avoid (all of these are brand violations)
- Do NOT change opacity
- Do NOT apply new colors (black, white, off-black only -- per file)
- Do NOT stretch or alter proportions
- Do NOT apply gradients, shadows, glows, or other embellishments
- Do NOT rotate
- Do NOT change the lockup or alter symbol/wordmark relative scale
### Product logos
New Greyhaven products/demos reuse the Greyhaven symbol with the product wordmark in the same lockup pattern (see `greyproxy-positive.svg`, `greywall-positive.svg`). Typography for new wordmarks: Circular Medium. Do NOT invent a new symbol unless the product genuinely needs its own sub-identity.
---
## 11. Self-check Before Shipping Any Copy
Run the output through these checks:
1. ☐ Does it pass **The One-Line Test** (accurate, direct, no hype)?
2. ☐ Does it land on the correct side of all **three brand axes** (containment, human-centered, engineered)?
3. ☐ Did I use any **banned words** (unleash, transform, revolutionary, seamless, game-changing, cutting-edge, leverage, synergy, unlock)?
4. ☐ Is every claim **specific and verifiable**, or am I using vague superlatives?
5. ☐ Does the copy **explain how the thing works**, or just tell the reader how to feel about it?
6. ☐ Does it match a **reasoning pattern** (cause→effect, constraint→outcome, observation→explanation, finite scope→concrete result)?
7. ☐ Does it fit one of the **driving ideas** (system-aware, applied, adaptable, unblocking, safe-to-experiment, contained, durable, iterative)?
8. ☐ Is the orange accent used only where immediate attention is warranted, not as decoration?
If any box is unchecked, rewrite.
---
## 12. Quick Examples
### Bad vs. Good: Hero headline
| Bad | Good |
|-----|------|
| "Unleash the power of AI in your organization" | "AI systems that run inside your environment" |
| "Revolutionary cloud-native AI platform" | "Custom AI, contained end to end" |
| "Transform your workflows with next-gen AI" | "Map one process. Deploy a working prototype in 48 hours." |
### Bad vs. Good: Feature description
**Bad**:
> Our cutting-edge AI seamlessly integrates with your existing infrastructure to unlock unprecedented productivity gains.
**Good**:
> The system runs on the machines you already have. Data, models, and execution stay inside your perimeter. Nothing is sent to external APIs.
### Bad vs. Good: CTA
| Bad | Good |
|-----|------|
| "Get started today!" | "Map your first process" |
| "Try it free -- limited time!" | "Review the architecture" |
| "Unlock AI superpowers" | "See a 48-hour prototype" |
---
## 13. When You're Unsure
Default to:
1. **Fewer words**. Greyhaven copy is shorter than you expect.
2. **More specifics**. Numbers, concrete nouns, named constraints.
3. **Less enthusiasm**. No exclamation marks. No superlatives. No urgency.
4. **Describe the system, not the feeling**.

View File

@@ -1,750 +0,0 @@
# Greyhaven Design System -- Claude Skill
> **Auto-generated** by `scripts/generate-skill.ts` -- DO NOT EDIT by hand.
> Re-generate: `pnpm skill:build`
>
> **Components**: 38 | **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. **ALWAYS use TypeScript (.tsx/.ts) — never plain JavaScript.**
---
## Design Philosophy
- **TypeScript only**: All code MUST be written in TypeScript (`.tsx` / `.ts`). Never generate plain JavaScript (`.jsx` / `.js`).
- **Minimal and restrained**: Off-white + off-black + warm greys. One single accent color (orange). No gradients, no decorative color, no multiple accent hues.
- **Typography-driven**: Source Serif 4/Pro (serif) for headings and body content. Aspekta (sans, self-hosted) for UI labels, buttons, navigation, and form elements.
- **Calm, professional aesthetic**: Tight border-radii, subtle shadows, generous whitespace.
- **Accessibility-first**: Built on Radix UI primitives for keyboard navigation, focus management, screen reader support. Visible focus rings, disabled states, ARIA attributes.
- **Dark mode native**: Thoughtful dark theme using inverted warm greys. Orange accent persists across both modes. Toggled via `.dark` class.
- **Framework-agnostic**: Pure React + Radix + Tailwind. No Next.js, no framework-specific imports.
---
## Font Setup
This design system uses two typefaces:
| Role | Font | Usage |
|------|------|-------|
| **Sans (UI)** | Aspekta (self-hosted) | Buttons, nav, labels, forms, metadata |
| **Serif (Content)** | Source Serif 4/Pro | Headings, body text, reading content |
### Aspekta (required)
Aspekta font files live in `public/fonts/`. Add `@font-face` declarations to your global CSS:
```css
/* Minimum set (covers font-weight 400-700) */
@font-face { font-family: 'Aspekta'; font-weight: 400; font-display: swap; src: url('/fonts/Aspekta-400.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-weight: 500; font-display: swap; src: url('/fonts/Aspekta-500.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-weight: 600; font-display: swap; src: url('/fonts/Aspekta-600.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-weight: 700; font-display: swap; src: url('/fonts/Aspekta-700.woff2') format('woff2'); }
/* Or import all weights: */
@import url('/fonts/font-face.css');
```
### Font stack CSS variables
```css
--font-sans: 'Aspekta', ui-sans-serif, system-ui, sans-serif;
--font-serif: 'Source Serif 4', 'Source Serif Pro', Georgia, serif;
```
### Tailwind usage
- `font-sans` — Aspekta (UI elements)
- `font-serif` — Source Serif (content)
Install fonts via: `./skill/install.sh /path/to/your/project`
---
## Token Quick Reference
Source of truth: `tokens/*.json` (W3C DTCG format).
### Color
| Token | Value | Description |
|-------|-------|-------------|
| `color.primitive.off-white` | `#F9F9F7` | Primary light surface — cards, elevated areas |
| `color.primitive.off-black` | `#161614` | Primary dark — foreground text, dark mode background |
| `color.primitive.orange` | `#D95E2A` | Only accent color — used sparingly for primary actions and emphasis |
| `color.primitive.destructive-red` | `#B43232` | Error/danger states |
| `color.primitive.grey.1` | `#F0F0EC` | 5% — Subtle backgrounds, secondary, muted |
| `color.primitive.grey.2` | `#DDDDD7` | 10% — Accent hover, light borders |
| `color.primitive.grey.3` | `#C4C4BD` | 20% — Border, input |
| `color.primitive.grey.4` | `#A6A69F` | 50% — Mid-tone |
| `color.primitive.grey.5` | `#7F7F79` | 60% — Mid-dark |
| `color.primitive.grey.7` | `#575753` | 70% — Secondary foreground, muted foreground |
| `color.primitive.grey.8` | `#2F2F2C` | 80% — Dark mode card, dark surfaces |
| `color.semantic.background` | `{color.primitive.grey.1}` | Page background |
| `color.semantic.foreground` | `{color.primitive.off-black}` | Primary text |
| `color.semantic.card` | `{color.primitive.off-white}` | Card/elevated surface background |
| `color.semantic.card-foreground` | `{color.primitive.off-black}` | Card text |
| `color.semantic.popover` | `{color.primitive.off-white}` | Popover background |
| `color.semantic.popover-foreground` | `{color.primitive.off-black}` | Popover text |
| `color.semantic.primary` | `{color.primitive.orange}` | Primary accent — buttons, links, focus rings |
| `color.semantic.primary-foreground` | `{color.primitive.off-white}` | Text on primary accent |
| `color.semantic.secondary` | `{color.primitive.grey.1}` | Secondary button/surface |
| `color.semantic.secondary-foreground` | `{color.primitive.grey.8}` | Text on secondary surface |
| `color.semantic.muted` | `{color.primitive.grey.1}` | Muted/subdued background |
| `color.semantic.muted-foreground` | `{color.primitive.grey.7}` | Muted/subdued text |
| `color.semantic.accent` | `{color.primitive.grey.2}` | Subtle hover state |
| `color.semantic.accent-foreground` | `{color.primitive.off-black}` | Text on accent hover |
| `color.semantic.destructive` | `{color.primitive.destructive-red}` | Destructive/error actions |
| `color.semantic.destructive-foreground` | `{color.primitive.off-white}` | Text on destructive |
| `color.semantic.border` | `{color.primitive.grey.3}` | Default border |
| `color.semantic.input` | `{color.primitive.grey.3}` | Input border |
| `color.semantic.ring` | `{color.primitive.orange}` | Focus ring |
| `color.semantic.hero-bg` | `{color.primitive.grey.2}` | Hero banner background |
| `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.8}` | Dark muted — grey.8 (distinct from grey.7 border so outlines on bg-muted surfaces remain visible) |
| `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.hero-bg` | `{color.primitive.grey.8}` | Dark hero banner background |
| `color.dark.chart.1` | `{color.primitive.orange}` | Dark chart accent |
| `color.dark.chart.2` | `{color.primitive.grey.3}` | Dark chart secondary |
| `color.dark.chart.3` | `{color.primitive.grey.4}` | Dark chart tertiary |
| `color.dark.chart.4` | `{color.primitive.grey.5}` | Dark chart quaternary |
| `color.dark.chart.5` | `{color.primitive.grey.1}` | Dark chart quinary |
| `color.dark.sidebar.background` | `{color.primitive.grey.8}` | Dark sidebar background |
| `color.dark.sidebar.foreground` | `{color.primitive.off-white}` | Dark sidebar text |
| `color.dark.sidebar.primary` | `{color.primitive.orange}` | Dark sidebar primary |
| `color.dark.sidebar.primary-foreground` | `{color.primitive.off-white}` | Dark sidebar primary text |
| `color.dark.sidebar.accent` | `{color.primitive.grey.7}` | Dark sidebar accent |
| `color.dark.sidebar.accent-foreground` | `{color.primitive.off-white}` | Dark sidebar accent text |
| `color.dark.sidebar.border` | `{color.primitive.grey.7}` | Dark sidebar border |
| `color.dark.sidebar.ring` | `{color.primitive.orange}` | Dark sidebar ring |
### Typography
| Token | Value | Description |
|-------|-------|-------------|
| `typography.fontFamily.sans` | `["Aspekta","ui-sans-serif","system-ui","sans-serif"]` | UI labels, buttons, nav, forms — Aspekta self-hosted |
| `typography.fontFamily.serif` | `["Source Serif 4","Source Serif Pro","Georgia","serif"]` | Headings, body content, reading — Source Serif primary |
| `typography.fontFamily.mono` | `["ui-monospace","SFMono-Regular","Menlo","Monaco","Consolas","monospace"]` | Code blocks and monospaced content |
| `typography.fontSize.xs` | `0.75rem` | 12px — metadata, fine print |
| `typography.fontSize.sm` | `0.875rem` | 14px — captions, nav, labels, buttons |
| `typography.fontSize.base` | `1rem` | 16px — body text |
| `typography.fontSize.lg` | `1.125rem` | 18px — large body, subtitles |
| `typography.fontSize.xl` | `1.25rem` | 20px — H3 |
| `typography.fontSize.2xl` | `1.5rem` | 24px — H2 |
| `typography.fontSize.3xl` | `1.875rem` | 30px — large H2 |
| `typography.fontSize.4xl` | `2.25rem` | 36px — H1 |
| `typography.fontSize.5xl` | `3rem` | 48px — hero heading |
| `typography.fontWeight.normal` | `400` | Regular body text |
| `typography.fontWeight.medium` | `500` | H3, labels, nav items |
| `typography.fontWeight.semibold` | `600` | H1, H2, buttons |
| `typography.fontWeight.bold` | `700` | Strong emphasis |
| `typography.lineHeight.tight` | `1.25` | Headings |
| `typography.lineHeight.normal` | `1.5` | Default |
| `typography.lineHeight.relaxed` | `1.625` | Body content for readability |
| `typography.letterSpacing.tight` | `-0.025em` | Headings — tracking-tight |
| `typography.letterSpacing.normal` | `0em` | Body text |
| `typography.letterSpacing.wide` | `0.05em` | Uppercase labels |
### Spacing
| Token | Value | Description |
|-------|-------|-------------|
| `spacing.0` | `0` | None |
| `spacing.1` | `0.25rem` | 4px — tight gaps |
| `spacing.2` | `0.5rem` | 8px — card header gap, form description spacing |
| `spacing.3` | `0.75rem` | 12px |
| `spacing.4` | `1rem` | 16px — form field gap, button padding |
| `spacing.5` | `1.25rem` | 20px |
| `spacing.6` | `1.5rem` | 24px — card padding, card internal gap |
| `spacing.8` | `2rem` | 32px — section margin-bottom |
| `spacing.10` | `2.5rem` | 40px |
| `spacing.12` | `3rem` | 48px |
| `spacing.16` | `4rem` | 64px — major section padding (py-16) |
| `spacing.20` | `5rem` | 80px |
| `spacing.24` | `6rem` | 96px — hero padding |
| `spacing.0.5` | `0.125rem` | 2px — micro spacing |
| `spacing.1.5` | `0.375rem` | 6px |
| `spacing.component.card-padding` | `1.5rem` | Card internal padding (px-6) |
| `spacing.component.card-gap` | `1.5rem` | Gap between cards (gap-6) |
| `spacing.component.section-padding` | `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) |
### Radii
| Token | Value | Description |
|-------|-------|-------------|
| `radii.base` | `0.375rem` | 6px — base radius |
| `radii.sm` | `calc(0.375rem - 2px)` | 4px — small variant |
| `radii.md` | `0.375rem` | 6px — medium (same as base) |
| `radii.lg` | `calc(0.375rem + 2px)` | 8px — large variant |
| `radii.xl` | `calc(0.375rem + 4px)` | 10px — extra large variant (cards) |
| `radii.full` | `9999px` | Fully round (pills, avatars) |
### Shadows
| Token | Value | Description |
|-------|-------|-------------|
| `shadow.xs` | `{"offsetX":"0","offsetY":"1px","blur":"2px","spread":"0","color":"rgba(22, 22, 20, 0.05)"}` | Subtle shadow for buttons, inputs |
| `shadow.sm` | `{"offsetX":"0","offsetY":"1px","blur":"3px","spread":"0","color":"rgba(22, 22, 20, 0.1)"}` | Small shadow for cards |
| `shadow.md` | `{"offsetX":"0","offsetY":"4px","blur":"6px","spread":"-1px","color":"rgba(22, 22, 20, 0.1)"}` | Medium shadow for dropdowns, popovers |
| `shadow.lg` | `{"offsetX":"0","offsetY":"10px","blur":"15px","spread":"-3px","color":"rgba(22, 22, 20, 0.1)"}` | Large shadow for dialogs, modals |
### Motion
| Token | Value | Description |
|-------|-------|-------------|
| `motion.duration.fast` | `150ms` | Quick transitions — tooltips, hover states |
| `motion.duration.normal` | `200ms` | Default transitions — most UI interactions |
| `motion.duration.slow` | `300ms` | Deliberate transitions — modals, drawers, accordions |
| `motion.easing.default` | `[0.4,0,0.2,1]` | Standard ease-in-out |
| `motion.easing.in` | `[0.4,0,1,1]` | Ease-in for exits |
| `motion.easing.out` | `[0,0,0.2,1]` | Ease-out for entrances |
---
## Component Catalog (38 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: 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**:
```tsx
<Badge variant="success">Active</Badge>
<Badge variant="secondary" size="sm">3 items</Badge>
<Badge variant="default" size="lg">New feature</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>
```
#### Code
- **File**: `components/ui/code.tsx`
- **Exports**: `Code`, `codeVariants`
- **Description**: Inline or block code snippet. Always use this instead of hand-rolling <code>/<pre> styling. Uses bg-muted + border-border so the outline stays visible in both light and dark modes. Block variant auto-wraps in <pre> for whitespace preservation and break-all for long commands.
- **Props**: `variant?: "inline" | "block"; language?: string (optional, for future syntax highlighting)`
- **Example**:
```tsx
<p>Install with <Code>pnpm install</Code>.</p>
<Code variant="block" language="bash">{`pnpm install
pnpm dev`}</Code>
```
### 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-[65px]. Off-white bg (light) / off-black (dark). Font-semibold menu items. Hover: opacity-70 (no bg). Active links: orange (text-primary), full opacity. Variants: solid, transparent, minimal. Logo left, nav center, actions right. Mobile hamburger.
- **Props**: `variant?: "solid" | "transparent" | "minimal"; logo?: ReactNode; actions?: ReactNode. NavbarLink: active?: boolean`
- **Example**:
```tsx
<Navbar variant="solid" logo={<Logo size="sm" />}><NavbarLink href="/" active>Home</NavbarLink><NavbarLink href="/about">About</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-10 internal padding. Colored variants (highlighted, accent) get my-8 vertical margin so they visually detach from adjacent sections; default has no margin so same-bg siblings flow seamlessly.
- **Props**: `variant?: "default" | "highlighted" | "accent"; width?: "narrow" | "default" | "wide" | "full"; title?: string; description?: string`
- **Example**:
```tsx
<Section title="Features" description="What we offer" width="wide">Content</Section>
```
#### Footer
- **File**: `components/ui/footer.tsx`
- **Exports**: `Footer`, `footerVariants`
- **Description**: Page footer. Minimal (single row) or full (multi-column with link groups).
- **Props**: `variant?: "minimal" | "full"; logo?: ReactNode; copyright?: ReactNode; linkGroups?: FooterLinkGroup[]; actions?: ReactNode`
- **Example**:
```tsx
<Footer variant="minimal" copyright="&copy; 2024 Greyhaven" />
```
#### PageLayout
- **File**: `components/ui/page-layout.tsx`
- **Exports**: `PageLayout`
- **Description**: Full page shell composing Navbar + main content + optional sidebar + Footer. Auto-offsets for fixed navbar.
- **Props**: `navbar?: ReactNode; sidebar?: ReactNode; footer?: ReactNode`
- **Example**:
```tsx
<PageLayout navbar={<Navbar />} footer={<Footer />}>Main content</PageLayout>
```
---
## Composition Rules
- **Never override component sizing via `className`**: Each component exposes `size` / `variant` props for a reason. Reach for those first. Overriding font-size, padding, or height with arbitrary Tailwind classes (`text-sm`, `px-3`, `py-1`, etc.) fragments the design system. If no variant fits, add a new `size`/`variant` to the component — don't one-off patch it at the call site.
- **Minimum font size is `text-xs` (12px)**: Anything smaller fails accessibility/readability minimums. If you genuinely need smaller text for a specific reason (e.g., a data-dense legend), add an explicit `// justification: ...` comment at the call site. Default answer is: use `text-xs`.
- **Card spacing**: `gap-6` between cards, `p-6` internal padding
- **Section rhythm**: `py-10` internal padding per section. Colored sections add `my-8` to detach from neighbors
- **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**: Trust the default component variants for orange accent -- they apply it at the right scale. Don't apply `bg-primary` to large surfaces, containers, or section backgrounds
- **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`
---
## HTMX / Server-Rendered Usage
For projects that cannot use React (HTMX, Django templates, Rails ERB, Go `html/template`, Astro SSR, etc.), the design system ships a framework-agnostic CSS layer: `dist/greyhaven.htmx.css`.
### What it is
An auto-generated stylesheet derived from `components/ui/*.tsx`. Every `data-slot` attribute gets a `@layer components` rule. `cva` variants become attribute selectors (`[data-variant=...]`, `[data-size=...]`). Default variants apply via `:not([data-variant])` so consumers can omit the attribute.
### Usage
1. Install: `./skill/install.sh /path/to/project --htmx-css`
2. Import in your Tailwind v4 input CSS: `@import "./greyhaven.htmx.css";`
3. Emit HTML with `data-slot` / `data-variant` / `data-size` attributes:
```html
<div data-slot="card">
<div data-slot="card-header">
<div data-slot="card-title">Title</div>
<div data-slot="card-description">Description</div>
</div>
<div data-slot="card-content">Body</div>
</div>
<button data-slot="button" data-variant="default">Save</button>
<button data-slot="button" data-variant="outline" data-size="sm">Cancel</button>
<span data-slot="badge" data-variant="success">Active</span>
```
### Scope
- **Fully static** (pure CSS, no JS): Card, Button, Badge, Input, Label, Textarea, Table, Separator, Code, Kbd, Progress, Avatar, Skeleton, Alert, Pagination, Breadcrumb, Navbar (solid variant), Spinner, AspectRatio, Empty, Hero, Section, Footer, CtaSection, ButtonGroup, InputGroup, Toast.
- **Visual-only** (CSS is correct but needs your own state JS): Dialog, Dropdown, Popover, Select, Combobox, Accordion, Tabs, Tooltip, Drawer, Sheet, Sidebar, Collapsible, NavigationMenu, Menubar, ContextMenu, HoverCard, Command, AlertDialog, InputOtp, Carousel. Pair with Alpine.js (`x-data`, `x-show`, `@click`) or native HTML primitives (`<dialog>`, `<details>`).
### Regenerate
```bash
pnpm htmx-css:build
```
---
## Extension Protocol
When adding new components to the system:
1. **Use CVA** for variants (`class-variance-authority`)
2. **Accept HTML element props** via spread: `React.ComponentProps<'div'>`
3. **Use `data-slot`** attribute: `data-slot="component-name"`
4. **Use `cn()`** from `@/lib/utils` for class merging
5. **Follow focus/disabled/aria patterns** from existing components
6. **Use semantic tokens only** -- never raw hex colors
7. **Support `asChild`** via `@radix-ui/react-slot` for polymorphism where appropriate
8. **Add to Storybook** with `tags: ['autodocs']` and all variant stories
9. **Add to `lib/catalog.ts`** so MCP server and SKILL.md pick it up automatically
10. **Run `pnpm skill:build`** to regenerate this file
### Template
```tsx
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const myComponentVariants = cva('base-classes', {
variants: {
variant: { default: 'default-classes' },
size: { default: 'size-classes' },
},
defaultVariants: { variant: 'default', size: 'default' },
})
function MyComponent({
className, variant, size, ...props
}: React.ComponentProps<'div'> & VariantProps<typeof myComponentVariants>) {
return (
<div
data-slot="my-component"
className={cn(myComponentVariants({ variant, size, className }))}
{...props}
/>
)
}
export { MyComponent, myComponentVariants }
```

View File

@@ -1,269 +0,0 @@
#!/usr/bin/env bash
# install.sh — Install the Greyhaven Design System into a consuming project.
#
# Copies (does NOT symlink) files so the consuming project owns its copies.
# Re-run this script to pull updated versions after design system changes.
#
# What it does (by default):
# 1. Copies SKILL.md into .claude/skills/ (for Claude Code)
# 2. Copies AGENTS.md into the project root (standard convention)
# 3. Copies Aspekta font files + font-face.css into public/fonts/
# 4. Prints CSS import + MCP setup instructions
#
# With --brand-skill:
# 5. Copies BRAND.md into .claude/skills/ (voice, tone, messaging rules)
# 6. Copies Greyhaven logo SVGs into public/logos/
#
# Usage:
# ./skill/install.sh /path/to/your/project
# ./skill/install.sh /path/to/your/project --brand-skill
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SKILL_FILE="${SCRIPT_DIR}/SKILL.md"
AGENTS_FILE="${SCRIPT_DIR}/AGENTS.md"
AGENTS_BRAND_FILE="${SCRIPT_DIR}/AGENTS.brand.md"
BRAND_FILE="${SCRIPT_DIR}/BRAND.md"
FONTS_DIR="${REPO_ROOT}/public/fonts"
PUBLIC_DIR="${REPO_ROOT}/public"
HTMX_CSS_FILE="${REPO_ROOT}/dist/greyhaven.htmx.css"
# Parse arguments
TARGET_PROJECT=""
INSTALL_BRAND=false
INSTALL_HTMX_CSS=false
while [ $# -gt 0 ]; do
case "$1" in
--brand-skill)
INSTALL_BRAND=true
shift
;;
--htmx-css)
INSTALL_HTMX_CSS=true
shift
;;
-h|--help)
echo "Usage: $0 <target-project-directory> [--brand-skill] [--htmx-css]"
echo ""
echo "Options:"
echo " --brand-skill Also install BRAND.md (voice/tone/messaging) and logo SVGs"
echo " --htmx-css Also install greyhaven.htmx.css (framework-agnostic CSS layer"
echo " for HTMX / server-rendered projects that can't use React)"
echo ""
echo "Examples:"
echo " $0 /path/to/my-app"
echo " $0 /path/to/my-app --brand-skill"
echo " $0 /path/to/my-app --htmx-css"
echo " $0 . --brand-skill --htmx-css"
exit 0
;;
*)
if [ -z "$TARGET_PROJECT" ]; then
TARGET_PROJECT="$1"
else
echo "Error: Unexpected argument: $1"
echo "Run '$0 --help' for usage."
exit 1
fi
shift
;;
esac
done
if [ -z "$TARGET_PROJECT" ]; then
echo "Usage: $0 <target-project-directory> [--brand-skill] [--htmx-css]"
echo "Run '$0 --help' for details."
exit 1
fi
if [ ! -d "$TARGET_PROJECT" ]; then
echo "Error: Directory not found: ${TARGET_PROJECT}"
exit 1
fi
TARGET_PROJECT="$(cd "$TARGET_PROJECT" && pwd)"
# Helper: backup existing file/symlink and copy new one
copy_with_backup() {
local src="$1"
local dst="$2"
if [ -L "$dst" ]; then
rm "$dst"
elif [ -f "$dst" ]; then
mv "$dst" "${dst}.bak"
echo " (backed up existing file to $(basename "${dst}.bak"))"
fi
cp "$src" "$dst"
}
echo "Installing Greyhaven Design System into ${TARGET_PROJECT}"
if [ "$INSTALL_BRAND" = true ]; then
echo " (with --brand-skill: BRAND.md + logos)"
fi
echo ""
# ── 1. SKILL.md ────────────────────────────────────────────────────────────
if [ -f "$SKILL_FILE" ]; then
SKILLS_DIR="${TARGET_PROJECT}/.claude/skills"
mkdir -p "$SKILLS_DIR"
DST="${SKILLS_DIR}/greyhaven-design-system.md"
copy_with_backup "$SKILL_FILE" "$DST"
echo "[ok] SKILL.md: ${DST}"
else
echo "[skip] SKILL.md not found — run 'pnpm skill:build' first"
fi
# ── 2. AGENTS.md ───────────────────────────────────────────────────────────
# Pick the brand-augmented variant if --brand-skill is passed, so agents
# working in the consuming project know how to use the brand skill + MCP tools.
if [ "$INSTALL_BRAND" = true ] && [ -f "$AGENTS_BRAND_FILE" ]; then
AGENTS_SRC="$AGENTS_BRAND_FILE"
AGENTS_LABEL="AGENTS.md (with brand voice addendum)"
else
AGENTS_SRC="$AGENTS_FILE"
AGENTS_LABEL="AGENTS.md"
fi
if [ -f "$AGENTS_SRC" ]; then
DST="${TARGET_PROJECT}/AGENTS.md"
copy_with_backup "$AGENTS_SRC" "$DST"
echo "[ok] ${AGENTS_LABEL}: ${DST}"
else
echo "[skip] AGENTS source not found — run 'pnpm skill:build' first"
fi
# ── 3. Fonts ───────────────────────────────────────────────────────────────
if [ -d "$FONTS_DIR" ]; then
TARGET_FONTS="${TARGET_PROJECT}/public/fonts"
mkdir -p "$TARGET_FONTS"
copied=0
for f in "$FONTS_DIR"/Aspekta-*.woff2; do
[ -f "$f" ] || continue
cp "$f" "$TARGET_FONTS/"
copied=$((copied + 1))
done
if [ -f "$FONTS_DIR/font-face.css" ]; then
cp "$FONTS_DIR/font-face.css" "$TARGET_FONTS/"
fi
echo "[ok] Fonts: ${copied} Aspekta woff2 files copied to ${TARGET_FONTS}/"
else
echo "[skip] Fonts dir not found at ${FONTS_DIR}"
fi
# ── 4. Brand skill (opt-in via --brand-skill) ──────────────────────────────
if [ "$INSTALL_BRAND" = true ]; then
# 4a. BRAND.md into .claude/skills/
if [ -f "$BRAND_FILE" ]; then
SKILLS_DIR="${TARGET_PROJECT}/.claude/skills"
mkdir -p "$SKILLS_DIR"
DST="${SKILLS_DIR}/greyhaven-brand.md"
copy_with_backup "$BRAND_FILE" "$DST"
echo "[ok] BRAND.md: ${DST}"
else
echo "[skip] BRAND.md not found at ${BRAND_FILE}"
fi
# 4b. Logo SVGs into public/logos/
TARGET_LOGOS="${TARGET_PROJECT}/public/logos"
mkdir -p "$TARGET_LOGOS"
logo_files=(
"gh - logo - positive - full black.svg"
"gh - logo - white.svg"
"gh - logo - offblack.svg"
"gh - symbol - full black.svg"
"gh - symbol - full white.svg"
"greyproxy - positive.svg"
"greywall - positive.svg"
)
# Rename files on copy to remove spaces — better for web paths
copied=0
for f in "${logo_files[@]}"; do
src="${PUBLIC_DIR}/${f}"
if [ -f "$src" ]; then
# "gh - logo - positive - full black.svg" → "gh-logo-positive-full-black.svg"
clean_name=$(echo "$f" | sed 's/ - /-/g; s/ /-/g')
cp "$src" "${TARGET_LOGOS}/${clean_name}"
copied=$((copied + 1))
fi
done
echo "[ok] Logos: ${copied} SVGs copied to ${TARGET_LOGOS}/ (renamed: spaces → dashes)"
fi
# ── 5. HTMX CSS (opt-in via --htmx-css) ────────────────────────────────────
if [ "$INSTALL_HTMX_CSS" = true ]; then
if [ -f "$HTMX_CSS_FILE" ]; then
TARGET_CSS_DIR="${TARGET_PROJECT}/public/css"
mkdir -p "$TARGET_CSS_DIR"
DST="${TARGET_CSS_DIR}/greyhaven.htmx.css"
copy_with_backup "$HTMX_CSS_FILE" "$DST"
echo "[ok] HTMX CSS: ${DST}"
else
echo "[skip] greyhaven.htmx.css not found at ${HTMX_CSS_FILE}"
echo " Run 'pnpm htmx-css:build' in the design system repo first."
fi
fi
# ── Next steps ─────────────────────────────────────────────────────────────
cat <<'EOF'
Done!
─── Next steps ────────────────────────────────────────────────────────────
1. Add Aspekta @font-face to your global CSS:
@font-face { font-family: 'Aspekta'; font-weight: 400; font-display: swap; src: url('/fonts/Aspekta-400.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-weight: 500; font-display: swap; src: url('/fonts/Aspekta-500.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-weight: 600; font-display: swap; src: url('/fonts/Aspekta-600.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-weight: 700; font-display: swap; src: url('/fonts/Aspekta-700.woff2') format('woff2'); }
(Or import the full set: @import url('/fonts/font-face.css');)
And set the font stack:
--font-sans: 'Aspekta', ui-sans-serif, system-ui, sans-serif;
2. If you installed --htmx-css, import it from your Tailwind v4 input CSS:
@import "tailwindcss";
@import "./tokens-light.css";
@import "./tokens-dark.css";
@import "./greyhaven.htmx.css"; /* ← component @layer rules */
Then consume via data-slot attributes in your HTML / Go templates / Jinja:
<div data-slot="card">
<div data-slot="card-header"><div data-slot="card-title">Hello</div></div>
<div data-slot="card-content">Body</div>
</div>
<button data-slot="button" data-variant="default">Save</button>
<span data-slot="badge" data-variant="success">Active</span>
Interactive components (dialog, dropdown, popover, etc.) emit their static
visual CSS only — supply your own open/close JS (Alpine.js pairs well
with HTMX).
3. (Optional) Register the Greyhaven MCP server. Create .mcp.json in your
project root:
{
"mcpServers": {
"greyhaven": {
"command": "npx",
"args": ["tsx", "<ABSOLUTE_PATH_TO_GREYHAVEN_REPO>/mcp/server.ts"]
}
}
}
4. Re-run this script after design system updates to refresh your copies.
EOF

View File

@@ -1,111 +0,0 @@
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

@@ -1,92 +0,0 @@
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

@@ -1,111 +0,0 @@
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

@@ -1,167 +0,0 @@
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

@@ -1,135 +0,0 @@
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

@@ -1,88 +0,0 @@
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

@@ -1,117 +0,0 @@
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

@@ -1,71 +0,0 @@
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

@@ -1,63 +0,0 @@
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

@@ -1,54 +0,0 @@
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

@@ -1,211 +0,0 @@
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

@@ -1,87 +0,0 @@
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

@@ -1,97 +0,0 @@
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>
),
}

Some files were not shown because too many files have changed in this diff Show More