@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.
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +83 -0
- package/README.md +2 -0
- package/bin/sable.mjs +45 -2
- package/dist/components/breadcrumbs/breadcrumbs.js +10 -9
- package/dist/components/breadcrumbs/breadcrumbs.meta.js +2 -2
- package/dist/components/button/button.meta.js +4 -0
- package/dist/components/chat-composer/chat-composer.js +4 -2
- package/dist/components/chat-composer/chat-composer.meta.js +8 -2
- package/dist/components/command-menu/command-menu.js +9 -2
- package/dist/components/command-menu/command-menu.meta.js +3 -3
- package/dist/components/data-table/data-table.js +117 -64
- package/dist/components/data-table/data-table.meta.js +27 -1
- package/dist/components/form/form.js +30 -9
- package/dist/components/form/form.meta.js +17 -3
- package/dist/components/message-scroller/message-scroller.meta.js +1 -1
- package/dist/components/page-header/page-header.js +1 -0
- package/dist/components/page-header/page-header.meta.js +1 -1
- package/dist/components/settings-menu/settings-menu.js +10 -1
- package/dist/components/settings-menu/settings-menu.meta.js +1 -1
- package/dist/components/sidebar/sidebar.js +1 -1
- package/dist/components/sidebar/sidebar.meta.js +5 -0
- package/dist/components/switch/switch.js +1 -1
- package/dist/components/switch/switch.meta.js +2 -2
- package/dist/components/toggle/toggle.js +2 -1
- package/dist/file-hashes.json +603 -0
- package/dist/icon-sets/reicon.js +13 -1
- package/dist/index.d.ts +80 -13
- package/dist/index.js +1 -1
- package/dist/lib/heading-row.js +3 -2
- package/dist/lib/trim-label.js +17 -4
- package/dist/styles.css +27 -2
- package/llms/components/breadcrumbs.md +1 -1
- package/llms/components/button.md +1 -0
- package/llms/components/chat-composer.md +3 -2
- package/llms/components/command-menu.md +2 -2
- package/llms/components/data-table.md +33 -1
- package/llms/components/form.md +19 -2
- package/llms/components/message-scroller.md +1 -1
- package/llms/components/page-header.md +1 -1
- package/llms/components/settings-menu.md +1 -1
- package/llms/components/sidebar.md +1 -0
- package/llms/components/switch.md +2 -2
- package/llms/tasks/getting-started.md +5 -2
- package/llms/tasks/patching-a-component.md +48 -0
- package/llms/tasks/reviewing-a-design.md +5 -0
- package/llms-full.txt +126 -15
- package/llms.txt +1 -1
- package/package.json +4 -2
- package/registry.json +98 -20
- package/scripts/design-memory.mjs +7 -2
- package/scripts/lint-rules.mjs +32 -1
- package/scripts/patches.mjs +219 -0
- package/scripts/sources.mjs +247 -0
- 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
|
+
}
|
package/skills/sable/SKILL.md
CHANGED
|
@@ -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.
|
|
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.
|
|
231
|
-
|
|
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.
|