@sableui/react 0.3.1 → 0.4.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 (58) hide show
  1. package/AGENTS.md +8 -4
  2. package/CHANGELOG.md +60 -0
  3. package/CONTRIBUTING.md +2 -2
  4. package/DESIGN.md +7 -7
  5. package/README.md +7 -3
  6. package/bin/sable-lint.mjs +0 -0
  7. package/bin/sable.mjs +10 -5
  8. package/dist/{_contract-o3tUc8Ig.d.ts → _contract-Cn3lUpih.d.ts} +10 -5
  9. package/dist/blocks/index.js +2 -2
  10. package/dist/{chunk-YXBOYWBN.js → chunk-C4Z3CF54.js} +1 -1
  11. package/dist/{chunk-CKNM7KSB.js → chunk-CCUDFLU3.js} +23 -6
  12. package/dist/{chunk-6LCIBMMG.js → chunk-D2ETQBHV.js} +1 -1
  13. package/dist/{chunk-MXJX3NZI.js → chunk-EFLC4NA3.js} +4 -4
  14. package/dist/{chunk-EP55KSYA.js → chunk-EHH4MHO6.js} +6 -6
  15. package/dist/{chunk-WCFLQVK5.js → chunk-FBSL2LZQ.js} +1 -1
  16. package/dist/{chunk-X276MF2W.js → chunk-OQACYT2D.js} +2 -0
  17. package/dist/index.d.ts +8 -8
  18. package/dist/index.js +13 -13
  19. package/dist/patterns/index.js +2 -2
  20. package/dist/presets/index.d.ts +1 -1
  21. package/dist/presets/index.js +2 -2
  22. package/dist/styles.css +16 -6
  23. package/dist/{theme-provider-BE-KqUG-.d.ts → theme-provider-CGeiusgu.d.ts} +3 -3
  24. package/dist/themes/atelier/index.d.ts +1 -1
  25. package/dist/themes/atelier/index.js +1 -1
  26. package/dist/themes/ember/ember.css +8 -5
  27. package/dist/themes/ember/index.d.ts +1 -1
  28. package/dist/themes/index.d.ts +3 -3
  29. package/dist/themes/index.js +3 -3
  30. package/dist/themes/meridian/index.d.ts +1 -1
  31. package/dist/themes/poppy/index.d.ts +1 -1
  32. package/dist/themes/poppy/poppy.css +1 -1
  33. package/dist/themes/prism/index.d.ts +1 -1
  34. package/dist/themes/sable/index.d.ts +9 -0
  35. package/dist/themes/sable/index.js +1 -0
  36. package/dist/themes/{base/base.css → sable/sable.css} +6 -6
  37. package/dist/themes/verdant/index.d.ts +1 -1
  38. package/dist/themes.css +3 -3
  39. package/llms/components/button.md +15 -3
  40. package/llms/components/input.md +1 -1
  41. package/llms/components/scrub-field.md +1 -1
  42. package/llms/components/select.md +1 -1
  43. package/llms/components/theme-selector.md +1 -1
  44. package/llms/reference/token-intent.md +5 -1
  45. package/llms/tasks/building-a-page.md +1 -1
  46. package/llms/tasks/creating-a-preset.md +1 -1
  47. package/llms/tasks/designing-a-surface.md +1 -1
  48. package/llms/tasks/getting-started.md +4 -0
  49. package/llms-full.txt +32 -12
  50. package/llms.txt +1 -1
  51. package/package.json +5 -5
  52. package/registry.json +2456 -18
  53. package/scripts/agent-setup.mjs +34 -7
  54. package/scripts/design-presets.mjs +1 -1
  55. package/scripts/grade.mjs +50 -11
  56. package/scripts/lint-rules.mjs +20 -1
  57. package/dist/themes/base/index.d.ts +0 -9
  58. package/dist/themes/base/index.js +0 -1
@@ -1,14 +1,40 @@
1
1
  import { createHash } from "node:crypto"
