Compare commits
2 Commits
main
...
mathieu/ht
| Author | SHA1 | Date | |
|---|---|---|---|
| 90930d8f78 | |||
| 928fdd8f75 |
179
GAPS.md
Normal file
179
GAPS.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# HTMX CSS Generator — Known Gaps & Fixes
|
||||
|
||||
Running log of edge cases discovered while consuming `dist/greyhaven.htmx.css`
|
||||
in framework-agnostic (HTMX / Go `html/template` / etc.) projects. Each entry
|
||||
captures: the bug, its root cause, and the fix. Keep this file updated when
|
||||
new gaps surface so consumers have a single place to check.
|
||||
|
||||
---
|
||||
|
||||
## Fixed
|
||||
|
||||
### 2026-04-24 — `toggle-group-item` missing Toggle base styles
|
||||
|
||||
**Symptom (consumer-side):** `data-slot="toggle-group-item"` renders as
|
||||
unstyled inline text — no padding, no height, no border, and `data-state="on"`
|
||||
produces no visual change.
|
||||
|
||||
**Root cause:** In React, `ToggleGroupItem` composes `toggleVariants()` with a
|
||||
small set of segmented-group overrides:
|
||||
|
||||
```tsx
|
||||
className={cn(
|
||||
toggleVariants({ variant, size }), // base + variant + size classes
|
||||
'min-w-0 flex-1 shrink-0 rounded-none ...'
|
||||
)}
|
||||
```
|
||||
|
||||
The generator's `extractSlot` only captured the first *string-literal* arg of
|
||||
`cn(...)` — the segmented overrides — and never followed the
|
||||
`toggleVariants(...)` call to pull in Toggle's base, variant, and size rules.
|
||||
As a result, the emitted `[data-slot="toggle-group-item"]` had no padding,
|
||||
hover, `disabled`, or — critically — the `data-[state=on]:bg-accent
|
||||
data-[state=on]:text-accent-foreground` rule that drives the pressed state.
|
||||
|
||||
**Fix (`scripts/generate-htmx-css.ts`):** `SlotExtract` now carries a
|
||||
`viaVariants: string[]` field. When processing `className={cn(xVariants(...),
|
||||
'literal')}`, the extractor records every `*Variants` call it sees. The
|
||||
emitter then merges each referenced CVA's base + variant rules under the
|
||||
slot's own selector, so a slot that *composes* another component's variant
|
||||
system inherits its full rule set.
|
||||
|
||||
**Generated output before:**
|
||||
```css
|
||||
[data-slot="toggle-group-item"] { /* only overrides */
|
||||
min-w-0 flex-1 ... rounded-none first:rounded-l-md ...
|
||||
}
|
||||
```
|
||||
|
||||
**Generated output after:**
|
||||
```css
|
||||
[data-slot="toggle-group-item"] { /* full Toggle base + item overrides */
|
||||
inline-flex items-center ... data-[state=on]:bg-accent ...
|
||||
min-w-0 flex-1 ... rounded-none first:rounded-l-md ...
|
||||
}
|
||||
[data-slot="toggle-group-item"][data-variant="outline"] { /* inherited variant */ }
|
||||
[data-slot="toggle-group-item"][data-size="sm"] { /* inherited size */ }
|
||||
```
|
||||
|
||||
**Consumer impact:** existing HTMX demos do not use `toggle-group-item`, so no
|
||||
screenshot-diff regression risk. Consumers previously working around this by
|
||||
using `data-slot="toggle"` on each item (with manual rounded-none / border
|
||||
overrides) can switch to the clean `toggle-group-item` form.
|
||||
|
||||
---
|
||||
|
||||
### 2026-04-24 — Toggle active state uses `primary` (orange), not `accent` (grey)
|
||||
|
||||
**Symptom:** Design review flagged the active-state grey (`bg-accent`) as too
|
||||
muted — users expected the brand orange for selected items in a segmented
|
||||
control, matching every other "selected" affordance in the system (primary
|
||||
button, active nav link, focus ring).
|
||||
|
||||
**Fix:** `components/ui/toggle.tsx` — `toggleVariants` base class swapped from
|
||||
`data-[state=on]:bg-accent data-[state=on]:text-accent-foreground` to
|
||||
`data-[state=on]:bg-primary data-[state=on]:text-primary-foreground`. This
|
||||
propagates to `ToggleGroupItem` automatically (composes `toggleVariants`) and
|
||||
to the generated `[data-slot="toggle"]` and `[data-slot="toggle-group-item"]`
|
||||
rules after `pnpm htmx-css:build && pnpm htmx-demo:build`.
|
||||
|
||||
**Rationale:** Greyhaven's palette reserves `accent` for hover hints and
|
||||
subtle surface shifts; `primary` is the single brand-accent color, used
|
||||
wherever a choice is committed. Selected-state for Toggle/ToggleGroup now
|
||||
matches that convention.
|
||||
|
||||
### 2026-04-24 — `ToggleGroupItem` overflow: text escapes the bg box
|
||||
|
||||
**Symptom:** With segmented labels of uneven length ("System" / "Light" /
|
||||
"Dark"), the longer label's text rendered outside its button's background
|
||||
rectangle. Selected-state highlight appeared narrower than the text it was
|
||||
supposed to cover, and hover state was clipped for longer labels.
|
||||
|
||||
**Root cause:** `ToggleGroupItem` layered `'min-w-0 flex-1 shrink-0'` on top
|
||||
of `toggleVariants`' size-based `min-w-*`. Because tailwind-merge keeps the
|
||||
later `min-w-0`, each item was allowed to shrink below its content. Combined
|
||||
with `flex: 1 1 0%` and `w-fit` on the group, items ended up forced to equal
|
||||
narrow columns sized to `container_width / N` — which for the "System" case
|
||||
was ~37px, well below the text's ~45px intrinsic width. `whitespace-nowrap`
|
||||
then let the text bleed out of the button's layout box.
|
||||
|
||||
**Fix:** `components/ui/toggle-group.tsx` — dropped `min-w-0 flex-1 shrink-0`
|
||||
from `ToggleGroupItem`'s override string. Items now size to their content
|
||||
(respecting the `min-w-*` floor from `toggleVariants`), the group's `w-fit`
|
||||
sums them up, and padding is symmetric. Unequal-width items are the
|
||||
intentional result (a segmented "System/Light/Dark" now shows "System" wider
|
||||
than "Light"/"Dark"); if a consumer specifically wants equal-width columns,
|
||||
they can re-apply `flex-1 basis-0` on each item via `className`.
|
||||
|
||||
**Consumer impact:** no regressions in the existing 21 sections; the new
|
||||
`toggle-group` section passes at 99.98%.
|
||||
|
||||
### 2026-04-24 — Toggle horizontal padding aligned with Button
|
||||
|
||||
**Symptom:** Side-by-side, a `Toggle` (or `ToggleGroupItem`) looked cramped
|
||||
compared to a regular `Button` of the same size — the active-state orange
|
||||
bg sat tight against the label, while Button had comfortable breathing room
|
||||
on both sides. Perceived as a visual-rhythm bug across any UI that mixed the
|
||||
two in the same view.
|
||||
|
||||
**Root cause:** `Toggle`'s cva had roughly half the horizontal padding of
|
||||
`Button` per size:
|
||||
|
||||
| size | Button | Toggle (old) |
|
||||
|---------|---------------|---------------|
|
||||
| default | `px-4` (16px) | `px-2` (8px) |
|
||||
| sm | `px-3` (12px) | `px-1.5` (6px)|
|
||||
| lg | `px-6` (24px) | `px-2.5` (10px)|
|
||||
|
||||
**Fix:** `components/ui/toggle.tsx` — `toggleVariants.size.*` horizontal
|
||||
padding now matches Button exactly. Also added `has-[>svg]:px-*` for
|
||||
icon-only toggles, mirroring Button's affordance. `min-w-*` kept as a floor
|
||||
so very short labels (`A`, `B`) still render as balanced pills.
|
||||
|
||||
```ts
|
||||
size: {
|
||||
default: 'h-9 px-4 min-w-9 has-[>svg]:px-3',
|
||||
sm: 'h-8 px-3 min-w-8 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 px-6 min-w-10 has-[>svg]:px-4',
|
||||
},
|
||||
```
|
||||
|
||||
## Known (not yet fixed)
|
||||
|
||||
*(Add entries here as they are discovered. Template:*
|
||||
|
||||
```
|
||||
### YYYY-MM-DD — <slot or behavior>
|
||||
**Symptom:** ...
|
||||
**Root cause:** ...
|
||||
**Workaround:** ...
|
||||
**Proposed fix:** ...
|
||||
```
|
||||
*)*
|
||||
|
||||
### Radix primitive-only components (AlertDialogAction, AlertDialogCancel)
|
||||
|
||||
**Symptom:** These components have no static `data-slot` in their JSX — they
|
||||
wrap `<AlertDialogPrimitive.Action>` which sets `data-slot` at runtime. The
|
||||
AST extractor never sees them, so no CSS is emitted.
|
||||
|
||||
**Workaround:** Consumers should add `data-slot="button"` on the
|
||||
corresponding HTML element. The visual contract matches Button exactly
|
||||
(`cn(buttonVariants(), className)` in React).
|
||||
|
||||
**Proposed fix:** none yet — requires either parsing the Radix primitive
|
||||
source, or adding explicit `data-slot` attributes upstream.
|
||||
|
||||
### Interactive components without a built-in JS bridge
|
||||
|
||||
**Symptom:** `data-slot="select-trigger"`, `data-slot="tooltip-content"`,
|
||||
`data-slot="dialog-content"`, `data-slot="popover-content"` emit the CSS for
|
||||
their open/closed states, but nothing drives them.
|
||||
|
||||
**Workaround:** Consumers must implement open/close + positioning themselves
|
||||
(Alpine.js, plain JS, HTMX swap). `public/htmx.html` ships a ~60-line vanilla
|
||||
bridge for checkbox / switch / tabs; select / tooltip / dialog remain
|
||||
consumer concerns.
|
||||
|
||||
**Proposed fix:** ship an opt-in `greyhaven.htmx.js` companion with pointer
|
||||
positioning (e.g. via Floating UI) and focus management.
|
||||
79
README.md
79
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
|
||||
<div data-slot="card">
|
||||
<div data-slot="card-header">
|
||||
<div data-slot="card-title">Requests Over Time</div>
|
||||
<div data-slot="card-description">Last 24 hours</div>
|
||||
</div>
|
||||
<div data-slot="card-content">…</div>
|
||||
</div>
|
||||
|
||||
<button data-slot="button" data-variant="default">Save</button>
|
||||
<button data-slot="button" data-variant="outline" data-size="sm">Cancel</button>
|
||||
<span data-slot="badge" data-variant="success">Active</span>
|
||||
```
|
||||
|
||||
### Scope
|
||||
|
||||
- **Static visual components** (Card, Button, Badge, Input, Label, Textarea, Table, Separator, Code, Kbd, Progress, Avatar, Skeleton, Alert, Pagination, Breadcrumb, Navbar, etc.) → fully driven by CSS, no JS needed.
|
||||
- **Interactive components** (Dialog, Dropdown, Popover, Select, Combobox, Accordion, Tabs, Tooltip, etc.) → CSS emits their static styles, but open/close / positioning / focus management is the consumer's responsibility. Alpine.js pairs naturally with HTMX for these.
|
||||
- **Native HTML alternatives**: `<details>` covers Accordion/Collapsible, `<dialog>` covers Dialog. The CSS rules apply to those too.
|
||||
|
||||
### Regenerate
|
||||
|
||||
```bash
|
||||
pnpm htmx-css:build # Regenerate dist/greyhaven.htmx.css from components/ui/*.tsx
|
||||
```
|
||||
|
||||
Re-runs of `./skill/install.sh --htmx-css` in consumer projects refresh their copy.
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Component
|
||||
|
||||
1. Create `components/ui/my-component.tsx` following the CVA pattern (see `button.tsx`)
|
||||
@@ -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 |
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -455,6 +456,69 @@ export function ComponentMatrix() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggle Group */}
|
||||
<div id="sub-toggle-group">
|
||||
<h4 className="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-4">
|
||||
Toggle Group
|
||||
</h4>
|
||||
<div className="border border-border rounded-md p-6 bg-card">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="font-sans text-xs text-muted-foreground mb-2">Single, outline</p>
|
||||
<ToggleGroup type="single" variant="outline" defaultValue="light" aria-label="Theme">
|
||||
<ToggleGroupItem value="system" aria-label="System">System</ToggleGroupItem>
|
||||
<ToggleGroupItem value="light" aria-label="Light">Light</ToggleGroupItem>
|
||||
<ToggleGroupItem value="dark" aria-label="Dark">Dark</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-sans text-xs text-muted-foreground mb-2">Single, default</p>
|
||||
<ToggleGroup type="single" defaultValue="grid" aria-label="Layout">
|
||||
<ToggleGroupItem value="list" aria-label="List">List</ToggleGroupItem>
|
||||
<ToggleGroupItem value="grid" aria-label="Grid">Grid</ToggleGroupItem>
|
||||
<ToggleGroupItem value="board" aria-label="Board" disabled>Board</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-sans text-xs text-muted-foreground mb-2">Multiple</p>
|
||||
<ToggleGroup type="multiple" variant="outline" defaultValue={["bold"]} aria-label="Text formatting">
|
||||
<ToggleGroupItem value="bold" aria-label="Bold">Bold</ToggleGroupItem>
|
||||
<ToggleGroupItem value="italic" aria-label="Italic">Italic</ToggleGroupItem>
|
||||
<ToggleGroupItem value="underline" aria-label="Underline">Underline</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="font-sans text-xs text-muted-foreground mb-2">Small</p>
|
||||
<ToggleGroup type="single" variant="outline" size="sm" defaultValue="light" aria-label="Theme (sm)">
|
||||
<ToggleGroupItem value="system">System</ToggleGroupItem>
|
||||
<ToggleGroupItem value="light">Light</ToggleGroupItem>
|
||||
<ToggleGroupItem value="dark">Dark</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-sans text-xs text-muted-foreground mb-2">Default</p>
|
||||
<ToggleGroup type="single" variant="outline" defaultValue="light" aria-label="Theme (default)">
|
||||
<ToggleGroupItem value="system">System</ToggleGroupItem>
|
||||
<ToggleGroupItem value="light">Light</ToggleGroupItem>
|
||||
<ToggleGroupItem value="dark">Dark</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-sans text-xs text-muted-foreground mb-2">Large</p>
|
||||
<ToggleGroup type="single" variant="outline" size="lg" defaultValue="light" aria-label="Theme (lg)">
|
||||
<ToggleGroupItem value="system">System</ToggleGroupItem>
|
||||
<ToggleGroupItem value="light">Light</ToggleGroupItem>
|
||||
<ToggleGroupItem value="dark">Dark</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tooltips */}
|
||||
<div>
|
||||
<h4 className="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-4">
|
||||
|
||||
@@ -60,7 +60,7 @@ function ToggleGroupItem({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
'min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l',
|
||||
'rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -16,9 +16,9 @@ const toggleVariants = cva(
|
||||
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-2 min-w-9',
|
||||
sm: 'h-8 px-1.5 min-w-8',
|
||||
lg: 'h-10 px-2.5 min-w-10',
|
||||
default: 'h-9 px-4 min-w-9 has-[>svg]:px-3',
|
||||
sm: 'h-8 px-3 min-w-8 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 px-6 min-w-10 has-[>svg]:px-4',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
871
dist/greyhaven.htmx.css
vendored
Normal file
871
dist/greyhaven.htmx.css
vendored
Normal file
@@ -0,0 +1,871 @@
|
||||
/*! Greyhaven Design System — HTMX / Framework-Agnostic CSS Layer
|
||||
* Auto-generated from components/ui/*.tsx by scripts/generate-htmx-css.ts — DO NOT EDIT
|
||||
*
|
||||
* Usage:
|
||||
* <link href="greyhaven.htmx.css" rel="stylesheet">
|
||||
*
|
||||
* Requires:
|
||||
* - Tokens: import tokens-light.css + tokens-dark.css before this file
|
||||
* - Tailwind v4: this file uses @apply against Tailwind utility classes.
|
||||
* It must be processed by Tailwind v4 (e.g., via `tailwindcss -i input.css`).
|
||||
* Your consumer Tailwind input should `@import "./greyhaven.htmx.css";`.
|
||||
*
|
||||
* Consume via data-slot / data-variant / data-size attributes:
|
||||
* <div data-slot="card">
|
||||
* <div data-slot="card-header"><div data-slot="card-title">Hello</div></div>
|
||||
* <div data-slot="card-content">…</div>
|
||||
* </div>
|
||||
* <button data-slot="button" data-variant="outline" data-size="sm">Click</button>
|
||||
* <span data-slot="badge" data-variant="success">Active</span>
|
||||
*/
|
||||
|
||||
|
||||
@layer utilities {
|
||||
|
||||
/* ── accordion-content ─────────────────────────────────────────── */
|
||||
:where([data-slot="accordion-content"]) { @apply data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm; }
|
||||
|
||||
/* ── accordion-item ─────────────────────────────────────────── */
|
||||
:where([data-slot="accordion-item"]) { @apply border-b last:border-b-0; }
|
||||
|
||||
/* ── accordion-trigger ─────────────────────────────────────────── */
|
||||
:where([data-slot="accordion-trigger"]) { @apply focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180; }
|
||||
|
||||
/* ── alert ─────────────────────────────────────────── */
|
||||
:where([data-slot="alert"]) { @apply relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current; }
|
||||
:where([data-slot="alert"]):where(:not([data-variant])) { @apply bg-card text-card-foreground; }
|
||||
:where([data-slot="alert"]):where([data-variant="default"]) { @apply bg-card text-card-foreground; }
|
||||
:where([data-slot="alert"]):where([data-variant="destructive"]) { @apply text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90; }
|
||||
|
||||
/* ── alert-description ─────────────────────────────────────────── */
|
||||
:where([data-slot="alert-description"]) { @apply text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed; }
|
||||
|
||||
/* ── alert-dialog-content ─────────────────────────────────────────── */
|
||||
:where([data-slot="alert-dialog-content"]) { @apply bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg; }
|
||||
|
||||
/* ── alert-dialog-description ─────────────────────────────────────────── */
|
||||
:where([data-slot="alert-dialog-description"]) { @apply text-muted-foreground text-sm; }
|
||||
|
||||
/* ── alert-dialog-footer ─────────────────────────────────────────── */
|
||||
:where([data-slot="alert-dialog-footer"]) { @apply flex flex-col-reverse gap-2 sm:flex-row sm:justify-end; }
|
||||
|
||||
/* ── alert-dialog-header ─────────────────────────────────────────── */
|
||||
:where([data-slot="alert-dialog-header"]) { @apply flex flex-col gap-2 text-center sm:text-left; }
|
||||
|
||||
/* ── alert-dialog-overlay ─────────────────────────────────────────── */
|
||||
:where([data-slot="alert-dialog-overlay"]) { @apply data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50; }
|
||||
|
||||
/* ── alert-dialog-title ─────────────────────────────────────────── */
|
||||
:where([data-slot="alert-dialog-title"]) { @apply text-lg font-semibold; }
|
||||
|
||||
/* ── alert-title ─────────────────────────────────────────── */
|
||||
:where([data-slot="alert-title"]) { @apply col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight; }
|
||||
|
||||
/* ── avatar ─────────────────────────────────────────── */
|
||||
:where([data-slot="avatar"]) { @apply relative flex size-8 shrink-0 overflow-hidden rounded-full; }
|
||||
|
||||
/* ── avatar-fallback ─────────────────────────────────────────── */
|
||||
:where([data-slot="avatar-fallback"]) { @apply bg-muted flex size-full items-center justify-center rounded-full; }
|
||||
|
||||
/* ── avatar-image ─────────────────────────────────────────── */
|
||||
:where([data-slot="avatar-image"]) { @apply aspect-square size-full; }
|
||||
|
||||
/* ── badge ─────────────────────────────────────────── */
|
||||
:where([data-slot="badge"]) { @apply inline-flex items-center justify-center rounded-md border font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden; }
|
||||
:where([data-slot="badge"]):where(:not([data-variant])) { @apply border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90; }
|
||||
:where([data-slot="badge"]):where([data-variant="default"]) { @apply border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90; }
|
||||
:where([data-slot="badge"]):where([data-variant="secondary"]) { @apply border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90; }
|
||||
:where([data-slot="badge"]):where([data-variant="muted"]) { @apply border-transparent bg-muted text-muted-foreground [a&]:hover:bg-muted/80; }
|
||||
:where([data-slot="badge"]):where([data-variant="destructive"]) { @apply border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60; }
|
||||
:where([data-slot="badge"]):where([data-variant="outline"]) { @apply text-foreground [a&]:hover:bg-accent/10 [a&]:hover:text-accent-foreground; }
|
||||
:where([data-slot="badge"]):where([data-variant="success"]) { @apply border-transparent bg-[#1a7f37] text-white [a&]:hover:bg-[#1a7f37]/90; }
|
||||
:where([data-slot="badge"]):where([data-variant="warning"]) { @apply border-transparent bg-[#9a6700] text-white [a&]:hover:bg-[#9a6700]/90; }
|
||||
:where([data-slot="badge"]):where([data-variant="info"]) { @apply border-transparent bg-[#0969da] text-white [a&]:hover:bg-[#0969da]/90; }
|
||||
:where([data-slot="badge"]):where([data-variant="tag"]) { @apply border-border bg-card text-foreground [a&]:hover:bg-muted; }
|
||||
:where([data-slot="badge"]):where([data-variant="value"]) { @apply border-transparent bg-muted text-foreground font-mono [a&]:hover:bg-muted/80; }
|
||||
:where([data-slot="badge"]):where([data-variant="whatsapp"]) { @apply border-transparent bg-[#22c55e] text-white [a&]:hover:bg-[#22c55e]/90; }
|
||||
:where([data-slot="badge"]):where([data-variant="email"]) { @apply border-transparent bg-[#4b5563] text-white [a&]:hover:bg-[#4b5563]/90; }
|
||||
:where([data-slot="badge"]):where([data-variant="telegram"]) { @apply border-transparent bg-[#3b82f6] text-white [a&]:hover:bg-[#3b82f6]/90; }
|
||||
:where([data-slot="badge"]):where([data-variant="zulip"]) { @apply border-transparent bg-[#a855f7] text-white [a&]:hover:bg-[#a855f7]/90; }
|
||||
:where([data-slot="badge"]):where([data-variant="platform"]) { @apply border-transparent bg-[#f97316] text-white [a&]:hover:bg-[#f97316]/90; }
|
||||
:where([data-slot="badge"]):where([data-size="sm"]) { @apply text-xs px-1.5 py-0; }
|
||||
:where([data-slot="badge"]):where(:not([data-size])) { @apply text-xs px-2 py-0.5; }
|
||||
:where([data-slot="badge"]):where([data-size="default"]) { @apply text-xs px-2 py-0.5; }
|
||||
:where([data-slot="badge"]):where([data-size="lg"]) { @apply text-sm px-3 py-1 [&>svg]:size-3.5; }
|
||||
|
||||
/* ── breadcrumb-ellipsis ─────────────────────────────────────────── */
|
||||
:where([data-slot="breadcrumb-ellipsis"]) { @apply flex size-9 items-center justify-center; }
|
||||
|
||||
/* ── breadcrumb-item ─────────────────────────────────────────── */
|
||||
:where([data-slot="breadcrumb-item"]) { @apply inline-flex items-center gap-1.5; }
|
||||
|
||||
/* ── breadcrumb-link ─────────────────────────────────────────── */
|
||||
:where([data-slot="breadcrumb-link"]) { @apply hover:text-foreground transition-colors; }
|
||||
|
||||
/* ── breadcrumb-list ─────────────────────────────────────────── */
|
||||
:where([data-slot="breadcrumb-list"]) { @apply text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5; }
|
||||
|
||||
/* ── breadcrumb-page ─────────────────────────────────────────── */
|
||||
:where([data-slot="breadcrumb-page"]) { @apply text-foreground font-normal; }
|
||||
|
||||
/* ── breadcrumb-separator ─────────────────────────────────────────── */
|
||||
:where([data-slot="breadcrumb-separator"]) { @apply [&>svg]:size-3.5; }
|
||||
|
||||
/* ── button ─────────────────────────────────────────── */
|
||||
:where([data-slot="button"]) { @apply inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive; }
|
||||
:where([data-slot="button"]):where(:not([data-variant])) { @apply bg-primary text-primary-foreground hover:bg-primary/90; }
|
||||
:where([data-slot="button"]):where([data-variant="default"]) { @apply bg-primary text-primary-foreground hover:bg-primary/90; }
|
||||
:where([data-slot="button"]):where([data-variant="destructive"]) { @apply bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60; }
|
||||
:where([data-slot="button"]):where([data-variant="outline"]) { @apply border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50; }
|
||||
:where([data-slot="button"]):where([data-variant="secondary"]) { @apply bg-secondary text-secondary-foreground hover:bg-secondary/80; }
|
||||
:where([data-slot="button"]):where([data-variant="ghost"]) { @apply hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50; }
|
||||
:where([data-slot="button"]):where([data-variant="link"]) { @apply text-primary underline-offset-4 hover:underline; }
|
||||
:where([data-slot="button"]):where(:not([data-size])) { @apply h-9 px-4 py-2 has-[>svg]:px-3; }
|
||||
:where([data-slot="button"]):where([data-size="default"]) { @apply h-9 px-4 py-2 has-[>svg]:px-3; }
|
||||
:where([data-slot="button"]):where([data-size="sm"]) { @apply h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5; }
|
||||
:where([data-slot="button"]):where([data-size="lg"]) { @apply h-10 rounded-md px-6 has-[>svg]:px-4; }
|
||||
:where([data-slot="button"]):where([data-size="icon"]) { @apply size-9; }
|
||||
:where([data-slot="button"]):where([data-size="icon-sm"]) { @apply size-8; }
|
||||
:where([data-slot="button"]):where([data-size="icon-lg"]) { @apply size-10; }
|
||||
|
||||
/* ── button-group ─────────────────────────────────────────── */
|
||||
:where([data-slot="button-group"]) { @apply flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2; }
|
||||
:where([data-slot="button-group"]):where(:not([data-orientation])) { @apply [&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none; }
|
||||
:where([data-slot="button-group"]):where([data-orientation="horizontal"]) { @apply [&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none; }
|
||||
:where([data-slot="button-group"]):where([data-orientation="vertical"]) { @apply flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none; }
|
||||
|
||||
/* ── button-group-separator ─────────────────────────────────────────── */
|
||||
:where([data-slot="button-group-separator"]) { @apply bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto; }
|
||||
|
||||
/* ── card ─────────────────────────────────────────── */
|
||||
:where([data-slot="card"]) { @apply bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm; }
|
||||
|
||||
/* ── card-action ─────────────────────────────────────────── */
|
||||
:where([data-slot="card-action"]) { @apply col-start-2 row-span-2 row-start-1 self-start justify-self-end; }
|
||||
|
||||
/* ── card-content ─────────────────────────────────────────── */
|
||||
:where([data-slot="card-content"]) { @apply px-6; }
|
||||
|
||||
/* ── card-description ─────────────────────────────────────────── */
|
||||
:where([data-slot="card-description"]) { @apply text-muted-foreground text-sm; }
|
||||
|
||||
/* ── card-footer ─────────────────────────────────────────── */
|
||||
:where([data-slot="card-footer"]) { @apply flex items-center px-6 [.border-t]:pt-6; }
|
||||
|
||||
/* ── card-header ─────────────────────────────────────────── */
|
||||
:where([data-slot="card-header"]) { @apply @container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6; }
|
||||
|
||||
/* ── card-title ─────────────────────────────────────────── */
|
||||
:where([data-slot="card-title"]) { @apply font-semibold; }
|
||||
|
||||
/* ── carousel ─────────────────────────────────────────── */
|
||||
:where([data-slot="carousel"]) { @apply relative; }
|
||||
|
||||
/* ── carousel-content ─────────────────────────────────────────── */
|
||||
:where([data-slot="carousel-content"]) { @apply overflow-hidden; }
|
||||
|
||||
/* ── carousel-item ─────────────────────────────────────────── */
|
||||
:where([data-slot="carousel-item"]) { @apply min-w-0 shrink-0 grow-0 basis-full; }
|
||||
|
||||
/* ── carousel-next ─────────────────────────────────────────── */
|
||||
:where([data-slot="carousel-next"]) { @apply absolute size-8 rounded-full; }
|
||||
|
||||
/* ── carousel-previous ─────────────────────────────────────────── */
|
||||
:where([data-slot="carousel-previous"]) { @apply absolute size-8 rounded-full; }
|
||||
|
||||
/* ── chart ─────────────────────────────────────────── */
|
||||
:where([data-slot="chart"]) { @apply [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden; }
|
||||
|
||||
/* ── checkbox ─────────────────────────────────────────── */
|
||||
:where([data-slot="checkbox"]) { @apply border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50; }
|
||||
|
||||
/* ── checkbox-indicator ─────────────────────────────────────────── */
|
||||
:where([data-slot="checkbox-indicator"]) { @apply flex items-center justify-center text-current transition-none; }
|
||||
|
||||
/* ── code ─────────────────────────────────────────── */
|
||||
:where([data-slot="code"]) { @apply bg-muted border border-border font-mono text-foreground; }
|
||||
:where([data-slot="code"]):where(:not([data-variant])) { @apply rounded text-xs px-1.5 py-0.5; }
|
||||
:where([data-slot="code"]):where([data-variant="inline"]) { @apply rounded text-xs px-1.5 py-0.5; }
|
||||
:where([data-slot="code"]):where([data-variant="block"]) { @apply block rounded-md text-sm px-4 py-3 break-all whitespace-pre-wrap; }
|
||||
|
||||
/* ── code-block ─────────────────────────────────────────── */
|
||||
|
||||
/* ── command ─────────────────────────────────────────── */
|
||||
:where([data-slot="command"]) { @apply bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md; }
|
||||
|
||||
/* ── command-empty ─────────────────────────────────────────── */
|
||||
:where([data-slot="command-empty"]) { @apply py-6 text-center text-sm; }
|
||||
|
||||
/* ── command-group ─────────────────────────────────────────── */
|
||||
:where([data-slot="command-group"]) { @apply text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium; }
|
||||
|
||||
/* ── command-input ─────────────────────────────────────────── */
|
||||
:where([data-slot="command-input"]) { @apply placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50; }
|
||||
|
||||
/* ── command-input-wrapper ─────────────────────────────────────────── */
|
||||
:where([data-slot="command-input-wrapper"]) { @apply flex h-9 items-center gap-2 border-b px-3; }
|
||||
|
||||
/* ── command-item ─────────────────────────────────────────── */
|
||||
:where([data-slot="command-item"]) { @apply data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
|
||||
|
||||
/* ── command-list ─────────────────────────────────────────── */
|
||||
:where([data-slot="command-list"]) { @apply max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto; }
|
||||
|
||||
/* ── command-separator ─────────────────────────────────────────── */
|
||||
:where([data-slot="command-separator"]) { @apply bg-border -mx-1 h-px; }
|
||||
|
||||
/* ── command-shortcut ─────────────────────────────────────────── */
|
||||
:where([data-slot="command-shortcut"]) { @apply text-muted-foreground ml-auto text-xs tracking-widest; }
|
||||
|
||||
/* ── context-menu-checkbox-item ─────────────────────────────────────────── */
|
||||
:where([data-slot="context-menu-checkbox-item"]) { @apply focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
|
||||
|
||||
/* ── context-menu-content ─────────────────────────────────────────── */
|
||||
:where([data-slot="context-menu-content"]) { @apply bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md; }
|
||||
|
||||
/* ── context-menu-item ─────────────────────────────────────────── */
|
||||
:where([data-slot="context-menu-item"]) { @apply focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
|
||||
|
||||
/* ── context-menu-label ─────────────────────────────────────────── */
|
||||
:where([data-slot="context-menu-label"]) { @apply text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8; }
|
||||
|
||||
/* ── context-menu-radio-item ─────────────────────────────────────────── */
|
||||
:where([data-slot="context-menu-radio-item"]) { @apply focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
|
||||
|
||||
/* ── context-menu-separator ─────────────────────────────────────────── */
|
||||
:where([data-slot="context-menu-separator"]) { @apply bg-border -mx-1 my-1 h-px; }
|
||||
|
||||
/* ── context-menu-shortcut ─────────────────────────────────────────── */
|
||||
:where([data-slot="context-menu-shortcut"]) { @apply text-muted-foreground ml-auto text-xs tracking-widest; }
|
||||
|
||||
/* ── context-menu-sub-content ─────────────────────────────────────────── */
|
||||
:where([data-slot="context-menu-sub-content"]) { @apply bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg; }
|
||||
|
||||
/* ── context-menu-sub-trigger ─────────────────────────────────────────── */
|
||||
:where([data-slot="context-menu-sub-trigger"]) { @apply focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
|
||||
|
||||
/* ── cta-section ─────────────────────────────────────────── */
|
||||
:where([data-slot="cta-section"]) { @apply py-16 px-6; }
|
||||
:where([data-slot="cta-section"]):where(:not([data-variant])) { @apply text-center; }
|
||||
:where([data-slot="cta-section"]):where([data-variant="centered"]) { @apply text-center; }
|
||||
:where([data-slot="cta-section"]):where([data-variant="left-aligned"]) { @apply text-left; }
|
||||
:where([data-slot="cta-section"]):where([data-background="default"]) { @apply bg-background; }
|
||||
:where([data-slot="cta-section"]):where(:not([data-background])) { @apply bg-muted; }
|
||||
:where([data-slot="cta-section"]):where([data-background="muted"]) { @apply bg-muted; }
|
||||
:where([data-slot="cta-section"]):where([data-background="accent"]) { @apply bg-primary text-primary-foreground; }
|
||||
:where([data-slot="cta-section"]):where([data-background="subtle"]) { @apply bg-primary/5; }
|
||||
|
||||
/* ── dialog-close ─────────────────────────────────────────── */
|
||||
:where([data-slot="dialog-close"]) { @apply ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
|
||||
|
||||
/* ── dialog-content ─────────────────────────────────────────── */
|
||||
:where([data-slot="dialog-content"]) { @apply bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg; }
|
||||
|
||||
/* ── dialog-description ─────────────────────────────────────────── */
|
||||
:where([data-slot="dialog-description"]) { @apply text-muted-foreground text-sm; }
|
||||
|
||||
/* ── dialog-footer ─────────────────────────────────────────── */
|
||||
:where([data-slot="dialog-footer"]) { @apply flex flex-col-reverse gap-2 sm:flex-row sm:justify-end; }
|
||||
|
||||
/* ── dialog-header ─────────────────────────────────────────── */
|
||||
:where([data-slot="dialog-header"]) { @apply flex flex-col gap-2 text-center sm:text-left; }
|
||||
|
||||
/* ── dialog-overlay ─────────────────────────────────────────── */
|
||||
:where([data-slot="dialog-overlay"]) { @apply data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50; }
|
||||
|
||||
/* ── dialog-title ─────────────────────────────────────────── */
|
||||
:where([data-slot="dialog-title"]) { @apply text-lg font-semibold; }
|
||||
|
||||
/* ── drawer-content ─────────────────────────────────────────── */
|
||||
:where([data-slot="drawer-content"]) { @apply bg-background fixed z-50 flex h-auto flex-col; }
|
||||
|
||||
/* ── drawer-description ─────────────────────────────────────────── */
|
||||
:where([data-slot="drawer-description"]) { @apply text-muted-foreground text-sm; }
|
||||
|
||||
/* ── drawer-footer ─────────────────────────────────────────── */
|
||||
:where([data-slot="drawer-footer"]) { @apply mt-auto flex flex-col gap-2 p-4; }
|
||||
|
||||
/* ── drawer-header ─────────────────────────────────────────── */
|
||||
:where([data-slot="drawer-header"]) { @apply flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left; }
|
||||
|
||||
/* ── drawer-overlay ─────────────────────────────────────────── */
|
||||
:where([data-slot="drawer-overlay"]) { @apply data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50; }
|
||||
|
||||
/* ── drawer-title ─────────────────────────────────────────── */
|
||||
:where([data-slot="drawer-title"]) { @apply text-foreground font-semibold; }
|
||||
|
||||
/* ── dropdown-menu-checkbox-item ─────────────────────────────────────────── */
|
||||
:where([data-slot="dropdown-menu-checkbox-item"]) { @apply focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
|
||||
|
||||
/* ── dropdown-menu-content ─────────────────────────────────────────── */
|
||||
:where([data-slot="dropdown-menu-content"]) { @apply bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md; }
|
||||
|
||||
/* ── dropdown-menu-item ─────────────────────────────────────────── */
|
||||
:where([data-slot="dropdown-menu-item"]) { @apply focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
|
||||
|
||||
/* ── dropdown-menu-label ─────────────────────────────────────────── */
|
||||
:where([data-slot="dropdown-menu-label"]) { @apply px-2 py-1.5 text-sm font-medium data-[inset]:pl-8; }
|
||||
|
||||
/* ── dropdown-menu-radio-item ─────────────────────────────────────────── */
|
||||
:where([data-slot="dropdown-menu-radio-item"]) { @apply focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
|
||||
|
||||
/* ── dropdown-menu-separator ─────────────────────────────────────────── */
|
||||
:where([data-slot="dropdown-menu-separator"]) { @apply bg-border -mx-1 my-1 h-px; }
|
||||
|
||||
/* ── dropdown-menu-shortcut ─────────────────────────────────────────── */
|
||||
:where([data-slot="dropdown-menu-shortcut"]) { @apply text-muted-foreground ml-auto text-xs tracking-widest; }
|
||||
|
||||
/* ── dropdown-menu-sub-content ─────────────────────────────────────────── */
|
||||
:where([data-slot="dropdown-menu-sub-content"]) { @apply bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg; }
|
||||
|
||||
/* ── dropdown-menu-sub-trigger ─────────────────────────────────────────── */
|
||||
:where([data-slot="dropdown-menu-sub-trigger"]) { @apply focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
|
||||
|
||||
/* ── empty ─────────────────────────────────────────── */
|
||||
:where([data-slot="empty"]) { @apply flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12; }
|
||||
|
||||
/* ── empty-content ─────────────────────────────────────────── */
|
||||
:where([data-slot="empty-content"]) { @apply flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance; }
|
||||
|
||||
/* ── empty-description ─────────────────────────────────────────── */
|
||||
:where([data-slot="empty-description"]) { @apply text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4; }
|
||||
|
||||
/* ── empty-header ─────────────────────────────────────────── */
|
||||
:where([data-slot="empty-header"]) { @apply flex max-w-sm flex-col items-center gap-2 text-center; }
|
||||
|
||||
/* ── empty-media ─────────────────────────────────────────── */
|
||||
:where([data-slot="empty-media"]) { @apply flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0; }
|
||||
:where([data-slot="empty-media"]):where(:not([data-variant])) { @apply bg-transparent; }
|
||||
:where([data-slot="empty-media"]):where([data-variant="default"]) { @apply bg-transparent; }
|
||||
:where([data-slot="empty-media"]):where([data-variant="icon"]) { @apply bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6; }
|
||||
|
||||
/* ── empty-title ─────────────────────────────────────────── */
|
||||
:where([data-slot="empty-title"]) { @apply text-lg font-medium tracking-tight; }
|
||||
|
||||
/* ── field ─────────────────────────────────────────── */
|
||||
:where([data-slot="field"]) { @apply flex w-full gap-3 data-[invalid=true]:text-destructive; }
|
||||
|
||||
/* ── field-content ─────────────────────────────────────────── */
|
||||
:where([data-slot="field-content"]) { @apply flex flex-1 flex-col gap-1.5; }
|
||||
|
||||
/* ── field-description ─────────────────────────────────────────── */
|
||||
:where([data-slot="field-description"]) { @apply text-muted-foreground text-sm font-normal group-has-[[data-orientation=horizontal]]/field:text-balance; }
|
||||
|
||||
/* ── field-error ─────────────────────────────────────────── */
|
||||
:where([data-slot="field-error"]) { @apply text-destructive text-sm font-normal; }
|
||||
|
||||
/* ── field-group ─────────────────────────────────────────── */
|
||||
:where([data-slot="field-group"]) { @apply @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4; }
|
||||
|
||||
/* ── field-label ─────────────────────────────────────────── */
|
||||
:where([data-slot="field-label"]) { @apply flex w-fit gap-2 group-data-[disabled=true]/field:opacity-50 flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50; }
|
||||
|
||||
/* ── field-legend ─────────────────────────────────────────── */
|
||||
:where([data-slot="field-legend"]) { @apply mb-3 font-medium; }
|
||||
|
||||
/* ── field-separator ─────────────────────────────────────────── */
|
||||
:where([data-slot="field-separator"]) { @apply relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2; }
|
||||
|
||||
/* ── field-separator-content ─────────────────────────────────────────── */
|
||||
:where([data-slot="field-separator-content"]) { @apply bg-background text-muted-foreground relative mx-auto block w-fit px-2; }
|
||||
|
||||
/* ── field-set ─────────────────────────────────────────── */
|
||||
:where([data-slot="field-set"]) { @apply flex flex-col gap-6; }
|
||||
|
||||
/* ── footer ─────────────────────────────────────────── */
|
||||
:where([data-slot="footer"]) { @apply border-t border-border bg-background font-sans; }
|
||||
:where([data-slot="footer"]):where(:not([data-variant])) { @apply py-8; }
|
||||
:where([data-slot="footer"]):where([data-variant="minimal"]) { @apply py-8; }
|
||||
:where([data-slot="footer"]):where([data-variant="full"]) { @apply py-12; }
|
||||
|
||||
/* ── form-description ─────────────────────────────────────────── */
|
||||
:where([data-slot="form-description"]) { @apply text-muted-foreground text-sm; }
|
||||
|
||||
/* ── form-item ─────────────────────────────────────────── */
|
||||
:where([data-slot="form-item"]) { @apply grid gap-2; }
|
||||
|
||||
/* ── form-label ─────────────────────────────────────────── */
|
||||
:where([data-slot="form-label"]) { @apply data-[error=true]:text-destructive; }
|
||||
|
||||
/* ── form-message ─────────────────────────────────────────── */
|
||||
:where([data-slot="form-message"]) { @apply text-destructive text-sm; }
|
||||
|
||||
/* ── hero ─────────────────────────────────────────── */
|
||||
:where([data-slot="hero"]) { @apply py-24 px-6; }
|
||||
:where([data-slot="hero"]):where(:not([data-variant])) { @apply text-center; }
|
||||
:where([data-slot="hero"]):where([data-variant="centered"]) { @apply text-center; }
|
||||
:where([data-slot="hero"]):where([data-variant="left-aligned"]) { @apply text-left; }
|
||||
:where([data-slot="hero"]):where([data-variant="split"]) { @apply text-left; }
|
||||
:where([data-slot="hero"]):where(:not([data-background])) { @apply bg-hero-bg; }
|
||||
:where([data-slot="hero"]):where([data-background="default"]) { @apply bg-hero-bg; }
|
||||
:where([data-slot="hero"]):where([data-background="muted"]) { @apply bg-muted; }
|
||||
:where([data-slot="hero"]):where([data-background="accent"]) { @apply bg-primary/5; }
|
||||
:where([data-slot="hero"]):where([data-background="dark"]) { @apply bg-foreground text-background; }
|
||||
|
||||
/* ── hover-card-content ─────────────────────────────────────────── */
|
||||
:where([data-slot="hover-card-content"]) { @apply bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden; }
|
||||
|
||||
/* ── input ─────────────────────────────────────────── */
|
||||
:where([data-slot="input"]) { @apply file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm; }
|
||||
|
||||
/* ── input-group ─────────────────────────────────────────── */
|
||||
:where([data-slot="input-group"]) { @apply border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none; }
|
||||
|
||||
/* ── input-group-addon ─────────────────────────────────────────── */
|
||||
:where([data-slot="input-group-addon"]) { @apply text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50; }
|
||||
:where([data-slot="input-group-addon"]):where(:not([data-align])) { @apply order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]; }
|
||||
:where([data-slot="input-group-addon"]):where([data-align="inline-start"]) { @apply order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]; }
|
||||
:where([data-slot="input-group-addon"]):where([data-align="inline-end"]) { @apply order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]; }
|
||||
:where([data-slot="input-group-addon"]):where([data-align="block-start"]) { @apply order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5; }
|
||||
:where([data-slot="input-group-addon"]):where([data-align="block-end"]) { @apply order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5; }
|
||||
|
||||
/* ── input-group-button ─────────────────────────────────────────── */
|
||||
:where([data-slot="input-group-button"]) { @apply text-sm shadow-none flex gap-2 items-center; }
|
||||
:where([data-slot="input-group-button"]):where(:not([data-size])) { @apply h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2; }
|
||||
:where([data-slot="input-group-button"]):where([data-size="xs"]) { @apply h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2; }
|
||||
:where([data-slot="input-group-button"]):where([data-size="sm"]) { @apply h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5; }
|
||||
:where([data-slot="input-group-button"]):where([data-size="icon-xs"]) { @apply size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0; }
|
||||
:where([data-slot="input-group-button"]):where([data-size="icon-sm"]) { @apply size-8 p-0 has-[>svg]:p-0; }
|
||||
|
||||
/* ── input-group-control ─────────────────────────────────────────── */
|
||||
:where([data-slot="input-group-control"]) { @apply flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent; }
|
||||
|
||||
/* ── input-otp ─────────────────────────────────────────── */
|
||||
:where([data-slot="input-otp"]) { @apply disabled:cursor-not-allowed; }
|
||||
|
||||
/* ── input-otp-group ─────────────────────────────────────────── */
|
||||
:where([data-slot="input-otp-group"]) { @apply flex items-center; }
|
||||
|
||||
/* ── input-otp-slot ─────────────────────────────────────────── */
|
||||
:where([data-slot="input-otp-slot"]) { @apply data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]; }
|
||||
|
||||
/* ── item ─────────────────────────────────────────── */
|
||||
:where([data-slot="item"]) { @apply flex items-center border border-transparent text-sm rounded-md transition-colors [a&]:hover:bg-accent/50 [a&]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]; }
|
||||
:where([data-slot="item"]):where(:not([data-variant])) { @apply bg-transparent; }
|
||||
:where([data-slot="item"]):where([data-variant="default"]) { @apply bg-transparent; }
|
||||
:where([data-slot="item"]):where([data-variant="outline"]) { @apply border-border; }
|
||||
:where([data-slot="item"]):where([data-variant="muted"]) { @apply bg-muted/50; }
|
||||
:where([data-slot="item"]):where(:not([data-size])) { @apply p-4 gap-4; }
|
||||
:where([data-slot="item"]):where([data-size="default"]) { @apply p-4 gap-4; }
|
||||
:where([data-slot="item"]):where([data-size="sm"]) { @apply py-3 px-4 gap-2.5; }
|
||||
|
||||
/* ── item-actions ─────────────────────────────────────────── */
|
||||
:where([data-slot="item-actions"]) { @apply flex items-center gap-2; }
|
||||
|
||||
/* ── item-content ─────────────────────────────────────────── */
|
||||
:where([data-slot="item-content"]) { @apply flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none; }
|
||||
|
||||
/* ── item-description ─────────────────────────────────────────── */
|
||||
:where([data-slot="item-description"]) { @apply text-muted-foreground line-clamp-2 text-sm font-normal text-balance; }
|
||||
|
||||
/* ── item-footer ─────────────────────────────────────────── */
|
||||
:where([data-slot="item-footer"]) { @apply flex basis-full items-center justify-between gap-2; }
|
||||
|
||||
/* ── item-group ─────────────────────────────────────────── */
|
||||
:where([data-slot="item-group"]) { @apply flex flex-col; }
|
||||
|
||||
/* ── item-header ─────────────────────────────────────────── */
|
||||
:where([data-slot="item-header"]) { @apply flex basis-full items-center justify-between gap-2; }
|
||||
|
||||
/* ── item-media ─────────────────────────────────────────── */
|
||||
:where([data-slot="item-media"]) { @apply flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5; }
|
||||
:where([data-slot="item-media"]):where(:not([data-variant])) { @apply bg-transparent; }
|
||||
:where([data-slot="item-media"]):where([data-variant="default"]) { @apply bg-transparent; }
|
||||
:where([data-slot="item-media"]):where([data-variant="icon"]) { @apply size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4; }
|
||||
:where([data-slot="item-media"]):where([data-variant="image"]) { @apply size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover; }
|
||||
|
||||
/* ── item-separator ─────────────────────────────────────────── */
|
||||
:where([data-slot="item-separator"]) { @apply my-0; }
|
||||
|
||||
/* ── item-title ─────────────────────────────────────────── */
|
||||
:where([data-slot="item-title"]) { @apply flex w-fit items-center gap-2 text-sm font-medium; }
|
||||
|
||||
/* ── kbd ─────────────────────────────────────────── */
|
||||
:where([data-slot="kbd"]) { @apply bg-muted w-fit text-muted-foreground pointer-events-none inline-flex h-5 min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none; }
|
||||
|
||||
/* ── kbd-group ─────────────────────────────────────────── */
|
||||
:where([data-slot="kbd-group"]) { @apply inline-flex items-center gap-1; }
|
||||
|
||||
/* ── label ─────────────────────────────────────────── */
|
||||
:where([data-slot="label"]) { @apply flex items-center gap-2 text-sm font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50; }
|
||||
|
||||
/* ── logo ─────────────────────────────────────────── */
|
||||
:where([data-slot="logo"]) { @apply inline-block; }
|
||||
:where([data-slot="logo"]):where([data-size="sm"]) { @apply h-6 w-auto; }
|
||||
:where([data-slot="logo"]):where(:not([data-size])) { @apply h-8 w-auto; }
|
||||
:where([data-slot="logo"]):where([data-size="md"]) { @apply h-8 w-auto; }
|
||||
:where([data-slot="logo"]):where([data-size="lg"]) { @apply h-10 w-auto; }
|
||||
:where([data-slot="logo"]):where([data-size="xl"]) { @apply h-14 w-auto; }
|
||||
|
||||
/* ── menubar ─────────────────────────────────────────── */
|
||||
:where([data-slot="menubar"]) { @apply bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs; }
|
||||
|
||||
/* ── menubar-checkbox-item ─────────────────────────────────────────── */
|
||||
:where([data-slot="menubar-checkbox-item"]) { @apply focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
|
||||
|
||||
/* ── menubar-content ─────────────────────────────────────────── */
|
||||
:where([data-slot="menubar-content"]) { @apply bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md; }
|
||||
|
||||
/* ── menubar-item ─────────────────────────────────────────── */
|
||||
:where([data-slot="menubar-item"]) { @apply focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
|
||||
|
||||
/* ── menubar-label ─────────────────────────────────────────── */
|
||||
:where([data-slot="menubar-label"]) { @apply px-2 py-1.5 text-sm font-medium data-[inset]:pl-8; }
|
||||
|
||||
/* ── menubar-radio-item ─────────────────────────────────────────── */
|
||||
:where([data-slot="menubar-radio-item"]) { @apply focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
|
||||
|
||||
/* ── menubar-separator ─────────────────────────────────────────── */
|
||||
:where([data-slot="menubar-separator"]) { @apply bg-border -mx-1 my-1 h-px; }
|
||||
|
||||
/* ── menubar-shortcut ─────────────────────────────────────────── */
|
||||
:where([data-slot="menubar-shortcut"]) { @apply text-muted-foreground ml-auto text-xs tracking-widest; }
|
||||
|
||||
/* ── menubar-sub-content ─────────────────────────────────────────── */
|
||||
:where([data-slot="menubar-sub-content"]) { @apply bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg; }
|
||||
|
||||
/* ── menubar-sub-trigger ─────────────────────────────────────────── */
|
||||
:where([data-slot="menubar-sub-trigger"]) { @apply focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8; }
|
||||
|
||||
/* ── menubar-trigger ─────────────────────────────────────────── */
|
||||
:where([data-slot="menubar-trigger"]) { @apply focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none; }
|
||||
|
||||
/* ── navbar ─────────────────────────────────────────── */
|
||||
:where([data-slot="navbar"]) { @apply fixed top-0 left-0 right-0 z-50 h-[65px] font-sans; }
|
||||
:where([data-slot="navbar"]):where(:not([data-variant])) { @apply bg-card dark:bg-background border-b border-border; }
|
||||
:where([data-slot="navbar"]):where([data-variant="solid"]) { @apply bg-card dark:bg-background border-b border-border; }
|
||||
:where([data-slot="navbar"]):where([data-variant="transparent"]) { @apply bg-transparent; }
|
||||
:where([data-slot="navbar"]):where([data-variant="minimal"]) { @apply bg-card/80 dark:bg-background/80 backdrop-blur-sm border-b border-border/50; }
|
||||
|
||||
/* ── navbar-actions ─────────────────────────────────────────── */
|
||||
:where([data-slot="navbar-actions"]) { @apply hidden md:flex items-center gap-2; }
|
||||
|
||||
/* ── navbar-link ─────────────────────────────────────────── */
|
||||
:where([data-slot="navbar-link"]) { @apply px-3 py-2 text-foreground transition-opacity hover:opacity-70; }
|
||||
|
||||
/* ── navbar-logo ─────────────────────────────────────────── */
|
||||
:where([data-slot="navbar-logo"]) { @apply shrink-0; }
|
||||
|
||||
/* ── navbar-mobile ─────────────────────────────────────────── */
|
||||
:where([data-slot="navbar-mobile"]) { @apply md:hidden border-b border-border bg-card dark:bg-background; }
|
||||
|
||||
/* ── navbar-nav ─────────────────────────────────────────── */
|
||||
:where([data-slot="navbar-nav"]) { @apply hidden md:flex items-center gap-1 text-sm font-semibold; }
|
||||
|
||||
/* ── navigation-menu ─────────────────────────────────────────── */
|
||||
:where([data-slot="navigation-menu"]) { @apply relative flex max-w-max flex-1 items-center justify-center; }
|
||||
|
||||
/* ── navigation-menu-content ─────────────────────────────────────────── */
|
||||
:where([data-slot="navigation-menu-content"]) { @apply data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto; }
|
||||
|
||||
/* ── navigation-menu-indicator ─────────────────────────────────────────── */
|
||||
:where([data-slot="navigation-menu-indicator"]) { @apply data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden; }
|
||||
|
||||
/* ── navigation-menu-item ─────────────────────────────────────────── */
|
||||
:where([data-slot="navigation-menu-item"]) { @apply relative; }
|
||||
|
||||
/* ── navigation-menu-link ─────────────────────────────────────────── */
|
||||
:where([data-slot="navigation-menu-link"]) { @apply data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4; }
|
||||
|
||||
/* ── navigation-menu-list ─────────────────────────────────────────── */
|
||||
:where([data-slot="navigation-menu-list"]) { @apply flex flex-1 list-none items-center justify-center gap-1; }
|
||||
|
||||
/* ── navigation-menu-trigger ─────────────────────────────────────────── */
|
||||
|
||||
/* ── navigation-menu-viewport ─────────────────────────────────────────── */
|
||||
:where([data-slot="navigation-menu-viewport"]) { @apply bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]; }
|
||||
|
||||
/* ── page-layout ─────────────────────────────────────────── */
|
||||
:where([data-slot="page-layout"]) { @apply min-h-screen flex flex-col bg-background text-foreground; }
|
||||
|
||||
/* ── page-layout-main ─────────────────────────────────────────── */
|
||||
:where([data-slot="page-layout-main"]) { @apply flex-1 min-w-0; }
|
||||
|
||||
/* ── page-layout-sidebar ─────────────────────────────────────────── */
|
||||
:where([data-slot="page-layout-sidebar"]) { @apply hidden lg:block w-64 border-r border-border bg-background flex-shrink-0; }
|
||||
|
||||
/* ── pagination ─────────────────────────────────────────── */
|
||||
:where([data-slot="pagination"]) { @apply mx-auto flex w-full justify-center; }
|
||||
|
||||
/* ── pagination-content ─────────────────────────────────────────── */
|
||||
:where([data-slot="pagination-content"]) { @apply flex flex-row items-center gap-1; }
|
||||
|
||||
/* ── pagination-ellipsis ─────────────────────────────────────────── */
|
||||
:where([data-slot="pagination-ellipsis"]) { @apply flex size-9 items-center justify-center; }
|
||||
|
||||
/* ── popover-content ─────────────────────────────────────────── */
|
||||
:where([data-slot="popover-content"]) { @apply bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden; }
|
||||
|
||||
/* ── progress ─────────────────────────────────────────── */
|
||||
:where([data-slot="progress"]) { @apply bg-primary/20 relative h-2 w-full overflow-hidden rounded-full; }
|
||||
|
||||
/* ── progress-indicator ─────────────────────────────────────────── */
|
||||
:where([data-slot="progress-indicator"]) { @apply bg-primary h-full w-full flex-1 transition-all; }
|
||||
|
||||
/* ── radio-group ─────────────────────────────────────────── */
|
||||
:where([data-slot="radio-group"]) { @apply grid gap-3; }
|
||||
|
||||
/* ── radio-group-indicator ─────────────────────────────────────────── */
|
||||
:where([data-slot="radio-group-indicator"]) { @apply relative flex items-center justify-center; }
|
||||
|
||||
/* ── radio-group-item ─────────────────────────────────────────── */
|
||||
:where([data-slot="radio-group-item"]) { @apply border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50; }
|
||||
|
||||
/* ── resizable-handle ─────────────────────────────────────────── */
|
||||
:where([data-slot="resizable-handle"]) { @apply bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90; }
|
||||
|
||||
/* ── resizable-panel-group ─────────────────────────────────────────── */
|
||||
:where([data-slot="resizable-panel-group"]) { @apply flex h-full w-full data-[panel-group-direction=vertical]:flex-col; }
|
||||
|
||||
/* ── scroll-area ─────────────────────────────────────────── */
|
||||
:where([data-slot="scroll-area"]) { @apply relative; }
|
||||
|
||||
/* ── scroll-area-scrollbar ─────────────────────────────────────────── */
|
||||
:where([data-slot="scroll-area-scrollbar"]) { @apply flex touch-none p-px transition-colors select-none; }
|
||||
|
||||
/* ── scroll-area-thumb ─────────────────────────────────────────── */
|
||||
:where([data-slot="scroll-area-thumb"]) { @apply bg-border relative flex-1 rounded-full; }
|
||||
|
||||
/* ── scroll-area-viewport ─────────────────────────────────────────── */
|
||||
:where([data-slot="scroll-area-viewport"]) { @apply focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1; }
|
||||
|
||||
/* ── section ─────────────────────────────────────────── */
|
||||
:where([data-slot="section"]) { @apply py-10; }
|
||||
:where([data-slot="section"]):where([data-variant="highlighted"]) { @apply bg-card my-8; }
|
||||
:where([data-slot="section"]):where([data-variant="accent"]) { @apply bg-primary/5 my-8; }
|
||||
:where([data-slot="section"]):where([data-width="narrow"]) { @apply max-w-3xl mx-auto; }
|
||||
:where([data-slot="section"]):where(:not([data-width])) { @apply max-w-5xl mx-auto; }
|
||||
:where([data-slot="section"]):where([data-width="default"]) { @apply max-w-5xl mx-auto; }
|
||||
:where([data-slot="section"]):where([data-width="wide"]) { @apply max-w-7xl mx-auto; }
|
||||
:where([data-slot="section"]):where([data-width="full"]) { @apply w-full; }
|
||||
|
||||
/* ── select-content ─────────────────────────────────────────── */
|
||||
:where([data-slot="select-content"]) { @apply bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md; }
|
||||
|
||||
/* ── select-item ─────────────────────────────────────────── */
|
||||
:where([data-slot="select-item"]) { @apply focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2; }
|
||||
|
||||
/* ── select-label ─────────────────────────────────────────── */
|
||||
:where([data-slot="select-label"]) { @apply text-muted-foreground px-2 py-1.5 text-xs; }
|
||||
|
||||
/* ── select-scroll-down-button ─────────────────────────────────────────── */
|
||||
:where([data-slot="select-scroll-down-button"]) { @apply flex cursor-default items-center justify-center py-1; }
|
||||
|
||||
/* ── select-scroll-up-button ─────────────────────────────────────────── */
|
||||
:where([data-slot="select-scroll-up-button"]) { @apply flex cursor-default items-center justify-center py-1; }
|
||||
|
||||
/* ── select-separator ─────────────────────────────────────────── */
|
||||
:where([data-slot="select-separator"]) { @apply bg-border pointer-events-none -mx-1 my-1 h-px; }
|
||||
|
||||
/* ── select-trigger ─────────────────────────────────────────── */
|
||||
:where([data-slot="select-trigger"]) { @apply border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
|
||||
|
||||
/* ── separator ─────────────────────────────────────────── */
|
||||
:where([data-slot="separator"]) { @apply bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px; }
|
||||
|
||||
/* ── sheet-content ─────────────────────────────────────────── */
|
||||
:where([data-slot="sheet-content"]) { @apply bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500; }
|
||||
|
||||
/* ── sheet-description ─────────────────────────────────────────── */
|
||||
:where([data-slot="sheet-description"]) { @apply text-muted-foreground text-sm; }
|
||||
|
||||
/* ── sheet-footer ─────────────────────────────────────────── */
|
||||
:where([data-slot="sheet-footer"]) { @apply mt-auto flex flex-col gap-2 p-4; }
|
||||
|
||||
/* ── sheet-header ─────────────────────────────────────────── */
|
||||
:where([data-slot="sheet-header"]) { @apply flex flex-col gap-1.5 p-4; }
|
||||
|
||||
/* ── sheet-overlay ─────────────────────────────────────────── */
|
||||
:where([data-slot="sheet-overlay"]) { @apply data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50; }
|
||||
|
||||
/* ── sheet-title ─────────────────────────────────────────── */
|
||||
:where([data-slot="sheet-title"]) { @apply text-foreground font-semibold; }
|
||||
|
||||
/* ── sidebar ─────────────────────────────────────────── */
|
||||
:where([data-slot="sidebar"]) { @apply bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden text-sidebar-foreground hidden md:block; }
|
||||
|
||||
/* ── sidebar-container ─────────────────────────────────────────── */
|
||||
:where([data-slot="sidebar-container"]) { @apply fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex; }
|
||||
|
||||
/* ── sidebar-content ─────────────────────────────────────────── */
|
||||
:where([data-slot="sidebar-content"]) { @apply flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden; }
|
||||
|
||||
/* ── sidebar-footer ─────────────────────────────────────────── */
|
||||
:where([data-slot="sidebar-footer"]) { @apply flex flex-col gap-2 p-2; }
|
||||
|
||||
/* ── sidebar-gap ─────────────────────────────────────────── */
|
||||
:where([data-slot="sidebar-gap"]) { @apply relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear; }
|
||||
|
||||
/* ── sidebar-group ─────────────────────────────────────────── */
|
||||
:where([data-slot="sidebar-group"]) { @apply relative flex w-full min-w-0 flex-col p-2; }
|
||||
|
||||
/* ── sidebar-group-action ─────────────────────────────────────────── */
|
||||
:where([data-slot="sidebar-group-action"]) { @apply text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0; }
|
||||
|
||||
/* ── sidebar-group-content ─────────────────────────────────────────── */
|
||||
:where([data-slot="sidebar-group-content"]) { @apply w-full text-sm; }
|
||||
|
||||
/* ── sidebar-group-label ─────────────────────────────────────────── */
|
||||
:where([data-slot="sidebar-group-label"]) { @apply text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0; }
|
||||
|
||||
/* ── sidebar-header ─────────────────────────────────────────── */
|
||||
:where([data-slot="sidebar-header"]) { @apply flex flex-col gap-2 p-2; }
|
||||
|
||||
/* ── sidebar-inner ─────────────────────────────────────────── */
|
||||
:where([data-slot="sidebar-inner"]) { @apply bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm; }
|
||||
|
||||
/* ── sidebar-input ─────────────────────────────────────────── */
|
||||
:where([data-slot="sidebar-input"]) { @apply bg-background h-8 w-full shadow-none; }
|
||||
|
||||
/* ── sidebar-inset ─────────────────────────────────────────── */
|
||||
:where([data-slot="sidebar-inset"]) { @apply bg-background relative flex w-full flex-1 flex-col; }
|
||||
|
||||
/* ── sidebar-menu ─────────────────────────────────────────── */
|
||||
:where([data-slot="sidebar-menu"]) { @apply flex w-full min-w-0 flex-col gap-1; }
|
||||
|
||||
/* ── sidebar-menu-action ─────────────────────────────────────────── */
|
||||
:where([data-slot="sidebar-menu-action"]) { @apply text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0; }
|
||||
|
||||
/* ── sidebar-menu-badge ─────────────────────────────────────────── */
|
||||
:where([data-slot="sidebar-menu-badge"]) { @apply text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none; }
|
||||
|
||||
/* ── sidebar-menu-button ─────────────────────────────────────────── */
|
||||
:where([data-slot="sidebar-menu-button"]) { @apply flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0; }
|
||||
:where([data-slot="sidebar-menu-button"]):where(:not([data-variant])) { @apply hover:bg-sidebar-accent hover:text-sidebar-accent-foreground; }
|
||||
:where([data-slot="sidebar-menu-button"]):where([data-variant="default"]) { @apply hover:bg-sidebar-accent hover:text-sidebar-accent-foreground; }
|
||||
:where([data-slot="sidebar-menu-button"]):where([data-variant="outline"]) { @apply bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]; }
|
||||
:where([data-slot="sidebar-menu-button"]):where(:not([data-size])) { @apply h-8 text-sm; }
|
||||
:where([data-slot="sidebar-menu-button"]):where([data-size="default"]) { @apply h-8 text-sm; }
|
||||
:where([data-slot="sidebar-menu-button"]):where([data-size="sm"]) { @apply h-7 text-xs; }
|
||||
:where([data-slot="sidebar-menu-button"]):where([data-size="lg"]) { @apply h-12 text-sm group-data-[collapsible=icon]:p-0!; }
|
||||
|
||||
/* ── sidebar-menu-item ─────────────────────────────────────────── */
|
||||
:where([data-slot="sidebar-menu-item"]) { @apply relative; }
|
||||
|
||||
/* ── sidebar-menu-skeleton ─────────────────────────────────────────── */
|
||||
:where([data-slot="sidebar-menu-skeleton"]) { @apply flex h-8 items-center gap-2 rounded-md px-2; }
|
||||
|
||||
/* ── sidebar-menu-sub ─────────────────────────────────────────── */
|
||||
:where([data-slot="sidebar-menu-sub"]) { @apply border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5; }
|
||||
|
||||
/* ── sidebar-menu-sub-button ─────────────────────────────────────────── */
|
||||
:where([data-slot="sidebar-menu-sub-button"]) { @apply text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0; }
|
||||
|
||||
/* ── sidebar-menu-sub-item ─────────────────────────────────────────── */
|
||||
:where([data-slot="sidebar-menu-sub-item"]) { @apply relative; }
|
||||
|
||||
/* ── sidebar-rail ─────────────────────────────────────────── */
|
||||
:where([data-slot="sidebar-rail"]) { @apply hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex; }
|
||||
|
||||
/* ── sidebar-separator ─────────────────────────────────────────── */
|
||||
:where([data-slot="sidebar-separator"]) { @apply bg-sidebar-border mx-2 w-auto; }
|
||||
|
||||
/* ── sidebar-trigger ─────────────────────────────────────────── */
|
||||
:where([data-slot="sidebar-trigger"]) { @apply size-7; }
|
||||
|
||||
/* ── sidebar-wrapper ─────────────────────────────────────────── */
|
||||
:where([data-slot="sidebar-wrapper"]) { @apply has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full; }
|
||||
|
||||
/* ── skeleton ─────────────────────────────────────────── */
|
||||
:where([data-slot="skeleton"]) { @apply bg-accent animate-pulse rounded-md; }
|
||||
|
||||
/* ── slider ─────────────────────────────────────────── */
|
||||
:where([data-slot="slider"]) { @apply relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col; }
|
||||
|
||||
/* ── slider-range ─────────────────────────────────────────── */
|
||||
:where([data-slot="slider-range"]) { @apply bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full; }
|
||||
|
||||
/* ── slider-thumb ─────────────────────────────────────────── */
|
||||
:where([data-slot="slider-thumb"]) { @apply border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50; }
|
||||
|
||||
/* ── slider-track ─────────────────────────────────────────── */
|
||||
:where([data-slot="slider-track"]) { @apply bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5; }
|
||||
|
||||
/* ── switch ─────────────────────────────────────────── */
|
||||
:where([data-slot="switch"]) { @apply data-[state=checked]:bg-primary data-[state=unchecked]:bg-[#7F7F79] focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50; }
|
||||
|
||||
/* ── switch-thumb ─────────────────────────────────────────── */
|
||||
:where([data-slot="switch-thumb"]) { @apply bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0; }
|
||||
|
||||
/* ── table ─────────────────────────────────────────── */
|
||||
:where([data-slot="table"]) { @apply w-full caption-bottom text-sm; }
|
||||
|
||||
/* ── table-body ─────────────────────────────────────────── */
|
||||
:where([data-slot="table-body"]) { @apply [&_tr:last-child]:border-0; }
|
||||
|
||||
/* ── table-caption ─────────────────────────────────────────── */
|
||||
:where([data-slot="table-caption"]) { @apply text-muted-foreground mt-4 text-sm; }
|
||||
|
||||
/* ── table-cell ─────────────────────────────────────────── */
|
||||
:where([data-slot="table-cell"]) { @apply p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]; }
|
||||
|
||||
/* ── table-container ─────────────────────────────────────────── */
|
||||
:where([data-slot="table-container"]) { @apply relative w-full overflow-x-auto; }
|
||||
|
||||
/* ── table-footer ─────────────────────────────────────────── */
|
||||
:where([data-slot="table-footer"]) { @apply bg-muted/50 border-t font-medium [&>tr]:last:border-b-0; }
|
||||
|
||||
/* ── table-head ─────────────────────────────────────────── */
|
||||
:where([data-slot="table-head"]) { @apply text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]; }
|
||||
|
||||
/* ── table-header ─────────────────────────────────────────── */
|
||||
:where([data-slot="table-header"]) { @apply [&_tr]:border-b; }
|
||||
|
||||
/* ── table-row ─────────────────────────────────────────── */
|
||||
:where([data-slot="table-row"]) { @apply hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors; }
|
||||
|
||||
/* ── tabs ─────────────────────────────────────────── */
|
||||
:where([data-slot="tabs"]) { @apply flex flex-col gap-2; }
|
||||
|
||||
/* ── tabs-content ─────────────────────────────────────────── */
|
||||
:where([data-slot="tabs-content"]) { @apply flex-1 outline-none; }
|
||||
|
||||
/* ── tabs-list ─────────────────────────────────────────── */
|
||||
:where([data-slot="tabs-list"]) { @apply bg-[rgb(var(--border))] text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]; }
|
||||
|
||||
/* ── tabs-trigger ─────────────────────────────────────────── */
|
||||
:where([data-slot="tabs-trigger"]) { @apply data-[state=active]:bg-card dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; }
|
||||
|
||||
/* ── textarea ─────────────────────────────────────────── */
|
||||
:where([data-slot="textarea"]) { @apply border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm; }
|
||||
|
||||
/* ── toast ─────────────────────────────────────────── */
|
||||
:where([data-slot="toast"]) { @apply pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full; }
|
||||
:where([data-slot="toast"]):where(:not([data-variant])) { @apply border bg-background text-foreground; }
|
||||
:where([data-slot="toast"]):where([data-variant="default"]) { @apply border bg-background text-foreground; }
|
||||
:where([data-slot="toast"]):where([data-variant="destructive"]) { @apply border-destructive bg-destructive text-destructive-foreground; }
|
||||
|
||||
/* ── toggle ─────────────────────────────────────────── */
|
||||
:where([data-slot="toggle"]) { @apply inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap; }
|
||||
:where([data-slot="toggle"]):where(:not([data-variant])) { @apply bg-transparent; }
|
||||
:where([data-slot="toggle"]):where([data-variant="default"]) { @apply bg-transparent; }
|
||||
:where([data-slot="toggle"]):where([data-variant="outline"]) { @apply border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground; }
|
||||
:where([data-slot="toggle"]):where(:not([data-size])) { @apply h-9 px-4 min-w-9 has-[>svg]:px-3; }
|
||||
:where([data-slot="toggle"]):where([data-size="default"]) { @apply h-9 px-4 min-w-9 has-[>svg]:px-3; }
|
||||
:where([data-slot="toggle"]):where([data-size="sm"]) { @apply h-8 px-3 min-w-8 has-[>svg]:px-2.5; }
|
||||
:where([data-slot="toggle"]):where([data-size="lg"]) { @apply h-10 px-6 min-w-10 has-[>svg]:px-4; }
|
||||
|
||||
/* ── toggle-group ─────────────────────────────────────────── */
|
||||
:where([data-slot="toggle-group"]) { @apply flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs; }
|
||||
|
||||
/* ── toggle-group-item ─────────────────────────────────────────── */
|
||||
:where([data-slot="toggle-group-item"]) { @apply inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l; }
|
||||
:where([data-slot="toggle-group-item"]):where(:not([data-variant])) { @apply bg-transparent; }
|
||||
:where([data-slot="toggle-group-item"]):where([data-variant="default"]) { @apply bg-transparent; }
|
||||
:where([data-slot="toggle-group-item"]):where([data-variant="outline"]) { @apply border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground; }
|
||||
:where([data-slot="toggle-group-item"]):where(:not([data-size])) { @apply h-9 px-4 min-w-9 has-[>svg]:px-3; }
|
||||
:where([data-slot="toggle-group-item"]):where([data-size="default"]) { @apply h-9 px-4 min-w-9 has-[>svg]:px-3; }
|
||||
:where([data-slot="toggle-group-item"]):where([data-size="sm"]) { @apply h-8 px-3 min-w-8 has-[>svg]:px-2.5; }
|
||||
:where([data-slot="toggle-group-item"]):where([data-size="lg"]) { @apply h-10 px-6 min-w-10 has-[>svg]:px-4; }
|
||||
|
||||
/* ── tooltip-content ─────────────────────────────────────────── */
|
||||
:where([data-slot="tooltip-content"]) { @apply bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance; }
|
||||
|
||||
}
|
||||
|
||||
/* Extraction warnings:
|
||||
* [components/ui/field.tsx] cva: fieldVariants.orientation.vertical: non-literal classes, skipping value
|
||||
* [components/ui/field.tsx] cva: fieldVariants.orientation.horizontal: non-literal classes, skipping value
|
||||
* [components/ui/field.tsx] cva: fieldVariants.orientation.responsive: non-literal classes, skipping value
|
||||
* [components/ui/navigation-menu.tsx] cva: navigationMenuTriggerStyle: no config object, skipping
|
||||
*/
|
||||
35
htmx-demo/README.md
Normal file
35
htmx-demo/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# HTMX Showcase — Validation Harness
|
||||
|
||||
Parallel to `app/page.tsx` (the React showcase), `public/htmx.html` is a plain HTML page that exercises the generated `dist/greyhaven.htmx.css` across every static component. Load it while running `pnpm dev` at `/htmx.html`.
|
||||
|
||||
## Purpose
|
||||
|
||||
Validate that `greyhaven.htmx.css` produces visually-equivalent output to the React components. The HTMX page only uses:
|
||||
- `data-slot` / `data-variant` / `data-size` attributes
|
||||
- Standard HTML tags (`<button>`, `<span>`, `<input>`, `<div>`)
|
||||
- Inline SVGs for icons (no lucide-react)
|
||||
|
||||
No React, no JavaScript (apart from the theme toggle).
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
pnpm htmx-css:build # Regenerate dist/greyhaven.htmx.css from components/ui/*.tsx
|
||||
pnpm htmx-demo:build # Compile htmx-demo/input.css + tokens + htmx.css → public/htmx.css
|
||||
pnpm dev # Serves /htmx.html at http://localhost:3000/htmx.html
|
||||
```
|
||||
|
||||
## What's covered
|
||||
|
||||
- Typography (H1/H2/H3 + body + UI label)
|
||||
- Button — variants (6), sizes (3), states (5), icon sizes (3)
|
||||
- Badge — core (4), tag/value (2), semantic (4), channel pills (5), on-muted-surface (6)
|
||||
- Input + Textarea (default / with value / disabled) + Label
|
||||
- Card (simple + with header/action/content/footer)
|
||||
- Alert (default + destructive)
|
||||
- Separator, Progress, Skeleton, Kbd
|
||||
|
||||
## What's intentionally out of scope
|
||||
|
||||
- Interactive components (Dialog, Dropdown, Popover, Select, Combobox, Accordion, Tabs, Tooltip) — their CSS rules exist in `greyhaven.htmx.css` but require Alpine.js or HTMX swap patterns for open/close state. Validate those in a separate runtime-integration test.
|
||||
- Form Control primitives with JS state (Checkbox, Switch, RadioGroup, Slider) — Radix renders these with bespoke markup the CSS targets via `data-state=checked`. Native `<input type="checkbox">` won't match without additional bridging.
|
||||
69
htmx-demo/compare-all.sh
Executable file
69
htmx-demo/compare-all.sh
Executable file
@@ -0,0 +1,69 @@
|
||||
#!/bin/bash
|
||||
# Batch section-by-section comparison: React vs HTMX.
|
||||
# Each entry: <label> <react-selector> <htmx-selector>
|
||||
#
|
||||
# Assumes `pnpm dev` is running and Charlotte MCP is unavailable from shell —
|
||||
# so this script expects screenshots already captured via Charlotte by name.
|
||||
# Run compare.py on each pair and emit a summary.
|
||||
|
||||
set -u
|
||||
OUT="${OUT:-/home/tito/code/monadical/greyproxy/docs/screenshots}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CMP="$SCRIPT_DIR/compare.py"
|
||||
|
||||
SECTIONS=(
|
||||
"colors"
|
||||
"typo"
|
||||
"btn-variants"
|
||||
"btn-sizes"
|
||||
"btn-states"
|
||||
"icon-buttons"
|
||||
"btn-with-icons"
|
||||
"badges-core"
|
||||
"badges-tag"
|
||||
"badges-semantic"
|
||||
"badges-channel"
|
||||
"badges-muted"
|
||||
"inputs"
|
||||
"select"
|
||||
"checkboxes-switches"
|
||||
"tabs"
|
||||
"toggle-group"
|
||||
"tooltips"
|
||||
"sample-form"
|
||||
"settings-card"
|
||||
"header"
|
||||
"footer"
|
||||
)
|
||||
|
||||
printf "%-25s %-12s %-12s %s\n" "section" "similarity" "differing" "notes"
|
||||
printf "%-25s %-12s %-12s %s\n" "-------" "----------" "---------" "-----"
|
||||
|
||||
fail=0
|
||||
for s in "${SECTIONS[@]}"; do
|
||||
r="$OUT/$s-react.webp"
|
||||
h="$OUT/$s-htmx.webp"
|
||||
d="$OUT/$s-diff.webp"
|
||||
if [ ! -f "$r" ] || [ ! -f "$h" ]; then
|
||||
printf "%-25s %-12s %-12s %s\n" "$s" "-" "-" "missing ($([ ! -f "$r" ] && echo react) $([ ! -f "$h" ] && echo htmx))"
|
||||
continue
|
||||
fi
|
||||
line=$(python3 "$CMP" "$r" "$h" --out "$d" 2>&1 | tail -1)
|
||||
# " similarity = 99.97% (393 / 1436512 pixels differ > 12)"
|
||||
sim=$(echo "$line" | sed -nE 's/.*similarity = ([0-9.]+)%.*/\1/p')
|
||||
diff=$(echo "$line" | sed -nE 's/.*\(([0-9]+) \/ .*/\1/p')
|
||||
# Threshold: 99.0%. Residual diffs under this threshold are driven by:
|
||||
# - font sub-pixel anti-aliasing (~0.03%)
|
||||
# - sticky-header overlay differences in Charlotte's selector screenshot
|
||||
# when element rects happen to land at different viewport Y positions
|
||||
# between React and HTMX (still has the same CSS, just different scroll).
|
||||
if awk "BEGIN{exit !($sim>=99.0)}"; then
|
||||
marker=PASS
|
||||
else
|
||||
marker=FAIL
|
||||
fail=1
|
||||
fi
|
||||
printf "%-25s %-12s %-12s %s\n" "$s" "${sim}%" "$diff" "$marker"
|
||||
done
|
||||
|
||||
exit $fail
|
||||
100
htmx-demo/compare.py
Normal file
100
htmx-demo/compare.py
Normal file
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Image comparator for React vs HTMX showcase validation.
|
||||
|
||||
Compares two PNG/WEBP screenshots and produces:
|
||||
1. Similarity percentage (pixels within tolerance / total pixels)
|
||||
2. A diff image with mismatches highlighted magenta on a faded background
|
||||
|
||||
Tolerance is per-channel: anti-aliasing / sub-pixel hinting is accepted
|
||||
(default 12 of 255 per channel, tweakable via --tol). Font / layout / color
|
||||
changes produce large regions of divergence that will exceed the tolerance.
|
||||
|
||||
Usage:
|
||||
python3 compare.py react.webp htmx.webp [--out diff.webp] [--tol 12]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageChops, ImageDraw
|
||||
|
||||
|
||||
def load(path):
|
||||
img = Image.open(path).convert("RGB")
|
||||
return img
|
||||
|
||||
|
||||
def compare(react_path, htmx_path, out_path, tol):
|
||||
a = load(react_path)
|
||||
b = load(htmx_path)
|
||||
|
||||
if a.size != b.size:
|
||||
# Pad the smaller one with transparent/white so we can still diff
|
||||
w, h = max(a.width, b.width), max(a.height, b.height)
|
||||
pad_a = Image.new("RGB", (w, h), (255, 255, 255))
|
||||
pad_b = Image.new("RGB", (w, h), (255, 255, 255))
|
||||
pad_a.paste(a, (0, 0))
|
||||
pad_b.paste(b, (0, 0))
|
||||
a, b = pad_a, pad_b
|
||||
size_mismatch = True
|
||||
else:
|
||||
size_mismatch = False
|
||||
|
||||
diff = ImageChops.difference(a, b)
|
||||
# Per-pixel max channel diff
|
||||
total = a.width * a.height
|
||||
differing = 0
|
||||
mask = Image.new("L", a.size, 0)
|
||||
mask_pixels = mask.load()
|
||||
diff_pixels = diff.load()
|
||||
for y in range(a.height):
|
||||
for x in range(a.width):
|
||||
r, g, bl = diff_pixels[x, y]
|
||||
if max(r, g, bl) > tol:
|
||||
differing += 1
|
||||
mask_pixels[x, y] = 255
|
||||
|
||||
similarity = 100.0 * (total - differing) / total
|
||||
|
||||
# Build diff image: React screenshot faded 50%, with diffs in magenta
|
||||
faded = Image.eval(a, lambda v: int(v * 0.4 + 0.6 * 255))
|
||||
magenta = Image.new("RGB", a.size, (255, 0, 180))
|
||||
out = Image.composite(magenta, faded, mask)
|
||||
|
||||
# Add a header text
|
||||
draw = ImageDraw.Draw(out)
|
||||
header = (
|
||||
f"similarity={similarity:.2f}% "
|
||||
f"differing={differing}/{total} "
|
||||
f"tol={tol}"
|
||||
+ (" (SIZE MISMATCH — padded)" if size_mismatch else "")
|
||||
)
|
||||
draw.rectangle([0, 0, a.width, 24], fill=(0, 0, 0))
|
||||
draw.text((8, 4), header, fill=(255, 255, 255))
|
||||
|
||||
out.save(out_path)
|
||||
return similarity, differing, total
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("react")
|
||||
p.add_argument("htmx")
|
||||
p.add_argument("--out", default="diff.webp")
|
||||
p.add_argument("--tol", type=int, default=12)
|
||||
args = p.parse_args()
|
||||
|
||||
sim, diff_px, total = compare(args.react, args.htmx, args.out, args.tol)
|
||||
print(f"react = {args.react}")
|
||||
print(f"htmx = {args.htmx}")
|
||||
print(f"diff -> {args.out}")
|
||||
print(f" similarity = {sim:.2f}% ({diff_px} / {total} pixels differ > {args.tol})")
|
||||
if sim < 99.5:
|
||||
sys.exit(1)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
88
htmx-demo/input.css
Normal file
88
htmx-demo/input.css
Normal file
@@ -0,0 +1,88 @@
|
||||
/* Greyhaven HTMX Showcase — Tailwind v4 source
|
||||
*
|
||||
* Pairs the generated `dist/greyhaven.htmx.css` with the design system tokens
|
||||
* so a plain HTML page (no React) can render every component via data-slot
|
||||
* attribute selectors.
|
||||
*
|
||||
* Compiled output: public/htmx.css (served at /htmx.css)
|
||||
*/
|
||||
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "../app/tokens/tokens-light.css";
|
||||
@import "../app/tokens/tokens-dark.css";
|
||||
@import "../dist/greyhaven.htmx.css";
|
||||
|
||||
@source "./*.html";
|
||||
@source "../public/htmx.html";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* Self-hosted Aspekta (served from /fonts/) */
|
||||
@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 400; font-display: swap; src: url('/fonts/Aspekta-400.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 500; font-display: swap; src: url('/fonts/Aspekta-500.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 600; font-display: swap; src: url('/fonts/Aspekta-600.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 700; font-display: swap; src: url('/fonts/Aspekta-700.woff2') format('woff2'); }
|
||||
|
||||
:root {
|
||||
--radius: 0.375rem;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: 'Aspekta', ui-sans-serif, system-ui, sans-serif;
|
||||
/* Matches React's `var(--font-source-serif, 'Source Serif 4'), 'Source Serif Pro', Georgia, serif`.
|
||||
* Next.js injects --font-source-serif via next/font/google. We load Source Serif 4 from
|
||||
* Google Fonts directly in htmx.html <link>, so naming it here is enough. */
|
||||
--font-serif: 'Source Serif 4', 'Source Serif Pro', Georgia, serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
|
||||
--color-background: rgb(var(--background));
|
||||
--color-foreground: rgb(var(--foreground));
|
||||
--color-card: rgb(var(--card));
|
||||
--color-card-foreground: rgb(var(--card-foreground));
|
||||
--color-popover: rgb(var(--popover));
|
||||
--color-popover-foreground: rgb(var(--popover-foreground));
|
||||
--color-primary: rgb(var(--primary));
|
||||
--color-primary-foreground: rgb(var(--primary-foreground));
|
||||
--color-secondary: rgb(var(--secondary));
|
||||
--color-secondary-foreground: rgb(var(--secondary-foreground));
|
||||
--color-muted: rgb(var(--muted));
|
||||
--color-muted-foreground: rgb(var(--muted-foreground));
|
||||
--color-accent: rgb(var(--accent));
|
||||
--color-accent-foreground: rgb(var(--accent-foreground));
|
||||
--color-destructive: rgb(var(--destructive));
|
||||
--color-destructive-foreground: rgb(var(--destructive-foreground));
|
||||
--color-border: rgb(var(--border));
|
||||
--color-input: rgb(var(--input));
|
||||
--color-ring: rgb(var(--ring));
|
||||
--color-chart-1: rgb(var(--chart-1));
|
||||
--color-chart-2: rgb(var(--chart-2));
|
||||
--color-chart-3: rgb(var(--chart-3));
|
||||
--color-chart-4: rgb(var(--chart-4));
|
||||
--color-chart-5: rgb(var(--chart-5));
|
||||
--color-hero-bg: rgb(var(--hero-bg));
|
||||
--color-sidebar: rgb(var(--sidebar));
|
||||
--color-sidebar-foreground: rgb(var(--sidebar-foreground));
|
||||
--color-sidebar-primary: rgb(var(--sidebar-primary));
|
||||
--color-sidebar-primary-foreground: rgb(var(--sidebar-primary-foreground));
|
||||
--color-sidebar-accent: rgb(var(--sidebar-accent));
|
||||
--color-sidebar-accent-foreground: rgb(var(--sidebar-accent-foreground));
|
||||
--color-sidebar-border: rgb(var(--sidebar-border));
|
||||
--color-sidebar-ring: rgb(var(--sidebar-ring));
|
||||
|
||||
--radius-sm: calc(var(--radius) - 2px);
|
||||
--radius-md: var(--radius);
|
||||
--radius-lg: calc(var(--radius) + 2px);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
border-color: rgb(var(--border));
|
||||
}
|
||||
body {
|
||||
background-color: rgb(var(--background));
|
||||
color: rgb(var(--foreground));
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
}
|
||||
11135
package-lock.json
generated
Normal file
11135
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,9 @@
|
||||
"scripts": {
|
||||
"tokens:build": "npx style-dictionary build --config style-dictionary.config.mjs",
|
||||
"skill:build": "npx tsx scripts/generate-skill.ts",
|
||||
"build": "pnpm tokens:build && pnpm skill:build && next build",
|
||||
"htmx-css:build": "npx tsx scripts/generate-htmx-css.ts",
|
||||
"htmx-demo:build": "tailwindcss -i htmx-demo/input.css -o public/htmx.css --minify",
|
||||
"build": "pnpm tokens:build && pnpm skill:build && pnpm htmx-css:build && pnpm htmx-demo:build && next build",
|
||||
"dev": "next dev",
|
||||
"lint": "eslint .",
|
||||
"start": "next start",
|
||||
@@ -74,6 +76,7 @@
|
||||
"@storybook/addon-onboarding": "^10.3.5",
|
||||
"@storybook/addon-vitest": "^10.3.5",
|
||||
"@storybook/nextjs-vite": "^10.3.5",
|
||||
"@tailwindcss/cli": "^4.2.4",
|
||||
"@tailwindcss/postcss": "^4.1.9",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19",
|
||||
|
||||
2
public/htmx.css
Normal file
2
public/htmx.css
Normal file
File diff suppressed because one or more lines are too long
1094
public/htmx.html
Normal file
1094
public/htmx.html
Normal file
File diff suppressed because it is too large
Load Diff
459
scripts/generate-htmx-css.ts
Normal file
459
scripts/generate-htmx-css.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Generates dist/greyhaven.htmx.css — a framework-agnostic CSS companion
|
||||
* to the React component library. Exposes every component via `data-slot`
|
||||
* (+ `data-variant` / `data-size`) attribute selectors so HTMX / server-
|
||||
* rendered projects can use the design system without React.
|
||||
*
|
||||
* Input:
|
||||
* components/ui/*.tsx
|
||||
*
|
||||
* Output:
|
||||
* dist/greyhaven.htmx.css
|
||||
*
|
||||
* Extraction strategy:
|
||||
* 1. AST-walk each .tsx file
|
||||
* 2. For `const xVariants = cva("base", { variants, defaultVariants })`,
|
||||
* capture base + variants + defaults.
|
||||
* 3. For any JSX element with `data-slot="X"`, capture the static string
|
||||
* in its `className` (direct string, or first arg of cn(...)).
|
||||
* 4. Merge the two: a slot with both a cva binding and static cn classes
|
||||
* (rare) gets both; otherwise one or the other.
|
||||
*
|
||||
* Limitations:
|
||||
* - Dynamic / conditional classes are dropped (logged as warnings).
|
||||
* - Components relying on runtime state (data-state, Radix Portals) emit
|
||||
* only their static visual rules. Open/close / focus / positioning JS
|
||||
* is the consumer's problem.
|
||||
*/
|
||||
|
||||
import * as ts from 'typescript'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const ROOT = path.resolve(__dirname, '..')
|
||||
const UI_DIR = path.join(ROOT, 'components/ui')
|
||||
const OUT_FILE = path.join(ROOT, 'dist/greyhaven.htmx.css')
|
||||
|
||||
type CvaExtract = {
|
||||
sourceFile: string
|
||||
variableName: string
|
||||
base: string
|
||||
variants: Record<string, Record<string, string>>
|
||||
defaultVariants: Record<string, string>
|
||||
}
|
||||
|
||||
type SlotExtract = {
|
||||
sourceFile: string
|
||||
slot: string
|
||||
classes: string
|
||||
/**
|
||||
* Names of CVA functions referenced inside the slot's `cn(...)` call — e.g.
|
||||
* `cn(toggleVariants({variant,size}), 'rounded-none ...')` on
|
||||
* ToggleGroupItem records `['toggleVariants']`. Used to emit the
|
||||
* referenced CVA's base + variant rules under this slot's selector, so
|
||||
* slots that *compose* another component's variant system (instead of
|
||||
* declaring their own cva) still inherit its padding/height/states.
|
||||
*/
|
||||
viaVariants: string[]
|
||||
}
|
||||
|
||||
type Warning = { sourceFile: string; message: string }
|
||||
|
||||
const cvaExtracts: CvaExtract[] = []
|
||||
const slotExtracts: SlotExtract[] = []
|
||||
const warnings: Warning[] = []
|
||||
|
||||
// ─── String extraction helpers ──────────────────────────────────────────
|
||||
|
||||
function getStringLiteral(node: ts.Node): string | null {
|
||||
if (ts.isStringLiteral(node)) return node.text
|
||||
if (ts.isNoSubstitutionTemplateLiteral(node)) return node.text
|
||||
// Template with only literal parts: `${''}foo${''}` — rare, skip
|
||||
return null
|
||||
}
|
||||
|
||||
// Object-literal property shortcut: returns first ObjectLiteralExpression named `name`.
|
||||
function getPropertyInitializer(
|
||||
obj: ts.ObjectLiteralExpression,
|
||||
name: string,
|
||||
): ts.Expression | null {
|
||||
for (const prop of obj.properties) {
|
||||
if (ts.isPropertyAssignment(prop) && prop.name && ts.isIdentifier(prop.name) && prop.name.text === name) {
|
||||
return prop.initializer
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── CVA extractor ──────────────────────────────────────────────────────
|
||||
|
||||
function extractCva(
|
||||
declList: ts.VariableDeclaration,
|
||||
call: ts.CallExpression,
|
||||
sourceFile: string,
|
||||
): void {
|
||||
if (!ts.isIdentifier(declList.name)) return
|
||||
const variableName = declList.name.text
|
||||
|
||||
const baseArg = call.arguments[0]
|
||||
const configArg = call.arguments[1]
|
||||
const base = baseArg ? (getStringLiteral(baseArg) ?? '') : ''
|
||||
if (!base) {
|
||||
warnings.push({ sourceFile, message: `cva: ${variableName}: non-literal base, skipping` })
|
||||
return
|
||||
}
|
||||
if (!configArg || !ts.isObjectLiteralExpression(configArg)) {
|
||||
warnings.push({ sourceFile, message: `cva: ${variableName}: no config object, skipping` })
|
||||
return
|
||||
}
|
||||
|
||||
const variants: Record<string, Record<string, string>> = {}
|
||||
const variantsNode = getPropertyInitializer(configArg, 'variants')
|
||||
if (variantsNode && ts.isObjectLiteralExpression(variantsNode)) {
|
||||
for (const prop of variantsNode.properties) {
|
||||
if (!ts.isPropertyAssignment(prop)) continue
|
||||
if (!prop.name || !ts.isIdentifier(prop.name)) continue
|
||||
const axisName = prop.name.text
|
||||
if (!ts.isObjectLiteralExpression(prop.initializer)) continue
|
||||
const values: Record<string, string> = {}
|
||||
for (const subProp of prop.initializer.properties) {
|
||||
if (!ts.isPropertyAssignment(subProp)) continue
|
||||
let key: string | null = null
|
||||
if (subProp.name) {
|
||||
if (ts.isIdentifier(subProp.name)) key = subProp.name.text
|
||||
else if (ts.isStringLiteral(subProp.name)) key = subProp.name.text
|
||||
}
|
||||
if (!key) continue
|
||||
const classes = getStringLiteral(subProp.initializer)
|
||||
if (classes === null) {
|
||||
warnings.push({
|
||||
sourceFile,
|
||||
message: `cva: ${variableName}.${axisName}.${key}: non-literal classes, skipping value`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
values[key] = classes
|
||||
}
|
||||
variants[axisName] = values
|
||||
}
|
||||
}
|
||||
|
||||
const defaultVariants: Record<string, string> = {}
|
||||
const defaultsNode = getPropertyInitializer(configArg, 'defaultVariants')
|
||||
if (defaultsNode && ts.isObjectLiteralExpression(defaultsNode)) {
|
||||
for (const prop of defaultsNode.properties) {
|
||||
if (!ts.isPropertyAssignment(prop)) continue
|
||||
if (!prop.name || !ts.isIdentifier(prop.name)) continue
|
||||
const axisName = prop.name.text
|
||||
const value = getStringLiteral(prop.initializer)
|
||||
if (value !== null) defaultVariants[axisName] = value
|
||||
}
|
||||
}
|
||||
|
||||
cvaExtracts.push({ sourceFile, variableName, base, variants, defaultVariants })
|
||||
}
|
||||
|
||||
// ─── Slot extractor ─────────────────────────────────────────────────────
|
||||
|
||||
function extractSlot(
|
||||
element: ts.JsxOpeningLikeElement,
|
||||
sourceFile: string,
|
||||
): void {
|
||||
let slot: string | null = null
|
||||
let classes: string | null = null
|
||||
const viaVariants: string[] = []
|
||||
|
||||
const attrs = element.attributes.properties
|
||||
for (const attr of attrs) {
|
||||
if (!ts.isJsxAttribute(attr)) continue
|
||||
const attrName = attr.name.getText()
|
||||
if (attrName === 'data-slot') {
|
||||
if (attr.initializer && ts.isStringLiteral(attr.initializer)) {
|
||||
slot = attr.initializer.text
|
||||
}
|
||||
} else if (attrName === 'className' && attr.initializer) {
|
||||
if (ts.isStringLiteral(attr.initializer)) {
|
||||
classes = attr.initializer.text
|
||||
} else if (ts.isJsxExpression(attr.initializer) && attr.initializer.expression) {
|
||||
const expr = attr.initializer.expression
|
||||
if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) {
|
||||
classes = (expr as ts.StringLiteral).text
|
||||
} else if (ts.isCallExpression(expr)) {
|
||||
const callName = expr.expression.getText()
|
||||
if (callName === 'cn' || callName.endsWith('Variants')) {
|
||||
// First string-literal arg is the static class baseline.
|
||||
const first = expr.arguments.find((a) => getStringLiteral(a) !== null)
|
||||
if (first) classes = getStringLiteral(first)
|
||||
// Any *call* arg whose callee is a `xVariants` identifier means this
|
||||
// slot inherits from another component's variant system (e.g.
|
||||
// ToggleGroupItem: `cn(toggleVariants({variant,size}), 'rounded-none...')`).
|
||||
// Record the names so the emitter can pull in those CVAs' base + variants.
|
||||
for (const arg of expr.arguments) {
|
||||
if (!ts.isCallExpression(arg)) continue
|
||||
const argCallee = arg.expression
|
||||
if (ts.isIdentifier(argCallee) && argCallee.text.endsWith('Variants')) {
|
||||
viaVariants.push(argCallee.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (slot) {
|
||||
slotExtracts.push({ sourceFile, slot, classes: classes ?? '', viaVariants })
|
||||
}
|
||||
}
|
||||
|
||||
// ─── AST walker ─────────────────────────────────────────────────────────
|
||||
|
||||
function walkFile(filePath: string): void {
|
||||
const relPath = path.relative(ROOT, filePath)
|
||||
const source = fs.readFileSync(filePath, 'utf-8')
|
||||
const sf = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||
|
||||
function visit(node: ts.Node): void {
|
||||
// CVA binding: `const xVariants = cva(...)`
|
||||
if (ts.isVariableDeclaration(node) && node.initializer && ts.isCallExpression(node.initializer)) {
|
||||
const callee = node.initializer.expression
|
||||
if (ts.isIdentifier(callee) && callee.text === 'cva') {
|
||||
extractCva(node, node.initializer, relPath)
|
||||
}
|
||||
}
|
||||
// JSX element with data-slot
|
||||
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
|
||||
extractSlot(node, relPath)
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sf)
|
||||
}
|
||||
|
||||
// ─── CSS emitter ────────────────────────────────────────────────────────
|
||||
|
||||
// Classes that can't be used with @apply in Tailwind v4.
|
||||
// peer / group — sibling/parent markers, pure class hooks with no CSS
|
||||
// peer/<name> / group/<name> — named variants of the above
|
||||
// contents — reserved Tailwind escape
|
||||
// not-prose — ships with @tailwindcss/typography (optional plugin)
|
||||
// Consumers who need these can add them directly on the HTML element.
|
||||
const NON_APPLIABLE_EXACT = new Set([
|
||||
'peer', // sibling marker
|
||||
'group', // parent marker
|
||||
'contents', // reserved
|
||||
'not-prose', // @tailwindcss/typography
|
||||
'origin-top-center', // not a stock Tailwind v4 utility (upstream bug)
|
||||
'destructive', // toast's own marker class (used with group-[.destructive]: selectors)
|
||||
])
|
||||
|
||||
function isNonAppliable(cls: string): boolean {
|
||||
if (NON_APPLIABLE_EXACT.has(cls)) return true
|
||||
// Named peer/group markers: e.g., `group/drawer-content`, `peer/email`
|
||||
if (/^(peer|group)\/[A-Za-z0-9_-]+$/.test(cls)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function uniqueClasses(s: string): string {
|
||||
// Collapse whitespace; preserve order and arbitrary variants.
|
||||
// Strip non-appliable marker classes (peer/group); consumers add them directly
|
||||
// on the HTML element when they need sibling/parent state styling.
|
||||
let tokens = s
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.split(' ')
|
||||
.filter((c) => !isNonAppliable(c))
|
||||
|
||||
// Always strip `leading-*` utilities from @apply. Tailwind v4's `text-*`
|
||||
// size utilities use `--tw-leading` as an override mechanism, but once
|
||||
// `leading-*` is applied via @apply it sets `--tw-leading` on the element,
|
||||
// and subsequent user-passed `text-sm`/`text-xl` classes still resolve
|
||||
// line-height through that inherited variable — defeating the override.
|
||||
// React + tailwind-merge removes `leading-*` at className-merge time when
|
||||
// a text-size utility is passed; replicate that behavior by stripping it
|
||||
// unconditionally so user `class="text-xl"` overrides produce the same
|
||||
// line-height React ends up with.
|
||||
const LEADING = /^leading-/
|
||||
tokens = tokens.filter((t) => !LEADING.test(t))
|
||||
|
||||
return tokens.join(' ')
|
||||
}
|
||||
|
||||
function emitCss(): string {
|
||||
const lines: string[] = []
|
||||
const header = `/*! Greyhaven Design System — HTMX / Framework-Agnostic CSS Layer
|
||||
* Auto-generated from components/ui/*.tsx by scripts/generate-htmx-css.ts — DO NOT EDIT
|
||||
*
|
||||
* Usage:
|
||||
* <link href="greyhaven.htmx.css" rel="stylesheet">
|
||||
*
|
||||
* Requires:
|
||||
* - Tokens: import tokens-light.css + tokens-dark.css before this file
|
||||
* - Tailwind v4: this file uses @apply against Tailwind utility classes.
|
||||
* It must be processed by Tailwind v4 (e.g., via \`tailwindcss -i input.css\`).
|
||||
* Your consumer Tailwind input should \`@import "./greyhaven.htmx.css";\`.
|
||||
*
|
||||
* Consume via data-slot / data-variant / data-size attributes:
|
||||
* <div data-slot="card">
|
||||
* <div data-slot="card-header"><div data-slot="card-title">Hello</div></div>
|
||||
* <div data-slot="card-content">…</div>
|
||||
* </div>
|
||||
* <button data-slot="button" data-variant="outline" data-size="sm">Click</button>
|
||||
* <span data-slot="badge" data-variant="success">Active</span>
|
||||
*/
|
||||
|
||||
`
|
||||
lines.push(header)
|
||||
// Emit in @layer utilities so individual Tailwind utility classes on child
|
||||
// elements (e.g. <svg class="h-3.5">) don't override our compound selectors
|
||||
// by layer precedence alone. Within the same layer, specificity decides.
|
||||
lines.push('@layer utilities {\n')
|
||||
|
||||
// Index slot → classes (dedupe: multiple JSX elements may declare the same slot).
|
||||
const slotMap = new Map<string, Set<string>>()
|
||||
for (const s of slotExtracts) {
|
||||
if (!s.classes) continue
|
||||
if (!slotMap.has(s.slot)) slotMap.set(s.slot, new Set())
|
||||
slotMap.get(s.slot)!.add(uniqueClasses(s.classes))
|
||||
}
|
||||
|
||||
// Index cva by slot-name heuristic: `xVariants` → component slot "x" when JSX
|
||||
// in the same file uses `data-slot="x"`. If ambiguous, fall back to stripping
|
||||
// "Variants" and kebab-casing.
|
||||
const cvaBySlot = new Map<string, CvaExtract>()
|
||||
const cvaByName = new Map<string, CvaExtract>()
|
||||
for (const cva of cvaExtracts) {
|
||||
cvaByName.set(cva.variableName, cva)
|
||||
const stripped = cva.variableName.replace(/Variants$/, '')
|
||||
const slot = stripped
|
||||
.replace(/([a-z])([A-Z])/g, '$1-$2')
|
||||
.toLowerCase()
|
||||
cvaBySlot.set(slot, cva)
|
||||
}
|
||||
|
||||
// Aggregate viaVariants per slot (a slot may appear in multiple JSX sites).
|
||||
const slotVia = new Map<string, Set<string>>()
|
||||
for (const s of slotExtracts) {
|
||||
if (s.viaVariants.length === 0) continue
|
||||
if (!slotVia.has(s.slot)) slotVia.set(s.slot, new Set())
|
||||
for (const v of s.viaVariants) slotVia.get(s.slot)!.add(v)
|
||||
}
|
||||
|
||||
// Emit slots in sorted order for stable output.
|
||||
const allSlots = Array.from(
|
||||
new Set([...slotMap.keys(), ...cvaBySlot.keys()]),
|
||||
).sort()
|
||||
|
||||
for (const slot of allSlots) {
|
||||
const selfCva = cvaBySlot.get(slot)
|
||||
// Gather CVAs to apply under this slot's selector: its own (if any), plus
|
||||
// any CVAs referenced via `cn(xVariants(...), ...)` in the JSX (e.g.
|
||||
// ToggleGroupItem inherits from toggleVariants). Dedup.
|
||||
const cvas: CvaExtract[] = []
|
||||
if (selfCva) cvas.push(selfCva)
|
||||
for (const name of slotVia.get(slot) ?? []) {
|
||||
const aliasCva = cvaByName.get(name)
|
||||
if (aliasCva && !cvas.includes(aliasCva)) cvas.push(aliasCva)
|
||||
}
|
||||
const staticSets = slotMap.get(slot)
|
||||
const staticClasses = staticSets ? Array.from(staticSets).join(' ') : ''
|
||||
|
||||
lines.push(` /* ── ${slot} ─────────────────────────────────────────── */`)
|
||||
|
||||
// Emit selectors wrapped in :where() so the attribute selectors contribute
|
||||
// zero specificity. This matches how React + tailwind-merge behave: user
|
||||
// overrides passed as `className` (e.g., `class="bg-primary/90"`) must
|
||||
// win over the variant defaults. If we used bare [data-slot][data-variant]
|
||||
// selectors, their specificity (0,2,0) would beat a plain utility class
|
||||
// (0,1,0) and silently drop user overrides.
|
||||
const SLOT = `:where([data-slot="${slot}"])`
|
||||
|
||||
// Base rule: every contributing CVA's base + any static classes from JSX.
|
||||
const basePieces: string[] = []
|
||||
for (const c of cvas) if (c.base) basePieces.push(c.base)
|
||||
if (staticClasses) basePieces.push(staticClasses)
|
||||
const base = uniqueClasses(basePieces.join(' '))
|
||||
if (base) {
|
||||
lines.push(` ${SLOT} { @apply ${base}; }`)
|
||||
}
|
||||
|
||||
// Emit variant rules for each contributing CVA, under this slot's selector.
|
||||
// When a slot inherits (e.g. toggle-group-item via toggleVariants), its
|
||||
// data-variant/data-size attributes on the DOM drive the inherited CVA's
|
||||
// rules just like they drive the self-CVA's.
|
||||
for (const c of cvas) {
|
||||
for (const [axis, values] of Object.entries(c.variants)) {
|
||||
const defaultValue = c.defaultVariants[axis]
|
||||
const axisAttr = `data-${axis}`
|
||||
for (const [key, classes] of Object.entries(values)) {
|
||||
if (!classes.trim()) continue
|
||||
if (key === defaultValue) {
|
||||
// Default: apply when attr absent OR explicitly set to this key.
|
||||
// Emit as TWO separate rules (no comma-joined selector list), because
|
||||
// Tailwind v4 miscompiles arbitrary variants like `has-[>svg]:px-3`
|
||||
// when @applied inside a rule with a comma-separated selector list
|
||||
// (it emits a stray `)` and the resulting selector is invalid).
|
||||
// Wrap :not() in :where() so it contributes zero specificity;
|
||||
// otherwise plain utility classes like .bg-primary/90 would tie on
|
||||
// specificity (both at 0,1,0) and lose by source order, breaking
|
||||
// user className overrides.
|
||||
lines.push(
|
||||
` ${SLOT}:where(:not([${axisAttr}])) { @apply ${uniqueClasses(classes)}; }`,
|
||||
)
|
||||
lines.push(
|
||||
` ${SLOT}:where([${axisAttr}="${key}"]) { @apply ${uniqueClasses(classes)}; }`,
|
||||
)
|
||||
} else {
|
||||
lines.push(
|
||||
` ${SLOT}:where([${axisAttr}="${key}"]) { @apply ${uniqueClasses(classes)}; }`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
lines.push('}\n')
|
||||
|
||||
if (warnings.length > 0) {
|
||||
lines.push('/* Extraction warnings:')
|
||||
for (const w of warnings) {
|
||||
lines.push(` * [${w.sourceFile}] ${w.message}`)
|
||||
}
|
||||
lines.push(' */\n')
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// ─── Entry ──────────────────────────────────────────────────────────────
|
||||
|
||||
function main(): void {
|
||||
const files = fs
|
||||
.readdirSync(UI_DIR)
|
||||
.filter((f) => f.endsWith('.tsx'))
|
||||
.map((f) => path.join(UI_DIR, f))
|
||||
|
||||
for (const file of files) {
|
||||
walkFile(file)
|
||||
}
|
||||
|
||||
const css = emitCss()
|
||||
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true })
|
||||
fs.writeFileSync(OUT_FILE, css, 'utf-8')
|
||||
|
||||
console.log(`Parsed ${files.length} component files`)
|
||||
console.log(` ${cvaExtracts.length} cva bindings`)
|
||||
console.log(` ${slotExtracts.length} data-slot occurrences`)
|
||||
console.log(` ${warnings.length} warnings`)
|
||||
console.log(`Wrote ${path.relative(ROOT, OUT_FILE)} (${(fs.statSync(OUT_FILE).size / 1024).toFixed(1)} KB)`)
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -182,6 +182,50 @@ function buildCompositionRules(): string {
|
||||
`
|
||||
}
|
||||
|
||||
function buildHtmxLayer(): string {
|
||||
return `## HTMX / Server-Rendered Usage
|
||||
|
||||
For projects that cannot use React (HTMX, Django templates, Rails ERB, Go \`html/template\`, Astro SSR, etc.), the design system ships a framework-agnostic CSS layer: \`dist/greyhaven.htmx.css\`.
|
||||
|
||||
### What it is
|
||||
|
||||
An auto-generated stylesheet derived from \`components/ui/*.tsx\`. Every \`data-slot\` attribute gets a \`@layer components\` rule. \`cva\` variants become attribute selectors (\`[data-variant=...]\`, \`[data-size=...]\`). Default variants apply via \`:not([data-variant])\` so consumers can omit the attribute.
|
||||
|
||||
### Usage
|
||||
|
||||
1. Install: \`./skill/install.sh /path/to/project --htmx-css\`
|
||||
2. Import in your Tailwind v4 input CSS: \`@import "./greyhaven.htmx.css";\`
|
||||
3. Emit HTML with \`data-slot\` / \`data-variant\` / \`data-size\` attributes:
|
||||
|
||||
\`\`\`html
|
||||
<div data-slot="card">
|
||||
<div data-slot="card-header">
|
||||
<div data-slot="card-title">Title</div>
|
||||
<div data-slot="card-description">Description</div>
|
||||
</div>
|
||||
<div data-slot="card-content">Body</div>
|
||||
</div>
|
||||
|
||||
<button data-slot="button" data-variant="default">Save</button>
|
||||
<button data-slot="button" data-variant="outline" data-size="sm">Cancel</button>
|
||||
|
||||
<span data-slot="badge" data-variant="success">Active</span>
|
||||
\`\`\`
|
||||
|
||||
### Scope
|
||||
|
||||
- **Fully static** (pure CSS, no JS): Card, Button, Badge, Input, Label, Textarea, Table, Separator, Code, Kbd, Progress, Avatar, Skeleton, Alert, Pagination, Breadcrumb, Navbar (solid variant), Spinner, AspectRatio, Empty, Hero, Section, Footer, CtaSection, ButtonGroup, InputGroup, Toast.
|
||||
- **Visual-only** (CSS is correct but needs your own state JS): Dialog, Dropdown, Popover, Select, Combobox, Accordion, Tabs, Tooltip, Drawer, Sheet, Sidebar, Collapsible, NavigationMenu, Menubar, ContextMenu, HoverCard, Command, AlertDialog, InputOtp, Carousel. Pair with Alpine.js (\`x-data\`, \`x-show\`, \`@click\`) or native HTML primitives (\`<dialog>\`, \`<details>\`).
|
||||
|
||||
### Regenerate
|
||||
|
||||
\`\`\`bash
|
||||
pnpm htmx-css:build
|
||||
\`\`\`
|
||||
|
||||
`
|
||||
}
|
||||
|
||||
function buildExtensionProtocol(): string {
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -659,6 +659,49 @@ pnpm dev`}</Code>
|
||||
- **Slot naming**: All components use `data-slot="component-name"`
|
||||
- **Icon sizing**: `[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0`
|
||||
|
||||
---
|
||||
|
||||
## HTMX / Server-Rendered Usage
|
||||
|
||||
For projects that cannot use React (HTMX, Django templates, Rails ERB, Go `html/template`, Astro SSR, etc.), the design system ships a framework-agnostic CSS layer: `dist/greyhaven.htmx.css`.
|
||||
|
||||
### What it is
|
||||
|
||||
An auto-generated stylesheet derived from `components/ui/*.tsx`. Every `data-slot` attribute gets a `@layer components` rule. `cva` variants become attribute selectors (`[data-variant=...]`, `[data-size=...]`). Default variants apply via `:not([data-variant])` so consumers can omit the attribute.
|
||||
|
||||
### Usage
|
||||
|
||||
1. Install: `./skill/install.sh /path/to/project --htmx-css`
|
||||
2. Import in your Tailwind v4 input CSS: `@import "./greyhaven.htmx.css";`
|
||||
3. Emit HTML with `data-slot` / `data-variant` / `data-size` attributes:
|
||||
|
||||
```html
|
||||
<div data-slot="card">
|
||||
<div data-slot="card-header">
|
||||
<div data-slot="card-title">Title</div>
|
||||
<div data-slot="card-description">Description</div>
|
||||
</div>
|
||||
<div data-slot="card-content">Body</div>
|
||||
</div>
|
||||
|
||||
<button data-slot="button" data-variant="default">Save</button>
|
||||
<button data-slot="button" data-variant="outline" data-size="sm">Cancel</button>
|
||||
|
||||
<span data-slot="badge" data-variant="success">Active</span>
|
||||
```
|
||||
|
||||
### Scope
|
||||
|
||||
- **Fully static** (pure CSS, no JS): Card, Button, Badge, Input, Label, Textarea, Table, Separator, Code, Kbd, Progress, Avatar, Skeleton, Alert, Pagination, Breadcrumb, Navbar (solid variant), Spinner, AspectRatio, Empty, Hero, Section, Footer, CtaSection, ButtonGroup, InputGroup, Toast.
|
||||
- **Visual-only** (CSS is correct but needs your own state JS): Dialog, Dropdown, Popover, Select, Combobox, Accordion, Tabs, Tooltip, Drawer, Sheet, Sidebar, Collapsible, NavigationMenu, Menubar, ContextMenu, HoverCard, Command, AlertDialog, InputOtp, Carousel. Pair with Alpine.js (`x-data`, `x-show`, `@click`) or native HTML primitives (`<dialog>`, `<details>`).
|
||||
|
||||
### Regenerate
|
||||
|
||||
```bash
|
||||
pnpm htmx-css:build
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Extension Protocol
|
||||
|
||||
@@ -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 <target-project-directory> [--brand-skill]"
|
||||
echo "Usage: $0 <target-project-directory> [--brand-skill] [--htmx-css]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --brand-skill Also install BRAND.md (voice/tone/messaging) and logo SVGs"
|
||||
echo " --htmx-css Also install greyhaven.htmx.css (framework-agnostic CSS layer"
|
||||
echo " for HTMX / server-rendered projects that can't use React)"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 /path/to/my-app"
|
||||
echo " $0 /path/to/my-app --brand-skill"
|
||||
echo " $0 . --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 <target-project-directory> [--brand-skill]"
|
||||
echo "Usage: $0 <target-project-directory> [--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:
|
||||
|
||||
<div data-slot="card">
|
||||
<div data-slot="card-header"><div data-slot="card-title">Hello</div></div>
|
||||
<div data-slot="card-content">Body</div>
|
||||
</div>
|
||||
<button data-slot="button" data-variant="default">Save</button>
|
||||
<span data-slot="badge" data-variant="success">Active</span>
|
||||
|
||||
Interactive components (dialog, dropdown, popover, etc.) emit their static
|
||||
visual CSS only — supply your own open/close JS (Alpine.js pairs well
|
||||
with HTMX).
|
||||
|
||||
3. (Optional) Register the Greyhaven MCP server. Create .mcp.json in your
|
||||
project root:
|
||||
|
||||
{
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user