2 Commits

Author SHA1 Message Date
90930d8f78 feat(htmx-css): ToggleGroup support + padding/primary parity
Generator (scripts/generate-htmx-css.ts): track `viaVariants` per slot so
slots that compose another component's variant system (e.g. ToggleGroupItem
via toggleVariants) inherit the referenced CVA's base + variant rules under
their own selector. Previously toggle-group-item's CSS contained only its
override classes, shipping with no padding/height/hover/active state.

Toggle (components/ui/toggle.tsx):
  - data-[state=on] now uses bg-primary (orange) instead of bg-accent (grey),
    matching every other "commit" affordance in the palette.
  - Horizontal padding aligned with Button: px-4/px-3/px-6 per size, plus
    has-[>svg]:px-* for icon-only toggles.

ToggleGroup (components/ui/toggle-group.tsx): drop min-w-0 flex-1 shrink-0
from the item override. Items now size to content instead of being clamped
into equal narrow columns where longer labels overflowed the bg box.

Showcase: add ToggleGroup section to the React page (component-matrix.tsx)
and 1:1 HTMX mirror (public/htmx.html) with a new JS bridge branch for
single/multi-select. compare-all.sh extended with the new section; 22/22
pass at ≥99.97%.

Docs: GAPS.md captures the generator gap, overflow root cause, color
rationale, and padding parity with before/after numbers.
2026-04-24 14:43:55 -06:00
928fdd8f75 feat: htmx derivation home page 1:1 from react 2026-04-23 23:05:50 -06:00
18 changed files with 14320 additions and 12 deletions

179
GAPS.md Normal file
View 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.

View File

@@ -37,7 +37,10 @@ greyhaven-design-system/
│ ├── utils.ts # cn() utility │ ├── utils.ts # cn() utility
│ └── catalog.ts # Shared component catalog (used by MCP + SKILL.md) │ └── catalog.ts # Shared component catalog (used by MCP + SKILL.md)
├── scripts/ ├── scripts/
── generate-skill.ts # SKILL.md generator ── 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) ├── app/ # Next.js showcase app (demo only)
└── style-dictionary.config.mjs └── style-dictionary.config.mjs
``` ```
@@ -53,6 +56,8 @@ greyhaven-design-system/
> **Framework-agnostic**: Components have zero Next.js imports. They work with Vite, Remix, Astro, CRA, or any React framework. > **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 ## Using the Design System with AI
@@ -244,6 +249,77 @@ 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 ## Adding a New Component
1. Create `components/ui/my-component.tsx` following the CVA pattern (see `button.tsx`) 1. Create `components/ui/my-component.tsx` following the CVA pattern (see `button.tsx`)
@@ -264,6 +340,7 @@ pnpm build-storybook # Static build
| `pnpm build-storybook` | Static Storybook build | | `pnpm build-storybook` | Static Storybook build |
| `pnpm tokens:build` | Regenerate CSS/TS/MD from token JSON files | | `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 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:start` | Start the MCP server (stdio transport) |
| `pnpm mcp:build` | Type-check MCP server | | `pnpm mcp:build` | Type-check MCP server |
| `pnpm lint` | Run ESLint | | `pnpm lint` | Run ESLint |

View File

@@ -7,6 +7,7 @@ import { Checkbox } from "@/components/ui/checkbox"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -455,6 +456,69 @@ export function ComponentMatrix() {
</div> </div>
</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 */} {/* Tooltips */}
<div> <div>
<h4 className="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-4"> <h4 className="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-4">

View File

@@ -60,7 +60,7 @@ function ToggleGroupItem({
variant: context.variant || variant, variant: context.variant || variant,
size: context.size || size, 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, className,
)} )}
{...props} {...props}

View File

@@ -7,7 +7,7 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const toggleVariants = cva( 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: { variants: {
variant: { variant: {
@@ -16,9 +16,9 @@ const toggleVariants = cva(
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground', 'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
}, },
size: { size: {
default: 'h-9 px-2 min-w-9', default: 'h-9 px-4 min-w-9 has-[>svg]:px-3',
sm: 'h-8 px-1.5 min-w-8', sm: 'h-8 px-3 min-w-8 has-[>svg]:px-2.5',
lg: 'h-10 px-2.5 min-w-10', lg: 'h-10 px-6 min-w-10 has-[>svg]:px-4',
}, },
}, },
defaultVariants: { defaultVariants: {

871
dist/greyhaven.htmx.css vendored Normal file
View 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
View 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
View 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
View 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
View 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);
}
}

11135
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,9 @@
"scripts": { "scripts": {
"tokens:build": "npx style-dictionary build --config style-dictionary.config.mjs", "tokens:build": "npx style-dictionary build --config style-dictionary.config.mjs",
"skill:build": "npx tsx scripts/generate-skill.ts", "skill:build": "npx tsx scripts/generate-skill.ts",
"build": "pnpm tokens:build && pnpm skill:build && next build", "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", "dev": "next dev",
"lint": "eslint .", "lint": "eslint .",
"start": "next start", "start": "next start",
@@ -74,6 +76,7 @@
"@storybook/addon-onboarding": "^10.3.5", "@storybook/addon-onboarding": "^10.3.5",
"@storybook/addon-vitest": "^10.3.5", "@storybook/addon-vitest": "^10.3.5",
"@storybook/nextjs-vite": "^10.3.5", "@storybook/nextjs-vite": "^10.3.5",
"@tailwindcss/cli": "^4.2.4",
"@tailwindcss/postcss": "^4.1.9", "@tailwindcss/postcss": "^4.1.9",
"@types/node": "^22", "@types/node": "^22",
"@types/react": "^19", "@types/react": "^19",

2
public/htmx.css Normal file

File diff suppressed because one or more lines are too long

1094
public/htmx.html Normal file

File diff suppressed because it is too large Load Diff

View 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()

View File

@@ -182,6 +182,50 @@ function buildCompositionRules(): string {
` `
} }
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 { function buildExtensionProtocol(): string {
return `## Extension Protocol return `## Extension Protocol
@@ -258,6 +302,8 @@ This skill gives you full context to generate pixel-perfect, on-brand UI using t
'---\n', '---\n',
buildCompositionRules(), buildCompositionRules(),
'---\n', '---\n',
buildHtmxLayer(),
'---\n',
buildExtensionProtocol(), buildExtensionProtocol(),
].join('\n') ].join('\n')
} }

