@sableui/react 0.16.0 → 0.18.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 (95) hide show
  1. package/AGENTS.md +10 -3
  2. package/CHANGELOG.md +132 -0
  3. package/README.md +11 -4
  4. package/bin/sable.mjs +160 -18
  5. package/dist/activity-DT8VK9fx.d.ts +25 -0
  6. package/dist/adapters/github.d.ts +41 -0
  7. package/dist/adapters/github.js +24 -0
  8. package/dist/blocks/approval-block.js +30 -48
  9. package/dist/blocks/approval-block.meta.js +7 -8
  10. package/dist/blocks/index.d.ts +1 -1
  11. package/dist/components/activity-heatmap/activity-heatmap.js +420 -0
  12. package/dist/components/activity-heatmap/activity-heatmap.meta.js +142 -0
  13. package/dist/components/activity-heatmap/index.js +9 -0
  14. package/dist/components/code-block/code-block.js +5 -3
  15. package/dist/components/code-block/code-block.meta.js +6 -0
  16. package/dist/components/dialog/dialog.js +25 -5
  17. package/dist/components/dialog/dialog.meta.js +26 -5
  18. package/dist/components/media-image/media-image.js +3 -1
  19. package/dist/components/media-image/media-image.meta.js +15 -1
  20. package/dist/components/media-tile/media-tile.js +13 -1
  21. package/dist/components/media-tile/media-tile.meta.js +10 -0
  22. package/dist/components/page-header/page-header.js +9 -4
  23. package/dist/components/page-header/page-header.meta.js +26 -1
  24. package/dist/components/pill/pill.meta.js +15 -6
  25. package/dist/components/pill/pill.variants.js +1 -0
  26. package/dist/components/search-input/search-input.js +30 -5
  27. package/dist/components/search-input/search-input.meta.js +29 -4
  28. package/dist/components/section-header/section-header.js +27 -17
  29. package/dist/components/section-header/section-header.meta.js +27 -5
  30. package/dist/components/section-rail/index.js +7 -0
  31. package/dist/components/section-rail/section-rail.js +153 -0
  32. package/dist/components/section-rail/section-rail.meta.js +189 -0
  33. package/dist/components/sheet/sheet.js +15 -3
  34. package/dist/components/sheet/sheet.meta.js +2 -2
  35. package/dist/file-hashes.json +53 -39
  36. package/dist/icon-sets/reicon.js +23 -1
  37. package/dist/index.d.ts +364 -27
  38. package/dist/index.js +11 -1
  39. package/dist/layout/AspectBox.js +2 -5
  40. package/dist/layout/Bleed.js +10 -6
  41. package/dist/layout/Center.js +11 -3
  42. package/dist/layout/Columns.js +6 -3
  43. package/dist/layout/Grid.js +14 -6
  44. package/dist/layout/GridOverlay.js +7 -7
  45. package/dist/layout/Region.js +18 -3
  46. package/dist/layout/Row.js +27 -20
  47. package/dist/layout/Spacer.js +13 -2
  48. package/dist/layout/SplitPane.js +1 -1
  49. package/dist/layout/Stack.js +25 -18
  50. package/dist/layout/index.d.ts +57 -6
  51. package/dist/lib/chart/activity.js +32 -0
  52. package/dist/lib/chart/calendar.js +67 -0
  53. package/dist/lib/chart/chart-tooltip.js +2 -2
  54. package/dist/lib/chart/heat.js +55 -0
  55. package/dist/lib/focus-return.js +53 -0
  56. package/dist/lib/taxonomy.js +2 -0
  57. package/dist/lib/use-active-section.js +59 -0
  58. package/dist/styles.css +122 -114
  59. package/llms/blocks/approval-block.md +40 -55
  60. package/llms/components/activity-heatmap.md +133 -0
  61. package/llms/components/code-block.md +1 -0
  62. package/llms/components/dialog.md +34 -3
  63. package/llms/components/media-image.md +12 -0
  64. package/llms/components/media-tile.md +21 -0
  65. package/llms/components/page-header.md +16 -1
  66. package/llms/components/pill.md +24 -5
  67. package/llms/components/search-input.md +37 -4
  68. package/llms/components/section-header.md +28 -4
  69. package/llms/components/section-rail.md +101 -0
  70. package/llms/components/sheet.md +2 -2
  71. package/llms/layout/aspect-box.md +1 -1
  72. package/llms/layout/bleed.md +1 -1
  73. package/llms/layout/center.md +1 -1
  74. package/llms/layout/columns.md +1 -1
  75. package/llms/layout/grid.md +1 -1
  76. package/llms/layout/region.md +1 -1
  77. package/llms/layout/row.md +9 -1
  78. package/llms/layout/spacer.md +1 -1
  79. package/llms/layout/split-pane.md +1 -1
  80. package/llms/layout/stack.md +1 -1
  81. package/llms/tasks/auditing-an-app.md +31 -0
  82. package/llms/tasks/getting-started.md +10 -3
  83. package/llms/tasks/using-presets.md +1 -2
  84. package/llms-full.txt +516 -90
  85. package/llms.txt +5 -3
  86. package/package.json +6 -1
  87. package/registry.json +745 -46
  88. package/scripts/adoption.mjs +239 -0
  89. package/scripts/agent-setup.mjs +110 -18
  90. package/scripts/grade.mjs +17 -0
  91. package/scripts/wiring.mjs +2 -1
  92. package/skills/entry/SKILL.md +1 -1
  93. package/skills/sable/SKILL.md +33 -4
  94. package/skills/sable/agents/openai.yaml +1 -1
  95. package/targets/ios.json +18 -0
