@sableui/react 0.11.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 (87) hide show
  1. package/AGENTS.md +10 -4
  2. package/CHANGELOG.md +170 -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/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 +47 -31
  44. package/dist/icon-sets/reicon.js +2 -0
  45. package/dist/index.d.ts +655 -162
  46. package/dist/index.js +12 -1
  47. package/dist/lib/taxonomy.js +5 -0
  48. package/dist/lib/use-caret-trigger.js +187 -0
  49. package/dist/lib/use-copy.js +6 -1
  50. package/dist/lib/use-smooth-wheel.js +164 -0
  51. package/dist/patterns/chat-panel.js +1 -1
  52. package/dist/patterns/chat-panel.meta.js +1 -0
  53. package/dist/styles.css +25 -0
  54. package/llms/components/app-header.md +1 -1
  55. package/llms/components/button-group.md +55 -0
  56. package/llms/components/button.md +1 -1
  57. package/llms/components/caret-menu.md +100 -0
  58. package/llms/components/chat-composer.md +24 -7
  59. package/llms/components/combobox.md +12 -0
  60. package/llms/components/input-group.md +78 -0
  61. package/llms/components/menu.md +16 -1
  62. package/llms/components/menubar.md +110 -0
  63. package/llms/components/message-scroller.md +1 -0
  64. package/llms/components/navigation-menu.md +113 -0
  65. package/llms/components/overflow-menu.md +26 -1
  66. package/llms/components/pagination.md +15 -3
  67. package/llms/components/scroller.md +1 -0
  68. package/llms/components/sheet.md +18 -3
  69. package/llms/components/sidebar.md +2 -0
  70. package/llms/components/streaming-text.md +2 -1
  71. package/llms/patterns/chat-panel.md +4 -1
  72. package/llms/tasks/building-a-chat.md +158 -0
  73. package/llms/tasks/building-with-sable.md +4 -1
  74. package/llms/tasks/converting-an-app.md +7 -0
  75. package/llms/tasks/extending-a-component.md +170 -0
  76. package/llms/tasks/getting-started.md +22 -4
  77. package/llms/tasks/migrating-from-shadcn.md +351 -0
  78. package/llms-full.txt +1321 -26
  79. package/llms.txt +13 -4
  80. package/package.json +3 -2
  81. package/registry.json +979 -23
  82. package/scripts/lint-rules.mjs +35 -0
  83. package/scripts/wiring.mjs +541 -0
  84. package/skills/entry/SKILL.md +3 -2
  85. package/skills/sable/SKILL.md +26 -5
  86. package/targets/ios.json +45 -0
  87. package/dist/lib/use-wheel-to-horizontal.js +0 -65
package/AGENTS.md CHANGED
@@ -51,10 +51,14 @@ These are short on purpose. Each links to a foundations doc with the full vocabu
51
51
 
52
52
  Pick a theme as a starting point, then override tokens on top of it. Every token a theme sets can be reset by the app on `:root` (or any subtree) after the theme CSS loads — radius, density, type scale, motion scale, accent — and components follow, because they only read tokens. The docs site's **Customize** panel does exactly this live and exports the result as CSS; paste it after your Sable imports. Retrieve the overridable set with `npx sable token --list`. When you retune a filled role (`--primary`, `--sidebar-primary`, `--highlight`, `--destructive`, `--success`, `--tone-warning`, `--tone-info`), check the label on it: `sable lint` measures the pair against the installed theme and suggests one that reads. If the pair is a deliberate brand choice, keep it with `/* design-lint-disable-next-line label-contrast -- <reason> */`. Status colours have a second token for the colour drawn on a surface — `--destructive-text`, `--success-text`, `--tone-warning-text`, `--tone-info-text` — which `text-`, `border-` and `ring-<status>` read; only `bg-<status>` reads the fill. Retune both.
53
53
 
54
+ When a component doesn't do something you need — be a link, carry a control, take a key, offer @mentions or /commands — read `sable docs extending-a-component` before restyling or copying it: `render`, slots, controlled state and refs, `useCaretTrigger` + `CaretMenu`, and how to tell a library gap from app-owned work.
55
+
54
56
  ## Wire it up
55
57
 
56
58
  The machine-readable version of this section is the `consume` block in `registry.json`.
57
59
 
