@sableui/react 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/AGENTS.md +10 -4
  2. package/CHANGELOG.md +199 -0
  3. package/DESIGN.md +3 -3
  4. package/README.md +15 -1
  5. package/bin/sable.mjs +254 -10
  6. package/dist/components/app-header/app-header.meta.js +2 -1
  7. package/dist/components/button/button.js +3 -0
  8. package/dist/components/button/button.meta.js +1 -1
  9. package/dist/components/button/button.variants.js +1 -1
  10. package/dist/components/button-group/button-group.js +17 -0
  11. package/dist/components/button-group/button-group.meta.js +72 -0
  12. package/dist/components/button-group/index.js +7 -0
  13. package/dist/components/caret-menu/caret-menu.js +221 -0
  14. package/dist/components/caret-menu/caret-menu.meta.js +119 -0
  15. package/dist/components/caret-menu/index.js +8 -0
  16. package/dist/components/chat-composer/chat-composer.js +30 -7
  17. package/dist/components/chat-composer/chat-composer.meta.js +27 -8
  18. package/dist/components/combobox/combobox.meta.js +26 -0
  19. package/dist/components/input-group/index.js +7 -0
  20. package/dist/components/input-group/input-group.js +66 -0
  21. package/dist/components/input-group/input-group.meta.js +106 -0
  22. package/dist/components/menu/index.js +2 -0
  23. package/dist/components/menu/menu.js +51 -9
  24. package/dist/components/menu/menu.meta.js +11 -2
  25. package/dist/components/menubar/index.js +8 -0
  26. package/dist/components/menubar/menubar.js +42 -0
  27. package/dist/components/menubar/menubar.meta.js +99 -0
  28. package/dist/components/message-scroller/message-scroller.meta.js +4 -0
  29. package/dist/components/navigation-menu/index.js +19 -0
  30. package/dist/components/navigation-menu/navigation-menu.js +108 -0
  31. package/dist/components/navigation-menu/navigation-menu.meta.js +141 -0
  32. package/dist/components/overflow-menu/overflow-menu.meta.js +13 -1
  33. package/dist/components/pagination/pagination.js +11 -5
  34. package/dist/components/pagination/pagination.meta.js +21 -3
  35. package/dist/components/scroller/scroller.js +8 -4
  36. package/dist/components/scroller/scroller.meta.js +6 -0
  37. package/dist/components/sheet/sheet.js +82 -9
  38. package/dist/components/sheet/sheet.meta.js +41 -4
  39. package/dist/components/sheet/sheet.variants.js +8 -1
  40. package/dist/components/sidebar/sidebar.js +13 -2
  41. package/dist/components/sidebar/sidebar.meta.js +10 -0
  42. package/dist/components/streaming-text/streaming-text.meta.js +5 -1
  43. package/dist/file-hashes.json +49 -33
  44. package/dist/icon-sets/reicon.js +2 -0
  45. package/dist/index.d.ts +660 -164
  46. package/dist/index.js +12 -1
  47. package/dist/lib/palette.js +46 -6
  48. package/dist/lib/taxonomy.js +5 -0
  49. package/dist/lib/use-caret-trigger.js +187 -0
  50. package/dist/lib/use-copy.js +6 -1
  51. package/dist/lib/use-smooth-wheel.js +164 -0
  52. package/dist/palette.css +243 -165
  53. package/dist/patterns/chat-panel.js +1 -1
  54. package/dist/patterns/chat-panel.meta.js +1 -0
  55. package/dist/styles.css +25 -0
  56. package/llms/components/app-header.md +1 -1
  57. package/llms/components/button-group.md +55 -0
  58. package/llms/components/button.md +1 -1
  59. package/llms/components/caret-menu.md +100 -0
  60. package/llms/components/chat-composer.md +24 -7
  61. package/llms/components/combobox.md +12 -0
  62. package/llms/components/input-group.md +78 -0
  63. package/llms/components/menu.md +16 -1
  64. package/llms/components/menubar.md +110 -0
  65. package/llms/components/message-scroller.md +1 -0
  66. package/llms/components/navigation-menu.md +113 -0
  67. package/llms/components/overflow-menu.md +26 -1
  68. package/llms/components/pagination.md +15 -3
  69. package/llms/components/scroller.md +1 -0
  70. package/llms/components/sheet.md +18 -3
  71. package/llms/components/sidebar.md +2 -0
  72. package/llms/components/streaming-text.md +2 -1
  73. package/llms/patterns/chat-panel.md +4 -1
  74. package/llms/reference/token-intent.md +1 -1
  75. package/llms/tasks/building-a-chat.md +158 -0
  76. package/llms/tasks/building-with-sable.md +4 -1
  77. package/llms/tasks/converting-an-app.md +7 -0
  78. package/llms/tasks/extending-a-component.md +170 -0
  79. package/llms/tasks/getting-started.md +22 -4
  80. package/llms/tasks/migrating-from-shadcn.md +351 -0
  81. package/llms-full.txt +1322 -27
  82. package/llms.txt +13 -4
  83. package/package.json +3 -2
  84. package/registry.json +979 -23
  85. package/scripts/lint-rules.mjs +35 -0
  86. package/scripts/wiring.mjs +541 -0
  87. package/skills/entry/SKILL.md +3 -2
  88. package/skills/sable/SKILL.md +26 -5
  89. package/targets/ios.json +45 -0
  90. package/dist/lib/use-wheel-to-horizontal.js +0 -65
