@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,113 @@
1
+ # NavigationMenu
2
+
3
+ **Status:** stable
4
+
5
+ A site header's primary nav over Base UI NavigationMenu: a bar of links and triggers, where a trigger opens a panel of links on hover, click or Enter. One shared panel glides between triggers and resizes to each one's content. Links take any router's link through `render` and mark the current page with `active`.
6
+
7
+ **Keywords:** navigation, nav, header nav, site nav, mega menu, top nav, navbar, dropdown nav
8
+
9
+ ## Usage
10
+
11
+ Use NavigationMenu for a marketing or docs site's header: a few top-level destinations, some of which open a panel of related pages. Use Menu for actions rather than destinations, Tabs to switch views inside a page, Sidebar or SideNav for an app's persistent navigation, and AppHeader for an app's mobile top bar.
12
+
13
+ ### Best practices
14
+
15
+ - Do: Keep the bar to a handful of entries. A trigger groups related pages; a destination with nothing beneath it is a plain NavigationMenuLink in its own item.
16
+ - Do: Give panel links a `description` when titles alone are ambiguous ("Sync" → "Offline first, conflict free"), and lay the Content out with layout classes (`grid w-[30rem] grid-cols-2`); the shared panel sizes itself to it.
17
+ - Do: Pass your router's link through `render` (`<NavigationMenuLink render={<Link to="/pricing" />}>`) and set `active` on the current page's link, so the bar shows where the reader is and announces it as `aria-current="page"`.
18
+ - Do: Below `md`, hide the bar and offer the same destinations in a Sheet opened from a menu Button: SheetContent side="left" holding a SideNav, its groups mirroring the panels. A hover panel has nothing to hover on a phone, and a bar of five entries does not fit one.
19
+ - Don't: Do not put actions in it (Sign in, New project). Those are Buttons beside the nav; the nav holds places to go.
20
+ - Don't: Do not assemble the panel yourself. The root renders the one Portal, Positioner, Popup and Viewport every trigger shares; a second one breaks the glide between triggers.
21
+
22
+ ### Anatomy
23
+
24
+ | Part | Required | Description |
25
+ | --- | --- | --- |
26
+ | NavigationMenu | yes | The `<nav>` root; also renders the shared floating panel. |
27
+ | NavigationMenuList | yes | The bar: a `<ul>` the arrow keys rove across. |
28
+ | NavigationMenuItem | yes | One `<li>`: a link, or a trigger with its content. |
29
+ | NavigationMenuTrigger | no | Opens its item's content; carries a chevron. |
30
+ | NavigationMenuContent | no | The links a trigger reveals, rendered into the shared panel. |
31
+ | NavigationMenuLink | yes | A destination: a bar entry in an item, a two-line row in content. |
32
+
33
+ ## Subcomponents
34
+
35
+ | Name | Type | Description |
36
+ | --- | --- | --- |
37
+ | NavigationMenuList | Base UI NavigationMenu.List props + className | The bar of items, a `<ul>`. Arrow keys move between entries. |
38
+ | NavigationMenuItem | Base UI NavigationMenu.Item props + className + value?: string | One `<li>` holding either a NavigationMenuLink or a NavigationMenuTrigger with its NavigationMenuContent. |
39
+ | NavigationMenuTrigger | Base UI NavigationMenu.Trigger props + className | Opens its item's content on hover, click or Enter, with a chevron that turns while open. Styled like the bar's links: muted at rest, full ink when hovered or open. |
40
+ | NavigationMenuContent | Base UI NavigationMenu.Content props + className + keepMounted?: boolean | The item's panel content. Size and lay it out with layout classes; the shared panel follows its size and slides it in from the side it was reached from. |
41
+ | NavigationMenuLink | Base UI NavigationMenu.Link props + className + href?: string + active?: boolean + closeOnClick?: boolean + render?: ReactElement + description?: ReactNode | A destination. In an item it is a bar entry; in content it is a row with an optional `description` line. `active` sets `aria-current="page"`; `render={<Link to="…" />}` keeps the roving focus on your router's link. |
42
+
43
+ ## Props
44
+
45
+ | Name | Type | Default | Description |
46
+ | --- | --- | --- | --- |
47
+ | NavigationMenu (Root) | Base UI NavigationMenu.Root props (value, defaultValue, onValueChange, delay, closeDelay, orientation) + className | — | The `<nav>`. `value` is the open item's value (null when closed); `delay` / `closeDelay` tune hover-to-open. |
48
+ | align | "start" \| "center" \| "end" | "start" | Alignment of the shared panel along its trigger. |
49
+ | sideOffset | number | 8 | Gap between the bar and the panel, in px. The pointer crosses it on a bridge, so the panel does not close on the way in. |
50
+
51
+ ## Tokens used
52
+
53
+ `popover`, `popover-foreground`, `border-floating`, `accent`, `accent-foreground`, `muted-foreground`, `foreground`, `ring`
54
+
55
+ ## Motion
56
+
57
+ - **arrival** · Panel · _custom (outside the recipe layer)_ — The panel fades and scales in from its trigger like a popover, but it also resizes as the reader moves between triggers, which motion-pop cannot express: Base UI measures each content into --popup-width/--popup-height and the popup transitions to them on the motion tokens.
58
+ - **response** · Trigger, Link · `motion-interactive` — Bar entries and panel rows fill on hover and focus like the other interactive surfaces, so the bar reads as live before anything opens.
59
+ - **transition** · Panel · _custom (outside the recipe layer)_ — Moving from one trigger to the next keeps one panel open: it glides along the bar and the new content slides in from the direction of travel, so the reader sees one surface changing rather than two popups swapping. Snaps on first open and on resize (data-instant), where a glide would mean nothing.
60
+ - **activity** · root · _no motion_ — Nothing in a nav moves on its own.
61
+
62
+ ## Accessibility
63
+
64
+ - The root is a <nav>; give it an aria-label when the page has more than one nav ("Main").
65
+ - Arrow keys move across the bar; Enter, Space or ArrowDown opens a trigger's content and moves focus into it; Esc closes and returns focus to the trigger.
66
+ - A trigger is a button with aria-expanded; its content is reachable by Tab in reading order once open.
67
+ - `active` on a link sets aria-current="page" and gives it full ink, so the current page is announced as well as shown.
68
+ - Panel motion runs on the motion tokens, so reduced motion (or MotionProvider "reduced") collapses the glide and resize to instant.
69
+
70
+ ## Related
71
+
72
+ - Components: Menu, SideNav, Sheet, AppHeader, Tabs
73
+
74
+ ## Examples
75
+
76
+ ### Bar with a panel
77
+
78
+ ```tsx
79
+ import { NavigationMenu, NavigationMenuContent, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger } from "@sableui/react"
80
+
81
+ <NavigationMenu aria-label="Main">
82
+ <NavigationMenuList>
83
+ <NavigationMenuItem>
84
+ <NavigationMenuTrigger>Product</NavigationMenuTrigger>
85
+ <NavigationMenuContent className="grid w-[30rem] grid-cols-2">
86
+ <NavigationMenuLink href="/editor" description="Write and review together.">Editor</NavigationMenuLink>
87
+ <NavigationMenuLink href="/sync" description="Offline first, conflict free.">Sync</NavigationMenuLink>
88
+ </NavigationMenuContent>
89
+ </NavigationMenuItem>
90
+ <NavigationMenuItem>
91
+ <NavigationMenuLink href="/pricing">Pricing</NavigationMenuLink>
92
+ </NavigationMenuItem>
93
+ </NavigationMenuList>
94
+ </NavigationMenu>
95
+ ```
96
+
97
+ ### With a router, marking the current page
98
+
99
+ ```tsx
100
+ import { NavigationMenu, NavigationMenuItem, NavigationMenuLink, NavigationMenuList } from "@sableui/react"
101
+ // <Link> = your router's link (react-router, next/link, @tanstack/router, …)
102
+
103
+ <NavigationMenu aria-label="Main">
104
+ <NavigationMenuList>
105
+ <NavigationMenuItem>
106
+ <NavigationMenuLink active={path === "/docs"} render={<Link to="/docs" />}>Docs</NavigationMenuLink>
107
+ </NavigationMenuItem>
108
+ <NavigationMenuItem>
109
+ <NavigationMenuLink active={path === "/blog"} render={<Link to="/blog" />}>Blog</NavigationMenuLink>
110
+ </NavigationMenuItem>
111
+ </NavigationMenuList>
112
+ </NavigationMenu>
113
+ ```
@@ -4,7 +4,7 @@
4
4
 
