diff --git a/GAPS.md b/GAPS.md new file mode 100644 index 0000000..9ac3ffd --- /dev/null +++ b/GAPS.md @@ -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 — +**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 `` 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. diff --git a/README.md b/README.md index 2bb717f..94445b5 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,10 @@ greyhaven-design-system/ │ ├── utils.ts # cn() utility │ └── catalog.ts # Shared component catalog (used by MCP + SKILL.md) ├── 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) └── 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. +> **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 @@ -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 +
+
+
Requests Over Time
+
Last 24 hours
+
+
+
+ + + +Active +``` + +### 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**: `
` covers Accordion/Collapsible, `` covers Dialog. The CSS rules apply to those too. + +### Regenerate + +```bash +pnpm htmx-css:build # Regenerate dist/greyhaven.htmx.css from components/ui/*.tsx +``` + +Re-runs of `./skill/install.sh --htmx-css` in consumer projects refresh their copy. + +--- + ## Adding a New Component 1. Create `components/ui/my-component.tsx` following the CVA pattern (see `button.tsx`) @@ -264,6 +340,7 @@ pnpm build-storybook # Static build | `pnpm build-storybook` | Static Storybook build | | `pnpm tokens:build` | Regenerate CSS/TS/MD from token JSON files | | `pnpm skill:build` | Regenerate skill/SKILL.md and skill/AGENTS.md from tokens + catalog | +| `pnpm htmx-css:build` | Regenerate dist/greyhaven.htmx.css from components/ui/*.tsx | | `pnpm mcp:start` | Start the MCP server (stdio transport) | | `pnpm mcp:build` | Type-check MCP server | | `pnpm lint` | Run ESLint | diff --git a/components/design-system/component-matrix.tsx b/components/design-system/component-matrix.tsx index 622b500..792970e 100644 --- a/components/design-system/component-matrix.tsx +++ b/components/design-system/component-matrix.tsx @@ -7,6 +7,7 @@ import { Checkbox } from "@/components/ui/checkbox" import { Switch } from "@/components/ui/switch" import { Badge } from "@/components/ui/badge" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { Select, SelectContent, @@ -455,6 +456,69 @@ export function ComponentMatrix() { + {/* Toggle Group */} +
+

+ Toggle Group +

+
+
+
+
+

Single, outline

+ + System + Light + Dark + +
+
+

Single, default

+ + List + Grid + Board + +
+
+

Multiple

+ + Bold + Italic + Underline + +
+
+
+
+

Small

+ + System + Light + Dark + +
+
+

Default

+ + System + Light + Dark + +
+
+

Large

+ + System + Light + Dark + +
+
+
+
+
+ {/* Tooltips */}

