@sableui/react 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/AGENTS.md +10 -3
  2. package/CHANGELOG.md +235 -0
  3. package/README.md +11 -4
  4. package/bin/sable.mjs +266 -30
  5. package/dist/{_contract-CDa9-NdB.d.ts → _contract-DlklV0Im.d.ts} +2 -2
  6. package/dist/activity-DT8VK9fx.d.ts +25 -0
  7. package/dist/adapters/github.d.ts +41 -0
  8. package/dist/adapters/github.js +24 -0
  9. package/dist/blocks/index.d.ts +1 -1
  10. package/dist/components/activity-heatmap/activity-heatmap.js +420 -0
  11. package/dist/components/activity-heatmap/activity-heatmap.meta.js +142 -0
  12. package/dist/components/activity-heatmap/index.js +9 -0
  13. package/dist/components/avatar/avatar.js +16 -7
  14. package/dist/components/avatar/avatar.meta.js +43 -10
  15. package/dist/components/avatar/avatar.variants.js +26 -2
  16. package/dist/components/code-block/code-block.js +5 -3
  17. package/dist/components/code-block/code-block.meta.js +6 -0
  18. package/dist/components/collapsible/collapsible.js +6 -5
  19. package/dist/components/collapsible/collapsible.meta.js +14 -5
  20. package/dist/components/data-table/data-table.js +132 -45
  21. package/dist/components/data-table/data-table.meta.js +63 -4
  22. package/dist/components/date-picker/date-picker.js +13 -6
  23. package/dist/components/date-picker/date-picker.meta.js +22 -0
  24. package/dist/components/dialog/dialog.js +25 -5
  25. package/dist/components/dialog/dialog.meta.js +26 -5
  26. package/dist/components/media-image/media-image.js +3 -1
  27. package/dist/components/media-image/media-image.meta.js +15 -1
  28. package/dist/components/media-tile/media-tile.js +13 -1
  29. package/dist/components/media-tile/media-tile.meta.js +10 -0
  30. package/dist/components/menu/menu.js +2 -0
  31. package/dist/components/menu/menu.meta.js +2 -2
  32. package/dist/components/page-header/page-header.js +9 -4
  33. package/dist/components/page-header/page-header.meta.js +26 -1
  34. package/dist/components/panel-group/panel-group.js +17 -1
  35. package/dist/components/panel-group/panel-group.meta.js +43 -3
  36. package/dist/components/pill/pill.meta.js +15 -6
  37. package/dist/components/pill/pill.variants.js +1 -0
  38. package/dist/components/search-input/search-input.js +30 -5
  39. package/dist/components/search-input/search-input.meta.js +29 -4
  40. package/dist/components/section-header/section-header.js +27 -17
  41. package/dist/components/section-header/section-header.meta.js +27 -5
  42. package/dist/components/sheet/sheet.js +15 -3
  43. package/dist/components/sheet/sheet.meta.js +2 -2
  44. package/dist/components/side-nav/side-nav.variants.js +8 -3
  45. package/dist/components/sidebar/index.js +4 -0
  46. package/dist/components/sidebar/sidebar.js +90 -11
  47. package/dist/components/sidebar/sidebar.meta.js +38 -4
  48. package/dist/components/table/table.js +7 -3
  49. package/dist/components/tabs/tabs.js +10 -2
  50. package/dist/components/tabs/tabs.meta.js +4 -0
  51. package/dist/components/workspace/workspace.js +13 -3
  52. package/dist/components/workspace/workspace.meta.js +109 -1
  53. package/dist/file-hashes.json +94 -80
  54. package/dist/foundations/typography.js +13 -0
  55. package/dist/icon/Icon.js +21 -2
  56. package/dist/icon/index.d.ts +36 -1
  57. package/dist/icon/index.js +5 -0
  58. package/dist/icon/names.js +240 -0
  59. package/dist/icon-sets/reicon.js +41 -1
  60. package/dist/index.d.ts +540 -75
  61. package/dist/index.js +18 -1
  62. package/dist/layout/AspectBox.js +2 -5
  63. package/dist/layout/Bleed.js +10 -6
  64. package/dist/layout/Center.js +11 -3
  65. package/dist/layout/Columns.js +6 -3
  66. package/dist/layout/Grid.js +14 -6
  67. package/dist/layout/GridOverlay.js +7 -7
  68. package/dist/layout/Region.js +18 -3
  69. package/dist/layout/Row.js +27 -20
  70. package/dist/layout/Spacer.js +13 -2
  71. package/dist/layout/SplitPane.js +1 -1
  72. package/dist/layout/Stack.js +25 -18
  73. package/dist/layout/index.d.ts +57 -6
  74. package/dist/lib/chart/activity.js +32 -0
  75. package/dist/lib/chart/calendar.js +67 -0
  76. package/dist/lib/chart/chart-tooltip.js +2 -2
  77. package/dist/lib/chart/heat.js +55 -0
  78. package/dist/lib/cn.js +1 -0
  79. package/dist/lib/focus-return.js +53 -0
  80. package/dist/lib/taxonomy.js +1 -0
  81. package/dist/lib/use-media-query.js +41 -0
  82. package/dist/{meta-DX5r5wQL.d.ts → meta-Dfm9AExe.d.ts} +6 -0
  83. package/dist/patterns/dashboard-page.js +23 -17
  84. package/dist/patterns/index.d.ts +7 -2
  85. package/dist/patterns/index.js +5 -1
  86. package/dist/patterns/tracker-page.js +392 -0
  87. package/dist/patterns/tracker-page.meta.js +40 -0
  88. package/dist/presets/index.d.ts +1 -1
  89. package/dist/styles.css +301 -188
  90. package/dist/{theme-provider-BydfM5Z0.d.ts → theme-provider-BUBealk_.d.ts} +1 -1
  91. package/dist/themes/_contract.js +9 -1
  92. package/dist/themes/atelier/index.d.ts +1 -1
  93. package/dist/themes/ember/index.d.ts +1 -1
  94. package/dist/themes/index.d.ts +2 -2
  95. package/dist/themes/meridian/index.d.ts +1 -1
  96. package/dist/themes/poppy/index.d.ts +1 -1
  97. package/dist/themes/prism/index.d.ts +1 -1
  98. package/dist/themes/sable/index.d.ts +1 -1
  99. package/dist/themes/verdant/index.d.ts +1 -1
  100. package/llms/components/activity-heatmap.md +133 -0
  101. package/llms/components/avatar.md +32 -9
  102. package/llms/components/code-block.md +1 -0
  103. package/llms/components/collapsible.md +12 -4
  104. package/llms/components/data-table.md +40 -3
  105. package/llms/components/date-picker.md +15 -1
  106. package/llms/components/dialog.md +34 -3
  107. package/llms/components/media-image.md +12 -0
  108. package/llms/components/media-tile.md +21 -0
  109. package/llms/components/menu.md +1 -1
  110. package/llms/components/page-header.md +16 -1
  111. package/llms/components/panel-group.md +34 -2
  112. package/llms/components/pill.md +24 -5
  113. package/llms/components/search-input.md +37 -4
  114. package/llms/components/section-header.md +28 -4
  115. package/llms/components/sheet.md +2 -2
  116. package/llms/components/sidebar.md +17 -4
  117. package/llms/components/tabs.md +1 -0
  118. package/llms/components/workspace.md +81 -1
  119. package/llms/foundations/typography.md +4 -1
  120. package/llms/layout/aspect-box.md +1 -1
  121. package/llms/layout/bleed.md +1 -1
  122. package/llms/layout/center.md +1 -1
  123. package/llms/layout/columns.md +1 -1
  124. package/llms/layout/grid.md +1 -1
  125. package/llms/layout/region.md +1 -1
  126. package/llms/layout/row.md +9 -1
  127. package/llms/layout/spacer.md +1 -1
  128. package/llms/layout/split-pane.md +1 -1
  129. package/llms/layout/stack.md +1 -1
  130. package/llms/patterns/dashboard-page.md +18 -15
  131. package/llms/patterns/tracker-page.md +450 -0
  132. package/llms/reference/token-intent.md +3 -1
  133. package/llms/reference/utilities.md +4 -1
  134. package/llms/tasks/auditing-an-app.md +35 -1
  135. package/llms/tasks/building-a-page.md +3 -2
  136. package/llms/tasks/building-a-tool.md +101 -5
  137. package/llms/tasks/building-with-sable.md +11 -3
  138. package/llms/tasks/creating-a-preset.md +9 -0
  139. package/llms/tasks/getting-started.md +10 -3
  140. package/llms/tasks/using-presets.md +1 -2
  141. package/llms-full.txt +1215 -89
  142. package/llms.txt +11 -9
  143. package/package.json +7 -1
  144. package/registry.json +1021 -70
  145. package/scripts/adoption.mjs +252 -0
  146. package/scripts/agent-setup.mjs +110 -18
  147. package/scripts/app-css.mjs +129 -0
  148. package/scripts/grade.mjs +178 -2
  149. package/scripts/lint-rules.mjs +126 -0
  150. package/scripts/wiring.mjs +2 -1
  151. package/skills/entry/SKILL.md +1 -1
  152. package/skills/sable/SKILL.md +64 -6
  153. package/skills/sable/agents/openai.yaml +1 -1
  154. package/targets/ios.json +9 -0