5
5
  An overflow ellipsis icon button that opens a Menu. Composes our Menu + Button + Icon — pass MenuItems as children and it wires the ghost icon trigger, accessible label, and portal-rendered content. All keyboard, focus, and dismissal behavior comes through Menu.
6
6
 
7
- **Keywords:** dots menu, more actions, ellipsis, kebab
7
+ **Keywords:** dots menu, more actions, ellipsis, kebab, responsive actions, responsive action menu, collapse actions, actions overflow on mobile
8
8
 
9
9
  ## Usage
10
10
 
@@ -14,6 +14,7 @@ Use OverflowMenu for compact overflow actions when the primary action is already
14
14
 
15
15
  - Do: Use it for secondary commands that belong to a row, card, or toolbar item.
16
16
  - Do: Keep labels explicit so the icon-only trigger does not obscure the command meaning.
17
+ - Do: For actions that fit inline on a wide screen but not a narrow one, render them twice: inline (a Row inside a wrapper shown from a container width; Row sets its own display, so the wrapper does the hiding), and as MenuItems in an OverflowMenu hidden from that width (`@container` + `@md:`). display:none takes the hidden copy out of the accessibility tree, so only one set is ever announced. Keep the primary action inline at every width.
17
18
  - Don't: Do not hide the most important or most frequent action inside OverflowMenu.
18
19
  - Don't: Do not use it as the only affordance on a touch surface. It is revealed on hover in most layouts, and hover does not exist on touch — pair it with a visible action or make it always-visible there.
19
20
 
@@ -62,3 +63,27 @@ import { Icon, MenuItem, MenuSeparator, OverflowMenu } from "@sableui/react"
62
63
 
63
64
  <OverflowMenu><MenuItem><Icon name="check" className="text-sm" />Mark as read</MenuItem><MenuSeparator /><MenuItem variant="destructive"><Icon name="xmark" className="text-sm" />Delete</MenuItem></OverflowMenu>