60
+ **Start with `sable init`.** After `pnpm add @sableui/react`, run `pnpm exec sable init` (`npx sable init` once the package is installed — never before, when `npx sable` would fetch an unrelated package) from the app root. It runs steps 2–5 and 7 below: installs missing packages with the project's package manager, puts the CSS imports in order, adds dedupe and the Tailwind plugin to a Vite config (or a PostCSS config for Next.js), loads the fonts, mounts `IconProvider` in a Vite starter's entry, installs the agent skill, and finishes with `sable doctor`. What it cannot change safely it prints as a snippet with the file it belongs in; apply those, then run `sable doctor` again. `--dry-run` previews every change. In an app that is already wired, `sable doctor --fix` applies the same fixes to whatever doctor flags. The numbered steps are the manual route and what `init` checks against.
61
+
58
62
  1. **Install** from npm: `pnpm add @sableui/react` (or the project's package manager). No registry configuration or token is needed. Sable depends on no router, data or form library, and keeps whatever the project already uses: links, menus and nav items take any router's link through `render` (`render={<Link to="/inbox" />}` is TanStack Router's or React Router's `Link` alike). For a blank-slate app, the suggested stack is Vite + TypeScript with TanStack Router, TanStack Query, TanStack Form for forms with validation logic, and TanStack Table once a table outgrows `DataTable` (`sable docs tables-with-tanstack`).
59
63
  2. **Dedupe React** in the bundler (`resolve.dedupe: ["react","react-dom"]`) — Base UI must not resolve a second React, or you get an "invalid hook call" blank page.
60
64
  3. **CSS, in order:** `@import "tailwindcss";` → `@import "@sableui/react/styles.css";` → `@source "<node_modules>/@sableui/react/dist/**/*.{js,mjs}";` so Tailwind scans the package's classes. That ships every component's classes (about 32 KB gzipped); before shipping, `sable sources --write src/sable-sources.css` lists just the modules the app imports, imported in place of that `@source`, and `sable check` fails when it goes stale.
@@ -73,7 +77,8 @@ The machine-readable version of this section is the `consume` block in `registry
73
77
  npx sable pattern SettingsPage --skeleton # composition tree, fixtures stripped
74
78
  npx sable block --list # section-sized blocks: cards, forms, lists
75
79
  npx sable component DataTable --props
76
- npx sable search "empty state" # across components, patterns, layout, tokens, docs
80
+ npx sable search "empty state" # across components, examples, patterns, blocks, layout, tokens, docs
81
+ npx sable component Form --example "Server errors" # one example a search hit names, imports included
77
82
  npx sable docs converting-an-app # the task walkthroughs
78
83
  npx sable manifest --json # the whole command surface, machine-readable
79
84
  ```
@@ -150,9 +155,10 @@ package with `--root` naming the repository root (for example
150
155
  there and resolve Sable through the package.
151
156
  Each entry resolves `skills/sable/SKILL.md` from the installed package, so package
152
157
  upgrades also update the workflow. Other agents can read that file directly.
153
- Use `$sable build <idea>`, `inspire <references>`, `audit <target>`, or
154
- `polish <target>` in Codex; use `/sable` with the same modes in Claude Code or
155
- Cursor. `create` is a build alias. These are agent modes, not shell commands.
158
+ Use `$sable build <idea>`, `inspire <references>`, `audit <target>`,
159
+ `polish <target>`, or `fix <target>` in Codex; use `/sable` with the same modes in
160
+ Claude Code or Cursor. `create` is a build alias. `fix` takes the short path for
161
+ one scoped defect and escalates to `polish` when the change grows. These are agent modes, not shell commands.
156
162
 
157
163
  Upgrade with `pnpm update @sableui/react --latest`, then
158
164
  `pnpm exec sable setup --agent --update`. Setup only replaces managed files
package/CHANGELOG.md CHANGED
@@ -1,5 +1,175 @@
1
1
  # What's new
2
2
 
3
+ ## 0.12.0
4
+
5
+ Getting to a working first component, finding the right example, moving over from shadcn, and the components that move called for: Menubar, NavigationMenu, InputGroup, ButtonGroup, CaretMenu and bottom-sheet snap points.
6
+
7
+ - **New: `sable init` wires an app in one step.** Run it after `pnpm add
8
+ @sableui/react`. It installs what is missing with your package manager
9
+ (React, Tailwind and its build plugin), puts the CSS imports in order, adds
10
+ React dedupe and the Tailwind plugin to a Vite config (a
11
+ `postcss.config.mjs` for Next.js), loads Figtree and DM Mono, and wraps a
12
+ Vite starter's `<App />` in `IconProvider`. Then it installs the agent
13
+ skill and runs `sable doctor`. What it can't change safely, such as a
14
+ Next.js layout or an unfamiliar config, it prints as the snippet and the
15
+ file it belongs in. `--dry-run` shows every change first.
16
+ - **New: `sable doctor --fix`** applies the same fixes to whatever doctor
17
+ flags in an app that's already set up, then checks again. A fix that
18
+ didn't take is reported as unverified, not done. Doctor also gains two
19
+ warnings: Tailwind missing from the Vite or Next.js build (`css:build`),
20
+ and the theme's fonts never loaded (`fonts`).
21
+ - **`sable search` finds examples.** Every component example is searchable
22
+ by what it shows: `sable search "multiple selection"` or `"server errors"`.
23
+ A hit names its own retrieve command, `sable component Combobox --example
24
+ "Multiple selection"`, which prints that one example with its imports.
25
+ Examples are capped per component and per list, so a component name still
26
+ lands on the component.
27
+ - **New: a migration guide from shadcn/ui and Radix.** `sable docs
28
+ migrating-from-shadcn` maps what changes everywhere (`asChild` becomes
29
+ `render`, values become arrays, menu rows take `onClick`,
30
+ `data-[state=open]:` becomes `sable-open:`), the tokens, lucide icons, and
31
+ every shadcn component to its Sable counterpart, or what to compose where
32
+ there isn't one. `converting-an-app` points to it.
33
+ - **New lint rule: `shadcn-leftover`.** Flags imports still coming from the
34
+ old stack (`@radix-ui/*`, `@/components/ui`, `lucide-react`, `sonner`,
35
+ `vaul`, `cmdk`, `input-otp`, `next-themes`) and any `asChild` prop, which a
36
+ Sable trigger silently ignores. A deliberate keeper takes a reasoned disable.
37
+ - **The agent skill has a `fix` mode.** `$sable fix <target>` (or `/sable
38
+ fix`) repairs one scoped defect on a short path: it reads only the
39
+ contracts the change touches, then runs `sable check` on the changed files
40
+ and looks at the affected state. It escalates to `polish`, and its full
41
+ finish checklist, when the change grows. Run `sable setup --agent --update`
42
+ to refresh installed entries.
43
+ - **ChatComposer can send attachments without text.** Pass
44
+ `allowEmpty={files.length > 0}` and send enables on the files alone;
45
+ `onSend` receives `""`. Tie it to the pending files: a constant
46
+ `allowEmpty` leaves send enabled with nothing to send.
47
+ - **ChatComposer: on a phone or tablet, Return starts a new line.** A soft
48
+ keyboard has no Shift+Return, so on a touch device (`pointer: coarse`) the
49
+ default `sendOn="enter"` now behaves as `mod-enter`: Return breaks the
50
+ line, the send button sends, and ⌘/Ctrl+Enter still sends from a hardware
51
+ keyboard. `aria-keyshortcuts` reports whichever is in effect. `footer` now
52
+ also takes a function of the send key in effect, so a keyboard hint can
53
+ stay true: `footer={({ sendOn }) => sendOn === "enter" ? "Enter to send" :
54
+ null}` (null removes the row). *Migration:* a fixed "Enter to send" footer
55
+ is wrong on touch; switch it to the function form, as ChatPanel has.
56
+ - **Combobox documents server-backed search.** A new "Async search" example
57
+ and the props it uses (`filter={null}`, `onInputValueChange`,
58
+ `itemToStringLabel`) show how to show results a server already filtered.
59
+ - **New: `Menubar`.** An application's row of menus (File, Edit, View) over
60
+ Base UI's Menubar. Each menu is an ordinary `Menu` with a `MenubarTrigger`
61
+ as its title. The bar is one tab stop with ←/→ between titles, and once a
62
+ menu is open, pointing at a neighbour opens it.
63
+ - **New: `NavigationMenu`.** A site header's primary nav: a bar of links and
64
+ triggers, where a trigger opens a panel of links on hover, click or Enter.
65
+ One shared panel moves along the bar and resizes to each trigger's
66
+ content. Links take your router's link through `render` and mark the
67
+ current page with `active`; panel rows take a `description`. Below `md`,
68
+ hand the same destinations to a Sheet with a SideNav, as the docs show.
69
+ - **Pagination can render real links.** `pageLink={(page) => <Link
70
+ to={`?page=${page}`} />}` makes every page control a link that crawlers
71
+ follow and readers can open in a new tab. The current page keeps
72
+ `aria-current`; the arrows at the ends stay disabled buttons.
73
+ `onPageChange` is optional with `pageLink`.
74
+ - **New: `InputGroup`.** An Input with addons inside the same field: a
75
+ prefix or suffix ("https://", ".com"), an icon, or a borderless Button
76
+ such as copy or reveal. One shell, one focus ring, one error state; inside
77
+ a Field, the label, description and error reach the Input.
78
+ - **OverflowMenu documents responsive actions.** Secondary actions inline
79
+ when the container is wide, folded into the menu when it's narrow, the
80
+ primary action always inline. `sable search "responsive action menu"`
81
+ finds it.
82
+ - **Sheet: snap points for bottom sheets.** `<Sheet snapPoints={[0.4, 1]}>`
83
+ gives a bottom sheet stops between closed and fully open; a swipe moves
84
+ between them, and `snapPoint` / `onSnapPointChange` control the active
85
+ one (new `SheetSnapPoint` type). With stops the sheet shows a drag handle,
86
+ and the part below the screen becomes padding, so content scrolls and
87
+ `SheetFooter` stays visible. Other sides ignore stops and a development
88
+ build says so. Every bottom sheet now also rides above the on-screen
89
+ keyboard (measured from `visualViewport`).
90
+ - **New: `CaretMenu` and `useCaretTrigger`, mentions and slash commands on
91
+ any text field.** The hook watches a Textarea, Input or ChatComposer
92
+ (`fieldRef`) for a trigger character at the caret (`@`, `/` at the start,
93
+ `#`) and reports the query. CaretMenu shows suggestions there, driven by
94
+ ↑/↓, Enter, Tab and Escape while focus stays in the field. The app
95
+ supplies the items and what choosing one does: `trigger.replace("@Sam
96
+ Rivera ")` inserts a mention as text, and a command runs and `replace("")`
97
+ clears it. The value stays a plain string; chips inside the field need a
98
+ rich-text editor, which Sable doesn't ship.
99
+ - **ChatComposer: an Enter someone else handled doesn't send.** A listener
100
+ on `fieldRef` that handles Enter and calls `preventDefault()` owns it. It's
101
+ how CaretMenu picks a suggestion instead of sending, and how an app adds
102
+ key behaviour of its own.
103
+ - **ChatComposer keeps focus after Stop.** Stop turns back into send in
104
+ place, and with an empty field that send is disabled, which dropped focus
105
+ to the page. Focus now moves to the field, except on touch, where it would
106
+ raise the keyboard unasked.
107
+ - **Scroller eases the mouse wheel.** New `smoothWheel` (on by default): a
108
+ notch eases toward its target instead of jumping, on either axis, the
109
+ inertia a library like Lenis adds but scoped to the region. Only a mouse
110
+ wheel is eased: trackpads and touch keep their own momentum, keys and
111
+ scrollbar drags stay native, it releases at the ends so the page scrolls
112
+ on, a nested area that can still scroll (a code block, a textarea) keeps
113
+ the wheel, a region that smooths or snaps in CSS is left to the browser,
114
+ and anything else that moves the region (`scrollIntoView`, a scrollbar
115
+ drag) stops it at once. Reduced motion turns it off. New
116
+ `useSmoothWheel()` gives a scroll container of your own the same thing.
117
+ - **New guide: `sable docs building-a-chat`.** Wires Sable's chat parts to
118
+ any streaming source: which part owns what and what stays with the app, a
119
+ turn's lifecycle mapped onto `busy`, `onStop`, `streaming` and `disabled`,
120
+ where each failure goes, announcing a turn once rather than per token, and
121
+ rendering model output without injecting HTML.
122
+ - **New guide: `sable docs extending-a-component`.** What to do when a
123
+ component doesn't do what you need: compose with `render`, fill a slot,
124
+ hold the state and reach the element, add behaviour on top (mentions as
125
+ the worked example), change the look through tokens and presets rather
126
+ than overrides, patch a defect, and tell a library gap from app work.
127
+ - **The shadcn migration guide now maps every component.** Menubar,
128
+ NavigationMenu, InputGroup, ButtonGroup, link Pagination and drawer snap
129
+ points all have counterparts.
130
+ - **Fix: a horizontal Scroller no longer widens its container.** Its outer
131
+ box kept the default `min-width: auto`, so inside a grid or flex column a
132
+ strip of cards grew to its full length and never scrolled. It now shrinks
133
+ to the column and scrolls with its edge fade. The Scrolling page's own
134
+ example was showing exactly this, and also lacked `orientation="horizontal"`.
135
+ - **`useCopy` takes a function.** `useCopy(() => fetch(url).then((r) =>
136
+ r.text()))` produces the text only on click; a pending promise goes to the
137
+ clipboard as a ClipboardItem so Safari still counts it as part of the click.
138
+ New `CopySource` type.
139
+ - **New: `ButtonGroup`.** Buttons joined into one control, such as a split
140
+ button (an action beside the chevron that opens its menu) or a short run of
141
+ related actions. The children stay ordinary Buttons with their own variants
142
+ and sizes. The group squares their inner corners, collapses bordered seams
143
+ into one hairline and draws a seam between filled ones. Button now reads a
144
+ per-side radius (`--button-radius-s` / `--button-radius-e`, falling back to
145
+ `--button-radius`) and renders `data-variant`, which is how the group
146
+ does it without overriding a class.
147
+ - **Menu rows can carry an icon and a description.** `MenuItem` takes `icon`
148
+ and `description`, a quieter second line saying what the action does; with
149
+ a description the icon sits in a small tile spanning both lines.
150
+ - **New: `MenuLinkItem`.** A menu row that navigates: a real `<a>`, so it opens
151
+ in a new tab, middle-clicks and copies as a link. With `target="_blank"`
152
+ it adds `rel="noopener noreferrer"` and a trailing ↗.
153
+ - **Icon contract: `arrow-up-right`.** Added for links that leave the app.
154
+ An app with its own icon set should map it; Reicon's set already does.
155
+ - **sableui.com: a Copy page button on every page.** Each page with a
156
+ Markdown version has a split button in its header. Copy page copies the
157
+ page as Markdown, fetched only when you click. The menu behind the chevron
158
+ opens the Markdown file, starts a ChatGPT or Claude chat that reads the
159
+ page (Claude tries the desktop app first and falls back to claude.ai), and
160
+ copies the setup command (`pnpm add @sableui/react && pnpm exec
161
+ sable init`). The Markdown files are the same ones the site publishes at
162
+ `/<page>.md`; both now come from one table
163
+ (`apps/docs/src/markdown-twins.json`).
164
+ - **New: `Sidebar surface="page"`.** The rail paints the page's own surface
165
+ and ink, with no border, instead of the theme's `--sidebar-*` set. Use it
166
+ when a full-width header sits above the rail: a tinted or dark rail
167
+ (Verdant, Meridian) starting under a page-coloured bar reads as pasted on.
168
+ The default, `surface="rail"`, is unchanged and still right for a
169
+ full-height rail. Overriding `bg-background text-foreground` on a Sidebar
170
+ never worked, because the rail re-points those tokens at its own;
171
+ use the prop instead. sableui.com's docs rail now uses it.
172
+
3
173
  ## 0.11.0
4
174
 
5
175
  - **New: `PropertyGroup`, a collapsible group inside a PanelSection.** The
package/DESIGN.md CHANGED
@@ -469,7 +469,7 @@ Reference via `z-(--z-overlay)`, never a raw `z-[9999]`. Base UI overlays portal
469
469
 
470
470
  **Vertical gets a bar; horizontal gets a carousel.** A vertical list needs position feedback — how far through a long transcript am I — and a thin bar is the only thing that gives it. A horizontal strip does not: what a reader needs there is "there is more this way", which an **edge fade says better than a 4px rail**, and which the rail obscures by adding chrome under the content. So `Scroller` draws a bar on its vertical axis and none on its horizontal one, where its alpha-masked edges already do the work. Reach for the same split in anything strip-shaped: media rows, chip rows, tab overflow.
471
471
 
472
- **A horizontal region must be operable without a horizontal input.** Hiding the bar removed the last non-drag affordance on that axis, and most mice have no horizontal wheel — so `Scroller` maps a vertical wheel to sideways movement, and is focusable with arrow / Home / End / PageUp / PageDown handling. Both are on by default; the wheel mapping is `wheelScroll`, the name is `label`. The four things that make a naive wheel mapping feel broken, all handled in `useWheelToHorizontal`: it **releases at both ends** so it never traps a reader scrolling the page, it **ignores trackpad `deltaX` gestures** (`|deltaX| > |deltaY|` means the browser is already doing it, and handling `deltaY` too double-scrolls a diagonal swipe), it **ignores `shift+wheel`** (already native), and it **normalises `deltaMode`** (mice report *lines*, so the raw value moves ~3px per notch). Keyboard focus is declared rather than inherited: Chrome 128+ and Firefox make scroll regions focusable on their own, Safari does not.
472
+ **A horizontal region must be operable without a horizontal input.** Hiding the bar removed the last non-drag affordance on that axis, and most mice have no horizontal wheel — so `Scroller` maps a vertical wheel to sideways movement, and is focusable with arrow / Home / End / PageUp / PageDown handling. Both are on by default; the wheel mapping is `wheelScroll`, the name is `label`. The four things that make a naive wheel mapping feel broken, all handled in `useSmoothWheel` (lib/use-smooth-wheel.ts): it **releases at both ends** so it never traps a reader scrolling the page, it **ignores trackpad `deltaX` gestures** (`|deltaX| > |deltaY|` means the browser is already doing it, and handling `deltaY` too double-scrolls a diagonal swipe), it **ignores `shift+wheel`** (already native), and it **normalises `deltaMode`** (mice report *lines*, so the raw value moves ~3px per notch). The same hook eases a mouse wheel on either axis (`smoothWheel`, on by default): only notches, never trackpads or touch, it gives way to a nested area that can still scroll, it leaves regions that smooth or snap in CSS to the browser, and anything else that moves the region stops the ease. Keyboard focus is declared rather than inherited: Chrome 128+ and Firefox make scroll regions focusable on their own, Safari does not.
473
473
 
474
474
  **Reveal is driven by scrolling, not by hover.** Hover is a decent proxy and a bad one for the cases that matter: a touch drag hovers nothing, and neither does a programmatic scroll — a chat auto-following a streamed reply, a jump-to-message, a restored read position — which are exactly the moments a reader needs to see where they now are. `useScrollActivity` sets `data-scrolling` on the element for ~600ms after each scroll event and `scrollbar-quiet` keys off it. Hover and `:focus-within` remain in the CSS as the no-JS baseline, so the bare utility still behaves for a consumer who never reaches for the hook. The fade honours `--motion-scale`, so reduced motion collapses it to an instant swap.
475
475
 
package/README.md CHANGED
@@ -62,6 +62,17 @@ retrieval is the default. These tools do not update model weights. See
62
62
 
63
63
  > **Agents:** the machine-readable version of these steps is the `consume` block in [`registry.json`](./registry.json).
64
64
 
65
+ **Quick start** — install, then let `sable init` do steps 2–5:
66
+
67
+ ```bash
68
+ pnpm add @sableui/react
69
+ pnpm exec sable init # or: npx sable init — once the package is installed
70
+ ```
71
+
72
+ `init` reads your package manager from the lockfile and your framework from the project. It installs what is missing (React, Tailwind and its build plugin), puts the CSS imports below in order, adds React dedupe and the Tailwind plugin to a Vite config, loads the fonts, mounts `IconProvider` in a Vite starter's entry, installs the agent skill (`sable setup --agent`), then runs `sable doctor`. Anything it cannot change safely — an unfamiliar config, a Next.js layout — it prints as the exact snippet and the file it belongs in. Vite and Next.js (App Router) get every step; other setups get the packages and the stylesheet. `--dry-run` shows every change first; `--no-agent` skips the skill. Already set up? `sable doctor --fix` applies the same fixes to whatever doctor flags.
73
+
74
+ The steps below are what `init` does, for doing it by hand or checking its work.
75
+
65
76
  **1. Install:**
66
77
 
67
78
  ```bash
@@ -138,8 +149,11 @@ npx sable pattern --list # the eight canonical page shells
138
149
  npx sable pattern DashboardPage --skeleton # its composition tree — 127 lines of source, 45 of structure
139
150
  npx sable block PricingCardBlock --code # a section-sized block's source, imports included
140
151
  npx sable component Button --props
141
- npx sable search "date range" # one ranked set across components, patterns, layout, tokens, docs
152
+ npx sable search "date range" # one ranked set across components, examples, patterns, layout, tokens, docs
153
+ npx sable component Calendar --example Range # just the example a search hit names
154
+ npx sable init # wire a new app: packages, CSS, fonts, IconProvider, agent skill, then doctor
142
155
  npx sable doctor # is this project wired correctly? exits 1 if not
156
+ npx sable doctor --fix # apply the fixes doctor can make safely; print the rest
143
157
  npx sable docs designing-a-surface # brief, direction, composition, interaction, adaptation
144
158
  npx sable docs developing-taste # references, rendered directions, scoped judgment
145
159
  npx sable atlas --search "editorial culture" --contrast --limit 3 --json # a project's own atlas, if it has one
package/bin/sable.mjs CHANGED
@@ -39,6 +39,7 @@ import { skeletonize } from "../scripts/skeleton.mjs"
39
39
  import { claudeStopHook, installAgentSkill, installClaudeStopHook } from "../scripts/agent-setup.mjs"
40
40
  import { patchChecks } from "../scripts/patches.mjs"
41
41
  import { sourcesCheck } from "../scripts/sources.mjs"
42
+ import { detectFramework, detectPackageManager, fixWiring, fontsCheck, providerSnippet, tailwindBuildCheck } from "../scripts/wiring.mjs"
42
43
 
43
44
  const HERE = dirname(fileURLToPath(import.meta.url))
44
45
  const PKG_ROOT = join(HERE, "..")
@@ -139,7 +140,7 @@ const COMMANDS = [
139
140
  summary: "One ranked result set across components, providers, patterns, blocks, layout primitives, utilities, tokens, and docs.",
140
141
  arg: { name: "query", required: true },
141
142
  flags: [
142
- { name: "--type", value: "component|provider|pattern|block|layout|utility|token|doc", describe: "Restrict to one kind." },
143
+ { name: "--type", value: "component|provider|pattern|block|example|layout|utility|token|doc", describe: "Restrict to one kind. `example` finds a component example by what it shows (\"multiple selection\", \"server errors\")." },
143
144
  { name: "--limit", value: "n", describe: "Cap results. Default 20." },
144
145
  ],
145
146
  returns: ["search"],
@@ -167,9 +168,10 @@ const COMMANDS = [
167
168
  { name: "--register", value: "page|workspace|either", describe: "With --list: filter by register." },
168
169
  { name: "--props", value: null, describe: "Props table only." },
169
170
  { name: "--examples", value: null, describe: "Examples only, imports included." },
171
+ { name: "--example", value: "label", describe: "One example by its label (case-insensitive), imports included. Labels come from --examples or search." },
170
172
  { name: "--a11y", value: null, describe: "Accessibility contract only." },
171
173
  ],
172
- returns: ["component.list", "component.detail", "component.props", "component.examples", "component.a11y"],
174
+ returns: ["component.list", "component.detail", "component.props", "component.examples", "component.example", "component.a11y"],
173
175
  },
174
176
  {
175
177
  name: "pattern",
@@ -246,8 +248,23 @@ const COMMANDS = [
246
248
  name: "doctor",
247
249
  summary: "Check this project's Sable wiring. Exits 1 on any failure.",
248
250
  arg: null,
249
- flags: [],
250
- returns: ["doctor"],
251
+ flags: [
252
+ { name: "--fix", value: null, describe: "Apply the fixes that need no judgment (imports in order, @source, missing packages, Vite dedupe and Tailwind plugin, fonts, IconProvider in a starter entry), print the rest, then check again." },
253
+ { name: "--dry-run", value: null, describe: "Report what --fix would change without writing or installing anything." },
254
+ { name: "--no-install", value: null, describe: "With --fix: print install commands instead of running them." },
255
+ ],
256
+ returns: ["doctor", "doctor.fix"],
257
+ },
258
+ {
259
+ name: "init",
260
+ summary: "Wire Sable into this app in one step: install what is missing, fix what doctor flags, install the agent skill, print the provider snippet for your root, then run doctor. Vite and Next.js get every step; other setups get the packages and stylesheet, and the rest printed.",
261
+ arg: null,
262
+ flags: [
263
+ { name: "--dry-run", value: null, describe: "Report every change without writing or installing anything." },
264
+ { name: "--no-agent", value: null, describe: "Skip the agent skill (sable setup --agent)." },
265
+ { name: "--no-install", value: null, describe: "Print install commands instead of running them." },
266
+ ],
267
+ returns: ["init"],
251
268
  },
252
269
  {
253
270
  name: "grade",
@@ -337,6 +354,7 @@ const ERROR_CODES = [
337
354
  { code: "ERR_PRESET_INVALID", when: "A preset is missing required metadata, semantic values, or uses unsupported tokens or CSS." },
338
355
  { code: "ERR_PRESET_CONTRAST", when: "A label on a brand or status fill (primary, sidebar-primary, highlight, destructive, success, tone-warning, tone-info) misses 4.5:1 or APCA Lc 60. Apply the suggested pair, or record an `exceptions` entry with a reason." },
339
356
  { code: "ERR_AGENT_SETUP", when: "The packaged skill is missing, the destination is linked, or existing local edits would be overwritten." },
357
+ { code: "ERR_INIT", when: "sable init ran outside an app root (no package.json)." },
340
358
  { code: "ERR_UNKNOWN_COMMAND", when: "The first argument is not a command." },
341
359
  { code: "ERR_MISSING_ARGUMENT", when: "A command that requires an argument got none." },
342
360
  { code: "ERR_UNKNOWN_COMPONENT", when: "No component by that name. `suggestions` carries near matches." },
@@ -347,6 +365,7 @@ const ERROR_CODES = [
347
365
  { code: "ERR_UNKNOWN_UTILITY", when: "No utility or variant by that name. `suggestions` carries near matches." },
348
366
  { code: "ERR_UNKNOWN_DOC", when: "No package guide or doc mirror by that slug. `suggestions` carries near matches and the package guides." },
349
367
  { code: "ERR_UNKNOWN_PROVIDER", when: "No provider by that name. `suggestions` carries near matches." },
368
+ { code: "ERR_UNKNOWN_EXAMPLE", when: "The component has no example with that label. `suggestions` carries near matches as commands." },
350
369
  { code: "ERR_LINT_INPUT", when: "sable lint --json: a path to lint does not exist." },
351
370
  { code: "ERR_CHECK_FAILED", when: "sable check: doctor, lint, or grade failed. `data.checks` names which." },
352
371
  { code: "ERR_BAD_FLAG", when: "A flag value is outside its allowed set." },
@@ -630,14 +649,33 @@ function cmdSearch() {
630
649
  if (!type || type === "doc")
631
650
  for (const d of docSlugs())
632
651
  pool.push({ kind: "doc", name: d.slug, summary: `${d.kind} mirror`, _s: score({ name: d.slug, text: d.kind }) })
652
+ if (!type || type === "example")
653
+ for (const c of COMPONENTS)
654
+ for (const e of c.examples ?? []) {
655
+ const s = scoreExample(c, e, { q, terms })
656
+ if (s > 0) pool.push({ kind: "example", name: `${c.name} › ${e.label}`, component: c.name, label: e.label, summary: `Uses ${exampleParts(e).join(", ")}.`, _s: s })
657
+ }
633
658
 
634
659
  // Ties resolve in the order AGENTS.md tells an agent to resolve a need:
635
660
  // a component first, then a pattern or primitive, then a utility, then a
636
661
  // token. "segmented" should land on ToggleGroup before the well behind it.
637
- const KIND_ORDER = ["component", "provider", "pattern", "block", "layout", "utility", "token", "doc"]
662
+ const KIND_ORDER = ["component", "provider", "pattern", "block", "example", "layout", "utility", "token", "doc"]
663
+ // A popular label ("Controlled value") exists on dozens of components, so
664
+ // examples are capped twice: per component, and as a share of the list, or
665
+ // one query would bury every component under its own examples.
666
+ const perComponent = new Map()
667
+ let examples = 0
638
668
  const hits = pool
639
669
  .filter((h) => h._s > 0)
640
670
  .sort((a, b) => b._s - a._s || KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind))
671
+ .filter((h) => {
672
+ if (h.kind !== "example" || type === "example") return true
673
+ const n = perComponent.get(h.component) ?? 0
674
+ if (n >= 2 || examples >= Math.max(3, Math.ceil(limit / 3))) return false
675
+ perComponent.set(h.component, n + 1)
676
+ examples++
677
+ return true
678
+ })
641
679
  .slice(0, limit)
642
680
  if (!hits.length)
643
681
  fail("ERR_NO_RESULTS", `Nothing matched "${query}".`, [
@@ -652,7 +690,10 @@ function cmdSearch() {
652
690
  const results = hits.map(({ _s, ...h }) => ({
653
691
  ...h,
654
692
  ...(h.kind === "pattern" || h.kind === "block" ? { reference: true } : {}),
655
- retrieve: `sable ${h.kind === "doc" ? "docs" : h.kind} ${h.name}`,
693
+ retrieve:
694
+ h.kind === "example"
695
+ ? `sable component ${h.component} --example "${h.label.replace(/"/g, '\\"')}"`
696
+ : `sable ${h.kind === "doc" ? "docs" : h.kind} ${h.name}`,
656
697
  }))