@@ -0,0 +1,170 @@
1
+ # Task: extending a Sable component
2
+
3
+ You need a component to do something it doesn't do out of the box: be a link,
4
+ carry an extra control, react to a key, suggest names as someone types. Before
5
+ restyling it from the outside or copying it into the app, go through the
6
+ extension points below, roughly in this order. Most needs are one of them, and
7
+ each keeps the component's states, density, focus and keyboard contract, which a
8
+ copy loses the day it is made.
9
+
10
+ The split to keep in mind: **behaviour** comes from Base UI and Sable, **look**
11
+ comes from variants and tokens, and **what is yours** (content, data, what an
12
+ action does) goes in through props and slots.
13
+
14
+ ## 1. Compose an element: `render`
15
+
16
+ Every component that renders an element takes `render`, and keeps its props,
17
+ ref and state on the element you give it. This is how a Sable control becomes a
18
+ link, or any router's link:
19
+
20
+ ```tsx
21
+ <Button render={<a href="/pricing" />}>See pricing</Button>
22
+ <Card render={<Link to="/projects/42" />}>…</Card>
23
+ <Button variant="ghost" render={<Link to="/inbox" />} iconStart={<Icon name="inbox" />}>Inbox</Button>
24
+ ```
25
+
26
+ A link that looks like a button is `<Button render={<a … />}>`, never an `<a>`
27
+ wearing button classes (`sable-lint` flags that as `lookalike-control`).
28
+
29
+ ## 2. Fill a slot
30
+
31
+ Slots take content you own. The component lays it out and keeps it aligned with
32
+ its own parts. Check `sable component <Name> --props` for the full list.
33
+
34
+ | Component | Slots |
35
+ |---|---|
36
+ | Button | `iconStart`, `iconEnd`, `badge` |
37
+ | ChatComposer | `leading`, `actions`, `attachments`, `footer` (a node, or a function of the send key in effect) |
38
+ | Message | `avatar`, `header`, `footer` |
39
+ | PageHeader | `actions`, `back` |
40
+ | MenuItem, MenuLinkItem, CaretMenuItem | `icon`, `description` |
41
+
42
+ `className` on a component is for layout (margins, width, alignment). It is not
43
+ a slot for a new look; see section 5.
44
+
45
+ ## 3. Hold the state, reach the element
46
+
47
+ Controlled props (`value` + `onValueChange`, `open` + `onOpenChange`) let the
48
+ app own the state and change it from anywhere: clear a draft after a server
49
+ confirms the send, open a menu from a shortcut.
50
+
51
+ Refs reach the element itself. ChatComposer's `fieldRef` is the textarea, which
52
+ is enough to focus it, read the caret, or **listen to it**. That last one is an
53
+ extension point in its own right: a key handler on the field that handles a key
54
+ and calls `event.preventDefault()` owns that key, and ChatComposer does not also
55
+ act on it. That is how the suggestion list below takes Enter without the message
56
+ being sent.
57
+
58
+ ## 4. Add behaviour on top: mentions and slash commands
59
+
60
+ The worked example. Mentions and slash commands are behaviour that sits on a
61
+ text field, not a new kind of field, so Sable ships the parts every app gets
62
+ wrong and leaves the rest to the app:
63
+
64
+ - **Sable owns:** noticing a trigger character at the caret (`@`, `/`, `#`), on
65
+ a word boundary and after IME composition; placing the list at the caret;
66
+ ↑/↓/Enter/Tab/Escape while focus stays in the field; the ARIA that lets a
67
+ screen reader follow along.
68
+ - **The app owns:** the items (filter or fetch them, debounced, from the query),
69
+ the loading and empty states, and what choosing one does.
70
+
71
+ ```tsx
72
+ const trigger = useCaretTrigger({
73
+ triggers: [{ char: "@", allowSpaces: true }, { char: "/", atStart: true }],
74
+ })
75
+ const q = trigger.match?.query.toLowerCase() ?? ""
76
+ const commands = trigger.match?.trigger.char === "/"
77
+
78
+ <ChatComposer fieldRef={trigger.fieldRef} onSend={send} />
79
+ <CaretMenu trigger={trigger} label={commands ? "Commands" : "People"} status={loading ? "Searching…" : undefined}>
80
+ {commands
81
+ ? slashCommands.filter((c) => c.name.startsWith(q)).map((c) => (
82
+ <CaretMenuItem key={c.name} description={c.hint} onSelect={() => { c.run(); trigger.replace("") }}>
83
+ /{c.name}
84
+ </CaretMenuItem>
85
+ ))
86
+ : people.filter((p) => p.name.toLowerCase().includes(q)).map((p) => (
87
+ <CaretMenuItem key={p.id} onSelect={() => trigger.replace(`@${p.name} `)}>{p.name}</CaretMenuItem>
88
+ ))}
89
+ </CaretMenu>
90
+ ```
91
+
92
+ A mention is inserted as text with a trailing space; a command runs and
93
+ `replace("")` clears its `/query`. The same hook works on `Textarea` and
94
+ `Input` through `ref={trigger.fieldRef}`. `sable component CaretMenu` has the
95
+ props and the accessibility notes.
96
+
97
+ The pattern generalises. To add your own behaviour to a Sable field, attach to
98
+ it through its ref, read and write through the field's own input path (the hook's
99
+ `replace()` does, so controlled values and undo keep working), and take a key by
100
+ handling it first and calling `preventDefault()`.
101
+
102
+ ### Mentions as chips are a different job
103
+
104
+ `useCaretTrigger` keeps the value a plain string, because that is what a
105
+ textarea holds. Showing a mention as a token *inside the field* needs a
106
+ rich-text (contenteditable) editor such as TipTap or Lexical, with its own value
107
+ model. Sable does not own an editor. Two ways to get chips today:
108
+
109
+ - Keep text mentions in the field and render them as chips where the message is
110
+ shown: parse `@Name` against your people list when you render the Message.
111
+ - Use an editor in place of the composer's field and keep Sable's controls around
112
+ it. A ChatComposer slot for a custom editor is a possible later addition; it
113
+ doesn't exist yet, so don't write code that assumes it.
114
+
115
+ ## 5. Change the look: tokens and presets, not overrides
116
+
117
+ A component's fill, edge, corners, ink and interaction states come from its
118
+ variants and the theme's tokens. To change how it looks:
119
+
120
+ - **Everywhere:** override tokens on `:root` (or any subtree) after Sable's CSS:
121
+ radius, density, accent, type scale, motion. `sable token --list` lists them.
122
+ - **As a reusable direction:** a preset (`sable docs creating-a-preset`), compiled
123
+ to scoped CSS with `sable preset --css <path>`.
124
+ - **One component, a look it lacks:** that is a missing variant. Report it (see
125
+ section 8); don't paint it on with `className`. `component-restyle`,
126
+ `destructive-item-ink` and `table-edge-padding` flag the common overrides,
127
+ because a class that repaints a part fights the part's own hover, focus and
128
+ disabled states.
129
+
130
+ Parts carry `data-slot` names (`chat-composer-attachments`,
131
+ `menu-item-description`, `button-group`). They are good test selectors and fine
132
+ for layout from a parent. They are not a way around the rule above, and a slot
133
+ name a component's docs don't mention may change between versions.
134
+
135
+ ## 6. Add icons: `composeIconSets`
136
+
137
+ `<IconProvider set={composeIconSets(appExtras, reiconIconSet)}>` adds your own
138
+ glyphs alongside Reicon's (list the added names in `.sable/icons.json` so
139
+ `sable grade` accepts them). Components keep using semantic names.
140
+
141
+ ## 7. Fix a defect: `sable patch`
142
+
143
+ When a component is wrong, not merely missing something, patch the installed
144
+ module and record why: `sable patch <Component> --reason … --repro …`. Doctor
145
+ re-checks it on every upgrade. `sable docs patching-a-component` has the steps.
146
+
147
+ ## 8. App-owned, or a library gap?
148
+
149
+ It belongs in the app when it is about **your** product: domain data, copy,
150
+ business rules, which items a list offers, what an action does, the layout of a
151
+ signature surface.
152
+
153
+ It is a library gap when **another app would need the same thing** and none of
154
+ the sections above gets there without fighting the component: a missing
155
+ variant, slot, prop, or piece of behaviour. Then:
156
+
157
+ 1. Show the search: `sable search "<need>"` and what came back.
158
+ 2. Name the missing capability and the smallest API that would cover it.
159
+ 3. Meanwhile, compose the nearest thing from Sable's parts in the app (not a
160
+ copy of the component), and say in your summary that it is a stand-in for a
161
+ reported gap.
162
+
163
+ In the Sable repository itself, close the gap in the package and then use it
164
+ (`sable docs adding-a-component`).
165
+
166
+ ## Read next
167
+
168
+ - [patching-a-component.md](./patching-a-component.md): the patch workflow
169
+ - [creating-a-preset.md](./creating-a-preset.md): a reusable look
170
+ - [building-a-chat.md](./building-a-chat.md): the chat parts and who owns what
@@ -10,13 +10,27 @@ TypeScript on Vite, TanStack Router, TanStack Query, TanStack Form for forms wit
10
10
  validation logic, and TanStack Table once a table outgrows `DataTable`
