@sableui/react 0.7.0 → 0.8.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 (77) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +115 -0
  3. package/README.md +2 -0
  4. package/bin/sable.mjs +34 -0
  5. package/dist/blocks/traffic-sources-block.js +1 -1
  6. package/dist/blocks/traffic-trend-block.js +3 -3
  7. package/dist/components/breadcrumbs/breadcrumbs.js +10 -9
  8. package/dist/components/breadcrumbs/breadcrumbs.meta.js +2 -2
  9. package/dist/components/button/button.meta.js +4 -0
  10. package/dist/components/card/card.js +7 -4
  11. package/dist/components/chat-composer/chat-composer.js +4 -2
  12. package/dist/components/chat-composer/chat-composer.meta.js +8 -2
  13. package/dist/components/command-menu/command-menu.js +9 -2
  14. package/dist/components/command-menu/command-menu.meta.js +3 -3
  15. package/dist/components/data-table/data-table.js +117 -64
  16. package/dist/components/data-table/data-table.meta.js +27 -1
  17. package/dist/components/form/form.js +30 -9
  18. package/dist/components/form/form.meta.js +17 -3
  19. package/dist/components/message-scroller/message-scroller.meta.js +1 -1
  20. package/dist/components/page-header/page-header.js +1 -0
  21. package/dist/components/page-header/page-header.meta.js +1 -1
  22. package/dist/components/pie-chart/pie-chart.js +3 -3
  23. package/dist/components/pie-chart/pie-chart.meta.js +3 -3
  24. package/dist/components/radial-chart/radial-chart.js +3 -3
  25. package/dist/components/radial-chart/radial-chart.meta.js +2 -2
  26. package/dist/components/settings-menu/settings-menu.js +10 -1
  27. package/dist/components/settings-menu/settings-menu.meta.js +1 -1
  28. package/dist/components/side-nav/side-nav.variants.js +3 -2
  29. package/dist/components/sidebar/sidebar.js +1 -1
  30. package/dist/components/sidebar/sidebar.meta.js +5 -0
  31. package/dist/components/switch/switch.js +1 -1
  32. package/dist/components/switch/switch.meta.js +2 -2
  33. package/dist/components/table/table.js +1 -1
  34. package/dist/components/toggle/toggle.js +2 -1
  35. package/dist/file-hashes.json +603 -0
  36. package/dist/icon-sets/reicon.js +13 -1
  37. package/dist/index.d.ts +88 -15
  38. package/dist/index.js +1 -1
  39. package/dist/layout/index.d.ts +5 -2
  40. package/dist/lib/chart/format.js +5 -0
  41. package/dist/lib/component-guidance.js +7 -2
  42. package/dist/lib/heading-row.js +3 -2
  43. package/dist/lib/trim-label.js +17 -4
  44. package/dist/presets/index.d.ts +7 -1
  45. package/dist/presets/index.js +10 -0
  46. package/dist/styles.css +27 -2
  47. package/llms/blocks/traffic-sources-block.md +1 -1
  48. package/llms/blocks/traffic-trend-block.md +5 -3
  49. package/llms/components/breadcrumbs.md +1 -1
  50. package/llms/components/button.md +1 -0
  51. package/llms/components/chat-composer.md +3 -2
  52. package/llms/components/command-menu.md +2 -2
  53. package/llms/components/data-table.md +33 -1
  54. package/llms/components/form.md +19 -2
  55. package/llms/components/message-scroller.md +1 -1
  56. package/llms/components/page-header.md +1 -1
  57. package/llms/components/pie-chart.md +2 -2
  58. package/llms/components/radial-chart.md +1 -1
  59. package/llms/components/settings-menu.md +1 -1
  60. package/llms/components/sidebar.md +1 -0
  61. package/llms/components/switch.md +2 -2
  62. package/llms/layout/region.md +16 -3
  63. package/llms/tasks/building-a-page.md +34 -3
  64. package/llms/tasks/creating-a-preset.md +4 -1
  65. package/llms/tasks/patching-a-component.md +48 -0
  66. package/llms/tasks/reviewing-a-design.md +9 -0
  67. package/llms/tasks/using-presets.md +19 -0
  68. package/llms-full.txt +207 -27
  69. package/llms.txt +1 -1
  70. package/package.json +4 -2
  71. package/registry.json +132 -30
  72. package/scripts/design-memory.mjs +7 -2
  73. package/scripts/design-presets.mjs +15 -4
  74. package/scripts/lint-rules.mjs +32 -1
  75. package/scripts/patches.mjs +219 -0
  76. package/scripts/sources.mjs +247 -0
  77. package/skills/sable/SKILL.md +16 -4