64
65
  ```
66
+
67
+ ### Responsive actions: inline when wide, in a menu when narrow
68
+
69
+ ```tsx
70
+ import { Button, Icon, MenuItem, OverflowMenu, Row } from "@sableui/react"
71
+
72
+ <div className="@container/actions flex w-full justify-end">
73
+ <Row gap={1} align="center">
74
+ <div className="hidden @md/actions:block">
75
+ <Row gap={1}>
76
+ <Button variant="ghost" size="sm" iconStart={<Icon name="share" />}>Share</Button>
77
+ <Button variant="ghost" size="sm" iconStart={<Icon name="download" />}>Export</Button>
78
+ </Row>
79
+ </div>
80
+ <div className="@md/actions:hidden">
81
+ <OverflowMenu label="Report actions" size="icon-sm">
82
+ <MenuItem icon={<Icon name="share" />}>Share</MenuItem>
83
+ <MenuItem icon={<Icon name="download" />}>Export</MenuItem>
84
+ </OverflowMenu>
85
+ </div>
86
+ <Button size="sm" iconStart={<Icon name="edit" />}>Edit report</Button>
87
+ </Row>
88
+ </div>
89
+ ```
@@ -2,9 +2,9 @@
2
2
 
3
3
  **Status:** stable
4
4
 
5
- Page navigation built from our own Button + Icon (no Base UI). A <nav aria-label='Pagination'> with prev/next icon buttons that disable at the bounds and numbered page buttons; the current page is a secondary button marked aria-current. Collapses to first/last/current plus a sibling window, bridging skipped runs with an ellipsis.
5
+ Page navigation built from our own Button + Icon (no Base UI). A <nav aria-label='Pagination'> with prev/next icon buttons that disable at the bounds and numbered page buttons; the current page is a secondary button marked aria-current. With pageLink every control is a real link (crawlable, opens in a new tab, any router through render). Collapses to first/last/current plus a sibling window, bridging skipped runs with an ellipsis.
6
6
 
7
- **Keywords:** pagination, pager, pages, table, results, navigation, next, previous
7
+ **Keywords:** pagination, pager, pages, table, results, navigation, next, previous, page links, seo, url
8
8
 
9
9
  ## Usage
10
10
 
@@ -14,6 +14,7 @@ Use Pagination when a result set is split into discrete pages and the user needs
14
14
 
15
15
  - Do: Keep page state controlled by the parent route or data layer so URL, filters, and result fetches stay in sync.
16
16
  - Do: Use a specific label when multiple pagers appear on a page, such as "Customer pages".
17
+ - Do: Pass pageLink when the page lives in the URL (search results, a blog, a catalogue): each page becomes a link a crawler can follow and a reader can open in a new tab. Use a router's Link there so navigation stays in the app.
17
18
  - Do: Reset to page 1 when filters or search terms materially change the result set.
18
19
  - Don't: Use Pagination for tiny lists where all items fit comfortably on one page.
19
20
  - Don't: Hide disabled previous/next buttons at the bounds; disabled controls make the current position clearer.
@@ -33,7 +34,8 @@ Use Pagination when a result set is split into discrete pages and the user needs
33
34
  | --- | --- | --- | --- |
34
35
  | page | number | — | The active page (1-based). Clamped into the valid range. |
35
36
  | total | number | — | Total number of pages. |
36
- | onPageChange | (page: number) => void | — | Called with the next page when a control is activated. No-op for out-of-range or unchanged values. |
37
+ | onPageChange | (page: number) => void | — | Called with the next page when a control is activated. No-op for out-of-range or unchanged values. Optional with pageLink, where the URL is the page state. |
38
+ | pageLink | (page: number) => ReactElement | — | Render each page control as a link: return the element for a page, (page) => <a href={`?page=${page}`} /> or a router's <Link to={`?page=${page}`} />. The current page keeps aria-current; previous/next at the bounds stay disabled buttons. onPageChange becomes optional and, if set, still fires on click. |
37
39
  | siblingCount | number | 1 | Page buttons shown on each side of the current page before the list collapses into ellipses. First and last pages are always shown. |
38
40
  | label | string | Pagination | Accessible label applied to the surrounding nav element. |
39
41
  | className | string | — | Extra classes merged onto the nav. |
@@ -47,6 +49,7 @@ Use Pagination when a result set is split into discrete pages and the user needs
47
49
  - Renders a landmark <nav> with aria-label so assistive tech can jump to the pager.
48
50
  - The current page button carries aria-current='page'.
49
51
  - Prev/next buttons are real <button>s with aria-label and become disabled at the bounds.
52
+ - With pageLink the controls are real <a> links (or the router's), so they are crawlable and open in a new tab; a bound arrow stays a disabled button rather than a link to nowhere.
50
53
  - Ellipsis slots are aria-hidden — they convey no navigable target.
51
54
 
52
55
  ## Related
@@ -82,6 +85,15 @@ import { DataTable, Pagination, Stack } from "@sableui/react"
82
85
  <Stack gap={2}><DataTable data={rows} columns={columns} rowKey={(row) => row.id} /><Pagination label="Customer pages" page={page} total={12} onPageChange={setPage} /></Stack>
83
86
  ```
84
87
 
88
+ ### Page links (URL is the state)
89
+
90
+ ```tsx
91
+ import { Pagination } from "@sableui/react"
92
+ // <Link> = your router's link (react-router, next/link, @tanstack/router, …)
93
+
94
+ <Pagination page={page} total={pageCount} pageLink={(page) => <Link to={`/search?q=${query}&page=${page}`} />} />
95
+ ```
96
+
85
97
  ### Server query state
86
98
 
