@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.
- package/AGENTS.md +10 -3
- package/CHANGELOG.md +132 -0
- package/README.md +11 -4
- package/bin/sable.mjs +160 -18
- package/dist/activity-DT8VK9fx.d.ts +25 -0
- package/dist/adapters/github.d.ts +41 -0
- package/dist/adapters/github.js +24 -0
- package/dist/blocks/approval-block.js +30 -48
- package/dist/blocks/approval-block.meta.js +7 -8
- package/dist/blocks/index.d.ts +1 -1
- package/dist/components/activity-heatmap/activity-heatmap.js +420 -0
- package/dist/components/activity-heatmap/activity-heatmap.meta.js +142 -0
- package/dist/components/activity-heatmap/index.js +9 -0
- package/dist/components/code-block/code-block.js +5 -3
- package/dist/components/code-block/code-block.meta.js +6 -0
- package/dist/components/dialog/dialog.js +25 -5
- package/dist/components/dialog/dialog.meta.js +26 -5
- package/dist/components/media-image/media-image.js +3 -1
- package/dist/components/media-image/media-image.meta.js +15 -1
- package/dist/components/media-tile/media-tile.js +13 -1
- package/dist/components/media-tile/media-tile.meta.js +10 -0
- package/dist/components/page-header/page-header.js +9 -4
- package/dist/components/page-header/page-header.meta.js +26 -1
- package/dist/components/pill/pill.meta.js +15 -6
- package/dist/components/pill/pill.variants.js +1 -0
- package/dist/components/search-input/search-input.js +30 -5
- package/dist/components/search-input/search-input.meta.js +29 -4
- package/dist/components/section-header/section-header.js +27 -17
- package/dist/components/section-header/section-header.meta.js +27 -5
- package/dist/components/section-rail/index.js +7 -0
- package/dist/components/section-rail/section-rail.js +153 -0
- package/dist/components/section-rail/section-rail.meta.js +189 -0
- package/dist/components/sheet/sheet.js +15 -3
- package/dist/components/sheet/sheet.meta.js +2 -2
- package/dist/file-hashes.json +53 -39
- package/dist/icon-sets/reicon.js +23 -1
- package/dist/index.d.ts +364 -27
- package/dist/index.js +11 -1
- package/dist/layout/AspectBox.js +2 -5
- package/dist/layout/Bleed.js +10 -6
- package/dist/layout/Center.js +11 -3
- package/dist/layout/Columns.js +6 -3
- package/dist/layout/Grid.js +14 -6
- package/dist/layout/GridOverlay.js +7 -7
- package/dist/layout/Region.js +18 -3
- package/dist/layout/Row.js +27 -20
- package/dist/layout/Spacer.js +13 -2
- package/dist/layout/SplitPane.js +1 -1
- package/dist/layout/Stack.js +25 -18
- package/dist/layout/index.d.ts +57 -6
- package/dist/lib/chart/activity.js +32 -0
- package/dist/lib/chart/calendar.js +67 -0
- package/dist/lib/chart/chart-tooltip.js +2 -2
- package/dist/lib/chart/heat.js +55 -0
- package/dist/lib/focus-return.js +53 -0
- package/dist/lib/taxonomy.js +2 -0
- package/dist/lib/use-active-section.js +59 -0
- package/dist/styles.css +122 -114
- package/llms/blocks/approval-block.md +40 -55
- package/llms/components/activity-heatmap.md +133 -0
- package/llms/components/code-block.md +1 -0
- package/llms/components/dialog.md +34 -3
- package/llms/components/media-image.md +12 -0
- package/llms/components/media-tile.md +21 -0
- package/llms/components/page-header.md +16 -1
- package/llms/components/pill.md +24 -5
- package/llms/components/search-input.md +37 -4
- package/llms/components/section-header.md +28 -4
- package/llms/components/section-rail.md +101 -0
- package/llms/components/sheet.md +2 -2
- package/llms/layout/aspect-box.md +1 -1
- package/llms/layout/bleed.md +1 -1
- package/llms/layout/center.md +1 -1
- package/llms/layout/columns.md +1 -1
- package/llms/layout/grid.md +1 -1
- package/llms/layout/region.md +1 -1
- package/llms/layout/row.md +9 -1
- package/llms/layout/spacer.md +1 -1
- package/llms/layout/split-pane.md +1 -1
- package/llms/layout/stack.md +1 -1
- package/llms/tasks/auditing-an-app.md +31 -0
- package/llms/tasks/getting-started.md +10 -3
- package/llms/tasks/using-presets.md +1 -2
- package/llms-full.txt +516 -90
- package/llms.txt +5 -3
- package/package.json +6 -1
- package/registry.json +745 -46
- package/scripts/adoption.mjs +239 -0
- package/scripts/agent-setup.mjs +110 -18
- package/scripts/grade.mjs +17 -0
- package/scripts/wiring.mjs +2 -1
- package/skills/entry/SKILL.md +1 -1
- package/skills/sable/SKILL.md +33 -4
- package/skills/sable/agents/openai.yaml +1 -1
- 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
|
+
}
|
package/scripts/agent-setup.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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),
|
|
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)`)
|
package/scripts/wiring.mjs
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/skills/entry/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: sable
|
|
3
|
-
description:
|
|
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
|
package/skills/sable/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: sable
|
|
3
|
-
description:
|
|
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`)
|
|
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.
|
|
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: "
|
|
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",
|