@sableui/react 0.7.1 → 0.8.1

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 (55) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +83 -0
  3. package/README.md +2 -0
  4. package/bin/sable.mjs +45 -2
  5. package/dist/components/breadcrumbs/breadcrumbs.js +10 -9
  6. package/dist/components/breadcrumbs/breadcrumbs.meta.js +2 -2
  7. package/dist/components/button/button.meta.js +4 -0
  8. package/dist/components/chat-composer/chat-composer.js +4 -2
  9. package/dist/components/chat-composer/chat-composer.meta.js +8 -2
  10. package/dist/components/command-menu/command-menu.js +9 -2
  11. package/dist/components/command-menu/command-menu.meta.js +3 -3
  12. package/dist/components/data-table/data-table.js +117 -64
  13. package/dist/components/data-table/data-table.meta.js +27 -1
  14. package/dist/components/form/form.js +30 -9
  15. package/dist/components/form/form.meta.js +17 -3
  16. package/dist/components/message-scroller/message-scroller.meta.js +1 -1
  17. package/dist/components/page-header/page-header.js +1 -0
  18. package/dist/components/page-header/page-header.meta.js +1 -1
  19. package/dist/components/settings-menu/settings-menu.js +10 -1
  20. package/dist/components/settings-menu/settings-menu.meta.js +1 -1
  21. package/dist/components/sidebar/sidebar.js +1 -1
  22. package/dist/components/sidebar/sidebar.meta.js +5 -0
  23. package/dist/components/switch/switch.js +1 -1
  24. package/dist/components/switch/switch.meta.js +2 -2
  25. package/dist/components/toggle/toggle.js +2 -1
  26. package/dist/file-hashes.json +603 -0
  27. package/dist/icon-sets/reicon.js +13 -1
  28. package/dist/index.d.ts +80 -13
  29. package/dist/index.js +1 -1
  30. package/dist/lib/heading-row.js +3 -2
  31. package/dist/lib/trim-label.js +17 -4
  32. package/dist/styles.css +27 -2
  33. package/llms/components/breadcrumbs.md +1 -1
  34. package/llms/components/button.md +1 -0
  35. package/llms/components/chat-composer.md +3 -2
  36. package/llms/components/command-menu.md +2 -2
  37. package/llms/components/data-table.md +33 -1
  38. package/llms/components/form.md +19 -2
  39. package/llms/components/message-scroller.md +1 -1
  40. package/llms/components/page-header.md +1 -1
  41. package/llms/components/settings-menu.md +1 -1
  42. package/llms/components/sidebar.md +1 -0
  43. package/llms/components/switch.md +2 -2
  44. package/llms/tasks/getting-started.md +5 -2
  45. package/llms/tasks/patching-a-component.md +48 -0
  46. package/llms/tasks/reviewing-a-design.md +5 -0
  47. package/llms-full.txt +126 -15
  48. package/llms.txt +1 -1
  49. package/package.json +4 -2
  50. package/registry.json +98 -20
  51. package/scripts/design-memory.mjs +7 -2
  52. package/scripts/lint-rules.mjs +32 -1
  53. package/scripts/patches.mjs +219 -0
  54. package/scripts/sources.mjs +247 -0
  55. package/skills/sable/SKILL.md +9 -4
@@ -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
+ }
@@ -13,7 +13,9 @@ References inform an original direction. A reference or preset is optional.
13
13
 
14
14
  1. Run `sable check <source paths>` (default `src`). It runs `sable doctor`,
15
15
  `sable lint`, and `sable grade` and exits 1 if any fails. Repair the findings
16
- instead of suppressing them, then run it again until it passes. Also run the
16
+ instead of suppressing them, then run it again until it passes. Act on its
17
+ notes too: they don't fail the check but name real work, such as trimming the
18
+ CSS to the components the app imports (`sable sources`). Also run the
17
19
  project's own build and type checks.
18
20
  2. Review the rendered design: `sable docs reviewing-a-design`. Capture desktop and
19
21
  mobile in every promised colour mode, and judge brief fit, composition,
@@ -34,7 +36,7 @@ References inform an original direction. A reference or preset is optional.
34
36
  lessons with evidence; never fabricate human approval or preference records.
35
37
 
36
38
  Steps 2–7 are what no tool can verify; `sable check` prints them after its
37
- results. A passing check is a contract result, not design approval.
39
+ results, plus any step its notes call for. A passing check is a contract result, not design approval.
38
40
 
39
41
  ## Route the request
40
42
 
@@ -227,6 +229,9 @@ Every build ends with the finish checklist at the top of this skill. Notes on it
227
229
  the smallest necessary addition, and include the inventory with the build's
228
230
  evidence so conformance is reviewable.
229
231
 
230
- Iterate on observed defects and recapture changed states. Contract checks cannot
231
- judge beauty. Deliver the result, the preset/revision used, the checks performed,
232
+ Iterate on observed defects and recapture changed states. A defect inside a Sable
233
+ component is patched, not restyled: `sable patch <Component> --reason … --repro …`,
234
+ then the package manager's patch (`sable docs patching-a-component`). When `sable
235
+ doctor` fails on a patch after an upgrade, re-run its repro on Sable's own files
236
+ and keep or drop it. Contract checks cannot judge beauty. Deliver the result, the preset/revision used, the checks performed,
232
237
  and material limitations.