@sableui/react 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/AGENTS.md +10 -4
  2. package/CHANGELOG.md +199 -0
  3. package/DESIGN.md +3 -3
  4. package/README.md +15 -1
  5. package/bin/sable.mjs +254 -10
  6. package/dist/components/app-header/app-header.meta.js +2 -1
  7. package/dist/components/button/button.js +3 -0
  8. package/dist/components/button/button.meta.js +1 -1
  9. package/dist/components/button/button.variants.js +1 -1
  10. package/dist/components/button-group/button-group.js +17 -0
  11. package/dist/components/button-group/button-group.meta.js +72 -0
  12. package/dist/components/button-group/index.js +7 -0
  13. package/dist/components/caret-menu/caret-menu.js +221 -0
  14. package/dist/components/caret-menu/caret-menu.meta.js +119 -0
  15. package/dist/components/caret-menu/index.js +8 -0
  16. package/dist/components/chat-composer/chat-composer.js +30 -7
  17. package/dist/components/chat-composer/chat-composer.meta.js +27 -8
  18. package/dist/components/combobox/combobox.meta.js +26 -0
  19. package/dist/components/input-group/index.js +7 -0
  20. package/dist/components/input-group/input-group.js +66 -0
  21. package/dist/components/input-group/input-group.meta.js +106 -0
  22. package/dist/components/menu/index.js +2 -0
  23. package/dist/components/menu/menu.js +51 -9
  24. package/dist/components/menu/menu.meta.js +11 -2
  25. package/dist/components/menubar/index.js +8 -0
  26. package/dist/components/menubar/menubar.js +42 -0
  27. package/dist/components/menubar/menubar.meta.js +99 -0
  28. package/dist/components/message-scroller/message-scroller.meta.js +4 -0
  29. package/dist/components/navigation-menu/index.js +19 -0
  30. package/dist/components/navigation-menu/navigation-menu.js +108 -0
  31. package/dist/components/navigation-menu/navigation-menu.meta.js +141 -0
  32. package/dist/components/overflow-menu/overflow-menu.meta.js +13 -1
  33. package/dist/components/pagination/pagination.js +11 -5
  34. package/dist/components/pagination/pagination.meta.js +21 -3
  35. package/dist/components/scroller/scroller.js +8 -4
  36. package/dist/components/scroller/scroller.meta.js +6 -0
  37. package/dist/components/sheet/sheet.js +82 -9
  38. package/dist/components/sheet/sheet.meta.js +41 -4
  39. package/dist/components/sheet/sheet.variants.js +8 -1
  40. package/dist/components/sidebar/sidebar.js +13 -2
  41. package/dist/components/sidebar/sidebar.meta.js +10 -0
  42. package/dist/components/streaming-text/streaming-text.meta.js +5 -1
  43. package/dist/file-hashes.json +49 -33
  44. package/dist/icon-sets/reicon.js +2 -0
  45. package/dist/index.d.ts +660 -164
  46. package/dist/index.js +12 -1
  47. package/dist/lib/palette.js +46 -6
  48. package/dist/lib/taxonomy.js +5 -0
  49. package/dist/lib/use-caret-trigger.js +187 -0
  50. package/dist/lib/use-copy.js +6 -1
  51. package/dist/lib/use-smooth-wheel.js +164 -0
  52. package/dist/palette.css +243 -165
  53. package/dist/patterns/chat-panel.js +1 -1
  54. package/dist/patterns/chat-panel.meta.js +1 -0
  55. package/dist/styles.css +25 -0
  56. package/llms/components/app-header.md +1 -1
  57. package/llms/components/button-group.md +55 -0
  58. package/llms/components/button.md +1 -1
  59. package/llms/components/caret-menu.md +100 -0
  60. package/llms/components/chat-composer.md +24 -7
  61. package/llms/components/combobox.md +12 -0
  62. package/llms/components/input-group.md +78 -0
  63. package/llms/components/menu.md +16 -1
  64. package/llms/components/menubar.md +110 -0
  65. package/llms/components/message-scroller.md +1 -0
  66. package/llms/components/navigation-menu.md +113 -0
  67. package/llms/components/overflow-menu.md +26 -1
  68. package/llms/components/pagination.md +15 -3
  69. package/llms/components/scroller.md +1 -0
  70. package/llms/components/sheet.md +18 -3
  71. package/llms/components/sidebar.md +2 -0
  72. package/llms/components/streaming-text.md +2 -1
  73. package/llms/patterns/chat-panel.md +4 -1
  74. package/llms/reference/token-intent.md +1 -1
  75. package/llms/tasks/building-a-chat.md +158 -0
  76. package/llms/tasks/building-with-sable.md +4 -1
  77. package/llms/tasks/converting-an-app.md +7 -0
  78. package/llms/tasks/extending-a-component.md +170 -0
  79. package/llms/tasks/getting-started.md +22 -4
  80. package/llms/tasks/migrating-from-shadcn.md +351 -0
  81. package/llms-full.txt +1322 -27
  82. package/llms.txt +13 -4
  83. package/package.json +3 -2
  84. package/registry.json +979 -23
  85. package/scripts/lint-rules.mjs +35 -0
  86. package/scripts/wiring.mjs +541 -0
  87. package/skills/entry/SKILL.md +3 -2
  88. package/skills/sable/SKILL.md +26 -5
  89. package/targets/ios.json +45 -0
  90. package/dist/lib/use-wheel-to-horizontal.js +0 -65