View File

@@ -659,6 +659,49 @@ pnpm dev`}</Code>
- **Slot naming**: All components use `data-slot="component-name"` - **Slot naming**: All components use `data-slot="component-name"`
- **Icon sizing**: `[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0` - **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 ## Extension Protocol

View File

@@ -28,10 +28,12 @@ AGENTS_BRAND_FILE="${SCRIPT_DIR}/AGENTS.brand.md"
BRAND_FILE="${SCRIPT_DIR}/BRAND.md" BRAND_FILE="${SCRIPT_DIR}/BRAND.md"
FONTS_DIR="${REPO_ROOT}/public/fonts" FONTS_DIR="${REPO_ROOT}/public/fonts"
PUBLIC_DIR="${REPO_ROOT}/public" PUBLIC_DIR="${REPO_ROOT}/public"
HTMX_CSS_FILE="${REPO_ROOT}/dist/greyhaven.htmx.css"
# Parse arguments # Parse arguments
TARGET_PROJECT="" TARGET_PROJECT=""
INSTALL_BRAND=false INSTALL_BRAND=false
INSTALL_HTMX_CSS=false
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
@@ -39,16 +41,23 @@ while [ $# -gt 0 ]; do
INSTALL_BRAND=true INSTALL_BRAND=true
shift shift
;; ;;
--htmx-css)
INSTALL_HTMX_CSS=true
shift
;;
-h|--help) -h|--help)
echo "Usage: $0 <target-project-directory> [--brand-skill]" echo "Usage: $0 <target-project-directory> [--brand-skill] [--htmx-css]"
echo "" echo ""
echo "Options:" echo "Options:"
echo " --brand-skill Also install BRAND.md (voice/tone/messaging) and logo SVGs" 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 ""
echo "Examples:" echo "Examples:"
echo " $0 /path/to/my-app" echo " $0 /path/to/my-app"
echo " $0 /path/to/my-app --brand-skill" echo " $0 /path/to/my-app --brand-skill"
echo " $0 . --brand-skill" echo " $0 /path/to/my-app --htmx-css"
echo " $0 . --brand-skill --htmx-css"
exit 0 exit 0
;; ;;
*) *)
@@ -65,7 +74,7 @@ while [ $# -gt 0 ]; do
done done
if [ -z "$TARGET_PROJECT" ]; then if [ -z "$TARGET_PROJECT" ]; then
echo "Usage: $0 <target-project-directory> [--brand-skill]" echo "Usage: $0 <target-project-directory> [--brand-skill] [--htmx-css]"
echo "Run '$0 --help' for details." echo "Run '$0 --help' for details."
exit 1 exit 1
fi fi
@@ -190,6 +199,20 @@ if [ "$INSTALL_BRAND" = true ]; then
echo "[ok] Logos: ${copied} SVGs copied to ${TARGET_LOGOS}/ (renamed: spaces → dashes)" echo "[ok] Logos: ${copied} SVGs copied to ${TARGET_LOGOS}/ (renamed: spaces → dashes)"
fi 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 ───────────────────────────────────────────────────────────── # ── Next steps ─────────────────────────────────────────────────────────────
cat <<'EOF' cat <<'EOF'
@@ -209,7 +232,27 @@ Done!
And set the font stack: And set the font stack:
--font-sans: 'Aspekta', ui-sans-serif, system-ui, sans-serif; --font-sans: 'Aspekta', ui-sans-serif, system-ui, sans-serif;
2. (Optional) Register the Greyhaven MCP server. Create .mcp.json in your 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: project root:
{ {
@@ -221,6 +264,6 @@ Done!
} }
} }
3. Re-run this script after design system updates to refresh your copies. 4. Re-run this script after design system updates to refresh your copies.
EOF EOF