87
99
  ```tsx
@@ -27,6 +27,7 @@ Use Scroller to create a contained overflow region for content that naturally ex
27
27
  | --- | --- | --- | --- |
28
28
  | orientation | "vertical" \| "horizontal" | "vertical" | "vertical" scrolls and fades top/bottom; "horizontal" scrolls and fades left/right. |
29
29
  | wheelScroll | boolean | true | On a horizontal Scroller, let a vertical mouse wheel scroll it sideways. Ignored when vertical. Turn it off only if the region is inside something that owns the wheel itself (a map, a canvas). |
30
+ | smoothWheel | boolean | true | Ease mouse-wheel steps toward their target, on either axis: the inertia a smooth-scroll library such as Lenis adds, scoped to this region. Only a mouse wheel is eased; trackpads and touch keep their own momentum, keys and scrollbar drags stay native, and reduced motion (the OS preference or MotionProvider) turns it off. Anything else that moves the region (scrollIntoView, a stick-to-bottom chat) stops the easing at once. For a scroll container of your own, `useSmoothWheel()` returns the same behaviour as a ref callback. |
30
31
  | label | string | — | Accessible name for the scroll region. The region takes focus, so it should say what it contains; without one a screen reader announces an unnamed group. |
31
32
  | viewportClassName | string | — | Classes for the inner element that actually scrolls. Narrow on purpose — `className` is what you want almost always. This is for the few utilities that only mean something on the scrolling box: `snap-x`, `scroll-smooth`, `scroll-p-*`. |
32
33
  | ...div props | ComponentPropsWithRef<'div'> | — | Everything except `className`/`style` spreads onto the SCROLLING element — `role`, `id`, `aria-*`, `data-*` and event handlers all describe the thing that scrolls, and CommandMenu relies on it to make this component its listbox. `ref` and `onScroll` likewise address the scroller, and compose with the component's own. |
@@ -4,7 +4,7 @@
4
4
 
5
5
  Edge-anchored drawer over Base UI Drawer — focus trap, scroll lock, Esc/outside dismissal, portal backdrop, plus swipe-to-dismiss on touch. Slides in from a viewport edge via side and swipes back out toward the same edge.
6
6
 
7
- **Keywords:** sheet, drawer, side panel, overlay, swipe to dismiss
7
+ **Keywords:** sheet, drawer, side panel, overlay, swipe to dismiss, bottom sheet, snap points, detents
8
8
 
9
9
  ## Usage
10
10
 
@@ -14,6 +14,7 @@ Use Sheet for secondary workflows that slide in while preserving context from th
14
14
 
15
15
  - Do: Use it for inspect, edit, filter, or create flows that relate to the current page.
16
16
  - Do: Include clear title, close path, and footer actions for task-oriented sheets.
17
+ - Do: Give a bottom sheet snap points when it shows a summary first and detail on demand (a map result, a now-playing panel): `snapPoints={[0.4, 1]}`. The first stop is where it opens; put what matters at that height, because the rest is a drag away.
17
18
  - Don't: Do not use Sheet for simple confirmations or deeply nested workflows.
18
19
  - Don't: Do not use it on desktop where a Dialog fits. A sheet's affordance is edge-anchored travel for thumb reach; on a wide viewport it becomes a dialog that arrives from the wrong place.
19
20
 
@@ -34,7 +35,7 @@ Use Sheet for secondary workflows that slide in while preserving context from th
34
35
 
35
36
  | Name | Type | Description |
36
37
  | --- | --- | --- |
37
- | SheetContent | Base UI Drawer.Popup props (initialFocus, finalFocus, …) + side: "right" \| "left" \| "top" \| "bottom" + className | Backdrop + edge-anchored popup; portals to <body>. side picks which viewport edge the sheet anchors to and slides in from (defaults to "right"). Left/right are full-height side panels; top/bottom span the width up to 80vh. On touch it swipes closed toward that same edge (right swipes right, left swipes left, top swipes up, bottom swipes down). |
38
+ | SheetContent | Base UI Drawer.Popup props (initialFocus, finalFocus, …) + side: "right" \| "left" \| "top" \| "bottom" + className | Backdrop + edge-anchored popup; portals to <body>. side picks which viewport edge the sheet anchors to and slides in from (defaults to "right"). Left/right are full-height side panels; top/bottom span the width up to 80vh. A bottom sheet with snapPoints is as tall as its highest stop allows (the viewport less a top gap), shows a drag handle, and translates down to the active stop; the part below the screen becomes bottom padding, so its scroll area and SheetFooter end at the visible edge. A bottom sheet rides above the on-screen keyboard (measured from visualViewport). On touch it swipes closed toward that same edge (right swipes right, left swipes left, top swipes up, bottom swipes down). |
38
39
  | SheetTrigger | Base UI Drawer.Trigger props (render) | Element that opens the sheet. Compose via render prop. |
39
40
  | SheetClose | Base UI Drawer.Close props (render) | Element that dismisses the sheet. Compose via render prop. |
40
41
  | SheetHeader | ComponentProps<"div"> | Top section holding SheetTitle/SheetDescription. |
@@ -47,7 +48,11 @@ Use Sheet for secondary workflows that slide in while preserving context from th
47
48
 
48
49
  | Name | Type | Default | Description |
49
50
  | --- | --- | --- | --- |
50
- | Sheet (Root) | Base UI Drawer.Root props (open, defaultOpen, onOpenChange, onOpenChangeComplete, modal, disablePointerDismissal, actionsRef, handle) — minus swipeDirection and snap points | — | Controls open state. Uncontrolled by default. The dismiss swipe direction is not a prop: it follows SheetContent's side. onOpenChange reports reason "swipe" when a swipe closed it. |
51
+ | Sheet (Root) | Base UI Drawer.Root props (open, defaultOpen, onOpenChange, onOpenChangeComplete, modal, disablePointerDismissal, actionsRef, handle, snapPoints, snapPoint, defaultSnapPoint, onSnapPointChange, snapToSequentialPoints) — minus swipeDirection | — | Controls open state. Uncontrolled by default. The dismiss swipe direction is not a prop: it follows SheetContent's side. onOpenChange reports reason "swipe" when a swipe closed it. |
52
+ | snapPoints | (number \| string)[] | — | Stops a bottom sheet rests at, smallest first: a fraction of the viewport (0.4), pixels (320), or a px/rem string ("20rem"). The panel is the full stop's height and opens at the first stop; a swipe moves between stops and a swipe below the first closes it. side="bottom" only: other sides ignore them, with a development warning. |
53
+ | snapPoint | number \| string \| null | — | The active stop, controlled. Pair with onSnapPointChange; use defaultSnapPoint to open at a different stop uncontrolled. |
54
+ | onSnapPointChange | (snapPoint: number \| string \| null, eventDetails) => void | — | Fires when a swipe (or your code) moves the sheet to another stop. |
55
+ | snapToSequentialPoints | boolean | false | Move one stop per swipe, by distance. Off, a fast flick can skip past a stop. |
51
56
 
52
57
  ## Variants
53
58
 
@@ -67,6 +72,8 @@ Use Sheet for secondary workflows that slide in while preserving context from th
67
72
  - Render a SheetClose inside SheetContent so screen-reader users can escape the popup.
68
73
  - Slide animation runs via data-[starting-style]/data-[ending-style] attributes on the motion-duration token; while swiping the panel tracks the finger directly, and a released swipe finishes on the same token (scaled by flick speed), so reduced motion makes it instant.
69
74
  - A mouse drag that starts on sheet content selects text rather than dragging the panel; mouse dragging only starts from the padding edge.
75
+ - Snap points add stops, not a gate: at any stop the content scrolls within the visible part and every control stays reachable by keyboard, so nothing needs the drag. The handle is decorative (aria-hidden).
76
+ - A bottom sheet stays above the on-screen keyboard: its bottom edge is lifted by the height the keyboard takes from the visual viewport, so a focused field is not covered. An app that sets interactive-widget=resizes-content in its viewport meta gets the same result from the browser, and the inset reads 0.
70
77
 
71
78
  ## Related
72
79
 
@@ -98,6 +105,14 @@ import { Button, Sheet, SheetClose, SheetContent, SheetFooter, SheetHeader, Shee
98
105
  <Sheet><SheetTrigger render={<Button variant="secondary">Menu</Button>} /><SheetContent side="left"><SheetHeader><SheetTitle>Navigation</SheetTitle></SheetHeader><SheetFooter><SheetClose render={<Button variant="secondary">Close</Button>} /></SheetFooter></SheetContent></Sheet>
99
106
  ```
100
107
 