@@ -0,0 +1,239 @@
1
+ /**
2
+ * adoption — how much of an app's UI is actually built from Sable.
3
+ *
4
+ * Wiring can be perfect while the app composes none of it: an agent that never
5
+ * loaded the skill sees Tailwind and a stylesheet and writes its own Button, its
6
+ * own Modal, its own Tabs on top of Sable's tokens. Lint catches a raw
7
+ * `<button>` with classes; it cannot see that `function Modal()` is a dialog
8
+ * Sable already ships, or that forty UI files import nothing from the package.
9
+ *
10
+ * This reads the source lexically, like grade, and answers three things:
11
+ *
12
+ * - level: how many UI files compose Sable at all
13
+ * - swaps: app-defined components named for something Sable ships, which the
14
+ * file does not itself build on (behaviour first: those are where
15
+ * focus, keyboard and ARIA bugs live)
16
+ * - foundation: "sable" when Sable already carries the UI; otherwise whether
17
+ * the app has its own system to respect (another UI library, or a
18
+ * folder of its own primitives) or is ad hoc
19
+ *
20
+ * It never fails anything. It is the evidence an agent shows the user when it
21
+ * asks whether to move app-built UI onto Sable, and the wording of that ask
22
+ * depends on the foundation: firm when there is none, gentle when there is one.
23
+ */
24
+
25
+ /** Groups whose components own behaviour (focus, keyboard, ARIA). */
26
+ const BEHAVIOUR_GROUPS = new Set(["Actions", "Forms", "Overlays", "Navigation", "Selection"])
27
+ const BEHAVIOUR_NAMES = new Set(["Accordion", "Collapsible", "Carousel", "Toast", "DataTable", "CommandMenu"])
28
+
29
+ /** Other component libraries, by import specifier. */
30
+ const LIBRARIES = [
31
+ [/^@mui\//, "MUI"],
32
+ [/^@chakra-ui\//, "Chakra UI"],
33
+ [/^antd(?:$|\/)/, "Ant Design"],
34
+ [/^@mantine\//, "Mantine"],
35
+ [/^react-bootstrap(?:$|\/)/, "React Bootstrap"],
36
+ [/^@headlessui\//, "Headless UI"],
37
+ [/^@radix-ui\//, "Radix"],
38
+ [/^@(?:nextui-org|heroui)\//, "HeroUI"],
39
+ [/^(?:react-aria-components$|@react-aria\/)/, "React Aria"],
40
+ [/^primereact(?:$|\/)/, "PrimeReact"],
41
+ [/^flowbite-react(?:$|\/)/, "Flowbite"],
42
+ [/^@ark-ui\//, "Ark UI"],
43
+ ]
44
+ /** shadcn/ui-style primitives: `@/components/ui/button`, `../components/ui/dialog`. */
45
+ const SHADCN = /(?:^|\/)components\/ui\/[a-z][\w-]*$/
46
+ /** Where an app keeps a system of its own. */
47
+ const PRIMITIVES_DIR = /(?:^|\/)(?:ui|primitives|design-system|ds)\//i
48
+ /** Names that are an app's own structure, never a control it rebuilt. */
49
+ const STRUCTURAL = /^(?:App|Root|Main|Home|Index)$|(?:Page|Layout|Provider|Context|Screen|View|Routes?|Router|Story|Stories|Demo|Example|Icon|Logo)$/
50
+
51
+ const IMPORT = /import\s+(?:type\s+)?([\s\S]*?)\s+from\s*["']([^"']+)["']/g
52
+ const DECLARATIONS = [
53
+ /(?:^|\n)[ \t]*(?:export\s+(?:default\s+)?)?function\s+([A-Z]\w*)\s*[(<]/g,
54
+ /(?:^|\n)[ \t]*(?:export\s+)?const\s+([A-Z]\w*)\s*(?::[^=\n]+)?=\s*(?:React\.)?(?:forwardRef|memo)\b/g,
55
+ /(?:^|\n)[ \t]*(?:export\s+)?const\s+([A-Z]\w*)\s*(?::[^=\n]+)?=\s*(?:async\s*)?\([^)]*\)\s*(?::[^=\n]+)?=>/g,
56
+ ]
57
+
58
+ const normalize = (s) => s.toLowerCase().replace(/[\s_-]+/g, "")
59
+ const words = (name) => name.match(/[A-Z]+(?![a-z])|[A-Z]?[a-z0-9]+/g) ?? [name]
60
+ /**
61
+ * Behaviour done by hand, on native elements: what makes a component named
62
+ * Modal or Tabs a rebuild rather than content handed to Sable's (a tooltip
63
+ * body for ActivityHeatmap) or a composition of Sable's own controls (a
64
+ * toolbar of Pill, Select and SearchInput). Handlers on a capitalised tag
65
+ * belong to that component, so only lowercase tags count.
66
+ */
67
+ const HAND_BEHAVIOUR = [
68
+ /<(?:button|input|select|textarea|dialog|details)\b/,
69
+ /<[a-z][a-z0-9]*\b[^<>]*\son(?:Click|KeyDown|KeyUp|MouseEnter|MouseLeave|PointerDown|PointerEnter|Focus|Blur)\s*=/,
70
+ /<[a-z][a-z0-9]*\b[^<>]*\srole=["'](?:dialog|alertdialog|tablist|tab|tabpanel|menu|menubar|menuitem|listbox|option|combobox|switch|slider|tooltip|checkbox|radio|radiogroup)["']/,
71
+ /<[a-z][a-z0-9]*\b[^<>]*\saria-(?:expanded|modal|selected|haspopup|pressed|checked)=/,
72
+ /\bcreatePortal\s*\(/,
73
+ /className=["'`][^"'`]*\bfixed\b[^"'`]*\binset-/,
74
+ ]
75
+ const isBehaviour = (c) => BEHAVIOUR_GROUPS.has(c.group) || BEHAVIOUR_NAMES.has(c.name)
76
+ const lineOf = (text, index) => text.slice(0, index).split("\n").length
77
+
78
+ /**
79
+ * A .tsx/.jsx file that renders markup: a closing tag, or a self-closing `/>`
80
+ * (matched bare, since an arrow handler's `=>` sits inside the tag).
81
+ */
82
+ const rendersJsx = (text) => /<\/[A-Za-z][\w.]*\s*>|\/>/.test(text)
83
+
84
+ /**
85
+ * The names apps give the controls they rebuild, mapped to Sable's. Curated
86
+ * rather than read from registry keywords: those are written for search, so
87
+ * "list", "panel" and "preview" tie half an app's own components to something.
88
+ */
89
+ const ALIASES = {
90
+ modal: ["Dialog"], dropdown: ["Menu", "Select"], dropdownmenu: ["Menu"], popup: ["Popover"],
91
+ drawer: ["Sheet"], sidesheet: ["Sheet"], bottomsheet: ["Sheet"], snackbar: ["Toast"], notification: ["Toast"],
92
+ spinner: ["Spinner"], loader: ["Spinner", "Skeleton"], progressbar: ["Progress"], autocomplete: ["Combobox"],
93
+ typeahead: ["Combobox"], multiselect: ["Combobox"], textfield: ["Input"], textinput: ["Input"], searchbar: ["SearchInput"],
94
+ searchbox: ["SearchInput"], searchfield: ["SearchInput"], iconbutton: ["Button"], fileupload: ["FileDropzone"],
95
+ uploader: ["FileDropzone"], dropzone: ["FileDropzone"], collapse: ["Collapsible"], disclosure: ["Collapsible"],
96
+ commandpalette: ["CommandMenu"], datepicker: ["DatePicker"], toggleswitch: ["Switch"], segmentedcontrol: ["Tabs", "ButtonGroup"],
97
+ tabbar: ["Tabs", "BottomNav"], navbar: ["AppHeader", "NavigationMenu"], topbar: ["AppHeader"], breadcrumb: ["Breadcrumbs"],
98
+ chip: ["Pill", "Badge"], tag: ["Badge"], alert: ["Banner"], callout: ["Note"], confirmdialog: ["DestructiveActionModal", "Dialog"],
99
+ contextmenu: ["ContextMenu"], hovercard: ["ContextCard"], numberinput: ["NumberField"], otpinput: ["OtpField"], pininput: ["OtpField"],
100
+ colorpicker: ["ColorField"], rating: ["RadioCard"], stepper: ["Stepper"], wizard: ["Stepper"], kbd: ["Kbd"],
101
+ }
102
+
103
+ /**
104
+ * The Sable components an app-defined name stands for: its whole name or its
105
+ * last word or two, as a Sable name (`Button`, `PrimaryButton`) or an alias
106
+ * (`Modal`, `SearchModal` → Dialog).
107
+ */
108
+ function matcher(components) {
109
+ const byName = new Map(components.map((c) => [c.name.toLowerCase(), c]))
110
+ return (appName) => {
111
+ if (STRUCTURAL.test(appName)) return []
112
+ const w = words(appName)
113
+ const candidates = [...new Set([appName, w.slice(-2).join(""), w.at(-1)].filter(Boolean).map(normalize))]
114
+ for (const key of candidates) {
115
+ if (byName.has(key)) return [byName.get(key)]
116
+ const alias = ALIASES[key]?.map((name) => byName.get(name.toLowerCase())).filter(Boolean)
117
+ if (alias?.length) return alias
118
+ }
119
+ return []
120
+ }
121
+ }
122
+
123
+ /**
124
+ * @param {{ files: { rel: string, text: string }[], components: object[], deps?: Record<string, string> | null }} input
125
+ * files: the app's .tsx/.jsx sources, paths relative to the project.
126
+ */
127
+ export function adoptionReport({ files, components, deps = null }) {
128
+ const match = matcher(components)
129
+ const sableUsed = new Set()
130
+ const libraries = new Map()
131
+ let uiFiles = 0
132
+ let sableFiles = 0
133
+ let ownPrimitives = 0
134
+ const swaps = []
135
+
136
+ for (const { rel, text } of files) {
137
+ if (!/\.(tsx|jsx)$/.test(rel) || !rendersJsx(text)) continue
138
+ uiFiles++
139
+ const imported = new Set()
140
+ for (const m of text.matchAll(IMPORT)) {
141
+ const from = m[2]
142
+ if (/^@sableui\/react(?:\/|$)/.test(from)) {
143
+ for (const raw of (m[1].match(/\{([^}]*)\}/)?.[1] ?? "").split(",")) {
144
+ const name = raw.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0]?.trim()
145
+ if (name) imported.add(name)
146
+ }
147
+ continue
148
+ }
149
+ const lib = LIBRARIES.find(([re]) => re.test(from))?.[1] ?? (SHADCN.test(from) ? "shadcn/ui-style components/ui" : null)
150
+ if (lib) libraries.set(lib, (libraries.get(lib) ?? 0) + 1)
151
+ }
152
+ if (imported.size) sableFiles++
153
+ for (const name of imported) sableUsed.add(name)
154
+
155
+ const declared = new Map()
156
+ for (const re of DECLARATIONS) for (const m of text.matchAll(re)) if (!declared.has(m[1])) declared.set(m[1], m.index + m[0].indexOf(m[1]))
157
+ // Each component's body runs to the next declaration: close enough to
158
+ // tell whose markup a handler or a role belongs to.
159
+ const starts = [...declared.values()].sort((a, b) => a - b)
160
+ const own = PRIMITIVES_DIR.test(rel)
161
+ for (const [name, index] of declared) {
162
+ const hits = match(name)
163
+ if (!hits.length) continue
164
+ if (own) ownPrimitives++
165
+ // Built on the Sable component it is named for: a composition, not a rebuild.
166
+ if (hits.some((c) => imported.has(c.name))) continue
167
+ const behaviour = hits.some(isBehaviour)
168
+ const body = text.slice(index, starts.find((s) => s > index) ?? text.length)
169
+ if (behaviour && !HAND_BEHAVIOUR.some((re) => re.test(body))) continue
170
+ swaps.push({ file: rel, line: lineOf(text, index), name, sable: hits.map((c) => c.name), behaviour })
171
+ }
172
+ }
173
+
174
+ // A dependency counts even before its first import is found in these paths.
175
+ for (const dep of Object.keys(deps ?? {})) {
176
+ const lib = LIBRARIES.find(([re]) => re.test(dep))?.[1]
177
+ if (lib && !libraries.has(lib)) libraries.set(lib, 0)
178
+ }
179
+
180
+ swaps.sort((a, b) => Number(b.behaviour) - Number(a.behaviour))
181
+ const share = uiFiles ? sableFiles / uiFiles : 0
182
+ const behaviourSwaps = swaps.filter((s) => s.behaviour).length
183
+ const level =
184
+ uiFiles === 0 ? "empty"
185
+ : sableFiles === 0 && uiFiles <= 2 ? "fresh"
186
+ : sableFiles === 0 ? "none"
187
+ : share < 0.5 || behaviourSwaps >= 3 ? "low"
188
+ : "sable"
189
+ const foundation = libraries.size ? "library" : level === "sable" ? "sable" : ownPrimitives >= 4 ? "own" : "ad-hoc"
190
+
191
+ return {
192
+ informationalOnly: true,
193
+ level,
194
+ foundation,
195
+ uiFiles,
196
+ sableFiles,
197
+ share: +share.toFixed(2),
198
+ sableComponents: [...sableUsed].sort(),
199
+ libraries: [...libraries].map(([name, files]) => ({ name, files })),
200
+ swaps,
201
+ summary: summaryOf({ level, uiFiles, sableFiles, sableUsed, swaps }),
202
+ recommendation: recommendationOf({ level, foundation, libraries, behaviourSwaps }),
203
+ }
204
+ }
205
+
206
+ function summaryOf({ level, uiFiles, sableFiles, sableUsed, swaps }) {
207
+ if (level === "empty") return "No UI files yet."
208
+ if (level === "fresh") return `${uiFiles} UI file(s), none using Sable yet. A starter: build on Sable from here.`
209
+ const built = swaps.length ? `; ${swaps.length} app-built component(s) named for something Sable ships` : ""
210
+ return `${sableFiles} of ${uiFiles} UI files compose Sable (${sableUsed.size} distinct export${sableUsed.size === 1 ? "" : "s"})${built}.`
211
+ }
212
+
213
+ /**
214
+ * What the agent should do with this, in words it can relay. Never "rewrite it":
215
+ * the user decides whether working screens move. The foundation decides how
216
+ * strongly to recommend it.
217
+ */
218
+ function recommendationOf({ level, foundation, libraries, behaviourSwaps }) {
219
+ if (level === "empty" || level === "fresh") return "Build new UI from Sable: sable search \"<need>\" before writing a component."
220
+ const names = [...libraries.keys()].join(", ")
221
+ if (level === "sable")
222
+ return libraries.size
223
+ ? `Sable is the foundation, alongside ${names}. Keep new UI on Sable; offer the swaps listed when a screen is next touched.`
224
+ : behaviourSwaps
225
+ ? "Sable is the foundation. Offer the few swaps listed when those screens are next touched; new UI starts with sable search."
226
+ : "Sable is the foundation. Keep composing from it; new UI starts with sable search."
227
+ if (foundation === "library")
228
+ return `This app also builds on ${names}. Ask the user whether Sable should replace it or only cover new work; don't mix two control libraries on one screen.`
229
+ if (foundation === "own")
230
+ return "This app has its own primitives. Offer the swaps listed, behaviour first, and keep the app's own where the user prefers them."
231
+ return "This app has no component foundation besides Sable. Recommend moving the listed components onto Sable, behaviour first, and ask the user before changing working screens."
232
+ }
233
+
234
+ /** One line per swap, for text output. */
235
+ export function swapLines(report, limit = 12, indent = " ") {
236
+ const lines = report.swaps.slice(0, limit).map((s) => `${indent}${s.file}:${s.line} ${s.name} → Sable has ${s.sable.join(" or ")}${s.behaviour ? " (behaviour)" : ""}`)
237
+ if (report.swaps.length > limit) lines.push(`${indent}… ${report.swaps.length - limit} more: sable grade <paths> --json → adoption.swaps`)
238
+ return lines
239
+ }
@@ -40,6 +40,27 @@ const STOP_HOOK_MARKER = "sable check --stop-hook"
40
40
  const PORTABLE = /^[\w@./ -]+$/
41
41
  const quoted = (s) => (/^[\w./-]+$/.test(s) ? s : `"${s}"`)
42
42
 
43
+ /**
44
+ * How to run this project's `sable` from `root`. A workspace package is reached
45
+ * through the package manager's own workspace flag, since its `sable` bin is not
46
+ * on the root's path.
47
+ */
48
+ function sableRunner({ root, project }) {
49
+ const projectPath = relative(resolve(root), resolve(project)).replaceAll("\\", "/")
50
+ const pm = packageManager(root, project)
51
+ if (projectPath) {
52
+ let name
53
+ try { name = JSON.parse(readFileSync(join(project, "package.json"), "utf8")).name } catch { /* unnamed */ }
54
+ if (typeof name === "string" && PORTABLE.test(name))
55
+ return {
56
+ pnpm: `pnpm --filter ${quoted(name)} exec sable`,
57
+ yarn: `yarn workspace ${quoted(name)} sable`,
58
+ npm: `npm exec --workspace ${quoted(name)} --no -- sable`,
59
+ }[pm]
60
+ }
61
+ return { pnpm: "pnpm exec sable", yarn: "yarn sable", npm: "npm exec --no -- sable" }[pm]
62
+ }
63
+
43
64
  /**
44
65
  * The Claude Code Stop hook that runs `sable check` before a task can end:
45
66
  * settings.json → hooks.Stop[] → { hooks: [{ type: "command", command }] }.
@@ -51,27 +72,13 @@ const quoted = (s) => (/^[\w./-]+$/.test(s) ? s : `"${s}"`)
51
72
  * without it on Windows, PowerShell 5.1, which supports neither. `sable check
52
73
  * --stop-hook` reads CLAUDE_PROJECT_DIR from its environment instead, and
53
74
  * `--root` names the Sable package when the entries live at a repository root.
54
- * A workspace package is reached through the package manager's own workspace
55
- * flag, since its `sable` bin is not on the root's path.
75
+ * A workspace package is reached through sableRunner.
56
76
  */
57
77
  export function claudeStopHook({ root, project = root }) {
58
78
  const projectPath = relative(resolve(root), resolve(project)).replaceAll("\\", "/")
59
79
  if (projectPath.startsWith("..") || isAbsolute(projectPath)) throw new Error("--root must be the project directory or one of its ancestors, such as the repository root.")
60
80
  if (projectPath && !PORTABLE.test(projectPath)) throw new Error(`Unsupported characters for a portable hook in the project path: ${projectPath}. Add the hook by hand.`)
61
- const pm = packageManager(root, project)
62
- let runner = { pnpm: "pnpm exec sable", yarn: "yarn sable", npm: "npm exec --no -- sable" }[pm]
63
- if (projectPath) {
64
- let name
65
- try { name = JSON.parse(readFileSync(join(project, "package.json"), "utf8")).name } catch { /* unnamed */ }
66
- if (typeof name === "string" && PORTABLE.test(name)) {
67
- runner = {
68
- pnpm: `pnpm --filter ${quoted(name)} exec sable`,
69
- yarn: `yarn workspace ${quoted(name)} sable`,
70
- npm: `npm exec --workspace ${quoted(name)} --no -- sable`,
71
- }[pm]
72
- }
73
- }
74
- const command = `${runner} check --stop-hook${projectPath ? ` --root ${quoted(projectPath)}` : ""}`
81
+ const command = `${sableRunner({ root, project })} check --stop-hook${projectPath ? ` --root ${quoted(projectPath)}` : ""}`
75
82
  const entry = { hooks: [{ type: "command", command, timeout: 300 }] }
76
83
  return { settingsFile: ".claude/settings.json", command, entry, settings: { hooks: { Stop: [entry] } }, installed: false }
77
84
  }
@@ -110,6 +117,63 @@ export function installClaudeStopHook({ root, project = root, dryRun = false })
110
117
  return { ...hook, installed: true, alreadyPresent }
111
118
  }
112
119
 
120
+ const BLOCK_BEGIN = "<!-- sable:begin"
121
+ const BLOCK_END = "<!-- sable:end -->"
122
+
123
+ /**
124
+ * The always-loaded half of the setup. A skill loads only when the request
125
+ * names it or its description matches, so "make me a pricing page" in a wired
126
+ * app can skip it entirely: the agent sees Tailwind and a stylesheet and writes
127
+ * its own Button and Modal on Sable's tokens. Project instructions (AGENTS.md
128
+ * for Codex and Cursor, CLAUDE.md for Claude Code) are read on every task, so a
129
+ * few lines there send every UI request through the skill.
130
+ *
131
+ * Short on purpose: it is in context for every task, UI or not, so it points at
132
+ * the skill rather than restating it.
133
+ */
134
+ export function instructionsBlock({ root, project = root }) {
135
+ const projectPath = relative(resolve(root), resolve(project)).replaceAll("\\", "/")
136
+ const sable = sableRunner({ root, project })
137
+ const where = projectPath ? `The UI in \`${projectPath}\`` : "This app's UI"
138
+ return [
139
+ `${BLOCK_BEGIN} — managed by \`sable setup --agent\`; edit outside these markers -->`,
140
+ "## Sable",
141
+ "",
142
+ `${where} is built with Sable (\`@sableui/react\`). For any UI work (a page, a component, styling, layout, theming, a fix), load the \`sable\` skill first: \`/sable\` in Claude Code and Cursor, \`$sable\` in Codex. Do this even when the request does not mention Sable.`,
143
+ "",
144
+ `- Build from the installed components, utilities and tokens: \`${sable} search "<need>"\` before writing a component. Never rebuild a control Sable ships.`,
145
+ `- Before calling UI work done, run \`${sable} check\` and fix what fails.`,
146
+ "- When `check` lists app-built components that Sable already ships (Adoption), show the user that list and ask before moving working screens onto Sable.",
147
+ BLOCK_END,
148
+ ].join("\n")
149
+ }
150
+
151
+ /** The managed block in `text`, as [start, end) offsets, or null. */
152
+ function findBlock(text) {
153
+ const start = text.indexOf(BLOCK_BEGIN)
154
+ if (start === -1) return null
155
+ const end = text.indexOf(BLOCK_END, start)
156
+ if (end === -1) throw new Error("A Sable block in the project instructions has no end marker. Fix or remove it, then rerun setup.")
157
+ return [start, end + BLOCK_END.length]
158
+ }
159
+
160
+ /**
161
+ * Which project-instruction files carry the block, per host: AGENTS.md for
162
+ * Codex and Cursor, CLAUDE.md for Claude Code. A CLAUDE.md that already imports
163
+ * AGENTS.md (`@AGENTS.md`) gets the block through that import, not twice.
164
+ */
165
+ function instructionFiles(root, target) {
166
+ const agents = target === "all" || target === "codex" || target === "cursor"
167
+ const claude = target === "all" || target === "claude"
168
+ const files = agents ? ["AGENTS.md"] : []
169
+ if (claude) {
170
+ const claudePath = join(root, "CLAUDE.md")
171
+ const imports = existsSync(claudePath) && /^@(?:\.\/)?AGENTS\.md\s*$/m.test(readFileSync(claudePath, "utf8"))
172
+ if (!(agents && imports)) files.push("CLAUDE.md")
173
+ }
174
+ return files
175
+ }
176
+
113
177
  /**
114
178
  * Install discoverable loaders; the installed package owns the actual workflow.
115
179
  *
@@ -117,7 +181,7 @@ export function installClaudeStopHook({ root, project = root, dryRun = false })
117
181
  * is where the agent entries go. They differ in a monorepo, where agents start at
118
182
  * the repository root but Sable belongs to one workspace package.
119
183
  */
120
- export function installAgentSkill({ root, project = root, packageRoot, target = "all", update = false, dryRun = false }) {
184
+ export function installAgentSkill({ root, project = root, packageRoot, target = "all", update = false, dryRun = false, instructions = true }) {
121
185
  const reject = (message) => { throw new Error(message) }
122
186
  if (!["all", "codex", "claude", "cursor"].includes(target)) reject("Choose --target all, codex, claude, or cursor.")
123
187
  if (!existsSync(join(project, "package.json"))) reject("Run setup from a project with a package.json.")
@@ -158,14 +222,42 @@ export function installAgentSkill({ root, project = root, packageRoot, target =
158
222
  if (current === file.content) continue
159
223
  if (!update || receipt.files[file.path] !== hash(current)) reject(`Existing skill differs at ${file.path}. Use --update for managed files; keep or move local edits before setup.`)
160
224
  }
225
+ /* The block in AGENTS.md / CLAUDE.md. Only the text between its markers is
226
+ * Sable's; everything around it is the project's and is never touched. It
227
+ * follows the skill files' rule: an unchanged older block refreshes under
228
+ * --update, one with local edits refuses. */
229
+ const block = instructions ? instructionsBlock({ root, project }) : null
230
+ const instructionPaths = block ? instructionFiles(root, target) : []
231
+ const edits = []
232
+ for (const path of instructionPaths) {
233
+ checkPath(path)
234
+ const key = `${path}#sable`
235
+ const current = existsSync(join(root, path)) ? readFileSync(join(root, path), "utf8") : null
236
+ const eol = current?.includes("\r\n") ? "\r\n" : "\n"
237
+ const ours = block.replaceAll("\n", eol)
238
+ const found = current === null ? null : findBlock(current)
239
+ if (found) {
240
+ const existing = current.slice(...found)
241
+ if (existing === ours) continue
242
+ if (!update || receipt.files[key] !== hash(existing.replaceAll("\r\n", "\n"))) reject(`The Sable block in ${path} differs. Use --update to refresh it; keep local edits outside its markers.`)
243
+ edits.push({ path, key, content: current.slice(0, found[0]) + ours + current.slice(found[1]) })
244
+ } else if (current === null) edits.push({ path, key, content: `${block}\n` })
245
+ else edits.push({ path, key, content: `${current.replace(/\s*$/, "")}${eol}${eol}${ours}${eol}` })
246
+ }
161
247
  if (!dryRun) {
162
248
  for (const file of files) {
163
249
  mkdirSync(dirname(join(root, file.path)), { recursive: true })
164
250
  writeFileSync(join(root, file.path), file.content)
165
251
  receipt.files[file.path] = hash(file.content)
166
252
  }
253
+ for (const edit of edits) {
254
+ writeFileSync(join(root, edit.path), edit.content)
255
+ receipt.files[edit.key] = hash(block)
256
+ }
167
257
  mkdirSync(dirname(join(root, receiptPath)), { recursive: true })
168
258
  writeFileSync(join(root, receiptPath), JSON.stringify(receipt, null, 2) + "\n")
169
259
  }
170
- return { destination: destinations[0], destinations, root, project: projectPath || ".", files: files.map(file => file.path), dryRun, update, next: "Use $sable build <your brief> in Codex or /sable build <your brief> in Claude Code or Cursor." }
260
+ return { destination: destinations[0], destinations, root, project: projectPath || ".", files: files.map(file => file.path), instructions: instructionPaths, instructionsChanged: edits.map(edit => edit.path), dryRun, update, next: block
261
+ ? "Ask your agent for what you want to build or change, in plain words. It uses Sable on its own; /sable (Codex: $sable) names it explicitly."
262
+ : "Use $sable build <your brief> in Codex or /sable build <your brief> in Claude Code or Cursor." }
171
263
  }
package/scripts/grade.mjs CHANGED
@@ -37,6 +37,7 @@ import { ignoreGlob, lintText, makeRules, surfaceInventory } from "./lint-rules.
37
37
  import { labelContrastScanner, statusTextDrift } from "./label-contrast.mjs"
38
38
  import { skeletonize } from "./skeleton.mjs"
39
39
  import { assessReview, reviewTemplate } from "./review-evidence.mjs"
40
+ import { adoptionReport, swapLines } from "./adoption.mjs"
40
41
 
41
42
  const HERE = dirname(fileURLToPath(import.meta.url))
42
43
  const PKG_ROOT = join(HERE, "..")
@@ -375,6 +376,8 @@ const routeSkeletons = []
375
376
  of listing from memory. The lookalike-* lint rules fail the unambiguous
376
377
  cases; this lists the rest. */
377
378
  const inventory = []
379
+ /** UI sources for the adoption report: how much of the app is built from Sable. */
380
+ const uiSources = []
378
381
 
379
382
  /**
380
383
  * Attribute names on one JSX open tag, stopping at the tag's own `>`.
@@ -502,6 +505,7 @@ for (const file of files) {
502
505
 
503
506
  /* 7. Surfaces the app built itself (informational). */
504
507
  if (/\.(tsx|jsx)$/.test(file)) {
508
+ uiSources.push({ rel, text })
505
509
  for (const { index, ...s } of surfaceInventory(text)) inventory.push({ file: rel, line: lineOf(text, index), ...s })
506
510
  // Arrow-key handling written by hand: list navigation is behaviour Sable
507
511
  // owns (roving focus, active descendant, typeahead), and the storefront
@@ -632,6 +636,12 @@ if (reviewPath) {
632
636
  }
633
637
  const review = assessReview(reviewDocument, { baseDir: reviewPath ? dirname(resolve(CWD, reviewPath)) : CWD, sourceHash })
634
638
  const clean = !Object.values(counts).some(Boolean)
639
+ let appDeps = null
640
+ try {
641
+ const pkg = JSON.parse(readFileSync(join(CWD, "package.json"), "utf8"))
642
+ appDeps = { ...pkg.dependencies, ...pkg.devDependencies }
643
+ } catch { /* no package.json here: imports alone decide */ }
644
+ const adoption = adoptionReport({ files: uiSources, components: REGISTRY.components ?? [], deps: appDeps })
635
645
  const exitCode = flags.has("strict") && (!clean || review.status !== "review-recorded") ? 1 : 0
636
646
 
637
647
  const result = {
@@ -655,6 +665,7 @@ const result = {
655
665
  byKind: Object.fromEntries(["surface", "media frame", "round"].map((k) => [k, inventory.filter((s) => s.kind === k).length])),
656
666
  surfaces: inventory,
657
667
  },
668
+ adoption,
658
669
  cliUsage,
659
670
  appIcons: APP_ICONS ? { file: APP_ICONS.file, names: APP_ICONS.names.size } : null,
660
671
  findings,
@@ -696,6 +707,12 @@ if (inventory.length) {
696
707
  for (const s of inventory.slice(0, 12)) L(` ${s.file}:${s.line} <${s.tag}> ${s.kind} (${s.classes}) → Sable has ${s.consider}`)
697
708
  if (inventory.length > 12) L(` … ${inventory.length - 12} more (--json lists every one)`)
698
709
  }
710
+ if (adoption.level !== "empty") {
711
+ L()
712
+ L(` adoption (notes, not scored) — ${adoption.level}: ${adoption.summary}`)
713
+ L(` ${adoption.recommendation}`)
714
+ for (const line of swapLines(adoption, 12, " ")) L(line)
715
+ }
699
716
  if (cliUsage) {
700
717
  L()
701
718
  L(` sable CLI ${cliUsage.invocations} invocation(s)`)
@@ -303,7 +303,8 @@ function addTailwindPlugin(text) {
303
303
  */
304
304
  export function fixWiring({ root, pkgRoot, report, framework, dryRun = false, install = true, quiet = false }) {
305
305
  const results = []
306
- const flagged = new Map(report.checks.filter((c) => c.status === "fail" || c.status === "warn" || (c.status === "info" && c.name === "dedupe")).map((c) => [c.name, c]))
306
+ // Adoption is how the app is built, not how it is wired: no fix of ours applies.
307
+ const flagged = new Map(report.checks.filter((c) => c.name !== "adoption" && (c.status === "fail" || c.status === "warn" || (c.status === "info" && c.name === "dedupe"))).map((c) => [c.name, c]))
307
308
  const handled = new Set()
308
309
  const done = (check, detail, file) => {
309
310
  handled.add(check)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: sable
3
- description: Build websites and apps with Sable, develop reusable presets from inspiration, audit interfaces, polish designs, and fix scoped defects using the installed library.
3
+ description: Use for any UI work in a project that depends on @sableui/react (a page, a component, styling, layout, theming, or a UI fix), even when the request does not mention Sable. Builds websites and apps from the installed Sable library, develops reusable presets from inspiration, audits and polishes interfaces, and fixes scoped defects.
4
4
  ---
5
5
 
6
6
  # Sable
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: sable
3
- description: Build original websites and apps with Sable, turn inspiration into reusable presets, audit existing interfaces, polish a selected design, and fix a scoped defect on a short path. Use for Sable design and UI work, not unrelated backend tasks.
3
+ description: Use for any UI work in a project that depends on @sableui/react (a page, a component, styling, layout, theming, or a UI fix), even when the request does not mention Sable. Builds websites and apps from the installed Sable library, turns inspiration into reusable presets, audits and polishes existing interfaces, and fixes scoped defects. Not for unrelated backend tasks.
4
4
  ---
5
5
 
6
6
  # Sable
@@ -35,7 +35,9 @@ the check turns up more than the fix. Say that you escalated, and why.
35
35
  `sable lint`, and `sable grade` and exits 1 if any fails. Repair the findings
36
36
  instead of suppressing them, then run it again until it passes. Act on its
37
37
  notes too: they don't fail the check but name real work, such as trimming the
38
- CSS to the components the app imports (`sable sources`). Also run the
38
+ CSS to the components the app imports (`sable sources`), or an Adoption block
39
+ listing app-built components Sable already ships, which you put to the user
40
+ (see "Adoption" under Verify and deliver). Also run the
39
41
  project's own build and type checks.
40
42
  2. Review the rendered design: `sable docs reviewing-a-design`. Capture desktop and
41
43
  mobile in every promised colour mode, and judge brief fit, composition,
@@ -64,6 +66,9 @@ results, plus any step its notes call for. A passing check is a contract result,
64
66
 
65
67
  These are agent skill modes, not shell commands. Use `$sable` in Codex and
66
68
  `/sable` in Claude Code or Cursor; natural requests work in compatible agents.
69
+ A request that never says "Sable" ("add a pricing page", "make the header
70
+ sticky") in a project that depends on `@sableui/react` is still this skill:
71
+ route it by the rules below and build it from the library.
67
72
 
68
73
  | Mode | Outcome |
69
74
  | --- | --- |
@@ -75,6 +80,11 @@ These are agent skill modes, not shell commands. Use `$sable` in Codex and
75
80
 
76
81
  `create` is an alias for `build`. A bare new-product brief means `build`; an editing
77
82
  follow-up means `polish`, unless it names one defect, which is a `fix`. A request for direction only stops before implementation.
83
+ "Move this app onto Sable" (or convert, migrate, "use Sable properly") is never
84
+ a `build`: that would start the product over. Run `sable check`, show the user
85
+ the Adoption swaps in one short list, ask once which to move, then `polish` them
86
+ a screen at a time without asking again for each. The user should not need to
87
+ know the mode names; route plain requests yourself.
78
88
  “Show alternatives” produces bounded rendered studies and stops at comparison when
79
89
  the user reserves the choice. Explore only when requested or when competing choices
80
90
  materially affect the brief; do not require three designs for every build.
@@ -151,7 +161,11 @@ rather than redoing it:
151
161
 
152
162
  `sable init` installs the discoverable entry files for Codex/Cursor and Claude
153
163
  Code; in an app wired by hand, run `sable setup --agent` once from the project root. `--target codex|claude|cursor|all` selects
154
- hosts. For Claude Code, setup also prints a Stop hook that runs `sable check`
164
+ hosts. Setup also writes a short managed block into `AGENTS.md` and `CLAUDE.md`,
165
+ the instructions every task reads, that sends UI requests through this skill
166
+ even when they don't name it. It replaces only the text between its
167
+ `<!-- sable:begin … -->` and `<!-- sable:end -->` markers; `--no-instructions`
168
+ skips it. For Claude Code, setup also prints a Stop hook that runs `sable check`
155
169
  before a task can end; it writes that hook to `.claude/settings.json` only with
156
170
  `--hook`, so add it only when the user wants it. The hook is one plain command
157
171
  (for example `pnpm exec sable check --stop-hook`) with no `cd` or `&&`, so it runs
@@ -213,7 +227,8 @@ The installed catalog and guides are authoritative for API and token names.
213
227
  consume it. In another app, implement only genuinely app-specific structure;
214
228
  report a missing library hook. Do not modify installed package files or build
215
229
  parallel controls as a shortcut. Preserve existing non-Sable work unless a
216
- migration was requested.
230
+ migration was requested, but don't leave it unmentioned: when `sable check`
231
+ reports adoption `none` or `low`, ask the user (see "Adoption" below).
217
232
 
218
233
  ## Develop or use a preset
219
234
 
@@ -281,6 +296,20 @@ contract violations, design judgments, intentional choices, and verified library
281
296
  gaps. A visual-only review leaves code and unobserved behavior untested. For
282
297
  `polish`, reuse the existing direction and inspect the affected product or preset.
283
298
 
299
+ **Adoption.** An app can be wired for Sable and barely use it: an earlier agent
300
+ never loaded this skill and wrote its own Modal, Button and Tabs on Sable's
301
+ tokens. `sable check` reports it under Adoption (`sable grade --json` →
302
+ `adoption`): how many UI files compose Sable, the app-built components Sable
303
+ already ships (behaviour first), and the app's `foundation`. When the level is
304
+ `none` or `low`, during any mode, ask the user before moving working screens, with
305
+ the host's question tool if it has one. Show the swaps and what each gains
306
+ ("your Modal has no focus trap or Esc; Sable's Dialog has both"). Recommend the
307
+ move plainly when the foundation is `ad-hoc`; offer it gently when the app has
308
+ its `own` primitives; ask whether Sable replaces another `library` or only covers
309
+ new work. Record the answer in the project's instructions, outside the Sable
310
+ block, so the next task doesn't ask again. `sable docs auditing-an-app` has the
311
+ full guide.
312
+
284
313
  Every build and polish ends with the finish checklist near the top of this skill. Notes on it:
285
314
 
286
315
  - `sable check` gives one combined result; while iterating, `sable lint <paths> --json`,
@@ -1,6 +1,6 @@
1
1
  interface:
2
2
  display_name: "Sable"
3
- short_description: "Build, develop presets, audit, and polish with Sable"
3
+ short_description: "Any UI work in a Sable app: build, presets, audit, polish, fix"
4
4
  default_prompt: "Use $sable build to create my idea with the installed Sable components, tokens, and design guides, then inspect and polish the working result."
5
5
  policy:
6
6
  allow_implicit_invocation: true
package/targets/ios.json CHANGED
@@ -78,6 +78,15 @@
78
78
  ],
79
79
  "guidance": "Use the Sable component as it is, in the preset. Adapt layout and touch targets to the phone; do not swap in a native lookalike or restyle it."
80
80
  },
81
+ "ActivityHeatmap": {
82
+ "source": "ActivityHeatmap",
83
+ "strategy": "preserve",
84
+ "importFrom": "@sableui/react",
85
+ "components": [
86
+ "ActivityHeatmap"
87
+ ],
88
+ "guidance": "Use the Sable component as it is, in the preset. Adapt layout and touch targets to the phone; do not swap in a native lookalike or restyle it."
89
+ },
81
90
  "AppHeader": {
82
91
  "source": "AppHeader",
83
92
  "strategy": "replace",
@@ -913,6 +922,15 @@
913
922
  ],
914
923
  "guidance": "Use the Sable component as it is, in the preset. Adapt layout and touch targets to the phone; do not swap in a native lookalike or restyle it."
915
924
  },
925
+ "SectionRail": {
926
+ "source": "SectionRail",
927
+ "strategy": "preserve",
928
+ "importFrom": "@sableui/react",
929
+ "components": [
930
+ "SectionRail"
931
+ ],
932
+ "guidance": "Use the Sable component as it is, in the preset. Adapt layout and touch targets to the phone; do not swap in a native lookalike or restyle it."
933
+ },
916
934
  "SegmentedBar": {
917
935
  "source": "SegmentedBar",
918
936
  "strategy": "preserve",