2
- import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
3
- import { dirname, join } from "node:path"
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
3
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path"
4
4
 
5
5
  const hash = content => createHash("sha256").update(content).digest("hex")
6
6
 
7
- /** Install discoverable loaders; the installed package owns the actual workflow. */
8
- export function installAgentSkill({ root, packageRoot, target = "all", update = false, dryRun = false }) {
7
+ const RESOLVE = `node -p "require.resolve('@sableui/react/registry.json')"`
8
+ const CONTEXT = "From the target project's dependency context, run:"
9
+
10
+ /**
11
+ * The entry as written at `root`. When Sable is a dependency of a workspace
12
+ * package rather than of the root, a bare `require.resolve` from the root fails
13
+ * (pnpm does not hoist it), so the entry names the package and resolves through it.
14
+ */
15
+ function entryContent(source, projectPath) {
16
+ if (!projectPath) return source
17
+ if (!source.includes(RESOLVE) || !source.includes(CONTEXT)) throw new Error("The installed skill entry has an unexpected shape. Reinstall Sable.")
18
+ return source
19
+ .replace(CONTEXT, `Sable is installed in this repository's \`${projectPath}\` package. From the repository root, run:`)
20
+ .replace(RESOLVE, `node -p "require.resolve('@sableui/react/registry.json', { paths: ['${projectPath}'] })"`)
21
+ }
22
+
23
+ /**
24
+ * Install discoverable loaders; the installed package owns the actual workflow.
25
+ *
26
+ * `project` is where Sable is a dependency (the directory setup runs in); `root`
27
+ * is where the agent entries go. They differ in a monorepo, where agents start at
28
+ * the repository root but Sable belongs to one workspace package.
29
+ */
30
+ export function installAgentSkill({ root, project = root, packageRoot, target = "all", update = false, dryRun = false }) {
9
31
  const reject = (message) => { throw new Error(message) }
10
32
  if (!["all", "codex", "claude", "cursor"].includes(target)) reject("Choose --target all, codex, claude, or cursor.")
11
- if (!existsSync(join(root, "package.json"))) reject("Run setup from a project with a package.json.")
33
+ if (!existsSync(join(project, "package.json"))) reject("Run setup from a project with a package.json.")
34
+ if (!existsSync(root) || !statSync(root).isDirectory()) reject(`--root is not a directory: ${root}`)
35
+ const projectPath = relative(resolve(root), resolve(project)).replaceAll("\\", "/")
36
+ if (projectPath.startsWith("..") || isAbsolute(projectPath)) reject("--root must be the project directory or one of its ancestors, such as the repository root.")
37
+ if (/['"\\`]/.test(projectPath)) reject(`Unsupported characters in the project path: ${projectPath}`)
12
38
  const canonical = join(packageRoot, "skills/sable/SKILL.md")
13
39
  const entry = join(packageRoot, "skills/entry/SKILL.md")
14
40
  if (!existsSync(canonical) || !existsSync(entry)) reject("The installed package is missing its skill. Reinstall Sable.")
@@ -29,8 +55,9 @@ export function installAgentSkill({ root, packageRoot, target = "all", update =
29
55
  receipt = JSON.parse(readFileSync(join(root, receiptPath), "utf8"))
30
56
  if (receipt.schemaVersion !== 1 || !receipt.files || typeof receipt.files !== "object") reject("Invalid Sable setup receipt.")
31
57
  }
58
+ const skill = entryContent(readFileSync(entry, "utf8"), projectPath)
32
59
  const files = destinations.flatMap(destination => [
33
- { path: `${destination}/SKILL.md`, content: readFileSync(entry, "utf8") },
60
+ { path: `${destination}/SKILL.md`, content: skill },
34
61
  ...(destination.startsWith(".agents/") ? [{ path: `${destination}/agents/openai.yaml`, content: readFileSync(join(packageRoot, "skills/sable/agents/openai.yaml"), "utf8") }] : []),
35
62
  ])
36
63
  // Preflight every host and the receipt before any writes; preserve local edits.
@@ -50,5 +77,5 @@ export function installAgentSkill({ root, packageRoot, target = "all", update =
50
77
  mkdirSync(dirname(join(root, receiptPath)), { recursive: true })
51
78
  writeFileSync(join(root, receiptPath), JSON.stringify(receipt, null, 2) + "\n")
52
79
  }
53
- return { destination: destinations[0], destinations, files: files.map(file => file.path), dryRun, update, next: "Use $sable build <your brief> in Codex or /sable build <your brief> in Claude Code or Cursor." }
80
+ return { destination: destinations[0], destinations, root, project: projectPath || ".", files: files.map(file => file.path), dryRun, update, next: "Use $sable build <your brief> in Codex or /sable build <your brief> in Claude Code or Cursor." }
54
81
  }
@@ -15,7 +15,7 @@ try {
15
15
  const action = modes[0]
16
16
  if (action === "--template") {
17
17
  const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"))
18
- const css = readFileSync(resolve(packageRoot, "dist/themes/base/base.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "")
18
+ const css = readFileSync(resolve(packageRoot, "dist/themes/sable/sable.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "")
19
19
  const groups = { light: {}, dark: {} }
20
20
  for (const match of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
21
21
  const target = match[1].includes(".dark") ? groups.dark : groups.light
package/scripts/grade.mjs CHANGED
@@ -33,7 +33,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"
33
33
  import { createHash } from "node:crypto"
34
34
  import { dirname, extname, join, relative, resolve } from "node:path"
35
35
  import { fileURLToPath } from "node:url"
36
- import { lintText, makeRules } from "./lint-rules.mjs"
36
+ import { ignoreGlob, lintText, makeRules } from "./lint-rules.mjs"
37
37
  import { skeletonize } from "./skeleton.mjs"
38
38
  import { assessReview, reviewTemplate } from "./review-evidence.mjs"
39
39
 
@@ -44,8 +44,9 @@ const CWD = process.cwd()
44
44
  const argv = process.argv.slice(2)
45
45
  const flags = new Map()
46
46
  const targets = []
47
+ const ignores = []
47
48
  const BOOLEAN_FLAGS = new Set(["json", "help", "template", "strict"])
48
- const VALUE_FLAGS = new Set(["cli-log", "review"])
49
+ const VALUE_FLAGS = new Set(["cli-log", "review", "icons"])
49
50
  const JSON_OUT = argv.includes("--json")
50
51
  function abort(message) {
51
52
  const error = { type: "error", code: "ERR_GRADE_INPUT", message, suggestions: ["sable grade --help", "sable grade --template"] }
@@ -55,7 +56,11 @@ function abort(message) {
55
56
  for (let i = 0; i < argv.length; i++) {
56
57
  const a = argv[i]
57
58
  if (!a.startsWith("--")) targets.push(a)
58
- else if (BOOLEAN_FLAGS.has(a.slice(2))) flags.set(a.slice(2), true)
59
+ else if (a === "--ignore" || a.startsWith("--ignore=")) {
60
+ const glob = a === "--ignore" ? argv[++i] : a.slice(9)
61
+ if (!glob || glob.startsWith("--")) abort("--ignore requires a glob.")
62
+ ignores.push(glob)
63
+ } else if (BOOLEAN_FLAGS.has(a.slice(2))) flags.set(a.slice(2), true)
59
64
  else if (VALUE_FLAGS.has(a.slice(2))) {
60
65
  if (!argv[i + 1] || argv[i + 1].startsWith("--")) abort(`${a} requires a file path.`)
61
66
  flags.set(a.slice(2), argv[++i])
@@ -72,12 +77,14 @@ if (flags.has("help") || !targets.length) {
72
77
  [
73
78
  "grade — check the static contract and recorded review evidence. No model calls.",
74
79
  "",
75
- " sable grade <dir...> [--json] [--review <review.json>] [--strict]",
80
+ " sable grade <dir...> [--json] [--review <review.json>] [--icons <icons.json>] [--ignore <glob>] [--strict]",
76
81
  "",
77
82
  " --json machine-readable scorecard",
78
83
  " --cli-log a SABLE_CLI_LOG jsonl, to report whether the CLI was used at all",
79
84
  " --template print a review JSON template; fill after inspecting the rendered build",
80
85
  " --review validate evidence files and observations; paths are relative to this JSON",
86
+ ' --icons icon names the app adds with composeIconSets: { "names": [...] }. Default: .sable/icons.json',
87
+ " --ignore skip matching paths in the checks, as sable-lint --ignore (repeatable)",
81
88
  " --strict exit 1 for static findings or a missing/incomplete/unresolved review",
82
89
  "",
83
90
  "Checks: design-contract violations · hallucinated components and props ·",
@@ -102,7 +109,27 @@ const motionTokens = new Set(
102
109
  ].filter(Boolean),
103
110
  )
104
111
  const RULES = makeRules({ semanticTokens, motionTokens }).filter((r) => r.scope !== "package")
112
+ const PROP_SURFACE = REGISTRY.propSurface?.items ?? {}
105
113
  const ICONS = new Set(REGISTRY.iconContract?.names ?? [])
114
+ /**
115
+ * The names an app adds with `composeIconSets(appExtras, reiconIconSet)`. That is
116
+ * the sanctioned way to extend the set, and the grade cannot read a set's names
117
+ * out of a render function, so the app declares them once. Without this every
118
+ * domain icon read as off-contract.
119
+ */
120
+ const APP_ICONS = (() => {
121
+ const explicit = flags.get("icons")
122
+ const path = resolve(CWD, explicit ?? ".sable/icons.json")
123
+ if (!existsSync(path)) {
124
+ if (explicit) abort(`Not found: ${explicit}`)
125
+ return null
126
+ }
127
+ let names
128
+ try { names = JSON.parse(readFileSync(path, "utf8")).names }
129
+ catch (error) { abort(`Cannot read ${relative(CWD, path)}: ${error.message}`) }
130
+ if (!Array.isArray(names) || !names.every((n) => typeof n === "string" && n)) abort(`${relative(CWD, path)} must be { "names": ["icon-name", …] }.`)
131
+ return { file: relative(CWD, path).replaceAll("\\", "/"), names: new Set(names) }
132
+ })()
106
133
  const PATTERNS = REGISTRY.patterns?.items ?? []
107
134
 
108
135
  /**
@@ -173,7 +200,13 @@ for (const t of targets) {
173
200
  else snapshotFiles.push(abs)
174
201
  }
175
202
  snapshotFiles.splice(0, snapshotFiles.length, ...new Set(snapshotFiles.sort()))
176
- const files = snapshotFiles.filter((file) => CODE.test(file))
203
+ /* --ignore takes the same globs sable-lint does, so the grade judges the same
204
+ files the lint does. A data layer (palette fixtures, document colours) is
205
+ skipped by the checks but still fingerprinted: it is part of the build. */
206
+ const ignoreRes = ignores.map(ignoreGlob)
207
+ const isIgnored = (file) => ignoreRes.some((re) => re.test(relative(CWD, file).replaceAll("\\", "/")))
208
+ const ignoredFiles = snapshotFiles.filter((file) => CODE.test(file) && isIgnored(file))
209
+ const files = snapshotFiles.filter((file) => CODE.test(file) && !isIgnored(file))
177
210
  if (!files.length) abort("No source files found.")
178
211
  if (!files.some((file) => readFileSync(file, "utf8").trim().length > 0)) abort("No non-empty source content found.")
179
212
  // Git checkouts may use CRLF on Windows and LF in CI. Normalize only known text
@@ -275,11 +308,14 @@ for (const file of files) {
275
308
  }
276
309
  }
277
310
 
278
- /* 3. Props set on a Sable component that its contract does not declare. */
311
+ /* 3. Props set on a Sable component that its contract does not declare.
312
+ The contract is the component's real type (registry → propSurface),
313
+ which includes what it forwards to Base UI — Toggle's `pressed`,
314
+ Combobox's `filter` — plus its documented table. */
279
315
  for (const m of text.matchAll(/<([A-Z][\w]*)[\s/>]/g)) {
280
316
  const comp = REGISTRY.components?.find((c) => c.name === m[1])
281
317
  if (!comp?.props?.length) continue
282
- const declared = new Set(comp.props.map((p) => p.name))
318
+ const declared = new Set([...comp.props.map((p) => p.name), ...(PROP_SURFACE[m[1]] ?? [])])
283
319
  for (const sub of comp.subcomponents ?? []) {
284
320
  if (typeof sub !== "string") for (const p of sub.props ?? []) declared.add(p.name)
285
321
  }
@@ -307,9 +343,10 @@ for (const file of files) {
307
343
  if (px >= 20 && px <= 56) findings.hardcodedControl.push({ file: rel, match: m[0], line: lineOf(text, m.index) })
308
344
  }
309
345
 
310
- /* 6. Icon names outside the contract render nothing at all. */
346
+ /* 6. Icon names outside the contract render nothing at all — unless the app
347
+ supplies them itself and has declared them (APP_ICONS). */
311
348
  for (const m of text.matchAll(/<Icon\s+[^>]*name=["']([^"']+)["']/g)) {
312
- if (!ICONS.has(m[1])) findings.offContractIcon.push({ file: rel, name: m[1], line: lineOf(text, m.index) })
349
+ if (!ICONS.has(m[1]) && !APP_ICONS?.names.has(m[1])) findings.offContractIcon.push({ file: rel, name: m[1], line: lineOf(text, m.index) })
313
350
  }
314
351
 
315
352
  /* 7. Composition. Anything exporting a component that returns a big tree is
@@ -438,6 +475,7 @@ const result = {
438
475
  sourceHash,
439
476
  fingerprint: { algorithm: "sha256", files: snapshotFiles.length, packageHash, coverage: "All non-test files in supplied targets, plus Sable runtime sources, registry, and lint contract. Known text formats normalize CRLF to LF; binary assets retain their bytes. Pass public/ for external asset directories; remote resources are not covered." },
440
477
  files: files.length,
478
+ ignored: { globs: ignores, files: ignoredFiles.length },
441
479
  loc,
442
480
  score,
443
481
  scoreMeaning: "Static contract only. Pattern similarity does not affect this score; visual quality is not scored.",
@@ -447,6 +485,7 @@ const result = {
447
485
  per100loc: Object.fromEntries(Object.entries(counts).map(([k, v]) => [k, per100(v)])),
448
486
  conformance: { informationalOnly: true, mean: meanConformance, routes: conformance },
449
487
  cliUsage,
488
+ appIcons: APP_ICONS ? { file: APP_ICONS.file, names: APP_ICONS.names.size } : null,
450
489
  findings,
451
490
  }
452
491
 
@@ -459,7 +498,7 @@ if (JSON_OUT) {
459
498
 
460
499
  const L = (s = "") => process.stdout.write(`${s}\n`)
461
500
  L()
462
- L(` grade — ${files.length} files, ${loc} lines, against @sableui/react ${REGISTRY.version}`)
501
+ L(` grade — ${files.length} files, ${loc} lines, against @sableui/react ${REGISTRY.version}${ignoredFiles.length ? ` (${ignoredFiles.length} ignored)` : ""}`)
463
502
  L(` ${"─".repeat(66)}`)
464
503
  const row = (label, n, note = "") =>
465
504
  L(` ${label.padEnd(26)}${String(n).padStart(4)}${note ? ` ${note}` : ""}`)
@@ -468,7 +507,7 @@ row("hallucinated components", counts.hallucinatedComponent)
468
507
  row("hallucinated props", counts.hallucinatedProp)
469
508
  row("Radix idioms", counts.radixIdiom, counts.radixIdiom ? "asChild / direct primitive imports" : "")
470
509
  row("hardcoded control heights", counts.hardcodedControl)
471
- row("off-contract icon names", counts.offContractIcon)
510
+ row("off-contract icon names", counts.offContractIcon, APP_ICONS ? `+${APP_ICONS.names.size} app icons from ${APP_ICONS.file}` : counts.offContractIcon ? "app-supplied? list them in .sable/icons.json" : "")
472
511
  L()
473
512
  if (conformance.length) {
474
513
  L(` pattern similarity (informational) — ${conformance.length} route-like file(s), mean ${meanConformance}`)
@@ -537,10 +537,14 @@ export function makeRules({ semanticTokens, motionTokens }) {
537
537
  since: "0.1.0",
538
538
  code: true,
539
539
  why:
540
- "A colour hand-mixed from the foreground ink — `color-mix(… var(--foreground) …)`. Sable already derives these from the surface underneath, in every theme and mode: `sable-well` (a recessed track, pad or well), `sable-surface` + `sable-surface-interactive` (hover and selected fills on any surface), `bg-hairline` / `border-hairline` (a faint ink edge or fill). `npx sable search` finds them; `registry.json → utilities` explains each. If none fits, it is a gap — report it, then take the escape hatch with the reason.",
540
+ "A colour hand-mixed from the foreground ink — `color-mix(… var(--foreground) …)`. Sable already derives these from the surface underneath, in every theme and mode: `sable-well` (a recessed track, pad or well), `sable-surface` + `sable-surface-interactive` (hover and selected fills on any surface), `bg-hairline` / `border-hairline` (a faint ink edge or fill). `npx sable search` finds them; `registry.json → utilities` explains each. If none fits, it is a gap — report it, then take the escape hatch with the reason. " +
541
+ "Exempt: the value of a `--theme-*` hook (`--theme-panel-field: color-mix(…)`), which is how a theme tunes a derived token.",
541
542
  find(line) {
542
543
  const out = []
543
544
  for (const m of line.matchAll(/color-mix\([^;"'`]*?var\(--foreground\)/g)) {
545
+ // A theme hook is the sanctioned place to retune a derived surface,
546
+ // in CSS or as an arbitrary property ([--theme-x:color-mix(…)]).
547
+ if (/--theme-[\w-]+\s*:\s*$/.test(line.slice(0, m.index))) continue
544
548
  out.push({ match: m[0].length > 60 ? `${m[0].slice(0, 57)}…` : m[0] })
545
549
  }
546
550
  return out
@@ -964,6 +968,21 @@ function stripComments(src) {
964
968
  .replace(/(^|[^:])\/\/[^\n]*/gm, (m, p) => p + " ".repeat(m.length - p.length))
965
969
  }
966
970
 
971
+ /**
972
+ * An `--ignore` glob as a RegExp over a CWD-relative, forward-slash path:
973
+ * `**` crosses directories, `*` stays within one. Shared by sable-lint and
974
+ * sable grade so the same flag skips the same files in both.
975
+ */
976
+ export function ignoreGlob(glob) {
977
+ return new RegExp(
978
+ `^${glob
979
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
980
+ .replace(/\*\*/g, "\0")
981
+ .replace(/\*/g, "[^/]*")
982
+ .replace(/\0/g, ".*")}$`,
983
+ )
984
+ }
985
+
967
986
  export function lintText(text, rules, ctx) {
968
987
  const lines = text.split(/\r?\n/)
969
988
  const codeLines = stripComments(text).split(/\r?\n/)
@@ -1,9 +0,0 @@
1
- import { T as ThemeManifest } from '../../_contract-o3tUc8Ig.js';
2
-
3
- /**
4
- * Base — the default Sable theme. Concrete token values live in
5
- * `base.css`; this manifest is the typed, machine-readable description.
6
- */
7
- declare const baseTheme: ThemeManifest;
8
-
9
- export { baseTheme, baseTheme as default };
@@ -1 +0,0 @@
1
- export { baseTheme, base_default as default } from '../../chunk-EP55KSYA.js';