108
+ ### Bottom sheet with snap points
109
+
110
+ ```tsx
111
+ import { Button, Entity, Sheet, SheetContent, SheetDescription, SheetHeader, SheetList, SheetTitle, SheetTrigger } from "@sableui/react"
112
+
113
+ <Sheet snapPoints={[0.4, 1]}><SheetTrigger render={<Button variant="secondary">Nearby</Button>} /><SheetContent side="bottom"><SheetHeader><SheetTitle>12 places nearby</SheetTitle><SheetDescription>Drag up for the full list.</SheetDescription></SheetHeader><SheetList>{places.map((place) => <Entity key={place.id} title={place.name} subtitle={place.distance} />)}</SheetList></SheetContent></Sheet>
114
+ ```
115
+
101
116
  ### Bottom side
102
117
 
103
118
  ```tsx
@@ -14,6 +14,7 @@ Use Sidebar for persistent desktop navigation or application chrome outside the
14
14
 
15
15
  - Do: Use it for stable app destinations that should remain visible while content changes.
16
16
  - Do: Keep item labels predictable and active state obvious.
17
+ - Do: Set surface="page" when a full-width top bar sits above the rail. A theme's tinted or dark rail is for a full-height rail that frames the app.
17
18
  - Don't: Do not use Sidebar for page-local settings, filters, or temporary action groups.
18
19
  - Don't: Do not put page content in it. It is fixed-width chrome OUTSIDE the content grid, so anything in it is exempt from the page's measure and rhythm.
19
20
 
@@ -39,6 +40,7 @@ Use Sidebar for persistent desktop navigation or application chrome outside the
39
40
  | collapsed | boolean | false | Collapse the rail to an icon-only width (--sidebar-width-collapsed). Items hide their label + badge and reveal the label in a hover tooltip; the content area reflows. Controlled — wire a toggle from app state. Read the state in custom header/footer content via useSidebar(). |
40
41
  | icons | boolean | true | Render the leading icon on each SidebarItem. Set false for a text-only rail. Ignored while collapsed (icon-only by definition). |
41
42
  | variant | "app" \| "inline" | "app" | Where the rail lives. "app" is the page's chrome column — sticky, full viewport height, hidden below lg where AppHeader + BottomNav take over. "inline" is always visible and fills its parent's height, for a rail inside a bounded frame (a nested shell, an embedded preview). Use it instead of overriding the layout with !important classes. |
43
+ | surface | "rail" \| "page" | "rail" | Which surface the rail is painted on. "rail" is its own --sidebar-* surface with a border — right for a full-height rail that frames the app, which a theme may tint or paint dark. "page" uses the page's surface and ink with no border — use it when a full-width header sits above the rail, where a tinted column starting under a page-coloured bar reads as pasted on. Use it instead of overriding bg-/text- classes, which the rail's re-pointed tokens defeat. |
42
44
 
43
45
  ## Variants
44
46
 
@@ -12,6 +12,7 @@ Use StreamingText for text whose final length is unknown while it renders — a
12
12
 
13
13
  ### Best practices
14
14
 
15
+ - Do: In a chat, read `sable docs building-a-chat`: one status drives `streaming` and the composer, a regenerated answer takes a new key (the text is append-only), and the finished turn is announced once.
15
16
  - Do: Grow `text` and let the component do the rest. It assumes an append-only string and keys units by index, so React mounts only the new spans — re-rendering a longer string animates what was added and leaves what the reader has already read alone.
16
17
  - Do: Drop `streaming` to false the moment the source closes. That is what removes the caret and the softened tail, and their disappearance is the only signal the reader gets that the answer is complete.
17
18
  - Do: Turn `caret` off when something adjacent already reports the wait — a spinner in the message header, a status row beneath. Two indicators for one wait read as two things happening.
@@ -74,7 +75,7 @@ import { StreamingText } from "@sableui/react"
74
75
  ```tsx
75
76
  import { Message, StreamingText } from "@sableui/react"
76
77
 
