@sableui/react 0.0.0-stage → 0.1.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 (247) hide show
  1. package/AGENTS.md +157 -0
  2. package/CHANGELOG.md +50 -0
  3. package/CONTRIBUTING.md +221 -0
  4. package/DESIGN.md +548 -0
  5. package/LICENSE +21 -0
  6. package/README.md +194 -2
  7. package/THIRD_PARTY_NOTICES.md +44 -0
  8. package/bin/sable-lint.mjs +0 -0
  9. package/bin/sable.mjs +1223 -0
  10. package/dist/_contract-CYScOdf5.d.ts +150 -0
  11. package/dist/chunk-3IWZYBGH.js +398 -0
  12. package/dist/chunk-5Z5KLOAU.js +167 -0
  13. package/dist/chunk-7MG4SHYX.js +15 -0
  14. package/dist/chunk-ADLLDWLW.js +15 -0
  15. package/dist/chunk-EP55KSYA.js +14 -0
  16. package/dist/chunk-J23ELQQZ.js +9396 -0
  17. package/dist/chunk-JI7WJP2L.js +286 -0
  18. package/dist/chunk-K7RUHUGC.js +14 -0
  19. package/dist/chunk-KO3NAEBF.js +15 -0
  20. package/dist/chunk-L4IEIQQA.js +14 -0
  21. package/dist/chunk-L6Y46C62.js +270 -0
  22. package/dist/chunk-NEQNXI2G.js +25 -0
  23. package/dist/chunk-PAOZBZME.js +40 -0
  24. package/dist/chunk-RWP4XBSK.js +139 -0
  25. package/dist/chunk-SRQWQUTW.js +63 -0
  26. package/dist/chunk-YXBOYWBN.js +15 -0
  27. package/dist/foundations/index.d.ts +88 -0
  28. package/dist/foundations/index.js +792 -0
  29. package/dist/icon/index.d.ts +60 -0
  30. package/dist/icon/index.js +2 -0
  31. package/dist/icon-sets/brands.d.ts +44 -0
  32. package/dist/icon-sets/brands.js +147 -0
  33. package/dist/icon-sets/reicon.d.ts +8 -0
  34. package/dist/icon-sets/reicon.js +1 -0
  35. package/dist/index.d.ts +7013 -0
  36. package/dist/index.js +15462 -0
  37. package/dist/ios/chrome.css +83 -0
  38. package/dist/ios/frame.css +5 -0
  39. package/dist/ios/index.d.ts +70 -0
  40. package/dist/ios/index.js +313 -0
  41. package/dist/ios/preview.d.ts +64 -0
  42. package/dist/ios/preview.js +133 -0
  43. package/dist/ios/styles.css +77 -0
  44. package/dist/layout/index.d.ts +280 -0
  45. package/dist/layout/index.js +2 -0
  46. package/dist/meta-6dUzgC6M.d.ts +205 -0
  47. package/dist/patterns/index.d.ts +36 -0
  48. package/dist/patterns/index.js +934 -0
  49. package/dist/presets/index.d.ts +37 -0
  50. package/dist/presets/index.js +117 -0
  51. package/dist/registry-CyL28OzJ.d.ts +28 -0
  52. package/dist/styles.css +1893 -0
  53. package/dist/theme-provider-BWcizhAt.d.ts +105 -0
  54. package/dist/themes/atelier/atelier.css +213 -0
  55. package/dist/themes/atelier/index.d.ts +9 -0
  56. package/dist/themes/atelier/index.js +1 -0
  57. package/dist/themes/base/base.css +443 -0
  58. package/dist/themes/base/index.d.ts +9 -0
  59. package/dist/themes/base/index.js +1 -0
  60. package/dist/themes/ember/ember.css +270 -0
  61. package/dist/themes/ember/index.d.ts +9 -0
  62. package/dist/themes/ember/index.js +1 -0
  63. package/dist/themes/index.d.ts +10 -0
  64. package/dist/themes/index.js +8 -0
  65. package/dist/themes/meridian/index.d.ts +9 -0
  66. package/dist/themes/meridian/index.js +1 -0
  67. package/dist/themes/meridian/meridian.css +255 -0
  68. package/dist/themes/poppy/index.d.ts +6 -0
  69. package/dist/themes/poppy/index.js +1 -0
  70. package/dist/themes/poppy/poppy.css +174 -0
  71. package/dist/themes/prism/index.d.ts +6 -0
  72. package/dist/themes/prism/index.js +1 -0
  73. package/dist/themes/prism/prism.css +183 -0
  74. package/dist/themes/verdant/index.d.ts +9 -0
  75. package/dist/themes/verdant/index.js +1 -0
  76. package/dist/themes/verdant/verdant.css +210 -0
  77. package/dist/themes.css +30 -0
  78. package/llms/components/accordion.md +93 -0
  79. package/llms/components/app-header.md +70 -0
  80. package/llms/components/app-shell.md +117 -0
  81. package/llms/components/attachment.md +105 -0
  82. package/llms/components/avatar.md +141 -0
  83. package/llms/components/badge.md +117 -0
  84. package/llms/components/banner.md +119 -0
  85. package/llms/components/bottom-nav.md +80 -0
  86. package/llms/components/breadcrumbs.md +75 -0
  87. package/llms/components/browser.md +67 -0
  88. package/llms/components/bubble.md +119 -0
  89. package/llms/components/button.md +199 -0
  90. package/llms/components/calendar.md +75 -0
  91. package/llms/components/card.md +135 -0
  92. package/llms/components/carousel.md +87 -0
  93. package/llms/components/chat-composer.md +126 -0
  94. package/llms/components/checkbox-card.md +98 -0
  95. package/llms/components/checkbox.md +90 -0
  96. package/llms/components/code-block.md +81 -0
  97. package/llms/components/code.md +62 -0
  98. package/llms/components/collapsible.md +83 -0
  99. package/llms/components/color-field.md +100 -0
  100. package/llms/components/combobox.md +119 -0
  101. package/llms/components/command-menu.md +158 -0
  102. package/llms/components/context-card.md +73 -0
  103. package/llms/components/context-menu.md +79 -0
  104. package/llms/components/data-table.md +99 -0
  105. package/llms/components/date-picker.md +97 -0
  106. package/llms/components/description.md +96 -0
  107. package/llms/components/destructive-action-modal.md +76 -0
  108. package/llms/components/dialog.md +127 -0
  109. package/llms/components/dots-menu.md +64 -0
  110. package/llms/components/empty-state.md +113 -0
  111. package/llms/components/entity.md +67 -0
  112. package/llms/components/feedback.md +63 -0
  113. package/llms/components/fieldset.md +106 -0
  114. package/llms/components/file-dropzone.md +103 -0
  115. package/llms/components/form.md +111 -0
  116. package/llms/components/gliding-highlight.md +84 -0
  117. package/llms/components/inline-status.md +62 -0
  118. package/llms/components/input.md +127 -0
  119. package/llms/components/kbd.md +57 -0
  120. package/llms/components/label.md +73 -0
  121. package/llms/components/marker.md +99 -0
  122. package/llms/components/marquee.md +66 -0
  123. package/llms/components/menu.md +122 -0
  124. package/llms/components/message-scroller.md +110 -0
  125. package/llms/components/message.md +114 -0
  126. package/llms/components/meter.md +124 -0
  127. package/llms/components/middle-truncate.md +62 -0
  128. package/llms/components/note.md +81 -0
  129. package/llms/components/notification-badge.md +67 -0
  130. package/llms/components/number-field.md +70 -0
  131. package/llms/components/otp-field.md +101 -0
  132. package/llms/components/page-header.md +76 -0
  133. package/llms/components/page-loader.md +67 -0
  134. package/llms/components/pagination.md +92 -0
  135. package/llms/components/panel-group.md +120 -0
  136. package/llms/components/panel-section.md +122 -0
  137. package/llms/components/phone.md +70 -0
  138. package/llms/components/pill.md +73 -0
  139. package/llms/components/popover.md +79 -0
  140. package/llms/components/progress.md +98 -0
  141. package/llms/components/radio-card.md +90 -0
  142. package/llms/components/radio.md +86 -0
  143. package/llms/components/relative-time.md +60 -0
  144. package/llms/components/scroller.md +88 -0
  145. package/llms/components/scrub-field.md +97 -0
  146. package/llms/components/search-input.md +84 -0
  147. package/llms/components/select.md +106 -0
  148. package/llms/components/separator.md +70 -0
  149. package/llms/components/settings-menu.md +139 -0
  150. package/llms/components/sheet.md +97 -0
  151. package/llms/components/show-more.md +59 -0
  152. package/llms/components/side-nav.md +106 -0
  153. package/llms/components/sidebar.md +87 -0
  154. package/llms/components/skeleton.md +62 -0
  155. package/llms/components/slider.md +114 -0
  156. package/llms/components/spinner.md +87 -0
  157. package/llms/components/status-bar.md +91 -0
  158. package/llms/components/status-dot.md +70 -0
  159. package/llms/components/stepper.md +125 -0
  160. package/llms/components/streaming-text.md +86 -0
  161. package/llms/components/switch.md +87 -0
  162. package/llms/components/table.md +112 -0
  163. package/llms/components/tabs.md +84 -0
  164. package/llms/components/textarea.md +88 -0
  165. package/llms/components/theme-selector.md +95 -0
  166. package/llms/components/theme-switcher.md +71 -0
  167. package/llms/components/thinking-orb.md +91 -0
  168. package/llms/components/toast.md +130 -0
  169. package/llms/components/toggle.md +95 -0
  170. package/llms/components/toolbar.md +168 -0
  171. package/llms/components/tooltip.md +93 -0
  172. package/llms/components/tree.md +227 -0
  173. package/llms/components/video.md +66 -0
  174. package/llms/components/workspace.md +138 -0
  175. package/llms/foundations/accessibility.md +60 -0
  176. package/llms/foundations/motion.md +96 -0
  177. package/llms/foundations/scrolling.md +41 -0
  178. package/llms/foundations/shape.md +73 -0
  179. package/llms/foundations/spacing.md +82 -0
  180. package/llms/foundations/surfaces.md +69 -0
  181. package/llms/foundations/typography.md +120 -0
  182. package/llms/layout/aspect-box.md +37 -0
  183. package/llms/layout/bleed.md +31 -0
  184. package/llms/layout/center.md +37 -0
  185. package/llms/layout/columns.md +51 -0
  186. package/llms/layout/grid-overlay.md +40 -0
  187. package/llms/layout/grid.md +47 -0
  188. package/llms/layout/page.md +39 -0
  189. package/llms/layout/region.md +49 -0
  190. package/llms/layout/row.md +40 -0
  191. package/llms/layout/safe-area.md +37 -0
  192. package/llms/layout/spacer.md +39 -0
  193. package/llms/layout/split-pane.md +41 -0
  194. package/llms/layout/stack.md +39 -0
  195. package/llms/patterns/auth-page.md +68 -0
  196. package/llms/patterns/brand-landing-page.md +423 -0
  197. package/llms/patterns/chat-panel.md +210 -0
  198. package/llms/patterns/dashboard-page.md +170 -0
  199. package/llms/patterns/feed-page.md +103 -0
  200. package/llms/patterns/landing-page.md +122 -0
  201. package/llms/patterns/list-detail-page.md +100 -0
  202. package/llms/patterns/settings-page.md +121 -0
  203. package/llms/reference/token-intent.md +324 -0
  204. package/llms/reference/utilities.md +129 -0
  205. package/llms/tasks/adding-a-component.md +113 -0
  206. package/llms/tasks/auditing-an-app.md +216 -0
  207. package/llms/tasks/building-a-page.md +109 -0
  208. package/llms/tasks/building-a-tool.md +141 -0
  209. package/llms/tasks/building-an-ios-prototype.md +105 -0
  210. package/llms/tasks/building-with-sable.md +73 -0
  211. package/llms/tasks/choosing-a-component.md +109 -0
  212. package/llms/tasks/converting-an-app.md +139 -0
  213. package/llms/tasks/creating-a-preset.md +43 -0
  214. package/llms/tasks/creating-design-assets.md +96 -0
  215. package/llms/tasks/designing-a-surface.md +115 -0
  216. package/llms/tasks/developing-taste.md +157 -0
  217. package/llms/tasks/getting-started.md +63 -0
  218. package/llms/tasks/learning-from-builds.md +74 -0
  219. package/llms/tasks/reviewing-a-design.md +114 -0
  220. package/llms/tasks/reviewing-a-journey.md +84 -0
  221. package/llms/tasks/reviewing-motion.md +92 -0
  222. package/llms/tasks/using-presets.md +87 -0
  223. package/llms/taste/asset-plan.template.json +53 -0
  224. package/llms/taste/composition-follows-content.md +20 -0
  225. package/llms/taste/hierarchy-is-a-relationship.md +22 -0
  226. package/llms/taste/lessons.json +57 -0
  227. package/llms/taste/motion-needs-a-reason.md +21 -0
  228. package/llms/taste/surface-states-travel-together.md +20 -0
  229. package/llms-full.txt +14906 -0
  230. package/llms.txt +405 -0
  231. package/package.json +185 -3
  232. package/registry.json +18812 -0
  233. package/scripts/agent-setup.mjs +54 -0
  234. package/scripts/design-atlas.mjs +221 -0
  235. package/scripts/design-directions.mjs +123 -0
  236. package/scripts/design-memory.mjs +166 -0
  237. package/scripts/design-preferences.mjs +227 -0
  238. package/scripts/design-presets.mjs +48 -0
  239. package/scripts/grade.mjs +509 -0
  240. package/scripts/lint-rules.mjs +1065 -0
  241. package/scripts/review-evidence.mjs +120 -0
  242. package/scripts/skeleton.mjs +248 -0
  243. package/skills/entry/SKILL.md +34 -0
  244. package/skills/sable/SKILL.md +187 -0
  245. package/skills/sable/agents/openai.yaml +6 -0
  246. package/targets/ios.json +982 -0
  247. package/third-party/reicon-LICENSE.txt +20 -0