@@ -0,0 +1,252 @@
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
+ property: ["PropertyRow"], propertyrow: ["PropertyRow"], proprow: ["PropertyRow"], picker: ["Select", "Combobox"],
102
+ statusmark: ["StatusDot"], statusindicator: ["StatusDot"], presencedot: ["StatusDot"], avatarstack: ["AvatarGroup"],
103
+ facepile: ["AvatarGroup"], topnav: ["WorkspaceBar", "AppHeader"], appbar: ["WorkspaceBar", "AppHeader"],
104
+ headerbar: ["WorkspaceBar", "AppHeader"], themetoggle: ["ThemeSwitcher"], modetoggle: ["ThemeSwitcher"],
105
+ darkmodetoggle: ["ThemeSwitcher"], colormodeswitch: ["ThemeSwitcher"],
106
+ }
107
+
108
+ /**
109
+ * The Sable components an app-defined name stands for: its whole name or its
110
+ * last word or two, as a Sable name (`Button`, `PrimaryButton`) or an alias
111
+ * (`Modal`, `SearchModal` → Dialog).
112
+ */
113
+ function matcher(components) {
114
+ const byName = new Map(components.map((c) => [c.name.toLowerCase(), c]))
115
+ // Parts count: `PropertyRow`, `WorkspaceBar` and `AvatarGroup` are exported
116
+ // names an app's `Property` or `TeamAvatars` stands in for. A part inherits
117
+ // its entry's group, so a part of a behaviour component is behaviour too.
118
+ for (const c of components)
119
+ for (const s of c.subcomponents ?? []) {
120
+ const name = typeof s === "string" ? s : s.name
121
+ if (name && !byName.has(name.toLowerCase())) byName.set(name.toLowerCase(), { name, group: c.group, partOf: c.name })
122
+ }
123
+ return (appName) => {
124
+ if (STRUCTURAL.test(appName)) return []
125
+ const w = words(appName)
126
+ const candidates = [...new Set([appName, w.slice(-2).join(""), w.at(-1)].filter(Boolean).map(normalize))]
127
+ for (const key of candidates) {
128
+ if (byName.has(key)) return [byName.get(key)]
129
+ const alias = ALIASES[key]?.map((name) => byName.get(name.toLowerCase())).filter(Boolean)
130
+ if (alias?.length) return alias
131
+ }
132
+ return []
133
+ }
134
+ }
135
+
136
+ /**
137
+ * @param {{ files: { rel: string, text: string }[], components: object[], deps?: Record<string, string> | null }} input
138
+ * files: the app's .tsx/.jsx sources, paths relative to the project.
139
+ */
140
+ export function adoptionReport({ files, components, deps = null }) {
141
+ const match = matcher(components)
142
+ const sableUsed = new Set()
143
+ const libraries = new Map()
144
+ let uiFiles = 0
145
+ let sableFiles = 0
146
+ let ownPrimitives = 0
147
+ const swaps = []
148
+
149
+ for (const { rel, text } of files) {
150
+ if (!/\.(tsx|jsx)$/.test(rel) || !rendersJsx(text)) continue
151
+ uiFiles++
152
+ const imported = new Set()
153
+ for (const m of text.matchAll(IMPORT)) {
154
+ const from = m[2]
155
+ if (/^@sableui\/react(?:\/|$)/.test(from)) {
156
+ for (const raw of (m[1].match(/\{([^}]*)\}/)?.[1] ?? "").split(",")) {
157
+ const name = raw.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0]?.trim()
158
+ if (name) imported.add(name)
159
+ }
160
+ continue
161
+ }
162
+ const lib = LIBRARIES.find(([re]) => re.test(from))?.[1] ?? (SHADCN.test(from) ? "shadcn/ui-style components/ui" : null)
163
+ if (lib) libraries.set(lib, (libraries.get(lib) ?? 0) + 1)
164
+ }
165
+ if (imported.size) sableFiles++
166
+ for (const name of imported) sableUsed.add(name)
167
+
168
+ const declared = new Map()
169
+ 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]))
170
+ // Each component's body runs to the next declaration: close enough to
171
+ // tell whose markup a handler or a role belongs to.
172
+ const starts = [...declared.values()].sort((a, b) => a - b)
173
+ const own = PRIMITIVES_DIR.test(rel)
174
+ for (const [name, index] of declared) {
175
+ const hits = match(name)
176
+ if (!hits.length) continue
177
+ if (own) ownPrimitives++
178
+ // Built on the Sable component it is named for: a composition, not a rebuild.
179
+ if (hits.some((c) => imported.has(c.name))) continue
180
+ const behaviour = hits.some(isBehaviour)
181
+ const body = text.slice(index, starts.find((s) => s > index) ?? text.length)
182
+ if (behaviour && !HAND_BEHAVIOUR.some((re) => re.test(body))) continue
183
+ swaps.push({ file: rel, line: lineOf(text, index), name, sable: hits.map((c) => c.name), behaviour, ...(hits[0].partOf ? { partOf: hits[0].partOf } : {}) })
184
+ }
185
+ }
186
+
187
+ // A dependency counts even before its first import is found in these paths.
188
+ for (const dep of Object.keys(deps ?? {})) {
189
+ const lib = LIBRARIES.find(([re]) => re.test(dep))?.[1]
190
+ if (lib && !libraries.has(lib)) libraries.set(lib, 0)
191
+ }
192
+
193
+ swaps.sort((a, b) => Number(b.behaviour) - Number(a.behaviour))
194
+ const share = uiFiles ? sableFiles / uiFiles : 0
195
+ const behaviourSwaps = swaps.filter((s) => s.behaviour).length
196
+ const level =
197
+ uiFiles === 0 ? "empty"
198
+ : sableFiles === 0 && uiFiles <= 2 ? "fresh"
199
+ : sableFiles === 0 ? "none"
200
+ : share < 0.5 || behaviourSwaps >= 3 ? "low"
201
+ : "sable"
202
+ const foundation = libraries.size ? "library" : level === "sable" ? "sable" : ownPrimitives >= 4 ? "own" : "ad-hoc"
203
+
204
+ return {
205
+ informationalOnly: true,
206
+ level,
207
+ foundation,
208
+ uiFiles,
209
+ sableFiles,
210
+ share: +share.toFixed(2),
211
+ sableComponents: [...sableUsed].sort(),
212
+ libraries: [...libraries].map(([name, files]) => ({ name, files })),
213
+ swaps,
214
+ summary: summaryOf({ level, uiFiles, sableFiles, sableUsed, swaps }),
215
+ recommendation: recommendationOf({ level, foundation, libraries, behaviourSwaps }),
216
+ }
217
+ }
218
+
219
+ function summaryOf({ level, uiFiles, sableFiles, sableUsed, swaps }) {
220
+ if (level === "empty") return "No UI files yet."
221
+ if (level === "fresh") return `${uiFiles} UI file(s), none using Sable yet. A starter: build on Sable from here.`
222
+ const built = swaps.length ? `; ${swaps.length} app-built component(s) named for something Sable ships` : ""
223
+ return `${sableFiles} of ${uiFiles} UI files compose Sable (${sableUsed.size} distinct export${sableUsed.size === 1 ? "" : "s"})${built}.`
224
+ }
225
+
226
+ /**
227
+ * What the agent should do with this, in words it can relay. Never "rewrite it":
228
+ * the user decides whether working screens move. The foundation decides how
229
+ * strongly to recommend it.
230
+ */
231
+ function recommendationOf({ level, foundation, libraries, behaviourSwaps }) {
232
+ if (level === "empty" || level === "fresh") return "Build new UI from Sable: sable search \"<need>\" before writing a component."
233
+ const names = [...libraries.keys()].join(", ")
234
+ if (level === "sable")
235
+ return libraries.size
236
+ ? `Sable is the foundation, alongside ${names}. Keep new UI on Sable; offer the swaps listed when a screen is next touched.`
237
+ : behaviourSwaps
238
+ ? "Sable is the foundation. Offer the few swaps listed when those screens are next touched; new UI starts with sable search."
239
+ : "Sable is the foundation. Keep composing from it; new UI starts with sable search."
240
+ if (foundation === "library")
241
+ 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.`
242
+ if (foundation === "own")
243
+ return "This app has its own primitives. Offer the swaps listed, behaviour first, and keep the app's own where the user prefers them."
244
+ 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."
245
+ }
246
+
247
+ /** One line per swap, for text output. */
248
+ export function swapLines(report, limit = 12, indent = " ") {
249
+ const lines = report.swaps.slice(0, limit).map((s) => `${indent}${s.file}:${s.line} ${s.name} → Sable has ${s.sable.join(" or ")}${s.partOf ? ` (part of ${s.partOf})` : ""}${s.behaviour ? " (behaviour)" : ""}`)
250
+ if (report.swaps.length > limit) lines.push(`${indent}… ${report.swaps.length - limit} more: sable grade <paths> --json → adoption.swaps`)
251
+ return lines
252
+ }
@@ -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
  }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * app-css — what an app's own stylesheets do to Sable that its TSX does not show.
3
+ *
4
+ * The surface inventory and the part mirrors read className strings. An app that
5
+ * styles semantic classes in a .css file hides the rest: a work tracker passed a
6
+ * 100/100 grade with 48/42/34/25px bar and row heights, `.issue-table th { height:
7
+ * 25px; padding … }` re-laying Sable's table from outside, a disclosure chevron
8
+ * rotated by `[aria-expanded=false] svg`, and its light colours overridden on
9
+ * :root with nothing for dark — so dark mode rendered Sable's dark palette under
10
+ * the app's light accents, and nobody had a control to see it.
11
+ *
12
+ * Everything here is a note for the reuse inventory except `modes`, which grade
13
+ * reports as a warning: a half-themed palette is a defect a user sees the moment
14
+ * they switch modes, while a fixed height or a reached-in selector can be a
15
+ * deliberate choice. Lexical, like the rest of grade; it parses enough CSS to
16
+ * know each declaration's selector and the @-rules around it.
17
+ */
18
+
19
+ /** Every declaration block with its selector and enclosing @-rule preludes. */
20
+ export function cssRules(css) {
21
+ const src = css.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
22
+ const rules = []
23
+ const stack = []
24
+ let start = 0
25
+ let quote = null
26
+ for (let i = 0; i < src.length; i++) {
27
+ const c = src[i]
28
+ if (quote) {
29
+ if (c === "\\") i++
30
+ else if (c === quote) quote = null
31
+ continue
32
+ }
33
+ if (c === '"' || c === "'") quote = c
34
+ else if (c === "{") {
35
+ const prelude = src.slice(start, i).split(";").at(-1).trim()
36
+ stack.push({ prelude, index: start + src.slice(start, i).lastIndexOf(prelude), body: i + 1 })
37
+ start = i + 1
38
+ } else if (c === "}") {
39
+ const top = stack.pop()
40
+ if (top && !top.prelude.startsWith("@")) {
41
+ const body = src.slice(start, i)
42
+ const context = stack.map((s) => s.prelude)
43
+ // Nested rules: `.a { .b { … } }` — the declarations are the block's own text.
44
+ rules.push({ selector: top.prelude, context, body, index: top.index, bodyIndex: top.body })
45
+ }
46
+ start = i + 1
47
+ }
48
+ }
49
+ return rules
50
+ }
51
+
52
+ const declarations = (body) =>
53
+ [...body.matchAll(/(?:^|;|\})\s*(--[\w-]+|[a-z-]+)\s*:\s*([^;{}]+)/g)].map((m) => ({ prop: m[1], value: m[2].trim(), offset: m.index + m[0].indexOf(m[1]) }))
54
+
55
+ const lineOf = (text, index) => text.slice(0, index).split("\n").length
56
+
57
+ /** A selector's last compound names a bar, a row, a header or a table cell. */
58
+ const BAR_OR_ROW = /(?:^|[\s>+~])(?:th|td|tr|thead|header)(?![\w-])|\.[\w-]*(?:bar|row|header|cell|tab|toolbar|item)(?![\w-])/
59
+ /** The density ladder's own names, re-declared by hand. */
60
+ const LADDER = /^--(?:control-(?:xs|sm|md|lg|cta)|chrome-bar-height|status-bar-height)$/
61
+
62
+ /**
63
+ * Notes on one stylesheet. `semantic` is the theme contract's colour token
64
+ * names, used by `modes` across all of an app's stylesheets, not here.
65
+ */
66
+ export function cssNotes(css) {
67
+ const notes = []
68
+ const seen = new Set()
69
+ const add = (index, kind, what, consider) => {
70
+ const key = `${kind}|${what}`
71
+ if (seen.has(key)) return
72
+ seen.add(key)
73
+ notes.push({ line: lineOf(css, index), kind, what, consider })
74
+ }
75
+ for (const r of cssRules(css)) {
76
+ const decls = declarations(r.body)
77
+ const at = (d) => r.bodyIndex + d.offset
78
+ if (/\[(?:aria-expanded|data-panel-open|data-open|data-state)[^\]]*\][^{,]*\bsvg\b/.test(r.selector) && decls.some((d) => /^(?:transform|rotate)$/.test(d.prop) && /rotate/.test(`${d.prop} ${d.value}`))) {
79
+ add(r.index, "rebuilt part", `${r.selector} rotates a disclosure chevron by hand`, "CollapsibleIndicator, which flips with the panel and eases (Accordion, Select and Menu triggers already flip theirs)")
80
+ }
81
+ if (/(?:^|[\s>+~])(?:th|td|thead|tr)(?![\w-])/.test(r.selector) && /^[.#[]/.test(r.selector.trim())) {
82
+ const props = decls.filter((d) => /^(?:height|min-height|padding|padding-block|padding-inline|font-size|font-weight)$/.test(d.prop))
83
+ if (props.length) add(r.index, "override", `${r.selector} { ${props.map((d) => d.prop).join(", ")} } re-lays a Sable table from outside`, "the table's density (rows follow a Workspace or Density scope), a column's className for its width, CardContent flush for edge padding")
84
+ }
85
+ if (/\[data-(?:selected|highlighted|active|checked|pressed)\]/.test(r.selector) && /^[.#]/.test(r.selector.trim())) {
86
+ add(r.index, "override", `${r.selector} restyles a Sable state from outside`, "the state's own tokens set on the theme (--muted for a selected row), or the component's variant")
87
+ }
88
+ for (const d of decls) {
89
+ const px = /^(\d+(?:\.\d+)?)px$/.exec(d.value)?.[1]
90
+ if (/^(?:height|min-height)$/.test(d.prop) && px && Number(px) >= 16 && Number(px) <= 64 && BAR_OR_ROW.test(r.selector)) {
91
+ add(at(d), "fixed height", `${r.selector} { ${d.prop}: ${d.value} }`, "the density ladder: --chrome-bar-height, --status-bar-height or --control-* follow the register (WorkspaceBar, StatusBar, a Density scope) instead of pinning one size")
92
+ }
93
+ }
94
+ const ladder = decls.filter((d) => LADDER.test(d.prop) && /^\d/.test(d.value))
95
+ if (ladder.length) {
96
+ const where = [...r.context.filter((c) => c.startsWith("@media")), r.selector].join(" ")
97
+ add(at(ladder[0]), "fixed height", `${where} { ${ladder.map((d) => `${d.prop}: ${d.value}`).join("; ")} } re-declares the density ladder`, "Workspace's density prop or <Density value=…>; the compact and comfortable ladders are already measured")
98
+ }
99
+ }
100
+ return notes
101
+ }
102
+
103
+ /** The selectors and @-rules that put a declaration in the dark mode. */
104
+ const DARK = /\.dark\b|\[data-(?:mode|theme|color-scheme)=["']?dark|prefers-color-scheme:\s*dark|:root\.dark|html\.dark|\[data-dark\]/
105
+
106
+ /**
107
+ * Light and dark coverage of the theme's colour tokens across every stylesheet.
108
+ * Returns the tokens an app sets only for light, and whether it set any dark
109
+ * values at all. A preset carries both modes; hand-written :root overrides
110
+ * usually carry one.
111
+ */
112
+ export function modeCoverage(sheets, semantic) {
113
+ const light = new Map()
114
+ const dark = new Set()
115
+ for (const { rel, text } of sheets) {
116
+ for (const r of cssRules(text)) {
117
+ const scope = [...r.context, r.selector].join(" ")
118
+ const isDark = DARK.test(scope)
119
+ for (const d of declarations(r.body)) {
120
+ const name = d.prop.slice(2)
121
+ if (!d.prop.startsWith("--") || !semantic.has(name)) continue
122
+ if (isDark) dark.add(name)
123
+ else if (!light.has(name)) light.set(name, { file: rel, line: lineOf(text, r.index) })
124
+ }
125
+ }
126
+ }
127
+ const lightOnly = [...light].filter(([name]) => !dark.has(name)).map(([name, at]) => ({ name, ...at }))
128
+ return { light: light.size, dark: dark.size, lightOnly }
129
+ }