<!-- Generated by packages/ui/design/compile.ts. Edit design/*.md and src/components/*.md instead. -->

# @cf/ui design rules

The rules for building with @cf/ui, Connor Forsyth's component system. Rules are MUST, SHOULD or MAY, in the RFC 2119 sense. Docs: https://ds.connorforsyth.co. MCP: https://ds.connorforsyth.co/api/mcp.

Install the registry once, then add components by name:

```json
{ "registries": { "@cf": "https://ds.connorforsyth.co/r/{name}.json" } }
```

```sh
bunx shadcn@latest add @cf/button
```

## Foundations

### Purpose

What @cf/ui is, where it comes from, and how to use it in another project.

- **SHOULD** `install-through-registry`: Add components with `bunx shadcn@latest add @cf/<name>` so the `@cf/theme` item and any sibling components come with them. _Every component lists `@cf/theme` as a registry dependency, and without it the semantic utilities resolve to nothing._
- **MUST** `bring-your-own-fonts`: Set the typeface on the page yourself; do not expect the registry to install KAG, Rodney or any other font. _KAG and Rodney are licensed, so they stay with the site._
- **SHOULD** `read-design-md`: Run `bunx shadcn@latest add @cf/design` in a consuming repo so the agents working there read the same rules as this document.

## What this is

@cf/ui is the set of components and tokens that my personal site actually runs on. It is not a separate library that the site consumes after the fact: the components in `packages/ui/src/components` are the ones rendering the notes editor, the chat panel and the CV, and the site imports `@cf/ui/styles/theme.css` for its palette, the same tokens `@cf/theme` writes into anyone else's stylesheet. When I fix a component for the site, the registry gets the fix.

I'm building it in public. Some of it is tidy and some of it is still carrying stock shadcn classes I haven't reviewed yet; where I know about a gap I say so in the relevant page rather than pretend it isn't there.

## How to use it

There are three ways in, all generated from the same source by `design/compile.ts`:

- **The registry.** Point shadcn at `https://ds.connorforsyth.co/r/{name}.json` under the `@cf` namespace, then `bunx shadcn@latest add @cf/button`. You get the source file copied into your repo, plus the theme and any components it imports.
- **DESIGN.md.** `bunx shadcn@latest add @cf/design` writes the rules on this site into your repo as `DESIGN.md`, so a coding agent can follow them without visiting the docs.
- **MCP.** `https://ds.connorforsyth.co/api/mcp` answers questions about the components, tokens and rules from the same compiled knowledge.

## What's not included

Fonts are not part of the registry. The components are font-agnostic and inherit whatever the page sets. Breakpoints, the site's brand blue, search highlight colours and utilities like `touch-target` also live with the site in `styles/globals.css`, not in the theme. If a component needs one of those to look right, that is a bug in the component.

### Principles

The handful of ideas the code already follows, written down so new work follows them too.

- **MUST** `semantic-tokens-only`: Colour components with semantic utilities such as `bg-primary`, `text-muted-foreground` and `border-border`, never raw palette values like `bg-gray-3` or hex codes.
- **MUST** `inherit-typeface`: Do not set a `font-family` in a component; let it inherit from the page, with `font-mono` in `Kbd` as the only exception.
- **SHOULD** `subtract-dont-adapt`: Below `md`, remove affordances that only exist for a keyboard or pointer (keycap hints, hover tooltips, shortcut rows) instead of shrinking them or inventing touch equivalents.
- **SHOULD** `comment-the-why`: When a component departs from the stock shadcn version, leave a comment in the source saying why. _The source is what gets copied into your repo, so the reasoning has to travel with it._

## Semantic tokens over raw values

Components ask for a role, not a colour. `bg-primary` means "the strongest fill", and `theme.css` decides that is Radix `gray-12` in light mode. That is what lets dark mode, and anyone else's palette, work without touching component code.

## Inherit type, don't own it

The site sets its typeface on `body`. Components set sizes and weights (`text-sm`, `font-medium`) and nothing else, so they look native wherever they're dropped. The one exception is `Kbd`, which uses `font-mono` because a keycap should read as a key.

## Subtract, don't adapt

On small screens I remove things rather than translate them. A tooltip that explains a shortcut has nothing to offer a phone, so it goes. What remains still gets touch treatment: 16px inputs so iOS doesn't zoom, taller menu items below `md`, and `touch-target` on isolated small controls.

## The source is the documentation

This registry copies source, so the source has to explain itself. `button.tsx` says why hit-area expansion is opt-in; `switch.tsx` says why the track uses foreground and background instead of `--input`; `theme.css` says why destructive skips Radix step 9. These pages summarise those comments; when they disagree, the code is right and this page is stale.

## Honest about gaps

Some components still carry stock shadcn classes I haven't reconciled, such as `dark:` opacity tweaks and two different focus ring weights. I'd rather list those than hide them.

### Styling

How components are styled with Tailwind v4 and semantic utilities, and how to change them.

- **MUST** `merge-with-cn`: Accept a `className` prop and merge it last with `cn(...)` so callers can override any class.
- **SHOULD** `no-dark-colour-overrides`: Do not add `dark:` colour classes to a component; change or add a token in `theme.css` so both modes switch together. _Tokens already resolve per mode under `.dark`, so a `dark:` class is a second source of truth._
- **SHOULD** `data-slot-on-parts`: Give each exported part a `data-slot` attribute (for example `data-slot="dialog-content"`) and style relationships between parts against it rather than against class names.
- **SHOULD** `extend-with-classname-first`: Change a component for one use with `className`; edit the copied source only when the change should apply everywhere in your project.

## Tailwind v4 and the theme

Components are plain Tailwind v4 class strings. `theme.css` maps each token into the `@theme` block (`--color-primary: var(--primary)`), so utilities like `bg-primary`, `border-input` and `ring-ring` exist, and the radius scale (`rounded-lg`, `rounded-md`, `rounded-sm`) is derived from one `--radius`. In another repo, `bunx shadcn@latest add @cf/theme` writes those tokens into your own global stylesheet (the Radix imports, `:root`, `.dark` and the `@theme` map), replacing the palette `shadcn init` left there. Keep `shadcn/tailwind.css` imported too: it supplies the `data-open`, `data-closed` and similar state variants the components use.

Variants are written with `cva`, as in `buttonVariants`, and every component passes its classes through `cn` from the `cn` package with the caller's `className` last. That is what makes `<Button className="w-full">` work without a new variant.

## Dark mode

Dark mode is a `.dark` class on an ancestor, declared with `@custom-variant dark (&:where(.dark, .dark *))`. The tokens switch underneath, so `bg-card` is white in light mode and `gray-2` in dark without the component knowing. Some components (`textarea`, `switch`, `input-group`, `field`, `bubble`) still carry `dark:` opacity tweaks from their shadcn origins. I haven't removed them yet; new work shouldn't add more.

## data-slot

Most parts carry a `data-slot`. It gives parents a stable hook: `Kbd` restyles itself `in-data-[slot=tooltip-content]`, and `Bubble` variants reach into `*:data-[slot=bubble-content]`. Base UI supplies state attributes (`data-checked`, `data-highlighted`, `data-disabled`) that are styled the same way.

## Extending

Because the registry copies source into your repo, you own the file. For a one-off, pass `className`. For a new permanent variant, add it to the `cva` in your copy. Keep in mind that re-running `shadcn add` will offer to overwrite local edits.

### Colors

Semantic colour tokens on Radix Gray, with red and green for status, switched by a `.dark` class.

- **MUST** `pair-fill-with-foreground`: When you use a filled token as a background, set text with its `-foreground` pair, for example `bg-destructive text-destructive-foreground`.
- **MUST** `status-colours-for-status`: Use `destructive` and `success` only to mean error or danger and success; do not use them as decoration.
- **MUST** `keep-status-contrast`: If you change `--destructive` or `--success`, keep white text on them at WCAG AA (4.5:1) in both modes. _That is why they use Radix step 11 in light and step 8 in dark instead of step 9._
- **SHOULD** `marker-is-highlight`: Reserve `bg-marker` with `text-marker-foreground` for highlighter-style emphasis, not for fills or status.
- **SHOULD** `surfaces-for-elevation`: Use `bg-card` or `bg-popover` for anything that sits above the page, and `bg-background` for the page itself.

## The palette

Almost everything is Radix Colors' Gray scale, used the way Radix's own guide describes: steps 1 to 2 for backgrounds, 3 to 5 for component surfaces, 6 to 8 for borders, 11 to 12 for text. Because `gray.css` and `gray-dark.css` already scope their variables under `:root` and `.dark`, a token like `--foreground: var(--gray-12)` resolves correctly in both modes with a single declaration.

| Token | Light | Dark | Use |
| --- | --- | --- | --- |
| `--background` | `var(--gray-1)` | same | Page surface. |
| `--foreground` | `var(--gray-12)` | same | Default text on the page. |
| `--card` | `oklch( 1 0 0 )` | `var( --gray-2 )` | Raised panels; brighter than the page in both modes. |
| `--card-foreground` | `var(--gray-12)` | same | Text on `card`. |
| `--popover` | `oklch(1 0 0)` | `var(--gray-2)` | Menus, selects and other floating surfaces. |
| `--popover-foreground` | `var(--gray-12)` | same | Text on `popover`. |
| `--primary` | `var(--gray-12)` | same | The strongest fill, used by the default button. |
| `--primary-foreground` | `var(--gray-1)` | same | Text and icons on `primary`. |
| `--secondary` | `var(--gray-3)` | same | Quiet filled controls. |
| `--secondary-foreground` | `var(--gray-12)` | same | Text on `secondary`. |
| `--muted` | `var(--gray-3)` | same | Subdued backgrounds for secondary content. |
| `--muted-foreground` | `var(--gray-11)` | same | Secondary text, placeholders and captions. |
| `--accent` | `var(--gray-4)` | same | Hover and highlighted states in menus and ghost buttons. |
| `--accent-foreground` | `var(--gray-12)` | same | Text on `accent`. |
| `--marker` | `#ffd34d` | `#6b5200` | Highlighter yellow for emphasised text. |
| `--marker-foreground` | `var(--gray-12)` | same | Text on `marker`. |
| `--destructive` | `var(--red-11)` | `var(--red-8)` | Errors and dangerous actions. |
| `--destructive-foreground` | `oklch(1 0 0)` | same | Text on `destructive`. |
| `--success` | `var(--green-11)` | `var(--green-8)` | Confirmation and success states. |
| `--success-foreground` | `oklch(1 0 0)` | same | Text on `success`. |
| `--border` | `var(--gray-6)` | same | Default borders and dividers. |
| `--input` | `var(--gray-6)` | same | Borders on form controls. |
| `--ring` | `var(--gray-12)` | same | Focus rings. |
| `--radius` | `0.5rem` | same | Base corner radius; `rounded-lg`, `rounded-md` and `rounded-sm` derive from it. |

## Where it isn't automatic

A few tokens use a different step per mode, so they need an explicit `.dark` override:

- **Card and popover** are pure white in light mode and `gray-2` in dark. In both cases that is a step brighter than the page, which is the elevation convention I follow.
- **Destructive and success** don't use Radix step 9. Step 9 is tuned for vividness rather than contrast, and against white text red and green fall below 4.5:1. Step 11 in light and step 8 in dark both clear AA, at roughly 4.7 to 5.4:1.
- **Marker** is a hex yellow, `#ffd34d` in light and `#6b5200` in dark. It is not a Radix step; no yellow scale is imported.

## What's missing

There is no brand colour in the theme. The site's blue lives in `styles/globals.css` as a site token, so components stay neutral. Opacity modifiers like `bg-destructive/10` are used for tinted states; they are not separate tokens.

### Typography

Components inherit the page's typeface; the site sets KAG, Rodney and a mono stack itself.

- **MUST** `no-font-family-in-components`: Do not add `font-kag`, `font-rodney` or any other family utility inside a component; only `font-mono` in `Kbd` is allowed.
- **MUST** `sixteen-pixel-inputs`: Keep text fields at `text-base` below `md` and drop to `md:text-sm` only at the breakpoint. _iOS zooms into any field under 16px and doesn't zoom back out._
- **SHOULD** `use-the-size-scale`: Size text with Tailwind's scale (`text-xs`, `text-sm`, `text-base`) and weight with `font-medium`; avoid arbitrary sizes except where a component documents why.
- **SHOULD** `fallback-before-brand-face`: If you use KAG, list `kag-fallback` before `kag` in the font stack so its broken glyphs are replaced.

## Components don't own type

No component in `@cf/ui` sets a typeface. They set size (`text-sm` on buttons and labels, `text-xs` in tooltips), weight (`font-medium`) and leading, and inherit the family from the page. That's deliberate: the fonts I use are licensed and can't ship in a public registry, and a component that inherits looks right in someone else's product without edits.

The one family a component names is `font-mono` in `Kbd`, at `text-[0.6875rem]`, because a keycap should look like a key.

## What the site sets

For reference, and so the docs match what you see on connorforsyth.co:

- **Body:** Kyneton Art Grotesque ("KAG") at weight 300, set on `body` in `styles/globals.css`, falling back to the system UI stack.
- **Italic:** `em` switches to Rodney at weight 400, and `a em` to Rodney Medium italic. Italic is a different typeface rather than a slanted KAG.
- **Mono:** JetBrains Mono through `next/font`, then the self-hosted `geist-mono`, then `monospace`.

## The KAG fallback

KAG's `&`, `=` and arrow glyphs don't render properly. `styles/type.css` declares a `kag-fallback` face that points at Geist with a `unicode-range` covering only those characters (U+0026, U+003D and the arrows U+2190, U+2192, U+2197, U+21B3), sized to 94% so they sit with KAG's metrics. Because it's first in the stack, the browser uses Geist for those code points and KAG for everything else. It's a patch, not a fix, and it only helps if the fallback is listed first.

## Mobile

Inputs and textareas use `text-base` below `md`; dropdown items do the same with `md:text-sm`. The larger size is about zoom and legibility on touch, not a separate type scale.

### Iconography

Phosphor icons, sized by the component they sit in rather than by the icon.

- **MUST** `use-phosphor`: Use icons from `@phosphor-icons/react`, imported by their `Icon`-suffixed names such as `XIcon` and `CaretDownIcon`.
- **SHOULD** `mark-icons-in-buttons`: Put `data-icon` on an icon inside a `Button` so the button's `[&_[data-icon]]:size-4 [&_[data-icon]]:shrink-0` rule sizes it.
- **SHOULD** `let-the-parent-size`: Leave out a `size-*` class on an icon unless you need a different size; containers like `Marker` and `InputGroup` apply `size-4` to any svg without one.
- **MUST** `label-icon-only-controls`: Give every icon-only control a text label, as a `<span className="sr-only">` or `aria-label`, because the icon itself carries no name.

## Library

The icon set is Phosphor. `components.json` sets `iconLibrary` to `phosphor`, and every component that draws an icon imports it from `@phosphor-icons/react`: `XIcon` in the dialog close button, `CaretDownIcon` and `CheckIcon` in select, `CaretRightIcon` in dropdown submenus, `ArrowLeftIcon` and `ArrowRightIcon` in the carousel, `CircleNotchIcon` for `Spinner`. I use the default weight throughout; nothing in the components sets `weight`.

## Sizing comes from the parent

An icon doesn't decide its own size. The component it sits in does, with a descendant selector:

- `Button` sizes anything carrying `data-icon`: `[&_[data-icon]]:size-4 [&_[data-icon]]:shrink-0`. Plain svgs without the attribute are left alone.
- `Marker`, `MarkerIcon` and `InputGroup` use `[&_svg:not([class*='size-'])]:size-4`, so an explicit `size-*` on the icon still wins.
- `Select` sets `[&_svg]:size-4` on its trigger and items. `AlertDialogMedia` uses the same `:not([class*='size-'])` pattern at `size-6`.

`data-icon` is currently a presence check. The carousel passes `data-icon="inline-start"` and the dialog passes a bare `data-icon`, and `Button` treats them the same: there is no inline-start or inline-end spacing rule yet. If I add one, the values will be `inline-start` and `inline-end`.

## Colour

Icons use `currentColor`, so they follow the text token of whatever they're in. Dimming is done with opacity, as in the select trigger's `opacity-50` caret, rather than with a separate icon colour.

## Meaning

Decorative icons next to text need nothing extra. `MarkerIcon` sets `aria-hidden="true"`. Icon-only controls always get a label: the dialog close button has `<span className="sr-only">Close</span>`, the carousel buttons say "Previous slide" and "Next slide", and `Spinner` renders with `role="status"` and `aria-label="Loading"`.

### Accessibility

Focus rings, contrast, invalid states, labels and touch targets, as the components implement them today.

- **MUST** `visible-focus`: Show focus with a `focus-visible:` ring on the `ring` token (`focus-visible:ring-1 focus-visible:ring-ring` or `focus-visible:ring-3 focus-visible:ring-ring/50`), and never remove an outline without replacing it.
- **MUST** `aa-contrast`: Keep text and its background at WCAG AA (4.5:1 for body text) in both modes by pairing each fill with its `-foreground` token.
- **MUST** `aria-invalid-for-errors`: Mark a field in error with `aria-invalid` and style it with `aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20`, not with colour applied by hand.
- **MUST** `label-every-control`: Associate every form control with a `Label` or `FieldLabel`, and give icon-only buttons an `sr-only` or `aria-label` name.
- **SHOULD** `touch-target-for-isolated-controls`: Add the site's `touch-target` utility to small isolated controls so they reach 44px on coarse pointers; in dense rows, raise the real height below `md` instead. _Overlapping expanded targets in a tight row make neighbours steal each other's taps._

## Built on Base UI

Interactive components wrap `@base-ui/react` primitives, which handle roles, keyboard support and focus management for dialogs, menus, selects, switches and tooltips. My layer is mostly styling, so most of what follows is about making states visible.

## Focus

Every focusable control uses `focus-visible:` so rings show for keyboard users and not on click. There are two weights in use: `Button` and `Input` use a 1px `ring-ring`, while `Textarea` and `Switch` use a 3px `ring-ring/50` with `border-ring`. I haven't settled on one. The site also sets `outline-ring/50` on everything as a base.

## Contrast

Neutral text is Radix `gray-11` or `gray-12`, which Radix designs for text contrast. `destructive` and `success` use step 11 in light and step 8 in dark so white text clears AA; see Colors.

## Invalid state

`Textarea` and `Switch` style `aria-invalid` with a destructive border and ring. `Input` doesn't yet, which is a gap. `FieldError` renders with `role="alert"` so the message is announced.

## Touch

On touch I follow the site's mobile rule: subtract what only makes sense with a keyboard or pointer, and make what remains comfortable. `Input` is 44px tall and 16px text below `md`. Dropdown items get `py-2.5` below `md`. `Button` has `touch-manipulation` to stop double-tap zoom, but deliberately no hit-area expansion, because menus like the editor's sit at `gap-0.5`. `Switch` extends its hit area with an `after:` pseudo-element.

## Motion

Dialogs, menus and tooltips fade and zoom with `animate-in` and `animate-out` classes keyed to `data-open` and `data-closed`. There is no `motion-reduce:` handling in the components yet. That's unfinished, and new animation should include it.

## Components

### Alert Dialog

`@cf/alert-dialog`: A modal confirmation that interrupts for a decision, usually before something destructive, and can't be dismissed by clicking outside.

Install: `bunx shadcn@latest add @cf/alert-dialog` · Docs: https://ds.connorforsyth.co/components/alert-dialog

- **MUST** `title-and-description`: Include `AlertDialogTitle` and `AlertDialogDescription` in `AlertDialogHeader`, and say in the description what will be lost.
- **MUST** `cancel-is-a-way-out`: Always render an `AlertDialogCancel`; it's the only built-in part that closes the dialog.
- **MUST** `action-closes-itself-manually`: Close the dialog yourself from `AlertDialogAction`'s `onClick` (for example with a controlled `open`), because it's a plain `Button`, not a close part.
- **SHOULD** `destructive-action-variant`: Give a delete or discard `AlertDialogAction` `variant="destructive"`, and disable both buttons while the action is pending.
- **MAY** `sm-for-two-buttons`: Use `size="sm"` on `AlertDialogContent` for a short confirmation; it centres the header and puts the two footer buttons side by side in a grid.

Base UI's `AlertDialog`. It shares the dialog's overlay, scroll cap and footer band, and adds `AlertDialogMedia` for an icon beside the title.

The thing to know is that `AlertDialogAction` doesn't close anything. That matches upstream and suits the delete flow in notes, which keeps the dialog open with both buttons disabled until the delete finishes, then closes it through `open`. If you want an action that closes on click, you have to do it yourself.

```tsx
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from "@/components/ui/alert-dialog";

<AlertDialog onOpenChange={setOpen} open={open}>
  <AlertDialogContent>
    <AlertDialogHeader>
      <AlertDialogTitle>Delete this note?</AlertDialogTitle>
      <AlertDialogDescription>This can't be undone.</AlertDialogDescription>
    </AlertDialogHeader>
    <AlertDialogFooter>
      <AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
      <AlertDialogAction disabled={pending} onClick={remove} variant="destructive">
        Delete
      </AlertDialogAction>
    </AlertDialogFooter>
  </AlertDialogContent>
</AlertDialog>
```

### Bubble

`@cf/bubble`: The coloured surface that holds one message's text in a chat, in seven variants, with an optional reactions badge.

Install: `bunx shadcn@latest add @cf/bubble` · Docs: https://ds.connorforsyth.co/components/bubble

- **MUST** `content-slot-required`: Put the text in `BubbleContent`; `Bubble` only sets the variant and alignment, and every variant paints `data-slot="bubble-content"`.
- **SHOULD** `render-for-interactive`: Make a tappable bubble with `BubbleContent`'s `render` prop (`render={<button type="button" />}` or an `<a>`), not by nesting a button inside it, so it gets the hover and focus styles.
- **SHOULD** `override-ghost-width-by-variant`: Override the ghost bubble's width with a `data-[variant=ghost]:max-w-*` class, not a bare `max-w-*`. _The ghost variant sets `max-w-full` through that selector, and an unprefixed class loses to it._
- **MAY** `destructive-for-failures`: Use `variant="destructive"` for a message that failed to send or an error from the assistant.

A `Bubble` is a wrapper that caps width at 80% and aligns itself; `BubbleContent` is the surface, rendered with Base UI's `useRender` so it can be a `div`, `button` or `a`. `tinted` derives its colour from `--primary` in OKLCH, and `BubbleReactions` pins a small badge to a corner.

In the chat, the person's turns are `outline` bubbles and replies are `ghost`, which reads as plain text with no box. Replies keep a tenth of the panel clear and stop at a comfortable line length through `data-[variant=ghost]:max-w-[min(90%,34rem)]`.

```tsx
import { Bubble, BubbleContent } from "@/components/ui/bubble";

<Bubble align="end" variant="outline">
  <BubbleContent className="whitespace-pre-wrap">{text}</BubbleContent>
</Bubble>
```

### Button

`@cf/button`: The clickable action primitive, in six variants and seven sizes, that every other control here builds its buttons from.

Install: `bunx shadcn@latest add @cf/button` · Docs: https://ds.connorforsyth.co/controls/button

- **MUST** `destructive-variant`: Use `variant="destructive"` for an action that deletes or can't be undone, rather than restyling `default` with a red class. _The destructive variant is the one signal the notes UI, menus and alert dialogs all share for danger._
- **MUST** `icon-only-needs-name`: Give every `size="icon"`, `size="icon-sm"` or `size="icon-xs"` button an accessible name, either `aria-label` or a `<span className="sr-only">` child.
- **SHOULD** `mark-icons-with-data-icon`: Put `data-icon` (or `data-icon="inline-start"`) on a child icon so the button sizes it to `size-4`, instead of sizing the icon with its own classes. _The button styles icons through `[&_[data-icon]]`, so an unmarked icon keeps Phosphor's default 1em and drifts from its neighbours._
- **SHOULD** `opt-in-tap-target`: Give an isolated small button a 44px tap target below `md` yourself, with `touch-target` or `className="size-11 md:size-8"`; don't expect the button to grow on its own. _Hit-area expansion is left out on purpose, because in tight rows like the `gap-0.5` editor menus neighbours would steal each other's edges._

Button is Base UI's `Button` with shadcn's variants on top. Every size carries `touch-manipulation`, which stops a double-tap zooming the page and costs nothing elsewhere.

What it doesn't do is grow small buttons to 44px. I tried that and it made dense rows worse, so isolated icon buttons opt in (the chat header uses `size-11 md:size-8`) and dense rows get real height below `md` from their own layout. It still uses `forwardRef`, which I haven't moved to ref-as-a-prop yet.

```tsx
import { Button } from "@/components/ui/button";
import { TrashIcon } from "@phosphor-icons/react";

<Button aria-label="Delete note" size="icon-sm" variant="ghost">
  <TrashIcon data-icon />
</Button>
```

### Carousel

`@cf/carousel`: A horizontally or vertically scrolling set of slides on Embla, with previous and next buttons and arrow-key support.

Install: `bunx shadcn@latest add @cf/carousel` · Docs: https://ds.connorforsyth.co/components/carousel

- **MUST** `items-inside-content`: Wrap every slide in `CarouselItem` inside `CarouselContent`, and keep `CarouselPrevious` and `CarouselNext` inside the `Carousel` root, which provides their context.
- **MUST** `position-the-buttons`: Place `CarouselPrevious` and `CarouselNext` yourself, because they have no positioning classes of their own.
- **SHOULD** `constrain-the-root`: Put a max width on `Carousel`, not on `CarouselContent`. _The overflow-hidden wrapper around the track is what clips, so constraining only the track lets the neighbouring slide spill into view._
- **SHOULD** `show-position`: Show where the person is (for example Slide 2 of 5) using the `setApi` callback, since the component has no dots or counter of its own.

shadcn's carousel over `embla-carousel-react`, with Phosphor arrows. The buttons disable at each end, and Left and Right arrow keys move between slides from anywhere inside the root.

It's the case-study carousel in MDX, with the buttons and a Slide n of m counter in a row above the track. A few upstream rough edges are still in it: arrow keys are Left and Right even when `orientation="vertical"`, the `reInit` listener is never removed, and it still uses `forwardRef`.

```tsx
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@/components/ui/carousel";

<Carousel className="mx-auto max-w-6xl" setApi={setApi}>
  <div className="flex items-center justify-center gap-3">
    <CarouselPrevious />
    <p>Slide {current} of {count}</p>
    <CarouselNext />
  </div>
  <CarouselContent>
    {slides.map((slide) => (
      <CarouselItem key={slide.id}>{slide.node}</CarouselItem>
    ))}
  </CarouselContent>
</Carousel>
```

### Dialog

`@cf/dialog`: A modal window for a focused task, like a short form or a media viewer, that the person can dismiss freely.

Install: `bunx shadcn@latest add @cf/dialog` · Docs: https://ds.connorforsyth.co/components/dialog

- **MUST** `title-or-label`: Give every `DialogContent` a `DialogTitle`, or an `aria-label` when the content has no heading of its own (as the chat media viewer does).
- **MUST** `alert-dialog-for-confirmation`: Use `AlertDialog`, not Dialog, to confirm a destructive or irreversible action. _An alert dialog isn't dismissed by clicking outside, so a confirmation can't be skipped by accident._
- **SHOULD** `one-close-button`: Keep the default close button (`showCloseButton` is true), or set `showCloseButton={false}` and render your own `DialogClose`; don't end up with two or none.
- **SHOULD** `footer-for-actions`: Put the actions in `DialogFooter`, primary last in source order, so they stack with the primary on top below `sm` and sit right-aligned above it.

Base UI's `Dialog`. The popup is capped at `100svh - 2rem` and scrolls, because without that anything taller than the screen got clipped with no way to reach the rest of it. It's `max-w-sm` from `sm` up, so wider content (the media viewer uses `sm:max-w-3xl`) has to say so.

`DialogFooter` bleeds to the popup's edges with a muted band, and can render its own outline Close button with `showCloseButton`. The note dialogs (add video, image alt text, section settings) all follow header, fields, footer.

```tsx
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";

<Dialog onOpenChange={setOpen} open={open}>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Add video</DialogTitle>
      <DialogDescription>Paste a link to an mp4.</DialogDescription>
    </DialogHeader>
    <DialogFooter>
      <Button type="submit">Add</Button>
    </DialogFooter>
  </DialogContent>
</Dialog>
```

### Dropdown Menu

`@cf/dropdown-menu`: A list of actions or toggles opened from a trigger, with groups, labels, submenus, checkbox and radio items.

Install: `bunx shadcn@latest add @cf/dropdown-menu` · Docs: https://ds.connorforsyth.co/controls/dropdown-menu

- **MUST** `destructive-item-variant`: Mark delete and discard items with `variant="destructive"` on `DropdownMenuItem`, and put them in their own `DropdownMenuGroup` after a `DropdownMenuSeparator`.
- **SHOULD** `confirm-irreversible`: Open an `AlertDialog` from a destructive item when the action can't be undone, rather than acting on the click.
- **MUST** `labels-inside-groups`: Put `DropdownMenuLabel` inside a `DropdownMenuGroup`; it's Base UI's group label and needs a group to belong to.
- **SHOULD** `shortcuts-hidden-on-touch`: Hide `DropdownMenuShortcut` below `md` (`hidden md:inline`) when you use it; the component doesn't do that for you. _Shortcut hints are keyboard affordances, and the site removes those on touch screens._
- **SHOULD** `select-for-values`: Use `Select` for picking a value that the trigger should then display, and keep the menu for actions and toggles.

Base UI's `Menu` under shadcn's names. Rows are 16px text with extra padding below `md`, the same as select items, so they're tappable without a separate mobile menu.

The two indicators sit on different sides and that's not fully resolved. The radio tick is on the right, where an item's state belongs, and it's a tick rather than a dot because a dot at that size reads as a stray mark. The checkbox tick is still on the left with a `pl-8` inset. Both carry `data-slot="menu-item-indicator"` so a row that shows its state another way, like the notes visibility rows with a `Switch`, can hide the tick.

```tsx
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

<DropdownMenu>
  <DropdownMenuTrigger render={<Button aria-label="Note actions" size="icon-sm" variant="ghost" />}>
    <DotsThreeIcon data-icon />
  </DropdownMenuTrigger>
  <DropdownMenuContent align="end">
    <DropdownMenuGroup>
      <DropdownMenuItem onClick={rename}>Rename</DropdownMenuItem>
    </DropdownMenuGroup>
    <DropdownMenuSeparator />
    <DropdownMenuGroup>
      <DropdownMenuItem onClick={() => setDeleting(true)} variant="destructive">
        Delete
      </DropdownMenuItem>
    </DropdownMenuGroup>
  </DropdownMenuContent>
</DropdownMenu>
```

### Editor

`@cf/editor`: The chrome for a Tiptap editor, meaning a formatting menu over the selection and a slash menu on empty lines, with no fixed toolbar.

Install: `bunx shadcn@latest add @cf/editor` · Docs: https://ds.connorforsyth.co/blocks/editor

- **MUST** `needs-starter-kit`: Use these with an editor that has StarterKit and TaskList (`@tiptap/extension-list`) loaded; `TOOLBAR_ACTIONS` call their commands.
- **SHOULD** `no-fixed-toolbar`: Put `SelectionMenu` and `SlashMenu` on the editor instead of a fixed toolbar row; formatting shows up where you're already looking.
- **SHOULD** `word-labels`: Label a new `ToolbarAction` with a word (`label: "Quote"`), not a glyph, and give it a `title` for the accessible name.
- **MUST** `keep-selection`: Give any custom menu button `onMouseDown={(event) => event.preventDefault()}`, as `ActionButtons` does, so clicking it doesn't take the selection away.
- **MAY** `extra-marks-need-extensions`: Leave the Highlight action in `TOOLBAR_ACTIONS` only if the editor has a mark named `highlight`; otherwise filter it out.

This is the part of the notes editor on connorforsyth.co that isn't about notes. There's no toolbar. Select text and `SelectionMenu` floats over it with Bold, Italic, Code, Highlight, Heading, Quote and Link. Type `/` on an empty line and `SlashMenu` offers the block actions; keep typing to filter them, and use the arrows and Enter to pick one. Both work from one list of `ToolbarAction`s, so an app adds its own (the notes editor adds images and note links) by passing a longer array.

The menus are labelled with words rather than icons, because nothing should need decoding. On a phone the selection menu goes below the selection instead of above it, since above is where iOS draws its own Copy and Look Up bar, and its buttons get taller. The row scrolls sideways instead of wrapping over what you're writing.

What isn't here is the editor itself: extensions, markdown in and out, autosave, uploads. Those differ per use, and on the site they're wired to notes. I've marked it beta because the actions assume StarterKit's command names and I haven't made that pluggable.

```tsx
"use client";
import { TaskItem, TaskList } from "@tiptap/extension-list";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { BLOCK_ACTIONS, SelectionMenu, SlashMenu } from "@/components/ui/editor";

const editor = useEditor({ extensions: [StarterKit, TaskList, TaskItem], immediatelyRender: false });

<EditorContent editor={editor} />
{editor && <SelectionMenu editor={editor} />}
{editor && <SlashMenu actions={BLOCK_ACTIONS} editor={editor} />}
```

### Field

`@cf/field`: The layout set for form fields, labels, descriptions, errors, fieldsets and groups, with vertical, horizontal and responsive orientations.

Install: `bunx shadcn@latest add @cf/field` · Docs: https://ds.connorforsyth.co/controls/field

- **MUST** `label-with-html-for`: Give every `Field` a `FieldLabel` with `htmlFor` matching the control's `id`; `FieldTitle` is a visual heading and doesn't name anything.
- **MUST** `invalid-on-both`: When a field is invalid, set `data-invalid` on `Field` and `aria-invalid` on the control together, and put the message in `FieldError`. _`data-invalid` only colours the group; `aria-invalid` is what assistive tech and the control's own styles read._
- **MUST** `responsive-needs-group`: Wrap fields in `FieldGroup` when using `orientation="responsive"`, because it switches on the `@container/field-group` query.
- **SHOULD** `fieldset-for-choice-groups`: Use `FieldSet` with a `FieldLegend` for a group of radios, checkboxes or switches that answer one question, not a `FieldGroup` with a heading.
- **SHOULD** `horizontal-for-toggles`: Use `orientation="horizontal"` for a Switch or checkbox beside its label, with the text in `FieldContent`.

Stock shadcn `base-nova`, built from plain elements rather than Base UI's `Field`, so it doesn't track validity itself. `Field` is a `role="group"` div that styles its children through `data-slot` selectors, `FieldLabel` is `Label` with extra states (a label that wraps a whole `Field` becomes a selectable card), and `FieldError` takes either children or an `errors` array, dropping duplicate messages.

This is what the contact form and the note dialogs use. `form` is an older Base UI wrapper with the same export names; don't mix the two in one file. One quirk carried over from upstream: `FieldTitle` also sets `data-slot="field-label"`.

```tsx
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";

<Field data-invalid={invalid || undefined}>
  <FieldLabel htmlFor="email">Email</FieldLabel>
  <Input aria-invalid={invalid} id="email" type="email" />
  <FieldDescription>Only used to reply to you.</FieldDescription>
</Field>
```

### Form

`@cf/form`: A thin wrapper over Base UI's `Field` (root, label, description, error) for fields that want Base UI's own validation state.

Install: `bunx shadcn@latest add @cf/form` · Docs: https://ds.connorforsyth.co/controls/form

- **MUST** `dont-mix-with-field`: Don't import `Field`, `FieldLabel`, `FieldDescription` or `FieldError` from both `form` and `field` in the same file; they share names but are different components.
- **SHOULD** `prefer-field-for-layout`: Reach for the `field` component for new forms, and use `form` only where you want Base UI's `Field.Root` validity handling.
- **MUST** `explicit-html-for`: Set `htmlFor` on `FieldLabel` and a matching `id` on the input, because `Input` is a plain `<input>` and not Base UI's `Field.Control`, so Base UI can't wire the label up itself.
- **SHOULD** `error-needs-validity`: Know that this `FieldError` renders from Base UI's validity state, which a plain `Input` doesn't report; show custom error text yourself, as the access form does, or give `FieldError` a `match` to force it. Passing `invalid` to `Field` does not show it.

This is the older of the two field sets, and it overlaps with `field`. `Field` here is Base UI's `Field.Root` exported as is; the label, description and error parts only add type styles.

Only the access form uses it, and even there the input is the plain `Input`, so the form sets `htmlFor`, `aria-invalid` and `data-invalid` by hand and writes its own error text. I'm keeping it while that form is the only caller. The honest direction is to fold it into `field` or wire `Input` up as a `Field.Control`, and until one of those happens I've marked it beta.

```tsx
import { Field, FieldLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";

<Field data-invalid={Boolean(error)}>
  <FieldLabel htmlFor="access-code">Access code</FieldLabel>
  <Input aria-invalid={Boolean(error)} id="access-code" type="password" />
</Field>
```

### Input

`@cf/input`: A single-line text field, 44px tall and 16px type on touch screens, shrinking to 36px and 14px at `md`.

Install: `bunx shadcn@latest add @cf/input` · Docs: https://ds.connorforsyth.co/controls/input

- **MUST** `label-every-input`: Pair every Input with a `FieldLabel` or `Label` whose `htmlFor` matches the input's `id`, or give it an `aria-label` when there's no visible label.
- **MUST** `keep-16px-on-touch`: Don't override the text size below `md` to anything under 16px; if you change it, keep the `text-base md:text-sm` pairing. _iOS zooms the page into any field set smaller than 16px and never zooms back out._
- **MUST** `use-group-control-in-groups`: Inside an `InputGroup`, use `InputGroupInput` instead of Input. _The group's focus and invalid rings key off `data-slot="input-group-control"`, which only InputGroupInput sets._
- **SHOULD** `invalid-needs-your-own-styles`: When a value is invalid, set `aria-invalid` on the input and `data-invalid` on its `Field`, and know that Input has no `aria-invalid` styles of its own yet.

A plain `<input>`, not a Base UI part. The 16px and 44px sizing on touch screens is the whole point of it: iOS zooms into anything smaller than 16px and a 36px field is awkward to hit, so both shrink back to the desktop size at `md`.

It's older than the rest of the set and it shows. It uses `forwardRef`, `rounded-md` and a `ring-1` focus ring, where Textarea uses `rounded-lg`, a `ring-3` focus ring and `aria-invalid` styles. The access form paints its own error border because of that gap. Bringing Input in line with Textarea is on the list.

```tsx
import { Input } from "@/components/ui/input";

<Input id="email" type="email" autoComplete="email" />
```

### Input Group

`@cf/input-group`: A bordered shell that joins an input or textarea with addons, buttons and text, sharing one focus ring.

Install: `bunx shadcn@latest add @cf/input-group` · Docs: https://ds.connorforsyth.co/controls/input-group

- **MUST** `use-group-controls`: Put `InputGroupInput` or `InputGroupTextarea` inside the group, never a bare `Input` or `Textarea`. _The shared focus and invalid rings watch for `data-slot="input-group-control"`, which only the group's own controls set._
- **MUST** `buttons-in-addons`: Use `InputGroupButton` for buttons inside an `InputGroupAddon`, and pass `type="submit"` explicitly for a send button, since it defaults to `type="button"`.
- **MUST** `icon-buttons-named`: Give every icon-only `InputGroupButton` (`size="icon-xs"` or `size="icon-sm"`) an `aria-label`.
- **SHOULD** `block-align-for-toolbars`: Use `align="block-start"` or `align="block-end"` on `InputGroupAddon` for rows above or below a textarea, and the `inline-*` values for prefixes and suffixes on a single-line input.

Stock shadcn `base-nova`. Clicking an addon focuses the control, unless the click lands on a button. The chat composer is the one real use: a quote strip in a `block-start` addon, the `InputGroupTextarea`, and send and stop buttons in a `block-end` addon.

The group is `h-8`, which doesn't agree with `InputGroupInput`: it inherits Input's `h-11` below `md`, so a single-line group will overflow on touch screens until one of them gives. I've only used it with a textarea so far, where the group goes `h-auto`. Icons also follow a different convention here: `InputGroupButton` sizes bare `svg` children rather than `data-icon` ones.

```tsx
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupTextarea,
} from "@/components/ui/input-group";

<InputGroup>
  <InputGroupTextarea aria-label="Message" rows={1} />
  <InputGroupAddon align="block-end">
    <InputGroupButton aria-label="Send" className="ml-auto" size="icon-xs" type="submit">
      <ArrowUpIcon />
    </InputGroupButton>
  </InputGroupAddon>
</InputGroup>
```

### Kbd

`@cf/kbd`: A keycap for showing a keyboard shortcut, usually inside a tooltip or beside a menu row.

Install: `bunx shadcn@latest add @cf/kbd` · Docs: https://ds.connorforsyth.co/controls/kbd

- **MUST** `hide-below-md`: Hide Kbd below `md` (wrap it in `hidden md:flex`, or show it only inside a `TooltipContent`, which touch never opens); don't adapt it for touch. _A keycap hint is a keyboard-and-pointer affordance, and on a phone it's noise._
- **SHOULD** `declare-the-shortcut`: Put `aria-keyshortcuts` on the control the shortcut triggers; Kbd is visual only.
- **SHOULD** `one-key-per-cap`: Render one Kbd per key for chords, rather than one Kbd holding `⌘K`.
- **SHOULD** `no-background-override-in-tooltips`: Leave Kbd's background alone inside `TooltipContent`; it switches to `bg-background/20` there by itself.

A `<kbd>` with `data-slot="kbd"`. Inside a tooltip the ground is the foreground colour, so the key lifts off it with `bg-background/20` instead of the usual `bg-foreground/10`, and the tooltip tightens its own right padding when it contains one.

Following the site's mobile rule, I remove shortcut hints below `md` rather than finding a touch equivalent. The notes sidebar wraps its keys in `hidden md:flex`, and the sidebar toggle only shows its `[` inside a tooltip.

```tsx
import { Kbd } from "@/components/ui/kbd";

<TooltipContent side="right">
  Hide the sidebar
  <Kbd>[</Kbd>
</TooltipContent>
```

### Label

`@cf/label`: A styled `<label>` for naming a form control when you're not using Field.

Install: `bunx shadcn@latest add @cf/label` · Docs: https://ds.connorforsyth.co/controls/label

- **MUST** `associate-with-control`: Set `htmlFor` to the control's `id`, or wrap the control inside the Label; a Label with neither names nothing. _The component just forwards `htmlFor`, so the association is entirely the caller's job._
- **SHOULD** `prefer-field-label`: Inside a `Field`, use `FieldLabel` instead of Label, so the label picks up the field's disabled and checked states.
- **MAY** `peer-disabled-needs-order`: Rely on the built-in `peer-disabled` dimming only when the control comes before the Label in the DOM and is a native input carrying the `peer` class; `:disabled` never matches Base UI's `Switch`, which renders a `span`.

A plain `<label>` with type styles. It exists mostly because `FieldLabel` is built on it, and I reach for `FieldLabel` in almost every case. Nothing on the site imports Label directly.

The `peer-disabled:` classes only work for a control that is an earlier sibling marked `peer`. That rules out both of the controls people reach for: `Input` isn't marked `peer`, and `Switch` renders a `span`, which never matches `:disabled`. Dim the Label yourself when its control is disabled.

```tsx
import { Label } from "@/components/ui/label";

<Label htmlFor="name">Name</Label>
<input id="name" />
```

### Marker

`@cf/marker`: A quiet inline line of muted text, optionally with an icon, a bottom border or rules either side, for events and dividers in a thread or list.

Install: `bunx shadcn@latest add @cf/marker` · Docs: https://ds.connorforsyth.co/components/marker

- **MUST** `text-in-content`: Put the text in `MarkerContent`; with `variant="separator"` it's what stays centred between the two rules.
- **SHOULD** `separator-for-events`: Use `variant="separator"` for a thread event like Connor joined the chat, and `variant="border"` for a heading over a list.
- **SHOULD** `icons-in-marker-icon`: Wrap a leading icon in `MarkerIcon`, which hides it from assistive tech; if the icon carries meaning, say it in `MarkerContent` too.
- **SHOULD** `not-for-actions`: Don't make a Marker the only way to do something; it's styled as muted secondary text, so any link inside should be supplementary.

`Marker` is a `useRender` element, so it can render as a `li` or `p` when the context wants one. The `separator` variant draws rules with `before:` and `after:`, which keeps the markup to the text alone.

The chat uses it for the handover to me ("Connor joined the chat") and for notices that sit between turns, both with the separator variant.

```tsx
import { Marker, MarkerContent } from "@/components/ui/marker";

<Marker variant="separator">
  <MarkerContent>Connor joined the chat</MarkerContent>
</Marker>
```

### Message

`@cf/message`: The row in a chat thread that holds one turn, aligned to the start or end, with optional avatar, header and footer around its bubbles.

Install: `bunx shadcn@latest add @cf/message` · Docs: https://ds.connorforsyth.co/components/message

- **MUST** `align-by-speaker`: Set `align="end"` on `Message` for the person's own turns and leave `align="start"` for everyone else, and set the same `align` on the `Bubble` inside.
- **MUST** `content-wraps-bubbles`: Put bubbles, quotes and attachments inside `MessageContent`, not directly in `Message`, so they stack and follow the row's alignment.
- **SHOULD** `footer-for-receipts`: Put timestamps and delivery receipts in `MessageFooter`, which right-aligns itself on `end` messages and drops its padding next to a ghost bubble.
- **SHOULD** `scroller-item-outside`: Wrap each Message in a `MessageScrollerItem` when it's inside a `MessageScroller`, rather than the other way round.

Layout only: no state, no Base UI. `Message` is a flex row that reverses for `align="end"`, and `MessageContent` pushes its slotted children to the end on those rows. `MessageAvatar` lifts itself above a footer when there is one.

In the chat panel every turn is `Message` then `MessageContent` then a `Bubble`, with a `MessageFooter` for the Sent and Delivered receipt. I don't use `MessageAvatar` or `MessageHeader` there yet.

```tsx
import { Message, MessageContent, MessageFooter } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";

<Message align="end">
  <MessageContent>
    <Bubble align="end" variant="outline">
      <BubbleContent>{text}</BubbleContent>
    </Bubble>
    <MessageFooter>Delivered</MessageFooter>
  </MessageContent>
</Message>
```

### Message Scroller

`@cf/message-scroller`: The scrolling container for a chat thread, which follows new messages, anchors a turn to the top and offers a jump-to-end button.

Install: `bunx shadcn@latest add @cf/message-scroller` · Docs: https://ds.connorforsyth.co/components/message-scroller

- **MUST** `full-structure`: Nest `MessageScrollerProvider` > `MessageScroller` > `MessageScrollerViewport` > `MessageScrollerContent` > `MessageScrollerItem`, with `MessageScrollerButton` inside `MessageScroller` but outside the viewport.
- **MUST** `item-per-message`: Give every `MessageScrollerItem` a stable `messageId`, including placeholder rows like a pending reply.
- **SHOULD** `anchor-sparingly`: Set `scrollAnchor` only on the turn that should pin to the top when it arrives (the person's own message), and leave it `false` on everything else.
- **MUST** `bounded-height`: Give `MessageScroller` a parent with a bounded height (for example `flex-1` in a flex column), because it fills its container with `size-full min-h-0`.

Styling over `@shadcn/react/message-scroller`, which does the scroll work. Items use `content-visibility: auto` with a 10rem intrinsic size, so long threads stay cheap to render, and the viewport hides itself while a scroll is pending so there's no jump on load.

`MessageScrollerButton` slides in from the edge when you're away from the end, and flips its arrow for `direction="start"`. The chat panel passes `autoScroll` and a `scrollMargin` for the header to the provider. I've marked it beta because the primitive underneath is new and I'm the only user.

```tsx
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from "@/components/ui/message-scroller";

<MessageScrollerProvider autoScroll>
  <MessageScroller className="flex-1">
    <MessageScrollerViewport>
      <MessageScrollerContent>
        {messages.map((m) => (
          <MessageScrollerItem key={m.id} messageId={m.id} scrollAnchor={m.role === "user"}>
            <ChatRow message={m} />
          </MessageScrollerItem>
        ))}
      </MessageScrollerContent>
    </MessageScrollerViewport>
    <MessageScrollerButton />
  </MessageScroller>
</MessageScrollerProvider>
```

### Select

`@cf/select`: A single choice from a short, known list, opened from a trigger that shows the current value.

Install: `bunx shadcn@latest add @cf/select` · Docs: https://ds.connorforsyth.co/controls/select

- **MUST** `name-the-trigger`: Give `SelectTrigger` an accessible name with a `FieldLabel` or an `aria-label` when there's no visible label.
- **MUST** `value-inside-trigger`: Render `SelectValue` inside `SelectTrigger`, and pass `items` to `Select` so the trigger can show the chosen item's label instead of its raw value.
- **MUST** `labels-inside-groups`: Put `SelectLabel` inside a `SelectGroup`; it's Base UI's group label and has nothing to label on its own.
- **SHOULD** `menu-for-actions`: Use `DropdownMenu` when the options are actions, and Select only when they are values.

Base UI's `Select`. The popup lines the selected item up with the trigger by default (`alignItemWithTrigger`), and items get the same touch treatment as menu rows: 16px text and taller padding below `md`.

The note editor's section picker is the real use. It restyles the trigger into a small borderless `h-7` control, which is fine, but the trigger has no fixed height of its own, so anything you put beside it needs checking.

```tsx
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";

<Select items={items} onValueChange={setValue} value={value}>
  <SelectTrigger aria-label="Section">
    <SelectValue />
  </SelectTrigger>
  <SelectContent>
    {items.map((item) => (
      <SelectItem key={item.value} value={item.value}>
        {item.label}
      </SelectItem>
    ))}
  </SelectContent>
</Select>
```

### Separator

`@cf/separator`: A one-pixel rule between groups of content, horizontal or vertical.

Install: `bunx shadcn@latest add @cf/separator` · Docs: https://ds.connorforsyth.co/components/separator

- **MUST** `set-vertical-orientation`: Pass `orientation="vertical"` for a divider inside a flex row; the default is `horizontal`, which renders a full-width 1px line.
- **SHOULD** `use-the-local-separator`: Inside menus and selects use `DropdownMenuSeparator` or `SelectSeparator`, and in forms use `FieldSeparator`, instead of a bare Separator. _Those carry the right margins and keep the separator inside the primitive's own list semantics._
- **SHOULD** `labelled-dividers-use-marker`: For a divider with text in the middle, like a date or an event in a thread, use `Marker` with `variant="separator"`, not two Separators around a span.

Base UI's `Separator`, which renders `role="separator"` and sizes itself from `data-horizontal` or `data-vertical`. A vertical one uses `self-stretch`, so it needs a flex parent to have any height.

The site doesn't use it directly. It shows up inside `FieldSeparator`, and the menu and select components have their own.

```tsx
import { Separator } from "@/components/ui/separator";

<div className="flex h-5 items-center gap-2">
  <span>Notes</span>
  <Separator orientation="vertical" />
  <span>Photos</span>
</div>
```

### Sidebar

`@cf/sidebar`: The row, row menu and open/close icon the notes sidebar is built from, for any long navigable list with actions on each item.

Install: `bunx shadcn@latest add @cf/sidebar` · Docs: https://ds.connorforsyth.co/blocks/sidebar

- **MUST** `row-active-state`: Mark the current row with `active` on `SidebarRow` (or `sidebarRowVariants({ active })`) and set `aria-current="page"` on it yourself when it's a link.
- **SHOULD** `links-via-render`: Render navigation rows as links with `render={<Link href="…" />}` rather than wrapping a button in a link.
- **MUST** `menu-items-once`: Pass a row's actions to `SidebarMenu` once as `items`; it shows the same items on right-click, long press and the hover ⋯ button, so don't build a second menu.
- **MAY** `icons-variant`: Pass `icons: false` to `sidebarRowVariants` when a row's icons size themselves, like a folder caret; the default sizes every child `svg` to 16px and mutes it.
- **SHOULD** `no-dots-on-touch`: Leave the ⋯ button hidden below `md`; on a phone the long press opens the same menu. _A column of dots down a list competes with the names it's there to act on._

These are the parts of the notes sidebar on connorforsyth.co that aren't about notes. The folder tree, search, drafts and drag-to-file are still in the app, built from these; I haven't pulled them out because every one of them knows what a note is. So this is a block of pieces rather than a finished sidebar you drop in, and I've marked it beta for that reason.

`SidebarRow` is 44px tall below `md`, where it's tapped, and 28px from `md`, where a few hundred rows have to fit. `SidebarMenu` wraps a row and gives it a context menu and a hover ⋯ button with the same items, and it turns off iOS's link preview so a long press only does one thing. `SidebarStateIcon` goes on the button that opens and closes the pane: the pane retracts into its edge while the window outline stays still. It was a framer-motion component on the site; here it's CSS transitions, so it costs nothing to install, and it respects reduced motion.

```tsx
import Link from "next/link";
import { SidebarMenu, SidebarRow } from "@/components/ui/sidebar";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";

<SidebarMenu items={<DropdownMenuItem>Rename</DropdownMenuItem>} label="Note actions">
  <SidebarRow active aria-current="page" render={<Link href="/notes/inbox" />}>
    Inbox
  </SidebarRow>
</SidebarMenu>
```

### Spinner

`@cf/spinner`: A spinning Phosphor circle-notch that announces "Loading" for anything in progress.

Install: `bunx shadcn@latest add @cf/spinner` · Docs: https://ds.connorforsyth.co/components/spinner

- **SHOULD** `data-icon-inside-button`: Inside a `Button`, give the Spinner `data-icon="inline-start"` so the button sizes it like any other icon.
- **SHOULD** `no-duplicate-loading-text`: Don't add a second visually hidden Loading label next to it; the Spinner already has `role="status"` and `aria-label="Loading"`. Override `aria-label` when something more specific is true, like Saving.
- **SHOULD** `disable-while-pending`: When a Spinner replaces a button's label, also set `disabled` on the button so it can't be submitted twice.
- **MAY** `avoid-layout-shift`: Position a status Spinner with `className="absolute"` over a reserved space when it appears and disappears next to text, as the notes save indicator does.

`CircleNotchIcon` from Phosphor with `animate-spin`, `size-4`, `role="status"` and an `aria-label` of Loading. Because it takes the icon's props, `size`, `weight` and `className` all work.

In buttons it takes the place of the label while a request is in flight (the access form, the owner login). In the note editors it sits absolutely positioned where the save state shows, so text beside it doesn't jump.

```tsx
import { Spinner } from "@/components/ui/spinner";

<Button disabled={pending} type="submit">
  {pending ? <Spinner data-icon="inline-start" /> : "Sign in"}
</Button>
```

### Switch

`@cf/switch`: An on/off toggle for a setting that takes effect straight away, in `default` and `sm` sizes.

Install: `bunx shadcn@latest add @cf/switch` · Docs: https://ds.connorforsyth.co/controls/switch

- **MUST** `name-the-switch`: Give an interactive Switch an accessible name, through a `FieldLabel` or an `aria-label`.
- **MUST** `display-only-in-rows`: When a whole row is the control (for example a `DropdownMenuCheckboxItem`), render the Switch with `aria-hidden`, `tabIndex={-1}` and `pointer-events-none`, and hide the row's tick with `[&_[data-slot=menu-item-indicator]]:hidden`. _Otherwise there are two focusable controls and two state indicators for one setting._
- **SHOULD** `small-in-dense-rows`: Use `size="sm"` inside menus and other dense rows, and `default` in forms.
- **SHOULD** `keep-foreground-colours`: Don't recolour the off state with `--input`; if you tint the on state, change only `data-checked:bg-*`. _Track and thumb use foreground and background so off and on stay distinguishable in both themes; the stock `--input` colours left a light thumb on a light track in dark mode._

Base UI's `Switch` with the stock colours replaced. Track and thumb are built from foreground and background only, because the `--input` colours shadcn ships left a light thumb on a light track in dark mode.

The track carries an `after:` pseudo-element that pads the hit area by 12px sideways and 8px vertically, so the 32px switch is easier to hit without changing its size. In the notes menus the Switch is only a display: the menu row is the control, and the Switch is hidden from assistive tech and pointer events.

```tsx
import { Switch } from "@/components/ui/switch";

<Switch aria-label="Confidential" checked={on} onCheckedChange={setOn} />
```

### Textarea

`@cf/textarea`: A multi-line text field that grows with its content, with focus and invalid states built in.

Install: `bunx shadcn@latest add @cf/textarea` · Docs: https://ds.connorforsyth.co/controls/textarea

- **MUST** `label-every-textarea`: Pair every Textarea with a `FieldLabel` or `Label` via `htmlFor` and `id`, or give it an `aria-label` when there's no visible label.
- **MUST** `keep-16px-on-touch`: Keep the `text-base md:text-sm` pairing if you override the text size; never go under 16px below `md`. _iOS zooms into any field smaller than 16px and doesn't zoom back out._
- **SHOULD** `cap-the-growth`: Set a `max-h-*` class when the textarea sits in a fixed layout, because `field-sizing-content` lets it grow without limit.
- **MUST** `use-group-textarea-in-groups`: Inside an `InputGroup`, use `InputGroupTextarea` instead of Textarea.

Stock shadcn `base-nova`. `field-sizing-content` means it grows with what you type, with a `min-h-16` floor and no ceiling, so give it a `max-h-*` wherever height matters. The chat composer uses `max-h-40`.

It styles `aria-invalid` itself (destructive border and ring), which Input doesn't yet. Nothing on the site uses Textarea on its own right now: the only multi-line field is the chat composer, which goes through `InputGroupTextarea`.

```tsx
import { Textarea } from "@/components/ui/textarea";

<Textarea aria-label="Message" className="max-h-40" rows={1} />
```

### Tooltip

`@cf/tooltip`: A short text label that appears on hover or focus, mainly to name icon-only buttons and show their shortcut.

Install: `bunx shadcn@latest add @cf/tooltip` · Docs: https://ds.connorforsyth.co/controls/tooltip

- **MUST** `no-interactive-content`: Never put links, buttons or anything focusable inside `TooltipContent`; use a popover or menu for that. _A tooltip closes as soon as the pointer or focus leaves the trigger, so its content can't be reached._
- **MUST** `not-the-only-name`: Keep an `aria-label` on an icon-only trigger even when a Tooltip shows the same words. _Touch devices never show the tooltip, so it can't be the control's only name._
- **SHOULD** `render-the-trigger`: Pass the actual button to `TooltipTrigger` through `render` rather than nesting a button inside it.
- **SHOULD** `wrap-in-provider`: Wrap each area of tooltips in one `TooltipProvider`, so moving between triggers doesn't wait out the delay each time.

Base UI's `Tooltip`. The provider defaults `delay` to 0, the popup is the foreground colour with background text, and it makes room for a `Kbd` inside it.

I treat tooltips as a pointer affordance: a touch device never sees one, so nothing important should live only in a tooltip, and I don't add a touch equivalent. The chat header and notes sidebar use them to label icon buttons that already carry an `aria-label`.

```tsx
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";

<Tooltip>
  <TooltipTrigger
    render={<Button aria-label="Clear chat" size="icon-sm" variant="ghost" />}
  >
    <TrashIcon data-icon />
  </TooltipTrigger>
  <TooltipContent>Clear chat</TooltipContent>
</Tooltip>
```