657
698
  const anyReference = results.some((h) => h.reference)
658
699
  ok("search", { query, count: results.length, results }, () =>
@@ -669,6 +710,57 @@ function cmdSearch() {
669
710
  )
670
711
  }
671
712
 
713
+ /** The names an example imports: the parts it composes. */
714
+ const exampleParts = (e) =>
715
+ (e.imports ?? "")
716
+ .split(/[{}]/)[1]
717
+ ?.split(",")
718
+ .map((s) => s.trim())
719
+ .filter(Boolean) ?? []
720
+
721
+ /** Every component and prop an example's code uses, lowercased: `<Combobox multiple items=…>` → combobox, multiple, items. */
722
+ const exampleIdentifiers = (e) => {
723
+ const ids = new Set(exampleParts(e).map((p) => p.toLowerCase()))
724
+ for (const [, tag] of (e.code ?? "").matchAll(/<([A-Z]\w*)/g)) ids.add(tag.toLowerCase())
725
+ for (const [, prop] of (e.code ?? "").matchAll(/\s([a-z][A-Za-z]+)(?==|[\s/>])/g)) ids.add(prop.toLowerCase())
726
+ return ids
727
+ }
728
+
729
+ /**
730
+ * An example is found by what it shows, not by the component it belongs to. The
731
+ * label carries the intent ("Multiple selection", "Server errors"); the code
732
+ * names the parts and props it relies on; the parent component's name counts,
733
+ * but less than the component itself scores, so "Combobox" still lands on the
734
+ * component and "multiple combobox" lands on its example. An example qualifies
735
+ * only by its own label, or — for a phrase — by covering every term with its
736
+ * code: otherwise every example that renders a Button answers "button".
737
+ */
738
+ function scoreExample(c, e, { q, terms }) {
739
+ const label = e.label.toLowerCase()
740
+ const ids = exampleIdentifiers(e)
741
+ const parent = c.name.toLowerCase()
742
+ const keywords = (c.keywords ?? []).map((k) => k.toLowerCase())
743
+ let s = terms.length > 1 && label === q ? 60 : 0
744
+ let labelHit = false
745
+ let codeHit = false
746
+ let covered = 0
747
+ for (const t of terms) {
748
+ const inLabel = label.includes(t)
749
+ const inCode = ids.has(t) ? 10 : [...ids].some((id) => id.includes(t)) ? 4 : 0
750
+ const inParent = parent === t ? 15 : parent.includes(t) ? 10 : keywords.includes(t) ? 5 : 0
751
+ s += (inLabel ? 30 : 0) + inCode + inParent
752
+ labelHit ||= inLabel
753
+ codeHit ||= inCode > 0
754
+ if (inLabel || inCode || inParent) covered++
755
+ }
756
+ const phrase = terms.length > 1 && covered === terms.length
757
+ if (!labelHit && !(phrase && codeHit)) return 0
758
+ // A single word is a lookup, and the component it names is the answer; a
759
+ // phrase is a need, and the example that covers all of it is.
760
+ if (terms.length === 1) return Math.round(s / 2)
761
+ return phrase ? s + 20 : Math.round((s * covered) / terms.length)
762
+ }
763
+
672
764
  /**
673
765
  * First sentence, for one-line list rows. The abbreviation guard is not fussiness:
674
766
  * summaries in this catalog say things like "A human relative timestamp (e.g. 2h
@@ -727,6 +819,22 @@ function cmdComponent() {
727
819
  )
728
820
  if (flags.has("examples"))
729
821
  return ok("component.examples", { name: c.name, examples: c.examples ?? [] }, () => renderExamples(c))
822
+ if (flags.has("example")) {
823
+ // The retrieve step for a search hit: one example, not the whole set.
824
+ const want = flags.get("example")
825
+ const labels = (c.examples ?? []).map((e) => e.label)
826
+ const quoted = (l) => `sable component ${c.name} --example "${l.replace(/"/g, '\\"')}"`
827
+ if (want === true) fail("ERR_MISSING_ARGUMENT", "--example needs a label.", labels.map(quoted))
828
+ const example = (c.examples ?? []).find((e) => e.label.toLowerCase() === String(want).toLowerCase())
829
+ if (!example) {
830
+ const near = nearest(String(want), labels)
831
+ fail("ERR_UNKNOWN_EXAMPLE", `${c.name} has no example labelled "${want}".`, [
832
+ ...(near.length ? near : labels).map(quoted),
833
+ `sable component ${c.name} --examples`,
834
+ ])
835
+ }
836
+ return ok("component.example", { name: c.name, example }, () => renderExample(example))
837
+ }
730
838
  if (flags.has("a11y")) return ok("component.a11y", { name: c.name, a11y: c.a11y ?? [] }, () => (c.a11y ?? []).map(bullet).join("\n"))
731
839
 
732
840
  const level = detailLevel("compact")
@@ -796,9 +904,11 @@ function renderUsage(c) {
796
904
  function renderExamples(c) {
797
905
  const ex = c.examples ?? []
798
906
  if (!ex.length) return ""
799
- return `\nExamples:\n${ex
800
- .map((e) => `\n ── ${e.label}\n ${e.imports}\n${e.code.split("\n").map((l) => ` ${l}`).join("\n")}`)
801
- .join("\n")}`
907
+ return `\nExamples:\n${ex.map((e) => `\n${renderExample(e)}`).join("\n")}`
908
+ }
909
+
910
+ function renderExample(e) {
911
+ return ` ── ${e.label}\n ${e.imports}\n${e.code.split("\n").map((l) => ` ${l}`).join("\n")}`
802
912
  }
803
913
 
804
914
  const slugify = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
@@ -1342,6 +1452,17 @@ function doctorReport() {
1342
1452
  )
1343
1453
  }
1344
1454
 
1455
+ /* Tailwind in the build, and the theme's fonts: both fail as a page that
1456
+ * renders, just not as Sable — unstyled, or in system fonts. */
1457
+ if (pkg) {
1458
+ const build = tailwindBuildCheck(CWD, detectFramework(CWD))
1459
+ if (build) checks.push(build)
1460
+ }
1461
+ if (usesSable || sableImport.test(cssText)) {
1462
+ const html = collectSources(/\.html$/).map((f) => f.text).join("\n")
1463
+ checks.push(fontsCheck({ css: cssText, source: srcText, html, deps: pkg ? { ...pkg.dependencies, ...pkg.devDependencies } : {} }))
1464
+ }
1465
+
1345
1466
  /* Local patches (sable patch): each one is re-checked against the installed
1346
1467
  * Sable, and fails when Sable changed a patched file, so an upgrade tells the
1347
1468
  * agent to re-run the repro instead of keeping or losing a fix silently. */