@@ -0,0 +1,219 @@
1
+ /**
2
+ * sable patch — fix a Sable component in place while the library fix ships, and
3
+ * hear about it on every upgrade.
4
+ *
5
+ * A defect in a component left an app two choices: restyle it with className,
6
+ * which `component-restyle` rightly flags, or wait for a release. A patch is the
7
+ * third: the project's own package manager patches the installed module
8
+ * (`pnpm patch`, `yarn patch`, or patch-package for npm), and this records why,
9
+ * against which version, and how to tell whether it is still needed:
10
+ *
11
+ * .sable/patches.json { patches: [{ component, files, version, base, reason, repro, issue? }] }
12
+ *
13
+ * `base` holds the hash of each file as Sable shipped it at that version
14
+ * (dist/file-hashes.json), so `sable doctor` can tell three things on a later
15
+ * install: whether the patch is applied, whether Sable has changed the file
16
+ * since, and what the changelog says about the component in between. A patch
17
+ * whose file Sable changed fails doctor (and so `sable check`) until someone
18
+ * re-runs the repro and keeps the patch or drops it. Nothing changes silently,
19
+ * in either direction.
20
+ */
21
+ import { createHash } from "node:crypto"
22
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
23
+ import { dirname, join, parse, relative, resolve } from "node:path"
24
+ import { fileURLToPath } from "node:url"
25
+ import { packageManager } from "./agent-setup.mjs"
26
+ import { parseFlags } from "./design-preferences.mjs"
27
+
28
+ export const PATCHES_FILE = join(".sable", "patches.json")
29
+ const PACKAGE = "@sableui/react"
30
+
31
+ const hash = (path) => createHash("sha256").update(readFileSync(path)).digest("hex").slice(0, 16)
32
+
33
+ /** Hashes of every dist file as published, written at build time. */
34
+ function shippedHashes(pkgRoot) {
35
+ const path = join(pkgRoot, "dist", "file-hashes.json")
36
+ return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null
37
+ }
38
+
39
+ export function readPatches(root) {
40
+ const path = join(root, PATCHES_FILE)
41
+ if (!existsSync(path)) return []
42
+ const data = JSON.parse(readFileSync(path, "utf8"))
43
+ if (!Array.isArray(data?.patches)) throw Object.assign(new Error(`${PATCHES_FILE} has no patches array.`), { code: "ERR_PATCHES_INVALID" })
44
+ return data.patches
45
+ }
46
+
47
+ function writePatches(root, patches) {
48
+ const path = join(root, PATCHES_FILE)
49
+ mkdirSync(dirname(path), { recursive: true })
50
+ writeFileSync(path, `${JSON.stringify({ patches }, null, 2)}\n`)
51
+ }
52
+
53
+ /**
54
+ * The dist modules a component's patch covers: its own folder's modules, not
55
+ * the barrel or the docs metadata. A part (CardList) resolves to its component.
56
+ */
57
+ export function componentFiles(pkgRoot, name) {
58
+ const registry = JSON.parse(readFileSync(join(pkgRoot, "registry.json"), "utf8"))
59
+ const entries = [...(registry.components ?? []), ...(registry.layoutPrimitives?.primitives ?? [])]
60
+ const entry =
61
+ entries.find((c) => c.name === name) ??
62
+ entries.find((c) => (c.subcomponents ?? []).some((s) => s.name === name))
63
+ if (!entry?.path) return null
64
+ const dir = join(pkgRoot, "dist", entry.path)
65
+ if (!existsSync(dir)) return null
66
+ return {
67
+ component: entry.name,
68
+ files: readdirSync(dir)
69
+ .filter((f) => f.endsWith(".js") && f !== "index.js" && !f.endsWith(".meta.js"))
70
+ .map((f) => `dist/${entry.path}/${f}`),
71
+ }
72
+ }
73
+
74
+ /** Changelog bullets between two versions (exclusive, inclusive) that name the component. */
75
+ export function changelogMentions(pkgRoot, component, from, to) {
76
+ const path = join(pkgRoot, "CHANGELOG.md")
77
+ if (!existsSync(path)) return []
78
+ const text = readFileSync(path, "utf8")
79
+ const out = []
80
+ for (const section of text.split(/^## /m).slice(1)) {
81
+ const version = section.split(/\s/, 1)[0]
82
+ if (compareVersions(version, from) <= 0 || compareVersions(version, to) > 0) continue
83
+ for (const bullet of section.split(/\n(?=- )/)) {
84
+ if (!bullet.startsWith("- ")) continue
85
+ if (!new RegExp(`\\b${component}\\b`).test(bullet)) continue
86
+ const lead = /^- \*\*(.+?)\*\*/.exec(bullet)?.[1] ?? bullet.slice(2, 120)
87
+ out.push(`${version}: ${lead.replace(/\s+/g, " ").replace(/\.$/, "")}`)
88
+ }
89
+ }
90
+ return out
91
+ }
92
+
93
+ function compareVersions(a, b) {
94
+ const x = String(a).split(/[.-]/).map((n) => Number(n) || 0)
95
+ const y = String(b).split(/[.-]/).map((n) => Number(n) || 0)
96
+ for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) < (y[i] ?? 0) ? -1 : 1
97
+ return 0
98
+ }
99
+
100
+ /** How to make, commit and remove a patch with this project's package manager. */
101
+ export function patchSteps(pm, version, files) {
102
+ const list = files.join(", ")
103
+ if (pm === "pnpm")
104
+ return {
105
+ make: [`pnpm patch ${PACKAGE}@${version}`, `edit ${list} in the folder it prints`, "pnpm patch-commit <that folder>"],
106
+ remove: [`pnpm patch-remove ${PACKAGE}@${version}`],
107
+ }
108
+ if (pm === "yarn")
109
+ return {
110
+ make: [`yarn patch ${PACKAGE}`, `edit ${list} in the folder it prints`, "yarn patch-commit -s <that folder>", "(Yarn 1: use the npm steps with patch-package)"],
111
+ remove: [`remove the patch: entry for ${PACKAGE} from package.json resolutions, then yarn install`],
112
+ }
113
+ return {
114
+ make: [`edit ${files.map((f) => `node_modules/${PACKAGE}/${f}`).join(", ")}`, `npx patch-package ${PACKAGE}`, 'add "postinstall": "patch-package" to package.json scripts'],
115
+ remove: [`delete patches/${PACKAGE.replace("/", "+")}+${version}.patch, then reinstall`],
116
+ }
117
+ }
118
+
119
+ /**
120
+ * One doctor check per recorded patch. `fail` when someone has to decide —
121
+ * Sable changed a patched file, or a patch is missing from the installed copy —
122
+ * so the agent is told on the upgrade itself, not when a user hits the bug.
123
+ */
124
+ export function patchChecks(pkgRoot, root, installed) {
125
+ let patches
126
+ try {
127
+ patches = readPatches(root)
128
+ } catch (error) {
129
+ return [{ status: "fail", name: "patches", detail: error.message, fix: `Fix or delete ${PATCHES_FILE}.` }]
130
+ }
131
+ if (!patches.length) return []
132
+ const shipped = shippedHashes(pkgRoot)
133
+ if (!shipped) return [{ status: "warn", name: "patches", detail: `${patches.length} recorded, but this Sable build ships no file hashes to check them against.` }]
134
+ const pm = packageManager(parse(root).root, root)
135
+ return patches.map((p) => {
136
+ const name = `patch:${p.component}`
137
+ const why = `"${p.reason}"`
138
+ const missing = p.files.filter((f) => !shipped[f])
139
+ if (missing.length)
140
+ return { status: "fail", name, detail: `${p.version} → ${installed}: Sable no longer ships ${missing.join(", ")} (${why}). The component was restructured.`, fix: `Re-run the repro without the patch: ${p.repro}. Fixed → sable patch ${p.component} --drop. Still broken → patch the new files, then sable patch ${p.component} --keep.` }
141
+ const applied = p.files.some((f) => existsSync(join(pkgRoot, f)) && hash(join(pkgRoot, f)) !== shipped[f])
142
+ const changed = p.files.filter((f) => p.base?.[f] !== shipped[f])
143
+ const steps = patchSteps(pm, installed, p.files)
144
+ if (p.version === installed) {
145
+ if (applied) return { status: "pass", name, detail: `patched for ${why}${p.issue ? ` (${p.issue})` : ""}.` }
146
+ return { status: "warn", name, detail: `recorded for ${why} but not applied: the installed files are Sable's own.`, fix: steps.make.join(" → ") }
147
+ }
148
+ if (!changed.length && applied) return { status: "pass", name, detail: `patched for ${why}; Sable has not changed these files since ${p.version}.` }
149
+ const notes = changelogMentions(pkgRoot, p.component, p.version, installed)
150
+ const news = notes.length ? ` Changelog: ${notes.join(" | ")}.` : ""
151
+ if (!changed.length)
152
+ return { status: "fail", name, detail: `${p.version} → ${installed}: Sable did not change ${p.component}, and your patch (${why}) is not applied to ${installed}.`, fix: `Re-apply it: ${steps.make.join(" → ")}. Then sable patch ${p.component} --keep.` }
153
+ return {
154
+ status: "fail",
155
+ name,
156
+ detail: `${p.version} → ${installed}: Sable changed ${changed.join(", ")} since your patch (${why}).${news}`,
157
+ fix: `Re-run the repro on Sable's own files${applied ? " (remove the patch first)" : ""}: ${p.repro}. Fixed → sable patch ${p.component} --drop. Still broken → re-patch (${steps.make.join(" → ")}), then sable patch ${p.component} --keep, and report it.`,
158
+ }
159
+ })
160
+ }
161
+
162
+ /* ── CLI ────────────────────────────────────────────────────────────────────── */
163
+
164
+ const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
165
+ if (isMain) {
166
+ const args = process.argv.slice(2)
167
+ const json = args.includes("--json")
168
+ const emit = (type, data, text) => process.stdout.write(json ? `${JSON.stringify({ type, data })}\n` : `${text}\n`)
169
+ try {
170
+ const pkgRoot = fileURLToPath(new URL("../", import.meta.url))
171
+ const root = process.cwd()
172
+ const installed = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8")).version
173
+ const name = args[0] && !args[0].startsWith("--") ? args[0] : null
174
+ const flags = parseFlags(name ? args.slice(1) : args, ["--reason", "--repro", "--issue", "--file"], ["--list", "--keep", "--drop", "--json"])
175
+ const patches = readPatches(root)
176
+ const pm = packageManager(parse(root).root, root)
177
+ if (flags.has("--list") || !name) {
178
+ const checks = patchChecks(pkgRoot, root, installed)
179
+ emit("patch.list", { patches, checks }, checks.length ? checks.map((c) => `${c.status === "pass" ? "✓" : c.status === "warn" ? "!" : "✗"} ${c.name} ${c.detail}${c.fix ? `\n → ${c.fix}` : ""}`).join("\n") : "No Sable patches recorded.")
180
+ } else if (flags.has("--drop")) {
181
+ const p = patches.find((x) => x.component === name)
182
+ if (!p) throw Object.assign(new Error(`No recorded patch for ${name}.`), { code: "ERR_NO_PATCH" })
183
+ writePatches(root, patches.filter((x) => x !== p))
184
+ const steps = patchSteps(pm, p.version, p.files)
185
+ emit("patch.dropped", { component: name, remove: steps.remove }, `Dropped the ${name} patch record. Remove the patch itself too: ${steps.remove.join(" → ")}`)
186
+ } else if (flags.has("--keep")) {
187
+ const p = patches.find((x) => x.component === name)
188
+ if (!p) throw Object.assign(new Error(`No recorded patch for ${name}.`), { code: "ERR_NO_PATCH" })
189
+ const shipped = shippedHashes(pkgRoot) ?? {}
190
+ Object.assign(p, { version: installed, base: Object.fromEntries(p.files.map((f) => [f, shipped[f]])) })
191
+ writePatches(root, patches)
192
+ emit("patch.kept", p, `Kept the ${name} patch against ${installed}. Report it upstream if you haven't, so the library fix can replace it.`)
193
+ } else {
194
+ const reason = flags.get("--reason")
195
+ const repro = flags.get("--repro")
196
+ if (!reason || !repro)
197
+ throw Object.assign(new Error("A patch records why and how to check it: pass --reason \"<what is wrong>\" and --repro \"<how to see it>\"."), { code: "ERR_BAD_ARGUMENT" })
198
+ if (patches.some((x) => x.component === name)) throw Object.assign(new Error(`${name} already has a patch record. Use --keep or --drop.`), { code: "ERR_PATCH_EXISTS" })
199
+ const found = componentFiles(pkgRoot, name)
200
+ const extra = flags.get("--file")
201
+ if (!found && !extra) throw Object.assign(new Error(`No Sable component named ${name}. For a shared module, pass --file dist/lib/<module>.js.`), { code: "ERR_UNKNOWN_COMPONENT" })
202
+ const shipped = shippedHashes(pkgRoot) ?? {}
203
+ const files = [...(found?.files ?? []), ...(extra ? [extra.replace(/\\/g, "/")] : [])]
204
+ const unknown = files.filter((f) => !shipped[f])
205
+ if (unknown.length) throw Object.assign(new Error(`Not a published Sable file: ${unknown.join(", ")}.`), { code: "ERR_UNKNOWN_FILE" })
206
+ const record = { component: found?.component ?? name, files, version: installed, base: Object.fromEntries(files.map((f) => [f, shipped[f]])), reason, repro, ...(flags.get("--issue") ? { issue: flags.get("--issue") } : {}) }
207
+ writePatches(root, [...patches, record])
208
+ const steps = patchSteps(pm, installed, files)
209
+ emit(
210
+ "patch.recorded",
211
+ { ...record, steps: steps.make },
212
+ `Recorded a patch for ${record.component} against ${installed} in ${relative(root, join(root, PATCHES_FILE))}.\nNow make it with ${pm}:\n${steps.make.map((s) => ` - ${s}`).join("\n")}\nOn every upgrade, sable doctor checks it: it fails when Sable changes these files, until the repro says keep (--keep) or drop (--drop).\nReport the defect upstream so the library fix can replace the patch.`,
213
+ )
214
+ }
215
+ } catch (error) {
216
+ process.stderr.write(`${JSON.stringify({ type: "error", code: error.code ?? "ERR_PATCH", message: error.message, suggestions: ["sable patch --list", "sable docs patching-a-component"] })}\n`)
217
+ process.exitCode = 1
218
+ }
219
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * sable sources — point Tailwind at the Sable modules an app actually imports.
3
+ *
4
+ * The install recipe's `@source` covers the whole published `dist`, so Tailwind
5
+ * generates every component's classes whether the app renders it or not:
6
+ * Sable's own stylesheet is about 6 KB gzipped, and the whole-dist scan takes an
7
+ * app to about 32 KB. A support app measured that, a third more CSS than the
8
+ * same app on shadcn.
9
+ *
10
+ * This follows the app's imports from `@sableui/react` (and its subpaths)
11
+ * through the published module graph — each name to the module that defines it,
12
+ * then every module that one imports, static and lazy — and writes one
13
+ * `@source` per module. The app's own files are Tailwind's to find as before.
14
+ *
15
+ * The cost is that the list goes stale when the app imports something new, and
16
+ * a component missing from it renders unstyled without an error. So the file
17
+ * says where it came from, and `sable doctor` (and `sable check`) recompute it
18
+ * and fail when a module the app needs is not covered.
19
+ */
20
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
21
+ import { dirname, join, relative, resolve } from "node:path"
22
+ import { fileURLToPath } from "node:url"
23
+
24
+ export const SOURCES_MARKER = "Generated by sable sources"
25
+ const SKIP = new Set(["node_modules", "dist", "build", "out", ".next", ".git", "coverage", ".turbo"])
26
+
27
+ /** `@sableui/react[/sub]` specifier → the published module, from the package's exports map. */
28
+ function entryFor(pkgRoot, specifier) {
29
+ const pkg = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8"))
30
+ const exports = pkg.publishConfig?.exports ?? pkg.exports ?? {}
31
+ const key = specifier === "@sableui/react" ? "." : `.${specifier.slice("@sableui/react".length)}`
32
+ const target = exports[key]
33
+ const path = typeof target === "string" ? target : target?.default ?? target?.import
34
+ if (!path || !path.endsWith(".js")) return null
35
+ const file = join(pkgRoot, path)
36
+ return existsSync(file) ? file : null
37
+ }
38
+
39
+ const moduleCache = new Map()
40
+ /** A module's imports and exports, read lexically from the bundler's plain ESM output. */
41
+ function parseModule(file) {
42
+ if (moduleCache.has(file)) return moduleCache.get(file)
43
+ const text = readFileSync(file, "utf8")
44
+ const rel = (spec) => resolve(dirname(file), spec)
45
+ const names = (list) =>
46
+ list
47
+ .split(",")
48
+ .map((s) => s.trim())
49
+ .filter(Boolean)
50
+ .map((s) => {
51
+ const [from, as] = s.split(/\s+as\s+/)
52
+ return { from: from.trim(), as: (as ?? from).trim() }
53
+ })
54
+ const imports = new Map() // local → { file, name }
55
+ for (const m of text.matchAll(/import\s*\{([^}]*)\}\s*from\s*"(\.[^"]+)"/g))
56
+ for (const n of names(m[1])) imports.set(n.as, { file: rel(m[2]), name: n.from })
57
+ const reexports = new Map() // exported → { file, name }
58
+ const local = new Set()
59
+ const stars = []
60
+ for (const m of text.matchAll(/export\s*\{([^}]*)\}\s*from\s*"(\.[^"]+)"/g))
61
+ for (const n of names(m[1])) reexports.set(n.as, { file: rel(m[2]), name: n.from })
62
+ for (const m of text.matchAll(/export\s*\*\s*from\s*"(\.[^"]+)"/g)) stars.push(rel(m[1]))
63
+ for (const m of text.matchAll(/export\s*\{([^}]*)\}\s*(?:;|\n|$)/g))
64
+ for (const n of names(m[1])) {
65
+ if (imports.has(n.from)) reexports.set(n.as, imports.get(n.from))
66
+ else local.add(n.as)
67
+ }
68
+ for (const m of text.matchAll(/export\s+(?:async\s+)?(?:function\*?|const|let|var|class)\s+([\w$]+)/g)) local.add(m[1])
69
+ const deps = [
70
+ ...[...text.matchAll(/(?:import|export)\s[^"']*?from\s*"(\.[^"]+)"/g)].map((m) => rel(m[1])),
71
+ ...[...text.matchAll(/import\s*"(\.[^"]+)"/g)].map((m) => rel(m[1])),
72
+ ...[...text.matchAll(/import\(\s*"(\.[^"]+)"\s*\)/g)].map((m) => rel(m[1])),
73
+ ]
74
+ const parsed = { local, reexports, stars, deps }
75
+ moduleCache.set(file, parsed)
76
+ return parsed
77
+ }
78
+
79
+ /** The module that defines `name`, following re-exports and `export *`. */
80
+ function definingModule(file, name, seen = new Set()) {
81
+ if (seen.has(file) || !existsSync(file)) return null
82
+ seen.add(file)
83
+ const m = parseModule(file)
84
+ if (m.local.has(name)) return file
85
+ const re = m.reexports.get(name)
86
+ if (re) return definingModule(re.file, re.name, seen)
87
+ for (const star of m.stars) {
88
+ const hit = definingModule(star, name, seen)
89
+ if (hit) return hit
90
+ }
91
+ return null
92
+ }
93
+
94
+ /** Every module reachable from `roots` by relative import. */
95
+ function closure(roots) {
96
+ const out = new Set()
97
+ const stack = [...roots]
98
+ while (stack.length) {
99
+ const file = stack.pop()
100
+ if (out.has(file) || !existsSync(file)) continue
101
+ out.add(file)
102
+ stack.push(...parseModule(file).deps)
103
+ }
104
+ return out
105
+ }
106
+
107
+ /** The app's source files, outside dependencies and build output. */
108
+ export function appSources(root) {
109
+ const out = []
110
+ const walk = (dir) => {
111
+ let entries
112
+ try {
113
+ entries = readdirSync(dir)
114
+ } catch {
115
+ return
116
+ }
117
+ for (const name of entries) {
118
+ if (SKIP.has(name) || name.startsWith(".")) continue
119
+ const full = join(dir, name)
120
+ const st = statSync(full)
121
+ if (st.isDirectory()) walk(full)
122
+ else if (/\.(?:[cm]?[jt]sx?)$/.test(name) && !/\.d\.ts$/.test(name)) out.push(full)
123
+ }
124
+ }
125
+ walk(root)
126
+ return out
127
+ }
128
+
129
+ /**
130
+ * The published modules an app's imports reach. `unresolved` lists what could
131
+ * not be traced (a namespace import, a name the package does not export); any
132
+ * of those means the whole package is needed.
133
+ */
134
+ export function sableModules(pkgRoot, files) {
135
+ const roots = new Set()
136
+ const unresolved = []
137
+ const spec = /(?:import|export)\s+(type\s+)?([^;"'`]*?)\s*from\s*["'](@sableui\/react(?:\/[\w./-]+)?)["']|import\(\s*["'](@sableui\/react(?:\/[\w./-]+)?)["']\s*\)/g
138
+ for (const path of files) {
139
+ const text = readFileSync(path, "utf8")
140
+ for (const m of text.matchAll(spec)) {
141
+ if (m[1]) continue // `import type` brings no runtime module
142
+ const specifier = m[3] ?? m[4]
143
+ if (/\.css$/.test(specifier)) continue
144
+ const entry = entryFor(pkgRoot, specifier)
145
+ if (!entry) {
146
+ unresolved.push(specifier)
147
+ continue
148
+ }
149
+ const clause = m[2] ?? ""
150
+ const braces = /\{([^}]*)\}/.exec(clause)?.[1]
151
+ // A namespace import, a default import, `export *` or a dynamic import
152
+ // can reach anything the entry exports.
153
+ if (m[4] || /\*/.test(clause) || !braces || /^\s*[\w$]+\s*(?:,|$)/.test(clause)) {
154
+ roots.add(entry)
155
+ if (!braces) continue
156
+ }
157
+ for (const part of braces.split(",")) {
158
+ const name = part.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim()
159
+ if (!name || /^type\s/.test(part.trim())) continue
160
+ const file = definingModule(entry, name)
161
+ if (file) roots.add(file)
162
+ else unresolved.push(`${name} from ${specifier}`)
163
+ }
164
+ }
165
+ }
166
+ // A component's .meta.js is its docs: class names there are example strings,
167
+ // never rendered, and a barrel import would otherwise ship them all.
168
+ return { modules: [...closure(roots)].filter((f) => !f.endsWith(".meta.js")).sort(), unresolved }
169
+ }
170
+
171
+ /** Where the app reaches the package: its own node_modules link when there is one. */
172
+ function packageBase(pkgRoot, root) {
173
+ const linked = join(root, "node_modules", "@sableui", "react")
174
+ return existsSync(join(linked, "package.json")) ? linked : pkgRoot
175
+ }
176
+
177
+ export function sourcesCss(pkgRoot, root, outFile) {
178
+ const { modules, unresolved } = sableModules(pkgRoot, appSources(root))
179
+ const base = packageBase(pkgRoot, root)
180
+ const from = dirname(resolve(root, outFile))
181
+ const line = (file) => `@source "${relative(from, join(base, relative(pkgRoot, file))).split("\\").join("/")}";`
182
+ const body = unresolved.length ? [line(join(pkgRoot, "dist")).replace(/";$/, '/**/*.{js,mjs}";')] : modules.map(line)
183
+ const css = [
184
+ `/* ${SOURCES_MARKER}: the @sableui/react modules this app imports, so Tailwind`,
185
+ " generates their classes and no others. Re-run `sable sources --write` after",
186
+ " importing something new from Sable; `sable check` fails while it is stale. */",
187
+ ...body,
188
+ "",
189
+ ].join("\n")
190
+ return { css, modules, unresolved }
191
+ }
192
+
193
+ /**
194
+ * Doctor: a sources file that is missing a module the app now imports renders
195
+ * that component unstyled, silently — so it fails. A whole-package @source is
196
+ * correct, just heavy, so it only gets a note.
197
+ */
198
+ export function sourcesCheck(pkgRoot, root, cssFiles) {
199
+ const generated = cssFiles.find((f) => f.text.includes(SOURCES_MARKER))
200
+ if (!generated) {
201
+ const whole = cssFiles.some((f) => /@source[^;]*(?:@sableui\/react|packages\/sable)\/dist(?:\/\*\*)?[^;]*;/.test(f.text))
202
+ return whole ? [{ status: "info", name: "css:size", detail: "@source scans all of Sable's dist, so every component's classes ship (about 32 KB gzipped).", fix: "sable sources --write src/sable-sources.css, then import it in place of that @source." }] : []
203
+ }
204
+ const rel = relative(root, generated.path)
205
+ const { modules, unresolved } = sableModules(pkgRoot, appSources(root))
206
+ if (unresolved.length && !/\/dist\/\*\*\//.test(generated.text))
207
+ return [{ status: "fail", name: "css:sources", detail: `${rel} lists modules, but these imports could not be traced: ${unresolved.slice(0, 4).join(", ")}.`, fix: `sable sources --write ${rel}` }]
208
+ const base = packageBase(pkgRoot, root)
209
+ const covered = new Set(
210
+ [...generated.text.matchAll(/@source\s+"([^"]+)"/g)].map((m) => resolve(dirname(generated.path), m[1])),
211
+ )
212
+ const wholeDist = [...covered].some((p) => /[\\/]dist[\\/]\*\*/.test(p))
213
+ if (wholeDist) return [{ status: "pass", name: "css:sources", detail: `${rel} covers all of Sable.` }]
214
+ const missing = modules.filter((file) => !covered.has(join(base, relative(pkgRoot, file))))
215
+ if (missing.length)
216
+ return [{ status: "fail", name: "css:sources", detail: `${rel} is stale: ${missing.length} module(s) the app now imports are not in it (${missing.slice(0, 3).map((f) => relative(join(pkgRoot, "dist"), f).split("\\").join("/")).join(", ")}${missing.length > 3 ? ", …" : ""}). Their components render unstyled.`, fix: `sable sources --write ${rel}` }]
217
+ return [{ status: "pass", name: "css:sources", detail: `${rel} covers the ${modules.length} Sable modules the app imports.` }]
218
+ }
219
+
220
+ /* ── CLI ────────────────────────────────────────────────────────────────────── */
221
+
222
+ const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
223
+ if (isMain) {
224
+ const args = process.argv.slice(2)
225
+ const json = args.includes("--json")
226
+ try {
227
+ const pkgRoot = fileURLToPath(new URL("../", import.meta.url))
228
+ const root = process.cwd()
229
+ const at = args.indexOf("--write")
230
+ const out = at === -1 ? null : args[at + 1]
231
+ if (at !== -1 && (!out || out.startsWith("--"))) throw Object.assign(new Error("--write needs a stylesheet path, e.g. src/sable-sources.css."), { code: "ERR_BAD_ARGUMENT" })
232
+ const unknown = args.filter((a, i) => a.startsWith("--") && !["--write", "--json"].includes(a) && args[i - 1] !== "--write")
233
+ if (unknown.length) throw Object.assign(new Error(`Unknown flag: ${unknown[0]}`), { code: "ERR_BAD_FLAG" })
234
+ const target = out ?? "src/sable-sources.css"
235
+ const result = sourcesCss(pkgRoot, root, target)
236
+ if (out) writeFileSync(resolve(root, out), result.css)
237
+ const note = result.unresolved.length
238
+ ? `Could not trace ${result.unresolved.slice(0, 4).join(", ")}, so it covers all of Sable.`
239
+ : `${result.modules.length} modules.`
240
+ if (json) process.stdout.write(`${JSON.stringify({ type: "sources", data: { file: out, modules: result.modules.length, unresolved: result.unresolved, css: result.css } })}\n`)
241
+ else if (out) process.stdout.write(`Wrote ${out}: ${note}\nImport it in your stylesheet in place of the whole-package @source:\n @import "./${relative(dirname(resolve(root, "src/app.css")), resolve(root, out)).split("\\").join("/")}";\n`)
242
+ else process.stdout.write(result.css)
243
+ } catch (error) {
244
+ process.stderr.write(`${JSON.stringify({ type: "error", code: error.code ?? "ERR_SOURCES", message: error.message, suggestions: ["sable sources --write src/sable-sources.css"] })}\n`)
245
+ process.exitCode = 1
246
+ }
247
+ }
@@ -157,12 +157,21 @@ The installed catalog and guides are authoritative for API and token names.
157
157
  ## Develop or use a preset
158
158
 
159
159
  Save a short brief in the project's existing design location: audience, primary
160
- task, content, constraints, selected identity, and meaningful states. Label invented
161
- sample content. A supplied preset is an explicit input: read its exact record and
160
+ task, content, constraints, selected identity, and meaningful states, plus each
161
+ route's width (a Region `max`, or fluid) and why, chosen by what the person does
162
+ there (`sable docs building-a-page`). Label invented sample content. A supplied preset is an explicit input: read its exact record and
162
163
  revision, validate it through `sable preset --check <path> --json`, and read
163
164
  `sable docs using-presets`. Preserve its tokens and guidance unless the user requests
164
165
  a change or the new task requires a documented adaptation.
165
166
 
167
+ A preset pasted or supplied for a project that already has an app (for example
168
+ from Canvas's “Copy for agent”) is a request to restyle that app, so it is
169
+ `polish`: save and check it, then apply it. Generate its CSS, load its fonts,
170
+ make it SableThemeProvider's default theme and mode in place of the old one, run
171
+ the build and the finish checklist, and look at it rendered. Don't stop at a saved
172
+ file plus a list of wiring steps for the user. Stop after saving only when the
173
+ user says so or the project has no app yet.
174
+
166
175
  Without a preset, develop an appropriate direction from the brief. Use the bundled
167
176
  presets as starting points or create original app-owned tokens. When the project has
168
177
  its own visual atlas, retrieve references with `sable atlas --search "<problem>"
@@ -218,6 +227,9 @@ Every build ends with the finish checklist at the top of this skill. Notes on it
218
227
  the smallest necessary addition, and include the inventory with the build's
219
228
  evidence so conformance is reviewable.
220
229
 
221
- Iterate on observed defects and recapture changed states. Contract checks cannot
222
- judge beauty. Deliver the result, the preset/revision used, the checks performed,
230
+ Iterate on observed defects and recapture changed states. A defect inside a Sable
231
+ component is patched, not restyled: `sable patch <Component> --reason … --repro …`,
232
+ then the package manager's patch (`sable docs patching-a-component`). When `sable
233
+ doctor` fails on a patch after an upgrade, re-run its repro on Sable's own files
234
+ and keep or drop it. Contract checks cannot judge beauty. Deliver the result, the preset/revision used, the checks performed,
223
235
  and material limitations.