11
11
  (`sable docs tables-with-tanstack`). An existing app keeps its own.
12
12
 
13
- ## Install
13
+ ## Install and wire it
14
14
 
15
15
  ```bash
16
16
  pnpm add @sableui/react
17
+ pnpm exec sable init --dry-run # every change it would make
18
+ pnpm exec sable init
17
19
  ```
18
20
 
19
- ## Connect CSS and React
21
+ `sable init` reads the package manager from your lockfile and the framework
22
+ from the project. It installs what is missing (React, Tailwind and its build
23
+ plugin), writes the CSS below into your global stylesheet in order, adds React
24
+ dedupe and the Tailwind plugin to a Vite config (a PostCSS config for Next.js),
25
+ loads Figtree and DM Mono, wraps a Vite starter's `<App />` in `IconProvider`,
26
+ installs the agent skill, and finishes with `sable doctor`. Edits it cannot make
27
+ safely, such as a Next.js root layout, it prints with the file they belong in.
28
+ Vite and Next.js get every step; other setups get the packages and stylesheet.
29
+ Once an app is wired, `sable doctor --fix` applies the same fixes to whatever
30
+ doctor flags. Run `sable init` with the package installed: before that, `npx
31
+ sable` names an unrelated package.
32
+
33
+ ## Or wire it by hand
20
34
 
21
35
  For `src/styles.css` with `node_modules` at the project root:
22
36
 
@@ -43,11 +57,15 @@ export default function App() {
43
57
  }
44
58
  ```
45
59
 
46
- No provider or icon dependency is required for this first component. Optional
47
- theme, icon, and motion providers are explained in the package README.
60
+ Mount `<IconProvider set={reiconIconSet}>` (from `@sableui/react/icons/reicon`)
61
+ once at the root: without it every icon, including those inside Sable's
62
+ components, renders nothing, and `sable doctor` fails. Optional theme and motion
63
+ providers are explained in the package README.
48
64
 
49
65
  ## Connect your agent and verify
50
66
 
67
+ `sable init` already ran these; by hand:
68
+
51
69
  ```bash
52
70
  pnpm exec sable setup --agent --dry-run
53
71
  pnpm exec sable setup --agent