package/bin/sable.mjs CHANGED
@@ -39,6 +39,7 @@ import { skeletonize } from "../scripts/skeleton.mjs"
39
39
  import { claudeStopHook, installAgentSkill, installClaudeStopHook } from "../scripts/agent-setup.mjs"
40
40
  import { patchChecks } from "../scripts/patches.mjs"
41
41
  import { sourcesCheck } from "../scripts/sources.mjs"
42
+ import { detectFramework, detectPackageManager, fixWiring, fontsCheck, providerSnippet, tailwindBuildCheck } from "../scripts/wiring.mjs"
42
43
 
43
44
  const HERE = dirname(fileURLToPath(import.meta.url))
44
45
  const PKG_ROOT = join(HERE, "..")
@@ -139,7 +140,7 @@ const COMMANDS = [
139
140
  summary: "One ranked result set across components, providers, patterns, blocks, layout primitives, utilities, tokens, and docs.",
140
141
  arg: { name: "query", required: true },
141
142
  flags: [
142
- { name: "--type", value: "component|provider|pattern|block|layout|utility|token|doc", describe: "Restrict to one kind." },
143
+ { name: "--type", value: "component|provider|pattern|block|example|layout|utility|token|doc", describe: "Restrict to one kind. `example` finds a component example by what it shows (\"multiple selection\", \"server errors\")." },
143
144
  { name: "--limit", value: "n", describe: "Cap results. Default 20." },
144
145
  ],
145
146
  returns: ["search"],
@@ -167,9 +168,10 @@ const COMMANDS = [
167
168
  { name: "--register", value: "page|workspace|either", describe: "With --list: filter by register." },
168
169
  { name: "--props", value: null, describe: "Props table only." },
169
170
  { name: "--examples", value: null, describe: "Examples only, imports included." },
171
+ { name: "--example", value: "label", describe: "One example by its label (case-insensitive), imports included. Labels come from --examples or search." },
170
172
  { name: "--a11y", value: null, describe: "Accessibility contract only." },
171
173
  ],
172
- returns: ["component.list", "component.detail", "component.props", "component.examples", "component.a11y"],
174
+ returns: ["component.list", "component.detail", "component.props", "component.examples", "component.example", "component.a11y"],
173
175
  },
