@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
@@ -727,6 +727,41 @@ export function makeRules({ semanticTokens, motionTokens, labelContrast = null,
727
727
  return out
728
728
  },
729
729
  },
730
+ {
731
+ id: "shadcn-leftover",
732
+ /* CONSUMER-scoped. A shadcn/Radix migration is done when nothing still
733
+ * imports the old stack. The leftovers compile and mostly render, which is
734
+ * the problem: a Radix Dialog beside Sable's has its own focus, layering
735
+ * and motion, and `asChild` on a Sable trigger is silently ignored (Base
736
+ * UI composes through `render`). The migration guide used to end with a
737
+ * grep for these; the grep is now this rule. A deliberate keeper, such as
738
+ * a Radix part Sable does not ship yet, takes a reasoned disable. */
739
+ scope: "consumer",
740
+ since: "0.12.0",
741
+ file: true,
742
+ code: true,
743
+ why:
744
+ "Leftover shadcn/ui or Radix code. Sable replaces the stack: `asChild` → `render`, `@radix-ui/*` and `@/components/ui/*` → the Sable component (`sable search \"<name>\"`), `lucide-react` → <Icon name> under IconProvider, `sonner` → ToastProvider + toast, `vaul` → Sheet, `cmdk` → CommandMenu, `input-otp` → OtpField, `next-themes` → SableThemeProvider. `sable docs migrating-from-shadcn` has the full map.",
745
+ scan(text) {
746
+ const REPLACEMENT = {
747
+ "lucide-react": "<Icon name> under IconProvider",
748
+ sonner: "ToastProvider + toast",
749
+ vaul: "Sheet",
750
+ cmdk: "CommandMenu",
751
+ "input-otp": "OtpField",
752
+ "next-themes": "SableThemeProvider",
753
+ }
754
+ const out = []
755
+ for (const m of text.matchAll(/from\s+["']((@radix-ui\/[\w-]+)|[@~]\/components\/ui(?:\/[\w-]+)?|lucide-react|sonner|vaul|cmdk|input-otp|next-themes)["']/g)) {
756
+ const target = REPLACEMENT[m[1]] ?? "the Sable component (sable search)"
757
+ out.push({ index: m.index, match: `import from "${m[1]}" → ${target}` })
758
+ }
759
+ // The JSX prop, not prose: `<DialogTrigger asChild>`, `asChild={true}`.
760
+ for (const m of text.matchAll(/<[A-Z][\w.]*\b[^<>]*?\s(asChild)(?=[\s=/>])/g))
761
+ out.push({ index: m.index + m[0].lastIndexOf("asChild"), match: "asChild → render={<Element />}" })
762
+ return out
763
+ },
764
+ },
730
765
  {
731
766
  id: "hand-rolled-control",
732
767
  /* CONSUMER-scoped: apps built WITH Sable. The package's own components are
@@ -0,0 +1,541 @@
1
+ /**
2
+ * sable doctor --fix and sable init — the wiring doctor checks, applied.
3
+ *
4
+ * Doctor names each way Sable fails silently in a consuming app, and most of
5
+ * them have one mechanical fix: an import in the right order, an `@source`, a
6
+ * dependency, a dedupe line. This applies the ones it can apply without
7
+ * guessing and prints the rest — the exact snippet and the file it belongs in —
8
+ * because an edit made on a guess is a second silent failure.
9
+ *
10
+ * Every edit is narrow and idempotent. A config whose shape it does not
11
+ * recognise is left alone and reported as manual. The CLI re-runs doctor after
12
+ * fixing, and a fix whose check still fails is reported as unverified, never as
13
+ * applied.
14
+ */
15
+ import { spawnSync } from "node:child_process"
16
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
17
+ import { dirname, join, relative, resolve } from "node:path"
18
+ import { SOURCES_MARKER, sourcesCss } from "./sources.mjs"
19
+
20
+ const read = (file) => (existsSync(file) ? readFileSync(file, "utf8") : null)
21
+ const readJson = (file) => {
22
+ try {
23
+ return JSON.parse(readFileSync(file, "utf8"))
24
+ } catch {
25
+ return null
26
+ }
27
+ }
28
+ const posix = (path) => path.split("\\").join("/")
29
+
30
+ const VITE_CONFIGS = ["vite.config.ts", "vite.config.mts", "vite.config.js", "vite.config.mjs"]
31
+ const NEXT_CONFIGS = ["next.config.ts", "next.config.mjs", "next.config.js"]
32
+ const POSTCSS_CONFIGS = ["postcss.config.mjs", "postcss.config.js", "postcss.config.cjs", "postcss.config.ts", ".postcssrc", ".postcssrc.json"]
33
+
34
+ /** The directories from the project up to its repository root (or the filesystem root). */
35
+ function upward(root) {
36
+ const dirs = []
37
+ for (let dir = resolve(root); ; dir = dirname(dir)) {
38
+ dirs.push(dir)
39
+ if (existsSync(join(dir, ".git")) || dirname(dir) === dir) return dirs
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Declared dependencies of the project and the workspace packages above it —
45
+ * a monorepo often keeps its build tooling in the root package.json. The
46
+ * nearest declaration wins.
47
+ */
48
+ export function declaredDeps(root) {
49
+ const deps = {}
50
+ for (const dir of upward(root)) {
51
+ const pkg = readJson(join(dir, "package.json"))
52
+ if (!pkg) continue
53
+ for (const [name, range] of Object.entries({ ...pkg.peerDependencies, ...pkg.devDependencies, ...pkg.dependencies }))
54
+ if (!(name in deps)) deps[name] = range
55
+ }
56
+ return deps
57
+ }
58
+
59
+ /** The package manager: package.json's `packageManager`, else the nearest lockfile, else npm. */
60
+ export function detectPackageManager(root) {
61
+ for (const dir of upward(root)) {
62
+ const declared = readJson(join(dir, "package.json"))?.packageManager
63
+ if (typeof declared === "string") return declared.split("@")[0]
64
+ if (existsSync(join(dir, "pnpm-lock.yaml")) || existsSync(join(dir, "pnpm-workspace.yaml"))) return "pnpm"
65
+ if (existsSync(join(dir, "yarn.lock"))) return "yarn"
66
+ if (existsSync(join(dir, "bun.lock")) || existsSync(join(dir, "bun.lockb"))) return "bun"
67
+ if (existsSync(join(dir, "package-lock.json"))) return "npm"
68
+ }
69
+ return "npm"
70
+ }
71
+
72
+ export function installCommand(pm, names, { dev = false } = {}) {
73
+ const verb = pm === "npm" ? "install" : "add"
74
+ const devFlag = dev ? [pm === "bun" ? "-d" : "-D"] : []
75
+ return [pm, verb, ...devFlag, ...names]
76
+ }
77
+
78
+ /**
79
+ * What the project is built with, from its own package.json and config files —
80
+ * never an ancestor's, which would label a plain package inside a Next.js
81
+ * monorepo as a Next.js app. `stylesheets` are where that framework's starter
82
+ * keeps the global stylesheet; `root` is the file that renders the app.
83
+ */
84
+ export function detectFramework(root) {
85
+ const pkg = readJson(join(root, "package.json")) ?? {}
86
+ const deps = { ...pkg.devDependencies, ...pkg.dependencies }
87
+ const first = (list) => list.find((f) => existsSync(join(root, f))) ?? null
88
+ if (deps.next || first(NEXT_CONFIGS)) {
89
+ const appDir = first(["app", "src/app"])
90
+ return {
91
+ id: "next",
92
+ label: appDir ? "Next.js (App Router)" : "Next.js (Pages Router)",
93
+ config: first(NEXT_CONFIGS),
94
+ html: null,
95
+ stylesheets: appDir ? [`${appDir}/globals.css`] : ["styles/globals.css", "src/styles/globals.css"],
96
+ root: appDir ? first(["tsx", "jsx", "js"].map((ext) => `${appDir}/layout.${ext}`)) : first(["pages/_app.tsx", "src/pages/_app.tsx", "pages/_app.jsx", "pages/_app.js"]),
97
+ appDir,
98
+ }
99
+ }
100
+ if (deps.vite || first(VITE_CONFIGS))
101
+ return {
102
+ id: "vite",
103
+ label: "Vite",
104
+ config: first(VITE_CONFIGS),
105
+ html: first(["index.html"]),
106
+ stylesheets: ["src/index.css", "src/app.css", "src/styles.css", "src/main.css", "src/App.css"],
107
+ root: first(["src/main.tsx", "src/main.jsx", "src/index.tsx", "src/index.jsx", "src/main.ts", "src/main.js"]),
108
+ }
109
+ return {
110
+ id: "unknown",
111
+ label: "an unrecognised setup",
112
+ config: null,
113
+ html: null,
114
+ stylesheets: ["src/index.css", "src/app.css", "src/styles.css", "app/globals.css", "src/app/globals.css"],
115
+ root: null,
116
+ }
117
+ }
118
+
119
+ /* ── Doctor checks this module owns. ─────────────────────────────────────────── */
120
+
121
+ /**
122
+ * Tailwind has to be in the build, or `@import "tailwindcss"` compiles to
123
+ * nothing and every component renders unstyled. A warning, not a failure: a
124
+ * project can run Tailwind through a pipeline this cannot see (a shared config,
125
+ * the CLI), and a false failure would block `sable check`.
126
+ */
127
+ export function tailwindBuildCheck(root, framework) {
128
+ if (framework.id === "unknown") return null
129
+ const deps = declaredDeps(root)
130
+ const postcss = POSTCSS_CONFIGS.map((f) => read(join(root, f)) ?? "").join("\n")
131
+ const viaPostcss = deps["@tailwindcss/postcss"] && /@tailwindcss\/postcss/.test(postcss)
132
+ if (framework.id === "vite") {
133
+ const config = framework.config ? (read(join(root, framework.config)) ?? "") : ""
134
+ if ((deps["@tailwindcss/vite"] && /@tailwindcss\/vite/.test(config)) || viaPostcss) return { status: "pass", name: "css:build", detail: "Tailwind runs in the Vite build." }
135
+ return {
136
+ status: "warn",
137
+ name: "css:build",
138
+ detail: deps["@tailwindcss/vite"]
139
+ ? `@tailwindcss/vite is installed but ${framework.config ?? "the Vite config"} does not use it, so @import "tailwindcss" compiles to nothing.`
140
+ : "No Tailwind integration in the Vite build (@tailwindcss/vite), so @import \"tailwindcss\" compiles to nothing.",
141
+ fix: 'Add @tailwindcss/vite and put tailwindcss() in the Vite config\'s plugins (sable doctor --fix does both).',
142
+ }
143
+ }
144
+ if (viaPostcss) return { status: "pass", name: "css:build", detail: "Tailwind runs through PostCSS." }
145
+ return {
146
+ status: "warn",
147
+ name: "css:build",
148
+ detail: "No @tailwindcss/postcss in a PostCSS config, so Next.js never compiles @import \"tailwindcss\".",
149
+ fix: 'Add @tailwindcss/postcss and a postcss.config.mjs with plugins: { "@tailwindcss/postcss": {} } (sable doctor --fix does both when there is no PostCSS config yet).',
150
+ }
151
+ }
152
+
153
+ const FONT_HREF = "https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Figtree:wght@300..900&display=swap"
154
+ const FONT_LINKS = [
155
+ '<link rel="preconnect" href="https://fonts.googleapis.com" />',
156
+ '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />',
157
+ `<link href="${FONT_HREF}" rel="stylesheet" />`,
158
+ ]
159
+
160
+ /**
161
+ * The Sable theme names Figtree and DM Mono; the app loads the files. Without
162
+ * them the stacks fall back to system fonts — the page works, just not as
163
+ * drawn, so this warns. Any mention counts as loading: a Google Fonts link,
164
+ * next/font, @fontsource, or an @font-face. An app that sets its own
165
+ * `--font-sans` has chosen other fonts and is not judged here.
166
+ */
167
+ export function fontsCheck({ css, source, html, deps }) {
168
+ if (/--font-sans\s*:/.test(css)) return { status: "pass", name: "fonts", detail: "The app sets its own --font-sans; load the files for the fonts it names." }
169
+ const text = [css, source, html, Object.keys(deps ?? {}).join(" ")].join("\n")
170
+ const missing = [!/figtree/i.test(text) && "Figtree", !/dm[\s_+-]?mono/i.test(text) && "DM Mono"].filter(Boolean)
171
+ if (!missing.length) return { status: "pass", name: "fonts", detail: "Figtree and DM Mono are loaded." }
172
+ return {
173
+ status: "warn",
174
+ name: "fonts",
175
+ detail: `${missing.join(" and ")} ${missing.length > 1 ? "are" : "is"} never loaded, so the Sable theme falls back to system fonts.`,
176
+ fix: `Load them in the document head: <link href="${FONT_HREF}" rel="stylesheet" />`,
177
+ }
178
+ }
179
+
180
+ /* ── Snippets. ───────────────────────────────────────────────────────────────── */
181
+
182
+ const CSS_BLOCK = (sourceGlob) => ['@import "tailwindcss";', '@import "@sableui/react/styles.css";', `@source "${sourceGlob}";`].join("\n")
183
+
184
+ /** Where IconProvider goes for this framework, as code to paste. */
185
+ export function providerSnippet(framework) {
186
+ if (framework.id === "next" && framework.appDir)
187
+ return {
188
+ file: `${framework.appDir}/providers.tsx`,
189
+ then: `${framework.root ?? `${framework.appDir}/layout.tsx`}: import { Providers } from "./providers", then <body><Providers>{children}</Providers></body>`,
190
+ code: [
191
+ '"use client"',
192
+ "",
193
+ 'import type { ReactNode } from "react"',
194
+ 'import { IconProvider } from "@sableui/react"',
195
+ 'import { reiconIconSet } from "@sableui/react/icons/reicon"',
196
+ "",
197
+ "// A client boundary: the icon set is components, which a server layout cannot pass as a prop.",
198
+ "export function Providers({ children }: { children: ReactNode }) {",
199
+ "\treturn <IconProvider set={reiconIconSet}>{children}</IconProvider>",
200
+ "}",
201
+ ].join("\n"),
202
+ }
203
+ return {
204
+ file: framework.root ?? "the file that renders your app",
205
+ then: null,
206
+ code: [
207
+ 'import { IconProvider } from "@sableui/react"',
208
+ 'import { reiconIconSet } from "@sableui/react/icons/reicon"',
209
+ "",
210
+ "<IconProvider set={reiconIconSet}>",
211
+ "\t<App />",
212
+ "</IconProvider>",
213
+ ].join("\n"),
214
+ }
215
+ }
216
+
217
+ /* ── Text edits. ─────────────────────────────────────────────────────────────── */
218
+
219
+ const COMMENT = /\/\*[\s\S]*?\*\/|\/\/[^\n]*/g
220
+ const CSS_COMMENT = /\/\*[\s\S]*?\*\//g
221
+ const TW_IMPORT = /@import\s+["']tailwindcss["'][^;]*;/g
222
+ const SABLE_IMPORT = /@import\s+["']@sableui\/react\/styles\.css["'][^;]*;/g
223
+
224
+ /** Matches of `pattern` outside comments — a commented-out import is not wiring. */
225
+ function live(text, pattern, comments = CSS_COMMENT) {
226
+ const ranges = [...text.matchAll(comments)].map((m) => [m.index, m.index + m[0].length])
227
+ return [...text.matchAll(new RegExp(pattern.source, "g"))].filter((m) => !ranges.some(([a, b]) => m.index >= a && m.index < b))
228
+ }
229
+
230
+ /** The indentation of the first indented line after `at`, or two spaces. */
231
+ function indentAfter(text, at) {
232
+ return /\n([ \t]+)\S/.exec(text.slice(at))?.[1] ?? " "
233
+ }
234
+
235
+ /** The file's string quote, read from its import specifiers, so an edit matches its style. */
236
+ function quoteOf(text) {
237
+ return /(?:from\s*|^import\s*)'/m.test(text) && !/(?:from\s*|^import\s*)"/m.test(text) ? "'" : '"'
238
+ }
239
+ const inQuotes = (text, code) => code.replaceAll('"', quoteOf(text))
240
+
241
+ /** Insert an import after the file's last import statement (multi-line ones included), or at the top. */
242
+ function addImport(text, line) {
243
+ const lines = text.split("\n")
244
+ let end = -1
245
+ for (let i = 0; i < lines.length; i++) {
246
+ if (!/^import\b/.test(lines[i])) continue
247
+ let j = i
248
+ while (j < lines.length - 1 && !/(?:from\s*["'][^"']+["']|^import\s*["'][^"']+["'])\s*;?\s*$/.test(lines[j])) j++
249
+ end = i = j
250
+ }
251
+ lines.splice(end + 1, 0, inQuotes(text, line))
252
+ return lines.join("\n")
253
+ }
254
+
255
+ /**
256
+ * Add `key: value` to the object a Vite config exports, when there is exactly
257
+ * one recognisable such object: `defineConfig({`, `defineConfig(() => ({`, or
258
+ * `export default {`. Anything else returns null and the edit is manual.
259
+ */
260
+ function addConfigKey(text, entry) {
261
+ const opens = [
262
+ ...live(text, /defineConfig\(\s*\{/, COMMENT),
263
+ ...live(text, /defineConfig\(\s*(?:async\s*)?\([^)]*\)\s*=>\s*\(\s*\{/, COMMENT),
264
+ ...live(text, /export\s+default\s+\{/, COMMENT),
265
+ ]
266
+ if (opens.length !== 1) return null
267
+ const at = opens[0].index + opens[0][0].length
268
+ const indent = indentAfter(text, at)
269
+ return `${text.slice(0, at)}\n${indent}${inQuotes(text, entry)},${/^\s*\n/.test(text.slice(at)) ? "" : "\n"}${text.slice(at)}`
270
+ }
271
+
272
+ function addDedupe(text) {
273
+ if (/dedupe/.test(text)) return text
274
+ const resolves = live(text, /\bresolve\s*:\s*\{/, COMMENT)
275
+ if (resolves.length > 1) return null
276
+ if (resolves.length === 1) {
277
+ const at = resolves[0].index + resolves[0][0].length
278
+ return `${text.slice(0, at)} ${inQuotes(text, 'dedupe: ["react", "react-dom"]')},${text.slice(at)}`
279
+ }
280
+ return addConfigKey(text, 'resolve: { dedupe: ["react", "react-dom"] }')
281
+ }
282
+
283
+ function addTailwindPlugin(text) {
284
+ if (/@tailwindcss\/vite/.test(text)) return text
285
+ // The plugin's conventional name is taken; renaming someone's binding is a guess.
286
+ if (live(text, /\btailwindcss\b/, COMMENT).length) return null
287
+ const plugins = live(text, /\bplugins\s*:\s*\[/, COMMENT)
288
+ let next
289
+ if (plugins.length === 1) {
290
+ const at = plugins[0].index + plugins[0][0].length
291
+ next = `${text.slice(0, at)}tailwindcss(), ${text.slice(at)}`.replace("tailwindcss(), ]", "tailwindcss()]")
292
+ } else if (!plugins.length) next = addConfigKey(text, "plugins: [tailwindcss()]")
293
+ if (!next) return null
294
+ return addImport(next, 'import tailwindcss from "@tailwindcss/vite"')
295
+ }
296
+
297
+ /* ── The fixer. ──────────────────────────────────────────────────────────────── */
298
+
299
+ /**
300
+ * Apply (or, with dryRun, plan) a fix for each failing doctor check that has a
301
+ * mechanical one. Returns one result per check it acted on:
302
+ * `{ check, status: "applied" | "would-apply" | "manual" | "failed", file?, detail, snippet? }`.
303
+ */
304
+ export function fixWiring({ root, pkgRoot, report, framework, dryRun = false, install = true, quiet = false }) {
305
+ const results = []
306
+ const flagged = new Map(report.checks.filter((c) => c.status === "fail" || c.status === "warn" || (c.status === "info" && c.name === "dedupe")).map((c) => [c.name, c]))
307
+ const handled = new Set()
308
+ const done = (check, detail, file) => {
309
+ handled.add(check)
310
+ results.push({ check, status: dryRun ? "would-apply" : "applied", ...(file ? { file } : {}), detail })
311
+ }
312
+ const manual = (check, detail, snippet, file) => {
313
+ handled.add(check)
314
+ results.push({ check, status: "manual", ...(file ? { file } : {}), detail, ...(snippet ? { snippet } : {}) })
315
+ }
316
+ const write = (file, text) => {
317
+ if (dryRun) return
318
+ mkdirSync(dirname(join(root, file)), { recursive: true })
319
+ writeFileSync(join(root, file), text)
320
+ }
321
+
322
+ /* Dependencies first: everything after edits files that assume them. */
323
+ const pm = detectPackageManager(root)
324
+ // Only a missing package installs. A version outside the range (a warning) is
325
+ // the user's upgrade to make, never a side effect of fixing CSS.
326
+ const failing = (name) => flagged.get(name)?.status === "fail"
327
+ const runtime = [failing("install") && "@sableui/react", failing("react") && "react", failing("react-dom") && "react-dom"].filter(Boolean)
328
+ const build = flagged.get("css:build")
329
+ const deps = declaredDeps(root)
330
+ const integration = framework.id === "vite" ? "@tailwindcss/vite" : framework.id === "next" ? "@tailwindcss/postcss" : null
331
+ const dev = [failing("tailwindcss") && "tailwindcss", build && integration && !deps[integration] && integration].filter(Boolean)
332
+ // The integration package is reported under css:build, with the config edit
333
+ // that makes it do anything; a failed install leaves that check to fail.
334
+ let integrationInstall = dev.includes(integration) ? "pending" : "present"
335
+ for (const [names, isDev] of [[runtime, false], [dev, true]]) {
336
+ if (!names.length) continue
337
+ const cmd = installCommand(pm, names, { dev: isDev })
338
+ const checks = names.filter((n) => n !== integration).map((n) => (n === "@sableui/react" ? "install" : n))
339
+ if (!install) {
340
+ if (names.includes(integration)) integrationInstall = "skipped"
341
+ for (const check of checks) manual(check, "Install it (skipped by --no-install).", cmd.join(" "))
342
+ continue
343
+ }
344
+ if (dryRun) {
345
+ for (const check of checks) done(check, cmd.join(" "))
346
+ continue
347
+ }
348
+ const r = spawnSync(cmd[0], cmd.slice(1), { cwd: root, encoding: "utf8", stdio: quiet ? "pipe" : ["ignore", "inherit", "inherit"], shell: process.platform === "win32" })
349
+ if (names.includes(integration)) integrationInstall = r.status === 0 ? "present" : "failed"
350
+ for (const check of checks)
351
+ if (r.status === 0) done(check, cmd.join(" "))
352
+ else results.push({ check, status: "failed", detail: `${cmd.join(" ")} exited ${r.status ?? "with an error"}${quiet && r.stderr ? `: ${r.stderr.trim().split("\n").pop()}` : ""}` })
353
+ }
354
+ const integrationCmd = integration ? installCommand(pm, [integration], { dev: true }).join(" ") : ""
355
+ const installedNote = dev.includes(integration) ? `${integrationCmd}; ` : ""
356
+ /** True when the integration package could not be installed, after reporting why. */
357
+ const buildBlocked = () => {
358
+ if (integrationInstall === "skipped") manual("css:build", `Install ${integration} (skipped by --no-install), then run sable doctor --fix again.`, integrationCmd)
359
+ else if (integrationInstall === "failed") {
360
+ handled.add("css:build")
361
+ results.push({ check: "css:build", status: "failed", detail: `${integrationCmd} failed, so the build integration was not configured.` })
362
+ } else return false
363
+ return true
364
+ }
365
+
366
+ /* The build: Tailwind in it, and one React. */
367
+ if (framework.id === "vite") {
368
+ const file = framework.config
369
+ const original = file ? read(join(root, file)) : null
370
+ let text = original
371
+ if (build && !buildBlocked()) {
372
+ const next = text === null ? null : addTailwindPlugin(text)
373
+ if (next === null) manual("css:build", `Add tailwindcss() from @tailwindcss/vite to the plugins in ${file ?? "your Vite config"}.`, 'import tailwindcss from "@tailwindcss/vite"\n// plugins: [tailwindcss(), …]', file ?? undefined)
374
+ else {
375
+ text = next
376
+ done("css:build", `${installedNote}tailwindcss() in the Vite plugins.`, file)
377
+ }
378
+ }
379
+ if (flagged.has("dedupe")) {
380
+ const next = text === null ? null : addDedupe(text)
381
+ if (next === null) manual("dedupe", `Add resolve.dedupe to ${file ?? "your Vite config"}; its shape is not one this edits safely.`, 'resolve: { dedupe: ["react", "react-dom"] }', file ?? undefined)
382
+ else {
383
+ text = next
384
+ done("dedupe", 'resolve: { dedupe: ["react", "react-dom"] } in the Vite config.', file)
385
+ }
386
+ }
387
+ if (text !== null && text !== original) write(file, text)
388
+ } else if (framework.id === "next" && build && !buildBlocked()) {
389
+ const existing = POSTCSS_CONFIGS.find((f) => existsSync(join(root, f)))
390
+ if (existing) manual("css:build", `Add "@tailwindcss/postcss": {} to the plugins in ${existing}.`, 'plugins: { "@tailwindcss/postcss": {} }', existing)
391
+ else {
392
+ write("postcss.config.mjs", 'const config = {\n\tplugins: {\n\t\t"@tailwindcss/postcss": {},\n\t},\n}\n\nexport default config\n')
393
+ done("css:build", `${installedNote}a postcss.config.mjs running @tailwindcss/postcss.`, "postcss.config.mjs")
394
+ }
395
+ } else if (build && !handled.has("css:build")) manual("css:build", build.detail, build.fix)
396
+ // Next.js resolves react to its own bundled copy for the app, so there is no
397
+ // second React to dedupe; doctor only notes that it could not confirm one.
398
+ if (flagged.has("dedupe") && !handled.has("dedupe") && framework.id !== "next")
399
+ manual("dedupe", "Dedupe React in your bundler, so Base UI and the app share one copy.", 'resolve: { dedupe: ["react", "react-dom"] }')
400
+
401
+ /* The stylesheet: tailwindcss, then Sable's styles, then the @source. */
402
+ const cssChecks = ["css", "css:tailwind", "css:styles", "css:order", "css:source"].filter((n) => flagged.has(n))
403
+ if (cssChecks.length) fixStylesheet({ root, framework, cssChecks, done, manual, write })
404
+
405
+ /* A generated sources list that went stale. */
406
+ const sources = flagged.get("css:sources")
407
+ if (sources?.status === "fail") {
408
+ const file = /--write\s+(\S+)/.exec(sources.fix ?? "")?.[1]
409
+ if (!file) manual("css:sources", sources.detail, sources.fix)
410
+ else {
411
+ if (!dryRun) writeFileSync(resolve(root, file), sourcesCss(pkgRoot, root, file).css)
412
+ done("css:sources", "The @source list regenerated for the modules the app imports.", file)
413
+ }
414
+ }
415
+
416
+ /* Fonts, in the document head. */
417
+ if (flagged.has("fonts")) {
418
+ const html = framework.html ? read(join(root, framework.html)) : null
419
+ const head = html ? live(html, /<\/head>/, /<!--[\s\S]*?-->/g) : []
420
+ if (head.length === 1) {
421
+ const lineStart = html.lastIndexOf("\n", head[0].index) + 1
422
+ const closing = html.slice(lineStart, head[0].index)
423
+ const inner = /\n([ \t]+)<[^/][^\n]*\n[ \t]*$/.exec(html.slice(0, lineStart))?.[1] ?? `${closing}\t`
424
+ const block = FONT_LINKS.map((l) => `${inner}${l}\n`).join("")
425
+ write(framework.html, html.slice(0, lineStart) + block + html.slice(lineStart))
426
+ done("fonts", "Google Fonts links for Figtree and DM Mono in the head.", framework.html)
427
+ } else
428
+ manual(
429
+ "fonts",
430
+ "Load Figtree and DM Mono in the document head" + (framework.id === "next" ? ` — in ${framework.root ?? "the root layout"}, inside <head>.` : "."),
431
+ FONT_LINKS.join("\n"),
432
+ framework.id === "next" ? (framework.root ?? undefined) : undefined,
433
+ )
434
+ }
435
+
436
+ /* IconProvider: edited only in the starter's own shape — one <App /> in a
437
+ * Vite entry that calls createRoot. Anything else is printed. */
438
+ if (flagged.has("icons")) {
439
+ const snippet = providerSnippet(framework)
440
+ const file = framework.id === "vite" ? framework.root : null
441
+ const text = file ? read(join(root, file)) : null
442
+ const apps = text ? live(text, /<App\s*\/>/, COMMENT) : []
443
+ if (text && apps.length === 1 && /createRoot\s*\(/.test(text) && !/\bIconProvider\b|\breiconIconSet\b/.test(text)) {
444
+ const m = apps[0]
445
+ const lineStart = text.lastIndexOf("\n", m.index) + 1
446
+ const indent = /^[ \t]*/.exec(text.slice(lineStart))[0]
447
+ const own = text.slice(lineStart, m.index).trim() === ""
448
+ const unit = indent.includes("\t") ? "\t" : " "
449
+ const wrapped = own
450
+ ? `<IconProvider set={reiconIconSet}>\n${indent}${unit}<App />\n${indent}</IconProvider>`
451
+ : "<IconProvider set={reiconIconSet}><App /></IconProvider>"
452
+ let next = text.slice(0, m.index) + wrapped + text.slice(m.index + m[0].length)
453
+ next = addImport(addImport(next, 'import { IconProvider } from "@sableui/react"'), 'import { reiconIconSet } from "@sableui/react/icons/reicon"')
454
+ write(file, next)
455
+ done("icons", "<App /> wrapped in <IconProvider set={reiconIconSet}>.", file)
456
+ } else manual("icons", `Mount IconProvider once at the app root${snippet.then ? `, then ${snippet.then}` : ""}.`, snippet.code, snippet.file)
457
+ }
458
+
459
+ /* Everything else doctor flags has no mechanical fix: print its own. */
460
+ for (const [name, check] of flagged) {
461
+ if (handled.has(name) || check.status === "info" || name === "css:sources") continue
462
+ if (name === "css:build") continue
463
+ manual(name, check.detail, check.fix)
464
+ }
465
+ if (sources && sources.status !== "fail") manual("css:sources", sources.detail, sources.fix)
466
+ return results
467
+ }
468
+
469
+ function fixStylesheet({ root, framework, cssChecks, done, manual, write }) {
470
+ // Every stylesheet in the project (not node_modules or build output), to find
471
+ // the one that already carries the imports.
472
+ const files = []
473
+ const walk = (dir) => {
474
+ let entries
475
+ try {
476
+ entries = readdirSync(dir, { withFileTypes: true })
477
+ } catch {
478
+ return
479
+ }
480
+ for (const entry of entries) {
481
+ if (["node_modules", "dist", "build", "out", ".next", ".git", "coverage", ".turbo"].includes(entry.name)) continue
482
+ const full = join(dir, entry.name)
483
+ if (entry.isDirectory()) walk(full)
484
+ else if (/\.css$/.test(entry.name)) files.push(posix(relative(root, full)))
485
+ }
486
+ }
487
+ walk(root)
488
+ const text = (f) => read(join(root, f)) ?? ""
489
+ const withSable = files.find((f) => live(text(f), SABLE_IMPORT).length)
490
+ const withTw = files.find((f) => live(text(f), TW_IMPORT).length)
491
+ const candidate = framework.stylesheets.find((f) => files.includes(f))
492
+ const entry = withSable ?? withTw ?? candidate ?? null
493
+
494
+ const pkgDir = upward(root).map((d) => join(d, "node_modules", "@sableui", "react")).find((d) => existsSync(d)) ?? join(root, "node_modules", "@sableui", "react")
495
+ const globFor = (file) => `${posix(relative(dirname(join(root, file)), join(pkgDir, "dist")))}/**/*.{js,mjs}`
496
+
497
+ if (!entry) {
498
+ // No stylesheet at all: create the framework's own and import it from the
499
+ // file that renders the app — the two edits a starter would have made.
500
+ const file = framework.stylesheets[0]
501
+ const rootFile = framework.root
502
+ const rootText = rootFile ? read(join(root, rootFile)) : null
503
+ if (framework.id === "unknown" || !rootText) {
504
+ for (const check of cssChecks) manual(check, `Create a global stylesheet${framework.id === "unknown" ? "" : ` (${file})`}, import it where the app renders, and start it with:`, CSS_BLOCK(globFor(file)))
505
+ return
506
+ }
507
+ write(file, `${CSS_BLOCK(globFor(file))}\n`)
508
+ const spec = `./${posix(relative(dirname(join(root, rootFile)), join(root, file)))}`
509
+ write(rootFile, addImport(rootText, `import "${spec}"`))
510
+ for (const check of cssChecks) done(check, `A new ${file} with the Sable imports, imported from ${rootFile}.`, file)
511
+ return
512
+ }
513
+
514
+ let css = text(entry)
515
+ const sableHere = live(css, SABLE_IMPORT)
516
+ // Sable's import is somewhere other than the file with tailwindcss (a partial
517
+ // imported before it): moving it means choosing between the user's files.
518
+ if (cssChecks.includes("css:order") && (!sableHere.length || !live(css, TW_IMPORT).length)) {
519
+ manual("css:order", `Import tailwindcss before @sableui/react/styles.css; they are split across files here (${[withTw, withSable].filter(Boolean).join(", ")}).`, CSS_BLOCK(globFor(entry)))
520
+ return
521
+ }
522
+ const what = []
523
+ for (const m of sableHere.reverse()) css = css.slice(0, m.index) + css.slice(m.index + m[0].length).replace(/^[ \t]*\r?\n/, "")
524
+ let tw = live(css, TW_IMPORT)[0]
525
+ if (!tw) {
526
+ const charset = /^@charset[^;]*;\s*\n?/.exec(css)
527
+ const at = charset ? charset[0].length : 0
528
+ css = `${css.slice(0, at)}@import "tailwindcss";\n${css.slice(at)}`
529
+ tw = live(css, TW_IMPORT)[0]
530
+ what.push('@import "tailwindcss"')
531
+ }
532
+ what.push(sableHere.length ? '@import "@sableui/react/styles.css" moved after it' : '@import "@sableui/react/styles.css" after it')
533
+ const all = files.map(text).join("\n")
534
+ const hasSource = /@source[^;]*(@sableui\/react|packages\/sable)/.test(all.replace(CSS_COMMENT, "")) || all.includes(SOURCES_MARKER)
535
+ if (!hasSource) what.push(`@source "${globFor(entry)}"`)
536
+ const at = tw.index + tw[0].length
537
+ const block = `\n@import "@sableui/react/styles.css";${hasSource ? "" : `\n@source "${globFor(entry)}";`}`
538
+ css = css.slice(0, at) + block + css.slice(at)
539
+ if (css !== text(entry)) write(entry, css)
540
+ for (const check of cssChecks) done(check, `${what.join(", ")}.`, entry)
541
+ }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: sable
3
- description: Build websites and apps with Sable, develop reusable presets from inspiration, audit interfaces, and polish designs using the installed library.
3
+ description: Build websites and apps with Sable, develop reusable presets from inspiration, audit interfaces, polish designs, and fix scoped defects using the installed library.
4
4
  ---
5
5
 
6
6
  # Sable
@@ -27,7 +27,8 @@ named `sable` or `sable-ui`. Explain missing access and continue
27
27
  independent brief work when installation is blocked.
28
28
 
29
29
  Primary modes are `build <idea> [using <preset>]`, `inspire <references or brief>`,
30
- `audit <existing interface>`, and `polish <target>`. `create` aliases `build`.
30
+ `audit <existing interface>`, `polish <target>`, and `fix <target>` for one scoped
31
+ defect. `create` aliases `build`.
31
32
  These are agent modes, not terminal subcommands. Every build must query and use
32
33
  the installed Sable components, variants, utilities, tokens, layouts, and guides,
33
34
  then run its design lint and inspect the working result. Do not begin UI code