Compare commits
14 Commits
update-des
...
mathieu/ht
| Author | SHA1 | Date | |
|---|---|---|---|
| 90930d8f78 | |||
| 928fdd8f75 | |||
| 72448f36d9 | |||
|
|
18c15a6437 | ||
|
|
9a311ff9a5 | ||
|
|
ae3d219d58 | ||
|
|
64d7df1439 | ||
|
|
b8168261a1 | ||
|
|
c4cc5a78b9 | ||
|
|
c1d4d55333 | ||
|
|
f7acb63782 | ||
|
|
1d80882647 | ||
|
|
c9209a6271 | ||
|
|
c3215945f2 |
5
.gitignore
vendored
@@ -25,3 +25,8 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
# llms
|
||||
vibedocs/*
|
||||
|
||||
17
.storybook/main.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { StorybookConfig } from '@storybook/nextjs-vite';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: [
|
||||
'../stories/**/*.mdx',
|
||||
'../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)',
|
||||
],
|
||||
addons: [
|
||||
'@storybook/addon-docs',
|
||||
'@storybook/addon-a11y',
|
||||
'@chromatic-com/storybook',
|
||||
],
|
||||
framework: '@storybook/nextjs-vite',
|
||||
staticDirs: ['../public'],
|
||||
};
|
||||
|
||||
export default config;
|
||||
44
.storybook/preview.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { Preview } from '@storybook/nextjs-vite'
|
||||
import '../app/globals.css'
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
a11y: {
|
||||
test: 'todo',
|
||||
},
|
||||
backgrounds: { disable: true },
|
||||
},
|
||||
globalTypes: {
|
||||
theme: {
|
||||
description: 'Theme',
|
||||
toolbar: {
|
||||
title: 'Theme',
|
||||
icon: 'paintbrush',
|
||||
items: [
|
||||
{ value: 'light', title: 'Light', icon: 'sun' },
|
||||
{ value: 'dark', title: 'Dark', icon: 'moon' },
|
||||
],
|
||||
dynamicTitle: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
initialGlobals: {
|
||||
theme: 'light',
|
||||
},
|
||||
decorators: [
|
||||
(Story, context) => {
|
||||
const theme = context.globals.theme || 'light'
|
||||
document.documentElement.classList.remove('light', 'dark')
|
||||
document.documentElement.classList.add(theme)
|
||||
return Story()
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default preview
|
||||
179
GAPS.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# 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
@@ -1,47 +1,346 @@
|
||||
# Greyhaven Design System
|
||||
|
||||
A modern design system built with Next.js, shadcn/ui, and Radix UI primitives.
|
||||
A framework-agnostic React component library built on Radix UI, Tailwind CSS v4, and shadcn/ui patterns. Designed for LLM consumption with a Claude Skill, MCP server, and Storybook documentation.
|
||||
|
||||

|
||||
|
||||
## Getting Started
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Start development server
|
||||
pnpm dev
|
||||
|
||||
# Build for production
|
||||
pnpm build
|
||||
|
||||
# Start production server
|
||||
pnpm start
|
||||
pnpm install # Install dependencies
|
||||
pnpm dev # Start showcase dev server (Next.js)
|
||||
pnpm build # Tokens + SKILL.md + production build
|
||||
pnpm storybook # Component catalog on http://localhost:6006
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
greyhaven-design-system/
|
||||
├── app/ # Next.js app directory
|
||||
│ ├── layout.tsx # Root layout with fonts
|
||||
│ ├── page.tsx # Design system showcase
|
||||
│ └── globals.css # Global styles
|
||||
├── components/
|
||||
│ ├── ui/ # Reusable UI components (57 components)
|
||||
│ ├── design-system/ # Showcase components
|
||||
│ └── theme-provider.tsx # Theme context
|
||||
├── hooks/ # Custom React hooks
|
||||
├── components/ui/ # 37+ framework-agnostic React components
|
||||
├── tokens/ # W3C DTCG design tokens (source of truth)
|
||||
│ ├── color.json
|
||||
│ ├── typography.json
|
||||
│ ├── spacing.json
|
||||
│ ├── radii.json
|
||||
│ ├── shadows.json
|
||||
│ └── motion.json
|
||||
├── skill/ # AI skills
|
||||
│ ├── SKILL.md # Design system reference (auto-generated)
|
||||
│ ├── AGENTS.md # Project instructions (auto-generated)
|
||||
│ ├── BRAND.md # Voice/tone/messaging (hand-curated, opt-in)
|
||||
│ └── install.sh # Installer (supports --brand-skill flag)
|
||||
├── mcp/ # MCP server for AI agents
|
||||
│ └── server.ts
|
||||
├── stories/ # Storybook stories (23 files, 8 categories)
|
||||
├── lib/
|
||||
│ └── utils.ts # Utility functions
|
||||
├── styles/ # Additional styles
|
||||
└── public/ # Static assets
|
||||
│ ├── utils.ts # cn() utility
|
||||
│ └── catalog.ts # Shared component catalog (used by MCP + SKILL.md)
|
||||
├── scripts/
|
||||
│ ├── generate-skill.ts # SKILL.md generator
|
||||
│ └── 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
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework:** Next.js 16, React 19, TypeScript
|
||||
- **Styling:** Tailwind CSS 4, shadcn/ui, Radix UI
|
||||
- **Forms:** React Hook Form, Zod
|
||||
- **Theming:** next-themes (light/dark mode)
|
||||
- **Components**: React 19, Radix UI, Tailwind CSS 4, CVA, tailwind-merge, clsx
|
||||
- **Icons**: Lucide React
|
||||
- **Forms**: React Hook Form + Zod
|
||||
- **Tokens**: Style Dictionary v4 (W3C DTCG format)
|
||||
- **Docs**: Storybook 10, auto-generated SKILL.md
|
||||
- **AI Integration**: MCP server, Claude Skill
|
||||
|
||||
> **Framework-agnostic**: Components have zero Next.js imports. They work with Vite, Remix, Astro, CRA, or any React framework.
|
||||
|
||||
> **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 |
|
||||
|
||||
150
app/globals.css
@@ -1,11 +1,27 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
@import './tokens/tokens-light.css';
|
||||
@import './tokens/tokens-dark.css';
|
||||
|
||||
/* Aspekta — self-hosted sans font (canonical UI typeface) */
|
||||
@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 100; font-display: swap; src: url('/fonts/Aspekta-100.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 200; font-display: swap; src: url('/fonts/Aspekta-200.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 300; font-display: swap; src: url('/fonts/Aspekta-300.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 400; font-display: swap; src: url('/fonts/Aspekta-400.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 500; font-display: swap; src: url('/fonts/Aspekta-500.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 600; font-display: swap; src: url('/fonts/Aspekta-600.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 700; font-display: swap; src: url('/fonts/Aspekta-700.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 800; font-display: swap; src: url('/fonts/Aspekta-800.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 900; font-display: swap; src: url('/fonts/Aspekta-900.woff2') format('woff2'); }
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* =============================================================================
|
||||
GREYHAVEN DESIGN TOKENS
|
||||
Based on Greyhaven Brand Guidelines v1.0
|
||||
|
||||
Token values are auto-generated by Style Dictionary from tokens/*.json
|
||||
(W3C DTCG format). DO NOT hand-edit :root or .dark — edit the source
|
||||
token files and run `pnpm tokens:build` instead.
|
||||
|
||||
Color Philosophy:
|
||||
- Neutral and minimal scheme
|
||||
@@ -14,142 +30,23 @@
|
||||
- No gradients, no decorative color
|
||||
============================================================================= */
|
||||
|
||||
/* Radius — not color, kept inline */
|
||||
:root {
|
||||
/* Background & Foreground */
|
||||
--background: 240 240 236; /* Grey 1 #F0F0EC */
|
||||
--foreground: 22 22 20; /* Off-black #161614 */
|
||||
|
||||
/* Card */
|
||||
--card: 249 249 247; /* Off-white #F9F9F7 - elevated from background */
|
||||
--card-foreground: 22 22 20;
|
||||
|
||||
/* Popover */
|
||||
--popover: 249 249 247;
|
||||
--popover-foreground: 22 22 20;
|
||||
|
||||
/* Primary - Orange accent (used sparingly) */
|
||||
--primary: 217 94 42; /* Orange #D95E2A */
|
||||
--primary-foreground: 249 249 247; /* Off-white */
|
||||
|
||||
/* Secondary */
|
||||
--secondary: 240 240 236; /* Grey 1 #F0F0EC */
|
||||
--secondary-foreground: 47 47 44; /* Grey 8 #2F2F2C */
|
||||
|
||||
/* Muted */
|
||||
--muted: 240 240 236; /* Grey 1 #F0F0EC */
|
||||
--muted-foreground: 87 87 83; /* Grey 7 #575753 */
|
||||
|
||||
/* Accent - Subtle grey for hover states */
|
||||
--accent: 221 221 215; /* Grey 2 #DDDDD7 */
|
||||
--accent-foreground: 22 22 20; /* Off-black */
|
||||
|
||||
/* Destructive */
|
||||
--destructive: 180 50 50; /* Muted red for destructive actions */
|
||||
--destructive-foreground: 249 249 247;
|
||||
|
||||
/* Borders & Inputs */
|
||||
--border: 196 196 189; /* Grey 3 #C4C4BD */
|
||||
--input: 196 196 189; /* Grey 3 #C4C4BD */
|
||||
|
||||
/* Focus Ring */
|
||||
--ring: 217 94 42; /* Orange for focus states */
|
||||
|
||||
/* Charts - Warm neutral palette with orange accent */
|
||||
--chart-1: 217 94 42; /* Orange accent */
|
||||
--chart-2: 87 87 83; /* Grey 7 */
|
||||
--chart-3: 127 127 121; /* Grey 5 */
|
||||
--chart-4: 166 166 159; /* Grey 4 */
|
||||
--chart-5: 47 47 44; /* Grey 8 */
|
||||
|
||||
/* Radius - Tightened, no playful rounding */
|
||||
--radius: 0.375rem;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: 240 240 236;
|
||||
--sidebar-foreground: 22 22 20;
|
||||
--sidebar-primary: 217 94 42;
|
||||
--sidebar-primary-foreground: 249 249 247;
|
||||
--sidebar-accent: 196 196 189;
|
||||
--sidebar-accent-foreground: 22 22 20;
|
||||
--sidebar-border: 196 196 189;
|
||||
--sidebar-ring: 217 94 42;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
DARK THEME
|
||||
Guidelines: Negative/reverse usage via off-black and greys
|
||||
============================================================================= */
|
||||
|
||||
.dark {
|
||||
/* Background & Foreground */
|
||||
--background: 22 22 20; /* Off-black #161614 */
|
||||
--foreground: 249 249 247; /* Off-white #F9F9F7 */
|
||||
|
||||
/* Card */
|
||||
--card: 47 47 44; /* Grey 8 #2F2F2C */
|
||||
--card-foreground: 249 249 247;
|
||||
|
||||
/* Popover */
|
||||
--popover: 47 47 44;
|
||||
--popover-foreground: 249 249 247;
|
||||
|
||||
/* Primary - Orange accent (same in dark mode) */
|
||||
--primary: 217 94 42; /* Orange #D95E2A */
|
||||
--primary-foreground: 249 249 247;
|
||||
|
||||
/* Secondary */
|
||||
--secondary: 87 87 83; /* Grey 7 #575753 */
|
||||
--secondary-foreground: 249 249 247;
|
||||
|
||||
/* Muted */
|
||||
--muted: 87 87 83; /* Grey 7 #575753 */
|
||||
--muted-foreground: 196 196 189; /* Grey 3 #C4C4BD */
|
||||
|
||||
/* Accent - Subtle grey for hover states */
|
||||
--accent: 87 87 83; /* Grey 7 #575753 */
|
||||
--accent-foreground: 249 249 247;
|
||||
|
||||
/* Destructive */
|
||||
--destructive: 180 50 50;
|
||||
--destructive-foreground: 249 249 247;
|
||||
|
||||
/* Borders & Inputs */
|
||||
--border: 87 87 83; /* Grey 7 */
|
||||
--input: 87 87 83;
|
||||
|
||||
/* Focus Ring */
|
||||
--ring: 217 94 42;
|
||||
|
||||
/* Charts */
|
||||
--chart-1: 217 94 42;
|
||||
--chart-2: 196 196 189;
|
||||
--chart-3: 166 166 159;
|
||||
--chart-4: 127 127 121;
|
||||
--chart-5: 240 240 236;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: 47 47 44;
|
||||
--sidebar-foreground: 249 249 247;
|
||||
--sidebar-primary: 217 94 42;
|
||||
--sidebar-primary-foreground: 249 249 247;
|
||||
--sidebar-accent: 87 87 83;
|
||||
--sidebar-accent-foreground: 249 249 247;
|
||||
--sidebar-border: 87 87 83;
|
||||
--sidebar-ring: 217 94 42;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
THEME CONFIGURATION
|
||||
Typography: Source Serif Pro (primary) + Aspekta (secondary/UI)
|
||||
Typography: Source Serif Pro (primary/serif) + Aspekta (secondary/UI/sans)
|
||||
============================================================================= */
|
||||
|
||||
@theme inline {
|
||||
/* Typography - Using CSS variables from Next.js font loading */
|
||||
--font-sans: var(--font-inter), 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-serif: var(--font-source-serif), 'Source Serif 4', 'Source Serif Pro', Georgia, serif;
|
||||
/* Typography — Aspekta (self-hosted) is the canonical sans font */
|
||||
--font-sans: 'Aspekta', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-serif: var(--font-source-serif, 'Source Serif 4'), 'Source Serif Pro', Georgia, serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
|
||||
/* Color mappings */
|
||||
/* Color mappings — maps CSS var RGB triplets to Tailwind utility classes */
|
||||
--color-background: rgb(var(--background));
|
||||
--color-foreground: rgb(var(--foreground));
|
||||
--color-card: rgb(var(--card));
|
||||
@@ -178,6 +75,7 @@
|
||||
--radius-md: var(--radius);
|
||||
--radius-lg: calc(var(--radius) + 2px);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-hero-bg: rgb(var(--hero-bg));
|
||||
--color-sidebar: rgb(var(--sidebar));
|
||||
--color-sidebar-foreground: rgb(var(--sidebar-foreground));
|
||||
--color-sidebar-primary: rgb(var(--sidebar-primary));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Source_Serif_4, Inter } from 'next/font/google'
|
||||
import { Source_Serif_4 } from 'next/font/google'
|
||||
import './globals.css'
|
||||
|
||||
// Primary typeface: Source Serif Pro (using Source Serif 4 which is the updated version)
|
||||
@@ -10,14 +10,9 @@ const sourceSerif = Source_Serif_4({
|
||||
display: 'swap',
|
||||
})
|
||||
|
||||
// Secondary typeface: Inter (Aspekta alternative from Google Fonts)
|
||||
// Aspekta is the brand typeface, Inter is a suitable system alternative
|
||||
// Secondary typeface: Aspekta (self-hosted in public/fonts/)
|
||||
// Loaded via @font-face in globals.css — no Next.js font loader needed
|
||||
// Used for UI labels, nav, buttons, small utility text
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: '--font-inter',
|
||||
display: 'swap',
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Greyhaven Design System',
|
||||
@@ -48,7 +43,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className={`${sourceSerif.variable} ${inter.variable}`}>
|
||||
<html lang="en" className={sourceSerif.variable}>
|
||||
<body className="font-sans antialiased bg-background text-foreground">
|
||||
{children}
|
||||
</body>
|
||||
|
||||
170
app/tokens/TOKENS.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Greyhaven Design Tokens Reference
|
||||
|
||||
> Auto-generated by Style Dictionary — DO NOT EDIT
|
||||
> Source: `tokens/*.json` (W3C DTCG format)
|
||||
|
||||
## Color
|
||||
|
||||
| Token | Value | Description |
|
||||
|-------|-------|-------------|
|
||||
| `color.primitive.off-white` | `#f9f9f7` | Primary light surface — cards, elevated areas |
|
||||
| `color.primitive.off-black` | `#161614` | Primary dark — foreground text, dark mode background |
|
||||
| `color.primitive.orange` | `#d95e2a` | Only accent color — used sparingly for primary actions and emphasis |
|
||||
| `color.primitive.destructive-red` | `#b43232` | Error/danger states |
|
||||
| `color.primitive.grey.1` | `#f0f0ec` | 5% — Subtle backgrounds, secondary, muted |
|
||||
| `color.primitive.grey.2` | `#ddddd7` | 10% — Accent hover, light borders |
|
||||
| `color.primitive.grey.3` | `#c4c4bd` | 20% — Border, input |
|
||||
| `color.primitive.grey.4` | `#a6a69f` | 50% — Mid-tone |
|
||||
| `color.primitive.grey.5` | `#7f7f79` | 60% — Mid-dark |
|
||||
| `color.primitive.grey.7` | `#575753` | 70% — Secondary foreground, muted foreground |
|
||||
| `color.primitive.grey.8` | `#2f2f2c` | 80% — Dark mode card, dark surfaces |
|
||||
| `color.semantic.background` | `#f0f0ec` | Page background |
|
||||
| `color.semantic.foreground` | `#161614` | Primary text |
|
||||
| `color.semantic.card` | `#f9f9f7` | Card/elevated surface background |
|
||||
| `color.semantic.card-foreground` | `#161614` | Card text |
|
||||
| `color.semantic.popover` | `#f9f9f7` | Popover background |
|
||||
| `color.semantic.popover-foreground` | `#161614` | Popover text |
|
||||
| `color.semantic.primary` | `#d95e2a` | Primary accent — buttons, links, focus rings |
|
||||
| `color.semantic.primary-foreground` | `#f9f9f7` | Text on primary accent |
|
||||
| `color.semantic.secondary` | `#f0f0ec` | Secondary button/surface |
|
||||
| `color.semantic.secondary-foreground` | `#2f2f2c` | Text on secondary surface |
|
||||
| `color.semantic.muted` | `#f0f0ec` | Muted/subdued background |
|
||||
| `color.semantic.muted-foreground` | `#575753` | Muted/subdued text |
|
||||
| `color.semantic.accent` | `#ddddd7` | Subtle hover state |
|
||||
| `color.semantic.accent-foreground` | `#161614` | Text on accent hover |
|
||||
| `color.semantic.destructive` | `#b43232` | Destructive/error actions |
|
||||
| `color.semantic.destructive-foreground` | `#f9f9f7` | Text on destructive |
|
||||
| `color.semantic.border` | `#c4c4bd` | Default border |
|
||||
| `color.semantic.input` | `#c4c4bd` | Input border |
|
||||
| `color.semantic.ring` | `#d95e2a` | Focus ring |
|
||||
| `color.semantic.hero-bg` | `#ddddd7` | Hero banner background |
|
||||
| `color.semantic.chart.1` | `#d95e2a` | Chart accent |
|
||||
| `color.semantic.chart.2` | `#575753` | Chart secondary |
|
||||
| `color.semantic.chart.3` | `#7f7f79` | Chart tertiary |
|
||||
| `color.semantic.chart.4` | `#a6a69f` | Chart quaternary |
|
||||
| `color.semantic.chart.5` | `#2f2f2c` | Chart quinary |
|
||||
| `color.semantic.sidebar.background` | `#f0f0ec` | Sidebar background |
|
||||
| `color.semantic.sidebar.foreground` | `#161614` | Sidebar text |
|
||||
| `color.semantic.sidebar.primary` | `#d95e2a` | Sidebar primary accent |
|
||||
| `color.semantic.sidebar.primary-foreground` | `#f9f9f7` | Sidebar primary text |
|
||||
| `color.semantic.sidebar.accent` | `#c4c4bd` | Sidebar accent/hover |
|
||||
| `color.semantic.sidebar.accent-foreground` | `#161614` | Sidebar accent text |
|
||||
| `color.semantic.sidebar.border` | `#c4c4bd` | Sidebar border |
|
||||
| `color.semantic.sidebar.ring` | `#d95e2a` | Sidebar focus ring |
|
||||
| `color.dark.background` | `#161614` | Dark page background |
|
||||
| `color.dark.foreground` | `#f9f9f7` | Dark primary text |
|
||||
| `color.dark.card` | `#2f2f2c` | Dark card surface |
|
||||
| `color.dark.card-foreground` | `#f9f9f7` | Dark card text |
|
||||
| `color.dark.popover` | `#2f2f2c` | Dark popover |
|
||||
| `color.dark.popover-foreground` | `#f9f9f7` | Dark popover text |
|
||||
| `color.dark.primary` | `#d95e2a` | Same orange in dark mode |
|
||||
| `color.dark.primary-foreground` | `#f9f9f7` | Dark primary foreground |
|
||||
| `color.dark.secondary` | `#575753` | Dark secondary |
|
||||
| `color.dark.secondary-foreground` | `#f9f9f7` | Dark secondary text |
|
||||
| `color.dark.muted` | `#2f2f2c` | Dark muted — grey.8 (distinct from grey.7 border so outlines on bg-muted surfaces remain visible) |
|
||||
| `color.dark.muted-foreground` | `#c4c4bd` | Dark muted text |
|
||||
| `color.dark.accent` | `#575753` | Dark accent/hover |
|
||||
| `color.dark.accent-foreground` | `#f9f9f7` | Dark accent text |
|
||||
| `color.dark.destructive` | `#b43232` | Same destructive in dark mode |
|
||||
| `color.dark.destructive-foreground` | `#f9f9f7` | Dark destructive text |
|
||||
| `color.dark.border` | `#575753` | Dark border |
|
||||
| `color.dark.input` | `#575753` | Dark input border |
|
||||
| `color.dark.ring` | `#d95e2a` | Dark focus ring |
|
||||
| `color.dark.hero-bg` | `#2f2f2c` | Dark hero banner background |
|
||||
| `color.dark.chart.1` | `#d95e2a` | Dark chart accent |
|
||||
| `color.dark.chart.2` | `#c4c4bd` | Dark chart secondary |
|
||||
| `color.dark.chart.3` | `#a6a69f` | Dark chart tertiary |
|
||||
| `color.dark.chart.4` | `#7f7f79` | Dark chart quaternary |
|
||||
| `color.dark.chart.5` | `#f0f0ec` | Dark chart quinary |
|
||||
| `color.dark.sidebar.background` | `#2f2f2c` | Dark sidebar background |
|
||||
| `color.dark.sidebar.foreground` | `#f9f9f7` | Dark sidebar text |
|
||||
| `color.dark.sidebar.primary` | `#d95e2a` | Dark sidebar primary |
|
||||
| `color.dark.sidebar.primary-foreground` | `#f9f9f7` | Dark sidebar primary text |
|
||||
| `color.dark.sidebar.accent` | `#575753` | Dark sidebar accent |
|
||||
| `color.dark.sidebar.accent-foreground` | `#f9f9f7` | Dark sidebar accent text |
|
||||
| `color.dark.sidebar.border` | `#575753` | Dark sidebar border |
|
||||
| `color.dark.sidebar.ring` | `#d95e2a` | Dark sidebar ring |
|
||||
|
||||
## Motion
|
||||
|
||||
| Token | Value | Description |
|
||||
|-------|-------|-------------|
|
||||
| `motion.duration.fast` | `150ms` | Quick transitions — tooltips, hover states |
|
||||
| `motion.duration.normal` | `200ms` | Default transitions — most UI interactions |
|
||||
| `motion.duration.slow` | `300ms` | Deliberate transitions — modals, drawers, accordions |
|
||||
| `motion.easing.default` | `cubic-bezier(0.4, 0, 0.2, 1)` | Standard ease-in-out |
|
||||
| `motion.easing.in` | `cubic-bezier(0.4, 0, 1, 1)` | Ease-in for exits |
|
||||
| `motion.easing.out` | `cubic-bezier(0, 0, 0.2, 1)` | Ease-out for entrances |
|
||||
|
||||
## Radii
|
||||
|
||||
| Token | Value | Description |
|
||||
|-------|-------|-------------|
|
||||
| `radii.base` | `0.375rem` | 6px — base radius |
|
||||
| `radii.sm` | `calc(0.375rem - 2px)` | 4px — small variant |
|
||||
| `radii.md` | `0.375rem` | 6px — medium (same as base) |
|
||||
| `radii.lg` | `calc(0.375rem + 2px)` | 8px — large variant |
|
||||
| `radii.xl` | `calc(0.375rem + 4px)` | 10px — extra large variant (cards) |
|
||||
| `radii.full` | `9999px` | Fully round (pills, avatars) |
|
||||
|
||||
## Shadow
|
||||
|
||||
| Token | Value | Description |
|
||||
|-------|-------|-------------|
|
||||
| `shadow.xs` | `0 1px 2px 0 rgba(22, 22, 20, 0.05)` | Subtle shadow for buttons, inputs |
|
||||
| `shadow.sm` | `0 1px 3px 0 rgba(22, 22, 20, 0.1)` | Small shadow for cards |
|
||||
| `shadow.md` | `0 4px 6px -1px rgba(22, 22, 20, 0.1)` | Medium shadow for dropdowns, popovers |
|
||||
| `shadow.lg` | `0 10px 15px -3px rgba(22, 22, 20, 0.1)` | Large shadow for dialogs, modals |
|
||||
|
||||
## Spacing
|
||||
|
||||
| Token | Value | Description |
|
||||
|-------|-------|-------------|
|
||||
| `spacing.0` | `0` | None |
|
||||
| `spacing.1` | `0.25rem` | 4px — tight gaps |
|
||||
| `spacing.2` | `0.5rem` | 8px — card header gap, form description spacing |
|
||||
| `spacing.3` | `0.75rem` | 12px |
|
||||
| `spacing.4` | `1rem` | 16px — form field gap, button padding |
|
||||
| `spacing.5` | `1.25rem` | 20px |
|
||||
| `spacing.6` | `1.5rem` | 24px — card padding, card internal gap |
|
||||
| `spacing.8` | `2rem` | 32px — section margin-bottom |
|
||||
| `spacing.10` | `2.5rem` | 40px |
|
||||
| `spacing.12` | `3rem` | 48px |
|
||||
| `spacing.16` | `4rem` | 64px — major section padding (py-16) |
|
||||
| `spacing.20` | `5rem` | 80px |
|
||||
| `spacing.24` | `6rem` | 96px — hero padding |
|
||||
| `spacing.0.5` | `0.125rem` | 2px — micro spacing |
|
||||
| `spacing.1.5` | `0.375rem` | 6px |
|
||||
| `spacing.component.card-padding` | `1.5rem` | Card internal padding (px-6) |
|
||||
| `spacing.component.card-gap` | `1.5rem` | Gap between cards (gap-6) |
|
||||
| `spacing.component.section-padding` | `2.5rem` | Vertical padding inside sections (py-10) |
|
||||
| `spacing.component.form-gap` | `1rem` | Gap between form fields (gap-4) |
|
||||
| `spacing.component.button-padding-x` | `1rem` | Button horizontal padding (px-4) |
|
||||
| `spacing.component.navbar-height` | `4rem` | Navbar height (h-16) |
|
||||
|
||||
## Typography
|
||||
|
||||
| Token | Value | Description |
|
||||
|-------|-------|-------------|
|
||||
| `typography.fontFamily.sans` | `Aspekta, ui-sans-serif, system-ui, sans-serif` | UI labels, buttons, nav, forms — Aspekta self-hosted |
|
||||
| `typography.fontFamily.serif` | `'Source Serif 4', 'Source Serif Pro', Georgia, serif` | Headings, body content, reading — Source Serif primary |
|
||||
| `typography.fontFamily.mono` | `ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace` | Code blocks and monospaced content |
|
||||
| `typography.fontSize.xs` | `0.75rem` | 12px — metadata, fine print |
|
||||
| `typography.fontSize.sm` | `0.875rem` | 14px — captions, nav, labels, buttons |
|
||||
| `typography.fontSize.base` | `1rem` | 16px — body text |
|
||||
| `typography.fontSize.lg` | `1.125rem` | 18px — large body, subtitles |
|
||||
| `typography.fontSize.xl` | `1.25rem` | 20px — H3 |
|
||||
| `typography.fontSize.2xl` | `1.5rem` | 24px — H2 |
|
||||
| `typography.fontSize.3xl` | `1.875rem` | 30px — large H2 |
|
||||
| `typography.fontSize.4xl` | `2.25rem` | 36px — H1 |
|
||||
| `typography.fontSize.5xl` | `3rem` | 48px — hero heading |
|
||||
| `typography.fontWeight.normal` | `400` | Regular body text |
|
||||
| `typography.fontWeight.medium` | `500` | H3, labels, nav items |
|
||||
| `typography.fontWeight.semibold` | `600` | H1, H2, buttons |
|
||||
| `typography.fontWeight.bold` | `700` | Strong emphasis |
|
||||
| `typography.lineHeight.tight` | `1.25rem` | Headings |
|
||||
| `typography.lineHeight.normal` | `1.5rem` | Default |
|
||||
| `typography.lineHeight.relaxed` | `1.625rem` | Body content for readability |
|
||||
| `typography.letterSpacing.tight` | `-0.025em` | Headings — tracking-tight |
|
||||
| `typography.letterSpacing.normal` | `0em` | Body text |
|
||||
| `typography.letterSpacing.wide` | `0.05em` | Uppercase labels |
|
||||
72
app/tokens/tokens-dark.css
Normal file
@@ -0,0 +1,72 @@
|
||||
/* Greyhaven Design Tokens — Dark Theme
|
||||
Auto-generated by Style Dictionary — DO NOT EDIT
|
||||
Source: tokens/color.json */
|
||||
|
||||
.dark {
|
||||
/* Dark page background */
|
||||
--background: 22 22 20;
|
||||
/* Dark primary text */
|
||||
--foreground: 249 249 247;
|
||||
/* Dark card surface */
|
||||
--card: 47 47 44;
|
||||
/* Dark card text */
|
||||
--card-foreground: 249 249 247;
|
||||
/* Dark popover */
|
||||
--popover: 47 47 44;
|
||||
/* Dark popover text */
|
||||
--popover-foreground: 249 249 247;
|
||||
/* Same orange in dark mode */
|
||||
--primary: 217 94 42;
|
||||
/* Dark primary foreground */
|
||||
--primary-foreground: 249 249 247;
|
||||
/* Dark secondary */
|
||||
--secondary: 87 87 83;
|
||||
/* Dark secondary text */
|
||||
--secondary-foreground: 249 249 247;
|
||||
/* Dark muted — grey.8 (distinct from grey.7 border so outlines on bg-muted surfaces remain visible) */
|
||||
--muted: 47 47 44;
|
||||
/* Dark muted text */
|
||||
--muted-foreground: 196 196 189;
|
||||
/* Dark accent/hover */
|
||||
--accent: 87 87 83;
|
||||
/* Dark accent text */
|
||||
--accent-foreground: 249 249 247;
|
||||
/* Same destructive in dark mode */
|
||||
--destructive: 180 50 50;
|
||||
/* Dark destructive text */
|
||||
--destructive-foreground: 249 249 247;
|
||||
/* Dark border */
|
||||
--border: 87 87 83;
|
||||
/* Dark input border */
|
||||
--input: 87 87 83;
|
||||
/* Dark focus ring */
|
||||
--ring: 217 94 42;
|
||||
/* Dark hero banner background */
|
||||
--hero-bg: 47 47 44;
|
||||
/* Dark chart accent */
|
||||
--chart-1: 217 94 42;
|
||||
/* Dark chart secondary */
|
||||
--chart-2: 196 196 189;
|
||||
/* Dark chart tertiary */
|
||||
--chart-3: 166 166 159;
|
||||
/* Dark chart quaternary */
|
||||
--chart-4: 127 127 121;
|
||||
/* Dark chart quinary */
|
||||
--chart-5: 240 240 236;
|
||||
/* Dark sidebar background */
|
||||
--sidebar: 47 47 44;
|
||||
/* Dark sidebar text */
|
||||
--sidebar-foreground: 249 249 247;
|
||||
/* Dark sidebar primary */
|
||||
--sidebar-primary: 217 94 42;
|
||||
/* Dark sidebar primary text */
|
||||
--sidebar-primary-foreground: 249 249 247;
|
||||
/* Dark sidebar accent */
|
||||
--sidebar-accent: 87 87 83;
|
||||
/* Dark sidebar accent text */
|
||||
--sidebar-accent-foreground: 249 249 247;
|
||||
/* Dark sidebar border */
|
||||
--sidebar-border: 87 87 83;
|
||||
/* Dark sidebar ring */
|
||||
--sidebar-ring: 217 94 42;
|
||||
}
|
||||
72
app/tokens/tokens-light.css
Normal file
@@ -0,0 +1,72 @@
|
||||
/* Greyhaven Design Tokens — Light Theme
|
||||
Auto-generated by Style Dictionary — DO NOT EDIT
|
||||
Source: tokens/color.json */
|
||||
|
||||
:root {
|
||||
/* Page background */
|
||||
--background: 240 240 236;
|
||||
/* Primary text */
|
||||
--foreground: 22 22 20;
|
||||
/* Card/elevated surface background */
|
||||
--card: 249 249 247;
|
||||
/* Card text */
|
||||
--card-foreground: 22 22 20;
|
||||
/* Popover background */
|
||||
--popover: 249 249 247;
|
||||
/* Popover text */
|
||||
--popover-foreground: 22 22 20;
|
||||
/* Primary accent — buttons, links, focus rings */
|
||||
--primary: 217 94 42;
|
||||
/* Text on primary accent */
|
||||
--primary-foreground: 249 249 247;
|
||||
/* Secondary button/surface */
|
||||
--secondary: 240 240 236;
|
||||
/* Text on secondary surface */
|
||||
--secondary-foreground: 47 47 44;
|
||||
/* Muted/subdued background */
|
||||
--muted: 240 240 236;
|
||||
/* Muted/subdued text */
|
||||
--muted-foreground: 87 87 83;
|
||||
/* Subtle hover state */
|
||||
--accent: 221 221 215;
|
||||
/* Text on accent hover */
|
||||
--accent-foreground: 22 22 20;
|
||||
/* Destructive/error actions */
|
||||
--destructive: 180 50 50;
|
||||
/* Text on destructive */
|
||||
--destructive-foreground: 249 249 247;
|
||||
/* Default border */
|
||||
--border: 196 196 189;
|
||||
/* Input border */
|
||||
--input: 196 196 189;
|
||||
/* Focus ring */
|
||||
--ring: 217 94 42;
|
||||
/* Hero banner background */
|
||||
--hero-bg: 221 221 215;
|
||||
/* Chart accent */
|
||||
--chart-1: 217 94 42;
|
||||
/* Chart secondary */
|
||||
--chart-2: 87 87 83;
|
||||
/* Chart tertiary */
|
||||
--chart-3: 127 127 121;
|
||||
/* Chart quaternary */
|
||||
--chart-4: 166 166 159;
|
||||
/* Chart quinary */
|
||||
--chart-5: 47 47 44;
|
||||
/* Sidebar background */
|
||||
--sidebar: 240 240 236;
|
||||
/* Sidebar text */
|
||||
--sidebar-foreground: 22 22 20;
|
||||
/* Sidebar primary accent */
|
||||
--sidebar-primary: 217 94 42;
|
||||
/* Sidebar primary text */
|
||||
--sidebar-primary-foreground: 249 249 247;
|
||||
/* Sidebar accent/hover */
|
||||
--sidebar-accent: 196 196 189;
|
||||
/* Sidebar accent text */
|
||||
--sidebar-accent-foreground: 22 22 20;
|
||||
/* Sidebar border */
|
||||
--sidebar-border: 196 196 189;
|
||||
/* Sidebar focus ring */
|
||||
--sidebar-ring: 217 94 42;
|
||||
}
|
||||
146
app/tokens/tokens.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
// Auto-generated by Style Dictionary — DO NOT EDIT
|
||||
// Source: tokens/*.json (W3C DTCG format)
|
||||
|
||||
export const ColorTokens = {
|
||||
'primitive.off-white': '#f9f9f7',
|
||||
'primitive.off-black': '#161614',
|
||||
'primitive.orange': '#d95e2a',
|
||||
'primitive.destructive-red': '#b43232',
|
||||
'primitive.grey.1': '#f0f0ec',
|
||||
'primitive.grey.2': '#ddddd7',
|
||||
'primitive.grey.3': '#c4c4bd',
|
||||
'primitive.grey.4': '#a6a69f',
|
||||
'primitive.grey.5': '#7f7f79',
|
||||
'primitive.grey.7': '#575753',
|
||||
'primitive.grey.8': '#2f2f2c',
|
||||
'semantic.background': '#f0f0ec',
|
||||
'semantic.foreground': '#161614',
|
||||
'semantic.card': '#f9f9f7',
|
||||
'semantic.card-foreground': '#161614',
|
||||
'semantic.popover': '#f9f9f7',
|
||||
'semantic.popover-foreground': '#161614',
|
||||
'semantic.primary': '#d95e2a',
|
||||
'semantic.primary-foreground': '#f9f9f7',
|
||||
'semantic.secondary': '#f0f0ec',
|
||||
'semantic.secondary-foreground': '#2f2f2c',
|
||||
'semantic.muted': '#f0f0ec',
|
||||
'semantic.muted-foreground': '#575753',
|
||||
'semantic.accent': '#ddddd7',
|
||||
'semantic.accent-foreground': '#161614',
|
||||
'semantic.destructive': '#b43232',
|
||||
'semantic.destructive-foreground': '#f9f9f7',
|
||||
'semantic.border': '#c4c4bd',
|
||||
'semantic.input': '#c4c4bd',
|
||||
'semantic.ring': '#d95e2a',
|
||||
'semantic.hero-bg': '#ddddd7',
|
||||
'semantic.chart.1': '#d95e2a',
|
||||
'semantic.chart.2': '#575753',
|
||||
'semantic.chart.3': '#7f7f79',
|
||||
'semantic.chart.4': '#a6a69f',
|
||||
'semantic.chart.5': '#2f2f2c',
|
||||
'semantic.sidebar.background': '#f0f0ec',
|
||||
'semantic.sidebar.foreground': '#161614',
|
||||
'semantic.sidebar.primary': '#d95e2a',
|
||||
'semantic.sidebar.primary-foreground': '#f9f9f7',
|
||||
'semantic.sidebar.accent': '#c4c4bd',
|
||||
'semantic.sidebar.accent-foreground': '#161614',
|
||||
'semantic.sidebar.border': '#c4c4bd',
|
||||
'semantic.sidebar.ring': '#d95e2a',
|
||||
'dark.background': '#161614',
|
||||
'dark.foreground': '#f9f9f7',
|
||||
'dark.card': '#2f2f2c',
|
||||
'dark.card-foreground': '#f9f9f7',
|
||||
'dark.popover': '#2f2f2c',
|
||||
'dark.popover-foreground': '#f9f9f7',
|
||||
'dark.primary': '#d95e2a',
|
||||
'dark.primary-foreground': '#f9f9f7',
|
||||
'dark.secondary': '#575753',
|
||||
'dark.secondary-foreground': '#f9f9f7',
|
||||
'dark.muted': '#2f2f2c',
|
||||
'dark.muted-foreground': '#c4c4bd',
|
||||
'dark.accent': '#575753',
|
||||
'dark.accent-foreground': '#f9f9f7',
|
||||
'dark.destructive': '#b43232',
|
||||
'dark.destructive-foreground': '#f9f9f7',
|
||||
'dark.border': '#575753',
|
||||
'dark.input': '#575753',
|
||||
'dark.ring': '#d95e2a',
|
||||
'dark.hero-bg': '#2f2f2c',
|
||||
'dark.chart.1': '#d95e2a',
|
||||
'dark.chart.2': '#c4c4bd',
|
||||
'dark.chart.3': '#a6a69f',
|
||||
'dark.chart.4': '#7f7f79',
|
||||
'dark.chart.5': '#f0f0ec',
|
||||
'dark.sidebar.background': '#2f2f2c',
|
||||
'dark.sidebar.foreground': '#f9f9f7',
|
||||
'dark.sidebar.primary': '#d95e2a',
|
||||
'dark.sidebar.primary-foreground': '#f9f9f7',
|
||||
'dark.sidebar.accent': '#575753',
|
||||
'dark.sidebar.accent-foreground': '#f9f9f7',
|
||||
'dark.sidebar.border': '#575753',
|
||||
'dark.sidebar.ring': '#d95e2a',
|
||||
} as const;
|
||||
|
||||
export const MotionTokens = {
|
||||
'duration.fast': '150ms',
|
||||
'duration.normal': '200ms',
|
||||
'duration.slow': '300ms',
|
||||
} as const;
|
||||
|
||||
export const RadiiTokens = {
|
||||
'base': '0.375rem',
|
||||
'sm': 'calc(0.375rem - 2px)',
|
||||
'md': '0.375rem',
|
||||
'lg': 'calc(0.375rem + 2px)',
|
||||
'xl': 'calc(0.375rem + 4px)',
|
||||
'full': '9999px',
|
||||
} as const;
|
||||
|
||||
export const ShadowTokens = {
|
||||
} as const;
|
||||
|
||||
export const SpacingTokens = {
|
||||
'0': '0',
|
||||
'1': '0.25rem',
|
||||
'2': '0.5rem',
|
||||
'3': '0.75rem',
|
||||
'4': '1rem',
|
||||
'5': '1.25rem',
|
||||
'6': '1.5rem',
|
||||
'8': '2rem',
|
||||
'10': '2.5rem',
|
||||
'12': '3rem',
|
||||
'16': '4rem',
|
||||
'20': '5rem',
|
||||
'24': '6rem',
|
||||
'0.5': '0.125rem',
|
||||
'1.5': '0.375rem',
|
||||
'component.card-padding': '1.5rem',
|
||||
'component.card-gap': '1.5rem',
|
||||
'component.section-padding': '2.5rem',
|
||||
'component.form-gap': '1rem',
|
||||
'component.button-padding-x': '1rem',
|
||||
'component.navbar-height': '4rem',
|
||||
} as const;
|
||||
|
||||
export const TypographyTokens = {
|
||||
'fontSize.xs': '0.75rem',
|
||||
'fontSize.sm': '0.875rem',
|
||||
'fontSize.base': '1rem',
|
||||
'fontSize.lg': '1.125rem',
|
||||
'fontSize.xl': '1.25rem',
|
||||
'fontSize.2xl': '1.5rem',
|
||||
'fontSize.3xl': '1.875rem',
|
||||
'fontSize.4xl': '2.25rem',
|
||||
'fontSize.5xl': '3rem',
|
||||
'fontWeight.normal': '400',
|
||||
'fontWeight.medium': '500',
|
||||
'fontWeight.semibold': '600',
|
||||
'fontWeight.bold': '700',
|
||||
'lineHeight.tight': '1.25rem',
|
||||
'lineHeight.normal': '1.5rem',
|
||||
'lineHeight.relaxed': '1.625rem',
|
||||
'letterSpacing.tight': '-0.025em',
|
||||
'letterSpacing.normal': '0em',
|
||||
'letterSpacing.wide': '0.05em',
|
||||
} as const;
|
||||
@@ -7,6 +7,7 @@ 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,
|
||||
@@ -455,6 +456,69 @@ 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">
|
||||
|
||||
@@ -1,11 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import {
|
||||
ThemeProvider as NextThemesProvider,
|
||||
type ThemeProviderProps,
|
||||
} from 'next-themes'
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
type Theme = 'light' | 'dark' | 'system'
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
attribute?: string
|
||||
}
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme
|
||||
resolvedTheme: 'light' | 'dark'
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const ThemeContext = React.createContext<ThemeContextValue | undefined>(undefined)
|
||||
|
||||
function getSystemTheme(): 'light' | 'dark' {
|
||||
if (typeof window === 'undefined') return 'light'
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = 'system',
|
||||
storageKey = 'greyhaven-theme',
|
||||
attribute = 'class',
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setThemeState] = React.useState<Theme>(() => {
|
||||
if (typeof window === 'undefined') return defaultTheme
|
||||
return (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
})
|
||||
|
||||
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme
|
||||
|
||||
const setTheme = React.useCallback(
|
||||
(newTheme: Theme) => {
|
||||
setThemeState(newTheme)
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(storageKey, newTheme)
|
||||
}
|
||||
},
|
||||
[storageKey],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
const root = document.documentElement
|
||||
|
||||
if (attribute === 'class') {
|
||||
root.classList.remove('light', 'dark')
|
||||
root.classList.add(resolvedTheme)
|
||||
} else {
|
||||
root.setAttribute(attribute, resolvedTheme)
|
||||
}
|
||||
}, [resolvedTheme, attribute])
|
||||
|
||||
// Listen for system theme changes when theme is 'system'
|
||||
React.useEffect(() => {
|
||||
if (theme !== 'system') return
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handler = () => setThemeState((prev) => (prev === 'system' ? 'system' : prev))
|
||||
|
||||
mediaQuery.addEventListener('change', handler)
|
||||
return () => mediaQuery.removeEventListener('change', handler)
|
||||
}, [theme])
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = React.useContext(ThemeContext)
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
|
||||
'inline-flex items-center justify-center rounded-md border font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -43,9 +43,15 @@ const badgeVariants = cva(
|
||||
platform:
|
||||
'border-transparent bg-[#f97316] text-white [a&]:hover:bg-[#f97316]/90',
|
||||
},
|
||||
size: {
|
||||
sm: 'text-xs px-1.5 py-0',
|
||||
default: 'text-xs px-2 py-0.5',
|
||||
lg: 'text-sm px-3 py-1 [&>svg]:size-3.5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -53,6 +59,7 @@ const badgeVariants = cva(
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'span'> &
|
||||
@@ -62,7 +69,7 @@ function Badge({
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
className={cn(badgeVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
63
components/ui/code.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const codeVariants = cva(
|
||||
'bg-muted border border-border font-mono text-foreground',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
inline: 'rounded text-xs px-1.5 py-0.5',
|
||||
block: 'block rounded-md text-sm px-4 py-3 leading-relaxed break-all whitespace-pre-wrap',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'inline',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
interface CodeProps
|
||||
extends React.ComponentProps<'code'>,
|
||||
VariantProps<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 }
|
||||
86
components/ui/cta-section.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ctaSectionVariants = cva('py-16 px-6', {
|
||||
variants: {
|
||||
variant: {
|
||||
centered: 'text-center',
|
||||
'left-aligned': 'text-left',
|
||||
},
|
||||
background: {
|
||||
default: 'bg-background',
|
||||
muted: 'bg-muted',
|
||||
accent: 'bg-primary text-primary-foreground',
|
||||
subtle: 'bg-primary/5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'centered',
|
||||
background: 'muted',
|
||||
},
|
||||
})
|
||||
|
||||
interface CTASectionProps
|
||||
extends React.ComponentProps<'section'>,
|
||||
VariantProps<typeof ctaSectionVariants> {
|
||||
heading: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
actions?: React.ReactNode
|
||||
}
|
||||
|
||||
function CTASection({
|
||||
className,
|
||||
variant,
|
||||
background,
|
||||
heading,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
...props
|
||||
}: CTASectionProps) {
|
||||
return (
|
||||
<section
|
||||
data-slot="cta-section"
|
||||
className={cn(ctaSectionVariants({ variant, background, className }))}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-3xl',
|
||||
variant === 'centered' && 'mx-auto',
|
||||
)}
|
||||
>
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-semibold tracking-tight mb-4">
|
||||
{heading}
|
||||
</h2>
|
||||
{description && (
|
||||
<p
|
||||
className={cn(
|
||||
'text-base font-sans mb-8 leading-relaxed',
|
||||
background === 'accent'
|
||||
? 'text-primary-foreground/80'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{actions && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-wrap gap-4',
|
||||
variant === 'centered' && 'justify-center',
|
||||
)}
|
||||
>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export { CTASection, ctaSectionVariants }
|
||||
109
components/ui/footer.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const footerVariants = cva(
|
||||
'border-t border-border bg-background font-sans',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
minimal: 'py-8',
|
||||
full: 'py-12',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'minimal',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
interface FooterLinkGroup {
|
||||
title: string
|
||||
links: { label: string; href: string }[]
|
||||
}
|
||||
|
||||
interface FooterProps
|
||||
extends React.ComponentProps<'footer'>,
|
||||
VariantProps<typeof footerVariants> {
|
||||
logo?: React.ReactNode
|
||||
copyright?: React.ReactNode
|
||||
linkGroups?: FooterLinkGroup[]
|
||||
actions?: React.ReactNode
|
||||
}
|
||||
|
||||
function Footer({
|
||||
className,
|
||||
variant,
|
||||
logo,
|
||||
copyright,
|
||||
linkGroups,
|
||||
actions,
|
||||
children,
|
||||
...props
|
||||
}: FooterProps) {
|
||||
if (variant === 'full' && linkGroups) {
|
||||
return (
|
||||
<footer
|
||||
data-slot="footer"
|
||||
className={cn(footerVariants({ variant, className }))}
|
||||
{...props}
|
||||
>
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 mb-8">
|
||||
{logo && (
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
{logo}
|
||||
</div>
|
||||
)}
|
||||
{linkGroups.map((group) => (
|
||||
<div key={group.title}>
|
||||
<h4 className="text-sm font-semibold mb-4">{group.title}</h4>
|
||||
<ul className="space-y-2">
|
||||
{group.links.map((link) => (
|
||||
<li key={link.href}>
|
||||
<a
|
||||
href={link.href}
|
||||
className="text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-border pt-8 flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
{copyright && (
|
||||
<p className="text-sm text-muted-foreground">{copyright}</p>
|
||||
)}
|
||||
{actions}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<footer
|
||||
data-slot="footer"
|
||||
className={cn(footerVariants({ variant, className }))}
|
||||
{...props}
|
||||
>
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
{logo && <div>{logo}</div>}
|
||||
{copyright && (
|
||||
<p className="text-sm text-muted-foreground">{copyright}</p>
|
||||
)}
|
||||
{actions}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
export { Footer, footerVariants }
|
||||
94
components/ui/hero.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const heroVariants = cva('py-24 px-6', {
|
||||
variants: {
|
||||
variant: {
|
||||
centered: 'text-center',
|
||||
'left-aligned': 'text-left',
|
||||
split: 'text-left',
|
||||
},
|
||||
background: {
|
||||
default: 'bg-hero-bg',
|
||||
muted: 'bg-muted',
|
||||
accent: 'bg-primary/5',
|
||||
dark: 'bg-foreground text-background',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'centered',
|
||||
background: 'default',
|
||||
},
|
||||
})
|
||||
|
||||
interface HeroProps
|
||||
extends React.ComponentProps<'section'>,
|
||||
VariantProps<typeof heroVariants> {
|
||||
heading: React.ReactNode
|
||||
subheading?: React.ReactNode
|
||||
actions?: React.ReactNode
|
||||
media?: React.ReactNode
|
||||
}
|
||||
|
||||
function Hero({
|
||||
className,
|
||||
variant,
|
||||
background,
|
||||
heading,
|
||||
subheading,
|
||||
actions,
|
||||
media,
|
||||
children,
|
||||
...props
|
||||
}: HeroProps) {
|
||||
const isSplit = variant === 'split'
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="hero"
|
||||
className={cn(heroVariants({ variant, background, className }))}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-7xl mx-auto',
|
||||
isSplit && 'grid grid-cols-1 lg:grid-cols-2 gap-12 items-center',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
variant === 'centered' && 'max-w-3xl mx-auto',
|
||||
!isSplit && 'max-w-3xl',
|
||||
)}
|
||||
>
|
||||
<h1 className="font-serif text-4xl sm:text-5xl font-semibold tracking-tight mb-6">
|
||||
{heading}
|
||||
</h1>
|
||||
{subheading && (
|
||||
<p className="text-lg text-muted-foreground font-sans mb-8 leading-relaxed">
|
||||
{subheading}
|
||||
</p>
|
||||
)}
|
||||
{actions && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-wrap gap-4',
|
||||
variant === 'centered' && 'justify-center',
|
||||
)}
|
||||
>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
{isSplit && media && (
|
||||
<div className="flex items-center justify-center">{media}</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export { Hero, heroVariants }
|
||||
92
components/ui/logo.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const logoVariants = cva('inline-block', {
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'h-6 w-auto',
|
||||
md: 'h-8 w-auto',
|
||||
lg: 'h-10 w-auto',
|
||||
xl: 'h-14 w-auto',
|
||||
},
|
||||
variant: {
|
||||
color: '',
|
||||
monochrome: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
variant: 'color',
|
||||
},
|
||||
})
|
||||
|
||||
function Logo({
|
||||
className,
|
||||
size,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<'svg'> &
|
||||
VariantProps<typeof logoVariants>) {
|
||||
return (
|
||||
<svg
|
||||
data-slot="logo"
|
||||
viewBox="0 0 1818 448"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn(logoVariants({ size, variant, className }))}
|
||||
{...props}
|
||||
>
|
||||
<g clipPath="url(#greyhaven-logo-clip)">
|
||||
<path
|
||||
d="M625.8 313.476L623.156 286.907C614.009 302.244 592.449 317.924 559.067 317.924C504.436 317.924 455.996 277.76 455.996 208.662C455.996 139.564 507.08 99.7111 561.4 99.7111C612.204 99.7111 644.684 128.956 655.884 163.489L622.502 176.182C615.409 152.569 594.751 132.471 561.369 132.471C527.987 132.471 491.991 156.676 491.991 208.662C491.991 260.649 525.062 285.444 561.089 285.444C603.307 285.444 619.267 256.511 621.04 238.498H551.942V207.48H654.422V313.476H625.769H625.8Z"
|
||||
className={variant === 'monochrome' ? 'fill-foreground' : 'fill-foreground'}
|
||||
/>
|
||||
<path
|
||||
d="M771.649 203.622C767.822 203.031 763.964 202.751 760.418 202.751C733.849 202.751 721.747 218.089 721.747 244.969V313.476H687.493V169.68H720.876V192.702C727.658 177.053 743.618 167.907 762.502 167.907C766.64 167.907 770.187 168.498 771.649 168.778V203.622Z"
|
||||
className={variant === 'monochrome' ? 'fill-foreground' : 'fill-foreground'}
|
||||
/>
|
||||
<path
|
||||
d="M919.582 272.44C911.898 297.547 889.156 317.924 854.622 317.924C815.64 317.924 781.107 289.582 781.107 240.862C781.107 195.378 814.769 165.262 851.076 165.262C895.378 165.262 921.356 194.507 921.356 239.96C921.356 245.56 920.764 250.289 920.453 250.88H815.329C816.2 272.72 833.342 288.369 854.591 288.369C875.84 288.369 885.889 277.449 890.618 263.262L919.551 272.409L919.582 272.44ZM886.822 225.773C886.231 208.942 875 193.884 851.387 193.884C829.827 193.884 817.444 210.404 816.262 225.773H886.822Z"
|
||||
className={variant === 'monochrome' ? 'fill-foreground' : 'fill-foreground'}
|
||||
/>
|
||||
<path
|
||||
d="M949.542 371.653L984.107 296.364L922.693 169.68H961.365L1002.71 260.618L1041.38 169.68H1077.69L986.16 371.653H949.542Z"
|
||||
className={variant === 'monochrome' ? 'fill-foreground' : 'fill-foreground'}
|
||||
/>
|
||||
<path
|
||||
d="M1128.09 313.476H1093.84V99.68H1128.09V183.556C1137.83 170.862 1154.07 165.542 1169.12 165.542C1204.56 165.542 1221.67 190.929 1221.67 222.538V313.476H1187.42V228.418C1187.42 210.684 1179.45 196.529 1157.89 196.529C1139.01 196.529 1128.65 210.684 1128.06 229.009V313.476H1128.09Z"
|
||||
className={variant === 'monochrome' ? 'fill-foreground' : 'fill-foreground'}
|
||||
/>
|
||||
<path
|
||||
d="M1288.56 231.093L1325.46 225.493C1333.73 224.311 1336.1 220.173 1336.1 215.164C1336.1 203.062 1327.82 193.324 1308.94 193.324C1290.05 193.324 1280.88 204.836 1279.41 219.302L1248.12 212.209C1250.76 187.413 1273.22 165.262 1308.66 165.262C1352.96 165.262 1369.79 190.369 1369.79 218.991V290.453C1369.79 303.458 1371.28 312.013 1371.56 313.476H1339.68C1339.4 312.573 1338.21 306.693 1338.21 295.151C1331.43 306.071 1317.24 317.893 1293.91 317.893C1263.8 317.893 1245.19 297.236 1245.19 274.493C1245.19 248.796 1264.08 234.64 1288.59 231.093H1288.56ZM1336.1 253.836V247.333L1298.61 252.933C1287.97 254.707 1279.41 260.618 1279.41 272.44C1279.41 282.178 1286.79 291.044 1300.38 291.044C1319.58 291.044 1336.1 281.898 1336.1 253.836Z"
|
||||
className={variant === 'monochrome' ? 'fill-foreground' : 'fill-foreground'}
|
||||
/>
|
||||
<path
|
||||
d="M1465.52 313.476H1431.27L1372.81 169.68H1410.61L1448.69 272.44L1485.9 169.68H1521.92L1465.52 313.476Z"
|
||||
className={variant === 'monochrome' ? 'fill-foreground' : 'fill-foreground'}
|
||||
/>
|
||||
<path
|
||||
d="M1663.08 272.44C1655.39 297.547 1632.65 317.924 1598.12 317.924C1559.13 317.924 1524.6 289.582 1524.6 240.862C1524.6 195.378 1558.26 165.262 1594.57 165.262C1638.87 165.262 1664.85 194.507 1664.85 239.96C1664.85 245.56 1664.26 250.289 1663.95 250.88H1558.82C1559.69 272.72 1576.84 288.369 1598.08 288.369C1619.33 288.369 1629.38 277.449 1634.11 263.262L1663.04 272.409L1663.08 272.44ZM1630.28 225.773C1629.69 208.942 1618.46 193.884 1594.85 193.884C1573.29 193.884 1560.91 210.404 1559.72 225.773H1630.28Z"
|
||||
className={variant === 'monochrome' ? 'fill-foreground' : 'fill-foreground'}
|
||||
/>
|
||||
<path
|
||||
d="M1724.12 313.476H1689.86V169.68H1723.24V188.876C1732.7 172.356 1749.81 165.542 1765.77 165.542C1800.9 165.542 1817.73 190.929 1817.73 222.538V313.476H1783.48V228.418C1783.48 210.684 1775.51 196.529 1753.95 196.529C1734.48 196.529 1724.12 211.587 1724.12 230.471V313.444V313.476Z"
|
||||
className={variant === 'monochrome' ? 'fill-foreground' : 'fill-foreground'}
|
||||
/>
|
||||
<path
|
||||
d="M345.582 100.551L287.498 66.4533H284.356L232.462 96.9111V34.0978L174.378 0H171.236L113.151 34.0978V96.9111L61.2578 66.4533H58.1156L0 100.551V102.791V347.013L58.0844 381.609H61.2578L113.12 350.747V413.467L171.204 448.062H174.378L232.462 413.467V350.747L284.324 381.609H287.498L345.582 347.013V100.551ZM59.6711 72.7378L111.627 103.258L59.6711 134.182L7.71556 103.258L59.6711 72.7378ZM6.22222 109.604L56.56 139.564V308.436L6.22222 337.991V109.604ZM56.56 315.653V373.427L7.71556 344.338L56.56 315.653ZM62.7822 373.427V315.653L111.627 344.338L62.7822 373.427ZM113.12 337.991L62.7822 308.436V139.564L113.12 109.604V337.991ZM226.24 102.791V163.333L175.902 133.778V73.1422L226.24 43.1822V102.791ZM172.791 200.604L120.836 169.68L172.791 139.16L224.747 169.68L172.791 200.604ZM175.902 65.8933V8.12L224.747 36.8044L175.902 65.8933ZM169.68 8.12V65.8933L120.836 36.8044L169.68 8.12ZM119.342 43.1511L169.68 73.1111V133.747L119.342 163.302V43.1511ZM119.342 176.027L169.68 205.987V374.858L119.342 404.413V176.027ZM169.68 382.076V439.849L120.836 410.76L169.68 382.076ZM175.902 439.849V382.076L224.747 410.76L175.902 439.849ZM226.24 404.413L175.902 374.858V205.987L226.24 176.027V404.413ZM289.022 74.5733L337.867 103.258L289.022 132.347V74.5733ZM282.8 74.5733V132.347L233.956 103.258L282.8 74.5733ZM232.462 109.604L282.8 139.564V308.436L232.462 337.991V109.604ZM285.911 375.293L233.956 344.338L285.911 313.818L337.867 344.338L285.911 375.293ZM339.36 337.991L289.022 308.436V139.564L339.36 109.604V337.991Z"
|
||||
className={variant === 'monochrome' ? 'fill-foreground' : 'fill-primary'}
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="greyhaven-logo-clip">
|
||||
<rect width="1817.73" height="448" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export { Logo, logoVariants }
|
||||
131
components/ui/navbar.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { MenuIcon, XIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const navbarVariants = cva(
|
||||
'fixed top-0 left-0 right-0 z-50 h-[65px] font-sans',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
solid: 'bg-card dark:bg-background border-b border-border',
|
||||
transparent: 'bg-transparent',
|
||||
minimal: 'bg-card/80 dark:bg-background/80 backdrop-blur-sm border-b border-border/50',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'solid',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
interface NavbarProps
|
||||
extends React.ComponentProps<'header'>,
|
||||
VariantProps<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 }
|
||||
52
components/ui/page-layout.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface PageLayoutProps extends React.ComponentProps<'div'> {
|
||||
navbar?: React.ReactNode
|
||||
sidebar?: React.ReactNode
|
||||
footer?: React.ReactNode
|
||||
}
|
||||
|
||||
function PageLayout({
|
||||
className,
|
||||
navbar,
|
||||
sidebar,
|
||||
footer,
|
||||
children,
|
||||
...props
|
||||
}: PageLayoutProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="page-layout"
|
||||
className={cn('min-h-screen flex flex-col bg-background text-foreground', className)}
|
||||
{...props}
|
||||
>
|
||||
{navbar}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1',
|
||||
navbar && 'pt-16.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 }
|
||||
69
components/ui/section.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const sectionVariants = cva('py-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 }
|
||||
@@ -1,14 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import { Toaster as Sonner, ToasterProps } from 'sonner'
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = 'system' } = useTheme()
|
||||
const { resolvedTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps['theme']}
|
||||
theme={resolvedTheme as ToasterProps['theme']}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
|
||||
@@ -60,7 +60,7 @@ function ToggleGroupItem({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
'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',
|
||||
'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}
|
||||
|
||||
@@ -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-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",
|
||||
"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",
|
||||
{
|
||||
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-2 min-w-9',
|
||||
sm: 'h-8 px-1.5 min-w-8',
|
||||
lg: 'h-10 px-2.5 min-w-10',
|
||||
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',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
871
dist/greyhaven.htmx.css
vendored
Normal file
@@ -0,0 +1,871 @@
|
||||
/*! 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
|
||||
*/
|
||||
35
htmx-demo/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# 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.
|
||||
69
htmx-demo/compare-all.sh
Executable file
@@ -0,0 +1,69 @@
|
||||
#!/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
|
||||
100
htmx-demo/compare.py
Normal file
@@ -0,0 +1,100 @@
|
||||
#!/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()
|
||||
88
htmx-demo/input.css
Normal file
@@ -0,0 +1,88 @@
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
439
lib/catalog.ts
Normal file
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* 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="© 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>',
|
||||
},
|
||||
]
|
||||
454
mcp/server.ts
Normal file
@@ -0,0 +1,454 @@
|
||||
#!/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)
|
||||
14
mcp/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "..",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["server.ts", "../lib/catalog.ts"]
|
||||
}
|
||||
@@ -6,6 +6,9 @@ const nextConfig = {
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
turbopack: {
|
||||
root: '.',
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
|
||||
11135
package-lock.json
generated
Normal file
30
package.json
@@ -3,10 +3,18 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"tokens:build": "npx style-dictionary build --config style-dictionary.config.mjs",
|
||||
"skill:build": "npx tsx scripts/generate-skill.ts",
|
||||
"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",
|
||||
"dev": "next dev",
|
||||
"lint": "eslint .",
|
||||
"start": "next start"
|
||||
"start": "next start",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
"mcp:build": "npx tsc -p mcp/tsconfig.json",
|
||||
"mcp:start": "npx tsx mcp/server.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
@@ -61,13 +69,29 @@
|
||||
"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"
|
||||
"typescript": "^5",
|
||||
"vite": "^8.0.8",
|
||||
"vitest": "^4.1.4"
|
||||
}
|
||||
}
|
||||
4403
pnpm-lock.yaml
generated
BIN
public/fonts/Aspekta-100.woff2
Normal file
BIN
public/fonts/Aspekta-1000.woff2
Normal file
BIN
public/fonts/Aspekta-150.woff2
Normal file
BIN
public/fonts/Aspekta-200.woff2
Normal file
BIN
public/fonts/Aspekta-250.woff2
Normal file
BIN
public/fonts/Aspekta-300.woff2
Normal file
BIN
public/fonts/Aspekta-350.woff2
Normal file
BIN
public/fonts/Aspekta-400.woff2
Normal file
BIN
public/fonts/Aspekta-450.woff2
Normal file
BIN
public/fonts/Aspekta-50.woff2
Normal file
BIN
public/fonts/Aspekta-500.woff2
Normal file
BIN
public/fonts/Aspekta-550.woff2
Normal file
BIN
public/fonts/Aspekta-600.woff2
Normal file
BIN
public/fonts/Aspekta-650.woff2
Normal file
BIN
public/fonts/Aspekta-700.woff2
Normal file
BIN
public/fonts/Aspekta-750.woff2
Normal file
BIN
public/fonts/Aspekta-800.woff2
Normal file
BIN
public/fonts/Aspekta-850.woff2
Normal file
BIN
public/fonts/Aspekta-900.woff2
Normal file
BIN
public/fonts/Aspekta-950.woff2
Normal file
161
public/fonts/font-face.css
Normal file
@@ -0,0 +1,161 @@
|
||||
/*! Aspekta | OFL v1.1 License | Ivo Dolenc (c) 2025 | https://github.com/ivodolenc/aspekta */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 50;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-50.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-100.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 150;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-150.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-200.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 250;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-250.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-300.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 350;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-350.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-400.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 450;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-450.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-500.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 550;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-550.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-600.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 650;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-650.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-700.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 750;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-750.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-800.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 850;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-850.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-900.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 950;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-950.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 1000;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-1000.woff2') format('woff2');
|
||||
}
|
||||
1
public/gh - logo - offblack.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
1
public/gh - logo - positive - full black.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
1
public/gh - logo - white.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
1
public/gh - symbol - full black.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/gh - symbol - full white.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/greyproxy - positive.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
1
public/greywall - positive.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
2
public/htmx.css
Normal file
1094
public/htmx.html
Normal file
459
scripts/generate-htmx-css.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
#!/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()
|
||||
475
scripts/generate-skill.ts
Normal file
@@ -0,0 +1,475 @@
|
||||
#!/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()
|
||||
110
skill/AGENTS.brand.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# 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.
|
||||
53
skill/AGENTS.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 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'); }
|
||||
```
|
||||
273
skill/BRAND.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# 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**.
|
||||
750
skill/SKILL.md
Normal file
@@ -0,0 +1,750 @@
|
||||
# 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="© 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 }
|
||||
```
|
||||
269
skill/install.sh
Executable file
@@ -0,0 +1,269 @@
|
||||
#!/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
|
||||
111
stories/Composition/CTASection.stories.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import { CTASection } from '@/components/ui/cta-section'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const meta = {
|
||||
title: 'Composition/CTASection',
|
||||
component: CTASection,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'fullscreen' },
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['centered', 'left-aligned'],
|
||||
},
|
||||
background: {
|
||||
control: 'select',
|
||||
options: ['default', 'muted', 'accent', 'subtle'],
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof CTASection>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const defaultActions = (
|
||||
<>
|
||||
<Button size="lg">Get Started</Button>
|
||||
<Button size="lg" variant="outline">Contact Sales</Button>
|
||||
</>
|
||||
)
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
heading: 'Ready to get started?',
|
||||
description: 'Join thousands of teams building better products with our design system.',
|
||||
actions: defaultActions,
|
||||
},
|
||||
}
|
||||
|
||||
export const Centered: Story = {
|
||||
args: {
|
||||
variant: 'centered',
|
||||
background: 'muted',
|
||||
heading: 'Start building today',
|
||||
description: 'Free for open source. Affordable for teams.',
|
||||
actions: defaultActions,
|
||||
},
|
||||
}
|
||||
|
||||
export const LeftAligned: Story = {
|
||||
args: {
|
||||
variant: 'left-aligned',
|
||||
background: 'muted',
|
||||
heading: 'Need help getting started?',
|
||||
description: 'Our team is ready to help you integrate the design system into your project.',
|
||||
actions: (
|
||||
<>
|
||||
<Button size="lg">Talk to us</Button>
|
||||
</>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export const AccentBackground: Story = {
|
||||
args: {
|
||||
variant: 'centered',
|
||||
background: 'accent',
|
||||
heading: 'Upgrade your workflow',
|
||||
description: 'Take your team to the next level with our premium plan.',
|
||||
actions: (
|
||||
<>
|
||||
<Button size="lg" variant="secondary">Start Free Trial</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="border-primary-foreground/20 text-primary-foreground hover:bg-primary-foreground/10"
|
||||
>
|
||||
Learn More
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export const SubtleBackground: Story = {
|
||||
args: {
|
||||
variant: 'centered',
|
||||
background: 'subtle',
|
||||
heading: 'Stay in the loop',
|
||||
description: 'Subscribe to our newsletter for the latest updates and releases.',
|
||||
actions: (
|
||||
<Button size="lg">Subscribe</Button>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export const DefaultBackground: Story = {
|
||||
args: {
|
||||
variant: 'centered',
|
||||
background: 'default',
|
||||
heading: 'Questions? We have answers.',
|
||||
description: 'Check out our documentation or reach out to our support team.',
|
||||
actions: (
|
||||
<>
|
||||
<Button size="lg">View Docs</Button>
|
||||
<Button size="lg" variant="ghost">Contact Support</Button>
|
||||
</>
|
||||
),
|
||||
},
|
||||
}
|
||||
92
stories/Composition/Footer.stories.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import { Footer } from '@/components/ui/footer'
|
||||
import { Logo } from '@/components/ui/logo'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const meta = {
|
||||
title: 'Composition/Footer',
|
||||
component: Footer,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'fullscreen' },
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['minimal', 'full'],
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Footer>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Minimal: Story = {
|
||||
args: {
|
||||
variant: 'minimal',
|
||||
logo: <Logo size="sm" />,
|
||||
copyright: <>© 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: <>© 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>
|
||||
),
|
||||
},
|
||||
}
|
||||
111
stories/Composition/Hero.stories.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import { Hero } from '@/components/ui/hero'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const meta = {
|
||||
title: 'Composition/Hero',
|
||||
component: Hero,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'fullscreen' },
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['centered', 'left-aligned', 'split'],
|
||||
},
|
||||
background: {
|
||||
control: 'select',
|
||||
options: ['default', 'muted', 'accent', 'dark'],
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Hero>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const defaultActions = (
|
||||
<>
|
||||
<Button size="lg">Get Started</Button>
|
||||
<Button size="lg" variant="outline">Learn More</Button>
|
||||
</>
|
||||
)
|
||||
|
||||
export const Centered: Story = {
|
||||
args: {
|
||||
variant: 'centered',
|
||||
heading: 'Build better products with Greyhaven',
|
||||
subheading:
|
||||
'A modern design system that helps you create consistent, accessible, and beautiful user interfaces.',
|
||||
actions: defaultActions,
|
||||
},
|
||||
}
|
||||
|
||||
export const LeftAligned: Story = {
|
||||
args: {
|
||||
variant: 'left-aligned',
|
||||
heading: 'Ship faster with confidence',
|
||||
subheading:
|
||||
'Pre-built components, design tokens, and patterns so your team can focus on what matters.',
|
||||
actions: defaultActions,
|
||||
},
|
||||
}
|
||||
|
||||
export const Split: Story = {
|
||||
args: {
|
||||
variant: 'split',
|
||||
heading: 'Design meets engineering',
|
||||
subheading:
|
||||
'Bridging the gap between design and code with a shared language of components and tokens.',
|
||||
actions: defaultActions,
|
||||
media: (
|
||||
<div className="w-full aspect-video bg-muted rounded-lg flex items-center justify-center text-muted-foreground">
|
||||
Image / Media Placeholder
|
||||
</div>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export const MutedBackground: Story = {
|
||||
args: {
|
||||
variant: 'centered',
|
||||
background: 'muted',
|
||||
heading: 'Welcome to the platform',
|
||||
subheading: 'Everything you need to build and scale your project.',
|
||||
actions: defaultActions,
|
||||
},
|
||||
}
|
||||
|
||||
export const AccentBackground: Story = {
|
||||
args: {
|
||||
variant: 'centered',
|
||||
background: 'accent',
|
||||
heading: 'Start building today',
|
||||
subheading: 'Join thousands of developers using our design system.',
|
||||
actions: defaultActions,
|
||||
},
|
||||
}
|
||||
|
||||
export const DarkBackground: Story = {
|
||||
args: {
|
||||
variant: 'centered',
|
||||
background: 'dark',
|
||||
heading: 'The future of design systems',
|
||||
subheading: 'A bold new approach to building consistent user interfaces at scale.',
|
||||
actions: (
|
||||
<>
|
||||
<Button size="lg" variant="secondary">Get Started</Button>
|
||||
<Button size="lg" variant="outline" className="border-background/20 text-background hover:bg-background/10">
|
||||
Learn More
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export const WithoutActions: Story = {
|
||||
args: {
|
||||
variant: 'centered',
|
||||
heading: 'A hero section without action buttons',
|
||||
subheading: 'Sometimes you just need a heading and description.',
|
||||
},
|
||||
}
|
||||
167
stories/Composition/PageLayout.stories.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import { PageLayout } from '@/components/ui/page-layout'
|
||||
import { Navbar, NavbarLink } from '@/components/ui/navbar'
|
||||
import { Footer } from '@/components/ui/footer'
|
||||
import { Hero } from '@/components/ui/hero'
|
||||
import { Section } from '@/components/ui/section'
|
||||
import { CTASection } from '@/components/ui/cta-section'
|
||||
import { Logo } from '@/components/ui/logo'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from '@/components/ui/card'
|
||||
|
||||
const meta = {
|
||||
title: 'Composition/PageLayout',
|
||||
component: PageLayout,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'fullscreen' },
|
||||
} satisfies Meta<typeof PageLayout>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const navLinks = (
|
||||
<>
|
||||
<NavbarLink href="#" active>Home</NavbarLink>
|
||||
<NavbarLink href="#">Features</NavbarLink>
|
||||
<NavbarLink href="#">Pricing</NavbarLink>
|
||||
<NavbarLink href="#">Docs</NavbarLink>
|
||||
</>
|
||||
)
|
||||
|
||||
const navActions = (
|
||||
<>
|
||||
<Button variant="ghost" size="sm">Log in</Button>
|
||||
<Button size="sm">Sign up</Button>
|
||||
</>
|
||||
)
|
||||
|
||||
const sampleNavbar = (
|
||||
<Navbar
|
||||
variant="solid"
|
||||
logo={<Logo size="sm" />}
|
||||
actions={navActions}
|
||||
>
|
||||
{navLinks}
|
||||
</Navbar>
|
||||
)
|
||||
|
||||
const sampleFooter = (
|
||||
<Footer
|
||||
variant="minimal"
|
||||
logo={<Logo size="sm" />}
|
||||
copyright={<>© 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>
|
||||
),
|
||||
},
|
||||
}
|
||||
135
stories/Composition/Section.stories.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import { Section } from '@/components/ui/section'
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from '@/components/ui/card'
|
||||
|
||||
const meta = {
|
||||
title: 'Composition/Section',
|
||||
component: Section,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'fullscreen' },
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['default', 'highlighted', 'accent'],
|
||||
},
|
||||
width: {
|
||||
control: 'select',
|
||||
options: ['narrow', 'default', 'wide', 'full'],
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Section>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const sampleCards = (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{['Design', 'Develop', 'Deploy'].map((title) => (
|
||||
<Card key={title}>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>Description for the {title.toLowerCase()} phase.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Content explaining the {title.toLowerCase()} process in detail.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
title: 'Our Process',
|
||||
description: 'How we build great products from concept to delivery.',
|
||||
children: sampleCards,
|
||||
},
|
||||
}
|
||||
|
||||
export const Highlighted: Story = {
|
||||
args: {
|
||||
variant: 'highlighted',
|
||||
title: 'Featured Section',
|
||||
description: 'This section uses a highlighted background to stand out.',
|
||||
children: sampleCards,
|
||||
},
|
||||
}
|
||||
|
||||
export const Accent: Story = {
|
||||
args: {
|
||||
variant: 'accent',
|
||||
title: 'Accent Section',
|
||||
description: 'A subtle accent background to differentiate this area.',
|
||||
children: sampleCards,
|
||||
},
|
||||
}
|
||||
|
||||
export const Narrow: Story = {
|
||||
args: {
|
||||
width: 'narrow',
|
||||
title: 'Narrow Section',
|
||||
description: 'Constrained width for focused reading.',
|
||||
children: (
|
||||
<p className="text-muted-foreground">
|
||||
This is a narrow section with max-w-3xl. Useful for text-heavy content that
|
||||
benefits from shorter line lengths for readability.
|
||||
</p>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export const Wide: Story = {
|
||||
args: {
|
||||
width: 'wide',
|
||||
title: 'Wide Section',
|
||||
description: 'Extended width for content-rich layouts.',
|
||||
children: sampleCards,
|
||||
},
|
||||
}
|
||||
|
||||
export const Full: Story = {
|
||||
args: {
|
||||
width: 'full',
|
||||
variant: 'highlighted',
|
||||
title: 'Full Width Section',
|
||||
description: 'Spans the full width of the viewport.',
|
||||
children: sampleCards,
|
||||
},
|
||||
}
|
||||
|
||||
export const NoHeader: Story = {
|
||||
args: {
|
||||
children: sampleCards,
|
||||
},
|
||||
}
|
||||
|
||||
export const AllCombinations: Story = {
|
||||
render: () => (
|
||||
<div>
|
||||
{(['default', 'highlighted', 'accent'] as const).map((variant) =>
|
||||
(['narrow', 'default', 'wide'] as const).map((width) => (
|
||||
<Section
|
||||
key={`${variant}-${width}`}
|
||||
variant={variant}
|
||||
width={width}
|
||||
title={`${variant} / ${width}`}
|
||||
description={`Section with variant="${variant}" and width="${width}".`}
|
||||
>
|
||||
<div className="h-20 rounded-lg border-2 border-dashed border-muted-foreground/25 flex items-center justify-center text-sm text-muted-foreground">
|
||||
Content area
|
||||
</div>
|
||||
</Section>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
88
stories/Data/Progress.stories.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
|
||||
const meta = {
|
||||
title: 'Data/Progress',
|
||||
component: Progress,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
argTypes: {
|
||||
value: {
|
||||
control: { type: 'range', min: 0, max: 100, step: 1 },
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="w-100">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof Progress>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
value: 60,
|
||||
},
|
||||
}
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
value: 0,
|
||||
},
|
||||
}
|
||||
|
||||
export const Quarter: Story = {
|
||||
args: {
|
||||
value: 25,
|
||||
},
|
||||
}
|
||||
|
||||
export const Half: Story = {
|
||||
args: {
|
||||
value: 50,
|
||||
},
|
||||
}
|
||||
|
||||
export const ThreeQuarters: Story = {
|
||||
args: {
|
||||
value: 75,
|
||||
},
|
||||
}
|
||||
|
||||
export const Complete: Story = {
|
||||
args: {
|
||||
value: 100,
|
||||
},
|
||||
}
|
||||
|
||||
export const AllStages: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-100">
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm text-muted-foreground">0%</span>
|
||||
<Progress value={0} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm text-muted-foreground">25%</span>
|
||||
<Progress value={25} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm text-muted-foreground">50%</span>
|
||||
<Progress value={50} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm text-muted-foreground">75%</span>
|
||||
<Progress value={75} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm text-muted-foreground">100%</span>
|
||||
<Progress value={100} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
117
stories/Data/Table.stories.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
} from '@/components/ui/table'
|
||||
|
||||
const meta = {
|
||||
title: 'Data/Table',
|
||||
component: Table,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
} satisfies Meta<typeof Table>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const invoices = [
|
||||
{ invoice: 'INV001', status: 'Paid', method: 'Credit Card', amount: '$250.00' },
|
||||
{ invoice: 'INV002', status: 'Pending', method: 'PayPal', amount: '$150.00' },
|
||||
{ invoice: 'INV003', status: 'Unpaid', method: 'Bank Transfer', amount: '$350.00' },
|
||||
{ invoice: 'INV004', status: 'Paid', method: 'Credit Card', amount: '$450.00' },
|
||||
{ invoice: 'INV005', status: 'Paid', method: 'PayPal', amount: '$550.00' },
|
||||
]
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<div className="w-150">
|
||||
<Table>
|
||||
<TableCaption>A list of your recent invoices.</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-25">Invoice</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Method</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invoices.map((invoice) => (
|
||||
<TableRow key={invoice.invoice}>
|
||||
<TableCell className="font-medium">{invoice.invoice}</TableCell>
|
||||
<TableCell>{invoice.status}</TableCell>
|
||||
<TableCell>{invoice.method}</TableCell>
|
||||
<TableCell className="text-right">{invoice.amount}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell colSpan={3}>Total</TableCell>
|
||||
<TableCell className="text-right">$1,750.00</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Simple: Story = {
|
||||
render: () => (
|
||||
<div className="w-100">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>Alice</TableCell>
|
||||
<TableCell>Engineer</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Bob</TableCell>
|
||||
<TableCell>Designer</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Charlie</TableCell>
|
||||
<TableCell>Manager</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Empty: Story = {
|
||||
render: () => (
|
||||
<div className="w-100">
|
||||
<Table>
|
||||
<TableCaption>No data available.</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center text-muted-foreground h-24">
|
||||
No results found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
71
stories/Feedback/Alert.stories.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { Terminal, AlertCircle } from 'lucide-react'
|
||||
|
||||
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
|
||||
const meta = {
|
||||
title: 'Feedback/Alert',
|
||||
component: Alert,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['default', 'destructive'],
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="w-125">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof Alert>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Alert>
|
||||
<Terminal className="size-4" />
|
||||
<AlertTitle>Heads up!</AlertTitle>
|
||||
<AlertDescription>
|
||||
You can add components to your app using the CLI.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
),
|
||||
}
|
||||
|
||||
export const Destructive: Story = {
|
||||
render: () => (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="size-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your session has expired. Please log in again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithoutIcon: Story = {
|
||||
render: () => (
|
||||
<Alert>
|
||||
<AlertTitle>Note</AlertTitle>
|
||||
<AlertDescription>
|
||||
This alert has no icon, just a title and description.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
),
|
||||
}
|
||||
|
||||
export const TitleOnly: Story = {
|
||||
render: () => (
|
||||
<Alert>
|
||||
<Terminal className="size-4" />
|
||||
<AlertTitle>A simple alert with only a title.</AlertTitle>
|
||||
</Alert>
|
||||
),
|
||||
}
|
||||
63
stories/Feedback/Skeleton.stories.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
const meta = {
|
||||
title: 'Feedback/Skeleton',
|
||||
component: Skeleton,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
} satisfies Meta<typeof Skeleton>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
className: 'h-4 w-62.5',
|
||||
},
|
||||
}
|
||||
|
||||
export const Circle: Story = {
|
||||
args: {
|
||||
className: 'size-12 rounded-full',
|
||||
},
|
||||
}
|
||||
|
||||
export const CardSkeleton: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center space-x-4">
|
||||
<Skeleton className="size-12 rounded-full" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-62.5" />
|
||||
<Skeleton className="h-4 w-50" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const FormSkeleton: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-4 w-75">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-30" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-25" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const TextBlock: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-2 w-87.5">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
54
stories/Feedback/Spinner.stories.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
|
||||
const meta = {
|
||||
title: 'Feedback/Spinner',
|
||||
component: Spinner,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
} satisfies Meta<typeof Spinner>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
className: 'size-3 animate-spin',
|
||||
},
|
||||
}
|
||||
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
className: 'size-8 animate-spin',
|
||||
},
|
||||
}
|
||||
|
||||
export const ExtraLarge: Story = {
|
||||
args: {
|
||||
className: 'size-12 animate-spin',
|
||||
},
|
||||
}
|
||||
|
||||
export const WithText: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner />
|
||||
<span className="text-sm text-muted-foreground">Loading...</span>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-4">
|
||||
<Spinner className="size-3 animate-spin" />
|
||||
<Spinner />
|
||||
<Spinner className="size-6 animate-spin" />
|
||||
<Spinner className="size-8 animate-spin" />
|
||||
<Spinner className="size-12 animate-spin" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
211
stories/Form/Form.stories.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import React from 'react'
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const meta = {
|
||||
title: 'Form/Form',
|
||||
component: Form,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
} satisfies Meta<typeof Form>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const profileSchema = z.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(2, { message: 'Username must be at least 2 characters.' })
|
||||
.max(30, { message: 'Username must not be longer than 30 characters.' }),
|
||||
email: z.string().email({ message: 'Please enter a valid email address.' }),
|
||||
})
|
||||
|
||||
type ProfileValues = z.infer<typeof profileSchema>
|
||||
|
||||
function ProfileForm() {
|
||||
const form = useForm<ProfileValues>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
email: '',
|
||||
},
|
||||
})
|
||||
|
||||
function onSubmit(data: ProfileValues) {
|
||||
alert(JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="w-100 space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="johndoe" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is your public display name.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" placeholder="john@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
We will never share your email with anyone.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit">Submit</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => <ProfileForm />,
|
||||
}
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email({ message: 'Invalid email address.' }),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, { message: 'Password must be at least 8 characters.' }),
|
||||
})
|
||||
|
||||
type LoginValues = z.infer<typeof loginSchema>
|
||||
|
||||
function LoginForm() {
|
||||
const form = useForm<LoginValues>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
})
|
||||
|
||||
function onSubmit(data: LoginValues) {
|
||||
alert(JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="w-100 space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" placeholder="you@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="Enter password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" className="w-full">
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export const Login: Story = {
|
||||
render: () => <LoginForm />,
|
||||
}
|
||||
|
||||
function PrefilledErrorForm() {
|
||||
const form = useForm<ProfileValues>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
defaultValues: {
|
||||
username: 'a',
|
||||
email: 'not-an-email',
|
||||
},
|
||||
})
|
||||
|
||||
// Trigger validation on mount
|
||||
React.useEffect(() => {
|
||||
form.trigger()
|
||||
}, [form])
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form className="w-100 space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit">Submit</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export const WithErrors: Story = {
|
||||
render: () => <PrefilledErrorForm />,
|
||||
}
|
||||
87
stories/Layout/Accordion.stories.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
AccordionContent,
|
||||
} from '@/components/ui/accordion'
|
||||
|
||||
const meta = {
|
||||
title: 'Layout/Accordion',
|
||||
component: Accordion,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
} satisfies Meta<typeof Accordion>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Single: Story = {
|
||||
render: () => (
|
||||
<Accordion type="single" collapsible className="w-100">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>Is it accessible?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Yes. It adheres to the WAI-ARIA design pattern.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger>Is it styled?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Yes. It comes with default styles that match the other components.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger>Is it animated?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Yes. It uses CSS animations for smooth open and close transitions.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
),
|
||||
}
|
||||
|
||||
export const Multiple: Story = {
|
||||
render: () => (
|
||||
<Accordion type="multiple" className="w-100">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>What is Greyhaven?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Greyhaven is a design system built with Radix UI and Tailwind CSS.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger>How do I install it?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
You can install it via npm or pnpm. Check the documentation for details.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger>Can I customize themes?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Absolutely. The design system uses CSS custom properties for full theme control.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
),
|
||||
}
|
||||
|
||||
export const DefaultOpen: Story = {
|
||||
render: () => (
|
||||
<Accordion type="single" defaultValue="item-1" collapsible className="w-100">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>Open by default</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
This accordion item is open by default.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger>Click to open</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
This accordion item starts closed.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
),
|
||||
}
|
||||
97
stories/Layout/Card.stories.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardAction,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const meta = {
|
||||
title: 'Layout/Card',
|
||||
component: Card,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
} satisfies Meta<typeof Card>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Card className="w-87.5">
|
||||
<CardHeader>
|
||||
<CardTitle>Card Title</CardTitle>
|
||||
<CardDescription>Card description goes here.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>Card content with some example text to demonstrate the layout.</p>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button>Action</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithAction: Story = {
|
||||
render: () => (
|
||||
<Card className="w-87.5">
|
||||
<CardHeader>
|
||||
<CardTitle>Notifications</CardTitle>
|
||||
<CardDescription>You have 3 unread messages.</CardDescription>
|
||||
<CardAction>
|
||||
<Button variant="outline" size="sm">Mark all read</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>Here are your latest notifications.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
}
|
||||
|
||||
export const Simple: Story = {
|
||||
render: () => (
|
||||
<Card className="w-87.5">
|
||||
<CardHeader>
|
||||
<CardTitle>Simple Card</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>A card with just a title and content.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithFooter: Story = {
|
||||
render: () => (
|
||||
<Card className="w-87.5">
|
||||
<CardHeader>
|
||||
<CardTitle>Create project</CardTitle>
|
||||
<CardDescription>Deploy your new project in one click.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>Configure your project settings below.</p>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button variant="outline">Cancel</Button>
|
||||
<Button>Deploy</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
),
|
||||
}
|
||||
|
||||
export const ContentOnly: Story = {
|
||||
render: () => (
|
||||
<Card className="w-87.5">
|
||||
<CardContent>
|
||||
<p>A minimal card with only content, no header or footer.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
}
|
||||
59
stories/Layout/Separator.stories.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
const meta = {
|
||||
title: 'Layout/Separator',
|
||||
component: Separator,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
argTypes: {
|
||||
orientation: {
|
||||
control: 'select',
|
||||
options: ['horizontal', 'vertical'],
|
||||
},
|
||||
decorative: { control: 'boolean' },
|
||||
},
|
||||
} satisfies Meta<typeof Separator>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Horizontal: Story = {
|
||||
args: {
|
||||
orientation: 'horizontal',
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="w-75">
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-medium leading-none">Greyhaven Design System</h4>
|
||||
<p className="text-sm text-muted-foreground">An open-source UI component library.</p>
|
||||
</div>
|
||||
<Story />
|
||||
<div className="flex h-5 items-center space-x-4 text-sm">
|
||||
<div>Blog</div>
|
||||
<div>Docs</div>
|
||||
<div>Source</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
export const Vertical: Story = {
|
||||
args: {
|
||||
orientation: 'vertical',
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="flex h-5 items-center space-x-4 text-sm">
|
||||
<div>Blog</div>
|
||||
<Story />
|
||||
<div>Docs</div>
|
||||
<Story />
|
||||
<div>Source</div>
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
109
stories/Navigation/Breadcrumb.stories.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
} from '@/components/ui/breadcrumb'
|
||||
|
||||
const meta = {
|
||||
title: 'Navigation/Breadcrumb',
|
||||
component: Breadcrumb,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
} satisfies Meta<typeof Breadcrumb>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#">Home</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#">Components</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Breadcrumb</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithEllipsis: Story = {
|
||||
render: () => (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#">Home</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbEllipsis />
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#">Components</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Breadcrumb</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
),
|
||||
}
|
||||
|
||||
export const TwoLevels: Story = {
|
||||
render: () => (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#">Dashboard</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Settings</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
),
|
||||
}
|
||||
|
||||
export const DeepNesting: Story = {
|
||||
render: () => (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#">Home</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#">Products</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#">Electronics</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#">Laptops</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>MacBook Pro</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
),
|
||||
}
|
||||
94
stories/Navigation/Navbar.stories.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import { Navbar, NavbarLink } from '@/components/ui/navbar'
|
||||
import { Logo } from '@/components/ui/logo'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const meta = {
|
||||
title: 'Navigation/Navbar',
|
||||
component: Navbar,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'fullscreen' },
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['solid', 'transparent', 'minimal'],
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="min-h-[200px]">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof Navbar>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const navLinks = (
|
||||
<>
|
||||
<NavbarLink href="#" active>Home</NavbarLink>
|
||||
<NavbarLink href="#">About</NavbarLink>
|
||||
<NavbarLink href="#">Services</NavbarLink>
|
||||
<NavbarLink href="#">Contact</NavbarLink>
|
||||
</>
|
||||
)
|
||||
|
||||
const navActions = (
|
||||
<>
|
||||
<Button variant="ghost" size="sm">Log in</Button>
|
||||
<Button size="sm">Sign up</Button>
|
||||
</>
|
||||
)
|
||||
|
||||
export const Solid: Story = {
|
||||
args: {
|
||||
variant: 'solid',
|
||||
logo: <Logo size="sm" />,
|
||||
actions: navActions,
|
||||
children: navLinks,
|
||||
},
|
||||
}
|
||||
|
||||
export const Transparent: Story = {
|
||||
args: {
|
||||
variant: 'transparent',
|
||||
logo: <Logo size="sm" />,
|
||||
actions: navActions,
|
||||
children: navLinks,
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="min-h-[200px] bg-gradient-to-br from-primary/20 to-primary/5">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
export const Minimal: Story = {
|
||||
args: {
|
||||
variant: 'minimal',
|
||||
logo: <Logo size="sm" />,
|
||||
actions: navActions,
|
||||
children: navLinks,
|
||||
},
|
||||
}
|
||||
|
||||
export const WithoutActions: Story = {
|
||||
args: {
|
||||
variant: 'solid',
|
||||
logo: <Logo size="sm" />,
|
||||
children: navLinks,
|
||||
},
|
||||
}
|
||||
|
||||
export const LogoOnly: Story = {
|
||||
args: {
|
||||
variant: 'solid',
|
||||
logo: <Logo size="sm" />,
|
||||
actions: navActions,
|
||||
},
|
||||
}
|
||||
72
stories/Overlay/AlertDialog.stories.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const meta = {
|
||||
title: 'Overlay/AlertDialog',
|
||||
component: AlertDialog,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
} satisfies Meta<typeof AlertDialog>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline">Delete Account</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete your account
|
||||
and remove your data from our servers.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction>Continue</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
),
|
||||
}
|
||||
|
||||
export const Destructive: Story = {
|
||||
render: () => (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive">Delete Project</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete project?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete the project and all associated data.
|
||||
This action cannot be reversed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction className="bg-destructive text-white hover:bg-destructive/90">
|
||||
Yes, delete project
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
),
|
||||
}
|
||||
106
stories/Overlay/Dialog.stories.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
const meta = {
|
||||
title: 'Overlay/Dialog',
|
||||
component: Dialog,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
} satisfies Meta<typeof Dialog>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">Open Dialog</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Dialog Title</DialogTitle>
|
||||
<DialogDescription>
|
||||
This is a dialog description. It provides context about the dialog content.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p>Dialog body content goes here.</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button>Confirm</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithForm: Story = {
|
||||
render: () => (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button>Edit Profile</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit profile</DialogTitle>
|
||||
<DialogDescription>
|
||||
Make changes to your profile here. Click save when you are done.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input id="name" defaultValue="John Doe" />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input id="username" defaultValue="@johndoe" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit">Save changes</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
),
|
||||
}
|
||||
|
||||
export const NoCloseButton: Story = {
|
||||
render: () => (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">Open (no close button)</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>No Close Button</DialogTitle>
|
||||
<DialogDescription>
|
||||
This dialog has no close button in the corner.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button>Got it</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
),
|
||||
}
|
||||
99
stories/Overlay/Tooltip.stories.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { Plus } from 'lucide-react'
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const meta = {
|
||||
title: 'Overlay/Tooltip',
|
||||
component: Tooltip,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
} satisfies Meta<typeof Tooltip>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline">Hover me</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>This is a tooltip</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
}
|
||||
|
||||
export const Top: Story = {
|
||||
render: () => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline">Top</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Tooltip on top</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
}
|
||||
|
||||
export const Right: Story = {
|
||||
render: () => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline">Right</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Tooltip on right</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
}
|
||||
|
||||
export const Bottom: Story = {
|
||||
render: () => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline">Bottom</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Tooltip on bottom</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
}
|
||||
|
||||
export const Left: Story = {
|
||||
render: () => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline">Left</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>Tooltip on left</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithIconButton: Story = {
|
||||
render: () => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="icon" variant="outline">
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Add item</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
}
|
||||
223
stories/Primitives/Badge.stories.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
const meta = {
|
||||
title: 'Primitives/Badge',
|
||||
component: Badge,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: [
|
||||
'default',
|
||||
'secondary',
|
||||
'muted',
|
||||
'destructive',
|
||||
'outline',
|
||||
'success',
|
||||
'warning',
|
||||
'info',
|
||||
'tag',
|
||||
'value',
|
||||
'whatsapp',
|
||||
'email',
|
||||
'telegram',
|
||||
'zulip',
|
||||
'platform',
|
||||
],
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['sm', 'default', 'lg'],
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Badge>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: 'Badge',
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
children: 'Secondary',
|
||||
variant: 'secondary',
|
||||
},
|
||||
}
|
||||
|
||||
export const Muted: Story = {
|
||||
args: {
|
||||
children: 'Muted',
|
||||
variant: 'muted',
|
||||
},
|
||||
}
|
||||
|
||||
export const Destructive: Story = {
|
||||
args: {
|
||||
children: 'Destructive',
|
||||
variant: 'destructive',
|
||||
},
|
||||
}
|
||||
|
||||
export const Outline: Story = {
|
||||
args: {
|
||||
children: 'Outline',
|
||||
variant: 'outline',
|
||||
},
|
||||
}
|
||||
|
||||
export const Success: Story = {
|
||||
args: {
|
||||
children: 'Success',
|
||||
variant: 'success',
|
||||
},
|
||||
}
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
children: 'Warning',
|
||||
variant: 'warning',
|
||||
},
|
||||
}
|
||||
|
||||
export const Info: Story = {
|
||||
args: {
|
||||
children: 'Info',
|
||||
variant: 'info',
|
||||
},
|
||||
}
|
||||
|
||||
export const Tag: Story = {
|
||||
args: {
|
||||
children: 'Tag',
|
||||
variant: 'tag',
|
||||
},
|
||||
}
|
||||
|
||||
export const Value: Story = {
|
||||
args: {
|
||||
children: '42',
|
||||
variant: 'value',
|
||||
},
|
||||
}
|
||||
|
||||
export const Whatsapp: Story = {
|
||||
args: {
|
||||
children: 'WhatsApp',
|
||||
variant: 'whatsapp',
|
||||
},
|
||||
}
|
||||
|
||||
export const Email: Story = {
|
||||
args: {
|
||||
children: 'Email',
|
||||
variant: 'email',
|
||||
},
|
||||
}
|
||||
|
||||
export const Telegram: Story = {
|
||||
args: {
|
||||
children: 'Telegram',
|
||||
variant: 'telegram',
|
||||
},
|
||||
}
|
||||
|
||||
export const Zulip: Story = {
|
||||
args: {
|
||||
children: 'Zulip',
|
||||
variant: 'zulip',
|
||||
},
|
||||
}
|
||||
|
||||
export const Platform: Story = {
|
||||
args: {
|
||||
children: 'Platform',
|
||||
variant: 'platform',
|
||||
},
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(
|
||||
[
|
||||
'default',
|
||||
'secondary',
|
||||
'muted',
|
||||
'destructive',
|
||||
'outline',
|
||||
'success',
|
||||
'warning',
|
||||
'info',
|
||||
'tag',
|
||||
'value',
|
||||
'whatsapp',
|
||||
'email',
|
||||
'telegram',
|
||||
'zulip',
|
||||
'platform',
|
||||
] as const
|
||||
).map((variant) => (
|
||||
<Badge key={variant} variant={variant}>
|
||||
{variant}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const SizeSmall: Story = {
|
||||
args: {
|
||||
children: 'Small',
|
||||
variant: 'secondary',
|
||||
size: 'sm',
|
||||
},
|
||||
}
|
||||
|
||||
export const SizeDefault: Story = {
|
||||
args: {
|
||||
children: 'Default',
|
||||
variant: 'secondary',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
|
||||
export const SizeLarge: Story = {
|
||||
args: {
|
||||
children: 'Large',
|
||||
variant: 'default',
|
||||
size: 'lg',
|
||||
},
|
||||
}
|
||||
|
||||
export const AllSizes: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-20 text-sm text-muted-foreground font-sans">sm</span>
|
||||
<Badge variant="secondary" size="sm">dense</Badge>
|
||||
<Badge variant="success" size="sm">12 new</Badge>
|
||||
<Badge variant="muted" size="sm">draft</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-20 text-sm text-muted-foreground font-sans">default</span>
|
||||
<Badge variant="default" size="default">Active</Badge>
|
||||
<Badge variant="success" size="default">Published</Badge>
|
||||
<Badge variant="warning" size="default">Pending</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-20 text-sm text-muted-foreground font-sans">lg</span>
|
||||
<Badge variant="default" size="lg">New feature</Badge>
|
||||
<Badge variant="info" size="lg">Beta</Badge>
|
||||
<Badge variant="success" size="lg">Available</Badge>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
171
stories/Primitives/Button.stories.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { ChevronRight, Mail, Loader2, Plus } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const meta = {
|
||||
title: 'Primitives/Button',
|
||||
component: Button,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['default', 'secondary', 'outline', 'ghost', 'link', 'destructive'],
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['default', 'sm', 'lg', 'icon', 'icon-sm', 'icon-lg'],
|
||||
},
|
||||
disabled: { control: 'boolean' },
|
||||
asChild: { control: 'boolean' },
|
||||
},
|
||||
} satisfies Meta<typeof Button>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: 'Button',
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
children: 'Secondary',
|
||||
variant: 'secondary',
|
||||
},
|
||||
}
|
||||
|
||||
export const Outline: Story = {
|
||||
args: {
|
||||
children: 'Outline',
|
||||
variant: 'outline',
|
||||
},
|
||||
}
|
||||
|
||||
export const Ghost: Story = {
|
||||
args: {
|
||||
children: 'Ghost',
|
||||
variant: 'ghost',
|
||||
},
|
||||
}
|
||||
|
||||
export const Link: Story = {
|
||||
args: {
|
||||
children: 'Link',
|
||||
variant: 'link',
|
||||
},
|
||||
}
|
||||
|
||||
export const Destructive: Story = {
|
||||
args: {
|
||||
children: 'Delete',
|
||||
variant: 'destructive',
|
||||
},
|
||||
}
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
children: 'Small',
|
||||
size: 'sm',
|
||||
},
|
||||
}
|
||||
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
children: 'Large',
|
||||
size: 'lg',
|
||||
},
|
||||
}
|
||||
|
||||
export const Icon: Story = {
|
||||
args: {
|
||||
size: 'icon',
|
||||
children: <Plus className="size-4" />,
|
||||
'aria-label': 'Add',
|
||||
},
|
||||
}
|
||||
|
||||
export const IconSmall: Story = {
|
||||
args: {
|
||||
size: 'icon-sm',
|
||||
children: <Plus className="size-4" />,
|
||||
'aria-label': 'Add',
|
||||
},
|
||||
}
|
||||
|
||||
export const IconLarge: Story = {
|
||||
args: {
|
||||
size: 'icon-lg',
|
||||
children: <Plus className="size-4" />,
|
||||
'aria-label': 'Add',
|
||||
},
|
||||
}
|
||||
|
||||
export const WithIcon: Story = {
|
||||
args: {
|
||||
children: (
|
||||
<>
|
||||
<Mail /> Login with Email
|
||||
</>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export const WithTrailingIcon: Story = {
|
||||
args: {
|
||||
children: (
|
||||
<>
|
||||
Next <ChevronRight />
|
||||
</>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
disabled: true,
|
||||
children: (
|
||||
<>
|
||||
<Loader2 className="animate-spin" /> Please wait
|
||||
</>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
children: 'Disabled',
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const AsChild: Story = {
|
||||
args: {
|
||||
asChild: true,
|
||||
children: <a href="#">Link styled as Button</a>,
|
||||
},
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4">
|
||||
{(['default', 'secondary', 'outline', 'ghost', 'link', 'destructive'] as const).map(
|
||||
(variant) => (
|
||||
<div key={variant} className="flex items-center gap-2">
|
||||
<span className="w-24 text-sm text-muted-foreground">{variant}</span>
|
||||
<Button variant={variant} size="sm">Small</Button>
|
||||
<Button variant={variant} size="default">Default</Button>
|
||||
<Button variant={variant} size="lg">Large</Button>
|
||||
<Button variant={variant} size="icon"><Plus className="size-4" /></Button>
|
||||
<Button variant={variant} disabled>Disabled</Button>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
90
stories/Primitives/Code.stories.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import { Code } from '@/components/ui/code'
|
||||
|
||||
const meta = {
|
||||
title: 'Primitives/Code',
|
||||
component: Code,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['inline', 'block'],
|
||||
},
|
||||
language: { control: 'text' },
|
||||
},
|
||||
} satisfies Meta<typeof Code>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Inline: Story = {
|
||||
args: {
|
||||
variant: 'inline',
|
||||
children: 'pnpm install',
|
||||
},
|
||||
}
|
||||
|
||||
export const InlineInSentence: Story = {
|
||||
render: () => (
|
||||
<p className="font-serif text-base leading-relaxed max-w-prose">
|
||||
To get started, run <Code>pnpm install</Code> and then{' '}
|
||||
<Code>pnpm dev</Code> to start the development server on{' '}
|
||||
<Code>localhost:3000</Code>.
|
||||
</p>
|
||||
),
|
||||
}
|
||||
|
||||
export const Block: Story = {
|
||||
args: {
|
||||
variant: 'block',
|
||||
language: 'bash',
|
||||
children: `pnpm install
|
||||
pnpm dev
|
||||
pnpm build`,
|
||||
},
|
||||
}
|
||||
|
||||
export const BlockLongCommand: Story = {
|
||||
args: {
|
||||
variant: 'block',
|
||||
language: 'bash',
|
||||
children:
|
||||
'curl -fsSL https://example.com/install.sh | bash -s -- --prefix=/usr/local --no-color --very-long-flag-that-should-wrap',
|
||||
},
|
||||
}
|
||||
|
||||
export const BlockTypescript: Story = {
|
||||
args: {
|
||||
variant: 'block',
|
||||
language: 'ts',
|
||||
children: `import { Code } from '@/components/ui/code'
|
||||
|
||||
export function Example() {
|
||||
return <Code variant="block">Hello, world!</Code>
|
||||
}`,
|
||||
},
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-6 max-w-2xl">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-2 font-sans">Inline</p>
|
||||
<p className="font-serif">
|
||||
Use <Code>cn()</Code> from <Code>@/lib/utils</Code> to merge Tailwind classes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-2 font-sans">Block</p>
|
||||
<Code variant="block" language="bash">
|
||||
{`# install and run
|
||||
pnpm install
|
||||
pnpm dev`}
|
||||
</Code>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
88
stories/Primitives/Input.stories.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
const meta = {
|
||||
title: 'Primitives/Input',
|
||||
component: Input,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
argTypes: {
|
||||
type: {
|
||||
control: 'select',
|
||||
options: ['text', 'email', 'password', 'number', 'search', 'tel', 'url', 'file'],
|
||||
},
|
||||
disabled: { control: 'boolean' },
|
||||
placeholder: { control: 'text' },
|
||||
},
|
||||
} satisfies Meta<typeof Input>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
placeholder: 'Enter text...',
|
||||
type: 'text',
|
||||
},
|
||||
}
|
||||
|
||||
export const Email: Story = {
|
||||
args: {
|
||||
type: 'email',
|
||||
placeholder: 'email@example.com',
|
||||
},
|
||||
}
|
||||
|
||||
export const Password: Story = {
|
||||
args: {
|
||||
type: 'password',
|
||||
placeholder: 'Enter password...',
|
||||
},
|
||||
}
|
||||
|
||||
export const File: Story = {
|
||||
args: {
|
||||
type: 'file',
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
placeholder: 'Disabled input',
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const WithLabel: Story = {
|
||||
render: () => (
|
||||
<div className="grid w-full max-w-sm gap-2">
|
||||
<Label htmlFor="with-label-input">Email</Label>
|
||||
<Input id="with-label-input" type="email" placeholder="email@example.com" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const ErrorState: Story = {
|
||||
render: () => (
|
||||
<div className="grid w-full max-w-sm gap-2">
|
||||
<Label htmlFor="error-input">Email</Label>
|
||||
<Input
|
||||
id="error-input"
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
aria-invalid="true"
|
||||
defaultValue="invalid-email"
|
||||
/>
|
||||
<p className="text-sm text-destructive">Please enter a valid email address.</p>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithDefaultValue: Story = {
|
||||
args: {
|
||||
type: 'text',
|
||||
defaultValue: 'Hello world',
|
||||
},
|
||||
}
|
||||
103
stories/Primitives/Toggle.stories.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { Bold, Italic, Underline } from 'lucide-react'
|
||||
|
||||
import { Toggle } from '@/components/ui/toggle'
|
||||
|
||||
const meta = {
|
||||
title: 'Primitives/Toggle',
|
||||
component: Toggle,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['default', 'outline'],
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['default', 'sm', 'lg'],
|
||||
},
|
||||
disabled: { control: 'boolean' },
|
||||
},
|
||||
} satisfies Meta<typeof Toggle>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: <Bold className="size-4" />,
|
||||
'aria-label': 'Toggle bold',
|
||||
},
|
||||
}
|
||||
|
||||
export const Outline: Story = {
|
||||
args: {
|
||||
variant: 'outline',
|
||||
children: <Italic className="size-4" />,
|
||||
'aria-label': 'Toggle italic',
|
||||
},
|
||||
}
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
size: 'sm',
|
||||
children: <Bold className="size-4" />,
|
||||
'aria-label': 'Toggle bold',
|
||||
},
|
||||
}
|
||||
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
size: 'lg',
|
||||
children: <Bold className="size-4" />,
|
||||
'aria-label': 'Toggle bold',
|
||||
},
|
||||
}
|
||||
|
||||
export const WithText: Story = {
|
||||
args: {
|
||||
children: (
|
||||
<>
|
||||
<Italic className="size-4" />
|
||||
Italic
|
||||
</>
|
||||
),
|
||||
'aria-label': 'Toggle italic',
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
disabled: true,
|
||||
children: <Bold className="size-4" />,
|
||||
'aria-label': 'Toggle bold',
|
||||
},
|
||||
}
|
||||
|
||||
export const Pressed: Story = {
|
||||
args: {
|
||||
defaultPressed: true,
|
||||
children: <Bold className="size-4" />,
|
||||
'aria-label': 'Toggle bold',
|
||||
},
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-20 text-sm text-muted-foreground">default</span>
|
||||
<Toggle size="sm" aria-label="Bold"><Bold className="size-4" /></Toggle>
|
||||
<Toggle size="default" aria-label="Italic"><Italic className="size-4" /></Toggle>
|
||||
<Toggle size="lg" aria-label="Underline"><Underline className="size-4" /></Toggle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-20 text-sm text-muted-foreground">outline</span>
|
||||
<Toggle variant="outline" size="sm" aria-label="Bold"><Bold className="size-4" /></Toggle>
|
||||
<Toggle variant="outline" size="default" aria-label="Italic"><Italic className="size-4" /></Toggle>
|
||||
<Toggle variant="outline" size="lg" aria-label="Underline"><Underline className="size-4" /></Toggle>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
190
style-dictionary.config.mjs
Normal file
@@ -0,0 +1,190 @@
|
||||
import StyleDictionary from 'style-dictionary';
|
||||
|
||||
// Custom format: CSS custom properties with RGB triplets for Tailwind v4 compatibility
|
||||
// Outputs `--background: 240 240 236;` format that existing components expect
|
||||
StyleDictionary.registerFormat({
|
||||
name: 'css/rgb-variables',
|
||||
format: ({ dictionary, options }) => {
|
||||
const selector = options.selector || ':root';
|
||||
const header = options.header || '';
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (!result) return null;
|
||||
return `${parseInt(result[1], 16)} ${parseInt(result[2], 16)} ${parseInt(result[3], 16)}`;
|
||||
}
|
||||
|
||||
// Map semantic token paths to CSS variable names
|
||||
function getCssVarName(token) {
|
||||
const path = token.path;
|
||||
// color.semantic.X or color.dark.X → --X
|
||||
if (path[0] === 'color' && (path[1] === 'semantic' || path[1] === 'dark')) {
|
||||
const rest = path.slice(2);
|
||||
// Handle nested like chart.1, sidebar.background
|
||||
if (rest[0] === 'chart') return `--chart-${rest[1]}`;
|
||||
if (rest[0] === 'sidebar') {
|
||||
const subParts = rest.slice(1);
|
||||
if (subParts.length === 1 && subParts[0] === 'background') return '--sidebar';
|
||||
return `--sidebar-${subParts.join('-')}`;
|
||||
}
|
||||
return `--${rest.join('-')}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
dictionary.allTokens.forEach((token) => {
|
||||
const varName = getCssVarName(token);
|
||||
if (!varName) return;
|
||||
|
||||
const value = token.value || token.$value;
|
||||
const rgb = hexToRgb(value);
|
||||
if (rgb) {
|
||||
const desc = token.$description || token.description;
|
||||
if (desc) lines.push(` /* ${desc} */`);
|
||||
lines.push(` ${varName}: ${rgb};`);
|
||||
}
|
||||
});
|
||||
|
||||
return `${header}\n${selector} {\n${lines.join('\n')}\n}`;
|
||||
},
|
||||
});
|
||||
|
||||
// Custom format: TypeScript constants
|
||||
StyleDictionary.registerFormat({
|
||||
name: 'typescript/constants',
|
||||
format: ({ dictionary }) => {
|
||||
const lines = [
|
||||
'// Auto-generated by Style Dictionary — DO NOT EDIT',
|
||||
'// Source: tokens/*.json (W3C DTCG format)',
|
||||
'',
|
||||
];
|
||||
|
||||
// Group tokens by top-level category
|
||||
const groups = {};
|
||||
dictionary.allTokens.forEach((token) => {
|
||||
const group = token.path[0];
|
||||
if (!groups[group]) groups[group] = [];
|
||||
groups[group].push(token);
|
||||
});
|
||||
|
||||
for (const [group, tokens] of Object.entries(groups)) {
|
||||
const constName = group.charAt(0).toUpperCase() + group.slice(1) + 'Tokens';
|
||||
lines.push(`export const ${constName} = {`);
|
||||
tokens.forEach((token) => {
|
||||
const key = token.path.slice(1).join('.');
|
||||
const value = token.value || token.$value;
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
lines.push(` '${key}': '${value}',`);
|
||||
}
|
||||
});
|
||||
lines.push('} as const;');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
},
|
||||
});
|
||||
|
||||
// Custom format: Markdown reference
|
||||
StyleDictionary.registerFormat({
|
||||
name: 'markdown/reference',
|
||||
format: ({ dictionary }) => {
|
||||
const lines = [
|
||||
'# Greyhaven Design Tokens Reference',
|
||||
'',
|
||||
'> Auto-generated by Style Dictionary — DO NOT EDIT',
|
||||
'> Source: `tokens/*.json` (W3C DTCG format)',
|
||||
'',
|
||||
];
|
||||
|
||||
const groups = {};
|
||||
dictionary.allTokens.forEach((token) => {
|
||||
const group = token.path[0];
|
||||
if (!groups[group]) groups[group] = [];
|
||||
groups[group].push(token);
|
||||
});
|
||||
|
||||
for (const [group, tokens] of Object.entries(groups)) {
|
||||
lines.push(`## ${group.charAt(0).toUpperCase() + group.slice(1)}`);
|
||||
lines.push('');
|
||||
lines.push('| Token | Value | Description |');
|
||||
lines.push('|-------|-------|-------------|');
|
||||
tokens.forEach((token) => {
|
||||
const name = token.path.join('.');
|
||||
const value = token.value || token.$value;
|
||||
const desc = token.$description || token.description || '';
|
||||
const displayValue = typeof value === 'object' ? JSON.stringify(value) : value;
|
||||
lines.push(`| \`${name}\` | \`${displayValue}\` | ${desc} |`);
|
||||
});
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
source: ['tokens/**/*.json'],
|
||||
preprocessors: ['tokens-studio'],
|
||||
platforms: {
|
||||
// CSS custom properties for light theme (semantic tokens)
|
||||
cssLight: {
|
||||
transformGroup: 'css',
|
||||
buildPath: 'app/tokens/',
|
||||
files: [
|
||||
{
|
||||
destination: 'tokens-light.css',
|
||||
format: 'css/rgb-variables',
|
||||
filter: (token) => {
|
||||
return token.path[0] === 'color' && token.path[1] === 'semantic';
|
||||
},
|
||||
options: {
|
||||
selector: ':root',
|
||||
header: '/* Greyhaven Design Tokens — Light Theme\n Auto-generated by Style Dictionary — DO NOT EDIT\n Source: tokens/color.json */\n',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// CSS custom properties for dark theme
|
||||
cssDark: {
|
||||
transformGroup: 'css',
|
||||
buildPath: 'app/tokens/',
|
||||
files: [
|
||||
{
|
||||
destination: 'tokens-dark.css',
|
||||
format: 'css/rgb-variables',
|
||||
filter: (token) => {
|
||||
return token.path[0] === 'color' && token.path[1] === 'dark';
|
||||
},
|
||||
options: {
|
||||
selector: '.dark',
|
||||
header: '/* Greyhaven Design Tokens — Dark Theme\n Auto-generated by Style Dictionary — DO NOT EDIT\n Source: tokens/color.json */\n',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// TypeScript constants
|
||||
ts: {
|
||||
transformGroup: 'js',
|
||||
buildPath: 'app/tokens/',
|
||||
files: [
|
||||
{
|
||||
destination: 'tokens.ts',
|
||||
format: 'typescript/constants',
|
||||
},
|
||||
],
|
||||
},
|
||||
// Markdown reference
|
||||
docs: {
|
||||
transformGroup: 'css',
|
||||
buildPath: 'app/tokens/',
|
||||
files: [
|
||||
{
|
||||
destination: 'TOKENS.md',
|
||||
format: 'markdown/reference',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
168
tokens/build/TOKENS.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# Greyhaven Design Tokens Reference
|
||||
|
||||
> Auto-generated by Style Dictionary — DO NOT EDIT
|
||||
> Source: `tokens/*.json` (W3C DTCG format)
|
||||
|
||||
## Color
|
||||
|
||||
| Token | Value | Description |
|
||||
|-------|-------|-------------|
|
||||
| `color.primitive.off-white` | `#f9f9f7` | Primary light surface — cards, elevated areas |
|
||||
| `color.primitive.off-black` | `#161614` | Primary dark — foreground text, dark mode background |
|
||||
| `color.primitive.orange` | `#d95e2a` | Only accent color — used sparingly for primary actions and emphasis |
|
||||
| `color.primitive.destructive-red` | `#b43232` | Error/danger states |
|
||||
| `color.primitive.grey.1` | `#f0f0ec` | 5% — Subtle backgrounds, secondary, muted |
|
||||
| `color.primitive.grey.2` | `#ddddd7` | 10% — Accent hover, light borders |
|
||||
| `color.primitive.grey.3` | `#c4c4bd` | 20% — Border, input |
|
||||
| `color.primitive.grey.4` | `#a6a69f` | 50% — Mid-tone |
|
||||
| `color.primitive.grey.5` | `#7f7f79` | 60% — Mid-dark |
|
||||
| `color.primitive.grey.7` | `#575753` | 70% — Secondary foreground, muted foreground |
|
||||
| `color.primitive.grey.8` | `#2f2f2c` | 80% — Dark mode card, dark surfaces |
|
||||
| `color.semantic.background` | `#f0f0ec` | Page background |
|
||||
| `color.semantic.foreground` | `#161614` | Primary text |
|
||||
| `color.semantic.card` | `#f9f9f7` | Card/elevated surface background |
|
||||
| `color.semantic.card-foreground` | `#161614` | Card text |
|
||||
| `color.semantic.popover` | `#f9f9f7` | Popover background |
|
||||
| `color.semantic.popover-foreground` | `#161614` | Popover text |
|
||||
| `color.semantic.primary` | `#d95e2a` | Primary accent — buttons, links, focus rings |
|
||||
| `color.semantic.primary-foreground` | `#f9f9f7` | Text on primary accent |
|
||||
| `color.semantic.secondary` | `#f0f0ec` | Secondary button/surface |
|
||||
| `color.semantic.secondary-foreground` | `#2f2f2c` | Text on secondary surface |
|
||||
| `color.semantic.muted` | `#f0f0ec` | Muted/subdued background |
|
||||
| `color.semantic.muted-foreground` | `#575753` | Muted/subdued text |
|
||||
| `color.semantic.accent` | `#ddddd7` | Subtle hover state |
|
||||
| `color.semantic.accent-foreground` | `#161614` | Text on accent hover |
|
||||
| `color.semantic.destructive` | `#b43232` | Destructive/error actions |
|
||||
| `color.semantic.destructive-foreground` | `#f9f9f7` | Text on destructive |
|
||||
| `color.semantic.border` | `#c4c4bd` | Default border |
|
||||
| `color.semantic.input` | `#c4c4bd` | Input border |
|
||||
| `color.semantic.ring` | `#d95e2a` | Focus ring |
|
||||
| `color.semantic.chart.1` | `#d95e2a` | Chart accent |
|
||||
| `color.semantic.chart.2` | `#575753` | Chart secondary |
|
||||
| `color.semantic.chart.3` | `#7f7f79` | Chart tertiary |
|
||||
| `color.semantic.chart.4` | `#a6a69f` | Chart quaternary |
|
||||
| `color.semantic.chart.5` | `#2f2f2c` | Chart quinary |
|
||||
| `color.semantic.sidebar.background` | `#f0f0ec` | Sidebar background |
|
||||
| `color.semantic.sidebar.foreground` | `#161614` | Sidebar text |
|
||||
| `color.semantic.sidebar.primary` | `#d95e2a` | Sidebar primary accent |
|
||||
| `color.semantic.sidebar.primary-foreground` | `#f9f9f7` | Sidebar primary text |
|
||||
| `color.semantic.sidebar.accent` | `#c4c4bd` | Sidebar accent/hover |
|
||||
| `color.semantic.sidebar.accent-foreground` | `#161614` | Sidebar accent text |
|
||||
| `color.semantic.sidebar.border` | `#c4c4bd` | Sidebar border |
|
||||
| `color.semantic.sidebar.ring` | `#d95e2a` | Sidebar focus ring |
|
||||
| `color.dark.background` | `#161614` | Dark page background |
|
||||
| `color.dark.foreground` | `#f9f9f7` | Dark primary text |
|
||||
| `color.dark.card` | `#2f2f2c` | Dark card surface |
|
||||
| `color.dark.card-foreground` | `#f9f9f7` | Dark card text |
|
||||
| `color.dark.popover` | `#2f2f2c` | Dark popover |
|
||||
| `color.dark.popover-foreground` | `#f9f9f7` | Dark popover text |
|
||||
| `color.dark.primary` | `#d95e2a` | Same orange in dark mode |
|
||||
| `color.dark.primary-foreground` | `#f9f9f7` | Dark primary foreground |
|
||||
| `color.dark.secondary` | `#575753` | Dark secondary |
|
||||
| `color.dark.secondary-foreground` | `#f9f9f7` | Dark secondary text |
|
||||
| `color.dark.muted` | `#575753` | Dark muted |
|
||||
| `color.dark.muted-foreground` | `#c4c4bd` | Dark muted text |
|
||||
| `color.dark.accent` | `#575753` | Dark accent/hover |
|
||||
| `color.dark.accent-foreground` | `#f9f9f7` | Dark accent text |
|
||||
| `color.dark.destructive` | `#b43232` | Same destructive in dark mode |
|
||||
| `color.dark.destructive-foreground` | `#f9f9f7` | Dark destructive text |
|
||||
| `color.dark.border` | `#575753` | Dark border |
|
||||
| `color.dark.input` | `#575753` | Dark input border |
|
||||
| `color.dark.ring` | `#d95e2a` | Dark focus ring |
|
||||
| `color.dark.chart.1` | `#d95e2a` | Dark chart accent |
|
||||
| `color.dark.chart.2` | `#c4c4bd` | Dark chart secondary |
|
||||
| `color.dark.chart.3` | `#a6a69f` | Dark chart tertiary |
|
||||
| `color.dark.chart.4` | `#7f7f79` | Dark chart quaternary |
|
||||
| `color.dark.chart.5` | `#f0f0ec` | Dark chart quinary |
|
||||
| `color.dark.sidebar.background` | `#2f2f2c` | Dark sidebar background |
|
||||
| `color.dark.sidebar.foreground` | `#f9f9f7` | Dark sidebar text |
|
||||
| `color.dark.sidebar.primary` | `#d95e2a` | Dark sidebar primary |
|
||||
| `color.dark.sidebar.primary-foreground` | `#f9f9f7` | Dark sidebar primary text |
|
||||
| `color.dark.sidebar.accent` | `#575753` | Dark sidebar accent |
|
||||
| `color.dark.sidebar.accent-foreground` | `#f9f9f7` | Dark sidebar accent text |
|
||||
| `color.dark.sidebar.border` | `#575753` | Dark sidebar border |
|
||||
| `color.dark.sidebar.ring` | `#d95e2a` | Dark sidebar ring |
|
||||
|
||||
## Motion
|
||||
|
||||
| Token | Value | Description |
|
||||
|-------|-------|-------------|
|
||||
| `motion.duration.fast` | `150ms` | Quick transitions — tooltips, hover states |
|
||||
| `motion.duration.normal` | `200ms` | Default transitions — most UI interactions |
|
||||
| `motion.duration.slow` | `300ms` | Deliberate transitions — modals, drawers, accordions |
|
||||
| `motion.easing.default` | `cubic-bezier(0.4, 0, 0.2, 1)` | Standard ease-in-out |
|
||||
| `motion.easing.in` | `cubic-bezier(0.4, 0, 1, 1)` | Ease-in for exits |
|
||||
| `motion.easing.out` | `cubic-bezier(0, 0, 0.2, 1)` | Ease-out for entrances |
|
||||
|
||||
## Radii
|
||||
|
||||
| Token | Value | Description |
|
||||
|-------|-------|-------------|
|
||||
| `radii.base` | `0.375rem` | 6px — base radius |
|
||||
| `radii.sm` | `calc(0.375rem - 2px)` | 4px — small variant |
|
||||
| `radii.md` | `0.375rem` | 6px — medium (same as base) |
|
||||
| `radii.lg` | `calc(0.375rem + 2px)` | 8px — large variant |
|
||||
| `radii.xl` | `calc(0.375rem + 4px)` | 10px — extra large variant (cards) |
|
||||
| `radii.full` | `9999px` | Fully round (pills, avatars) |
|
||||
|
||||
## Shadow
|
||||
|
||||
| Token | Value | Description |
|
||||
|-------|-------|-------------|
|
||||
| `shadow.xs` | `0 1px 2px 0 rgba(22, 22, 20, 0.05)` | Subtle shadow for buttons, inputs |
|
||||
| `shadow.sm` | `0 1px 3px 0 rgba(22, 22, 20, 0.1)` | Small shadow for cards |
|
||||
| `shadow.md` | `0 4px 6px -1px rgba(22, 22, 20, 0.1)` | Medium shadow for dropdowns, popovers |
|
||||
| `shadow.lg` | `0 10px 15px -3px rgba(22, 22, 20, 0.1)` | Large shadow for dialogs, modals |
|
||||
|
||||
## Spacing
|
||||
|
||||
| Token | Value | Description |
|
||||
|-------|-------|-------------|
|
||||
| `spacing.0` | `0` | None |
|
||||
| `spacing.1` | `0.25rem` | 4px — tight gaps |
|
||||
| `spacing.2` | `0.5rem` | 8px — card header gap, form description spacing |
|
||||
| `spacing.3` | `0.75rem` | 12px |
|
||||
| `spacing.4` | `1rem` | 16px — form field gap, button padding |
|
||||
| `spacing.5` | `1.25rem` | 20px |
|
||||
| `spacing.6` | `1.5rem` | 24px — card padding, card internal gap |
|
||||
| `spacing.8` | `2rem` | 32px — section margin-bottom |
|
||||
| `spacing.10` | `2.5rem` | 40px |
|
||||
| `spacing.12` | `3rem` | 48px |
|
||||
| `spacing.16` | `4rem` | 64px — major section padding (py-16) |
|
||||
| `spacing.20` | `5rem` | 80px |
|
||||
| `spacing.24` | `6rem` | 96px — hero padding |
|
||||
| `spacing.0.5` | `0.125rem` | 2px — micro spacing |
|
||||
| `spacing.1.5` | `0.375rem` | 6px |
|
||||
| `spacing.component.card-padding` | `1.5rem` | Card internal padding (px-6) |
|
||||
| `spacing.component.card-gap` | `1.5rem` | Gap between cards (gap-6) |
|
||||
| `spacing.component.section-padding` | `4rem` | Vertical padding between major sections (py-16) |
|
||||
| `spacing.component.form-gap` | `1rem` | Gap between form fields (gap-4) |
|
||||
| `spacing.component.button-padding-x` | `1rem` | Button horizontal padding (px-4) |
|
||||
| `spacing.component.navbar-height` | `4rem` | Navbar height (h-16) |
|
||||
|
||||
## Typography
|
||||
|
||||
| Token | Value | Description |
|
||||
|-------|-------|-------------|
|
||||
| `typography.fontFamily.sans` | `Aspekta, Inter, ui-sans-serif, system-ui, sans-serif` | UI labels, buttons, nav, forms — Aspekta primary, Inter fallback |
|
||||
| `typography.fontFamily.serif` | `'Source Serif 4', 'Source Serif Pro', Georgia, serif` | Headings, body content, reading — Source Serif primary |
|
||||
| `typography.fontFamily.mono` | `ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace` | Code blocks and monospaced content |
|
||||
| `typography.fontSize.xs` | `0.75rem` | 12px — metadata, fine print |
|
||||
| `typography.fontSize.sm` | `0.875rem` | 14px — captions, nav, labels, buttons |
|
||||
| `typography.fontSize.base` | `1rem` | 16px — body text |
|
||||
| `typography.fontSize.lg` | `1.125rem` | 18px — large body, subtitles |
|
||||
| `typography.fontSize.xl` | `1.25rem` | 20px — H3 |
|
||||
| `typography.fontSize.2xl` | `1.5rem` | 24px — H2 |
|
||||
| `typography.fontSize.3xl` | `1.875rem` | 30px — large H2 |
|
||||
| `typography.fontSize.4xl` | `2.25rem` | 36px — H1 |
|
||||
| `typography.fontSize.5xl` | `3rem` | 48px — hero heading |
|
||||
| `typography.fontWeight.normal` | `400` | Regular body text |
|
||||
| `typography.fontWeight.medium` | `500` | H3, labels, nav items |
|
||||
| `typography.fontWeight.semibold` | `600` | H1, H2, buttons |
|
||||
| `typography.fontWeight.bold` | `700` | Strong emphasis |
|
||||
| `typography.lineHeight.tight` | `1.25rem` | Headings |
|
||||
| `typography.lineHeight.normal` | `1.5rem` | Default |
|
||||
| `typography.lineHeight.relaxed` | `1.625rem` | Body content for readability |
|
||||
| `typography.letterSpacing.tight` | `-0.025em` | Headings — tracking-tight |
|
||||
| `typography.letterSpacing.normal` | `0em` | Body text |
|
||||
| `typography.letterSpacing.wide` | `0.05em` | Uppercase labels |
|
||||