# Design skill

Design skill là file luật cho agent code. Token quyết định giá trị, còn skill quyết định cách ghép chúng lại: cỡ chữ, khoảng cách, góc bo, chuyển động. Agent đọc file này trước khi viết một màn hình.

## 1. Trong file có gì

Mỗi luật có một cách nên viết và một cách nên tránh. Chọn một luật để đọc đầy đủ.

**Type**

- [1. Content text is 14px](#rule-1)
- [2. Headings use sentence case](#rule-2)
- [3. Never change letter spacing](#rule-3)
- [4. No bold](#rule-4)
- [5. Inline code is 0.9em](#rule-5)

**Spacing and alignment**

- [6. Related things sit closer than unrelated things](#rule-6)
- [7. Vertical padding around text is one step smaller](#rule-7)
- [8. Icons line up with the first line of text](#rule-8)

**Surfaces**

- [9. A layer with a shadow gets a ring, not a border](#rule-9)
- [10. Nested corners are concentric](#rule-10)
- [11. Don't put a Card inside a Card](#rule-11)
- [12. Sticky bars are separated by a border](#rule-12)

**Motion and state**

- [13. Hover changes color over 160ms](#rule-13)
- [14. Every state change a component makes is visible in motion](#rule-14)
- [15. Collapsing content keeps its size](#rule-15)
- [16. Dialogs stay mounted and use `open`](#rule-16)

**Size**

- [17. Element sizes come from twelve classes](#rule-17)

**Loading**

- [18. Never blank content while the next content loads](#rule-18)
- [19. Images keep their space and fade in](#rule-19)

**Opacity**

- [20. Opacity comes from three role tokens](#rule-20)

**Glass**

- [21. Glass only on floating layers, two at most](#rule-21)

## 2. Dùng ngay bây giờ

Copy file `skills/werewolf-design/SKILL.md` vào thư mục mà agent của bạn đọc luật.

## 3. Sắp có

`npx werewolf-ui init` sẽ cài file này giúp bạn.

## Các luật

### Type

#### 1. Content text is 14px

Body copy, input values, table cells and `md` (default) and `lg` controls use 14px: `text-body` for paragraphs, `text-md` inside controls. 14px is the size the whole scale is tuned around, and mixing 14 and 15 or 16 in the same block looks like a mistake, not a hierarchy.

- 13px (`text-sm`) is for secondary text next to a primary line: descriptions, helper and error text, `sm` controls.
- 12px (`text-xs`) is for badges, tokens, captions and timestamps.
- 16px and up (`text-lg`, `text-heading-*`) is for headings and `xl` controls only.

```tsx
// Do
<p className="text-body text-text-primary">Your trial ends in 12 days.</p>
// Avoid: body copy at 16px to "make it readable"
<p className="text-lg text-text-primary">Your trial ends in 12 days.</p>
```

#### 2. Headings use sentence case

Capitalize only the first word and proper nouns. Product and feature names keep the casing their owner uses. Title Case and `uppercase` make headings slower to read and louder than the content under them.

```tsx
// Do
<h2 className="text-heading-3">Billing history</h2>
// Avoid
<h2 className="text-heading-3">Billing History</h2>
<h2 className="text-heading-3 uppercase">Billing history</h2>
```

#### 3. Never change letter spacing

No `tracking-*` classes and no `letter-spacing` in CSS. Geist and JetBrains Mono are already spaced for the sizes in the scale; tightening or loosening them is how generated UI starts to look generated.

```tsx
// Do
<span className="text-xs font-medium text-text-secondary">Last 30 days</span>
// Avoid
<span className="text-xs font-medium tracking-wide uppercase text-text-secondary">Last 30 days</span>
```

#### 4. No bold

The scale has regular (400), medium (500) and semibold (600). There is no 700, and `font-bold` does nothing in a Werewolf project.

- Headings: the weight comes with the role (`text-heading-1` to `-3` set it). Don't add a weight class on top.
- Emphasis inside text, labels, button text: `font-medium`.
- `font-semibold` only when medium can't separate a label from medium text right next to it.

```tsx
// Do
<p className="text-body">Payment failed. <span className="font-medium">Update your card</span> to keep access.</p>
// Avoid
<p className="text-body">Payment failed. <b>Update your card</b> to keep access.</p>
```

#### 5. Inline code is 0.9em

Mono text sitting inside a sans sentence looks bigger than the words around it at the same size. Use `code-inline`: it sets the mono family at `0.9em`, so it follows whatever size the sentence is.

```tsx
// Do
<p className="text-body">Run <code className="code-inline">npm run build</code> again.</p>
// Avoid
<p className="text-body">Run <code className="font-mono">npm run build</code> again.</p>
```

### Spacing and alignment

#### 6. Related things sit closer than unrelated things

A title and its description are one unit, so the gap between them is smaller than the gap to the next unit. If every gap is the same, the reader can't tell what belongs to what.

- Title to description, label to field, label to helper text: `gap-xs` or `gap-sm`.
- Field to field, header to body inside a card or dialog: `gap-lg`.
- Group to group: `gap-xl`. Section to section: `gap-2xl` or more.

The inner gap must always be at least one step smaller than the gap outside it.

```tsx
// Do
<div className="flex flex-col gap-lg">
  <div className="flex flex-col gap-xs">
    <h3 className="text-heading-3">Webhooks</h3>
    <p className="text-sm text-text-secondary">Send events to your server.</p>
  </div>
  <Button>Add endpoint</Button>
</div>
// Avoid: one gap for everything
<div className="flex flex-col gap-md">
  <h3 className="text-heading-3">Webhooks</h3>
  <p className="text-sm text-text-secondary">Send events to your server.</p>
  <Button>Add endpoint</Button>
</div>
```

#### 7. Vertical padding around text is one step smaller

Line height already adds space above and below the letters, so equal padding on all sides looks taller than it is wide. Around text, use one spacing step less on top and bottom than on the sides. Fixed-height controls (`h-element-*`) already handle this; the rule is for blocks you pad yourself: banners, callouts, list rows, table cells.

```tsx
// Do
<div className="rounded-element bg-background-sunken px-lg py-md text-body">Changes are saved automatically.</div>
// Avoid
<div className="rounded-element bg-background-sunken p-lg text-body">Changes are saved automatically.</div>
```

#### 8. Icons line up with the first line of text

An icon next to text that can wrap goes in a box exactly one line tall, so it centers on the first line and stays there when the text wraps. Centering it on the whole block makes it drift to the middle of a paragraph. Use the icon size that matches the text (12px with `text-xs`, 16px with `text-sm` and `text-md`, 20px with `text-lg` and up) and `color-icon-*` for its color.

```tsx
// Do
<div className="flex gap-sm text-body">
  <span className="flex h-[1lh] shrink-0 items-center">
    <IconInfoCircle size={16} className="text-icon-secondary" aria-hidden />
  </span>
  <p>Keys can't be viewed again after you close this dialog.</p>
</div>
// Avoid
<div className="flex items-center gap-sm text-body">
  <InfoIcon size={16} weight="regular" aria-hidden />
  <p>Keys can't be viewed again after you close this dialog.</p>
</div>
```

### Surfaces

#### 9. A layer with a shadow gets a ring, not a border

Floating layers (menus, popovers, dialogs, toasts) use `ring-1 ring-border` for their edge. A ring is drawn with the shadow, outside the layout, so the edge stays sharp and the layer's size doesn't change. Cards that sit on the page have no shadow and keep `border border-border`.

```tsx
// Do
<div className="rounded-container bg-background-raised ring-1 ring-border shadow-med">...</div>
// Avoid
<div className="rounded-container bg-background-raised border border-border shadow-med">...</div>
```

Glass layers follow the same rule: `material-thick` with `ring-1 ring-border` and a `shadow-*` (rule 21).

In dark mode the `shadow-*` tokens already carry the layer's edge (pending review): a darker shadow plus a 1px light line inside the top edge. Put the layer on `bg-background-raised` and use the shadow class; don't add a highlight of your own (no light `border-t`, no `inset-shadow-*`, no extra ring), or the edge shows twice. A layer that draws its own edge highlight sets `--werewolf-shadow-edge: 0 0 #0000` to drop the built-in one; the `material-*` classes already do.

The glossy Button variants (`glossy`, `destructive-glossy`) are the one control with a shadow, and they draw their own edges (pending review). Use the variant; don't build a raised control out of `shadow-*` classes and gradients.

#### 10. Nested corners are concentric

When one rounded shape sits inside another and the gap between their edges is 8px or less, the outer radius equals the inner radius plus the gap. Otherwise the corners pinch at the inside and look wobbly.

Werewolf's radius steps are 4px apart (`radius-inner`, `radius-element`, `radius-container`, `radius-page`), so the steps are concentric exactly when the gap is 4px (`p-xs`):

- `radius-inner` inside `radius-element` with a 4px gap (a token in an input, a segment in a segmented control).
- `radius-element` inside `radius-container` with a 4px gap (a menu item in a menu).

A menu, select or combobox list has `p-xs` plus a 1px border, so its rows sit 5px in from the outer edge. Use `rounded-item` (`radius-item`, the container radius minus 5px) on those rows, not `rounded-inner`.

For any other gap up to 8px, compute the inner radius from the outer one. For gaps over 8px the corners are far enough apart that you just use the next step down. If the outer shape is `rounded-full`, the inner one is `rounded-full` too.

```tsx
// Do: 4px gap, steps line up
<div className="rounded-container p-xs ring-1 ring-border shadow-med bg-background-raised">
  <button className="rounded-element px-md h-element-sm w-full text-left">Rename</button>
</div>
// Do: 8px gap, compute it
<div className="rounded-container p-sm">
  <div className="rounded-[max(calc(var(--radius-container)-var(--spacing-sm)),0px)]">...</div>
</div>
// Avoid: 8px gap with the 4px-gap step
<div className="rounded-container p-sm">
  <div className="rounded-element">...</div>
</div>
```

#### 11. Don't put a Card inside a Card

Two borders and two paddings stacked inside each other waste space and blur what is grouped with what. Inside a Card, separate parts with spacing (rule 6), a divider (`border-t border-border`), or a sunken block (`bg-background-sunken rounded-element`). If the inner part really is its own object, the outer one probably shouldn't be a Card.

```tsx
// Do
<Card>
  <CardHeader>...</CardHeader>
  <CardContent className="flex flex-col gap-lg">
    <div className="rounded-element bg-background-sunken px-lg py-md">...</div>
  </CardContent>
</Card>
// Avoid
<Card>
  <CardContent>
    <Card>...</Card>
  </CardContent>
</Card>
```

#### 12. Sticky bars are separated by a border

A sticky header, toolbar or footer gets `border-b border-border` (or `border-t` for a footer) against the content scrolling under it. Shadows are reserved for floating layers (rule 9), and a sticky bar is not floating. If content should show through, use `material-thin`, which draws its own edge highlight and goes solid where blur is missing (rule 21).

```tsx
// Do
<header className="sticky top-0 bg-background-page border-b border-border">...</header>
// Avoid
<header className="sticky top-0 bg-background-page shadow-low">...</header>
```

### Motion and state

#### 13. Hover changes color over 160ms

Hover, press and focus color changes use `duration-fast` (160ms) with `ease-standard`. List the properties instead of using `transition-colors` or `transition-all`: those include `outline-color`, so the focus ring would fade in instead of appearing at once. The ring itself is never animated.

```tsx
// Do
<a className="transition-[color,background-color,border-color] duration-fast ease-standard hover:overlay-hover focus-visible:focus-ring">...</a>
// Avoid
<a className="transition-colors duration-300 hover:overlay-hover">...</a>
<a className="transition-all hover:overlay-hover">...</a>
```

Respect `prefers-reduced-motion`: color can still change, movement (press scale, slides) goes behind `motion-safe:`.

#### 14. Every state change a component makes is visible in motion

A component is not done until its visible state changes animate. Interactive elements (buttons, toggles, checkboxes, inputs, menu items) transition their color, background and border properties with `duration-fast ease-standard`, and pressable controls add `motion-safe:active:scale-pressed`. Things that arrive or leave (dialogs, sheets, popovers, menus, tooltips, expanding rows) animate in and out: `duration-medium` (`duration-slow` for dialogs and sheets), `ease-enter` on arrival, `ease-exit` on exit, on the Sheet pattern:

```tsx
<div className="transition-[translate,opacity] duration-slow ease-enter starting:opacity-0 motion-safe:starting:-translate-y-full">
```

Static containers (Card, Separator, Badge, Kbd, EmptyState, Metadata, Field) need no motion of their own, but any interactive child they render must carry it. A component whose interactive parts are other components (Pagination uses Button, ToggleGroup uses Toggle, TableSelection uses Checkbox) inherits their motion.

#### 15. Collapsing content keeps its size

When a section opens or closes, animate the height of a wrapper that clips (`overflow-hidden`), and let the content inside keep its full width and height the whole time. Don't animate the content's padding, font size or scale, and don't let it reflow while the wrapper shrinks; text that rewraps mid-animation looks broken. Use `duration-medium`, `ease-enter` to open and `ease-exit` to close.

```tsx
// Do: the wrapper animates, the content stays the same size
<div className="grid transition-[grid-template-rows] duration-medium ease-enter data-[state=closed]:grid-rows-[0fr] data-[state=open]:grid-rows-[1fr]">
  <div className="overflow-hidden"><div className="pt-md">...</div></div>
</div>
// Avoid: padding and content shrink together
<div className="transition-all data-[state=closed]:h-0 data-[state=closed]:py-0">...</div>
```

#### 16. Dialogs stay mounted and use `open`

Render the Dialog all the time and show or hide it with `open` and `onOpenChange`. Mounting it only when it should show skips the exit animation and can lose track of where focus should go back when it closes.

```tsx
// Do
<Dialog open={open} onOpenChange={setOpen}>
  <DialogContent>...</DialogContent>
</Dialog>
// Avoid
{open && (
  <Dialog open onOpenChange={setOpen}>
    <DialogContent>...</DialogContent>
  </Dialog>
)}
```

### Size

#### 17. Element sizes come from twelve classes

Control heights, widths and square slots such as icon buttons come from exactly these 12 classes, so they line up with Button and Input of the same size. Components take the same steps as their `size` prop: `sm`, `md` (the default), `lg`, `xl`. Touch (`pointer: coarse`) moves `md`, `lg` and `xl` up the ladder; `sm` keeps its look.

| Step | Height | Width | Width and height | Mouse | Touch (`pointer: coarse`) |
| --- | --- | --- | --- | --- | --- |
| sm | `h-element-sm` | `w-element-sm` | `size-element-sm` | 28px | 28px, hit area via `size-target-min` |
| md | `h-element-md` | `w-element-md` | `size-element-md` | 32px | 36px |
| lg | `h-element-lg` | `w-element-lg` | `size-element-lg` | 36px | 44px |
| xl | `h-element-xl` | `w-element-xl` | `size-element-xl` | 44px | 48px |

Not valid: no `-xs` element size (`sm` is the smallest control), no `h-sm`, `w-sm` or `h-md` shorthand. Use these classes.

Each step brings its own text and icon size: `sm` 13px text (`text-sm`) with 16px icons, `md` and `lg` 14px (`text-md`) with 16px icons, `xl` 16px (`text-lg`) with 20px icons. Use `sm` for toolbars, tables and dense menus, `md` everywhere else by default (props panels included: `sm` keeps its small look on a phone), `lg` for roomy forms and settings, `xl` for the primary action and touch-first screens. Dense or roomy UI is not a global setting: pick the step per region, and keep one step per region. Two neighbouring steps in one row (an `md` Input next to an `lg` Button) differ by only 4px with a mouse and look like a mistake.

Controls shorter than 44px on touch (`sm`, `md`, checkboxes, icon buttons) keep their look and extend their hit area to `size-target-min`. Inputs and textareas can't: on touch, Input `sm` is 28px and `md` is 36px. On touch-first screens use Input `lg` or `xl`, or wrap the field in a padded `<label>`.

```tsx
// Do: one step for the whole row
<div className="flex gap-sm">
  <Input size="lg" aria-label="Email" />
  <Button size="lg">Subscribe</Button>
</div>
// Avoid: neighbouring steps side by side
<div className="flex gap-sm">
  <Input aria-label="Email" />
  <Button size="lg">Subscribe</Button>
</div>
```

### Loading

#### 18. Never blank content while the next content loads

When the user changes page, tab, filter or page of a table, keep what is on screen until the next content is ready. Make the change in a transition (`useTransition`) and mark the region pending: `pending` on `TabsContent` and `Table` dims it to `opacity-pending` and sets `aria-busy`. Show a Skeleton only when there is nothing to keep (the first load), only after about 150ms, and for at least 300ms once shown (`useSkeletonDelay`), so it never flashes. Shape the skeleton like the content: text lines at the same font size (each line is `h-lh` tall), controls at the same `size` (`shape="control"`), the same corners. Wrap it in `SkeletonGroup`, which sets `aria-busy` and says "Loading" in a polite status. The pulse runs on `duration-loop` and stops under reduced motion.

```tsx
// Do
const [isPending, startTransition] = useTransition()
<Tabs value={tab} onValueChange={(next) => startTransition(() => setTab(next))}>
  <TabsContent value={tab} pending={isPending}>...</TabsContent>
</Tabs>
// Avoid
{isLoading ? <Spinner /> : <TabsContent value={tab}>...</TabsContent>}
```

#### 19. Images keep their space and fade in

Reserve an image's space before it loads (`ratio`, an aspect class or a height), show a small blurred version of it at once, and fade the full image in over it when it has loaded. Use `Image` with `placeholderSrc`: a data URL of a few hundred bytes made when the image is added, so it needs no request. In a Next.js app, `next/image` with `placeholder="blur"` does the same for static images. No fade under reduced motion.

```tsx
// Do
<Image src={post.cover} placeholderSrc={post.coverBlur} alt={post.coverAlt} ratio="16 / 9" />
// Avoid
<img src={post.cover} alt={post.coverAlt} />
```

### Opacity

#### 20. Opacity comes from three role tokens

There is no opacity scale. Use one of three tokens, by what the dim is for: `opacity-pending` on content kept on screen while the next content loads (`pending` on `TabsContent` and `Table` already applies it), `opacity-disabled` on non-text content in a disabled control whose color no token sets (a color swatch, an image, a photo avatar), `opacity-subtle` on decorative imagery that should recede. Never write another step (`opacity-50`, `opacity-[.7]`) or an alpha on a color class (`text-text-primary/60`, `bg-accent/50`). Fading something in or out goes between `opacity-0` and `opacity-100`; that hides or shows it, it doesn't dim it.

Never use opacity to make text lighter. Secondary text uses `text-text-secondary` (hints and metadata `text-text-tertiary`), which are solved to keep their contrast on every background; text at an opacity takes whatever is behind it and has no checked contrast. Disabled labels and icons use `text-text-disabled` and `text-icon-disabled`, not `opacity-disabled`.

```tsx
// Do
<p className="text-sm text-text-secondary">Updated 5 minutes ago</p>
<span className="size-4 rounded-inner opacity-disabled" style={{ background: value }} />
// Avoid
<p className="text-sm text-text-primary opacity-60">Updated 5 minutes ago</p>
<button disabled className="disabled:opacity-50">Save</button>
```

### Glass

#### 21. Glass only on floating layers, two at most

Glass is one class per level: `material-thin` for bars (header, nav bar, tab bar), `material-thick` for sheets, popovers, dialogs and menus, `material-refract` for fixed-size elements only (tab bar, floating action button, nav bar). The class draws the fill, the blur, the edge and top highlights, and the solid fallbacks; never build glass from `bg-*`, `backdrop-blur-*` and an alpha color yourself. Its fill is just opaque enough for `text-text-primary` and `text-text-secondary` to keep 4.5:1 over anything behind it, so use only those two for text on glass.

- At most two glass layers visible on a screen at once. No glass on glass beyond that, so a menu over a glass sheet is the limit.
- Only floating and overlay layers. Never the page background, never a Card, never inside a long scrolling list or on list rows.
- `material-refract` only on an element whose size and corner radius never change. Anything that resizes, reflows with its content or animates its size uses `material-thick`. Refraction draws only in Chromium-based browsers, after `enableMaterialRefraction()` from the `material` registry item marks the root; everywhere else it looks exactly like `material-thick`. Before a component uses it, check scrolling and animation by hand on an older iPhone and on a low-end Android phone.
- Under reduced motion, no animated highlight: anything that moves a highlight goes behind `motion-safe:`.

```tsx
// Do
<SheetContent className="material-thick ring-1 ring-border shadow-high">...</SheetContent>
<header className="sticky top-0 material-thin">...</header>
// Avoid
<div className="bg-background-raised/60 backdrop-blur-lg">...</div>
<li className="material-thin">...</li>
```

### Before you hand UI back

- Every value is a token class; no hex, no px outside arbitrary values the rules above show.
- No `font-bold`, no `tracking-*`, no Title Case headings.
- No `border` together with a `shadow-*` on the same element.
- At most one glossy Button per view, and never next to a solid one: `glossy` replaces `solid`, it doesn't add a level.
- Inner gaps are smaller than outer gaps.
- Nested radii follow rule 10.
- Every visible state change is in motion: color transitions, `ease-enter` arrivals, `ease-exit` departures, movement behind `motion-safe:` (rule 14).
- Element sizes come from the 12 classes and the `size` prop, one step per region (rule 17).
- Nothing on screen goes blank while the next content loads; skeletons match the layout they stand in for (rule 18).
- No opacity class but `opacity-pending`, `opacity-disabled`, `opacity-subtle` (and `opacity-0` / `opacity-100` for a fade), no color alpha like `/60`, and no text made lighter with opacity (rule 20).
- At most two glass layers on a screen, only on floating layers, `material-refract` only on fixed-size elements (rule 21).
