@sableui/react 0.10.0 → 0.12.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 (91) hide show
  1. package/AGENTS.md +10 -4
  2. package/CHANGELOG.md +184 -0
  3. package/DESIGN.md +1 -1
  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/panel-section/index.js +2 -1
  36. package/dist/components/panel-section/panel-section.js +15 -0
  37. package/dist/components/panel-section/panel-section.meta.js +32 -0
  38. package/dist/components/scroller/scroller.js +8 -4
  39. package/dist/components/scroller/scroller.meta.js +6 -0
  40. package/dist/components/sheet/sheet.js +82 -9
  41. package/dist/components/sheet/sheet.meta.js +41 -4
  42. package/dist/components/sheet/sheet.variants.js +8 -1
  43. package/dist/components/sidebar/sidebar.js +13 -2
  44. package/dist/components/sidebar/sidebar.meta.js +10 -0
  45. package/dist/components/streaming-text/streaming-text.meta.js +5 -1
  46. package/dist/file-hashes.json +50 -34
  47. package/dist/icon-sets/reicon.js +2 -0
  48. package/dist/index.d.ts +687 -164
  49. package/dist/index.js +12 -1
  50. package/dist/lib/taxonomy.js +5 -0
  51. package/dist/lib/use-caret-trigger.js +187 -0
  52. package/dist/lib/use-copy.js +6 -1
  53. package/dist/lib/use-smooth-wheel.js +164 -0
  54. package/dist/patterns/chat-panel.js +1 -1
  55. package/dist/patterns/chat-panel.meta.js +1 -0
  56. package/dist/styles.css +25 -0
  57. package/llms/components/app-header.md +1 -1
  58. package/llms/components/button-group.md +55 -0
  59. package/llms/components/button.md +1 -1
  60. package/llms/components/caret-menu.md +100 -0
  61. package/llms/components/chat-composer.md +24 -7
  62. package/llms/components/combobox.md +12 -0
  63. package/llms/components/input-group.md +78 -0
  64. package/llms/components/menu.md +16 -1
  65. package/llms/components/menubar.md +110 -0
  66. package/llms/components/message-scroller.md +1 -0
  67. package/llms/components/navigation-menu.md +113 -0
  68. package/llms/components/overflow-menu.md +26 -1
  69. package/llms/components/pagination.md +15 -3
  70. package/llms/components/panel-section.md +28 -0
  71. package/llms/components/scroller.md +1 -0
  72. package/llms/components/sheet.md +18 -3
  73. package/llms/components/sidebar.md +2 -0
  74. package/llms/components/streaming-text.md +2 -1
  75. package/llms/patterns/chat-panel.md +4 -1
  76. package/llms/tasks/building-a-chat.md +158 -0
  77. package/llms/tasks/building-with-sable.md +4 -1
  78. package/llms/tasks/converting-an-app.md +7 -0
  79. package/llms/tasks/extending-a-component.md +170 -0
  80. package/llms/tasks/getting-started.md +22 -4
  81. package/llms/tasks/migrating-from-shadcn.md +351 -0
  82. package/llms-full.txt +1349 -26
  83. package/llms.txt +14 -5
  84. package/package.json +3 -2
  85. package/registry.json +1037 -53
  86. package/scripts/lint-rules.mjs +35 -0
  87. package/scripts/wiring.mjs +541 -0
  88. package/skills/entry/SKILL.md +3 -2
  89. package/skills/sable/SKILL.md +26 -5
  90. package/targets/ios.json +45 -0
  91. package/dist/lib/use-wheel-to-horizontal.js +0 -65
@@ -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 PanelSection to group properties inside an inspector or docked panel, with P
27
27
  | Header | yes | The section title in the UI section tier, with an optional trailing action. |
28
28
  | PropertyRow | no | One property: label left, control right. `stacked` moves the control to its own line for full-width controls. |
29
29
  | PropertyPair | no | Two controls side by side, with optional captions above each. |
30
+ | PropertyGroup | no | A set of properties folded behind one row that names the group and summarises its value, opening with +/− to the controls beneath. |
30
31
 
31
32
  ## Subcomponents
32
33
 
@@ -34,6 +35,7 @@ Use PanelSection to group properties inside an inspector or docked panel, with P
34
35
  | --- | --- | --- |
35
36
  | PropertyRow | { label: ReactNode; htmlFor?: string; hint?: string; stacked?: boolean; scrub?: { value, onValueChange, step?, min?, max?, onGestureStart?, onGestureEnd?, disabled? } } | One property line. `hint` becomes the label's hover explanation; `stacked` puts the control on its own line. `scrub` makes the property NAME a drag handle for its value — the Figma/Framer/Paper convention, and the answer for a numeric row with no honest glyph to put in a ScrubField (a schema-generated `levels` or `seed`, where a `#` would be noise pretending to be meaning). Opt-in and additive: a row without it is unchanged. The label stays a real `<label htmlFor>` — a 2px jitter threshold keeps a click a click, and the click synthesised after a real drag is swallowed so a scrub does not also select the field's text. |
36
37
  | PropertyPair | { labels?: [ReactNode, ReactNode] } | Two controls side by side in a 2-up grid, with optional captions above each. |
38
+ | PropertyGroup | { label: ReactNode; summary?: ReactNode; defaultOpen?: boolean; open?: boolean; onOpenChange?: (open: boolean) => void } | A collapsible set of properties inside a section, at the panel's scale (an Accordion is page furniture). The row shows the label, then the summary (the group's value, readable while closed: a word, a few swatches, or both), then a + that turns to − when open. Groups open independently and stack with a hairline between them. Built on Collapsible. |
37
39
 
38
40
  ## Props
39
41
 
@@ -48,6 +50,7 @@ Use PanelSection to group properties inside an inspector or docked panel, with P
48
50
 
49
51
  ## Accessibility
50
52
 
53
+ - PropertyGroup's row is a real button with aria-expanded (Collapsible); its summary is part of the button's name, so a closed group still announces its value.
51
54
  - PropertyRow renders a real <label>; pass htmlFor with the control's id so clicking the label focuses it.
52
55
  - The section title is an h3, so panels contribute a real heading outline rather than styled divs.
53
56
  - `hint` is exposed as the label's title attribute — keep the essential meaning in the label itself, since title text is not reliably announced.
@@ -59,6 +62,31 @@ Use PanelSection to group properties inside an inspector or docked panel, with P
59
62
 
60
63
  ## Examples
61
64
 
65
+ ### Groups that fold behind their value
66
+
67
+ ```tsx
68
+ import { PanelSection, PropertyGroup, PropertyRow, ScrubField, Select, SelectContent, SelectItem, SelectTrigger } from "@sableui/react"
69
+
70
+ <PanelSection title="Color">
71
+ <PropertyGroup label="Tones" summary="Brand only">
72
+ <PropertyRow label="Scheme">
73
+ <Select value="brand">
74
+ <SelectTrigger size="xs" />
75
+ <SelectContent>
76
+ <SelectItem value="brand">Brand only</SelectItem>
77
+ <SelectItem value="harmonize">Harmonize</SelectItem>
78
+ </SelectContent>
79
+ </Select>
80
+ </PropertyRow>
81
+ </PropertyGroup>
82
+ <PropertyGroup label="Grid" summary="8px">
83
+ <PropertyRow label="Size">
84
+ <ScrubField label="Grid size" value={8} min={1} max={64} suffix="px" />
85
+ </PropertyRow>
86
+ </PropertyGroup>
87
+ </PanelSection>
88
+ ```
89
+
62
90
  ### Blending section, 2-up
63
91
 
64
92
  ```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.
@@ -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`
@@ -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