@sableui/react 0.17.0 → 0.19.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 (154) hide show
  1. package/AGENTS.md +10 -3
  2. package/CHANGELOG.md +235 -0
  3. package/README.md +11 -4
  4. package/bin/sable.mjs +266 -30
  5. package/dist/{_contract-CDa9-NdB.d.ts → _contract-DlklV0Im.d.ts} +2 -2
  6. package/dist/activity-DT8VK9fx.d.ts +25 -0
  7. package/dist/adapters/github.d.ts +41 -0
  8. package/dist/adapters/github.js +24 -0
  9. package/dist/blocks/index.d.ts +1 -1
  10. package/dist/components/activity-heatmap/activity-heatmap.js +420 -0
  11. package/dist/components/activity-heatmap/activity-heatmap.meta.js +142 -0
  12. package/dist/components/activity-heatmap/index.js +9 -0
  13. package/dist/components/avatar/avatar.js +16 -7
  14. package/dist/components/avatar/avatar.meta.js +43 -10
  15. package/dist/components/avatar/avatar.variants.js +26 -2
  16. package/dist/components/code-block/code-block.js +5 -3
  17. package/dist/components/code-block/code-block.meta.js +6 -0
  18. package/dist/components/collapsible/collapsible.js +6 -5
  19. package/dist/components/collapsible/collapsible.meta.js +14 -5
  20. package/dist/components/data-table/data-table.js +132 -45
  21. package/dist/components/data-table/data-table.meta.js +63 -4
  22. package/dist/components/date-picker/date-picker.js +13 -6
  23. package/dist/components/date-picker/date-picker.meta.js +22 -0
  24. package/dist/components/dialog/dialog.js +25 -5
  25. package/dist/components/dialog/dialog.meta.js +26 -5
  26. package/dist/components/media-image/media-image.js +3 -1
  27. package/dist/components/media-image/media-image.meta.js +15 -1
  28. package/dist/components/media-tile/media-tile.js +13 -1
  29. package/dist/components/media-tile/media-tile.meta.js +10 -0
  30. package/dist/components/menu/menu.js +2 -0
  31. package/dist/components/menu/menu.meta.js +2 -2
  32. package/dist/components/page-header/page-header.js +9 -4
  33. package/dist/components/page-header/page-header.meta.js +26 -1
  34. package/dist/components/panel-group/panel-group.js +17 -1
  35. package/dist/components/panel-group/panel-group.meta.js +43 -3
  36. package/dist/components/pill/pill.meta.js +15 -6
  37. package/dist/components/pill/pill.variants.js +1 -0
  38. package/dist/components/search-input/search-input.js +30 -5
  39. package/dist/components/search-input/search-input.meta.js +29 -4
  40. package/dist/components/section-header/section-header.js +27 -17
  41. package/dist/components/section-header/section-header.meta.js +27 -5
  42. package/dist/components/sheet/sheet.js +15 -3
  43. package/dist/components/sheet/sheet.meta.js +2 -2
  44. package/dist/components/side-nav/side-nav.variants.js +8 -3
  45. package/dist/components/sidebar/index.js +4 -0
  46. package/dist/components/sidebar/sidebar.js +90 -11
  47. package/dist/components/sidebar/sidebar.meta.js +38 -4
  48. package/dist/components/table/table.js +7 -3
  49. package/dist/components/tabs/tabs.js +10 -2
  50. package/dist/components/tabs/tabs.meta.js +4 -0
  51. package/dist/components/workspace/workspace.js +13 -3
  52. package/dist/components/workspace/workspace.meta.js +109 -1
  53. package/dist/file-hashes.json +94 -80
  54. package/dist/foundations/typography.js +13 -0
  55. package/dist/icon/Icon.js +21 -2
  56. package/dist/icon/index.d.ts +36 -1
  57. package/dist/icon/index.js +5 -0
  58. package/dist/icon/names.js +240 -0
  59. package/dist/icon-sets/reicon.js +41 -1
  60. package/dist/index.d.ts +540 -75
  61. package/dist/index.js +18 -1
  62. package/dist/layout/AspectBox.js +2 -5
  63. package/dist/layout/Bleed.js +10 -6
  64. package/dist/layout/Center.js +11 -3
  65. package/dist/layout/Columns.js +6 -3
  66. package/dist/layout/Grid.js +14 -6
  67. package/dist/layout/GridOverlay.js +7 -7
  68. package/dist/layout/Region.js +18 -3
  69. package/dist/layout/Row.js +27 -20
  70. package/dist/layout/Spacer.js +13 -2
  71. package/dist/layout/SplitPane.js +1 -1
  72. package/dist/layout/Stack.js +25 -18
  73. package/dist/layout/index.d.ts +57 -6
  74. package/dist/lib/chart/activity.js +32 -0
  75. package/dist/lib/chart/calendar.js +67 -0
  76. package/dist/lib/chart/chart-tooltip.js +2 -2
  77. package/dist/lib/chart/heat.js +55 -0
  78. package/dist/lib/cn.js +1 -0
  79. package/dist/lib/focus-return.js +53 -0
  80. package/dist/lib/taxonomy.js +1 -0
  81. package/dist/lib/use-media-query.js +41 -0
  82. package/dist/{meta-DX5r5wQL.d.ts → meta-Dfm9AExe.d.ts} +6 -0
  83. package/dist/patterns/dashboard-page.js +23 -17
  84. package/dist/patterns/index.d.ts +7 -2
  85. package/dist/patterns/index.js +5 -1
  86. package/dist/patterns/tracker-page.js +392 -0
  87. package/dist/patterns/tracker-page.meta.js +40 -0
  88. package/dist/presets/index.d.ts +1 -1
  89. package/dist/styles.css +301 -188
  90. package/dist/{theme-provider-BydfM5Z0.d.ts → theme-provider-BUBealk_.d.ts} +1 -1
  91. package/dist/themes/_contract.js +9 -1
  92. package/dist/themes/atelier/index.d.ts +1 -1
  93. package/dist/themes/ember/index.d.ts +1 -1
  94. package/dist/themes/index.d.ts +2 -2
  95. package/dist/themes/meridian/index.d.ts +1 -1
  96. package/dist/themes/poppy/index.d.ts +1 -1
  97. package/dist/themes/prism/index.d.ts +1 -1
  98. package/dist/themes/sable/index.d.ts +1 -1
  99. package/dist/themes/verdant/index.d.ts +1 -1
  100. package/llms/components/activity-heatmap.md +133 -0
  101. package/llms/components/avatar.md +32 -9
  102. package/llms/components/code-block.md +1 -0
  103. package/llms/components/collapsible.md +12 -4
  104. package/llms/components/data-table.md +40 -3
  105. package/llms/components/date-picker.md +15 -1
  106. package/llms/components/dialog.md +34 -3
  107. package/llms/components/media-image.md +12 -0
  108. package/llms/components/media-tile.md +21 -0
  109. package/llms/components/menu.md +1 -1
  110. package/llms/components/page-header.md +16 -1
  111. package/llms/components/panel-group.md +34 -2
  112. package/llms/components/pill.md +24 -5
  113. package/llms/components/search-input.md +37 -4
  114. package/llms/components/section-header.md +28 -4
  115. package/llms/components/sheet.md +2 -2
  116. package/llms/components/sidebar.md +17 -4
  117. package/llms/components/tabs.md +1 -0
  118. package/llms/components/workspace.md +81 -1
  119. package/llms/foundations/typography.md +4 -1
  120. package/llms/layout/aspect-box.md +1 -1
  121. package/llms/layout/bleed.md +1 -1
  122. package/llms/layout/center.md +1 -1
  123. package/llms/layout/columns.md +1 -1
  124. package/llms/layout/grid.md +1 -1
  125. package/llms/layout/region.md +1 -1
  126. package/llms/layout/row.md +9 -1
  127. package/llms/layout/spacer.md +1 -1
  128. package/llms/layout/split-pane.md +1 -1
  129. package/llms/layout/stack.md +1 -1
  130. package/llms/patterns/dashboard-page.md +18 -15
  131. package/llms/patterns/tracker-page.md +450 -0
  132. package/llms/reference/token-intent.md +3 -1
  133. package/llms/reference/utilities.md +4 -1
  134. package/llms/tasks/auditing-an-app.md +35 -1
  135. package/llms/tasks/building-a-page.md +3 -2
  136. package/llms/tasks/building-a-tool.md +101 -5
  137. package/llms/tasks/building-with-sable.md +11 -3
  138. package/llms/tasks/creating-a-preset.md +9 -0
  139. package/llms/tasks/getting-started.md +10 -3
  140. package/llms/tasks/using-presets.md +1 -2
  141. package/llms-full.txt +1215 -89
  142. package/llms.txt +11 -9
  143. package/package.json +7 -1
  144. package/registry.json +1021 -70
  145. package/scripts/adoption.mjs +252 -0
  146. package/scripts/agent-setup.mjs +110 -18
  147. package/scripts/app-css.mjs +129 -0
  148. package/scripts/grade.mjs +178 -2
  149. package/scripts/lint-rules.mjs +126 -0
  150. package/scripts/wiring.mjs +2 -1
  151. package/skills/entry/SKILL.md +1 -1
  152. package/skills/sable/SKILL.md +64 -6
  153. package/skills/sable/agents/openai.yaml +1 -1
  154. package/targets/ios.json +9 -0