77
- <Message role="assistant"><StreamingText text={answer} streaming={pending} caret={false} /></Message>
78
+ <Message align="start"><StreamingText text={answer} streaming={pending} caret={false} /></Message>
78
79
  ```
79
80
 
80
81
  ### A headline resolving character by character
@@ -6,6 +6,7 @@ A full-height conversation surface: titled header, scrolling transcript, and a c
6
6
 
7
7
  ## Notes
8
8
 
9
+ - This is the assembled screen with sample data. To wire it to a real streaming source (a hook such as useChat, or your own transport), read `sable docs building-a-chat`: who owns what, the turn lifecycle mapped to props, and every failure state.
9
10
  - The panel is a fixed-height column, not a growing page. `h-[calc(100dvh_-_--spacing(12))]` on the Card (the viewport minus the Page's py-6, in spacing units so density moves both together — a fixed `3rem` drifts the moment density moves the padding) and `flex-1 min-h-0` on the MessageScroller are what make the transcript scroll instead of the document — the single most common thing to get wrong here, and it fails by pushing the composer off the bottom of the screen.
10
11
  - The panel is a Card, not a hand-built bordered section: CardHeader (title, description, CardAction), the MessageScroller and a CardFooter all inset by the card's own `--card-p`, so the three regions share one edge at any card density.
11
12
  - `dvh` rather than `vh`: on mobile the URL bar collapses, and a `vh`-sized panel puts the composer under it exactly when someone is trying to type.
@@ -245,7 +246,9 @@ export function ChatPanel() {
245
246
  onStop={() => undefined}
246
247
  stopLabel="Stop answering"
247
248
  onAttach={() => undefined}
248
- footer="Enter to send · Shift+Enter for a new line"
249
+ // A keyboard hint, so only where Enter sends: on touch Return
250
+ // breaks the line and the button sends, and the row goes away.
251
+ footer={({ sendOn }) => (sendOn === "enter" ? "Enter to send · Shift+Enter for a new line" : null)}
249
252
  // Model and effort sit in `actions`, at the send end of the row:
250
253
  // they qualify the message about to be sent, so they belong beside
251
254
  // the button that sends it rather than next to the attach control.
@@ -342,7 +342,7 @@ Which token to reach for, and what it is most often confused with. Generated fro
342
342
  More colour for apps, generated from the active theme: neutral and eight hue families, plus a 50–950 scale under every tone. Each family keeps its hue and leans toward the brand (half the gap, at most 15°; --theme-palette-harmony scales it, 0 = own hues). Neutral is the theme's --background mixed toward its --foreground. Steps follow the mode: low steps sit near the page and high ones far from it in light and dark alike, so a pair needs no dark: variant. For decorative and categorical colour (tags, avatars, labels, legends, illustration); surfaces, text and actions take roles. Components never name a step.
343
343
 
344
344
  - **Families:** `neutral`, `red`, `orange`, `yellow`, `green`, `teal`, `blue`, `purple`, `pink`, each 50–950.
345
- - **Tone scales:** `tone-1-*`, `tone-2-*`, `tone-3-*`, `tone-info-*`, `tone-warning-*`, `success-*`, `destructive-*`; step 500 is the tone itself.
345
+ - **Tone scales:** `tone-1-*`, `tone-2-*`, `tone-3-*`, `tone-info-*`, `tone-warning-*`, `success-*`, `destructive-*`; one ladder of depths shared by every tone, with the tone exactly on the step nearest its own lightness (500 for a mid tone, 800 for a light status colour in dark mode). Use the tone token itself for the tone.
346
346
  - **Use as:** bg-blue-100, text-blue-800, border-neutral-200, bg-tone-1-100 — or var(--teal-500). Tailwind's other families (slate, gray, zinc, stone, amber, lime, emerald, cyan, sky, indigo, violet, fuchsia, rose) are switched off.
347
347
  - **tag / label:** bg-<family>-100 text-<family>-800 (held to 4.5:1 in every theme and mode)
348
348
  - **text on the page:** text-<family>-700
@@ -0,0 +1,158 @@
1
+ # Task: building a chat
2
+
3
+ Sable ships the parts a conversation is made of, and the [`ChatPanel`
4
+ pattern](../patterns/chat-panel.md) assembles them into a working screen. It
5
+ ships no transport, no message store and no model client. This guide is the
6
+ seam between the two: which part owns what, how a request's lifecycle maps
7
+ onto the parts' props, what each failure looks like, and what is left for the
8
+ app.
9
+
10
+ Check every name here against the installed package with
11
+ `sable component <Name> --detail full` before relying on it.
12
+
13
+ ## Who owns what
14
+
15
+ | Sable part | Owns | Does not own |
16
+ |---|---|---|
17
+ | [`MessageScroller`](../components/message-scroller.md) | The scroll region: following the newest message, letting go when the reader scrolls up, the jump-to-latest button, holding still when history is prepended, `scrollToMessage`, `onVisibleMessagesChange` | Your messages, your loading of history |
18
+ | [`Message`](../components/message.md) | One turn's row: side (`align`), avatar gutter, `header` and `footer`, `grouped` runs, `messageId` | How the content looks |
19
+ | [`Bubble`](../components/bubble.md) | The surface: `muted` (them), `primary` (me), `plain` (long-form answers), the tail, reactions | Timestamps and names (those go in Message's `header`/`footer`) |
20
+ | [`StreamingText`](../components/streaming-text.md) | Text arriving a token at a time: `text` (append-only), `streaming`, the caret, `aria-busy` | Markdown, and announcing the finished answer |
21
+ | [`ThinkingOrb`](../components/thinking-orb.md), [`Marker`](../components/marker.md) | The wait before the first token (orb or `Marker icon="spinner" spin`), and every non-message row: status, tool notes, date breaks, bordered notices | — |
22
+ | [`Attachment`](../components/attachment.md) | A file's four states: queued, `uploading` with `progress`, `error` with the reason in `meta`, delivered | The upload itself |
23
+ | [`ChatComposer`](../components/chat-composer.md) | The input: Enter to send (never mid-IME; Return breaks the line on touch), `busy` + `onStop`, `allowEmpty` for attachment-only sends, `disabled`, `invalid`, `footer` as a function of the send key in effect | Whether the send succeeded |
24
+ | [`Toast`](../components/toast.md) | Notices about the app, not the conversation | — |
25
+
26
+ **The app owns** the transport (HTTP, SSE, WebSocket), the streaming state, message ids, persistence and history, retries and backoff, auth, rate limits, moderation, uploads, and what counts as a failed turn. Sable renders whatever state you hand it; it never infers one.
27
+
28
+ ## The lifecycle, mapped to props
29
+
30
+ A turn moves through five states. Keep **one** status value in the app and derive every prop from it; two flags (`sending` here, `streaming` there) drift, and the composer ends up saying "sending" under an answer that already finished.
31
+
32
+ | State | Transcript | Composer |
33
+ |---|---|---|
34
+ | **idle** | Settled messages. The last answer's `footer` carries its actions (copy, regenerate) as a `Toolbar` of icon `ToolbarButton`s | Normal; `busy={false}` |
35
+ | **submitted** (sent, no token yet) | The reader's message, optimistically, with the id you generated. Below it, the wait: `<Marker icon="spinner" spin shimmer>Thinking…</Marker>` or a `ThinkingOrb` | `busy` and `onStop` |
36
+ | **streaming** | The wait is **replaced** by the answer: `StreamingText` with `streaming` in a `plain` Bubble. No answer actions yet | `busy` and `onStop` |
37
+ | **done** | `streaming={false}`, which removes the caret: the only signal the reader gets that the answer is complete. Actions appear in the `footer` | `busy={false}` |
38
+ | **stopped / error** | See [Failures](#failures) | `busy={false}`; the draft is still the reader's |
39
+
40
+ Rules that follow from it:
41
+
42
+ - **One indicator per wait.** The spinner Marker (or orb) shows before the first token and is removed when tokens land. While the answer streams, its own caret is the indicator; pass `caret={false}` only when something adjacent already reports the wait.
43
+ - **`busy` is not `disabled`.** `busy` is temporary and comes with a way out (`onStop` turns send into stop). `disabled` is for a composer that cannot send at all: offline, over a quota, a read-only thread.
44
+ - **Controlled composers are cleared by you, after the send is accepted.** An uncontrolled composer clears itself; a controlled one leaves the text so a failed send loses nothing.
45
+ - **Regenerate replaces the answer, not appends to it.** `StreamingText` assumes `text` only grows. When regenerating, give the new answer a new `key` (a new message id does it) so the old spans unmount instead of being diffed against.
46
+ - **Editing a sent message** is the app's feature: put the text back in a controlled composer, or swap the Bubble's content for a `Textarea` in place, then truncate the turns after it before re-sending. Sable has no edit mode on Message.
47
+
48
+ ## Scrolling
49
+
50
+ `MessageScroller` needs no configuration for streaming. It follows the newest message as messages arrive **and** as the last one grows, and stops following the moment the reader scrolls up past `followThreshold` (64px). The jump-to-latest button appears while they are away; returning to the bottom resumes following.
51
+
52
+ - Bound it: `flex-1 min-h-0` inside a flex column. A transcript that grows the page pushes the composer off the screen.
53
+ - Never call `scrollToBottom` per token. It fights a reader who scrolled up to read. Call it once when **they** send, so their own message is in view.
54
+ - Give every `Message` a `messageId`; it is the handle for `scrollToMessage`, `initialScroll={{ messageId }}` (restore a read position) and `onVisibleMessagesChange` (read receipts).
55
+ - Load older history by prepending to `children`. The scroller anchors on the first rendered message, so the view does not jump.
56
+
57
+ ## Attachments
58
+
59
+ - **Pending** files are `Attachment` chips in the composer's `attachments`, each with a remove action. Upload when picked, not when sent, so `status="uploading"` and `progress` can show in the box the file belongs to.
60
+ - **Failed** uploads keep their card: `status="error"`, the reason in `meta` ("Over the 50 MB limit"), a Retry action. Colour alone is not a status.
61
+ - **Attachment-only sends:** `allowEmpty={files.length > 0}`. `onSend` then receives `""`. Tie it to the pending files; a constant `allowEmpty` leaves send enabled with nothing to send. Disable sending while an upload is still in flight if your backend needs the file first.
62
+ - **Sent** files move into the turn: one `Message` holding the files and the text together, `variant="media"` for images, `variant="card"` for documents, `onOpen` to preview.
63
+
64
+ ## Failures
65
+
66
+ Put each failure where the reader is looking when it happens. A failure that belongs to a turn stays in the transcript, next to that turn; a toast is for things that are not about the conversation.
67
+
68
+ | Failure | UI |
69
+ |---|---|
70
+ | **Send rejected** (network down, 4xx before streaming) | The optimistic message stays, with a Message `footer` of `<InlineStatus tone="destructive">Not sent</InlineStatus>` and a Retry button. The composer keeps the draft if it was controlled |
71
+ | **Error mid-stream** | Keep what streamed, set `streaming={false}`, and add `<Marker variant="bordered" icon="warning">The answer was cut off.</Marker>` with Retry in the answer's `footer`. Never delete partial text the reader may be reading |
72
+ | **Stopped by the reader** | Keep the partial answer, `streaming={false}`, and a quiet `<Marker variant="note">Stopped</Marker>`. Not an error, so no warning colour |
73
+ | **Rate limited / quota** | `ChatComposer disabled`, with the reason and the wait in its `footer` ("Limit reached · try again in 2 min"). Re-enable it when the window passes |
74
+ | **Empty response** | Treat as an error: a bordered Marker ("No answer came back") and Retry. An empty plain Bubble reads as a rendering bug |
75
+ | **Tool call failed** | A `Marker variant="note" icon="warning"` in that turn's `header` ("Search failed: timeout"). The answer may continue without it; say so rather than hiding the failure |
76
+ | **Disconnected** (socket dropped) | A `Banner` above the transcript, or a toast if the panel is small, and `disabled` on the composer until reconnected |
77
+ | **Moderation / blocked** | A bordered Marker in place of the answer, naming what happened. Don't render the blocked content and then hide it |
78
+
79
+ ## Accessibility
80
+
81
+ - **Don't put streamed text in a live region.** Every token would be announced. `StreamingText` sets `aria-busy` while streaming instead.
82
+ - **Announce the turn once, when it settles.** Keep an empty `<p role="status" className="sr-only">` mounted from the start, and set its text when a turn ends ("Answer ready", "Answer stopped", "Couldn't answer"). A region inserted together with its text is announced unreliably (`sable docs AGENTS` → Announcing and focusing).
83
+ - **A status Marker is already live** (`role="status"`), so replacing its text as the work moves on ("Searching the docs" → "Writing the answer") is announced. Remove it when the work ends; a permanent "Thinking…" is a region that never resolves.
84
+ - **ThinkingOrb's `label` must stay constant** while it is mounted. Put the changing phase in adjacent text.
85
+ - **Focus:** after sending, keep focus in the composer (`fieldRef`) so the reader can keep typing. After Stop, ChatComposer moves focus to the field itself (the stop button turns back into send in place, and a disabled send would drop focus to the page); on touch it leaves focus alone rather than raise the keyboard.
86
+ - **Reduced motion:** StreamingText, ThinkingOrb, the Marker shimmer and the scroller's smooth scrolling all fall back under `prefers-reduced-motion` or `MotionProvider` off. Don't add your own animation that ignores it.
87
+
88
+ ## Rendering the model's output
89
+
90
+ Sable has no Markdown renderer; use one (react-markdown, or your own parser) and map its elements to Sable parts:
91
+
92
+ - the answer sits in `<Bubble variant="plain">`, which drops the surface and the measure cap; give it `max-w-full` so a wide code line scrolls inside its block rather than pushing the transcript sideways on a phone;
93
+ - fenced code → `CodeBlock` (copy button included; wire highlighting once with `CodeHighlightProvider`);
94
+ - inline code → `Code`; tables → `Table` parts; links → your router's link or a plain `<a>` with `rel="noopener noreferrer"` for outside URLs;
95
+ - paragraphs and lists take the prose tier (`useTypeTier().prose`) rather than a hand-picked size.
96
+
97
+ **Never render model output as HTML** (`dangerouslySetInnerHTML`, or a Markdown renderer with raw HTML on). Model output is untrusted input. Strip or escape HTML, and allow only `http(s)` and `mailto` link targets.
98
+
99
+ While an answer streams, render it with `StreamingText` (plain text); switch to the Markdown renderer when it settles, or render Markdown progressively if your renderer tolerates half-finished syntax (an unclosed fence). Either way the switch must not move the text the reader is on.
100
+
101
+ ## Wiring a chat hook
102
+
103
+ The shape below fits most streaming chat hooks. The hook's names (`messages`, `status`, `send`, `stop`, `regenerate`, `error`) belong to **your library**, not Sable. The AI SDK's `useChat`, for example, exposes a `status` of `"submitted" | "streaming" | "ready" | "error"`; check the version you have installed.
104
+
105
+ ```tsx
106
+ import {
107
+ Bubble, ChatComposer, Marker, Message, MessageScroller, StreamingText,
108
+ } from "@sableui/react"
109
+
110
+ function Chat() {
111
+ const { messages, status, send, stop } = useYourChat() // your library
112
+ const busy = status === "submitted" || status === "streaming"
113
+ const last = messages.at(-1)
114
+ return (
115
+ <div className="flex h-full min-h-0 flex-col">
116
+ <MessageScroller className="flex-1 min-h-0 px-4 py-6">
117
+ {messages.map((m) => {
118
+ const mine = m.role === "user"
119
+ const live = status === "streaming" && m.id === last?.id && !mine
120
+ return (
121
+ <Message key={m.id} messageId={m.id} align={mine ? "end" : "start"}>
122
+ <Bubble variant={mine ? "primary" : "plain"} align={mine ? "end" : "start"} className={mine ? undefined : "max-w-full"}>
123
+ {mine ? m.text : <StreamingText text={m.text} streaming={live} />}
124
+ </Bubble>
125
+ </Message>
126
+ )
127
+ })}
128
+ {status === "submitted" ? <Marker icon="spinner" spin shimmer>Thinking…</Marker> : null}
129
+ </MessageScroller>
130
+ <ChatComposer
131
+ onSend={(text) => send(text)}
132
+ busy={busy}
133
+ onStop={stop}
134
+ footer={({ sendOn }) => (sendOn === "enter" ? "Enter to send · Shift+Enter for a new line" : null)}
135
+ />
136
+ </div>
137
+ )
138
+ }
139
+ ```
140
+
141
+ For the full screen (header, grouping, avatars, answer actions, the empty state), start from `sable pattern ChatPanel --skeleton` and replace its sample data with your hook. For a chat docked beside other work, compose the same three parts inside a `Sheet` or a panel and wrap them in `<Density value="compact">`.
142
+
143
+ ## What Sable does not do
144
+
145
+ - No transport, retries or reconnection.
146
+ - No message store, ids, history paging or persistence; `MessageScroller` only renders what you pass.
147
+ - No Markdown parsing or sanitising.
148
+ - No mentions or slash commands in `ChatComposer` (it holds a plain string).
149
+ - No typing indicators or presence from other people; compose them from `Marker` and `Avatar` status if your product has them.
150
+
151
+ ## Before you ship
152
+
153
+ 1. `sable check src` passes.
154
+ 2. Walk the journey (`sable docs reviewing-a-journey`) through every row of the lifecycle and failure tables: send, wait, stream, stop mid-answer, regenerate, a failed send, an error mid-stream, attachment-only send, an upload failure, and a reload that restores the thread.
155
+ 3. Scroll up mid-stream: the view must stay put, and jump-to-latest must appear and work.
156
+ 4. Keyboard only, then a screen reader: the finished answer is announced once; Stop is reachable from the composer; nothing announces per token.
157
+ 5. On a phone: Return breaks the line, the send button sends, the composer stays above the on-screen keyboard (`dvh`, not `vh`), and wide code scrolls inside its block.
158
+ 6. Reduced motion on: text still arrives, nothing keeps moving after the turn settles (`sable docs reviewing-motion`).
@@ -25,6 +25,7 @@ $sable inspire these references into a warm, compact shop preset
25
25
  $sable build a ceramics shop using .sable/presets/clay.json
26
26
  $sable audit our checkout and report prioritized findings
27
27
  $sable polish our checkout and repair the reported issues
28
+ $sable fix the settings dialog's footer buttons
28
29
  ```
