@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.
- package/AGENTS.md +10 -3
- package/CHANGELOG.md +235 -0
- package/README.md +11 -4
- package/bin/sable.mjs +266 -30
- package/dist/{_contract-CDa9-NdB.d.ts → _contract-DlklV0Im.d.ts} +2 -2
- 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/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/avatar/avatar.js +16 -7
- package/dist/components/avatar/avatar.meta.js +43 -10
- package/dist/components/avatar/avatar.variants.js +26 -2
- package/dist/components/code-block/code-block.js +5 -3
- package/dist/components/code-block/code-block.meta.js +6 -0
- package/dist/components/collapsible/collapsible.js +6 -5
- package/dist/components/collapsible/collapsible.meta.js +14 -5
- package/dist/components/data-table/data-table.js +132 -45
- package/dist/components/data-table/data-table.meta.js +63 -4
- package/dist/components/date-picker/date-picker.js +13 -6
- package/dist/components/date-picker/date-picker.meta.js +22 -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/menu/menu.js +2 -0
- package/dist/components/menu/menu.meta.js +2 -2
- package/dist/components/page-header/page-header.js +9 -4
- package/dist/components/page-header/page-header.meta.js +26 -1
- package/dist/components/panel-group/panel-group.js +17 -1
- package/dist/components/panel-group/panel-group.meta.js +43 -3
- 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/sheet/sheet.js +15 -3
- package/dist/components/sheet/sheet.meta.js +2 -2
- package/dist/components/side-nav/side-nav.variants.js +8 -3
- package/dist/components/sidebar/index.js +4 -0
- package/dist/components/sidebar/sidebar.js +90 -11
- package/dist/components/sidebar/sidebar.meta.js +38 -4
- package/dist/components/table/table.js +7 -3
- package/dist/components/tabs/tabs.js +10 -2
- package/dist/components/tabs/tabs.meta.js +4 -0
- package/dist/components/workspace/workspace.js +13 -3
- package/dist/components/workspace/workspace.meta.js +109 -1
- package/dist/file-hashes.json +94 -80
- package/dist/foundations/typography.js +13 -0
- package/dist/icon/Icon.js +21 -2
- package/dist/icon/index.d.ts +36 -1
- package/dist/icon/index.js +5 -0
- package/dist/icon/names.js +240 -0
- package/dist/icon-sets/reicon.js +41 -1
- package/dist/index.d.ts +540 -75
- package/dist/index.js +18 -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/cn.js +1 -0
- package/dist/lib/focus-return.js +53 -0
- package/dist/lib/taxonomy.js +1 -0
- package/dist/lib/use-media-query.js +41 -0
- package/dist/{meta-DX5r5wQL.d.ts → meta-Dfm9AExe.d.ts} +6 -0
- package/dist/patterns/dashboard-page.js +23 -17
- package/dist/patterns/index.d.ts +7 -2
- package/dist/patterns/index.js +5 -1
- package/dist/patterns/tracker-page.js +392 -0
- package/dist/patterns/tracker-page.meta.js +40 -0
- package/dist/presets/index.d.ts +1 -1
- package/dist/styles.css +301 -188
- package/dist/{theme-provider-BydfM5Z0.d.ts → theme-provider-BUBealk_.d.ts} +1 -1
- package/dist/themes/_contract.js +9 -1
- package/dist/themes/atelier/index.d.ts +1 -1
- package/dist/themes/ember/index.d.ts +1 -1
- package/dist/themes/index.d.ts +2 -2
- package/dist/themes/meridian/index.d.ts +1 -1
- package/dist/themes/poppy/index.d.ts +1 -1
- package/dist/themes/prism/index.d.ts +1 -1
- package/dist/themes/sable/index.d.ts +1 -1
- package/dist/themes/verdant/index.d.ts +1 -1
- package/llms/components/activity-heatmap.md +133 -0
- package/llms/components/avatar.md +32 -9
- package/llms/components/code-block.md +1 -0
- package/llms/components/collapsible.md +12 -4
- package/llms/components/data-table.md +40 -3
- package/llms/components/date-picker.md +15 -1
- 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/menu.md +1 -1
- package/llms/components/page-header.md +16 -1
- package/llms/components/panel-group.md +34 -2
- 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/sheet.md +2 -2
- package/llms/components/sidebar.md +17 -4
- package/llms/components/tabs.md +1 -0
- package/llms/components/workspace.md +81 -1
- package/llms/foundations/typography.md +4 -1
- 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/patterns/dashboard-page.md +18 -15
- package/llms/patterns/tracker-page.md +450 -0
- package/llms/reference/token-intent.md +3 -1
- package/llms/reference/utilities.md +4 -1
- package/llms/tasks/auditing-an-app.md +35 -1
- package/llms/tasks/building-a-page.md +3 -2
- package/llms/tasks/building-a-tool.md +101 -5
- package/llms/tasks/building-with-sable.md +11 -3
- package/llms/tasks/creating-a-preset.md +9 -0
- package/llms/tasks/getting-started.md +10 -3
- package/llms/tasks/using-presets.md +1 -2
- package/llms-full.txt +1215 -89
- package/llms.txt +11 -9
- package/package.json +7 -1
- package/registry.json +1021 -70
- package/scripts/adoption.mjs +252 -0
- package/scripts/agent-setup.mjs +110 -18
- package/scripts/app-css.mjs +129 -0
- package/scripts/grade.mjs +178 -2
- package/scripts/lint-rules.mjs +126 -0
- package/scripts/wiring.mjs +2 -1
- package/skills/entry/SKILL.md +1 -1
- package/skills/sable/SKILL.md +64 -6
- package/skills/sable/agents/openai.yaml +1 -1
- 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
|
+
}
|
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
|
}
|
|
@@ -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
|
+
}
|