@@ -1354,6 +1475,7 @@ function doctorReport() {
1354
1475
  }
1355
1476
 
1356
1477
  function cmdDoctor() {
1478
+ if (flags.has("fix") || flags.has("dry-run")) return cmdDoctorFix()
1357
1479
  const data = doctorReport()
1358
1480
  const { checks, failed } = data
1359
1481
  if (JSON_OUT) {
@@ -1370,6 +1492,127 @@ function cmdDoctor() {
1370
1492
  process.exit(failed ? 1 : 0)
1371
1493
  }
1372
1494
 
1495
+ /**
1496
+ * A fix counts as applied only once doctor, run again, stops flagging its
1497
+ * check. An edit that did not take is reported as unverified, with what doctor
1498
+ * still says, rather than as done.
1499
+ */
1500
+ function verifyFixes(fixes, after) {
1501
+ for (const f of fixes) {
1502
+ if (f.status !== "applied") continue
1503
+ const c = after.checks.find((x) => x.name === f.check)
1504
+ if (c && (c.status === "fail" || c.status === "warn" || (f.check === "dedupe" && c.status === "info"))) {
1505
+ f.status = "unverified"
1506
+ f.detail = `${f.detail} Doctor still reports: ${c.detail}`
1507
+ }
1508
+ }
1509
+ return fixes
1510
+ }
1511
+
1512
+ function fixLines(fixes, indent = " ") {
1513
+ const mark = { applied: "+", "would-apply": "~", manual: "!", failed: "✗", unverified: "?" }
1514
+ const word = { applied: "", "would-apply": "", manual: "by hand: ", failed: "failed: ", unverified: "unverified: " }
1515
+ return fixes
1516
+ .map(
1517
+ (f) =>
1518
+ `${indent}${mark[f.status]} ${f.check.padEnd(14)}${word[f.status]}${f.file ? `${f.file} — ` : ""}${f.detail}\n` +
1519
+ (f.snippet ? `${f.snippet.split("\n").map((l) => `${indent} ${l}`).join("\n")}\n` : ""),
1520
+ )
1521
+ .join("")
1522
+ }
1523
+
1524
+ function cmdDoctorFix() {
1525
+ const dryRun = flags.has("dry-run")
1526
+ const before = doctorReport()
1527
+ const fixes = fixWiring({ root: CWD, pkgRoot: PKG_ROOT, report: before, framework: detectFramework(CWD), dryRun, install: !flags.has("no-install"), quiet: JSON_OUT })
1528
+ const after = dryRun ? before : doctorReport()
1529
+ verifyFixes(fixes, after)
1530
+ const { failed } = after
1531
+ if (JSON_OUT) process.stdout.write(`${JSON.stringify({ type: "doctor.fix", data: { ...after, dryRun, fixes } }, null, DENSE ? 0 : 1)}\n`)
1532
+ else
1533
+ process.stdout.write(
1534
+ [
1535
+ fixes.length ? `${dryRun ? "Would fix (dry run, nothing written):" : "Fixes:"}\n${fixLines(fixes)}` : "Nothing to fix.\n",
1536
+ `${dryRun ? "Now:" : "After:"}\n${doctorLines(after.checks)}`,
1537
+ failed ? `[sable doctor] ✗ ${failed} failure(s)${dryRun ? "" : " left; the steps marked ! need you"}.\n` : "[sable doctor] ✓ wiring holds.\n",
1538
+ ].join("\n"),
1539
+ )
1540
+ logInvocation(failed ? 1 : 0)
1541
+ process.exit(failed ? 1 : 0)
1542
+ }
1543
+
1544
+ /* ── init ───────────────────────────────────────────────────────────────────── */
1545
+
1546
+ /**
1547
+ * The install recipe, run: doctor --fix, the agent skill, and the provider the
1548
+ * app still has to mount. Each step is the code its own command runs, so init
1549
+ * can never wire an app differently from what doctor checks.
1550
+ */
1551
+ function cmdInit() {
1552
+ if (!existsSync(join(CWD, "package.json")))
1553
+ fail("ERR_INIT", `No package.json in ${CWD}.`, ["Run sable init from your app's root.", "Starting fresh: pnpm create vite@latest my-app --template react-ts, then pnpm add @sableui/react && pnpm exec sable init"])
1554
+ const dryRun = flags.has("dry-run")
1555
+ const framework = detectFramework(CWD)
1556
+ const packageManager = detectPackageManager(CWD)
1557
+ const before = doctorReport()
1558
+ const fixes = fixWiring({ root: CWD, pkgRoot: PKG_ROOT, report: before, framework, dryRun, install: !flags.has("no-install"), quiet: JSON_OUT })
1559
+
1560
+ let agent
1561
+ if (flags.has("no-agent")) agent = { status: "skipped" }
1562
+ else
1563
+ try {
1564
+ // update: an unchanged managed entry from an older Sable is refreshed;
1565
+ // one with local edits still refuses, and is reported for the user.
1566
+ const setup = installAgentSkill({ root: CWD, project: CWD, packageRoot: PKG_ROOT, target: "all", update: true, dryRun })
1567
+ agent = { status: dryRun ? "would-apply" : "applied", destinations: setup.destinations, next: setup.next }
1568
+ } catch (error) {
1569
+ agent = { status: "manual", detail: error.message, fix: "sable setup --agent --dry-run" }
1570
+ }
1571
+
1572
+ const after = dryRun ? before : doctorReport()
1573
+ verifyFixes(fixes, after)
1574
+ const mounted = after.checks.find((c) => c.name === "icons")?.status === "pass"
1575
+ const providers = { ...providerSnippet(framework), mounted }
1576
+ const data = { framework: { id: framework.id, label: framework.label }, packageManager, dryRun, fixes, agent, providers, doctor: after }
1577
+
1578
+ if (JSON_OUT) process.stdout.write(`${JSON.stringify({ type: "init", data }, null, DENSE ? 0 : 1)}\n`)
1579
+ else {
1580
+ const agentText = {
1581
+ applied: () => `Agent skill: installed at ${agent.destinations.join(", ")}.`,
1582
+ "would-apply": () => `Agent skill: would install at ${agent.destinations.join(", ")}.`,
1583
+ skipped: () => "Agent skill: skipped (--no-agent).",
1584
+ manual: () => `Agent skill: not installed — ${agent.detail}\n → ${agent.fix}`,
1585
+ }[agent.status]()
1586
+ const iconsFix = fixes.find((f) => f.check === "icons")
1587
+ const providerText = mounted
1588
+ ? "Providers: <IconProvider> is mounted. MotionProvider and SableThemeProvider are optional: sable provider --list."
1589
+ : iconsFix?.status === "would-apply"
1590
+ ? `Providers: <IconProvider> would be mounted in ${iconsFix.file}.`
1591
+ : iconsFix?.status === "manual"
1592
+ ? "Providers: mount IconProvider as shown under icons above."
1593
+ : [`Providers — mount IconProvider once, in ${providers.file}${providers.then ? `, then ${providers.then}` : ""}:`, ...providers.code.split("\n").map((l) => ` ${l}`)].join("\n")
1594
+ process.stdout.write(
1595
+ [
1596
+ `sable init — ${framework.label}, ${packageManager}${dryRun ? " (dry run: nothing written or installed)" : ""}`,
1597
+ ...(framework.id === "unknown" ? ["Not Vite or Next.js: init installs the packages and wires the stylesheet it finds; the bundler and provider steps are printed for you."] : []),
1598
+ "",
1599
+ fixes.length ? `${dryRun ? "Would fix:" : "Fixes:"}\n${fixLines(fixes)}` : "Nothing to fix.\n",
1600
+ agentText,
1601
+ "",
1602
+ providerText,
1603
+ "",
1604
+ `Doctor:\n${doctorLines(after.checks)}`,
1605
+ after.failed
1606
+ ? `[sable init] ✗ ${after.failed} failure(s) ${dryRun ? "now" : "left; the steps marked ! need you"}. Re-check with sable doctor.`
1607
+ : `[sable init] ✓ wiring holds. Next: ${agent.next ?? "sable docs building-with-sable"}`,
1608
+ "",
1609
+ ].join("\n"),
1610
+ )
1611
+ }
1612
+ logInvocation(after.failed ? 1 : 0)
1613
+ process.exit(after.failed ? 1 : 0)
1614
+ }
1615
+
1373
1616
  function doctorLines(checks, indent = " ") {
1374
1617
  const mark = { pass: "✓", warn: "!", fail: "✗", info: "·" }
1375
1618
  return checks
@@ -1407,7 +1650,7 @@ function collectSources(match, limit = 4000) {
1407
1650
 
1408
1651
 
1409
1652
  function readConfigs() {
1410
- return ["vite.config.ts", "vite.config.js", "next.config.js", "next.config.mjs", "webpack.config.js"]
1653
+ return ["vite.config.ts", "vite.config.mts", "vite.config.js", "vite.config.mjs", "next.config.ts", "next.config.js", "next.config.mjs", "webpack.config.js"]
1411
1654
  .map((f) => (existsSync(join(CWD, f)) ? readFileSync(join(CWD, f), "utf8") : ""))
1412
1655
  .join("\n")
1413
1656
  }
@@ -1698,6 +1941,7 @@ const HANDLERS = {
1698
1941
  utility: cmdUtility,
1699
1942
  docs: cmdDocs,
1700
1943
  doctor: cmdDoctor,
1944
+ init: cmdInit,
1701
1945
  grade: cmdGrade,
1702
1946
  learn: cmdLearn,
1703
1947
  atlas: () => cmdDesignTool("design-atlas.mjs"),