package/bin/sable.mjs ADDED
@@ -0,0 +1,1223 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * sable — the catalog, served at the size an agent can actually read.
4
+ *
5
+ * npx sable pattern --list # the eight page shells, one line each
6
+ * npx sable pattern DashboardPage --skeleton
7
+ * npx sable component Button --props
8
+ * npx sable search "date range"
9
+ * npx sable doctor
10
+ *
11
+ * Why this exists: `registry.json` is 780KB and `llms-full.txt` is 550KB. Both
12
+ * are complete and both are unreadable in a working context window, so in
13
+ * practice an agent greps them, or skips them and writes what its training data
14
+ * suggests — which for a private library is nothing. The contract being
15
+ * published is not the same as the contract being read.
16
+ *
17
+ * So the same generated artifacts get a retrieval surface: ask for one
18
+ * component, one pattern, one token group, at one of three detail levels. It
19
+ * runs from `node_modules`, offline, and it is versioned with the package it
20
+ * describes — a hosted docs site can drift from your installed version, and an
21
+ * MCP endpoint cannot serve a private package to a machine that has no token.
22
+ *
23
+ * Three rules this file keeps:
24
+ * 1. **Everything is derivable.** `--json` emits `{ type, data }` with a
25
+ * stable discriminator, so output can be consumed without scraping prose.
26
+ * 2. **Failures are recoverable.** Every error carries a stable `code` and
27
+ * concrete `suggestions`. An agent that misspells a component name should
28
+ * be able to fix itself from the error rather than fall back to guessing.
29
+ * 3. **`manifest` is generated from the same table that drives `--help`.** A
30
+ * hand-written capability list is a capability list that lies.
31
+ */
32
+
33
+ import { execFileSync } from "node:child_process"
34
+ import { appendFileSync, existsSync, readdirSync, readFileSync, statSync } from "node:fs"
35
+ import { dirname, join, relative, resolve } from "node:path"
36
+ import { fileURLToPath } from "node:url"
37
+ import { skeletonize } from "../scripts/skeleton.mjs"
38
+ import { installAgentSkill } from "../scripts/agent-setup.mjs"
39
+
40
+ const HERE = dirname(fileURLToPath(import.meta.url))
41
+ const PKG_ROOT = join(HERE, "..")
42
+ const CWD = process.cwd()
43
+
44
+ /* ── The catalog. ───────────────────────────────────────────────────────────── */
45
+
46
+ const registryPath = join(PKG_ROOT, "registry.json")
47
+ if (!existsSync(registryPath)) {
48
+ process.stderr.write(
49
+ JSON.stringify({
50
+ type: "error",
51
+ code: "ERR_REGISTRY_MISSING",
52
+ message: `No registry.json inside ${PKG_ROOT}. The installed @sableui/react is incomplete.`,
53
+ suggestions: ["Reinstall @sableui/react", "Report this — registry.json is in the published files list"],
54
+ }) + "\n",
55
+ )
56
+ process.exit(1)
57
+ }
58
+ const R = JSON.parse(readFileSync(registryPath, "utf8"))
59
+ const LLMS_DIR = join(PKG_ROOT, "llms")
60
+
61
+ /* ── The command table. Drives --help, `manifest`, and dispatch. ────────────── */
62
+
63
+ const DETAILS = ["brief", "compact", "full"]
64
+
65
+ const GLOBAL_FLAGS = [
66
+ { name: "--json", value: null, describe: "Emit a { type, data } envelope instead of text." },
67
+ { name: "--detail", value: DETAILS.join("|"), describe: "How much to print. Default: compact (brief for lists)." },
68
+ { name: "--dense", value: null, describe: "Drop prose, one fact per line. Token-efficient." },
69
+ { name: "--help", value: null, describe: "Print usage for the command." },
70
+ ]
71
+
72
+ const COMMANDS = [
73
+ {
74
+ name: "target",
75
+ summary: "Retrieve an optional platform's Sable component replacements and composition rules.",
76
+ arg: { name: "platform", required: true },
77
+ flags: [{ name: "--component", value: "Name", describe: "Retrieve one Sable-to-platform mapping. Omit for the whole map." }],
78
+ returns: ["target", "target.component"],
79
+ },
80
+ {
81
+ name: "preset",
82
+ summary: "Create, validate, or compile a portable Sable preset used by Canvas and builds.",
83
+ arg: null,
84
+ flags: [
85
+ { name: "--template", value: null, describe: "Print a complete editable Base preset JSON record." },
86
+ { name: "--check", value: "file.json", describe: "Validate the schema and Sable token contract." },
87
+ { name: "--css", value: "file.json", describe: "Generate scoped theme CSS from a validated preset." },
88
+ ],
89
+ returns: ["preset.template", "preset.check", "preset.css"],
90
+ },
91
+ {
92
+ name: "setup",
93
+ summary: "Install package-backed skill entry files for coding agents, preserving local edits.",
94
+ arg: null,
95
+ flags: [
96
+ { name: "--agent", value: null, describe: "Install skill entry files. Required." },
97
+ { name: "--target", value: "all|codex|claude|cursor", describe: "Host discovery directories. Default all; Codex and Cursor share .agents." },
98
+ { name: "--update", value: null, describe: "Refresh unchanged managed entries after a package upgrade." },
99
+ { name: "--dry-run", value: null, describe: "Check and report the destination without writing files." },
100
+ ],
101
+ returns: ["setup.agent"],
102
+ },
103
+ {
104
+ name: "manifest",
105
+ summary: "This CLI's own capability manifest — commands, flags, response types, error codes.",
106
+ arg: null,
107
+ flags: [],
108
+ returns: ["manifest"],
109
+ },
110
+ {
111
+ name: "search",
112
+ summary: "One ranked result set across components, patterns, layout primitives, utilities, tokens, and docs.",
113
+ arg: { name: "query", required: true },
114
+ flags: [
115
+ { name: "--type", value: "component|pattern|layout|utility|token|doc", describe: "Restrict to one kind." },
116
+ { name: "--limit", value: "n", describe: "Cap results. Default 20." },
117
+ ],
118
+ returns: ["search"],
119
+ },
120
+ {
121
+ name: "component",
122
+ summary: "A component's contract: props, subcomponents, a11y, tokens, examples.",
123
+ arg: { name: "Name", required: false },
124
+ flags: [
125
+ { name: "--list", value: null, describe: "List the catalog instead." },
126
+ { name: "--group", value: "Group", describe: "With --list: filter by taxonomy group." },
127
+ { name: "--register", value: "page|workspace|either", describe: "With --list: filter by register." },
128
+ { name: "--props", value: null, describe: "Props table only." },
129
+ { name: "--examples", value: null, describe: "Examples only, imports included." },
130
+ { name: "--a11y", value: null, describe: "Accessibility contract only." },
131
+ ],
132
+ returns: ["component.list", "component.detail", "component.props", "component.examples", "component.a11y"],
133
+ },
134
+ {
135
+ name: "pattern",
136
+ summary: "Canonical page shells. Read the skeleton first, copy the code second.",
137
+ arg: { name: "Name", required: false },
138
+ flags: [
139
+ { name: "--list", value: null, describe: "List the patterns instead." },
140
+ { name: "--skeleton", value: null, describe: "Composition tree only — no fixtures, no copy." },
141
+ { name: "--code", value: null, describe: "Full source with its import line." },
142
+ ],
143
+ returns: ["pattern.list", "pattern.detail", "pattern.skeleton", "pattern.code"],
144
+ },
145
+ {
146
+ name: "layout",
147
+ summary: "Layout primitives and the layout doctrine.",
148
+ arg: { name: "Name", required: false },
149
+ flags: [
150
+ { name: "--list", value: null, describe: "List the primitives instead." },
151
+ { name: "--doctrine", value: null, describe: "The layout rules only." },
152
+ ],
153
+ returns: ["layout.list", "layout.detail", "layout.doctrine"],
154
+ },
155
+ {
156
+ name: "token",
157
+ summary: "The token contract, with intent — what each token means and what it is NOT.",
158
+ arg: { name: "group", required: false },
159
+ flags: [{ name: "--list", value: null, describe: "List the groups." }],
160
+ returns: ["token.list", "token.group"],
161
+ },
162
+ {
163
+ name: "utility",
164
+ summary: "The CSS utility layer, with intent — relative surfaces, frames, state variants, scroll policies — and what each replaces.",
165
+ arg: { name: "name", required: false },
166
+ flags: [
167
+ { name: "--list", value: null, describe: "List the utilities, grouped by family." },
168
+ { name: "--family", value: "family", describe: "With --list: one family only (e.g. \"relative surface\")." },
169
+ ],
170
+ returns: ["utility.list", "utility.detail"],
171
+ },
172
+ {
173
+ name: "docs",
174
+ summary: "The llms/ markdown mirrors: per-component pages, task walkthroughs, token intent.",
175
+ arg: { name: "slug", required: false },
176
+ flags: [
177
+ { name: "--list", value: null, describe: "List every doc slug." },
178
+ { name: "--kind", value: "components|layout|patterns|reference|tasks|taste", describe: "With --list: one kind." },
179
+ ],
180
+ returns: ["docs.list", "docs.detail"],
181
+ },
182
+ {
183
+ name: "doctor",
184
+ summary: "Check this project's Sable wiring. Exits 1 on any failure.",
185
+ arg: null,
186
+ flags: [],
187
+ returns: ["doctor"],
188
+ },
189
+ {
190
+ name: "grade",
191
+ summary: "Static contract checks and evidence review. Original compositions carry no penalty; no automatic taste score.",
192
+ arg: { name: "dir...", required: false },
193
+ flags: [
194
+ { name: "--template", value: null, describe: "Print the review evidence JSON template." },
195
+ { name: "--review", value: "review.json", describe: "Check captures, observations, iterations, and the source fingerprint." },
196
+ { name: "--strict", value: null, describe: "Exit 1 for contract findings or missing/incomplete/unresolved review evidence." },
197
+ { name: "--cli-log", value: "file.jsonl", describe: "Include retrieval usage from a SABLE_CLI_LOG file." },
198
+ ],
199
+ returns: ["grade.score", "grade.template"],
200
+ },
201
+ {
202
+ name: "learn",
203
+ summary: "Retrieve design lessons and record evidence-backed lessons in this project's .sable directory.",
204
+ arg: null,
205
+ flags: [
206
+ { name: "--search", value: "query", describe: "Search packaged and local design lessons." },
207
+ { name: "--list", value: null, describe: "List available design lessons." },
208
+ { name: "--show", value: "id", describe: "Read one lesson and its provenance." },
209
+ { name: "--template", value: null, describe: "Print a lesson JSON template." },
210
+ { name: "--record", value: "file.json", describe: "Validate and save a local lesson with before/after evidence." },
211
+ { name: "--check", value: null, describe: "Validate the local lesson collection." },
212
+ ],
213
+ returns: ["learn.list", "learn.detail", "learn.template", "learn.recorded", "learn.check"],
214
+ },
215
+ {
216
+ name: "lint",
217
+ summary: "Run the design contract against this project (delegates to sable-lint).",
218
+ arg: { name: "dir...", required: false },
219
+ flags: [{ name: "--rules", value: null, describe: "List the rules." }],
220
+ returns: ["(passthrough)"],
221
+ },
222
+ {
223
+ name: "atlas",
224
+ summary: "Retrieve annotated visual references with viewable evidence and contrasting design approaches.",
225
+ arg: null,
226
+ flags: [
227
+ { name: "--list", value: null, describe: "List the packaged visual atlas." },
228
+ { name: "--search", value: "query", describe: "Find references by audience, task, material, or composition." },
229
+ { name: "--show", value: "id", describe: "Read one reference and verified absolute capture paths." },
230
+ { name: "--mode", value: "persuade|operate|read|experience", describe: "Restrict to a visitor's task mode." },
231
+ { name: "--family", value: "slug", describe: "Restrict to a design family." },
232
+ { name: "--limit", value: "n", describe: "Cap results." },
233
+ { name: "--contrast", value: null, describe: "Diversify relevant results across design families." },
234
+ { name: "--check", value: null, describe: "Verify the entire atlas schema, capture paths, and hashes." },
235
+ ],
236
+ returns: ["atlas.list", "atlas.detail", "atlas.check"],
237
+ },
238
+ {
239
+ name: "prefer",
240
+ summary: "Remember contextual design comparisons, with human and agent judgments kept distinct.",
241
+ arg: null,
242
+ flags: [
243
+ { name: "--template", value: null, describe: "Print a preference record template." },
244
+ { name: "--record", value: "file.json", describe: "Save an immutable comparison and pin its evidence." },
245
+ { name: "--list", value: null, describe: "List recorded comparisons; human records by default." },
246
+ { name: "--search", value: "query", describe: "Search judgments within an explicit scope." },
247
+ { name: "--scope", value: "kind:value", describe: "Exact personal, project, brand, or task-type context." },
248
+ { name: "--reviewer", value: "human|agent|all", describe: "Include declared human choices or agent observations." },
249
+ { name: "--check", value: null, describe: "Validate all stored comparisons and evidence." },
250
+ ],
251
+ returns: ["prefer.list", "prefer.template", "prefer.recorded", "prefer.check"],
252
+ },
253
+ {
254
+ name: "explore",
255
+ summary: "Validate three rendered directions and create a local anonymous comparison with preference export.",
256
+ arg: null,
257
+ flags: [
258
+ { name: "--template", value: null, describe: "Print a brief and three-direction manifest template." },
259
+ { name: "--check", value: "file.json", describe: "Check structural variation and desktop/mobile/interaction evidence." },
260
+ { name: "--compare", value: "file.json", describe: "Create a local comparison HTML page in .sable/comparisons." },
261
+ ],
262
+ returns: ["explore.template", "explore.check", "explore.comparison"],
263
+ },
264
+ ]
265
+
266
+ const ERROR_CODES = [
267
+ { code: "ERR_TARGET_MISSING", when: "The installed package is missing its optional target mapping artifact." },
268
+ { code: "ERR_PRESET_INVALID", when: "A preset is missing required metadata, semantic values, or uses unsupported tokens or CSS." },
269
+ { code: "ERR_AGENT_SETUP", when: "The packaged skill is missing, the destination is linked, or existing local edits would be overwritten." },
270
+ { code: "ERR_UNKNOWN_COMMAND", when: "The first argument is not a command." },
271
+ { code: "ERR_MISSING_ARGUMENT", when: "A command that requires an argument got none." },
272
+ { code: "ERR_UNKNOWN_COMPONENT", when: "No component by that name. `suggestions` carries near matches." },
273
+ { code: "ERR_UNKNOWN_PATTERN", when: "No pattern by that name." },
274
+ { code: "ERR_UNKNOWN_LAYOUT", when: "No layout primitive by that name." },
275
+ { code: "ERR_UNKNOWN_TOKEN_GROUP", when: "No token group by that name." },
276
+ { code: "ERR_UNKNOWN_UTILITY", when: "No utility or variant by that name. `suggestions` carries near matches." },
277
+ { code: "ERR_UNKNOWN_DOC", when: "No doc mirror by that slug." },
278
+ { code: "ERR_BAD_FLAG", when: "A flag value is outside its allowed set." },
279
+ { code: "ERR_NO_RESULTS", when: "A search matched nothing." },
280
+ { code: "ERR_REGISTRY_MISSING", when: "The installed package has no registry.json." },
281
+ { code: "ERR_GRADE_INPUT", when: "The grade input path, flag, or review JSON cannot be read." },
282
+ { code: "ERR_LESSON_INVALID", when: "A design lesson is missing required observations or evidence." },
283
+ { code: "ERR_LESSON_EXISTS", when: "Recording a design lesson would overwrite an existing lesson." },
284
+ { code: "ERR_UNKNOWN_LESSON", when: "No packaged or local design lesson has the requested id." },
285
+ { code: "ERR_LESSON_IO", when: "A design lesson file cannot be read or written." },
286
+ { code: "ERR_ATLAS_INVALID", when: "An atlas annotation, capture path, or artifact hash fails validation." },
287
+ { code: "ERR_ATLAS_IO", when: "The installed visual atlas cannot be read." },
288
+ { code: "ERR_UNKNOWN_REFERENCE", when: "No visual reference has the requested id." },
289
+ { code: "ERR_UNKNOWN_FAMILY", when: "The requested visual family is not present in the atlas." },
290
+ { code: "ERR_BAD_ARGUMENT", when: "An atlas mode or limit has an invalid value." },
291
+ { code: "ERR_PREFERENCE_INVALID", when: "A preference, scope, provenance, artifact, or record operation fails validation." },
292
+ { code: "ERR_EXPLORATION_INVALID", when: "A direction specification, artifact, or comparison operation fails validation." },
293
+ ]
294
+
295
+ /* ── Args. ──────────────────────────────────────────────────────────────────── */
296
+
297
+ const argv = process.argv.slice(2)
298
+ const flags = new Map()
299
+ const positional = []
300
+ for (let i = 0; i < argv.length; i++) {
301
+ const a = argv[i]
302
+ if (!a.startsWith("--")) {
303
+ positional.push(a)
304
+ continue
305
+ }
306
+ const eq = a.indexOf("=")
307
+ if (eq !== -1) flags.set(a.slice(2, eq), a.slice(eq + 1))
308
+ else if (argv[i + 1] && !argv[i + 1].startsWith("--")) flags.set(a.slice(2), argv[++i])
309
+ else flags.set(a.slice(2), true)
310
+ }
311
+
312
+ const JSON_OUT = flags.has("json")
313
+ const DENSE = flags.has("dense")
314
+ const [command, ...rest] = positional
315
+
316
+ /* ── Usage log. Opt-in, off unless SABLE_CLI_LOG names a file. ──────────────────
317
+ *
318
+ * Why: when generated code comes out wrong, "the agent ignored the CLI" and "the
319
+ * agent used the CLI and it did not help" look identical from the outside, and
320
+ * they have opposite fixes — one is a placement problem, the other a content
321
+ * problem. Without a record there is no way to tell them apart, so every
322
+ * conclusion about whether this retrieval surface earns its keep is a guess.
323
+ *
324
+ * One JSONL line per invocation. No network, no identifiers, no arguments beyond
325
+ * the command and flag NAMES — a search query can carry a consumer's product
326
+ * details, so values are deliberately not recorded. Failures to write are
327
+ * swallowed: telemetry must never be able to break the tool it observes.
328
+ */
329
+ const LOG_PATH = process.env.SABLE_CLI_LOG
330
+ const START = Date.now()
331
+ function logInvocation(exitCode, errorCode) {
332
+ if (!LOG_PATH) return
333
+ try {
334
+ appendFileSync(
335
+ LOG_PATH,
336
+ `${JSON.stringify({
337
+ ts: new Date().toISOString(),
338
+ version: R.version,
339
+ command: command ?? null,
340
+ flags: [...flags.keys()].sort(),
341
+ detail: flags.get("detail") ?? null,
342
+ json: JSON_OUT,
343
+ exitCode,
344
+ ...(errorCode ? { errorCode } : null),
345
+ ms: Date.now() - START,
346
+ })}\n`,
347
+ )
348
+ } catch {
349
+ /* A log that breaks the CLI is worse than no log. */
350
+ }
351
+ }
352
+
353
+ /* ── Output. Every success is `{ type, data }`; every failure carries a code. ── */
354
+
355
+ function ok(type, data, text) {
356
+ if (JSON_OUT) process.stdout.write(`${JSON.stringify({ type, data }, null, DENSE ? 0 : 1)}\n`)
357
+ else process.stdout.write(`${typeof text === "function" ? text() : text}\n`)
358
+ logInvocation(0)
359
+ process.exit(0)
360
+ }
361
+
362
+ function fail(code, message, suggestions = []) {
363
+ if (JSON_OUT) {
364
+ process.stdout.write(`${JSON.stringify({ type: "error", code, message, suggestions })}\n`)
365
+ } else {
366
+ process.stderr.write(`[sable] ${code}: ${message}\n`)
367
+ for (const s of suggestions) process.stderr.write(` → ${s}\n`)
368
+ }
369
+ logInvocation(1, code)
370
+ process.exit(1)
371
+ }
372
+
373
+ /**
374
+ * Near matches for an unknown name. This is the difference between an error an
375
+ * agent can act on and one it can only retry: "no component named `Buton`" sends
376
+ * it back to guessing, "did you mean Button" ends the loop in one step.
377
+ */
378
+ function nearest(name, candidates, max = 3) {
379
+ const q = name.toLowerCase()
380
+ const dist = (a, b) => {
381
+ const rows = [Array.from({ length: b.length + 1 }, (_, i) => i)]
382
+ for (let i = 1; i <= a.length; i++) {
383
+ rows[i] = [i]
384
+ for (let j = 1; j <= b.length; j++) {
385
+ rows[i][j] = Math.min(
386
+ rows[i - 1][j] + 1,
387
+ rows[i][j - 1] + 1,
388
+ rows[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
389
+ )
390
+ }
391
+ }
392
+ return rows[a.length][b.length]
393
+ }
394
+ return candidates
395
+ .map((c) => ({ c, d: c.toLowerCase().includes(q) || q.includes(c.toLowerCase()) ? 0 : dist(q, c.toLowerCase()) }))
396
+ .filter((x) => x.d <= Math.max(3, Math.floor(q.length / 2)))
397
+ .sort((a, b) => a.d - b.d)
398
+ .slice(0, max)
399
+ .map((x) => x.c)
400
+ }
401
+
402
+ function detailLevel(fallback = "compact") {
403
+ const v = flags.get("detail")
404
+ if (v === undefined || v === true) return fallback
405
+ if (!DETAILS.includes(v)) fail("ERR_BAD_FLAG", `--detail must be one of ${DETAILS.join(", ")}.`, DETAILS)
406
+ return v
407
+ }
408
+
409
+ const bullet = (s) => ` • ${s}`
410
+ const section = (title, lines) => (lines.length ? `\n${DENSE ? title : `${title}:`}\n${lines.join("\n")}` : "")
411
+
412
+ /* ── Catalog accessors. ─────────────────────────────────────────────────────── */
413
+
414
+ const COMPONENTS = R.components ?? []
415
+ const PATTERNS = R.patterns?.items ?? []
416
+ const PRIMITIVES = R.layoutPrimitives?.primitives ?? []
417
+ const TOKEN_GROUPS = Object.keys(R.tokens ?? {})
418
+ const UTILITIES = R.utilities?.items ?? []
419
+
420
+ const importLine = (name) => `import { ${name} } from "@sableui/react"`
421
+
422
+ function findComponent(name) {
423
+ const hit = COMPONENTS.find((c) => c.name.toLowerCase() === name.toLowerCase())
424
+ if (hit) return hit
425
+ fail(
426
+ "ERR_UNKNOWN_COMPONENT",
427
+ `No component named "${name}".`,
428
+ [
429
+ ...nearest(name, COMPONENTS.map((c) => c.name)).map((n) => `Did you mean ${n}?`),
430
+ `sable search "${name}" — searches summaries and keywords too`,
431
+ "sable component --list",
432
+ ],
433
+ )
434
+ }
435
+
436
+ /* ── manifest ───────────────────────────────────────────────────────────────── */
437
+
438
+ function cmdManifest() {
439
+ const data = {
440
+ name: "sable",
441
+ version: R.version,
442
+ package: R.name,
443
+ describes: { components: COMPONENTS.length, patterns: PATTERNS.length, layoutPrimitives: PRIMITIVES.length },
444
+ envelope: { success: "{ type, data }", error: "{ type: 'error', code, message, suggestions }" },
445
+ globalFlags: GLOBAL_FLAGS,
446
+ commands: COMMANDS,
447
+ errorCodes: ERROR_CODES,
448
+ }
449
+ ok("manifest", data, () =>
450
+ [
451
+ `sable ${R.version} — ${COMPONENTS.length} components, ${PATTERNS.length} patterns, ${PRIMITIVES.length} layout primitives.`,
452
+ "",
453
+ "Commands:",
454
+ ...COMMANDS.map((c) => ` ${(c.name + (c.arg ? ` <${c.arg.name}>` : "")).padEnd(22)}${c.summary}`),
455
+ "",
456
+ "Global flags:",
457
+ ...GLOBAL_FLAGS.map((f) => ` ${(f.name + (f.value ? ` <${f.value}>` : "")).padEnd(32)}${f.describe}`),
458
+ "",
459
+ "Machine-readable: sable manifest --json",
460
+ ].join("\n"),
461
+ )
462
+ }
463
+
464
+ /* ── search ─────────────────────────────────────────────────────────────────── */
465
+
466
+ function cmdSearch() {
467
+ const query = rest.join(" ").trim()
468
+ if (!query) fail("ERR_MISSING_ARGUMENT", "search needs a query.", ['sable search "date range"'])
469
+ const type = flags.get("type")
470
+ const limit = Number(flags.get("limit")) || 20
471
+ const q = query.toLowerCase()
472
+ // Function words match nearly every entry by substring ("on" is inside
473
+ // "background"), so in a phrase like "hover color on a surface" they only add
474
+ // noise. Dropped unless the query is nothing but them.
475
+ const STOPWORDS = new Set(["a", "an", "the", "on", "of", "in", "to", "for", "with", "and", "or", "is", "my"])
476
+ const allTerms = q.split(/\s+/).filter(Boolean)
477
+ const contentTerms = allTerms.filter((t) => !STOPWORDS.has(t))
478
+ const terms = contentTerms.length ? contentTerms : allTerms
479
+
480
+ /** Name hits outrank keyword hits outrank prose hits — an exact name is never buried.
481
+ * A keyword that IS the term (or the whole query) outranks one that merely
482
+ * contains it: `segmented` is what ToggleGroup is called, while the well
483
+ * behind it is only described as a "segmented track". */
484
+ const score = (fields) => {
485
+ let s = 0
486
+ const keywords = (fields.keywords ?? []).map((k) => k.toLowerCase())
487
+ if (terms.length > 1 && keywords.includes(q)) s += 50
488
+ for (const t of terms) {
489
+ if (fields.name.toLowerCase() === t) s += 100
490
+ else if (fields.name.toLowerCase().includes(t)) s += 40
491
+ if (keywords.includes(t)) s += 30
492
+ else if (keywords.some((k) => k.includes(t))) s += 20
493
+ if ((fields.text ?? "").toLowerCase().includes(t)) s += 5
494
+ }
495
+ return s
496
+ }
497
+
498
+ const pool = []
499
+ if (!type || type === "component")
500
+ for (const c of COMPONENTS)
501
+ pool.push({ kind: "component", name: c.name, summary: c.summary, status: c.status, group: c.group, _s: score({ name: c.name, keywords: c.keywords, text: `${c.summary} ${c.usage?.description ?? ""}` }) })
502
+ if (!type || type === "pattern")
503
+ for (const p of PATTERNS)
504
+ pool.push({ kind: "pattern", name: p.name, summary: p.summary, _s: score({ name: p.name, text: `${p.summary} ${p.intent ?? ""}` }) })
505
+ if (!type || type === "layout")
506
+ for (const m of PRIMITIVES)
507
+ pool.push({ kind: "layout", name: m.name, summary: m.summary, _s: score({ name: m.name, keywords: m.keywords, text: m.summary }) })
508
+ if (!type || type === "utility")
509
+ for (const u of UTILITIES)
510
+ pool.push({ kind: "utility", name: u.name, summary: u.intent, family: u.family, _s: score({ name: u.name, keywords: u.keywords, text: `${u.intent} ${u.whenToUse}` }) })
511
+ if (!type || type === "token")
512
+ for (const t of R.tokens?.semantic ?? [])
513
+ pool.push({ kind: "token", name: t.name, summary: t.intent, _s: score({ name: t.name, text: `${t.intent} ${t.whenToUse ?? ""}` }) })
514
+ if (!type || type === "doc")
515
+ for (const d of docSlugs())
516
+ pool.push({ kind: "doc", name: d.slug, summary: `${d.kind} mirror`, _s: score({ name: d.slug, text: d.kind }) })
517
+
518
+ // Ties resolve in the order AGENTS.md tells an agent to resolve a need:
519
+ // a component first, then a pattern or primitive, then a utility, then a
520
+ // token. "segmented" should land on ToggleGroup before the well behind it.
521
+ const KIND_ORDER = ["component", "pattern", "layout", "utility", "token", "doc"]
522
+ const hits = pool
523
+ .filter((h) => h._s > 0)
524
+ .sort((a, b) => b._s - a._s || KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind))
525
+ .slice(0, limit)
526
+ if (!hits.length)
527
+ fail("ERR_NO_RESULTS", `Nothing matched "${query}".`, [
528
+ "sable component --list",
529
+ "sable pattern --list",
530
+ "Try a single word — search is term-wise, not phrase-wise",
531
+ ])
532
+
533
+ const results = hits.map(({ _s, ...h }) => ({ ...h, retrieve: `sable ${h.kind === "doc" ? "docs" : h.kind} ${h.name}` }))
534
+ ok("search", { query, count: results.length, results }, () =>
535
+ results
536
+ .map((h) =>
537
+ DENSE
538
+ ? `${h.kind}/${h.name}`
539
+ : `${h.kind.padEnd(9)} ${h.name.padEnd(24)} ${firstSentence(h.summary ?? "")}`,
540
+ )
541
+ .join("\n"),
542
+ )
543
+ }
544
+
545
+ /**
546
+ * First sentence, for one-line list rows. The abbreviation guard is not fussiness:
547
+ * summaries in this catalog say things like "A human relative timestamp (e.g. 2h
548
+ * ago)", and a naive split on the first period renders that row as "A human
549
+ * relative timestamp (e.g." — a listing whose whole job is to be scannable.
550
+ */
551
+ const ABBREV = /(?:^|[\s(])(?:e\.g|i\.e|etc|vs|approx|min|max|no|Dr|Mr|Ms|St|Fig)\.$/i
552
+ function firstSentence(s) {
553
+ const text = (s ?? "").trim()
554
+ for (const m of text.matchAll(/[.!?](?=\s|$)/g)) {
555
+ const head = text.slice(0, m.index + 1)
556
+ if (ABBREV.test(head)) continue
557
+ return head
558
+ }
559
+ return text
560
+ }
561
+
562
+ /* ── component ──────────────────────────────────────────────────────────────── */
563
+
564
+ function cmdComponent() {
565
+ if (flags.has("list") || !rest[0]) {
566
+ let list = COMPONENTS
567
+ const group = flags.get("group")
568
+ const register = flags.get("register")
569
+ if (group) list = list.filter((c) => c.group?.toLowerCase() === String(group).toLowerCase())
570
+ if (register) list = list.filter((c) => c.register === register || c.register === "either")
571
+ const level = detailLevel("brief")
572
+ const data = list.map((c) => ({
573
+ name: c.name,
574
+ status: c.status,
575
+ group: c.group,
576
+ register: c.register,
577
+ ...(level === "brief" ? {} : { summary: c.summary }),
578
+ }))
579
+ return ok("component.list", { count: data.length, components: data }, () =>
580
+ [
581
+ DENSE ? `${data.length}` : `${data.length} components${group ? ` in ${group}` : ""}${register ? ` for the ${register} register` : ""}:\n`,
582
+ ...data.map((c) =>
583
+ DENSE
584
+ ? `${c.name} ${c.group} ${c.register}${c.status === "deprecated" ? " DEPRECATED" : ""}`
585
+ : ` ${c.name.padEnd(24)}${(c.group ?? "").padEnd(14)}${c.register}${c.status === "deprecated" ? " ⚠ deprecated" : ""}${c.summary ? `\n ${firstSentence(c.summary)}` : ""}`,
586
+ ),
587
+ ].join("\n"),
588
+ )
589
+ }
590
+
591
+ const c = findComponent(rest[0])
592
+
593
+ if (flags.has("props"))
594
+ return ok("component.props", { name: c.name, props: c.props ?? [], subcomponents: c.subcomponents ?? [] }, () =>
595
+ renderProps(c),
596
+ )
597
+ if (flags.has("examples"))
598
+ return ok("component.examples", { name: c.name, examples: c.examples ?? [] }, () => renderExamples(c))
599
+ if (flags.has("a11y")) return ok("component.a11y", { name: c.name, a11y: c.a11y ?? [] }, () => (c.a11y ?? []).map(bullet).join("\n"))
600
+
601
+ const level = detailLevel("compact")
602
+ const data = {
603
+ name: c.name,
604
+ status: c.status,
605
+ group: c.group,
606
+ register: c.register,
607
+ import: importLine(c.name),
608
+ summary: c.summary,
609
+ }
610
+ if (level !== "brief") {
611
+ data.props = c.props ?? []
612
+ data.subcomponents = c.subcomponents ?? []
613
+ data.related = c.related ?? []
614
+ if (!DENSE) data.usage = c.usage
615
+ }
616
+ if (level === "full") {
617
+ data.a11y = c.a11y ?? []
618
+ data.tokensUsed = c.tokensUsed ?? []
619
+ data.motion = c.motion
620
+ data.examples = c.examples ?? []
621
+ }
622
+
623
+ ok("component.detail", data, () =>
624
+ [
625
+ `${c.name} [${c.status}] ${c.group} register:${c.register}`,
626
+ c.summary,
627
+ `\n${data.import}`,
628
+ level === "brief" ? "" : renderProps(c),
629
+ level !== "full" || DENSE ? "" : renderUsage(c),
630
+ level === "full" ? section("a11y", (c.a11y ?? []).map(bullet)) : "",
631
+ level === "full" ? section("tokens", [` ${(c.tokensUsed ?? []).join(", ")}`]) : "",
632
+ level === "full" ? renderExamples(c) : "",
633
+ level === "brief" ? "" : `\nMore: sable component ${c.name} --detail full · sable docs ${c.name}`,
634
+ ]
635
+ .filter(Boolean)
636
+ .join("\n"),
637
+ )
638
+ }
639
+
640
+ function renderProps(c) {
641
+ const props = c.props ?? []
642
+ const lines = props.map((p) =>
643
+ DENSE
644
+ ? ` ${p.name}:${p.type}${p.default ? `=${p.default}` : ""}`
645
+ : ` ${p.name.padEnd(18)}${p.type}${p.default ? ` (default ${p.default})` : ""}${p.description ? `\n ${p.description}` : ""}`,
646
+ )
647
+ const subs = (c.subcomponents ?? []).map((s) =>
648
+ typeof s === "string" ? bullet(s) : bullet(`${s.name}${s.description ? ` — ${s.description}` : ""}`),
649
+ )
650
+ return [section("props", lines), section("subcomponents", subs)].filter(Boolean).join("\n")
651
+ }
652
+
653
+ function renderUsage(c) {
654
+ if (!c.usage) return ""
655
+ const dos = (c.usage.bestPractices ?? []).map((b) => ` ${b.guidance === "do" ? "✓" : "✗"} ${b.description}`)
656
+ return [c.usage.description ? `\nWhen to use:\n ${c.usage.description}` : "", section("guidance", dos)]
657
+ .filter(Boolean)
658
+ .join("\n")
659
+ }
660
+
661
+ function renderExamples(c) {
662
+ const ex = c.examples ?? []
663
+ if (!ex.length) return ""
664
+ return `\nExamples:\n${ex
665
+ .map((e) => `\n ── ${e.label}\n ${e.imports}\n${e.code.split("\n").map((l) => ` ${l}`).join("\n")}`)
666
+ .join("\n")}`
667
+ }
668
+
669
+ const slugify = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
670
+
671
+ /**
672
+ * The doc mirror's slug for a catalog entry. It comes from the entry's PATH, not
673
+ * its name, and those disagree: `ErrorState` lives in `components/error`, so its
674
+ * page is `error.md`. Deriving the slug from the name looks right on 109 of 110
675
+ * components and 404s on the one that matters — exactly the shape of bug an agent
676
+ * cannot diagnose, because the name it was given is correct.
677
+ */
678
+ function docSlugFor(name) {
679
+ const entry =
680
+ COMPONENTS.find((c) => c.name.toLowerCase() === name.toLowerCase()) ??
681
+ PRIMITIVES.find((m) => m.name.toLowerCase() === name.toLowerCase()) ??
682
+ PATTERNS.find((p) => p.name.toLowerCase() === name.toLowerCase())
683
+ if (!entry?.path) return slugify(name)
684
+ return entry.path.split("/").pop().replace(/\.tsx$/, "")
685
+ }
686
+
687
+ /* ── pattern ────────────────────────────────────────────────────────────────── */
688
+
689
+ function cmdPattern() {
690
+ if (flags.has("list") || !rest[0]) {
691
+ const data = PATTERNS.map((p) => ({ name: p.name, summary: p.summary, intent: p.intent }))
692
+ return ok("pattern.list", { count: data.length, about: R.patterns?.about, patterns: data }, () =>
693
+ [
694
+ DENSE ? "" : `${data.length} canonical page shells. Read a skeleton before composing anything:\n`,
695
+ ...data.map((p) =>
696
+ DENSE
697
+ ? `${p.name} ${firstSentence(p.summary)}`
698
+ : ` ${p.name.padEnd(20)}${firstSentence(p.intent ?? p.summary)}`,
699
+ ),
700
+ DENSE ? "" : "\n sable pattern <Name> --skeleton the composition tree, ~25 lines",
701
+ ]
702
+ .filter(Boolean)
703
+ .join("\n"),
704
+ )
705
+ }
706
+
707
+ const name = rest[0]
708
+ const p = PATTERNS.find((x) => x.name.toLowerCase() === name.toLowerCase())
709
+ if (!p)
710
+ fail("ERR_UNKNOWN_PATTERN", `No pattern named "${name}".`, [
711
+ ...nearest(name, PATTERNS.map((x) => x.name)).map((n) => `Did you mean ${n}?`),
712
+ "sable pattern --list",
713
+ ])
714
+
715
+ if (flags.has("skeleton")) {
716
+ const { tree, elided } = skeletonize(p.code)
717
+ return ok("pattern.skeleton", { name: p.name, tree, elidedFixtures: elided }, () =>
718
+ [
719
+ DENSE ? "" : `${p.name} — composition tree${elided ? ` (${elided} demo fixtures elided)` : ""}\n`,
720
+ tree,
721
+ DENSE ? "" : `\nFull source: sable pattern ${p.name} --code`,
722
+ ]
723
+ .filter(Boolean)
724
+ .join("\n"),
725
+ )
726
+ }
727
+ if (flags.has("code"))
728
+ return ok("pattern.code", { name: p.name, imports: p.imports, code: p.code }, () => `${p.imports}\n\n${p.code}`)
729
+
730
+ const level = detailLevel("compact")
731
+ const data = { name: p.name, path: p.path, summary: p.summary, intent: p.intent }
732
+ if (level !== "brief") {
733
+ data.notes = p.notes ?? []
734
+ data.a11y = p.a11y ?? []
735
+ data.skeleton = skeletonize(p.code).tree
736
+ }
737
+ if (level === "full") {
738
+ data.imports = p.imports
739
+ data.code = p.code
740
+ }
741
+ ok("pattern.detail", data, () =>
742
+ [
743
+ `${p.name} (${p.path})`,
744
+ p.summary,
745
+ p.intent ? `\nReach for it when:\n ${p.intent}` : "",
746
+ level === "brief" ? "" : section("notes", (p.notes ?? []).map(bullet)),
747
+ level === "brief" ? "" : section("a11y", (p.a11y ?? []).map(bullet)),
748
+ level === "brief" ? "" : `\nComposition:\n${data.skeleton}`,
749
+ level === "full" ? `\n${p.imports}\n\n${p.code}` : `\nFull source: sable pattern ${p.name} --code`,
750
+ ]
751
+ .filter(Boolean)
752
+ .join("\n"),
753
+ )
754
+ }
755
+
756
+ /* ── layout ─────────────────────────────────────────────────────────────────── */
757
+
758
+ function cmdLayout() {
759
+ const doctrine = R.layoutPrimitives?.doctrine ?? []
760
+ if (flags.has("doctrine")) return ok("layout.doctrine", { doctrine }, () => doctrine.map(bullet).join("\n"))
761
+
762
+ if (flags.has("list") || !rest[0]) {
763
+ const data = PRIMITIVES.map((m) => ({ name: m.name, status: m.status, summary: m.summary }))
764
+ return ok("layout.list", { count: data.length, doctrine, primitives: data }, () =>
765
+ [
766
+ section("doctrine", doctrine.map(bullet)),
767
+ section(
768
+ "primitives",
769
+ data.map((m) => (DENSE ? ` ${m.name}` : ` ${m.name.padEnd(14)}${firstSentence(m.summary)}`)),
770
+ ),
771
+ ].join("\n"),
772
+ )
773
+ }
774
+
775
+ const name = rest[0]
776
+ const m = PRIMITIVES.find((x) => x.name.toLowerCase() === name.toLowerCase())
777
+ if (!m)
778
+ fail("ERR_UNKNOWN_LAYOUT", `No layout primitive named "${name}".`, [
779
+ ...nearest(name, PRIMITIVES.map((x) => x.name)).map((n) => `Did you mean ${n}?`),
780
+ "sable layout --list",
781
+ ])
782
+
783
+ const level = detailLevel("compact")
784
+ const data = { name: m.name, status: m.status, summary: m.summary, import: importLine(m.name) }
785
+ if (level !== "brief") {
786
+ data.props = m.props ?? []
787
+ data.related = m.related ?? []
788
+ }
789
+ if (level === "full") {
790
+ data.tokensUsed = m.tokensUsed ?? []
791
+ data.examples = m.examples ?? []
792
+ }
793
+ ok("layout.detail", data, () =>
794
+ [
795
+ `${m.name} [${m.status}]`,
796
+ m.summary,
797
+ `\n${data.import}`,
798
+ level === "brief" ? "" : renderProps(m),
799
+ level === "full" ? renderExamples(m) : "",
800
+ ]
801
+ .filter(Boolean)
802
+ .join("\n"),
803
+ )
804
+ }
805
+
806
+ /* ── token ──────────────────────────────────────────────────────────────────── */
807
+
808
+ function cmdToken() {
809
+ const group = rest[0]
810
+ if (flags.has("list") || !group) {
811
+ const data = TOKEN_GROUPS.map((g) => ({
812
+ group: g,
813
+ count: Array.isArray(R.tokens[g]) ? R.tokens[g].length : Object.keys(R.tokens[g]).length,
814
+ }))
815
+ return ok("token.list", { groups: data }, () =>
816
+ data.map((d) => ` ${d.group.padEnd(21)}${String(d.count).padStart(3)} entries sable token ${d.group}`).join("\n"),
817
+ )
818
+ }
819
+ if (!TOKEN_GROUPS.includes(group))
820
+ fail("ERR_UNKNOWN_TOKEN_GROUP", `No token group "${group}".`, TOKEN_GROUPS.map((g) => `sable token ${g}`))
821
+
822
+ const entries = R.tokens[group]
823
+ ok("token.group", { group, tokens: entries }, () => {
824
+ if (!Array.isArray(entries)) return JSON.stringify(entries, null, 2)
825
+ return entries
826
+ .map((t) => {
827
+ if (typeof t === "string") return ` ${t}`
828
+ if (DENSE) return ` ${t.name} — ${t.intent}`
829
+ return [
830
+ ` ${t.name}`,
831
+ t.intent ? ` ${t.intent}` : "",
832
+ t.whenToUse ? ` use: ${t.whenToUse}` : "",
833
+ t.insteadOf ? ` not: ${t.insteadOf}` : "",
834
+ ]
835
+ .filter(Boolean)
836
+ .join("\n")
837
+ })
838
+ .join(DENSE ? "\n" : "\n\n")
839
+ })
840
+ }
841
+
842
+ /* ── utility ────────────────────────────────────────────────────────────────── */
843
+
844
+ /**
845
+ * Resolve what someone would actually type: `sable-well`, `.sable-well`, a
846
+ * variant with its colon (`sable-selected:`), or a filled-in functional utility
847
+ * (`rounded-concentric-md/1` → `rounded-concentric-*`).
848
+ */
849
+ function findUtility(input) {
850
+ const name = input.replace(/^\./, "").replace(/:$/, "")
851
+ const exact = UTILITIES.find((u) => u.name === name)
852
+ if (exact) return exact
853
+ return UTILITIES.filter((u) => u.name.endsWith("-*")).find((u) => name.startsWith(u.name.slice(0, -1)))
854
+ }
855
+
856
+ function cmdUtility() {
857
+ const name = rest[0]
858
+ if (flags.has("list") || !name) {
859
+ const family = flags.get("family")
860
+ const families = R.utilities?.families ?? [...new Set(UTILITIES.map((u) => u.family))]
861
+ if (family && !families.includes(family))
862
+ fail("ERR_BAD_FLAG", `No utility family "${family}".`, families.map((f) => `sable utility --list --family "${f}"`))
863
+ const items = UTILITIES.filter((u) => !family || u.family === family).map((u) => ({
864
+ name: u.name,
865
+ kind: u.kind,
866
+ family: u.family,
867
+ intent: u.intent,
868
+ }))
869
+ return ok("utility.list", { count: items.length, elsewhere: R.utilities?.elsewhere, utilities: items }, () =>
870
+ [
871
+ ...families
872
+ .filter((f) => !family || f === family)
873
+ .flatMap((f) => {
874
+ const rows = items.filter((u) => u.family === f)
875
+ if (!rows.length) return []
876
+ return [
877
+ DENSE ? f : `${f}:`,
878
+ ...rows.map((u) => ` ${(u.kind === "variant" ? `${u.name}:` : u.name).padEnd(28)}${firstSentence(u.intent)}`),
879
+ ]
880
+ }),
881
+ ...(DENSE || family ? [] : ["", ...Object.values(R.utilities?.elsewhere ?? {})]),
882
+ ].join("\n"),
883
+ )
884
+ }
885
+ const u = findUtility(name)
886
+ if (!u)
887
+ fail("ERR_UNKNOWN_UTILITY", `No utility "${name}".`, [
888
+ ...nearest(name, UTILITIES.map((x) => x.name)).map((n) => `sable utility ${n}`),
889
+ `sable search "${name}"`,
890
+ "sable utility --list",
891
+ ])
892
+ ok("utility.detail", u, () => {
893
+ const label = u.kind === "variant" ? `${u.name}: (variant)` : u.name
894
+ if (detailLevel() === "brief" || DENSE) return `${label} — ${u.intent}`
895
+ return [
896
+ `${label} [${u.family}]`,
897
+ ` ${u.intent}`,
898
+ "",
899
+ ` use: ${u.whenToUse}`,
900
+ ` not: ${u.insteadOf}`,
901
+ "",
902
+ ` ${u.example}`,
903
+ ...(u.pairsWith?.length ? ["", ` pairs with: ${u.pairsWith.join(", ")}`] : []),
904
+ ].join("\n")
905
+ })
906
+ }
907
+
908
+ /* ── docs ───────────────────────────────────────────────────────────────────── */
909
+
910
+ function docSlugs() {
911
+ if (!existsSync(LLMS_DIR)) return []
912
+ const out = []
913
+ for (const kind of readdirSync(LLMS_DIR)) {
914
+ const dir = join(LLMS_DIR, kind)
915
+ if (!statSync(dir).isDirectory()) continue
916
+ for (const file of readdirSync(dir)) {
917
+ if (file.endsWith(".md")) out.push({ kind, slug: file.replace(/\.md$/, ""), path: join(dir, file) })
918
+ }
919
+ }
920
+ return out
921
+ }
922
+
923
+ function cmdDocs() {
924
+ const all = docSlugs()
925
+ const kind = flags.get("kind")
926
+ if (flags.has("list") || !rest[0]) {
927
+ const list = kind ? all.filter((d) => d.kind === kind) : all
928
+ return ok("docs.list", { count: list.length, docs: list.map(({ kind, slug }) => ({ kind, slug })) }, () => {
929
+ const byKind = new Map()
930
+ for (const d of list) byKind.set(d.kind, [...(byKind.get(d.kind) ?? []), d.slug])
931
+ return [...byKind]
932
+ .map(([k, slugs]) => `${k}:\n${slugs.map((s) => ` ${s}`).join("\n")}`)
933
+ .join("\n\n")
934
+ })
935
+ }
936
+
937
+ const want = docSlugFor(rest[0])
938
+ const hit =
939
+ all.find((d) => d.slug === rest[0]) ??
940
+ all.find((d) => d.slug === want) ??
941
+ all.find((d) => d.slug === slugify(rest[0]))
942
+ if (!hit)
943
+ fail("ERR_UNKNOWN_DOC", `No doc mirror for "${rest[0]}".`, [
944
+ ...nearest(want, all.map((d) => d.slug)).map((s) => `Did you mean ${s}?`),
945
+ "sable docs --list",
946
+ ])
947
+ const content = readFileSync(hit.path, "utf8")
948
+ ok("docs.detail", { kind: hit.kind, slug: hit.slug, content }, content)
949
+ }
950
+
951
+ /* ── doctor ─────────────────────────────────────────────────────────────────── */
952
+
953
+ /**
954
+ * Every check below corresponds to a documented way Sable fails *silently* in a
955
+ * consuming app — no error, just wrong output: a second React resolving to an
956
+ * invalid-hook blank page, `@source` missing so Tailwind emits none of the
957
+ * package's classes, or `data-sable-theme` set with no theme CSS imported, which
958
+ * renders the default theme and looks like the switcher is broken.
959
+ */
960
+ function cmdDoctor() {
961
+ const checks = []
962
+ const add = (status, name, detail, fix) => checks.push({ status, name, detail, ...(fix ? { fix } : {}) })
963
+
964
+ const node = process.versions.node.split(".").map(Number)
965
+ if (node[0] >= 20) add("pass", "node", `v${process.versions.node}`)
966
+ else add("warn", "node", `v${process.versions.node}`, "Sable's tooling is developed on Node 22.")
967
+
968
+ let pkg = null
969
+ try {
970
+ pkg = JSON.parse(readFileSync(join(CWD, "package.json"), "utf8"))
971
+ } catch {
972
+ add("warn", "package.json", `Not found in ${CWD}.`, "Run doctor from your project root.")
973
+ }
974
+
975
+ if (pkg) {
976
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies }
977
+ const declared = deps["@sableui/react"]
978
+ if (!declared) add("fail", "install", "@sableui/react is not a dependency of this project.", "pnpm add @sableui/react")
979
+ else add("pass", "install", `@sableui/react ${declared} (this CLI serves the ${R.version} contract)`)
980
+
981
+ for (const [name, want] of [["react", "19"], ["react-dom", "19"], ["tailwindcss", "4"]]) {
982
+ const got = deps[name]
983
+ if (!got) add("fail", name, `Missing. Sable needs ${name} ^${want}.`, `pnpm add ${name}`)
984
+ else if (!String(got).includes(want)) add("warn", name, `${got} — Sable targets ^${want}.`)
985
+ else add("pass", name, got)
986
+ }
987
+ }
988
+
989
+
990
+ /* CSS wiring — read every stylesheet in the project, not just a guessed path. */
991
+ const css = collectSources(/\.(css|scss)$/)
992
+ const withoutComments = text => text.replace(/\/\*[\s\S]*?\*\//g, "")
993
+ const cssByPath = new Map(css.map(file => [file.path, file]))
994
+ const expandCss = (file, seen = new Set()) => {
995
+ if (seen.has(file.path)) return ""
996
+ const next = new Set(seen).add(file.path)
997
+ return withoutComments(file.text).replace(/@import\s+["'](\.[^"']+)["']\s*;/g, (original, specifier) => {
998
+ const imported = cssByPath.get(resolve(dirname(file.path), specifier))
999
+ return imported ? expandCss(imported, next) : original
1000
+ })
1001
+ }
1002
+ const expandedCss = css.map(file => ({ ...file, text: expandCss(file) }))
1003
+ const cssText = expandedCss.map(file => file.text).join("\n")
1004
+ const tailwindImport = /@import\s+["']tailwindcss["']/
1005
+ const sableImport = /@import\s+["'](?:@sableui\/react\/|[^"']*packages\/sable\/(?:src|dist)\/)styles\.css["']/
1006
+ if (!css.length) add("warn", "css", "No stylesheet found to inspect.")
1007
+ else {
1008
+ const hasSable = sableImport.test(cssText)
1009
+ const hasTw = tailwindImport.test(cssText)
1010
+ // Either form counts: the published `@sableui/react/dist/**` specifier, or a
1011
+ // relative path into `packages/sable/src` the way a workspace consumer writes it.
1012
+ const hasSource = /@source[^;]*(@sableui\/react|packages\/sable)/.test(cssText)
1013
+ if (!hasSable)
1014
+ add("fail", "css:styles", "No @import of @sableui/react/styles.css.", '@import "@sableui/react/styles.css";')
1015
+ else if (hasTw) {
1016
+ const file = expandedCss.find(f => sableImport.test(f.text) && tailwindImport.test(f.text)) || expandedCss.find(f => sableImport.test(f.text))
1017
+ const text = withoutComments(file.text)
1018
+ const twFirst = text.search(tailwindImport)
1019
+ const sableAt = text.search(sableImport)
1020
+ if (twFirst === -1 || twFirst > sableAt)
1021
+ add("fail", "css:order", "Sable's styles.css is imported before tailwindcss.", 'tailwindcss first, then styles.css.')
1022
+ else add("pass", "css:styles", relative(CWD, file.path))
1023
+ } else add("fail", "css:tailwind", 'No @import "tailwindcss" — the consumer owns Tailwind.', '@import "tailwindcss";')
1024
+
1025
+ if (hasSable && !hasSource)
1026
+ add(
1027
+ "fail",
1028
+ "css:source",
1029
+ "No @source pointing at the package — Tailwind will emit none of Sable's classes and every component renders unstyled.",
1030
+ '@source "<path-to>/@sableui/react/dist/**/*.{js,mjs}";',
1031
+ )
1032
+ else if (hasSable) add("pass", "css:source", "@source covers the package.")
1033
+ }
1034
+
1035
+ /* The theme trap: opting into theme switching without importing theme CSS. */
1036
+ const src = collectSources(/\.(tsx?|jsx?)$/)
1037
+ const srcText = src.map((f) => f.text).join("\n")
1038
+ // A provider used only for light/dark mode needs Base, not alternate themes.
1039
+ const usesThemes = /ThemeSelector|data-sable-theme|applySableTheme|setThemeId|defaultThemeId/.test(srcText)
1040
+ if (usesThemes) {
1041
+ const themeCss = /@import\s+["'](?:@sableui\/react\/|[^"']*packages\/sable\/(?:src|dist)\/)(themes\.css|theme\/[a-z-]+\.css|themes\/[a-z-]+\/[a-z-]+\.css)["']/.test(cssText)
1042
+ const customCss = /\[data-sable-theme\s*=\s*["']?[a-z][a-z0-9-]*["']?\s*\][^{]*\{[^}]*--(?:background|primary|font-sans)\s*:/.test(cssText)
1043
+ if (themeCss || customCss) add("pass", "themes", themeCss ? "Theme CSS is imported." : "App-owned scoped theme CSS is present; verify its ID and loading in the rendered app.")
1044
+ else
1045
+ add(
1046
+ "fail",
1047
+ "themes",
1048
+ "This app selects a design theme but has no alternate or app-owned scoped theme CSS — the selected theme may silently render as Base.",
1049
+ 'Import themes.css, one theme/<id>.css, or the scoped CSS generated by sable preset --css.',
1050
+ )
1051
+ }
1052
+
1053
+ if (pkg && !/resolve\s*:\s*\{[^}]*dedupe|dedupe/.test(readConfigs()))
1054
+ add("info", "dedupe", "Could not confirm React dedupe in a bundler config.", 'resolve.dedupe: ["react","react-dom"] — a second React is an invalid-hook blank page.')
1055
+
1056
+ const failed = checks.filter((c) => c.status === "fail").length
1057
+ const data = { version: R.version, checks, failed }
1058
+ if (JSON_OUT) {
1059
+ process.stdout.write(`${JSON.stringify({ type: "doctor", data }, null, 1)}\n`)
1060
+ } else {
1061
+ const mark = { pass: "✓", warn: "!", fail: "✗", info: "·" }
1062
+ for (const c of checks) {
1063
+ process.stdout.write(` ${mark[c.status]} ${c.name.padEnd(14)}${c.detail}\n`)
1064
+ if (c.fix) process.stdout.write(` → ${c.fix}\n`)
1065
+ }
1066
+ process.stdout.write(
1067
+ failed
1068
+ ? `\n[sable doctor] ✗ ${failed} failure(s). Each one renders wrong without erroring.\n`
1069
+ : "\n[sable doctor] ✓ wiring holds.\n",
1070
+ )
1071
+ }
1072
+ process.exit(failed ? 1 : 0)
1073
+ }
1074
+
1075
+ const SKIP_DIRS = new Set(["node_modules", ".next", ".nuxt", "dist", "build", "out", "coverage", ".git", ".turbo"])
1076
+ function collectSources(match, limit = 4000) {
1077
+ const out = []
1078
+ const walk = (dir) => {
1079
+ if (out.length >= limit) return
1080
+ let entries
1081
+ try {
1082
+ entries = readdirSync(dir)
1083
+ } catch {
1084
+ return
1085
+ }
1086
+ for (const name of entries) {
1087
+ if (SKIP_DIRS.has(name)) continue
1088
+ const full = join(dir, name)
1089
+ let st
1090
+ try {
1091
+ st = statSync(full)
1092
+ } catch {
1093
+ continue
1094
+ }
1095
+ if (st.isDirectory()) walk(full)
1096
+ else if (match.test(name)) out.push({ path: full, text: readFileSync(full, "utf8") })
1097
+ }
1098
+ }
1099
+ walk(CWD)
1100
+ return out
1101
+ }
1102
+
1103
+
1104
+ function readConfigs() {
1105
+ return ["vite.config.ts", "vite.config.js", "next.config.js", "next.config.mjs", "webpack.config.js"]
1106
+ .map((f) => (existsSync(join(CWD, f)) ? readFileSync(join(CWD, f), "utf8") : ""))
1107
+ .join("\n")
1108
+ }
1109
+
1110
+ /* ── lint (passthrough) ─────────────────────────────────────────────────────── */
1111
+
1112
+ function cmdLint() {
1113
+ try {
1114
+ execFileSync(process.execPath, [join(HERE, "sable-lint.mjs"), ...argv.slice(1)], { stdio: "inherit" })
1115
+ process.exit(0)
1116
+ } catch (e) {
1117
+ process.exit(e.status ?? 1)
1118
+ }
1119
+ }
1120
+
1121
+ /* ── Dispatch. ──────────────────────────────────────────────────────────────── */
1122
+
1123
+ function cmdGrade() {
1124
+ let output
1125
+ let code = 0
1126
+ try {
1127
+ output = execFileSync(process.execPath, [join(PKG_ROOT, "scripts/grade.mjs"), ...argv.slice(1)], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
1128
+ } catch (error) {
1129
+ output = `${error.stdout ?? ""}${error.stderr ?? ""}`
1130
+ code = error.status ?? 1
1131
+ }
1132
+ if (JSON_OUT) {
1133
+ try {
1134
+ const data = JSON.parse(output)
1135
+ output = `${JSON.stringify(data.type === "error" ? data : { type: flags.has("template") ? "grade.template" : "grade.score", data }, null, DENSE ? 0 : 1)}\n`
1136
+ } catch { fail("ERR_GRADE_INPUT", output || "Grade failed to return a report.") }
1137
+ }
1138
+ process.stdout.write(output)
1139
+ logInvocation(code)
1140
+ process.exit(code)
1141
+ }
1142
+
1143
+ function cmdLearn() {
1144
+ cmdDesignTool("design-memory.mjs")
1145
+ }
1146
+
1147
+ function cmdDesignTool(script) {
1148
+ try {
1149
+ execFileSync(process.execPath, [join(PKG_ROOT, "scripts", script), ...argv.slice(1)], { stdio: "inherit" })
1150
+ logInvocation(0)
1151
+ process.exit(0)
1152
+ } catch (error) {
1153
+ logInvocation(error.status ?? 1)
1154
+ process.exit(error.status ?? 1)
1155
+ }
1156
+ }
1157
+
1158
+ const HANDLERS = {
1159
+ target: () => {
1160
+ if (rest[0] !== "ios" || rest.length !== 1) fail("ERR_BAD_ARGUMENT", "Use sable target ios [--component Name].")
1161
+ const targetPath = join(PKG_ROOT, "targets", "ios.json")
1162
+ if (!existsSync(targetPath)) fail("ERR_TARGET_MISSING", "The installed Sable package is missing its iOS target mapping.", ["Reinstall @sableui/react"])
1163
+ const target = JSON.parse(readFileSync(targetPath, "utf8"))
1164
+ const name = flags.get("component")
1165
+ if (name === true) fail("ERR_BAD_ARGUMENT", "--component needs a Sable component name.", ["sable target ios --component Switch"])
1166
+ if (name) {
1167
+ const match = target.components[name]
1168
+ if (!match) fail("ERR_UNKNOWN_COMPONENT", `No iOS mapping for ${name}.`, nearest(String(name), Object.keys(target.components)))
1169
+ const contexts = (match.contexts ?? []).map(c => `${c.role}: ${c.strategy} → ${c.importFrom} (${c.components.join(" + ")})`).join("\n")
1170
+ ok("target.component", match, `${match.source}: ${match.strategy}\n${contexts || `${match.importFrom ?? "app"} → ${match.components.join(" + ") || match.strategy}`}\n${match.guidance}`)
1171
+ }
1172
+ ok("target", target, `iOS ${target.platformVersion}: ${target.designPolicy.principle}\n${target.designPolicy.rule}\n` + Object.values(target.components).map(c => `${c.source}: ${c.strategy} → ${c.strategy === "contextual" ? "choose by role" : c.components.join(" + ") || "redesign for phone"}`).join("\n"))
1173
+ },
1174
+ preset: () => cmdDesignTool("design-presets.mjs"),
1175
+ setup: () => {
1176
+ if (!flags.has("agent") || rest.length) fail("ERR_BAD_ARGUMENT", "Use sable setup --agent from your project root.", ["sable setup --agent --dry-run"])
1177
+ try {
1178
+ const data = installAgentSkill({ root: CWD, packageRoot: PKG_ROOT, target: flags.get("target") ?? "all", update: flags.has("update"), dryRun: flags.has("dry-run") })
1179
+ ok("setup.agent", data, `${data.dryRun ? "Would install" : "Installed"} Sable skill entries at ${data.destinations.join(", ")}.\nNext: ${data.next}`)
1180
+ } catch (error) { fail("ERR_AGENT_SETUP", error.message, ["sable setup --agent --dry-run"]) }
1181
+ },
1182
+ manifest: cmdManifest,
1183
+ search: cmdSearch,
1184
+ component: cmdComponent,
1185
+ pattern: cmdPattern,
1186
+ layout: cmdLayout,
1187
+ token: cmdToken,
1188
+ utility: cmdUtility,
1189
+ docs: cmdDocs,
1190
+ doctor: cmdDoctor,
1191
+ grade: cmdGrade,
1192
+ learn: cmdLearn,
1193
+ atlas: () => cmdDesignTool("design-atlas.mjs"),
1194
+ prefer: () => cmdDesignTool("design-preferences.mjs"),
1195
+ explore: () => cmdDesignTool("design-directions.mjs"),
1196
+ lint: cmdLint,
1197
+ }
1198
+
1199
+ if (!command || flags.has("help") || command === "help") {
1200
+ if (command && command !== "help" && HANDLERS[command]) {
1201
+ const c = COMMANDS.find((x) => x.name === command)
1202
+ process.stdout.write(
1203
+ [
1204
+ `sable ${c.name}${c.arg ? ` <${c.arg.name}>` : ""} — ${c.summary}`,
1205
+ ...(c.flags.length ? ["", "Flags:", ...c.flags.map((f) => ` ${(f.name + (f.value ? ` <${f.value}>` : "")).padEnd(24)}${f.describe}`)] : []),
1206
+ "",
1207
+ `Returns: ${c.returns.join(", ")}`,
1208
+ "",
1209
+ ].join("\n"),
1210
+ )
1211
+ process.exit(0)
1212
+ }
1213
+ cmdManifest()
1214
+ }
1215
+
1216
+ const handler = HANDLERS[command]
1217
+ if (!handler)
1218
+ fail("ERR_UNKNOWN_COMMAND", `No command "${command}".`, [
1219
+ ...nearest(command, Object.keys(HANDLERS)).map((n) => `Did you mean \`sable ${n}\`?`),
1220
+ "sable manifest — the full command list",
1221
+ ])
1222
+
1223
+ handler()