@sableui/react 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +10 -4
- package/CHANGELOG.md +199 -0
- package/DESIGN.md +3 -3
- 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 +49 -33
- package/dist/icon-sets/reicon.js +2 -0
- package/dist/index.d.ts +660 -164
- package/dist/index.js +12 -1
- package/dist/lib/palette.js +46 -6
- 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/palette.css +243 -165
- 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/reference/token-intent.md +1 -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 +1322 -27
- 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/scripts/lint-rules.mjs
CHANGED
|
@@ -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
|
+
}
|
package/skills/entry/SKILL.md
CHANGED
|
@@ -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,
|
|
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>`,
|
|
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
|