package/bin/sable.mjs CHANGED
@@ -36,13 +36,15 @@
36
36
 
37
37
  import { spawnSync } from "node:child_process"
38
38
  import { appendFileSync, existsSync, readdirSync, readFileSync, statSync } from "node:fs"
39
+ import { createRequire } from "node:module"
39
40
  import { dirname, join, relative, resolve } from "node:path"
40
41
  import { fileURLToPath } from "node:url"
41
42
  import { skeletonize } from "../scripts/skeleton.mjs"
42
43
  import { claudeStopHook, installAgentSkill, installClaudeStopHook } from "../scripts/agent-setup.mjs"
44
+ import { adoptionReport, swapLines } from "../scripts/adoption.mjs"
43
45
  import { patchChecks } from "../scripts/patches.mjs"
44
46
  import { sourcesCheck } from "../scripts/sources.mjs"
45
- import { detectFramework, detectPackageManager, fixWiring, fontsCheck, providerSnippet, tailwindBuildCheck } from "../scripts/wiring.mjs"
47
+ import { detectFramework, detectPackageManager, fixWiring, fontsCheck, installCommand, providerSnippet, tailwindBuildCheck } from "../scripts/wiring.mjs"
46
48
 
47
49
  const HERE = dirname(fileURLToPath(import.meta.url))
48
50
  const PKG_ROOT = join(HERE, "..")
@@ -119,7 +121,7 @@ const COMMANDS = [
119
121
  },
120
122
  {
121
123
  name: "setup",
122
- summary: "Install package-backed skill entry files for coding agents, preserving local edits.",
124
+ summary: "Install package-backed skill entry files for coding agents, plus a short managed block in AGENTS.md / CLAUDE.md that sends every UI request through the skill, preserving local edits.",
123
125
  arg: null,
124
126
  flags: [
125
127
  { name: "--agent", value: null, describe: "Install skill entry files. Required." },
@@ -128,6 +130,7 @@ const COMMANDS = [
128
130
  { name: "--update", value: null, describe: "Refresh unchanged managed entries after a package upgrade." },
129
131
  { name: "--dry-run", value: null, describe: "Check and report the destination without writing files." },
130
132
  { name: "--hook", value: null, describe: "Also add a Claude Code Stop hook that runs `sable check` to .claude/settings.json (Claude targets only). Without it, setup prints the hook and writes nothing there." },
133
+ { name: "--no-instructions", value: null, describe: "Skip the managed Sable block in AGENTS.md / CLAUDE.md. Without that block an agent uses the skill only when a request names it." },
131
134
  ],
132
135
  returns: ["setup.agent"],
133
136
  },
@@ -220,6 +223,13 @@ const COMMANDS = [
220
223
  ],
221
224
  returns: ["provider.list", "provider.detail", "provider.props", "provider.examples"],
222
225
  },