29
30
 
30
31
  Claude Code and Cursor use `/sable` with the same modes. `create` is a `build`
@@ -32,7 +33,9 @@ alias. These are agent instructions; the shell CLI serves catalog resources and
32
33
  validates records. Other agents can read the package's `skills/sable/SKILL.md`.
33
34
 
34
35
  `inspire` produces a preset and component preview. `audit` evaluates an existing
35
- interface and reports by default. A reference URL does not request a cloned site.
36
+ interface and reports by default. `fix` repairs one scoped defect: it reads only the
37
+ contracts the change touches and finishes with `sable check` on the changed files
38
+ and a look at the affected state, escalating to `polish` when the change grows. A reference URL does not request a cloned site.
36
39
  Develop alternative rendered directions when requested or when a consequential
37
40
  choice needs comparison. A three-design comparison is not a mandatory build step.
38
41
 
@@ -3,6 +3,11 @@
3
3
  Migrating a React app that has its own styles. The order below front-loads the
4
4
  decisions that invalidate later work if you get them wrong.
5
5
 
6
+ Coming from **shadcn/ui or Radix**? Do this order, then use
7
+ [migrating-from-shadcn.md](./migrating-from-shadcn.md) for step 3: it maps
8
+ `asChild` → `render`, array values, renamed parts and events, the tokens and
9
+ icons, and every shadcn component to its Sable counterpart.
10
+
6
11
  ## 0. Classify every surface first
7
12
 
8
13
  Before touching a component, **list the routes and assign a register to each** —
@@ -147,5 +152,7 @@ layers panel lost 174 lines by adopting `Tree`.
147
152
 
148
153
  - [building-a-tool.md](./building-a-tool.md) or
149
154
  [building-a-page.md](./building-a-page.md)
155
+ - [migrating-from-shadcn.md](./migrating-from-shadcn.md) — the per-component map from shadcn/ui and Radix
156
+ - [extending-a-component.md](./extending-a-component.md) — what to do where the app used to edit a component's source
150
157
  - `DESIGN.md → Registers`
151
158
  - `llms/reference/token-intent.md`