174
176
  {
175
177
  name: "pattern",
@@ -246,8 +248,23 @@ const COMMANDS = [
246
248
  name: "doctor",
247
249
  summary: "Check this project's Sable wiring. Exits 1 on any failure.",
248
250
  arg: null,
249
- flags: [],
250
- returns: ["doctor"],
251
+ flags: [
252
+ { name: "--fix", value: null, describe: "Apply the fixes that need no judgment (imports in order, @source, missing packages, Vite dedupe and Tailwind plugin, fonts, IconProvider in a starter entry), print the rest, then check again." },
253
+ { name: "--dry-run", value: null, describe: "Report what --fix would change without writing or installing anything." },
254
+ { name: "--no-install", value: null, describe: "With --fix: print install commands instead of running them." },
255
+ ],
256
+ returns: ["doctor", "doctor.fix"],
257
+ },
258
+ {
259
+ name: "init",
260
+ summary: "Wire Sable into this app in one step: install what is missing, fix what doctor flags, install the agent skill, print the provider snippet for your root, then run doctor. Vite and Next.js get every step; other setups get the packages and stylesheet, and the rest printed.",
261
+ arg: null,
262
+ flags: [
263
+ { name: "--dry-run", value: null, describe: "Report every change without writing or installing anything." },
264
+ { name: "--no-agent", value: null, describe: "Skip the agent skill (sable setup --agent)." },
265
+ { name: "--no-install", value: null, describe: "Print install commands instead of running them." },
266
+ ],
267
+ returns: ["init"],
251
268
  },
252
269
  {
253
270
  name: "grade",
@@ -337,6 +354,7 @@ const ERROR_CODES = [
337
354
  { code: "ERR_PRESET_INVALID", when: "A preset is missing required metadata, semantic values, or uses unsupported tokens or CSS." },
338
355
  { code: "ERR_PRESET_CONTRAST", when: "A label on a brand or status fill (primary, sidebar-primary, highlight, destructive, success, tone-warning, tone-info) misses 4.5:1 or APCA Lc 60. Apply the suggested pair, or record an `exceptions` entry with a reason." },
339
356
  { code: "ERR_AGENT_SETUP", when: "The packaged skill is missing, the destination is linked, or existing local edits would be overwritten." },
357
+ { code: "ERR_INIT", when: "sable init ran outside an app root (no package.json)." },
340
358
  { code: "ERR_UNKNOWN_COMMAND", when: "The first argument is not a command." },
341
359
  { code: "ERR_MISSING_ARGUMENT", when: "A command that requires an argument got none." },
342
360
  { code: "ERR_UNKNOWN_COMPONENT", when: "No component by that name. `suggestions` carries near matches." },
@@ -347,6 +365,7 @@ const ERROR_CODES = [
347
365
  { code: "ERR_UNKNOWN_UTILITY", when: "No utility or variant by that name. `suggestions` carries near matches." },
348
366
  { code: "ERR_UNKNOWN_DOC", when: "No package guide or doc mirror by that slug. `suggestions` carries near matches and the package guides." },
349
367
  { code: "ERR_UNKNOWN_PROVIDER", when: "No provider by that name. `suggestions` carries near matches." },
368
+ { code: "ERR_UNKNOWN_EXAMPLE", when: "The component has no example with that label. `suggestions` carries near matches as commands." },
350
369
  { code: "ERR_LINT_INPUT", when: "sable lint --json: a path to lint does not exist." },
351
370
  { code: "ERR_CHECK_FAILED", when: "sable check: doctor, lint, or grade failed. `data.checks` names which." },
352
371
  { code: "ERR_BAD_FLAG", when: "A flag value is outside its allowed set." },
@@ -630,14 +649,33 @@ function cmdSearch() {
630
649
  if (!type || type === "doc")
631
650
  for (const d of docSlugs())
632
651
  pool.push({ kind: "doc", name: d.slug, summary: `${d.kind} mirror`, _s: score({ name: d.slug, text: d.kind }) })
652
+ if (!type || type === "example")
653
+ for (const c of COMPONENTS)
654
+ for (const e of c.examples ?? []) {
655
+ const s = scoreExample(c, e, { q, terms })
656
+ if (s > 0) pool.push({ kind: "example", name: `${c.name} › ${e.label}`, component: c.name, label: e.label, summary: `Uses ${exampleParts(e).join(", ")}.`, _s: s })
657
+ }
633
658
 
634
659
  // Ties resolve in the order AGENTS.md tells an agent to resolve a need:
635
660
  // a component first, then a pattern or primitive, then a utility, then a
636
661
  // token. "segmented" should land on ToggleGroup before the well behind it.
637
- const KIND_ORDER = ["component", "provider", "pattern", "block", "layout", "utility", "token", "doc"]
662
+ const KIND_ORDER = ["component", "provider", "pattern", "block", "example", "layout", "utility", "token", "doc"]
663
+ // A popular label ("Controlled value") exists on dozens of components, so
664
+ // examples are capped twice: per component, and as a share of the list, or
665
+ // one query would bury every component under its own examples.
666
+ const perComponent = new Map()
667
+ let examples = 0
638
668
  const hits = pool
639
669
  .filter((h) => h._s > 0)
640
670
  .sort((a, b) => b._s - a._s || KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind))
671
+ .filter((h) => {
672
+ if (h.kind !== "example" || type === "example") return true
673
+ const n = perComponent.get(h.component) ?? 0
674
+ if (n >= 2 || examples >= Math.max(3, Math.ceil(limit / 3))) return false
675
+ perComponent.set(h.component, n + 1)
676
+ examples++
677
+ return true
678
+ })
641
679
  .slice(0, limit)
642
680
  if (!hits.length)
643
681
  fail("ERR_NO_RESULTS", `Nothing matched "${query}".`, [
@@ -652,7 +690,10 @@ function cmdSearch() {
652
690
  const results = hits.map(({ _s, ...h }) => ({
653
691
  ...h,
654
692
  ...(h.kind === "pattern" || h.kind === "block" ? { reference: true } : {}),
655
- retrieve: `sable ${h.kind === "doc" ? "docs" : h.kind} ${h.name}`,
693
+ retrieve:
694
+ h.kind === "example"
695
+ ? `sable component ${h.component} --example "${h.label.replace(/"/g, '\\"')}"`
696
+ : `sable ${h.kind === "doc" ? "docs" : h.kind} ${h.name}`,
656
697
  }))
657
698
  const anyReference = results.some((h) => h.reference)
658
699
  ok("search", { query, count: results.length, results }, () =>
@@ -669,6 +710,57 @@ function cmdSearch() {
669
710
  )
670
711
  }
671
712
 
713
+ /** The names an example imports: the parts it composes. */
714
+ const exampleParts = (e) =>
715
+ (e.imports ?? "")
716
+ .split(/[{}]/)[1]
717
+ ?.split(",")
718
+ .map((s) => s.trim())
719
+ .filter(Boolean) ?? []
720
+
721
+ /** Every component and prop an example's code uses, lowercased: `<Combobox multiple items=…>` → combobox, multiple, items. */
722
+ const exampleIdentifiers = (e) => {
723
+ const ids = new Set(exampleParts(e).map((p) => p.toLowerCase()))
724
+ for (const [, tag] of (e.code ?? "").matchAll(/<([A-Z]\w*)/g)) ids.add(tag.toLowerCase())
725
+ for (const [, prop] of (e.code ?? "").matchAll(/\s([a-z][A-Za-z]+)(?==|[\s/>])/g)) ids.add(prop.toLowerCase())
726
+ return ids
727
+ }
728
+
729
+ /**
730
+ * An example is found by what it shows, not by the component it belongs to. The
731
+ * label carries the intent ("Multiple selection", "Server errors"); the code
732
+ * names the parts and props it relies on; the parent component's name counts,
733
+ * but less than the component itself scores, so "Combobox" still lands on the
734
+ * component and "multiple combobox" lands on its example. An example qualifies
735
+ * only by its own label, or — for a phrase — by covering every term with its
736
+ * code: otherwise every example that renders a Button answers "button".
737
+ */
738
+ function scoreExample(c, e, { q, terms }) {
739
+ const label = e.label.toLowerCase()
740
+ const ids = exampleIdentifiers(e)
741
+ const parent = c.name.toLowerCase()
742
+ const keywords = (c.keywords ?? []).map((k) => k.toLowerCase())
743
+ let s = terms.length > 1 && label === q ? 60 : 0
744
+ let labelHit = false
745
+ let codeHit = false
746
+ let covered = 0
747
+ for (const t of terms) {
748
+ const inLabel = label.includes(t)
749
+ const inCode = ids.has(t) ? 10 : [...ids].some((id) => id.includes(t)) ? 4 : 0
750
+ const inParent = parent === t ? 15 : parent.includes(t) ? 10 : keywords.includes(t) ? 5 : 0
751
+ s += (inLabel ? 30 : 0) + inCode + inParent
752
+ labelHit ||= inLabel
753
+ codeHit ||= inCode > 0
754
+ if (inLabel || inCode || inParent) covered++
755
+ }
756
+ const phrase = terms.length > 1 && covered === terms.length
757
+ if (!labelHit && !(phrase && codeHit)) return 0
758
+ // A single word is a lookup, and the component it names is the answer; a
759
+ // phrase is a need, and the example that covers all of it is.
760
+ if (terms.length === 1) return Math.round(s / 2)
761
+ return phrase ? s + 20 : Math.round((s * covered) / terms.length)
762
+ }
763
+
672
764
  /**
673
765
  * First sentence, for one-line list rows. The abbreviation guard is not fussiness:
674
766
  * summaries in this catalog say things like "A human relative timestamp (e.g. 2h
@@ -727,6 +819,22 @@ function cmdComponent() {
727
819
  )
728
820
  if (flags.has("examples"))
729
821
  return ok("component.examples", { name: c.name, examples: c.examples ?? [] }, () => renderExamples(c))
822
+ if (flags.has("example")) {
823
+ // The retrieve step for a search hit: one example, not the whole set.
824
+ const want = flags.get("example")
825
+ const labels = (c.examples ?? []).map((e) => e.label)
826
+ const quoted = (l) => `sable component ${c.name} --example "${l.replace(/"/g, '\\"')}"`
827
+ if (want === true) fail("ERR_MISSING_ARGUMENT", "--example needs a label.", labels.map(quoted))
828
+ const example = (c.examples ?? []).find((e) => e.label.toLowerCase() === String(want).toLowerCase())
829
+ if (!example) {
830
+ const near = nearest(String(want), labels)
831
+ fail("ERR_UNKNOWN_EXAMPLE", `${c.name} has no example labelled "${want}".`, [
832
+ ...(near.length ? near : labels).map(quoted),
833
+ `sable component ${c.name} --examples`,
834
+ ])
835
+ }
836
+ return ok("component.example", { name: c.name, example }, () => renderExample(example))
837
+ }
730
838
  if (flags.has("a11y")) return ok("component.a11y", { name: c.name, a11y: c.a11y ?? [] }, () => (c.a11y ?? []).map(bullet).join("\n"))
731
839
 
732
840
  const level = detailLevel("compact")
@@ -796,9 +904,11 @@ function renderUsage(c) {
796
904
  function renderExamples(c) {
797
905
  const ex = c.examples ?? []
798
906
  if (!ex.length) return ""
799
- return `\nExamples:\n${ex
800
- .map((e) => `\n ── ${e.label}\n ${e.imports}\n${e.code.split("\n").map((l) => ` ${l}`).join("\n")}`)
801
- .join("\n")}`
907
+ return `\nExamples:\n${ex.map((e) => `\n${renderExample(e)}`).join("\n")}`
908
+ }
909
+
910
+ function renderExample(e) {
911
+ return ` ── ${e.label}\n ${e.imports}\n${e.code.split("\n").map((l) => ` ${l}`).join("\n")}`
802
912
  }
803
913
 
804
914
  const slugify = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
@@ -1342,6 +1452,17 @@ function doctorReport() {
1342
1452
  )
1343
1453
  }
1344
1454
 
1455
+ /* Tailwind in the build, and the theme's fonts: both fail as a page that
1456
+ * renders, just not as Sable — unstyled, or in system fonts. */
1457
+ if (pkg) {
1458
+ const build = tailwindBuildCheck(CWD, detectFramework(CWD))
1459
+ if (build) checks.push(build)
1460
+ }
1461
+ if (usesSable || sableImport.test(cssText)) {
1462
+ const html = collectSources(/\.html$/).map((f) => f.text).join("\n")
1463
+ checks.push(fontsCheck({ css: cssText, source: srcText, html, deps: pkg ? { ...pkg.dependencies, ...pkg.devDependencies } : {} }))
1464
+ }
1465
+
1345
1466
  /* Local patches (sable patch): each one is re-checked against the installed
1346
1467
  * Sable, and fails when Sable changed a patched file, so an upgrade tells the
1347
1468
  * agent to re-run the repro instead of keeping or losing a fix silently. */
@@ -1354,6 +1475,7 @@ function doctorReport() {
1354
1475
  }
1355
1476
 
1356
1477
  function cmdDoctor() {
1478
+ if (flags.has("fix") || flags.has("dry-run")) return cmdDoctorFix()
1357
1479
  const data = doctorReport()
1358
1480
  const { checks, failed } = data
1359
1481
  if (JSON_OUT) {
@@ -1370,6 +1492,127 @@ function cmdDoctor() {
1370
1492
  process.exit(failed ? 1 : 0)
1371
1493
  }
1372
1494
 
1495
+ /**
1496
+ * A fix counts as applied only once doctor, run again, stops flagging its
1497
+ * check. An edit that did not take is reported as unverified, with what doctor
1498
+ * still says, rather than as done.
1499
+ */
1500
+ function verifyFixes(fixes, after) {
1501
+ for (const f of fixes) {
1502
+ if (f.status !== "applied") continue
1503
+ const c = after.checks.find((x) => x.name === f.check)
1504
+ if (c && (c.status === "fail" || c.status === "warn" || (f.check === "dedupe" && c.status === "info"))) {
1505
+ f.status = "unverified"
1506
+ f.detail = `${f.detail} Doctor still reports: ${c.detail}`
1507
+ }
1508
+ }
1509
+ return fixes
1510
+ }
1511
+
1512
+ function fixLines(fixes, indent = " ") {
1513
+ const mark = { applied: "+", "would-apply": "~", manual: "!", failed: "✗", unverified: "?" }
1514
+ const word = { applied: "", "would-apply": "", manual: "by hand: ", failed: "failed: ", unverified: "unverified: " }
1515
+ return fixes
1516
+ .map(
1517
+ (f) =>
1518
+ `${indent}${mark[f.status]} ${f.check.padEnd(14)}${word[f.status]}${f.file ? `${f.file} — ` : ""}${f.detail}\n` +
1519
+ (f.snippet ? `${f.snippet.split("\n").map((l) => `${indent} ${l}`).join("\n")}\n` : ""),
1520
+ )
1521
+ .join("")
1522
+ }
1523
+
1524
+ function cmdDoctorFix() {
1525
+ const dryRun = flags.has("dry-run")
1526
+ const before = doctorReport()
1527
+ const fixes = fixWiring({ root: CWD, pkgRoot: PKG_ROOT, report: before, framework: detectFramework(CWD), dryRun, install: !flags.has("no-install"), quiet: JSON_OUT })
1528
+ const after = dryRun ? before : doctorReport()
1529
+ verifyFixes(fixes, after)
1530
+ const { failed } = after
1531
+ if (JSON_OUT) process.stdout.write(`${JSON.stringify({ type: "doctor.fix", data: { ...after, dryRun, fixes } }, null, DENSE ? 0 : 1)}\n`)
1532
+ else
1533
+ process.stdout.write(
1534
+ [
1535
+ fixes.length ? `${dryRun ? "Would fix (dry run, nothing written):" : "Fixes:"}\n${fixLines(fixes)}` : "Nothing to fix.\n",
1536
+ `${dryRun ? "Now:" : "After:"}\n${doctorLines(after.checks)}`,
1537
+ failed ? `[sable doctor] ✗ ${failed} failure(s)${dryRun ? "" : " left; the steps marked ! need you"}.\n` : "[sable doctor] ✓ wiring holds.\n",
1538
+ ].join("\n"),
1539
+ )
1540
+ logInvocation(failed ? 1 : 0)
1541
+ process.exit(failed ? 1 : 0)
1542
+ }
1543
+
1544
+ /* ── init ───────────────────────────────────────────────────────────────────── */
1545
+
1546
+ /**
1547
+ * The install recipe, run: doctor --fix, the agent skill, and the provider the
1548
+ * app still has to mount. Each step is the code its own command runs, so init
1549
+ * can never wire an app differently from what doctor checks.
1550
+ */
1551
+ function cmdInit() {
1552
+ if (!existsSync(join(CWD, "package.json")))
1553
+ fail("ERR_INIT", `No package.json in ${CWD}.`, ["Run sable init from your app's root.", "Starting fresh: pnpm create vite@latest my-app --template react-ts, then pnpm add @sableui/react && pnpm exec sable init"])
1554
+ const dryRun = flags.has("dry-run")
1555
+ const framework = detectFramework(CWD)
1556
+ const packageManager = detectPackageManager(CWD)
1557
+ const before = doctorReport()
1558
+ const fixes = fixWiring({ root: CWD, pkgRoot: PKG_ROOT, report: before, framework, dryRun, install: !flags.has("no-install"), quiet: JSON_OUT })
1559
+
1560
+ let agent
1561
+ if (flags.has("no-agent")) agent = { status: "skipped" }
1562
+ else
1563
+ try {
1564
+ // update: an unchanged managed entry from an older Sable is refreshed;
1565
+ // one with local edits still refuses, and is reported for the user.
1566
+ const setup = installAgentSkill({ root: CWD, project: CWD, packageRoot: PKG_ROOT, target: "all", update: true, dryRun })
1567
+ agent = { status: dryRun ? "would-apply" : "applied", destinations: setup.destinations, next: setup.next }
1568
+ } catch (error) {
1569
+ agent = { status: "manual", detail: error.message, fix: "sable setup --agent --dry-run" }
1570
+ }
1571
+
1572
+ const after = dryRun ? before : doctorReport()
1573
+ verifyFixes(fixes, after)
1574
+ const mounted = after.checks.find((c) => c.name === "icons")?.status === "pass"
1575
+ const providers = { ...providerSnippet(framework), mounted }
1576
+ const data = { framework: { id: framework.id, label: framework.label }, packageManager, dryRun, fixes, agent, providers, doctor: after }
1577
+
1578
+ if (JSON_OUT) process.stdout.write(`${JSON.stringify({ type: "init", data }, null, DENSE ? 0 : 1)}\n`)
1579
+ else {
1580
+ const agentText = {
1581
+ applied: () => `Agent skill: installed at ${agent.destinations.join(", ")}.`,
1582
+ "would-apply": () => `Agent skill: would install at ${agent.destinations.join(", ")}.`,
1583
+ skipped: () => "Agent skill: skipped (--no-agent).",
1584
+ manual: () => `Agent skill: not installed — ${agent.detail}\n → ${agent.fix}`,
1585
+ }[agent.status]()
1586
+ const iconsFix = fixes.find((f) => f.check === "icons")
1587
+ const providerText = mounted
1588
+ ? "Providers: <IconProvider> is mounted. MotionProvider and SableThemeProvider are optional: sable provider --list."
1589
+ : iconsFix?.status === "would-apply"
1590
+ ? `Providers: <IconProvider> would be mounted in ${iconsFix.file}.`
1591
+ : iconsFix?.status === "manual"
1592
+ ? "Providers: mount IconProvider as shown under icons above."
1593
+ : [`Providers — mount IconProvider once, in ${providers.file}${providers.then ? `, then ${providers.then}` : ""}:`, ...providers.code.split("\n").map((l) => ` ${l}`)].join("\n")
1594
+ process.stdout.write(
1595
+ [
1596
+ `sable init — ${framework.label}, ${packageManager}${dryRun ? " (dry run: nothing written or installed)" : ""}`,
1597
+ ...(framework.id === "unknown" ? ["Not Vite or Next.js: init installs the packages and wires the stylesheet it finds; the bundler and provider steps are printed for you."] : []),
1598
+ "",
1599
+ fixes.length ? `${dryRun ? "Would fix:" : "Fixes:"}\n${fixLines(fixes)}` : "Nothing to fix.\n",
1600
+ agentText,
1601
+ "",
1602
+ providerText,
1603
+ "",
1604
+ `Doctor:\n${doctorLines(after.checks)}`,
1605
+ after.failed
1606
+ ? `[sable init] ✗ ${after.failed} failure(s) ${dryRun ? "now" : "left; the steps marked ! need you"}. Re-check with sable doctor.`
1607
+ : `[sable init] ✓ wiring holds. Next: ${agent.next ?? "sable docs building-with-sable"}`,
1608
+ "",
1609
+ ].join("\n"),
1610
+ )
1611
+ }
1612
+ logInvocation(after.failed ? 1 : 0)
1613
+ process.exit(after.failed ? 1 : 0)
1614
+ }
1615
+
1373
1616
  function doctorLines(checks, indent = " ") {
1374
1617
  const mark = { pass: "✓", warn: "!", fail: "✗", info: "·" }
1375
1618
  return checks
@@ -1407,7 +1650,7 @@ function collectSources(match, limit = 4000) {
1407
1650
 
1408
1651
 
1409
1652
  function readConfigs() {
1410
- return ["vite.config.ts", "vite.config.js", "next.config.js", "next.config.mjs", "webpack.config.js"]
1653
+ return ["vite.config.ts", "vite.config.mts", "vite.config.js", "vite.config.mjs", "next.config.ts", "next.config.js", "next.config.mjs", "webpack.config.js"]
1411
1654
  .map((f) => (existsSync(join(CWD, f)) ? readFileSync(join(CWD, f), "utf8") : ""))
1412
1655
  .join("\n")
1413
1656
  }
@@ -1698,6 +1941,7 @@ const HANDLERS = {
1698
1941
  utility: cmdUtility,
1699
1942
  docs: cmdDocs,
1700
1943
  doctor: cmdDoctor,
1944
+ init: cmdInit,
1701
1945
  grade: cmdGrade,
1702
1946
  learn: cmdLearn,
1703
1947
  atlas: () => cmdDesignTool("design-atlas.mjs"),
@@ -34,7 +34,8 @@ var appHeaderMeta = {
34
34
  components: [
35
35
  "AppShell",
36
36
  "PageHeader",
37
- "BottomNav"
37
+ "BottomNav",
38
+ "NavigationMenu"
38
39
  ]
39
40
  },
40
41
  props: [
@@ -109,6 +109,9 @@ function Button({
109
109
  children: content,
110
110
  onClick: handleClick,
111
111
  onKeyDown: handleKeyDown,
112
+ // Read by a ButtonGroup, which draws the seam between a bordered pair
113
+ // differently from a filled one.
114
+ "data-variant": variant ?? "primary",
112
115
  ...resolvedBleed ? { "data-bleed": resolvedBleed } : null,
113
116
  ...busy ? { "aria-busy": true } : null,
114
117
  // A busy native button stays focusable: aria-disabled, with presses
@@ -33,7 +33,7 @@ var buttonMeta = {
33
33
  },
34
34
  {
35
35
  guidance: "do",
36
- description: "For a copy control, pair an icon Button with `useCopy(text, { timeout? })`. It takes the text when the hook is called and returns `{ copy, copied }`; `copy()` takes no argument, so pass the value the button copies to the hook, not to `copy`."
36
+ description: "For a copy control, pair an icon Button with `useCopy(text, { timeout? })`. It takes the text when the hook is called and returns `{ copy, copied }`; `copy()` takes no argument, so pass the value the button copies to the hook, not to `copy`. Text that costs something to produce (a file to fetch) goes in as a function, `useCopy(() => fetch(url).then((r) => r.text()))`, called only on click."
37
37
  },
38
38
  {
39
39
  guidance: "do",
@@ -2,7 +2,7 @@
2
2
  import { cva } from "class-variance-authority";
3
3
  var ON_COLOR_FOCUS = "focus-visible:ring-current focus-visible:ring-offset-2 focus-visible:ring-offset-transparent";
4
4
  var buttonVariants = cva(
5
- "inline-flex items-center justify-center gap-(--control-gap) whitespace-nowrap rounded-[var(--button-radius,9999px)] motion-interactive motion-press focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:shrink-0 [&_svg]:size-(--button-icon)",
5
+ "inline-flex items-center justify-center gap-(--control-gap) whitespace-nowrap rounded-s-[var(--button-radius-s,var(--button-radius,9999px))] rounded-e-[var(--button-radius-e,var(--button-radius,9999px))] motion-interactive motion-press focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:shrink-0 [&_svg]:size-(--button-icon)",
6
6
  {
7
7
  variants: {
8
8
  variant: {
@@ -0,0 +1,17 @@
1
+ // src/components/button-group/button-group.tsx
2
+ import { cn } from "../../lib/cn.js";
3
+ import { jsx } from "react/jsx-runtime";
4
+ function ButtonGroup({ className, role = "group", ...props }) {
5
+ return /* @__PURE__ */ jsx(
6
+ "div",
7
+ {
8
+ role,
9
+ "data-slot": "button-group",
10
+ className: cn("inline-flex items-stretch button-group-join", className),
11
+ ...props
12
+ }
13
+ );
14
+ }
15
+ export {
16
+ ButtonGroup
17
+ };
@@ -0,0 +1,72 @@
1
+ // src/components/button-group/button-group.meta.ts
2
+ var buttonGroupMeta = {
3
+ name: "ButtonGroup",
4
+ summary: "Buttons joined into one control: a split button (an action beside the chevron that opens its menu) or a short run of related actions. The children stay ordinary Buttons; the group squares their inner corners, collapses bordered seams into one hairline, and draws a seam between filled ones.",
5
+ status: "stable",
6
+ keywords: ["split button", "button group", "segmented actions", "dropdown button", "joined buttons", "toolbar"],
7
+ usage: {
8
+ description: "Use ButtonGroup when one action has close alternatives (Copy page, with Open in\u2026 behind a chevron) or a few actions belong together as one object (zoom out, reset, zoom in). Use ToggleGroup for a choice that stays selected, and Tabs to switch views.",
9
+ bestPractices: [
10
+ {
11
+ guidance: "do",
12
+ description: 'Give the children one height: `size="sm"` with `size="icon-sm"`, `md` with `icon`. The group aligns them but cannot reconcile two heights into one edge.'
13
+ },
14
+ {
15
+ guidance: "do",
16
+ description: 'For a split button, put the main action first and a Menu whose trigger is the chevron Button second, with an aria-label on the trigger ("More page actions").'
17
+ },
18
+ {
19
+ guidance: "do",
20
+ description: "Name the group with aria-label when the buttons alone do not say what they act on."
21
+ },
22
+ {
23
+ guidance: "dont",
24
+ description: "Do not round or border the children yourself to make them meet; that is the group's job, and an override breaks under a theme's button shape."
25
+ },
26
+ {
27
+ guidance: "dont",
28
+ description: "Do not use it for a selection that stays on. That is ToggleGroup, which carries the pressed state."
29
+ }
30
+ ]
31
+ },
32
+ related: {
33
+ components: ["Button", "Menu", "Toggle"]
34
+ },
35
+ props: [
36
+ {
37
+ name: "role",
38
+ type: "string",
39
+ default: '"group"',
40
+ description: "Group semantics, so a screen reader announces the buttons as one set. Pass `toolbar` when it carries arrow-key navigation of your own."
41
+ },
42
+ {
43
+ name: "className",
44
+ type: "string",
45
+ description: "Layout classes for the group (margins, alignment). The children's look comes from their own variants."
46
+ },
47
+ {
48
+ name: "...props",
49
+ type: 'ComponentPropsWithRef<"div">',
50
+ description: "All native <div> attributes, including aria-label."
51
+ }
52
+ ],
53
+ tokensUsed: [],
54
+ a11y: [
55
+ 'Renders role="group", so assistive tech announces the buttons as one set; name it with aria-label.',
56
+ "Each child keeps its own focus stop and label; a focused child is raised above its neighbour so its ring is drawn whole.",
57
+ 'An icon-only chevron that opens a menu needs its own aria-label, such as "More page actions".'
58
+ ],
59
+ examples: [
60
+ {
61
+ label: "Split button with a menu",
62
+ code: '<ButtonGroup aria-label="Page actions"><Button variant="outline" size="sm" iconStart={<Icon name="copy" />} onClick={copy}>Copy page</Button><Menu><MenuTrigger render={<Button variant="outline" size="icon-sm" aria-label="More page actions" />}><Icon name="chevron-down" /></MenuTrigger><MenuContent align="end"><MenuItem icon={<Icon name="file" />} description="View this page as plain text">View as Markdown</MenuItem></MenuContent></Menu></ButtonGroup>'
63
+ },
64
+ {
65
+ label: "Related actions",
66
+ code: '<ButtonGroup aria-label="Zoom"><Button variant="secondary" size="icon-sm" aria-label="Zoom out"><Icon name="minus" /></Button><Button variant="secondary" size="sm">100%</Button><Button variant="secondary" size="icon-sm" aria-label="Zoom in"><Icon name="plus" /></Button></ButtonGroup>'
67
+ }
68
+ ]
69
+ };
70
+ export {
71
+ buttonGroupMeta
72
+ };
@@ -0,0 +1,7 @@
1
+ // src/components/button-group/index.ts
2
+ import { ButtonGroup } from "./button-group.js";
3
+ import { buttonGroupMeta } from "./button-group.meta.js";
4
+ export {
5
+ ButtonGroup,
6
+ buttonGroupMeta
7
+ };