226
+ {
227
+ name: "icon",
228
+ summary: "The icon names <Icon name> renders with the bundled set. Check a name before using it: an unknown one renders nothing.",
229
+ arg: { name: "name", required: false },
230
+ flags: [],
231
+ returns: ["icon.list", "icon.detail"],
232
+ },
223
233
  {
224
234
  name: "token",
225
235
  summary: "The token contract, with intent — what each token means and what it is NOT.",
@@ -249,7 +259,7 @@ const COMMANDS = [
249
259
  },
250
260
  {
251
261
  name: "doctor",
252
- summary: "Check this project's Sable wiring. Exits 1 on any failure.",
262
+ summary: "Check this project's Sable wiring, and warn when its UI barely uses Sable (adoption). Exits 1 on any wiring failure.",
253
263
  arg: null,
254
264
  flags: [
255
265
  { 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." },
@@ -265,10 +275,26 @@ const COMMANDS = [
265
275
  flags: [
266
276
  { name: "--dry-run", value: null, describe: "Report every change without writing or installing anything." },
267
277
  { name: "--no-agent", value: null, describe: "Skip the agent skill (sable setup --agent)." },
278
+ { name: "--no-instructions", value: null, describe: "Install the skill but skip its managed block in AGENTS.md / CLAUDE.md." },
279
+ { name: "--hook", value: null, describe: "Also add the Claude Code Stop hook that runs `sable check` before a task can end. In a terminal, init asks; elsewhere it only prints the hook." },
280
+ { name: "--no-hook", value: null, describe: "Don't ask about the Stop hook." },
268
281
  { name: "--no-install", value: null, describe: "Print install commands instead of running them." },
269
282
  ],
270
283
  returns: ["init"],
271
284
  },
285
+ {
286
+ name: "upgrade",
287
+ summary: "Update @sableui/react to the latest release, then run the new version's init: skill entries and the AGENTS.md / CLAUDE.md block refreshed, wiring checked, adoption reported. Safe on an app that is already set up.",
288
+ arg: null,
289
+ flags: [
290
+ { name: "--dry-run", value: null, describe: "Print the update command and preview init's changes without installing or writing anything." },
291
+ { name: "--no-install", value: null, describe: "Skip the package update (you updated it yourself); just run the installed version's init." },
292
+ { name: "--hook", value: null, describe: "Passed to init: add the Claude Code Stop hook." },
293
+ { name: "--no-hook", value: null, describe: "Passed to init: don't ask about the Stop hook." },
294
+ { name: "--no-instructions", value: null, describe: "Passed to init: skip the AGENTS.md / CLAUDE.md block." },
295
+ ],
296
+ returns: ["init"],
297
+ },
272
298
  {
273
299
  name: "grade",
274
300
  summary: "Static contract checks and evidence review. Original compositions carry no penalty; no automatic taste score.",
@@ -358,6 +384,7 @@ const ERROR_CODES = [
358
384
  { 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." },
359
385
  { code: "ERR_AGENT_SETUP", when: "The packaged skill is missing, the destination is linked, or existing local edits would be overwritten." },
360
386
  { code: "ERR_INIT", when: "sable init ran outside an app root (no package.json)." },
387
+ { code: "ERR_UPGRADE", when: "sable upgrade ran where @sableui/react is not a dependency, or the package manager's update failed." },
361
388
  { code: "ERR_UNKNOWN_COMMAND", when: "The first argument is not a command." },
362
389
  { code: "ERR_MISSING_ARGUMENT", when: "A command that requires an argument got none." },
363
390
  { code: "ERR_UNKNOWN_COMPONENT", when: "No component by that name. `suggestions` carries near matches." },
@@ -368,6 +395,7 @@ const ERROR_CODES = [
368
395
  { code: "ERR_UNKNOWN_UTILITY", when: "No utility or variant by that name. `suggestions` carries near matches." },
369
396
  { code: "ERR_UNKNOWN_DOC", when: "No package guide or doc mirror by that slug. `suggestions` carries near matches and the package guides." },
370
397
  { code: "ERR_UNKNOWN_PROVIDER", when: "No provider by that name. `suggestions` carries near matches." },
398
+ { code: "ERR_UNKNOWN_ICON", when: "No icon by that name in the contract. `suggestions` carries the names to use instead." },
371
399
  { code: "ERR_UNKNOWN_EXAMPLE", when: "The component has no example with that label. `suggestions` carries near matches as commands." },
372
400
  { code: "ERR_LINT_INPUT", when: "sable lint --json: a path to lint does not exist." },
373
401
  { code: "ERR_CHECK_FAILED", when: "sable check: doctor, lint, or grade failed. `data.checks` names which." },
@@ -551,14 +579,32 @@ const importLine = (name) => `import { ${name} } from "@sableui/react"`
551
579
  const PROVIDERS = R.providers?.items ?? []
552
580
  const findProvider = (name) => PROVIDERS.find((p) => p.name.toLowerCase() === String(name).toLowerCase())
553
581
 
582
+ /**
583
+ * Exported parts by name → the entry that documents them. `PropertyRow`,
584
+ * `WorkspaceBar`, `CollapsibleIndicator` and `Panel` are real exports with no
585
+ * entry of their own, and `sable component PropertyRow` used to answer
586
+ * ERR_UNKNOWN_COMPONENT — so an agent that knew the right name concluded the
587
+ * part did not exist and built its own. A part shared by two entries (a
588
+ * MenuRadioGroup under Menu and SettingsMenu) resolves to the first.
589
+ */
590
+ const PARTS = new Map()
591
+ for (const c of COMPONENTS)
592
+ for (const s of c.subcomponents ?? []) {
593
+ const sub = typeof s === "string" ? { name: s.split(/\s+[—-]\s+/)[0].trim(), description: s } : s
594
+ if (sub.name && !PARTS.has(sub.name.toLowerCase())) PARTS.set(sub.name.toLowerCase(), { parent: c, part: sub })
595
+ }
596
+ const findPart = (name) => PARTS.get(String(name).toLowerCase())
597
+
554
598
  function findComponent(name) {
555
599
  const hit = COMPONENTS.find((c) => c.name.toLowerCase() === name.toLowerCase())
556
600
  if (hit) return hit
601
+ const part = findPart(name)
602
+ if (part) return { ...part.parent, partOf: part.parent.name, part: part.part }
557
603
  fail(
558
604
  "ERR_UNKNOWN_COMPONENT",
559
605
  `No component named "${name}".`,
560
606
  [
561
- ...nearest(name, [...COMPONENTS, ...PROVIDERS].map((c) => c.name)).map((n) => `Did you mean ${n}?`),
607
+ ...nearest(name, [...COMPONENTS, ...PROVIDERS].map((c) => c.name).concat([...PARTS.values()].map((p) => p.part.name))).map((n) => `Did you mean ${n}?`),
562
608
  `sable search "${name}" — searches summaries and keywords too`,
563
609
  "sable component --list",
564
610
  ],
@@ -629,14 +675,23 @@ function cmdSearch() {
629
675
 
630
676
  const pool = []
631
677
  if (!type || type === "component")
632
- for (const c of COMPONENTS)
678
+ for (const c of COMPONENTS) {
633
679
  pool.push({ kind: "component", name: c.name, summary: c.summary, status: c.status, group: c.group, ...(c.composition ? { reference: true, parts: c.composition } : {}), _s: score({ name: c.name, keywords: c.keywords, text: `${c.summary} ${c.usage?.description ?? ""}` }) })
680
+ // Parts are found by their own name and description: `WorkspaceBar` and
681
+ // `PropertyRow` are what a need is called, and they used to match nothing
682
+ // (or rank ColorField first, for a word in its prose). A part's hit names
683
+ // its entry, which is where its contract lives.
684
+ for (const s of c.subcomponents ?? []) {
685
+ if (typeof s !== "object" || !s.name || PARTS.get(s.name.toLowerCase())?.parent !== c) continue
686
+ pool.push({ kind: "component", name: s.name, partOf: c.name, summary: `${s.description ?? ""} Part of ${c.name}.`.trim(), status: c.status, group: c.group, _s: score({ name: s.name, text: s.description ?? "" }) })
687
+ }
688
+ }
634
689
  if (!type || type === "provider")
635
690
  for (const p of PROVIDERS)
636
691
  pool.push({ kind: "provider", name: p.name, summary: p.summary, _s: score({ name: p.name, keywords: ["provider", ...(p.companions ?? [])], text: `${p.summary} ${p.placement ?? ""}` }) })
637
692
  if (!type || type === "pattern")
638
693
  for (const p of PATTERNS)
639
- pool.push({ kind: "pattern", name: p.name, summary: p.summary, _s: score({ name: p.name, text: `${p.summary} ${p.intent ?? ""}` }) })
694
+ pool.push({ kind: "pattern", name: p.name, summary: p.summary, _s: score({ name: p.name, keywords: p.keywords, text: `${p.summary} ${p.intent ?? ""}` }) })
640
695
  if (!type || type === "block")
641
696
  for (const b of BLOCKS)
642
697
  pool.push({ kind: "block", name: b.name, summary: b.summary, family: b.family, _s: score({ name: b.name, keywords: [b.family.toLowerCase()], text: `${b.summary} ${b.intent ?? ""}` }) })
@@ -696,7 +751,9 @@ function cmdSearch() {
696
751
  retrieve:
697
752
  h.kind === "example"
698
753
  ? `sable component ${h.component} --example "${h.label.replace(/"/g, '\\"')}"`
699
- : `sable ${h.kind === "doc" ? "docs" : h.kind} ${h.name}`,
754
+ : h.partOf
755
+ ? `sable component ${h.partOf} --props`
756
+ : `sable ${h.kind === "doc" ? "docs" : h.kind} ${h.name}`,
700
757
  }))
701
758
  const anyReference = results.some((h) => h.reference)
702
759
  ok("search", { query, count: results.length, results }, () =>
@@ -704,7 +761,7 @@ function cmdSearch() {
704
761
  ...results.map((h) =>
705
762
  DENSE
706
763
  ? `${h.kind}/${h.name}${h.reference ? " (reference)" : ""}`
707
- : `${h.kind.padEnd(9)} ${`${h.name}${h.reference ? " ◇" : ""}`.padEnd(24)} ${firstSentence(h.summary ?? "")}`,
764
+ : `${h.kind.padEnd(9)} ${`${h.name}${h.reference ? " ◇" : ""}`.padEnd(24)} ${h.partOf ? `(part of ${h.partOf}) ` : ""}${firstSentence(h.summary ?? "")}`,
708
765
  ),
709
766
  ...(anyReference && !DENSE
710
767
  ? ["", "◇ a reference layout: use it when it fits the brief, or compose your own from Sable's parts. Either is fine. Behaviour components (controls, overlays, fields) are always used, never rebuilt."]
@@ -781,6 +838,36 @@ function firstSentence(s) {
781
838
  return text
782
839
  }
783
840
 
841
+ /* ── icon ───────────────────────────────────────────────────────────────────── */
842
+
843
+ /**
844
+ * The names `<Icon name>` draws, and the other sets' names it accepts for them
845
+ * (registry → iconContract, generated from src/icon/names.ts). A work tracker
846
+ * asked for nine names that did not exist, got blank space, and wrote its own
847
+ * alias map, because nothing listed the names that do.
848
+ */
849
+ function cmdIcon() {
850
+ const names = R.iconContract?.names ?? []
851
+ const aliases = R.iconContract?.aliases ?? {}
852
+ const set = new Set(names)
853
+ const name = rest[0]
854
+ if (!name)
855
+ return ok("icon.list", { count: names.length, names, aliases, note: R.iconContract?.note }, () =>
856
+ [`${names.length} icon names (the bundled Reicon set). An app adds more with composeIconSets and lists them in .sable/icons.json.`, "", names.join(" ")].join("\n"),
857
+ )
858
+ if (set.has(name)) return ok("icon.detail", { name, known: true, usage: `<Icon name="${name}" />` }, () => `${name} — in the contract.\n<Icon name="${name}" />`)
859
+ if (aliases[name])
860
+ return ok("icon.detail", { name, known: false, alias: aliases[name], usage: `<Icon name="${aliases[name]}" />` }, () =>
861
+ `${name} is another set's name for ${aliases[name]}: <Icon> draws it, but write the contract name.\n<Icon name="${aliases[name]}" />`,
862
+ )
863
+ const use = nearest(name, names)
864
+ fail("ERR_UNKNOWN_ICON", `No icon named "${name}"; <Icon name="${name}"> renders nothing.`, [
865
+ ...use.map((n) => `Use "${n}"`),
866
+ "Or add the glyph to the app's set with composeIconSets and list it in .sable/icons.json",
867
+ "sable icon — every name",
868
+ ])
869
+ }
870
+
784
871
  /* ── component ──────────────────────────────────────────────────────────────── */
785
872
 
786
873
  function cmdComponent() {
@@ -813,12 +900,16 @@ function cmdComponent() {
813
900
  // A provider is asked for the way a component is. Answer with its entry rather
814
901
  // than ERR_UNKNOWN_COMPONENT, which is what `sable component SableThemeProvider`
815
902
  // used to say.
816
- if (!COMPONENTS.some((c) => c.name.toLowerCase() === rest[0].toLowerCase()) && findProvider(rest[0])) return cmdProvider()
903
+ if (!COMPONENTS.some((c) => c.name.toLowerCase() === rest[0].toLowerCase()) && !findPart(rest[0]) && findProvider(rest[0])) return cmdProvider()
817
904
  const c = findComponent(rest[0])
905
+ // Asked for a part: answer with its entry, and say so first, so the agent
906
+ // knows the part exists, what to import, and where the rest of it is documented.
907
+ const part = c.partOf ? { name: c.part.name, partOf: c.partOf, import: `import { ${c.partOf}, ${c.part.name} } from "@sableui/react"`, ...(c.part.type ? { type: c.part.type } : {}), description: c.part.description ?? "" } : null
908
+ const partLine = part ? `${part.name} is part of ${part.partOf}.${part.description ? ` ${part.description}` : ""}\n${part.import}\n` : ""
818
909
 
819
910
  if (flags.has("props"))
820
- return ok("component.props", { name: c.name, props: c.props ?? [], subcomponents: c.subcomponents ?? [] }, () =>
821
- renderProps(c),
911
+ return ok("component.props", { name: c.name, ...(part ? { part } : {}), props: c.props ?? [], subcomponents: c.subcomponents ?? [] }, () =>
912
+ `${partLine}${part ? "\n" : ""}${renderProps(c)}`,
822
913
  )
823
914
  if (flags.has("examples"))
824
915
  return ok("component.examples", { name: c.name, examples: c.examples ?? [] }, () => renderExamples(c))
@@ -849,6 +940,7 @@ function cmdComponent() {
849
940
  import: importLine(c.name),
850
941
  summary: c.summary,
851
942
  ...(c.composition ? { composition: c.composition } : {}),
943
+ ...(part ? { part } : {}),
852
944
  }
853
945
  if (level !== "brief") {
854
946
  data.props = c.props ?? []
@@ -866,6 +958,7 @@ function cmdComponent() {
866
958
 
867
959
  ok("component.detail", data, () =>
868
960
  [
961
+ partLine,
869
962
  `${c.name} [${c.status}] ${c.group} register:${c.register}`,
870
963
  c.summary,
871
964
  c.composition
@@ -1471,6 +1564,21 @@ function doctorReport() {
1471
1564
  )
1472
1565
  }
1473
1566
 
1567
+ /* Adoption: wiring that holds says nothing about whether the app composes
1568
+ * Sable. An agent that never loaded the skill wires the stylesheet, uses the
1569
+ * tokens, and writes its own Button and Modal. A warning, never a failure:
1570
+ * check lists the swaps, and moving working screens is the user's call. */
1571
+ if (pkg && { ...pkg.dependencies, ...pkg.devDependencies }["@sableui/react"]) {
1572
+ const files = src
1573
+ .filter((f) => /\.(tsx|jsx)$/.test(f.path) && !/\.(test|spec|stories)\./.test(f.path))
1574
+ .map((f) => ({ rel: relative(CWD, f.path).replaceAll("\\", "/"), text: f.text }))
1575
+ const adoption = adoptionReport({ files, components: COMPONENTS, deps: { ...pkg.dependencies, ...pkg.devDependencies } })
1576
+ if (adoption.level === "none" || adoption.level === "low")
1577
+ add("warn", "adoption", `${adoption.summary} ${adoption.recommendation}`, "sable check src lists each swap; then ask your agent to /sable audit (or $sable audit) the app's use of Sable.")
1578
+ else if (adoption.level === "sable") add("pass", "adoption", adoption.summary)
1579
+ else if (adoption.level === "fresh") add("info", "adoption", adoption.summary)
1580
+ }
1581
+
1474
1582
  /* Tailwind in the build, and the theme's fonts: both fail as a page that
1475
1583
  * renders, just not as Sable — unstyled, or in system fonts. */
1476
1584
  if (pkg) {
@@ -1567,7 +1675,7 @@ function cmdDoctorFix() {
1567
1675
  * app still has to mount. Each step is the code its own command runs, so init
1568
1676
  * can never wire an app differently from what doctor checks.
1569
1677
  */
1570
- function cmdInit() {
1678
+ async function cmdInit() {
1571
1679
  if (!existsSync(join(CWD, "package.json")))
1572
1680
  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"])
1573
1681
  const dryRun = flags.has("dry-run")
@@ -1582,26 +1690,57 @@ function cmdInit() {
1582
1690
  try {
1583
1691
  // update: an unchanged managed entry from an older Sable is refreshed;
1584
1692
  // one with local edits still refuses, and is reported for the user.
1585
- const setup = installAgentSkill({ root: CWD, project: CWD, packageRoot: PKG_ROOT, target: "all", update: true, dryRun })
1586
- agent = { status: dryRun ? "would-apply" : "applied", destinations: setup.destinations, next: setup.next }
1693
+ const setup = installAgentSkill({ root: CWD, project: CWD, packageRoot: PKG_ROOT, target: "all", update: true, dryRun, instructions: !flags.has("no-instructions") })
1694
+ agent = { status: dryRun ? "would-apply" : "applied", destinations: setup.destinations, instructions: setup.instructions, instructionsChanged: setup.instructionsChanged, next: setup.next }
1587
1695
  } catch (error) {
1588
1696
  agent = { status: "manual", detail: error.message, fix: "sable setup --agent --dry-run" }
1589
1697
  }
1590
1698
 
1699
+ /* The Stop hook changes how Claude Code behaves, so init never adds it on its
1700
+ * own. A person at a terminal is asked; an agent running init (no TTY) gets
1701
+ * the one-line offer in the output, and --hook adds it either way. */
1702
+ let hook = null
1703
+ if (agent.status === "applied" || agent.status === "would-apply") {
1704
+ let want = flags.has("hook")
1705
+ const interactive = process.stdin.isTTY && process.stdout.isTTY && !JSON_OUT && !dryRun
1706
+ if (!want && !flags.has("no-hook") && interactive) {
1707
+ const { createInterface } = await import("node:readline/promises")
1708
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
1709
+ const answer = await rl.question("Add a Claude Code Stop hook, so Claude can't end a task while `sable check` fails? [Y/n] ")
1710
+ rl.close()
1711
+ want = !/^\s*n/i.test(answer)
1712
+ }
1713
+ try {
1714
+ hook = want ? installClaudeStopHook({ root: CWD, project: CWD, dryRun }) : claudeStopHook({ root: CWD, project: CWD })
1715
+ } catch (error) {
1716
+ hook = { installed: false, error: error.message }
1717
+ }
1718
+ }
1719
+
1591
1720
  const after = dryRun ? before : doctorReport()
1592
1721
  verifyFixes(fixes, after)
1593
1722
  const mounted = after.checks.find((c) => c.name === "icons")?.status === "pass"
1594
1723
  const providers = { ...providerSnippet(framework), mounted }
1595
- const data = { framework: { id: framework.id, label: framework.label }, packageManager, dryRun, fixes, agent, providers, doctor: after }
1724
+ // An app that is wired but built mostly without Sable needs the audit path
1725
+ // next, not a new build: `build` would start the product over.
1726
+ const lowAdoption = after.checks.some((c) => c.name === "adoption" && c.status === "warn")
1727
+ const data = { framework: { id: framework.id, label: framework.label }, packageManager, dryRun, fixes, agent, stopHook: hook, providers, doctor: after, next: lowAdoption ? EXISTING_APP_NEXT : (agent.next ?? null) }
1596
1728
 
1597
1729
  if (JSON_OUT) process.stdout.write(`${JSON.stringify({ type: "init", data }, null, DENSE ? 0 : 1)}\n`)
1598
1730
  else {
1599
1731
  const agentText = {
1600
- applied: () => `Agent skill: installed at ${agent.destinations.join(", ")}.`,
1601
- "would-apply": () => `Agent skill: would install at ${agent.destinations.join(", ")}.`,
1732
+ applied: () => `Agent skill: installed at ${agent.destinations.join(", ")}.\n${instructionsText({ ...agent, dryRun })}`,
1733
+ "would-apply": () => `Agent skill: would install at ${agent.destinations.join(", ")}.\n${instructionsText({ ...agent, dryRun })}`,
1602
1734
  skipped: () => "Agent skill: skipped (--no-agent).",
1603
1735
  manual: () => `Agent skill: not installed — ${agent.detail}\n → ${agent.fix}`,
1604
1736
  }[agent.status]()
1737
+ const hookText = !hook
1738
+ ? ""
1739
+ : hook.error
1740
+ ? `Stop hook: not added — ${hook.error}`
1741
+ : hook.installed
1742
+ ? `Stop hook: ${dryRun ? "would add to" : hook.alreadyPresent ? "already in" : "added to"} ${hook.settingsFile}. Claude can't end a task while sable check fails.`
1743
+ : `Stop hook (optional): sable init --hook, or sable setup --agent --hook, makes Claude Code run sable check before it can end a task.`
1605
1744
  const iconsFix = fixes.find((f) => f.check === "icons")
1606
1745
  const providerText = mounted
1607
1746
  ? "Providers: <IconProvider> is mounted. MotionProvider and SableThemeProvider are optional: sable provider --list."
@@ -1617,13 +1756,14 @@ function cmdInit() {
1617
1756
  "",
1618
1757
  fixes.length ? `${dryRun ? "Would fix:" : "Fixes:"}\n${fixLines(fixes)}` : "Nothing to fix.\n",
1619
1758
  agentText,
1759
+ ...(hookText ? [hookText] : []),
1620
1760
  "",
1621
1761
  providerText,
1622
1762
  "",
1623
1763
  `Doctor:\n${doctorLines(after.checks)}`,
1624
1764
  after.failed
1625
1765
  ? `[sable init] ✗ ${after.failed} failure(s) ${dryRun ? "now" : "left; the steps marked ! need you"}. Re-check with sable doctor.`
1626
- : `[sable init] ✓ wiring holds. Next: ${agent.next ?? "sable docs building-with-sable"}`,
1766
+ : `[sable init] ✓ wiring holds. Next: ${lowAdoption ? EXISTING_APP_NEXT : (agent.next ?? "sable docs building-with-sable")}`,
1627
1767
  "",
1628
1768
  ].join("\n"),
1629
1769
  )
@@ -1632,6 +1772,54 @@ function cmdInit() {
1632
1772
  process.exit(after.failed ? 1 : 0)
1633
1773
  }
1634
1774
 
1775
+ const EXISTING_APP_NEXT = "most of this app's UI isn't built from Sable yet. Tell your agent \"move this app onto Sable\": it lists what to swap and asks before changing anything."
1776
+
1777
+ /* ── upgrade ────────────────────────────────────────────────────────────────── */
1778
+
1779
+ /**
1780
+ * The whole update in one command: the package, then the NEW version's init.
1781
+ * This process is the old CLI, so after installing it hands over to the
1782
+ * freshly installed bin rather than running its own init: the skill entries,
1783
+ * the instructions block and the checks must be the new release's.
1784
+ */
1785
+ function cmdUpgrade() {
1786
+ let pkg
1787
+ try {
1788
+ pkg = JSON.parse(readFileSync(join(CWD, "package.json"), "utf8"))
1789
+ } catch {
1790
+ fail("ERR_UPGRADE", `No package.json in ${CWD}.`, ["Run sable upgrade from your app's root."])
1791
+ }
1792
+ const spec = pkg.dependencies?.["@sableui/react"] ?? pkg.devDependencies?.["@sableui/react"]
1793
+ if (!spec) fail("ERR_UPGRADE", "@sableui/react is not a dependency of this project.", ["pnpm add @sableui/react, then pnpm exec sable init"])
1794
+ const pm = detectPackageManager(CWD)
1795
+ const dryRun = flags.has("dry-run")
1796
+ // A tarball, link or workspace copy is not something "latest" can replace.
1797
+ const local = /^(?:file:|link:|workspace:|portal:|\.{0,2}\/)/.test(String(spec))
1798
+ const install = installCommand(pm, ["@sableui/react@latest"], { dev: !pkg.dependencies?.["@sableui/react"] })
1799
+ const say = (line) => { if (!JSON_OUT) process.stdout.write(`${line}\n`) }
1800
+
1801
+ if (flags.has("no-install")) say("sable upgrade — skipping the package update (--no-install).")
1802
+ else if (local) say(`sable upgrade — @sableui/react comes from ${spec}, which "latest" can't replace. Update that copy yourself; running the installed version's init.`)
1803
+ else if (dryRun) say(`sable upgrade — would run: ${install.join(" ")}`)
1804
+ else {
1805
+ say(`sable upgrade — ${install.join(" ")}`)
1806
+ const r = spawnSync(install[0], install.slice(1), { cwd: CWD, stdio: JSON_OUT ? "pipe" : "inherit", shell: process.platform === "win32" })
1807
+ if (r.status !== 0) fail("ERR_UPGRADE", `${install.join(" ")} exited ${r.status}.`, ["Fix the install, then run sable upgrade --no-install."])
1808
+ }
1809
+
1810
+ // The installed package's own CLI, resolved the way the skill resolves it.
1811
+ let bin = join(PKG_ROOT, "bin", "sable.mjs")
1812
+ try {
1813
+ const registry = createRequire(join(CWD, "package.json")).resolve("@sableui/react/registry.json")
1814
+ const installed = join(dirname(registry), "bin", "sable.mjs")
1815
+ if (existsSync(installed)) bin = installed
1816
+ } catch { /* not resolvable from here (a workspace copy): this CLI's init is the installed one */ }
1817
+ const passed = ["dry-run", "hook", "no-hook", "no-instructions", "json"].filter((f) => flags.has(f)).map((f) => `--${f}`)
1818
+ const r = spawnSync(process.execPath, [bin, "init", ...passed], { cwd: CWD, stdio: "inherit" })
1819
+ logInvocation(r.status ?? 1)
1820
+ process.exit(r.status ?? 1)
1821
+ }
1822
+
1635
1823
  function doctorLines(checks, indent = " ") {
1636
1824
  const mark = { pass: "✓", warn: "!", fail: "✗", info: "·" }
1637
1825
  return checks
@@ -1719,7 +1907,7 @@ const MANUAL_REVIEW = [
1719
1907
  { step: "Walk the journey", how: "sable docs reviewing-a-journey — operate the primary task and its recovery path end to end; loading, empty, error and success states; keyboard and touch." },
1720
1908
  { step: "Review the motion", how: "sable docs reviewing-motion — observe playback and interruption for anything that moves." },
1721
1909
  { step: "Check reduced motion", how: "Turn on prefers-reduced-motion (or MotionProvider \"reduced\"): state still changes, nothing essential depends on animation, nothing keeps moving." },
1722
- { step: "Write the reuse inventory", how: "List the Sable components, parts, utilities and token groups used, and every app-owned UI or CSS addition with the catalog search that justified it. Start from the app-owned surfaces sable check lists (sable grade --json → inventory) and note, for each, whether you used the Sable part named beside it or kept your own. Either is fine for layout and look. Behaviour (controls, overlays, keyboard handling) comes from Sable." },
1910
+ { step: "Write the reuse inventory", how: "List the Sable components, parts, utilities and token groups used, and every app-owned UI or CSS addition with the catalog search that justified it. Start from the app-owned surfaces and rebuilt parts sable check lists (sable grade --json → inventory, rebuiltParts) and note, for each, whether you used the Sable part named beside it or kept your own, and why. Either is fine for layout and look. Behaviour (controls, overlays, keyboard handling) comes from Sable." },
1723
1911
  { step: "Record what you learned", how: "sable docs learning-from-builds — keep conditional lessons with evidence (sable learn --record); never record human approval you did not get." },
1724
1912
  ]
1725
1913
 
@@ -1751,7 +1939,8 @@ function cmdCheck() {
1751
1939
  details: doctorFailures.map((c) => `${c.name}: ${c.detail}${c.fix ? ` → ${c.fix}` : ""}`),
1752
1940
  // Warnings and notes don't fail the check, but they are things to act on,
1753
1941
  // and a passing check that printed nothing else hid them from the agent.
1754
- notes: doctor.checks.filter((c) => c.status === "warn" || c.status === "info").map((c) => `${c.name}: ${c.detail}${c.fix ? ` → ${c.fix}` : ""}`),
1942
+ // Adoption is reported once, from grade, with its swaps.
1943
+ notes: doctor.checks.filter((c) => (c.status === "warn" || c.status === "info") && c.name !== "adoption").map((c) => `${c.name}: ${c.detail}${c.fix ? ` → ${c.fix}` : ""}`),
1755
1944
  data: doctor,
1756
1945
  })
1757
1946
 
@@ -1788,7 +1977,7 @@ function cmdCheck() {
1788
1977
  const LABELS = { hallucinatedComponent: "hallucinated components", hallucinatedProp: "hallucinated props", radixIdiom: "Radix idioms", hardcodedControl: "hardcoded control heights", offContractIcon: "off-contract icons" }
1789
1978
  const found = Object.entries(LABELS).filter(([k]) => grade.counts[k])
1790
1979
  const reviewMissing = review.length > 0 && grade.review?.status !== "review-recorded"
1791
- const details = found.flatMap(([k]) => grade.findings[k].map((f) => `${LABELS[k]} — ${f.file}:${f.line} → ${f.name ?? f.prop ?? f.what ?? f.match}${f.component ? ` on ${f.component}` : ""}${f.from ? ` from ${f.from}${f.reason ? ` (${f.reason})` : ""}` : ""}`))
1980
+ const details = found.flatMap(([k]) => grade.findings[k].map((f) => `${LABELS[k]} — ${f.file}:${f.line} → ${f.name ?? f.prop ?? f.what ?? f.match}${f.component ? ` on ${f.component}` : ""}${f.from ? ` from ${f.from}${f.reason ? ` (${f.reason})` : ""}` : ""}${f.via ? ` via ${f.via}` : ""}${f.suggest?.length ? `; the set has ${f.suggest.join(", ")} (sable icon)` : ""}`))
1792
1981
  if (reviewMissing) details.push(`review: ${grade.review?.status ?? "missing"}${grade.review?.problems?.length ? ` — ${grade.review.problems.join("; ")}` : ""}`)
1793
1982
  checks.push({
1794
1983
  name: "grade",
@@ -1801,20 +1990,35 @@ function cmdCheck() {
1801
1990
  .filter(Boolean)
1802
1991
  .join("; "),
1803
1992
  details,
1804
- data: { score: grade.score, contract: grade.contract, counts: grade.counts, review: grade.review, findings: grade.findings },
1993
+ // A half-themed palette passes the contract and breaks the moment
1994
+ // someone switches modes, so it is said on the grade line, as a warning.
1995
+ notes: [
1996
+ ...(grade.modes?.status === "warn" ? [`warn — colour modes: ${grade.modes.lightOnly.length} colour token(s) set for light with no dark value (${grade.modes.lightOnly.slice(0, 4).map((t) => `--${t.name}`).join(", ")}${grade.modes.lightOnly.length > 4 ? ", …" : ""}) → ${grade.modes.fix}`] : []),
1997
+ ...(grade.modes && !grade.modes.modeControl ? ["colour modes: nothing imports ThemeSwitcher, ThemeSelector or useSableTheme, so no one can switch modes to review both → mount ThemeSwitcher under SableThemeProvider, or pin the one mode the brief asks for"] : []),
1998
+ ],
1999
+ data: { score: grade.score, contract: grade.contract, counts: grade.counts, review: grade.review, findings: grade.findings, modes: grade.modes },
1805
2000
  })
1806
2001
  }
1807
2002
  /* App-owned surfaces, from grade. Never a failure: the input to the reuse
1808
2003
  * inventory (finish step 6), which otherwise gets written from memory. */
1809
2004
  const inventory = grade && grade.type !== "error" ? (grade.inventory ?? null) : null
2005
+ /* Adoption, from grade. Never a failure either: wiring can hold while the app
2006
+ * composes almost none of Sable, and the move onto it is the user's call. */
2007
+ const adoption = grade && grade.type !== "error" ? (grade.adoption ?? null) : null
2008
+ /* Rebuilt parts, from grade: elements and app CSS standing in for a part
2009
+ * Sable ships, or reaching into one. Notes, answered in the reuse inventory. */
2010
+ const rebuilt = grade && grade.type !== "error" ? (grade.rebuiltParts ?? null) : null
2011
+ const lowAdoption = adoption && (adoption.level === "none" || adoption.level === "low")
1810
2012
 
1811
2013
  const failed = checks.filter((c) => c.status !== "pass").map((c) => c.name)
1812
2014
  // A build that still scans all of Sable ships every component's classes;
1813
2015
  // trimming it is a finish step, because sources are only final once the app is.
1814
- const manual = doctor.checks.some((c) => c.name === "css:size")
1815
- ? [...MANUAL_REVIEW, { step: "Ship only the CSS you use", how: "sable sources --write src/sable-sources.css, then @import it in place of the whole-package @source (about 32 KB of CSS gzipped down to what the app imports). Re-run it whenever you import something new from Sable; sable check fails while it is stale." }]
1816
- : MANUAL_REVIEW
1817
- const data = { paths, passed: !failed.length, failed, checks, inventory, manualReview: manual }
2016
+ const manual = [
2017
+ ...(lowAdoption ? [{ step: "Ask about moving onto Sable", how: `${adoption.recommendation} The swaps are listed under Adoption above; sable docs auditing-an-app ("Adoption") covers how to put the choice to the user.` }] : []),
2018
+ ...MANUAL_REVIEW,
2019
+ ...(doctor.checks.some((c) => c.name === "css:size") ? [{ step: "Ship only the CSS you use", how: "sable sources --write src/sable-sources.css, then @import it in place of the whole-package @source (about 32 KB of CSS gzipped down to what the app imports). Re-run it whenever you import something new from Sable; sable check fails while it is stale." }] : []),
2020
+ ]
2021
+ const data = { paths, passed: !failed.length, failed, checks, inventory, adoption, rebuiltParts: rebuilt, manualReview: manual }
1818
2022
  const MARK = { pass: "✓", fail: "✗", error: "✗" }
1819
2023
  const MAX_DETAILS = 12
1820
2024
  const summaryText = () =>
@@ -1824,7 +2028,7 @@ function cmdCheck() {
1824
2028
  ` ${MARK[c.status]} ${c.name.padEnd(7)}${c.summary}`,
1825
2029
  ...c.details.slice(0, MAX_DETAILS).map((d) => ` ${d}`),
1826
2030
  ...(c.details.length > MAX_DETAILS ? [` … ${c.details.length - MAX_DETAILS} more: sable ${c.name}${c.name === "doctor" ? "" : ` ${paths.join(" ")}`}`] : []),
1827
- ...(c.notes ?? []).map((n) => ` note — ${n}`),
2031
+ ...(c.notes ?? []).map((n) => (n.startsWith("warn — ") ? ` ${n}` : ` note — ${n}`)),
1828
2032
  ]),
1829
2033
  "",
1830
2034
  failed.length ? `[sable check] ✗ ${failed.join(", ")} failed. Fix these and run sable check again.` : "[sable check] ✓ doctor, lint and grade pass.",
@@ -1840,6 +2044,27 @@ function cmdCheck() {
1840
2044
  "",
1841
2045
  ].join("\n")
1842
2046
  }
2047
+ const rebuiltText = () => {
2048
+ const list = rebuilt?.items ?? []
2049
+ if (!list.length) return ""
2050
+ return [
2051
+ "",
2052
+ `Rebuilt parts — ${list.length}. Sable ships these, or the app styles one from outside. Use the part (or its variant or prop), or say in the reuse inventory why yours stays. A part that cannot fit your design is a missing variant to report, not a reason to rebuild it.`,
2053
+ ...list.slice(0, MAX_DETAILS).map((r) => ` ${r.file}:${r.line} ${r.what}${r.count ? ` ×${r.count}` : ""} → ${r.consider}`),
2054
+ ...(list.length > MAX_DETAILS ? [` … ${list.length - MAX_DETAILS} more: sable grade ${paths.join(" ")} --json → rebuiltParts.items`] : []),
2055
+ "",
2056
+ ].join("\n")
2057
+ }
2058
+ const adoptionText = () => {
2059
+ if (!adoption || adoption.level === "empty" || (adoption.level === "sable" && !adoption.swaps.length)) return ""
2060
+ return [
2061
+ "",
2062
+ `Adoption — ${adoption.level}. ${adoption.summary}`,
2063
+ ` ${adoption.recommendation}`,
2064
+ ...swapLines(adoption, MAX_DETAILS),
2065
+ "",
2066
+ ].join("\n")
2067
+ }
1843
2068
  const manualText = () =>
1844
2069
  [
1845
2070
  "",
@@ -1869,7 +2094,7 @@ function cmdCheck() {
1869
2094
  }
1870
2095
 
1871
2096
  if (JSON_OUT) process.stdout.write(`${JSON.stringify({ type: "check", data }, null, DENSE ? 0 : 1)}\n`)
1872
- else process.stdout.write(`${summaryText()}\n${inventoryText()}${manualText()}\n`)
2097
+ else process.stdout.write(`${summaryText()}\n${adoptionText()}${rebuiltText()}${inventoryText()}${manualText()}\n`)
1873
2098
  logInvocation(failed.length ? 1 : 0, failed.length ? "ERR_CHECK_FAILED" : undefined)
1874
2099
  process.exit(failed.length ? 1 : 0)
1875
2100
  }
@@ -1899,6 +2124,15 @@ function cmdDesignTool(script) {
1899
2124
  passThrough(runScript(join(PKG_ROOT, "scripts", script)))
1900
2125
  }
1901
2126
 
2127
+ /** What setup did to the project instructions, in one line. */
2128
+ function instructionsText(data) {
2129
+ if (!data.instructions.length) return "Project instructions: skipped (--no-instructions). Agents use the skill only when a request names it."
2130
+ const changed = data.instructionsChanged
2131
+ const files = data.instructions.join(", ")
2132
+ if (!changed.length) return `Project instructions: ${files} already carry the Sable block.`
2133
+ return `Project instructions: ${data.dryRun ? "would add" : "added"} the Sable block to ${changed.join(", ")}, so every UI request goes through the skill. Edit outside its markers; --no-instructions skips it.`
2134
+ }
2135
+
1902
2136
  function cmdSetup() {
1903
2137
  if (!flags.has("agent") || rest.length) fail("ERR_BAD_ARGUMENT", "Use sable setup --agent from your project root.", ["sable setup --agent --dry-run"])
1904
2138
  const root = flags.get("root")
@@ -1908,7 +2142,7 @@ function cmdSetup() {
1908
2142
  if (flags.has("hook") && !forClaude)
1909
2143
  fail("ERR_BAD_ARGUMENT", "--hook installs a Claude Code Stop hook; use it with --target claude or all.", ["sable setup --agent --target claude --hook"])
1910
2144
  try {
1911
- const options = { root: root ? resolve(CWD, root) : CWD, project: CWD, packageRoot: PKG_ROOT, target, update: flags.has("update"), dryRun: flags.has("dry-run") }
2145
+ const options = { root: root ? resolve(CWD, root) : CWD, project: CWD, packageRoot: PKG_ROOT, target, update: flags.has("update"), dryRun: flags.has("dry-run"), instructions: !flags.has("no-instructions") }
1912
2146
  // Preflight the settings file too, so a refusal there writes nothing anywhere.
1913
2147
  if (flags.has("hook")) installClaudeStopHook({ ...options, dryRun: true })
1914
2148
  const data = installAgentSkill(options)
@@ -1922,7 +2156,7 @@ function cmdSetup() {
1922
2156
  : hook.installed
1923
2157
  ? `\n${data.dryRun ? "Would add" : hook.alreadyPresent ? "Already present:" : "Added"} a Claude Code Stop hook to ${hook.settingsFile}: Claude cannot end a task while \`sable check\` fails.`
1924
2158
  : `\nOptional: block Claude Code from ending a task while \`sable check\` fails. Add this to ${hook.settingsFile} (merge into an existing "hooks" key), or rerun with --hook to add it:\n\n${JSON.stringify(hook.settings, null, 2)}\n\nThe hook runs: ${hook.command}`
1925
- ok("setup.agent", data, `${data.dryRun ? "Would install" : "Installed"} Sable skill entries at ${data.destinations.map((d) => relative(CWD, join(data.root, d)).replaceAll("\\", "/")).join(", ")}.\nNext: ${data.next}${hookText}`)
2159
+ ok("setup.agent", data, `${data.dryRun ? "Would install" : "Installed"} Sable skill entries at ${data.destinations.map((d) => relative(CWD, join(data.root, d)).replaceAll("\\", "/")).join(", ")}.\n${instructionsText(data)}\nNext: ${data.next}${hookText}`)
1926
2160
  } catch (error) {
1927
2161
  fail("ERR_AGENT_SETUP", error.message, ["sable setup --agent --dry-run"])
1928
2162
  }
@@ -1950,6 +2184,7 @@ const HANDLERS = {
1950
2184
  setup: cmdSetup,
1951
2185
  check: cmdCheck,
1952
2186
  provider: cmdProvider,
2187
+ icon: cmdIcon,
1953
2188
  manifest: cmdManifest,
1954
2189
  search: cmdSearch,
1955
2190
  component: cmdComponent,
@@ -1961,6 +2196,7 @@ const HANDLERS = {
1961
2196
  docs: cmdDocs,
1962
2197
  doctor: cmdDoctor,
1963
2198
  init: cmdInit,
2199
+ upgrade: cmdUpgrade,
1964
2200
  grade: cmdGrade,
1965
2201
  learn: cmdLearn,
1966
2202
  atlas: () => cmdDesignTool("design-atlas.mjs"),
@@ -46,7 +46,7 @@ type TypeScaleToken = (typeof TYPE_SCALE_TOKENS)[number];
46
46
  * Set them at a theme or region scope, then keep consuming whole `text-*` tiers.
47
47
  * Compact controls still follow TYPE_UI_TOKENS for the workspace register.
48
48
  */
49
- declare const TYPE_REFINEMENT_TOKENS: readonly ["type-display-4xl-size", "type-title-lg-size", "type-title-sm-size", "type-body-lg-size", "type-body-lg-weight", "type-body-lg-leading", "type-body-lg-tracking", "type-body-md-size", "type-body-md-weight", "type-body-md-leading", "type-body-md-tracking", "type-body-sm-size", "type-body-sm-weight", "type-body-sm-leading", "type-body-sm-tracking", "type-body-xs-size", "type-body-xs-weight", "type-body-xs-leading", "type-body-xs-tracking", "type-label-lg-size", "type-label-lg-weight", "type-label-lg-leading", "type-label-lg-tracking", "type-label-md-size", "type-label-md-weight", "type-label-md-leading", "type-label-md-tracking", "type-label-sm-size", "type-label-sm-weight", "type-label-sm-leading", "type-label-sm-tracking", "type-label-xs-size", "type-label-xs-weight", "type-label-xs-leading", "type-label-xs-tracking", "type-label-xs-transform", "type-caption-size", "type-caption-weight", "type-caption-leading", "type-caption-tracking", "type-mono-xl-size", "type-mono-xl-weight", "type-mono-xl-leading", "type-mono-xl-tracking", "type-mono-lg-size", "type-mono-lg-weight", "type-mono-lg-leading", "type-mono-lg-tracking", "type-mono-md-size", "type-mono-md-weight", "type-mono-md-leading", "type-mono-md-tracking", "type-mono-caption-size", "type-mono-caption-weight", "type-mono-caption-leading", "type-mono-caption-tracking", "type-mono-sm-size", "type-mono-sm-weight", "type-mono-sm-leading", "type-mono-sm-tracking"];
49
+ declare const TYPE_REFINEMENT_TOKENS: readonly ["type-base", "type-display-4xl-size", "type-title-lg-size", "type-title-sm-size", "type-body-lg-size", "type-body-lg-weight", "type-body-lg-leading", "type-body-lg-tracking", "type-body-md-size", "type-body-md-weight", "type-body-md-leading", "type-body-md-tracking", "type-body-sm-size", "type-body-sm-weight", "type-body-sm-leading", "type-body-sm-tracking", "type-body-xs-size", "type-body-xs-weight", "type-body-xs-leading", "type-body-xs-tracking", "type-label-lg-size", "type-label-lg-weight", "type-label-lg-leading", "type-label-lg-tracking", "type-label-md-size", "type-label-md-weight", "type-label-md-leading", "type-label-md-tracking", "type-label-sm-size", "type-label-sm-weight", "type-label-sm-leading", "type-label-sm-tracking", "type-label-xs-size", "type-label-xs-weight", "type-label-xs-leading", "type-label-xs-tracking", "type-label-xs-transform", "type-caption-size", "type-caption-weight", "type-caption-leading", "type-caption-tracking", "type-mono-xl-size", "type-mono-xl-weight", "type-mono-xl-leading", "type-mono-xl-tracking", "type-mono-lg-size", "type-mono-lg-weight", "type-mono-lg-leading", "type-mono-lg-tracking", "type-mono-md-size", "type-mono-md-weight", "type-mono-md-leading", "type-mono-md-tracking", "type-mono-caption-size", "type-mono-caption-weight", "type-mono-caption-leading", "type-mono-caption-tracking", "type-mono-sm-size", "type-mono-sm-weight", "type-mono-sm-leading", "type-mono-sm-tracking"];
50
50
  type TypeRefinementToken = (typeof TYPE_REFINEMENT_TOKENS)[number];
51
51
  /**
52
52
  * Overline/eyebrow/kicker type tokens (mode-independent). A theme MAY override
@@ -206,7 +206,7 @@ declare const MARK_TOKENS: readonly ["destructive-mark", "success-mark", "tone-w
206
206
  * an unrecognised `text-*` class falls into tailwind-merge's COLOUR group and
207
207
  * silently evicts the real colour class, which is a genuinely hard bug to see.
208
208
  */
209
- declare const TYPE_UI_TOKENS: readonly ["type-ui-md-size", "type-ui-md-weight", "type-ui-sm-size", "type-ui-sm-weight", "type-ui-leading", "type-ui-section-weight", "type-ui-micro-size", "type-ui-micro-weight"];
209
+ declare const TYPE_UI_TOKENS: readonly ["type-ui-md-size", "type-ui-md-weight", "type-ui-sm-size", "type-ui-sm-weight", "type-ui-leading", "type-ui-section-weight", "type-ui-micro-size", "type-ui-micro-weight", "type-ui-title-size", "type-ui-title-weight", "type-ui-title-leading"];
210
210
  type TypeUiToken = (typeof TYPE_UI_TOKENS)[number];
211
211
  type ThemeMode = "light" | "dark";
212
212
  /** Lightweight, typed description of a theme (the TS manifest each theme ships). */
@@ -0,0 +1,25 @@
1
+ /** One day of activity. Only `date` and `value` are required. */
2
+ interface ActivityDay {
3
+ /** The calendar day, "YYYY-MM-DD". Read as a day, never as an instant. */
4
+ date: string;
5
+ /** The measure for that day. A day missing from the data counts as 0. */
6
+ value: number;
7
+ /** 0…levels-1. Overrides the computed level — GitHub's API sends its own. */
8
+ level?: number;
9
+ /** A key into the heatmap's `categories`, for multi-source cells. */
10
+ category?: string;
11
+ }
12
+ /** Days as a list, or as a map of date to value (GitLab's and Mantine's shape). */
13
+ type ActivityData = ActivityDay[] | Record<string, number>;
14
+ /**
15
+ * Several sources' days merged into one list with categories: a day active in
16
+ * one source takes that source's key, a day active in more than one takes
17
+ * `both`. Values sum. For "GitHub, Codex, or both on that day".
18
+ *
19
+ * @example mergeActivity({ github: githubDays, codex: codexDays })
20
+ */
21
+ declare function mergeActivity(sources: Record<string, ActivityData>, { both }?: {
22
+ both?: string;
23
+ }): ActivityDay[];
24
+
25
+ export { type ActivityDay as A, type ActivityData as a, mergeActivity as m };