@@ -0,0 +1,351 @@
1
+ # Task: migrating from shadcn/ui (and Radix) to Sable
2
+
3
+ An app built on shadcn/ui owns a `components/ui/` folder of copied Radix
4
+ wrappers. Sable replaces that folder with one installed package. The visible
5
+ API is close: Sable's semantic tokens share shadcn's names, and most
6
+ components keep their part names. The behaviour layer is different. Sable is
7
+ built on **Base UI**, not Radix, so composition, values and events follow Base
8
+ UI. This guide maps one to the other. Every Sable name below is in the
9
+ installed contract: confirm any of them with `sable component <Name>`.
10
+
11
+ Do the app-level work in [converting-an-app.md](./converting-an-app.md) first
12
+ (registers, wiring, deleting your tokens). This guide covers the component
13
+ swap that follows.
14
+
15
+ ## 0. What goes away
16
+
17
+ - **`components/ui/*` and `components.json`.** Delete the folder once nothing
18
+ imports it. Every component comes from `@sableui/react` (layout primitives
19
+ too; blocks and patterns from `@sableui/react/blocks` and `/patterns`).
20
+ Don't copy a Sable component into the app to edit it. Use a variant, a
21
+ preset, or [patching-a-component.md](./patching-a-component.md).
22
+ - **`@radix-ui/*`, `cmdk`, `vaul`, `sonner`, `input-otp`, `next-themes`,
23
+ `tw-animate-css`, `class-variance-authority` (unless the app's own code uses
24
+ it) and `lucide-react`.** Each has a Sable counterpart below.
25
+ - **Your `globals.css` token block.** Sable's tokens carry the same names
26
+ (`--background`, `--card`, `--primary`…) and are defined by the theme. A
27
+ copied shadcn `:root { … }` block overrides them with values the theme
28
+ never tested. Delete it, and move brand colour into a preset
29
+ (`sable docs using-presets`).
30
+
31
+ `cn()` survives: Sable exports it (`import { cn } from "@sableui/react"`), with
32
+ the paired typography utilities registered in its merge groups.
33
+
34
+ ## 1. The patterns that change everywhere
35
+
36
+ ### `asChild` → `render`
37
+
38
+ Radix composes by cloning a child. Base UI takes the element as a prop:
39
+
40
+ ```tsx
41
+ // shadcn
42
+ <DialogTrigger asChild><Button variant="outline">Edit</Button></DialogTrigger>
43
+
44
+ // Sable
45
+ <DialogTrigger render={<Button variant="outline">Edit</Button>} />
46
+ ```
47
+
48
+ It applies to every trigger and close part (`DialogTrigger`, `DialogClose`,
49
+ `SheetTrigger`, `MenuTrigger`, `PopoverTrigger`, `TooltipTrigger`,
50
+ `CollapsibleTrigger`, `ContextCardTrigger`…). The same goes for links: a
51
+ `Button` that navigates is `<Button render={<Link to="/x" />}>Open</Button>`.
52
+ `SidebarItem`, `SidebarBrand`, `Card` and `Separator` take `render` too.
53
+
54
+ ### Part names
55
+
56
+ | shadcn / Radix | Sable |
57
+ |---|---|
58
+ | `DropdownMenu*` | `Menu*`: `Menu`, `MenuTrigger`, `MenuContent`, `MenuItem` |
59
+ | `DropdownMenuLabel` | `MenuGroupLabel`, **inside a `MenuGroup`** (Base UI requires the group) |
60
+ | `DropdownMenuSub` / `SubTrigger` / `SubContent` | `MenuSubmenu` / `MenuSubmenuTrigger` / a nested `MenuContent side="inline-end"` |
61
+ | `*Content` on Accordion, Collapsible | `AccordionPanel`, `CollapsiblePanel` |
62
+ | `TabsContent` | `TabsPanel` (`TabsContent` is exported as an alias) |
63
+ | `HoverCard*` | `ContextCard*` |
64
+ | `Drawer*` (vaul) | `Sheet*` with `side="bottom"` |
65
+
66
+ Dialog, AlertDialog, Popover, Tooltip, Select, ContextMenu, Sheet and Combobox
67
+ keep `*Content`.
68
+
69
+ ### Values are arrays where Base UI allows several
70
+
71
+ | shadcn / Radix | Sable |
72
+ |---|---|
73
+ | `<Accordion type="single" collapsible defaultValue="a">` | `<Accordion defaultValue={["a"]}>`. Single is the default, and an open item always closes on its trigger, so there is no `collapsible` |
74
+ | `<Accordion type="multiple">` | `<Accordion multiple>` |
75
+ | `<ToggleGroup type="single" value="a">` | `<ToggleGroup value={["a"]}>`. Always an array; `multiple` allows several |
76
+ | `<Slider defaultValue={[50]}>` | `<Slider defaultValue={50}>`. A single thumb is a number, a range is `[20, 80]` |
77
+ | `<Checkbox checked="indeterminate">` | `<Checkbox indeterminate>`. `checked` is only ever a boolean |
78
+
79
+ ### Events
80
+
81
+ - **Menu rows:** `onSelect` → `onClick`. To keep the menu open after a click
82
+ (Radix: `event.preventDefault()` in `onSelect`), set `closeOnClick={false}`.
83
+ - **Change handlers take a second argument.** `onValueChange`,
84
+ `onCheckedChange` and `onOpenChange` receive `(value, eventDetails)`. The
85
+ first argument is what Radix passed, so most handlers work unchanged.
86
+ - **Dismissal hooks:** `onEscapeKeyDown` / `onPointerDownOutside` /
87
+ `onInteractOutside` → one `onOpenChange` on the root. Read
88
+ `eventDetails.reason` (`"escape-key"`, `"outside-press"`…) and call
89
+ `eventDetails.cancel()` to keep it open.
90
+ - **`forceMount`** → `keepMounted` (on `AccordionPanel`, `CollapsiblePanel` and
91
+ portals).
92
+ - **State styling:** `data-[state=open]:` → Sable's state variants:
93
+ `sable-open:`, `sable-selected:`, `sable-highlighted:`, `sable-disabled:`
94
+ and `sable-invalid:` (`sable utility sable-open:`). Base UI writes
95
+ `data-open`, `data-checked`, `data-highlighted` and `data-pressed`, not
96
+ `data-state`.
97
+
98
+ ### Variants, not overrides
99
+
100
+ shadcn invites restyling through `className`. Sable does not: a
101
+ `bg-*`/`border*`/`rounded*`/`ring-*`/`shadow-*` class on `Button`, `Pill` or
102
+ `Badge` fails `component-restyle`. Layout classes (`w-full`, `ms-auto`, grid
103
+ placement) are fine. If no variant gives you the look, the component
104
+ needs one, and that is a library change; `extending-a-component.md` has the
105
+ ways that don't need one (`render`, slots, refs, tokens). Map the variants instead:
106
+
107
+ | shadcn `Button` | Sable `Button` |
108
+ |---|---|
109
+ | `variant="default"` | `variant="primary"` (the default) |
110
+ | `secondary` / `outline` / `ghost` / `destructive` / `link` | same names |
111
+ | `size="default"` / `sm` / `lg` | `size="md"` (default) / `sm` / `cta` |
112
+ | `size="icon"` / `icon-sm` / `icon-lg` | `icon` / `icon-sm` / `icon` (add `icon-xs` in tool chrome) |
113
+ | a hand-placed `<Loader2 className="animate-spin" />` | `loading` (keeps the label; `loadingLabel` to change it) |
114
+ | `<Icon /> Label` children | `iconStart={<Icon name="plus" />}` / `iconEnd` |
115
+
116
+ `buttonVariants` is exported, as in shadcn, for the rare non-`Button` element
117
+ that must look like one. Prefer `render`.
118
+
119
+ ### Classes shadcn's source teaches that Sable's lint rejects
120
+
121
+ Copied shadcn markup carries these idioms. `sable-lint` names each one:
122
+
123
+ | shadcn idiom | Sable | rule |
124
+ |---|---|---|
125
+ | `text-sm font-medium` | a paired tier: `text-label-md`, `text-body-sm`, `text-heading-3`… (`sable token typography`) | `decomposed-type` |
126
+ | `hover:bg-accent hover:text-accent-foreground` | a component, or `sable-surface-interactive` | `surface-hover` |
127
+ | `transition-colors`, `transition-all`, `transition-[color,box-shadow]` | `motion-interactive` | `transition-colors`, `transition-all` |
128
+ | `animate-in fade-in-0 zoom-in-95 data-[state=open]:…` | `motion-pop` / `motion-fade` / `motion-slide-*` | n/a (tw-animate is gone) |
129
+ | `animate-spin`, `animate-pulse` | `motion-spin`, `motion-pulse`, or `Spinner` / `Skeleton` | `builtin-animate` |
130
+ | `ring-ring/50`, `focus-visible:ring-[3px]` | the component's own focus ring; `ring-ring` at full strength if you must | `faded-focus-ring` |
131
+ | `bg-black/50` scrim | `bg-overlay` (Dialog and Sheet draw their own) | `raw-black-white` |
132
+ | `z-50` | `z-(--z-overlay)` and siblings | `raw-z-index` |
133
+ | `text-muted-foreground/70` | `text-muted-foreground` | `text-opacity` |
134
+ | `overflow-y-auto` alone | add `scrollbar-quiet`, or use `Scroller` | `unmanaged-overflow` |
135
+ | `bg-zinc-900`, `text-slate-500` | a semantic token, or a generated family (`bg-blue-100`) for categorical colour | `palette-ref` |
136
+ | `<div className="border-input rounded-md …">` dressed as a field | `Input` / `Textarea` / `SearchInput` | `lookalike-control` |
137
+
138
+ ## 2. Tokens
139
+
140
+ The names carry over. Most shadcn utilities (`bg-background`, `text-foreground`,
141
+ `bg-card`, `bg-popover`, `bg-muted`, `text-muted-foreground`, `bg-primary`,
142
+ `bg-secondary`, `bg-accent`, `bg-destructive`, `border-border`, `border-input`,
143
+ `ring-ring`, `bg-sidebar*`) compile unchanged against Sable. Sable is stricter
144
+ about **role**, so check each use against its intent (`sable token <name>`):
145
+
146
+ | shadcn var | Sable | note |
147
+ |---|---|---|
148
+ | `--background`, `--foreground` | same | the page ground and ink |
149
+ | `--card`, `--popover` | same | `popover` is for anything floating, including persistent inspectors |
150
+ | `--muted` | same | recessed and **inert**; not a hover fill |
151
+ | `--secondary` | same | a pressable low-emphasis fill; not a surface |
152
+ | `--accent` | same | the transient **state** layer (hover, current), applied by components; don't paint it by hand |
153
+ | `--border` | `border` or `border-strong` | `border-strong` when the line carries structure (a table rule, a section divider) |
154
+ | `--input` | `input` (fields), `control-border` (checkbox, radio, switch) | `control-border` is held to 3:1 |
155
+ | `--ring` | same | focus only, never selection |
156
+ | `--destructive` | `destructive` (fill), `destructive-text` (ink on a surface) | likewise `success`/`success-text`, `tone-warning`/`tone-warning-text`, `tone-info`/`tone-info-text` |
157
+ | `--chart-1`…`--chart-5` | `--chart-1`…`--chart-8`, set by the theme | address series by `tone` on the chart, not by variable |
158
+ | `--sidebar-*` | same names | |
159
+ | `--radius` (one value, derived) | a ladder: `rounded-xs`…`rounded-xl`, `rounded-full`; `--button-radius` for buttons | a theme sets the ladder; pick by role (`sable token radii`) |
160
+
161
+ New to a shadcn app: `tone-1`…`tone-3` (category colour), `highlight` (marked
162
+ content), `overlay` (scrims), and the generated palette families. Fonts:
163
+ Sable's `--font-sans` / `--font-mono` default to Figtree and DM Mono, loaded
164
+ by the host (see **Consuming** in `llms.txt`).
165
+
166
+ Dark mode: `next-themes` → `SableThemeProvider` (`defaultPreference`,
167
+ `useSableTheme()`). It writes the same `.dark` class on `<html>`.
168
+
169
+ ## 3. Icons
170
+
171
+ `lucide-react` imports → `<Icon name="…" />`, rendered by the set in
172
+ `<IconProvider set={reiconIconSet}>` (`@sableui/react/icons/reicon`).
173
+ Without a provider, every icon renders nothing, including the ones inside
174
+ Sable's components. Names are semantic:
175
+
176
+ | lucide | Sable | lucide | Sable |
177
+ |---|---|---|---|
178
+ | `X` | `xmark` | `MoreHorizontal` / `Ellipsis` | `ellipsis` |
179
+ | `Check` | `check` | `ChevronDown` / `Up` / `Left` / `Right` | `chevron-down` … |
180
+ | `Search` | `search` | `Plus` / `Minus` | `plus` / `minus` |
181
+ | `Settings` | `gear` | `Trash2` | `trash` |
182
+ | `Pencil` | `edit` | `Copy` | `copy` |
183
+ | `Loader2` | `spinner` (or `Spinner`) | `AlertTriangle` | `warning` |
184
+ | `Info` | `info` | `CircleCheck` | `circle-check` |
185
+ | `Mail` | `mail` | `Menu` | `bars` |
186
+ | `ExternalLink` | `external` | `LogOut` | `logout` |
187
+
188
+ The full contract is 84 names (`iconContract.names` in `registry.json`). An app
189
+ icon outside it goes in a set of your own composed over Reicon
190
+ (`composeIconSets(appIcons, reiconIconSet)`) and is listed in
191
+ `.sable/icons.json`. Keeping lucide as that set is allowed; use `<Icon>` at the
192
+ call sites either way.
193
+
194
+ ## 4. Component by component
195
+
196
+ `—` means Sable has no counterpart. The note says what to compose instead.
197
+
198
+ | shadcn | Sable | what changes |
199
+ |---|---|---|
200
+ | accordion | `Accordion`, `AccordionItem`, `AccordionTrigger`, `AccordionPanel` | array values; no `type` or `collapsible` (§1) |
201
+ | alert | `Note` (`tone`, `title`, `icon`, `action`) | `variant="destructive"` → `tone="destructive"`. A page-wide bar is `Banner`; a one-line status is `InlineStatus` |
202
+ | alert-dialog | `AlertDialog`, `AlertDialogTrigger`, `AlertDialogContent`, `AlertDialogHeader`, `AlertDialogTitle`, `AlertDialogDescription`, `AlertDialogFooter`, `AlertDialogClose` | `AlertDialogAction` / `AlertDialogCancel` → `AlertDialogClose render={<Button …/>}`. For a delete confirmation use `DestructiveActionModal` (`onConfirm`, `pending`, `confirmText`) |
203
+ | aspect-ratio | `AspectBox` (`ratio`) | layout primitive |
204
+ | avatar | `Avatar` (`src`, `fallback`, `size`, `status`), or `AvatarRoot` / `AvatarImage` / `AvatarFallback` | the one-element form covers most uses; `AvatarGroup` for stacks |
205
+ | badge | `Badge` (`appearance` × `tone`) | `default` → `appearance="solid"`; `secondary` → `soft`; `outline` → `outline`; `destructive` → `tone="destructive"` |
206
+ | breadcrumb | `Breadcrumbs`, `BreadcrumbItem`, `BreadcrumbLink`, `BreadcrumbPage`, `BreadcrumbSeparator` | no `BreadcrumbList` (the root renders the `<ol>`); no `BreadcrumbEllipsis` |
207
+ | button | `Button` | §1 table |
208
+ | calendar | `Calendar` | same react-day-picker v9 (`mode`, `selected`, `onSelect`); Sable styles it, so drop `classNames` overrides |
209
+ | card | `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardAction`, `CardContent`, `CardFooter` | same parts, plus `CardList` for rows and `density="compact"` |
210
+ | carousel | `Carousel` (`slidesToShow`, `loop`, `autoplay`, `index`) | one component, no Embla parts |
211
+ | checkbox | `Checkbox` | `indeterminate` prop (§1); wrap in `Field label orientation="horizontal"` for a label |
212
+ | collapsible | `Collapsible`, `CollapsibleTrigger`, `CollapsiblePanel` | `CollapsibleIndicator` for the chevron |
213
+ | combobox (Popover + Command) | `Combobox`, `ComboboxInput`, `ComboboxContent`, `ComboboxList`, `ComboboxItem`, `ComboboxEmpty` | one real combobox instead of a popover holding a command list; `items` on the root, a render function in `ComboboxList`; `multiple` + `ComboboxChips` for several |
214
+ | command | `CommandMenu` (`items`, `open`, `onOpenChange`), `CommandMenuPanel` inline | data-driven: `{ group, label, onSelect, icon, hint }` items instead of `CommandGroup`/`CommandItem` children (declarative `CommandMenuGroup` / `CommandMenuItem` exist too). `useCommandMenuShortcut` binds ⌘K |
215
+ | context-menu | `ContextMenu`, `ContextMenuTrigger`, `ContextMenuContent`, `ContextMenuItem`, `ContextMenuSeparator`, `ContextMenuGroup`, `ContextMenuGroupLabel` | `onSelect` → `onClick`; destructive rows use `variant="destructive"` |
216
+ | data-table | `DataTable` (`columns`, `data`, `rowKey`, `defaultSort`, selection) | built-in sorting and selection; for TanStack features see `sable docs tables-with-tanstack` |
217
+ | dialog | `Dialog`, `DialogTrigger`, `DialogContent`, `DialogHeader`, `DialogTitle`, `DialogDescription`, `DialogFooter`, `DialogClose` | **`showCloseButton` defaults to false** (true in shadcn); `size="fullscreen"` for a media viewer |
218
+ | drawer | `Sheet`, `SheetTrigger`, `SheetContent side="bottom"`, `SheetHeader`, `SheetTitle`, `SheetDescription`, `SheetFooter`, `SheetClose` | swipe-to-dismiss included. vaul's `snapPoints` / `activeSnapPoint` / `setActiveSnapPoint` → `Sheet` `snapPoints` / `snapPoint` / `onSnapPointChange` (bottom only; fractions, px or rem); `fadeFromIndex` has no equivalent |
219
+ | dropdown-menu | `Menu`, `MenuTrigger`, `MenuContent`, `MenuItem`, `MenuGroup`, `MenuGroupLabel`, `MenuSeparator`, `MenuCheckboxItem`, `MenuRadioGroup`, `MenuRadioItem`, `MenuSubmenu`, `MenuSubmenuTrigger` | §1; `DropdownMenuShortcut` → a `<KbdGroup className="ms-auto">` in the row; an ellipsis trigger is `OverflowMenu` |
220
+ | empty | `EmptyState` (`icon`, `title`, `description`, `action`) | |
221
+ | field / form (react-hook-form) | `Form`, `Field` (`label`, `description`, `error`, `required`, `name`, `trailing`) | §5 |
222
+ | hover-card | `ContextCard`, `ContextCardTrigger`, `ContextCardContent` | |
223
+ | input | `Input` | `aria-invalid` drives the error state; `SearchInput` for search; `NumberField` for numbers |
224
+ | input-group | `InputGroup` (`leading`, `trailing`) around one `Input` | addons are props, not `InputGroupAddon` / `InputGroupInput` / `InputGroupButton` parts: `<InputGroup leading="https://" trailing={<Button variant="ghost" size="icon-sm" aria-label="Copy">…</Button>}><Input /></InputGroup>`. A button beside the field (not inside it) is `Field trailing` |
225
+ | input-otp | `OtpField` (`length`, `groupSize`, `onValueComplete`) | one component: no `InputOTPGroup` / `InputOTPSlot` / `InputOTPSeparator`; `groupSize={3}` draws the split |
226
+ | item | `Entity` (`leading`, `title`, `subtitle`, `action`, `interactive`) | |
227
+ | kbd | `Kbd`, `KbdGroup` | `mod` renders ⌘ or Ctrl per platform |
228
+ | label | `Label`, or better `Field label` | `Field` wires `htmlFor`, `aria-describedby` and `aria-invalid` for you |
229
+ | menubar | `Menubar` | one `Menu` per title: `MenubarMenu` → `Menu`, `MenubarTrigger` → `MenubarTrigger`, `MenubarContent` → `MenuContent`, `MenubarItem` → `MenuItem`, `MenubarSub` / `MenubarSubTrigger` / `MenubarSubContent` → `MenuSubmenu` / `MenuSubmenuTrigger` / `MenuContent side="inline-end"`, checkbox and radio rows → `MenuCheckboxItem` / `MenuRadioGroup` + `MenuRadioItem`. No `MenubarShortcut` |
230
+ | navigation-menu | `NavigationMenu` | `NavigationMenuList` / `Item` / `Trigger` / `Content` / `Link` keep their names. `NavigationMenuViewport` and `NavigationMenuIndicator` go: the root renders the one shared panel, which glides between triggers and resizes itself. `asChild` on a link → `render={<Link to="…" />}`; mark the current page with `active` (`aria-current="page"`). shadcn's `navigationMenuTriggerStyle()` on a plain link → a `NavigationMenuLink` in its own item, which is styled as a bar entry. Panel rows take `description` for the second line. For app navigation, use `Sidebar` / `SideNav` / `AppHeader` / `BottomNav` in an `AppShell` instead |
231
+ | pagination | `Pagination` (`page`, `total`, `onPageChange`, `siblingCount`, `pageLink`) | one component, not `PaginationContent` / `PaginationItem` / `PaginationLink` parts. Links: `` pageLink={(page) => <Link to={`?page=${page}`} />} `` makes every control a real link |
232
+ | popover | `Popover`, `PopoverTrigger`, `PopoverContent` (`side`, `align`, `sideOffset`), `PopoverTitle`, `PopoverDescription`, `PopoverClose` | `PopoverAnchor` → `anchor` on `PopoverContent` (an element or ref) |
233
+ | progress | `Progress` (`value`, `null` for indeterminate) | a level within a range (storage, quota) is `Meter`, not Progress |
234
+ | radio-group | `RadioGroup`, `Radio` | `RadioGroupItem` → `Radio`; `RadioCard` for card options |
235
+ | resizable | `PanelGroup`, `Panel`, `PanelResizer`, `usePanelWidth` | pixel widths with persisted bounds, for tool chrome; no percentage layout. A non-resizable two-pane split is `SplitPane` |
236
+ | scroll-area | `Scroller` (edge fades), or `scrollbar-quiet` on any overflow element | |
237
+ | select | `Select`, `SelectTrigger`, `SelectContent`, `SelectItem`, `SelectGroup`, `SelectGroupLabel`, `SelectSeparator` | `<SelectValue placeholder>` → `<SelectTrigger placeholder>`; the trigger shows the item's label, derived from `SelectItem`s or `items` |
238
+ | separator | `Separator` (`orientation`, `label`) | |
239
+ | sheet | `Sheet` parts as above, `side` on `SheetContent` (default `"right"`) | `SheetList` for row content |
240
+ | sidebar | `Sidebar` (`collapsed`, `variant`), `SidebarHeader`, `SidebarBrand`, `SidebarNav`, `SidebarItem` (`icon`, `label`, `active`, `badge`, `render`), `SidebarFooter`; mounted in `AppShell sidebar={…}` | no `SidebarProvider` / `SidebarMenu*` / `SidebarGroup`; the rail is desktop-only, and mobile uses `AppHeader` + `BottomNav` (`sable pattern` shells show the wiring) |
241
+ | skeleton | `Skeleton` | same; shape with utilities |
242
+ | slider | `Slider` | number vs array (§1) |
243
+ | sonner | `ToastProvider` at the root + `toast` | sonner's API: `toast.success/error/warning/info/loading/promise/dismiss`. `<Toaster />` → `<ToastProvider position="bottom-right">` around the app |
244
+ | spinner | `Spinner` (`size`, `appearance="ring" \| "dots"`, `label`) | |
245
+ | switch | `Switch` | same `checked` / `onCheckedChange` |
246
+ | table | `Table`, `TableHeader`, `TableBody`, `TableFooter`, `TableRow`, `TableHead`, `TableCell`, `TableCaption` | same parts; don't pad edge cells by hand (`table-edge-padding`); `CardContent flush` sets the inset |
247
+ | tabs | `Tabs`, `TabsList` (`variant="underline" \| "pill"`), `TabsTrigger`, `TabsPanel` | `TabsContent` still works as an alias |
248
+ | textarea | `Textarea` (`autoResize`) | |
249
+ | toggle | `Toggle` (`pressed`, `onPressedChange`) | |
250
+ | toggle-group | `ToggleGroup` (`appearance="segmented"` for a segmented control), `Toggle` / `ToggleGroupItem` | array value (§1) |
251
+ | tooltip | `TooltipProvider`, `Tooltip`, `TooltipTrigger`, `TooltipContent` (`side`), or `SimpleTooltip` | `delayDuration` → `delay` on `TooltipProvider` |
252
+ | chart | `AreaChart`, `BarChart`, `LineChart`, `PieChart`, `RadarChart`, `RadialChart`, `Sparkline` | no Recharts and no `ChartContainer`/`ChartConfig`: pass `data`, `index` (the x key) and `series: [{ key, label, tone }]` |
253
+ | button-group | `ButtonGroup` | children stay `Button`s; the group squares inner corners and draws the seam. A split button is `ButtonGroup` > `Button` + `Menu` with the chevron `Button` as `MenuTrigger`. No `ButtonGroupSeparator`/`ButtonGroupText` |
254
+ | native-select | — | use `Select` in place of a native one |
255
+
256
+ Before and after for the non-obvious cases:
257
+
258
+ ```tsx
259
+ // dropdown-menu
260
+ <DropdownMenu>
261
+ <DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal /></Button></DropdownMenuTrigger>
262
+ <DropdownMenuContent align="end">
263
+ <DropdownMenuLabel>Post</DropdownMenuLabel>
264
+ <DropdownMenuItem onSelect={edit}>Edit</DropdownMenuItem>
265
+ <DropdownMenuItem className="text-destructive" onSelect={remove}>Delete</DropdownMenuItem>
266
+ </DropdownMenuContent>
267
+ </DropdownMenu>
268
+
269
+ <OverflowMenu label="Post actions">
270
+ <MenuGroup>
271
+ <MenuGroupLabel>Post</MenuGroupLabel>
272
+ <MenuItem onClick={edit}>Edit</MenuItem>
273
+ <MenuItem variant="destructive" onClick={remove}>Delete</MenuItem>
274
+ </MenuGroup>
275
+ </OverflowMenu>
276
+ ```
277
+
278
+ ```tsx
279
+ // chart
280
+ <ChartContainer config={{ revenue: { label: "Revenue", color: "var(--chart-1)" } }}>
281
+ <AreaChart data={data}><XAxis dataKey="month" /><Area dataKey="revenue" /></AreaChart>
282
+ </ChartContainer>
283
+
284
+ <AreaChart label="Revenue" data={data} index="month" series={[{ key: "revenue", label: "Revenue", tone: "1" }]} />
285
+ ```
286
+
287
+ ## 5. Forms
288
+
289
+ shadcn's `Form` wraps react-hook-form with `FormField` / `FormItem` /
290
+ `FormLabel` / `FormControl` / `FormMessage`. Sable's `Form` + `Field` is
291
+ library-agnostic and does that wiring itself:
292
+
293
+ ```tsx
294
+ <Form onFormSubmit={save} errors={serverErrors}>
295
+ <Field name="email" label="Email" required messages={{ valueMissing: "Enter your email." }}>
296
+ <Input type="email" autoComplete="email" spellCheck={false} />
297
+ </Field>
298
+ <Button type="submit" loading={saving}>Save</Button>
299
+ </Form>
300
+ ```
301
+
302
+ - `FormLabel` + `FormControl` + `FormDescription` + `FormMessage` → one
303
+ `Field` (`label`, `description`, `error`). It wires `htmlFor`,
304
+ `aria-describedby` and `aria-invalid`.
305
+ - **Keeping react-hook-form** is fine: register the control as usual and pass
306
+ `error={errors.email?.message}` to its `Field`. Use `<Form noValidate>` so
307
+ your resolver owns validation. Don't also mark that `Field` `required`
308
+ with an `error` inside a validating `Form` (`form-required-error`).
309
+ - Server errors go in `Form errors={{ email: "Taken" }}`, keyed by `Field name`.
310
+ Each clears when the user edits that field.
311
+ - Follow the form submit contract in `AGENTS.md`: submit stays enabled until
312
+ the request starts; focus the first invalid field (`Form` does);
313
+ `loading` keeps the label.
314
+
315
+ ## 6. What Sable doesn't have yet
316
+
317
+ Every shadcn component in the map above now has a Sable counterpart. For
318
+ anything else shadcn's ecosystem offers, compose it from Sable's parts rather
319
+ than porting the shadcn file back in. If a missing piece blocks the app, say so
320
+ upstream. A local workaround is a
321
+ `sable patch`, not a copy.
322
+
323
+ ## 7. Verify
324
+
325
+ ```bash
326
+ pnpm exec sable doctor # wiring: styles order, @source, IconProvider, dedupe
327
+ pnpm exec sable lint src # the idioms in §1, by rule
328
+ pnpm exec sable check src # doctor + lint + grade before calling it done
329
+ ```
330
+
331
+ `shadcn-leftover` flags what is still imported from the old stack (`@radix-ui/*`,
332
+ `@/components/ui`, `lucide-react`, `sonner`, `vaul`, `cmdk`, `input-otp`,
333
+ `next-themes`) and every `asChild` prop. A deliberate keeper, such as a Radix
334
+ part Sable does not ship yet, takes a reasoned disable. The rest no rule can
335
+ judge, so grep for it; each of these should return nothing:
336
+
337
+ ```bash
338
+ grep -rnE 'data-\[state=|animate-in|tw-animate|type="(single|multiple)"' src
339
+ grep -rn 'onSelect=' src # Menu rows: should be onClick (Calendar, CommandMenu items and Entity keep onSelect)
340
+ ```
341
+
342
+ Then render it: open each overlay, menu and form in light and dark. Lint
343
+ passes on markup that never opens.
344
+
345
+ ## Read next
346
+
347
+ - [converting-an-app.md](./converting-an-app.md): registers, wiring, tokens, verification by measurement
348
+ - [patching-a-component.md](./patching-a-component.md): when a Sable component is wrong and the fix hasn't shipped
349
+ - [extending-a-component.md](./extending-a-component.md): owning the source is gone, so this is how you add what a component lacks
350
+ - [tables-with-tanstack.md](./tables-with-tanstack.md): data tables beyond `DataTable`
351
+ - [auditing-an-app.md](./auditing-an-app.md): staying converted across upgrades