@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.
- package/AGENTS.md +10 -4
- package/CHANGELOG.md +170 -0
- package/DESIGN.md +1 -1
- package/README.md +15 -1
- package/bin/sable.mjs +254 -10
- package/dist/components/app-header/app-header.meta.js +2 -1
- package/dist/components/button/button.js +3 -0
- package/dist/components/button/button.meta.js +1 -1
- package/dist/components/button/button.variants.js +1 -1
- package/dist/components/button-group/button-group.js +17 -0
- package/dist/components/button-group/button-group.meta.js +72 -0
- package/dist/components/button-group/index.js +7 -0
- package/dist/components/caret-menu/caret-menu.js +221 -0
- package/dist/components/caret-menu/caret-menu.meta.js +119 -0
- package/dist/components/caret-menu/index.js +8 -0
- package/dist/components/chat-composer/chat-composer.js +30 -7
- package/dist/components/chat-composer/chat-composer.meta.js +27 -8
- package/dist/components/combobox/combobox.meta.js +26 -0
- package/dist/components/input-group/index.js +7 -0
- package/dist/components/input-group/input-group.js +66 -0
- package/dist/components/input-group/input-group.meta.js +106 -0
- package/dist/components/menu/index.js +2 -0
- package/dist/components/menu/menu.js +51 -9
- package/dist/components/menu/menu.meta.js +11 -2
- package/dist/components/menubar/index.js +8 -0
- package/dist/components/menubar/menubar.js +42 -0
- package/dist/components/menubar/menubar.meta.js +99 -0
- package/dist/components/message-scroller/message-scroller.meta.js +4 -0
- package/dist/components/navigation-menu/index.js +19 -0
- package/dist/components/navigation-menu/navigation-menu.js +108 -0
- package/dist/components/navigation-menu/navigation-menu.meta.js +141 -0
- package/dist/components/overflow-menu/overflow-menu.meta.js +13 -1
- package/dist/components/pagination/pagination.js +11 -5
- package/dist/components/pagination/pagination.meta.js +21 -3
- package/dist/components/scroller/scroller.js +8 -4
- package/dist/components/scroller/scroller.meta.js +6 -0
- package/dist/components/sheet/sheet.js +82 -9
- package/dist/components/sheet/sheet.meta.js +41 -4
- package/dist/components/sheet/sheet.variants.js +8 -1
- package/dist/components/sidebar/sidebar.js +13 -2
- package/dist/components/sidebar/sidebar.meta.js +10 -0
- package/dist/components/streaming-text/streaming-text.meta.js +5 -1
- package/dist/file-hashes.json +47 -31
- package/dist/icon-sets/reicon.js +2 -0
- package/dist/index.d.ts +655 -162
- package/dist/index.js +12 -1
- package/dist/lib/taxonomy.js +5 -0
- package/dist/lib/use-caret-trigger.js +187 -0
- package/dist/lib/use-copy.js +6 -1
- package/dist/lib/use-smooth-wheel.js +164 -0
- package/dist/patterns/chat-panel.js +1 -1
- package/dist/patterns/chat-panel.meta.js +1 -0
- package/dist/styles.css +25 -0
- package/llms/components/app-header.md +1 -1
- package/llms/components/button-group.md +55 -0
- package/llms/components/button.md +1 -1
- package/llms/components/caret-menu.md +100 -0
- package/llms/components/chat-composer.md +24 -7
- package/llms/components/combobox.md +12 -0
- package/llms/components/input-group.md +78 -0
- package/llms/components/menu.md +16 -1
- package/llms/components/menubar.md +110 -0
- package/llms/components/message-scroller.md +1 -0
- package/llms/components/navigation-menu.md +113 -0
- package/llms/components/overflow-menu.md +26 -1
- package/llms/components/pagination.md +15 -3
- package/llms/components/scroller.md +1 -0
- package/llms/components/sheet.md +18 -3
- package/llms/components/sidebar.md +2 -0
- package/llms/components/streaming-text.md +2 -1
- package/llms/patterns/chat-panel.md +4 -1
- package/llms/tasks/building-a-chat.md +158 -0
- package/llms/tasks/building-with-sable.md +4 -1
- package/llms/tasks/converting-an-app.md +7 -0
- package/llms/tasks/extending-a-component.md +170 -0
- package/llms/tasks/getting-started.md +22 -4
- package/llms/tasks/migrating-from-shadcn.md +351 -0
- package/llms-full.txt +1321 -26
- package/llms.txt +13 -4
- package/package.json +3 -2
- package/registry.json +979 -23
- package/scripts/lint-rules.mjs +35 -0
- package/scripts/wiring.mjs +541 -0
- package/skills/entry/SKILL.md +3 -2
- package/skills/sable/SKILL.md +26 -5
- package/targets/ios.json +45 -0
- 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>`,
|
|
154
|
-
`polish <target>` in Codex; use `/sable` with the same modes in
|
|
155
|
-
Cursor. `create` is a build alias.
|
|
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 `
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
801
|
-
|
|
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"),
|