diff --git a/components/ui/toggle-group.tsx b/components/ui/toggle-group.tsx index 0ab9971..b75a6e0 100644 --- a/components/ui/toggle-group.tsx +++ b/components/ui/toggle-group.tsx @@ -60,7 +60,7 @@ function ToggleGroupItem({ variant: context.variant || variant, size: context.size || size, }), - 'min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l', + 'rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l', className, )} {...props} diff --git a/components/ui/toggle.tsx b/components/ui/toggle.tsx index ad6b286..e3ccccf 100644 --- a/components/ui/toggle.tsx +++ b/components/ui/toggle.tsx @@ -7,7 +7,7 @@ import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/utils' const toggleVariants = cva( - "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", + "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", { variants: { variant: { @@ -16,9 +16,9 @@ const toggleVariants = cva( 'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground', }, size: { - default: 'h-9 px-2 min-w-9', - sm: 'h-8 px-1.5 min-w-8', - lg: 'h-10 px-2.5 min-w-10', + default: 'h-9 px-4 min-w-9 has-[>svg]:px-3', + sm: 'h-8 px-3 min-w-8 has-[>svg]:px-2.5', + lg: 'h-10 px-6 min-w-10 has-[>svg]:px-4', }, }, defaultVariants: { diff --git a/dist/greyhaven.htmx.css b/dist/greyhaven.htmx.css new file mode 100644 index 0000000..fff9e9a --- /dev/null +++ b/dist/greyhaven.htmx.css @@ -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: + * + * + * 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: + *
+ *
Hello
+ *
+ *
+ * + * Active + */ + + +@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 + */ diff --git a/htmx-demo/README.md b/htmx-demo/README.md new file mode 100644 index 0000000..9681a94 --- /dev/null +++ b/htmx-demo/README.md @@ -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 (` +

+ + + +
+ + +
+
+

Color Tokens

+

A restrained set of warm greys, off-black, and muted neutrals, with orange as the only accent.

+
+ +
+ +
+

+ Primary Scheme +

+
+
+
+ #F9F9F7 +
+
+

Off-white

+

--card

+
+
+
+
+ #161614 +
+
+

Off-black

+

--foreground

+
+
+
+
+ #D95E2A +
+
+

Orange

+

--primary

+
+
+
+
+ + +
+

+ Grey Scale +

+
+
+
+ #F0F0EC +
+

Grey 1 (5%)

+
+
+
+ #DDDDD7 +
+

Grey 2 (10%)

+
+
+
+ #C4C4BD +
+

Grey 3 (20%)

+
+
+
+ #A6A69F +
+

Grey 4 (50%)

+
+
+
+ #7F7F79 +
+

Grey 5 (60%)

+
+
+
+ #575753 +
+

Grey 7 (70%)

+
+
+
+ #2F2F2C +
+

Grey 8 (80%)

+
+
+
+ + +
+

+ Semantic Tokens +

+
+
+
+ Background +
+

bg-background

+
+
+
+ Foreground +
+

bg-foreground

+
+
+
+ Card +
+

bg-card

+
+
+
+ Muted +
+

bg-muted

+
+
+
+ Secondary +
+

bg-secondary

+
+
+
+ Primary +
+

bg-primary

+
+
+
+ Accent +
+

bg-accent

+
+
+
+ Border +
+

bg-border

+
+
+
+
+
+ + +
+
+

Typography

+

Source Serif Pro for explanation and human-readable detail. Aspekta (displayed as Inter) for structure, navigation, and UI.

+
+ +
+ +
+
+ + Primary + +

+ Source Serif Pro — Reading & Explanation +

+
+ +
+
+

Display / H1

+

+ Local-first AI systems shaped by real work +

+
+ +
+

Heading / H2

+

+ Built where work happens. Contained end to end. +

+
+ +
+

Heading / H3

+

+ Systems run inside the perimeter. Nothing leaks. +

+
+ +
+

Body

+

+ Real work is messy, distributed, and embedded in internal systems. Cloud AI often + cannot operate there. Useful AI runs inside the environment and supports human + decision-making — not abstract it away. We sit with the operators, map the steps, + and build a system that mirrors what actually happens. +

+
+ +
+

Small / Caption

+

+ Powered by Monadical's internal, open-source stack hardened over eight years. +

+
+
+
+ + +
+
+ + Secondary + +

+ Aspekta — UI, Navigation & Labels +

+
+ +
+
+

Navigation

+
+ Overview + Documentation + Support + Contact +
+
+ +
+

Button Text

+
+ Get Started + Learn More + View All +
+
+ +
+

Labels & Form Elements

+
+ Email Address + Optional + Required field +
+
+ +
+

Status & Metadata

+
+ Active + Pending + Last updated: 2 hours ago +
+
+
+
+ + +
+
+

Serif Stack

+

+ 'Source Serif Pro', 'Source Serif 4', Georgia, serif +

+
+
+

Sans Stack

+

+ 'Aspekta', ui-sans-serif, system-ui, sans-serif +

+
+
+
+
+ + +
+
+

Component Library

+

Interactive elements with consistent styling across all states: default, hover, active, disabled.

+
+ +
+ +
+

+ Buttons — Variants +

+
+
+
+

Primary

+ +
+
+

Secondary

+ +
+
+

Outline

+ +
+
+

Ghost

+ +
+
+

Link

+ +
+
+

Destructive

+ +
+
+
+
+ + +
+

+ Buttons — Sizes +

+
+
+
+

Small

+ +
+
+

Default

+ +
+
+

Large

+ +
+
+
+
+ + +
+

+ Buttons — States +

+
+
+
+

Primary

+
+ + + + + +
+
+
+

Outline

+
+ + + + +
+
+
+

Destructive

+
+ + + + +
+
+
+
+
+ + +
+

+ Icon Buttons +

+
+
+
+

Variants

+
+ + + + + +
+
+
+

Sizes

+
+ + + +
+
+
+

Disabled

+
+ + + +
+
+
+
+
+ + +
+

+ Buttons with Icons +

+
+
+
+

Leading Icon

+
+ + + + +
+
+
+

Trailing Icon

+
+ + + +
+
+
+

Sizes with Icons

+
+ + + +
+
+
+
+
+ + +
+

+ Badges — Core Variants +

+
+
+ Default + Secondary + Muted + Outline +
+
+
+ + +
+

+ Badges — Tag & Value +

+
+
+ Tag + Category + 42 + $1,234 + 100% +
+
+
+ + +
+

+ Badges — Semantic +

+
+
+ Success + Warning + Danger + Info +
+
+
+ + +
+

+ Badges — Channel Pills +

+
+
+ WhatsApp + Email + Telegram + Zulip + Platform +
+
+
+ + +
+

+ Badges — On Muted Surface +

+
+
+ Default + Secondary + Outline + Tag + Success + Info +
+
+
+ + +
+

+ Inputs +

+
+
+
+

Default

+ +
+
+

With Value

+ +
+
+

Disabled

+ +
+
+
+

Textarea

+
+ + +
+
+
+
+ + +
+

+ Select +

+
+
+
+

Default

+ +
+
+

With Value

+ +
+
+

Disabled

+ +
+
+
+
+ + +
+

+ Checkboxes & Switches +

+
+
+
+

Checkboxes

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

Switches

+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+ + +
+

+ Tabs +

+
+
+
+ + + + +
+
+

+ Overview content. This tab demonstrates the default active state. +

+
+ + +
+
+
+ + +
+

+ Toggle Group +

+
+
+
+
+

Single, outline

+
+ + + +
+
+
+

Single, default

+
+ + + +
+
+
+

Multiple

+
+ + + +
+
+
+
+
+

Small

+
+ + + +
+
+
+

Default

+
+ + + +
+
+
+

Large

+
+ + + +
+
+
+
+
+
+ + +
+

+ Tooltips +

+
+
+ + +
+
+
+
+
+ + +
+
+

Real-World Examples

+

Complete UI patterns demonstrating how components work together in production.

+
+ +
+ +
+

+ Consultation Request Form +

+ +
+
+
Request a Consultation
+
+ Tell us about your operational requirements. We'll assess whether a contained + AI system can help. +
+
+
+
+ +
+ + +
+ + +
+ + +

We'll use this to schedule a call.

+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +

Be specific about constraints: data sensitivity, existing systems, compliance requirements.

+
+ + +
+ +
+ +
+
+ + +
+ + +
+
+
+
+
+ + +
+

+ System Settings +

+ +
+
+
System Configuration
+
+ Configure your contained AI deployment. All settings remain within your environment. +
+
+
+ +
+
+
+ +
+
+ +

Receive alerts for system events and anomalies

+
+
+ +
+ +
+ + +
+
+
+ +
+
+ +

Record all system actions for compliance

+
+
+ +
+ +
+ + +
+
+
+ +
+
+ +

Keep processed data beyond 30 days

+
+
+ +
+ +
+ + +
+
+
+ +
+
+ +

Isolation and access control level

+
+
+ +
+ +
+ + +
+ + +
+
+
+
+
+
+ +
+ + + + + + + + + diff --git a/scripts/generate-htmx-css.ts b/scripts/generate-htmx-css.ts new file mode 100644 index 0000000..294b5c3 --- /dev/null +++ b/scripts/generate-htmx-css.ts @@ -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> + defaultVariants: Record +} + +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> = {} + 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 = {} + 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 = {} + 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/ / group/ — 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: + * + * + * 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: + *
+ *
Hello
+ *
+ *
+ * + * Active + */ + +` + lines.push(header) + // Emit in @layer utilities so individual Tailwind utility classes on child + // elements (e.g. ) 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>() + 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() + const cvaByName = new Map() + 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>() + 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() diff --git a/scripts/generate-skill.ts b/scripts/generate-skill.ts index 56e4cf9..9056c01 100644 --- a/scripts/generate-skill.ts +++ b/scripts/generate-skill.ts @@ -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 +
+
+
Title
+
Description
+
+
Body
+
+ + + + +Active +\`\`\` + +### 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 (\`\`, \`
\`). + +### Regenerate + +\`\`\`bash +pnpm htmx-css:build +\`\`\` + +` +} + function buildExtensionProtocol(): string { return `## Extension Protocol @@ -258,6 +302,8 @@ This skill gives you full context to generate pixel-perfect, on-brand UI using t '---\n', buildCompositionRules(), '---\n', + buildHtmxLayer(), + '---\n', buildExtensionProtocol(), ].join('\n') } diff --git a/skill/SKILL.md b/skill/SKILL.md index f647c7d..8deb22d 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -659,6 +659,49 @@ pnpm dev`} - **Slot naming**: All components use `data-slot="component-name"` - **Icon sizing**: `[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0` +--- + +## HTMX / Server-Rendered Usage + +For projects that cannot use React (HTMX, Django templates, Rails ERB, Go `html/template`, Astro SSR, etc.), the design system ships a framework-agnostic CSS layer: `dist/greyhaven.htmx.css`. + +### What it is + +An auto-generated stylesheet derived from `components/ui/*.tsx`. Every `data-slot` attribute gets a `@layer components` rule. `cva` variants become attribute selectors (`[data-variant=...]`, `[data-size=...]`). Default variants apply via `:not([data-variant])` so consumers can omit the attribute. + +### Usage + +1. Install: `./skill/install.sh /path/to/project --htmx-css` +2. Import in your Tailwind v4 input CSS: `@import "./greyhaven.htmx.css";` +3. Emit HTML with `data-slot` / `data-variant` / `data-size` attributes: + +```html +
+
+
Title
+
Description
+
+
Body
+
+ + + + +Active +``` + +### 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 (``, `
`). + +### Regenerate + +```bash +pnpm htmx-css:build +``` + + --- ## Extension Protocol diff --git a/skill/install.sh b/skill/install.sh index 5fa5b55..ec28118 100755 --- a/skill/install.sh +++ b/skill/install.sh @@ -28,10 +28,12 @@ AGENTS_BRAND_FILE="${SCRIPT_DIR}/AGENTS.brand.md" BRAND_FILE="${SCRIPT_DIR}/BRAND.md" FONTS_DIR="${REPO_ROOT}/public/fonts" PUBLIC_DIR="${REPO_ROOT}/public" +HTMX_CSS_FILE="${REPO_ROOT}/dist/greyhaven.htmx.css" # Parse arguments TARGET_PROJECT="" INSTALL_BRAND=false +INSTALL_HTMX_CSS=false while [ $# -gt 0 ]; do case "$1" in @@ -39,16 +41,23 @@ while [ $# -gt 0 ]; do INSTALL_BRAND=true shift ;; + --htmx-css) + INSTALL_HTMX_CSS=true + shift + ;; -h|--help) - echo "Usage: $0 [--brand-skill]" + echo "Usage: $0 [--brand-skill] [--htmx-css]" echo "" echo "Options:" echo " --brand-skill Also install BRAND.md (voice/tone/messaging) and logo SVGs" + echo " --htmx-css Also install greyhaven.htmx.css (framework-agnostic CSS layer" + echo " for HTMX / server-rendered projects that can't use React)" echo "" echo "Examples:" echo " $0 /path/to/my-app" echo " $0 /path/to/my-app --brand-skill" - echo " $0 . --brand-skill" + echo " $0 /path/to/my-app --htmx-css" + echo " $0 . --brand-skill --htmx-css" exit 0 ;; *) @@ -65,7 +74,7 @@ while [ $# -gt 0 ]; do done if [ -z "$TARGET_PROJECT" ]; then - echo "Usage: $0 [--brand-skill]" + echo "Usage: $0 [--brand-skill] [--htmx-css]" echo "Run '$0 --help' for details." exit 1 fi @@ -190,6 +199,20 @@ if [ "$INSTALL_BRAND" = true ]; then echo "[ok] Logos: ${copied} SVGs copied to ${TARGET_LOGOS}/ (renamed: spaces → dashes)" fi +# ── 5. HTMX CSS (opt-in via --htmx-css) ──────────────────────────────────── +if [ "$INSTALL_HTMX_CSS" = true ]; then + if [ -f "$HTMX_CSS_FILE" ]; then + TARGET_CSS_DIR="${TARGET_PROJECT}/public/css" + mkdir -p "$TARGET_CSS_DIR" + DST="${TARGET_CSS_DIR}/greyhaven.htmx.css" + copy_with_backup "$HTMX_CSS_FILE" "$DST" + echo "[ok] HTMX CSS: ${DST}" + else + echo "[skip] greyhaven.htmx.css not found at ${HTMX_CSS_FILE}" + echo " Run 'pnpm htmx-css:build' in the design system repo first." + fi +fi + # ── Next steps ───────────────────────────────────────────────────────────── cat <<'EOF' @@ -209,7 +232,27 @@ Done! And set the font stack: --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: + +
+
Hello
+
Body
+
+ + Active + + 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: { @@ -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