UNPKG

raft-ui

Version:

React UI components for Raft.

178 lines (119 loc) 7.38 kB
--- name: styling description: "Styling contract: oklch semantic token families, banned palettes, interaction color pairs, tv() recipe conventions, the data-slot protocol, the var-first shadow trap." --- # Styling ## Contents - Tailwind's default palette is banned - Semantic token families - Interaction colors come in pairs - When a raw oklch literal is allowed - Never write a var-first shadow - Writing a `tv()` recipe - Styling by `data-slot` - Merging classes --- ## Tailwind's default palette is banned Raft-ui's theme contract is authored in oklch and exposed through semantic tokens. Tailwind's built-in palette is not part of that contract and will not track the theme. **Incorrect:** ```tsx <div className="bg-slate-50 text-gray-500 border-neutral-200" /> ``` **Correct:** ```tsx <div className="border border-line-muted bg-fill-muted text-foreground-muted" /> ``` Never write `hex`, `rgb()`, or `hsl()` either. --- ## Semantic token families Reach for these first. The token contract lives in `raft-ui/styles.css`. | Family | Purpose | Registered suffixes | | -------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | `layer-*` | elevation surfaces | `canvas` `canvas-muted` `panel` `popover` `backdrop` `inset` `card` `hud` `hud-foreground` | | `fill-*` | opaque neutral fills | `muted` `strong` | | `line-*` | borders | bare `line`, then `strong` `muted` `hairline` `field` `field-hover` | | `foreground-*` | text and icons | bare `foreground`, then `strong` `muted` `hint` `icon` `placeholder` `disabled` `inverse` `active` `hover` | | `ink-*` | neutral washes on transparent surfaces | bare `ink`, then `2` `4` `6` `8` `10` `16` `20` `30` `40` | State families do not share one uniform suffix set. `primary` and `accent` expose numeric ramps plus `strong`, `soft`, `hover`, and `active`; `info` exposes `strong`, `muted`, `soft`, `hover`, and `active`; `success` exposes `foreground`, `strong`, `muted`, and `soft`; `warning` and `danger` expose `foreground`, `strong`, `muted`, `soft`, `hover`, and `active`. Check `styles.css` before naming a utility. The `ink` ramp step is the alpha percentage — `ink-8` is 8% ink. Use it for hairlines, hover fills, and pressed states on transparent backgrounds. Within the state families, `primary` and `accent` split roles rather than rank: - **`primary-*` (Source Yellow) — hints and interactive expression.** New-message indicators, attention badges, the active tab, focus rings. This is how the recipes already use it — the tabs active state, the rail's attention badge, and focus rings all draw from the primary family. - **`accent-*` — core and important actions.** A dialog's confirm, the composer's send. `ComposerSubmit` locks `variant="accent"` at the type level. Do not use yellow for an action button just because it is the brand color, and do not use accent for a passive indicator. Opaque tokens and alpha washes composite differently on non-white backdrops. Never swap one for the other during a refactor without checking the actual background. --- ## Interaction colors come in pairs Hover and active states on solid fills are derived tokens, not opacity hacks. ```tsx <button className="bg-primary hover:bg-primary-hover active:bg-primary-active" /> ``` Not every state family ships the full set — check `styles.css` before assuming `success-hover` exists. Adding a family to the interaction contract means defining its complete hover/active pair in the token layer, not patching one callsite. --- ## When a raw oklch literal is allowed One-off precision values — a tuned tint, a glass surface, a terminal black — may be written inline: ```tsx <div className="bg-[oklch(0.21_0.006_285)]" /> ``` Tokenize on the rule of three: once the same value appears in a third component, it earns a token. Do not invent a token for a single consumer. --- ## Never write a var-first shadow ```tsx // Incorrect — tailwind-merge misreads this as a shadow-color and conflict // resolution silently breaks. <div className="shadow-[var(--theme-shadow-sm),0_1px_2px_oklch(0_0_0_/_0.18)]" /> ``` Use a registered named shadow utility such as `shadow-raft-sm`, or keep the shadow fully literal: ```tsx <div className="shadow-[0_1px_2px_oklch(0_0_0_/_0.18)]" /> ``` --- ## Writing a `tv()` recipe Long class strings become arrays, one responsibility per entry. Keep a property together with its own state and responsive variants; split unrelated responsibilities apart. No comments — the grouping carries the intent. Group by: - **layout** — display, grid/flex, gap, min/max size, overflow - **typography** — font, size, line-height, weight, wrapping - **surface** — background, border, radius, shadow, ring - **state** — hover, active, focus-visible, disabled, selected, invalid - **child/slot selectors** — icon sizing, `has-*`, `data-*` relationships ```ts const button = tv({ base: [ "inline-flex items-center justify-center", "rounded-md text-sm font-medium", "transition-colors disabled:opacity-50", ], variants: { variant: { primary: ["bg-primary hover:bg-primary-hover active:bg-primary-active", "text-primary-950"], }, }, }); ``` Add a `theme: { brutal, elegant }` variant when the two families genuinely differ, and resolve it from `useThemeFamily()` inside the component — never from a public prop. **Never construct Tailwind utilities dynamically.** Tailwind's static extraction cannot see them, and the class silently does nothing: ```ts // Broken — the class is never generated. const padding = `px-${size}`; ``` Use static recipe entries (a `size` variant with literal classes) instead. --- ## Styling by `data-slot` Raft-ui's DOM-rendering parts stamp stable `data-slot` attributes (`data-slot="dialog-content"`, `data-slot="banner-action"`, …). Behavior-only roots and providers that render no element have no slot. Recipes use this DOM protocol for structural relationships (`has-[[data-slot=…]]`). When your styling depends on a part's presence or identity, target `data-slot`, never the part's class names: ```tsx // React to a slot's presence from a parent you control: <div className="grid has-[[data-slot=banner-action]]:grid-cols-[1fr_auto]"> ``` Class names are recipe internals and change between releases; `data-slot` values are the stable contract. This is also how the library's own style tests select elements. --- ## Merging classes ```tsx import { cn } from "raft-ui/cn"; ``` `cn` is exported from the package. Do not add `clsx` or `tailwind-merge` as direct dependencies to compose raft-ui classes. Conditional classes go through `cn`, not template-literal ternaries: ```tsx // Incorrect <div className={`px-3 ${active ? "bg-fill-muted" : ""}`} /> // Correct <div className={cn("px-3", active && "bg-fill-muted")} /> ```