@sableui/react 0.10.0 → 0.12.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 -4
- package/CHANGELOG.md +184 -0
- package/DESIGN.md +1 -1
- package/README.md +15 -1
- package/bin/sable.mjs +254 -10
- package/dist/components/app-header/app-header.meta.js +2 -1
- package/dist/components/button/button.js +3 -0
- package/dist/components/button/button.meta.js +1 -1
- package/dist/components/button/button.variants.js +1 -1
- package/dist/components/button-group/button-group.js +17 -0
- package/dist/components/button-group/button-group.meta.js +72 -0
- package/dist/components/button-group/index.js +7 -0
- package/dist/components/caret-menu/caret-menu.js +221 -0
- package/dist/components/caret-menu/caret-menu.meta.js +119 -0
- package/dist/components/caret-menu/index.js +8 -0
- package/dist/components/chat-composer/chat-composer.js +30 -7
- package/dist/components/chat-composer/chat-composer.meta.js +27 -8
- package/dist/components/combobox/combobox.meta.js +26 -0
- package/dist/components/input-group/index.js +7 -0
- package/dist/components/input-group/input-group.js +66 -0
- package/dist/components/input-group/input-group.meta.js +106 -0
- package/dist/components/menu/index.js +2 -0
- package/dist/components/menu/menu.js +51 -9
- package/dist/components/menu/menu.meta.js +11 -2
- package/dist/components/menubar/index.js +8 -0
- package/dist/components/menubar/menubar.js +42 -0
- package/dist/components/menubar/menubar.meta.js +99 -0
- package/dist/components/message-scroller/message-scroller.meta.js +4 -0
- package/dist/components/navigation-menu/index.js +19 -0
- package/dist/components/navigation-menu/navigation-menu.js +108 -0
- package/dist/components/navigation-menu/navigation-menu.meta.js +141 -0
- package/dist/components/overflow-menu/overflow-menu.meta.js +13 -1
- package/dist/components/pagination/pagination.js +11 -5
- package/dist/components/pagination/pagination.meta.js +21 -3
- package/dist/components/panel-section/index.js +2 -1
- package/dist/components/panel-section/panel-section.js +15 -0
- package/dist/components/panel-section/panel-section.meta.js +32 -0
- package/dist/components/scroller/scroller.js +8 -4
- package/dist/components/scroller/scroller.meta.js +6 -0
- package/dist/components/sheet/sheet.js +82 -9
- package/dist/components/sheet/sheet.meta.js +41 -4
- package/dist/components/sheet/sheet.variants.js +8 -1
- package/dist/components/sidebar/sidebar.js +13 -2
- package/dist/components/sidebar/sidebar.meta.js +10 -0
- package/dist/components/streaming-text/streaming-text.meta.js +5 -1
- package/dist/file-hashes.json +50 -34
- package/dist/icon-sets/reicon.js +2 -0
- package/dist/index.d.ts +687 -164
- package/dist/index.js +12 -1
- package/dist/lib/taxonomy.js +5 -0
- package/dist/lib/use-caret-trigger.js +187 -0
- package/dist/lib/use-copy.js +6 -1
- package/dist/lib/use-smooth-wheel.js +164 -0
- package/dist/patterns/chat-panel.js +1 -1
- package/dist/patterns/chat-panel.meta.js +1 -0
- package/dist/styles.css +25 -0
- package/llms/components/app-header.md +1 -1
- package/llms/components/button-group.md +55 -0
- package/llms/components/button.md +1 -1
- package/llms/components/caret-menu.md +100 -0
- package/llms/components/chat-composer.md +24 -7
- package/llms/components/combobox.md +12 -0
- package/llms/components/input-group.md +78 -0
- package/llms/components/menu.md +16 -1
- package/llms/components/menubar.md +110 -0
- package/llms/components/message-scroller.md +1 -0
- package/llms/components/navigation-menu.md +113 -0
- package/llms/components/overflow-menu.md +26 -1
- package/llms/components/pagination.md +15 -3
- package/llms/components/panel-section.md +28 -0
- package/llms/components/scroller.md +1 -0
- package/llms/components/sheet.md +18 -3
- package/llms/components/sidebar.md +2 -0
- package/llms/components/streaming-text.md +2 -1
- package/llms/patterns/chat-panel.md +4 -1
- package/llms/tasks/building-a-chat.md +158 -0
- package/llms/tasks/building-with-sable.md +4 -1
- package/llms/tasks/converting-an-app.md +7 -0
- package/llms/tasks/extending-a-component.md +170 -0
- package/llms/tasks/getting-started.md +22 -4
- package/llms/tasks/migrating-from-shadcn.md +351 -0
- package/llms-full.txt +1349 -26
- package/llms.txt +14 -5
- package/package.json +3 -2
- package/registry.json +1037 -53
- package/scripts/lint-rules.mjs +35 -0
- package/scripts/wiring.mjs +541 -0
- package/skills/entry/SKILL.md +3 -2
- package/skills/sable/SKILL.md +26 -5
- package/targets/ios.json +45 -0
- package/dist/lib/use-wheel-to-horizontal.js +0 -65
package/bin/sable.mjs
CHANGED
|
@@ -39,6 +39,7 @@ import { skeletonize } from "../scripts/skeleton.mjs"
|
|
|
39
39
|
import { claudeStopHook, installAgentSkill, installClaudeStopHook } from "../scripts/agent-setup.mjs"
|
|
40
40
|
import { patchChecks } from "../scripts/patches.mjs"
|
|
41
41
|
import { sourcesCheck } from "../scripts/sources.mjs"
|
|
42
|
+
import { detectFramework, detectPackageManager, fixWiring, fontsCheck, providerSnippet, tailwindBuildCheck } from "../scripts/wiring.mjs"
|
|
42
43
|
|
|
43
44
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
44
45
|
const PKG_ROOT = join(HERE, "..")
|
|
@@ -139,7 +140,7 @@ const COMMANDS = [
|
|
|
139
140
|
summary: "One ranked result set across components, providers, patterns, blocks, layout primitives, utilities, tokens, and docs.",
|
|
140
141
|
arg: { name: "query", required: true },
|
|
141
142
|
flags: [
|
|
142
|
-
{ name: "--type", value: "component|provider|pattern|block|layout|utility|token|doc", describe: "Restrict to one kind." },
|
|
143
|
+
{ name: "--type", value: "component|provider|pattern|block|example|layout|utility|token|doc", describe: "Restrict to one kind. `example` finds a component example by what it shows (\"multiple selection\", \"server errors\")." },
|
|
143
144
|
{ name: "--limit", value: "n", describe: "Cap results. Default 20." },
|
|
144
145
|
],
|
|
145
146
|
returns: ["search"],
|
|
@@ -167,9 +168,10 @@ const COMMANDS = [
|
|
|
167
168
|
{ name: "--register", value: "page|workspace|either", describe: "With --list: filter by register." },
|
|
168
169
|
{ name: "--props", value: null, describe: "Props table only." },
|
|
169
170
|
{ name: "--examples", value: null, describe: "Examples only, imports included." },
|
|
171
|
+
{ name: "--example", value: "label", describe: "One example by its label (case-insensitive), imports included. Labels come from --examples or search." },
|
|
170
172
|
{ name: "--a11y", value: null, describe: "Accessibility contract only." },
|
|
171
173
|
],
|
|
172
|
-
returns: ["component.list", "component.detail", "component.props", "component.examples", "component.a11y"],
|
|
174
|
+
returns: ["component.list", "component.detail", "component.props", "component.examples", "component.example", "component.a11y"],
|
|
173
175
|
},
|
|
174
176
|
{
|
|
175
177
|
name: "pattern",
|
|
@@ -246,8 +248,23 @@ const COMMANDS = [
|
|
|
246
248
|
name: "doctor",
|
|
247
249
|
summary: "Check this project's Sable wiring. Exits 1 on any failure.",
|
|
248
250
|
arg: null,
|
|
249
|
-
flags: [
|
|
250
|
-
|
|
251
|
+
flags: [
|
|
252
|
+
{ name: "--fix", value: null, describe: "Apply the fixes that need no judgment (imports in order, @source, missing packages, Vite dedupe and Tailwind plugin, fonts, IconProvider in a starter entry), print the rest, then check again." },
|
|
253
|
+
{ name: "--dry-run", value: null, describe: "Report what --fix would change without writing or installing anything." },
|
|
254
|
+
{ name: "--no-install", value: null, describe: "With --fix: print install commands instead of running them." },
|
|
255
|
+
],
|
|
256
|
+
returns: ["doctor", "doctor.fix"],
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
name: "init",
|
|
260
|
+
summary: "Wire Sable into this app in one step: install what is missing, fix what doctor flags, install the agent skill, print the provider snippet for your root, then run doctor. Vite and Next.js get every step; other setups get the packages and stylesheet, and the rest printed.",
|
|
261
|
+
arg: null,
|
|
262
|
+
flags: [
|
|
263
|
+
{ name: "--dry-run", value: null, describe: "Report every change without writing or installing anything." },
|
|
264
|
+
{ name: "--no-agent", value: null, describe: "Skip the agent skill (sable setup --agent)." },
|
|
265
|
+
{ name: "--no-install", value: null, describe: "Print install commands instead of running them." },
|
|
266
|
+
],
|
|
267
|
+
returns: ["init"],
|
|
251
268
|
},
|
|
252
269
|
{
|
|
253
270
|
name: "grade",
|
|
@@ -337,6 +354,7 @@ const ERROR_CODES = [
|
|
|
337
354
|
{ code: "ERR_PRESET_INVALID", when: "A preset is missing required metadata, semantic values, or uses unsupported tokens or CSS." },
|
|
338
355
|
{ code: "ERR_PRESET_CONTRAST", when: "A label on a brand or status fill (primary, sidebar-primary, highlight, destructive, success, tone-warning, tone-info) misses 4.5:1 or APCA Lc 60. Apply the suggested pair, or record an `exceptions` entry with a reason." },
|
|
339
356
|
{ code: "ERR_AGENT_SETUP", when: "The packaged skill is missing, the destination is linked, or existing local edits would be overwritten." },
|
|
357
|
+
{ code: "ERR_INIT", when: "sable init ran outside an app root (no package.json)." },
|
|
340
358
|
{ code: "ERR_UNKNOWN_COMMAND", when: "The first argument is not a command." },
|
|
341
359
|
{ code: "ERR_MISSING_ARGUMENT", when: "A command that requires an argument got none." },
|
|
342
360
|
{ code: "ERR_UNKNOWN_COMPONENT", when: "No component by that name. `suggestions` carries near matches." },
|
|
@@ -347,6 +365,7 @@ const ERROR_CODES = [
|
|
|
347
365
|
{ code: "ERR_UNKNOWN_UTILITY", when: "No utility or variant by that name. `suggestions` carries near matches." },
|
|
348
366
|
{ code: "ERR_UNKNOWN_DOC", when: "No package guide or doc mirror by that slug. `suggestions` carries near matches and the package guides." },
|
|
349
367
|
{ code: "ERR_UNKNOWN_PROVIDER", when: "No provider by that name. `suggestions` carries near matches." },
|
|
368
|
+
{ code: "ERR_UNKNOWN_EXAMPLE", when: "The component has no example with that label. `suggestions` carries near matches as commands." },
|
|
350
369
|
{ code: "ERR_LINT_INPUT", when: "sable lint --json: a path to lint does not exist." },
|
|
351
370
|
{ code: "ERR_CHECK_FAILED", when: "sable check: doctor, lint, or grade failed. `data.checks` names which." },
|
|
352
371
|
{ code: "ERR_BAD_FLAG", when: "A flag value is outside its allowed set." },
|
|
@@ -630,14 +649,33 @@ function cmdSearch() {
|
|
|
630
649
|
if (!type || type === "doc")
|
|
631
650
|
for (const d of docSlugs())
|
|
632
651
|
pool.push({ kind: "doc", name: d.slug, summary: `${d.kind} mirror`, _s: score({ name: d.slug, text: d.kind }) })
|
|
652
|
+
if (!type || type === "example")
|
|
653
|
+
for (const c of COMPONENTS)
|
|
654
|
+
for (const e of c.examples ?? []) {
|
|
655
|
+
const s = scoreExample(c, e, { q, terms })
|
|
656
|
+
if (s > 0) pool.push({ kind: "example", name: `${c.name} › ${e.label}`, component: c.name, label: e.label, summary: `Uses ${exampleParts(e).join(", ")}.`, _s: s })
|
|
657
|
+
}
|
|
633
658
|
|
|
634
659
|
// Ties resolve in the order AGENTS.md tells an agent to resolve a need:
|
|
635
660
|
// a component first, then a pattern or primitive, then a utility, then a
|
|
636
661
|
// token. "segmented" should land on ToggleGroup before the well behind it.
|
|
637
|
-
const KIND_ORDER = ["component", "provider", "pattern", "block", "layout", "utility", "token", "doc"]
|
|
662
|
+
const KIND_ORDER = ["component", "provider", "pattern", "block", "example", "layout", "utility", "token", "doc"]
|
|
663
|
+
// A popular label ("Controlled value") exists on dozens of components, so
|
|
664
|
+
// examples are capped twice: per component, and as a share of the list, or
|
|
665
|
+
// one query would bury every component under its own examples.
|
|
666
|
+
const perComponent = new Map()
|
|
667
|
+
let examples = 0
|
|
638
668
|
const hits = pool
|
|
639
669
|
.filter((h) => h._s > 0)
|
|
640
670
|
.sort((a, b) => b._s - a._s || KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind))
|
|
671
|
+
.filter((h) => {
|
|
672
|
+
if (h.kind !== "example" || type === "example") return true
|
|
673
|
+
const n = perComponent.get(h.component) ?? 0
|
|
674
|
+
if (n >= 2 || examples >= Math.max(3, Math.ceil(limit / 3))) return false
|
|
675
|
+
perComponent.set(h.component, n + 1)
|
|
676
|
+
examples++
|
|
677
|
+
return true
|
|
678
|
+
})
|
|
641
679
|
.slice(0, limit)
|
|
642
680
|
if (!hits.length)
|
|
643
681
|
fail("ERR_NO_RESULTS", `Nothing matched "${query}".`, [
|
|
@@ -652,7 +690,10 @@ function cmdSearch() {
|
|
|
652
690
|
const results = hits.map(({ _s, ...h }) => ({
|
|
653
691
|
...h,
|
|
654
692
|
...(h.kind === "pattern" || h.kind === "block" ? { reference: true } : {}),
|
|
655
|
-
retrieve:
|
|
693
|
+
retrieve:
|
|
694
|
+
h.kind === "example"
|
|
695
|
+
? `sable component ${h.component} --example "${h.label.replace(/"/g, '\\"')}"`
|
|
696
|
+
: `sable ${h.kind === "doc" ? "docs" : h.kind} ${h.name}`,
|
|
656
697
|
}))
|
|
657
698
|
const anyReference = results.some((h) => h.reference)
|
|
658
699
|
ok("search", { query, count: results.length, results }, () =>
|
|
@@ -669,6 +710,57 @@ function cmdSearch() {
|
|
|
669
710
|
)
|
|
670
711
|
}
|
|
671
712
|
|
|
713
|
+
/** The names an example imports: the parts it composes. */
|
|
714
|
+
const exampleParts = (e) =>
|
|
715
|
+
(e.imports ?? "")
|
|
716
|
+
.split(/[{}]/)[1]
|
|
717
|
+
?.split(",")
|
|
718
|
+
.map((s) => s.trim())
|
|
719
|
+
.filter(Boolean) ?? []
|
|
720
|
+
|
|
721
|
+
/** Every component and prop an example's code uses, lowercased: `<Combobox multiple items=…>` → combobox, multiple, items. */
|
|
722
|
+
const exampleIdentifiers = (e) => {
|
|
723
|
+
const ids = new Set(exampleParts(e).map((p) => p.toLowerCase()))
|
|
724
|
+
for (const [, tag] of (e.code ?? "").matchAll(/<([A-Z]\w*)/g)) ids.add(tag.toLowerCase())
|
|
725
|
+
for (const [, prop] of (e.code ?? "").matchAll(/\s([a-z][A-Za-z]+)(?==|[\s/>])/g)) ids.add(prop.toLowerCase())
|
|
726
|
+
return ids
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* An example is found by what it shows, not by the component it belongs to. The
|
|
731
|
+
* label carries the intent ("Multiple selection", "Server errors"); the code
|
|
732
|
+
* names the parts and props it relies on; the parent component's name counts,
|
|
733
|
+
* but less than the component itself scores, so "Combobox" still lands on the
|
|
734
|
+
* component and "multiple combobox" lands on its example. An example qualifies
|
|
735
|
+
* only by its own label, or — for a phrase — by covering every term with its
|
|
736
|
+
* code: otherwise every example that renders a Button answers "button".
|
|
737
|
+
*/
|
|
738
|
+
function scoreExample(c, e, { q, terms }) {
|
|
739
|
+
const label = e.label.toLowerCase()
|
|
740
|
+
const ids = exampleIdentifiers(e)
|
|
741
|
+
const parent = c.name.toLowerCase()
|
|
742
|
+
const keywords = (c.keywords ?? []).map((k) => k.toLowerCase())
|
|
743
|
+
let s = terms.length > 1 && label === q ? 60 : 0
|
|
744
|
+
let labelHit = false
|
|
745
|
+
let codeHit = false
|
|
746
|
+
let covered = 0
|
|
747
|
+
for (const t of terms) {
|
|
748
|
+
const inLabel = label.includes(t)
|
|
749
|
+
const inCode = ids.has(t) ? 10 : [...ids].some((id) => id.includes(t)) ? 4 : 0
|
|
750
|
+
const inParent = parent === t ? 15 : parent.includes(t) ? 10 : keywords.includes(t) ? 5 : 0
|
|
751
|
+
s += (inLabel ? 30 : 0) + inCode + inParent
|
|
752
|
+
labelHit ||= inLabel
|
|
753
|
+
codeHit ||= inCode > 0
|
|
754
|
+
if (inLabel || inCode || inParent) covered++
|
|
755
|
+
}
|
|
756
|
+
const phrase = terms.length > 1 && covered === terms.length
|
|
757
|
+
if (!labelHit && !(phrase && codeHit)) return 0
|
|
758
|
+
// A single word is a lookup, and the component it names is the answer; a
|
|
759
|
+
// phrase is a need, and the example that covers all of it is.
|
|
760
|
+
if (terms.length === 1) return Math.round(s / 2)
|
|
761
|
+
return phrase ? s + 20 : Math.round((s * covered) / terms.length)
|
|
762
|
+
}
|
|
763
|
+
|
|
672
764
|
/**
|
|
673
765
|
* First sentence, for one-line list rows. The abbreviation guard is not fussiness:
|
|
674
766
|
* summaries in this catalog say things like "A human relative timestamp (e.g. 2h
|
|
@@ -727,6 +819,22 @@ function cmdComponent() {
|
|
|
727
819
|
)
|
|
728
820
|
if (flags.has("examples"))
|
|
729
821
|
return ok("component.examples", { name: c.name, examples: c.examples ?? [] }, () => renderExamples(c))
|
|
822
|
+
if (flags.has("example")) {
|
|
823
|
+
// The retrieve step for a search hit: one example, not the whole set.
|
|
824
|
+
const want = flags.get("example")
|
|
825
|
+
const labels = (c.examples ?? []).map((e) => e.label)
|
|
826
|
+
const quoted = (l) => `sable component ${c.name} --example "${l.replace(/"/g, '\\"')}"`
|
|
827
|
+
if (want === true) fail("ERR_MISSING_ARGUMENT", "--example needs a label.", labels.map(quoted))
|
|
828
|
+
const example = (c.examples ?? []).find((e) => e.label.toLowerCase() === String(want).toLowerCase())
|
|
829
|
+
if (!example) {
|
|
830
|
+
const near = nearest(String(want), labels)
|
|
831
|
+
fail("ERR_UNKNOWN_EXAMPLE", `${c.name} has no example labelled "${want}".`, [
|
|
832
|
+
...(near.length ? near : labels).map(quoted),
|
|
833
|
+
`sable component ${c.name} --examples`,
|
|
834
|
+
])
|
|
835
|
+
}
|
|
836
|
+
return ok("component.example", { name: c.name, example }, () => renderExample(example))
|
|
837
|
+
}
|
|
730
838
|
if (flags.has("a11y")) return ok("component.a11y", { name: c.name, a11y: c.a11y ?? [] }, () => (c.a11y ?? []).map(bullet).join("\n"))
|
|
731
839
|
|
|
732
840
|
const level = detailLevel("compact")
|
|
@@ -796,9 +904,11 @@ function renderUsage(c) {
|
|
|
796
904
|
function renderExamples(c) {
|
|
797
905
|
const ex = c.examples ?? []
|
|
798
906
|
if (!ex.length) return ""
|
|
799
|
-
return `\nExamples:\n${ex
|
|
800
|
-
|
|
801
|
-
|
|
907
|
+
return `\nExamples:\n${ex.map((e) => `\n${renderExample(e)}`).join("\n")}`
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function renderExample(e) {
|
|
911
|
+
return ` ── ${e.label}\n ${e.imports}\n${e.code.split("\n").map((l) => ` ${l}`).join("\n")}`
|
|
802
912
|
}
|
|
803
913
|
|
|
804
914
|
const slugify = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
|
|
@@ -1342,6 +1452,17 @@ function doctorReport() {
|
|
|
1342
1452
|
)
|
|
1343
1453
|
}
|
|
1344
1454
|
|
|
1455
|
+
/* Tailwind in the build, and the theme's fonts: both fail as a page that
|
|
1456
|
+
* renders, just not as Sable — unstyled, or in system fonts. */
|
|
1457
|
+
if (pkg) {
|
|
1458
|
+
const build = tailwindBuildCheck(CWD, detectFramework(CWD))
|
|
1459
|
+
if (build) checks.push(build)
|
|
1460
|
+
}
|
|
1461
|
+
if (usesSable || sableImport.test(cssText)) {
|
|
1462
|
+
const html = collectSources(/\.html$/).map((f) => f.text).join("\n")
|
|
1463
|
+
checks.push(fontsCheck({ css: cssText, source: srcText, html, deps: pkg ? { ...pkg.dependencies, ...pkg.devDependencies } : {} }))
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1345
1466
|
/* Local patches (sable patch): each one is re-checked against the installed
|
|
1346
1467
|
* Sable, and fails when Sable changed a patched file, so an upgrade tells the
|
|
1347
1468
|
* agent to re-run the repro instead of keeping or losing a fix silently. */
|
|
@@ -1354,6 +1475,7 @@ function doctorReport() {
|
|
|
1354
1475
|
}
|
|
1355
1476
|
|
|
1356
1477
|
function cmdDoctor() {
|
|
1478
|
+
if (flags.has("fix") || flags.has("dry-run")) return cmdDoctorFix()
|
|
1357
1479
|
const data = doctorReport()
|
|
1358
1480
|
const { checks, failed } = data
|
|
1359
1481
|
if (JSON_OUT) {
|
|
@@ -1370,6 +1492,127 @@ function cmdDoctor() {
|
|
|
1370
1492
|
process.exit(failed ? 1 : 0)
|
|
1371
1493
|
}
|
|
1372
1494
|
|
|
1495
|
+
/**
|
|
1496
|
+
* A fix counts as applied only once doctor, run again, stops flagging its
|
|
1497
|
+
* check. An edit that did not take is reported as unverified, with what doctor
|
|
1498
|
+
* still says, rather than as done.
|
|
1499
|
+
*/
|
|
1500
|
+
function verifyFixes(fixes, after) {
|
|
1501
|
+
for (const f of fixes) {
|
|
1502
|
+
if (f.status !== "applied") continue
|
|
1503
|
+
const c = after.checks.find((x) => x.name === f.check)
|
|
1504
|
+
if (c && (c.status === "fail" || c.status === "warn" || (f.check === "dedupe" && c.status === "info"))) {
|
|
1505
|
+
f.status = "unverified"
|
|
1506
|
+
f.detail = `${f.detail} Doctor still reports: ${c.detail}`
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
return fixes
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
function fixLines(fixes, indent = " ") {
|
|
1513
|
+
const mark = { applied: "+", "would-apply": "~", manual: "!", failed: "✗", unverified: "?" }
|
|
1514
|
+
const word = { applied: "", "would-apply": "", manual: "by hand: ", failed: "failed: ", unverified: "unverified: " }
|
|
1515
|
+
return fixes
|
|
1516
|
+
.map(
|
|
1517
|
+
(f) =>
|
|
1518
|
+
`${indent}${mark[f.status]} ${f.check.padEnd(14)}${word[f.status]}${f.file ? `${f.file} — ` : ""}${f.detail}\n` +
|
|
1519
|
+
(f.snippet ? `${f.snippet.split("\n").map((l) => `${indent} ${l}`).join("\n")}\n` : ""),
|
|
1520
|
+
)
|
|
1521
|
+
.join("")
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
function cmdDoctorFix() {
|
|
1525
|
+
const dryRun = flags.has("dry-run")
|
|
1526
|
+
const before = doctorReport()
|
|
1527
|
+
const fixes = fixWiring({ root: CWD, pkgRoot: PKG_ROOT, report: before, framework: detectFramework(CWD), dryRun, install: !flags.has("no-install"), quiet: JSON_OUT })
|
|
1528
|
+
const after = dryRun ? before : doctorReport()
|
|
1529
|
+
verifyFixes(fixes, after)
|
|
1530
|
+
const { failed } = after
|
|
1531
|
+
if (JSON_OUT) process.stdout.write(`${JSON.stringify({ type: "doctor.fix", data: { ...after, dryRun, fixes } }, null, DENSE ? 0 : 1)}\n`)
|
|
1532
|
+
else
|
|
1533
|
+
process.stdout.write(
|
|
1534
|
+
[
|
|
1535
|
+
fixes.length ? `${dryRun ? "Would fix (dry run, nothing written):" : "Fixes:"}\n${fixLines(fixes)}` : "Nothing to fix.\n",
|
|
1536
|
+
`${dryRun ? "Now:" : "After:"}\n${doctorLines(after.checks)}`,
|
|
1537
|
+
failed ? `[sable doctor] ✗ ${failed} failure(s)${dryRun ? "" : " left; the steps marked ! need you"}.\n` : "[sable doctor] ✓ wiring holds.\n",
|
|
1538
|
+
].join("\n"),
|
|
1539
|
+
)
|
|
1540
|
+
logInvocation(failed ? 1 : 0)
|
|
1541
|
+
process.exit(failed ? 1 : 0)
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
/* ── init ───────────────────────────────────────────────────────────────────── */
|
|
1545
|
+
|
|
1546
|
+
/**
|
|
1547
|
+
* The install recipe, run: doctor --fix, the agent skill, and the provider the
|
|
1548
|
+
* app still has to mount. Each step is the code its own command runs, so init
|
|
1549
|
+
* can never wire an app differently from what doctor checks.
|
|
1550
|
+
*/
|
|
1551
|
+
function cmdInit() {
|
|
1552
|
+
if (!existsSync(join(CWD, "package.json")))
|
|
1553
|
+
fail("ERR_INIT", `No package.json in ${CWD}.`, ["Run sable init from your app's root.", "Starting fresh: pnpm create vite@latest my-app --template react-ts, then pnpm add @sableui/react && pnpm exec sable init"])
|
|
1554
|
+
const dryRun = flags.has("dry-run")
|
|
1555
|
+
const framework = detectFramework(CWD)
|
|
1556
|
+
const packageManager = detectPackageManager(CWD)
|
|
1557
|
+
const before = doctorReport()
|
|
1558
|
+
const fixes = fixWiring({ root: CWD, pkgRoot: PKG_ROOT, report: before, framework, dryRun, install: !flags.has("no-install"), quiet: JSON_OUT })
|
|
1559
|
+
|
|
1560
|
+
let agent
|
|
1561
|
+
if (flags.has("no-agent")) agent = { status: "skipped" }
|
|
1562
|
+
else
|
|
1563
|
+
try {
|
|
1564
|
+
// update: an unchanged managed entry from an older Sable is refreshed;
|
|
1565
|
+
// one with local edits still refuses, and is reported for the user.
|
|
1566
|
+
const setup = installAgentSkill({ root: CWD, project: CWD, packageRoot: PKG_ROOT, target: "all", update: true, dryRun })
|
|
1567
|
+
agent = { status: dryRun ? "would-apply" : "applied", destinations: setup.destinations, next: setup.next }
|
|
1568
|
+
} catch (error) {
|
|
1569
|
+
agent = { status: "manual", detail: error.message, fix: "sable setup --agent --dry-run" }
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
const after = dryRun ? before : doctorReport()
|
|
1573
|
+
verifyFixes(fixes, after)
|
|
1574
|
+
const mounted = after.checks.find((c) => c.name === "icons")?.status === "pass"
|
|
1575
|
+
const providers = { ...providerSnippet(framework), mounted }
|
|
1576
|
+
const data = { framework: { id: framework.id, label: framework.label }, packageManager, dryRun, fixes, agent, providers, doctor: after }
|
|
1577
|
+
|
|
1578
|
+
if (JSON_OUT) process.stdout.write(`${JSON.stringify({ type: "init", data }, null, DENSE ? 0 : 1)}\n`)
|
|
1579
|
+
else {
|
|
1580
|
+
const agentText = {
|
|
1581
|
+
applied: () => `Agent skill: installed at ${agent.destinations.join(", ")}.`,
|
|
1582
|
+
"would-apply": () => `Agent skill: would install at ${agent.destinations.join(", ")}.`,
|
|
1583
|
+
skipped: () => "Agent skill: skipped (--no-agent).",
|
|
1584
|
+
manual: () => `Agent skill: not installed — ${agent.detail}\n → ${agent.fix}`,
|
|
1585
|
+
}[agent.status]()
|
|
1586
|
+
const iconsFix = fixes.find((f) => f.check === "icons")
|
|
1587
|
+
const providerText = mounted
|
|
1588
|
+
? "Providers: <IconProvider> is mounted. MotionProvider and SableThemeProvider are optional: sable provider --list."
|
|
1589
|
+
: iconsFix?.status === "would-apply"
|
|
1590
|
+
? `Providers: <IconProvider> would be mounted in ${iconsFix.file}.`
|
|
1591
|
+
: iconsFix?.status === "manual"
|
|
1592
|
+
? "Providers: mount IconProvider as shown under icons above."
|
|
1593
|
+
: [`Providers — mount IconProvider once, in ${providers.file}${providers.then ? `, then ${providers.then}` : ""}:`, ...providers.code.split("\n").map((l) => ` ${l}`)].join("\n")
|
|
1594
|
+
process.stdout.write(
|
|
1595
|
+
[
|
|
1596
|
+
`sable init — ${framework.label}, ${packageManager}${dryRun ? " (dry run: nothing written or installed)" : ""}`,
|
|
1597
|
+
...(framework.id === "unknown" ? ["Not Vite or Next.js: init installs the packages and wires the stylesheet it finds; the bundler and provider steps are printed for you."] : []),
|
|
1598
|
+
"",
|
|
1599
|
+
fixes.length ? `${dryRun ? "Would fix:" : "Fixes:"}\n${fixLines(fixes)}` : "Nothing to fix.\n",
|
|
1600
|
+
agentText,
|
|
1601
|
+
"",
|
|
1602
|
+
providerText,
|
|
1603
|
+
"",
|
|
1604
|
+
`Doctor:\n${doctorLines(after.checks)}`,
|
|
1605
|
+
after.failed
|
|
1606
|
+
? `[sable init] ✗ ${after.failed} failure(s) ${dryRun ? "now" : "left; the steps marked ! need you"}. Re-check with sable doctor.`
|
|
1607
|
+
: `[sable init] ✓ wiring holds. Next: ${agent.next ?? "sable docs building-with-sable"}`,
|
|
1608
|
+
"",
|
|
1609
|
+
].join("\n"),
|
|
1610
|
+
)
|
|
1611
|
+
}
|
|
1612
|
+
logInvocation(after.failed ? 1 : 0)
|
|
1613
|
+
process.exit(after.failed ? 1 : 0)
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1373
1616
|
function doctorLines(checks, indent = " ") {
|
|
1374
1617
|
const mark = { pass: "✓", warn: "!", fail: "✗", info: "·" }
|
|
1375
1618
|
return checks
|
|
@@ -1407,7 +1650,7 @@ function collectSources(match, limit = 4000) {
|
|
|
1407
1650
|
|
|
1408
1651
|
|
|
1409
1652
|
function readConfigs() {
|
|
1410
|
-
return ["vite.config.ts", "vite.config.js", "next.config.js", "next.config.mjs", "webpack.config.js"]
|
|
1653
|
+
return ["vite.config.ts", "vite.config.mts", "vite.config.js", "vite.config.mjs", "next.config.ts", "next.config.js", "next.config.mjs", "webpack.config.js"]
|
|
1411
1654
|
.map((f) => (existsSync(join(CWD, f)) ? readFileSync(join(CWD, f), "utf8") : ""))
|
|
1412
1655
|
.join("\n")
|
|
1413
1656
|
}
|
|
@@ -1698,6 +1941,7 @@ const HANDLERS = {
|
|
|
1698
1941
|
utility: cmdUtility,
|
|
1699
1942
|
docs: cmdDocs,
|
|
1700
1943
|
doctor: cmdDoctor,
|
|
1944
|
+
init: cmdInit,
|
|
1701
1945
|
grade: cmdGrade,
|
|
1702
1946
|
learn: cmdLearn,
|
|
1703
1947
|
atlas: () => cmdDesignTool("design-atlas.mjs"),
|
|
@@ -109,6 +109,9 @@ function Button({
|
|
|
109
109
|
children: content,
|
|
110
110
|
onClick: handleClick,
|
|
111
111
|
onKeyDown: handleKeyDown,
|
|
112
|
+
// Read by a ButtonGroup, which draws the seam between a bordered pair
|
|
113
|
+
// differently from a filled one.
|
|
114
|
+
"data-variant": variant ?? "primary",
|
|
112
115
|
...resolvedBleed ? { "data-bleed": resolvedBleed } : null,
|
|
113
116
|
...busy ? { "aria-busy": true } : null,
|
|
114
117
|
// A busy native button stays focusable: aria-disabled, with presses
|
|
@@ -33,7 +33,7 @@ var buttonMeta = {
|
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
35
|
guidance: "do",
|
|
36
|
-
description: "For a copy control, pair an icon Button with `useCopy(text, { timeout? })`. It takes the text when the hook is called and returns `{ copy, copied }`; `copy()` takes no argument, so pass the value the button copies to the hook, not to `copy`."
|
|
36
|
+
description: "For a copy control, pair an icon Button with `useCopy(text, { timeout? })`. It takes the text when the hook is called and returns `{ copy, copied }`; `copy()` takes no argument, so pass the value the button copies to the hook, not to `copy`. Text that costs something to produce (a file to fetch) goes in as a function, `useCopy(() => fetch(url).then((r) => r.text()))`, called only on click."
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
39
|
guidance: "do",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { cva } from "class-variance-authority";
|
|
3
3
|
var ON_COLOR_FOCUS = "focus-visible:ring-current focus-visible:ring-offset-2 focus-visible:ring-offset-transparent";
|
|
4
4
|
var buttonVariants = cva(
|
|
5
|
-
"inline-flex items-center justify-center gap-(--control-gap) whitespace-nowrap rounded-[var(--button-radius,9999px)] motion-interactive motion-press focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:shrink-0 [&_svg]:size-(--button-icon)",
|
|
5
|
+
"inline-flex items-center justify-center gap-(--control-gap) whitespace-nowrap rounded-s-[var(--button-radius-s,var(--button-radius,9999px))] rounded-e-[var(--button-radius-e,var(--button-radius,9999px))] motion-interactive motion-press focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:shrink-0 [&_svg]:size-(--button-icon)",
|
|
6
6
|
{
|
|
7
7
|
variants: {
|
|
8
8
|
variant: {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// src/components/button-group/button-group.tsx
|
|
2
|
+
import { cn } from "../../lib/cn.js";
|
|
3
|
+
import { jsx } from "react/jsx-runtime";
|
|
4
|
+
function ButtonGroup({ className, role = "group", ...props }) {
|
|
5
|
+
return /* @__PURE__ */ jsx(
|
|
6
|
+
"div",
|
|
7
|
+
{
|
|
8
|
+
role,
|
|
9
|
+
"data-slot": "button-group",
|
|
10
|
+
className: cn("inline-flex items-stretch button-group-join", className),
|
|
11
|
+
...props
|
|
12
|
+
}
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
export {
|
|
16
|
+
ButtonGroup
|
|
17
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// src/components/button-group/button-group.meta.ts
|
|
2
|
+
var buttonGroupMeta = {
|
|
3
|
+
name: "ButtonGroup",
|
|
4
|
+
summary: "Buttons joined into one control: a split button (an action beside the chevron that opens its menu) or a short run of related actions. The children stay ordinary Buttons; the group squares their inner corners, collapses bordered seams into one hairline, and draws a seam between filled ones.",
|
|
5
|
+
status: "stable",
|
|
6
|
+
keywords: ["split button", "button group", "segmented actions", "dropdown button", "joined buttons", "toolbar"],
|
|
7
|
+
usage: {
|
|
8
|
+
description: "Use ButtonGroup when one action has close alternatives (Copy page, with Open in\u2026 behind a chevron) or a few actions belong together as one object (zoom out, reset, zoom in). Use ToggleGroup for a choice that stays selected, and Tabs to switch views.",
|
|
9
|
+
bestPractices: [
|
|
10
|
+
{
|
|
11
|
+
guidance: "do",
|
|
12
|
+
description: 'Give the children one height: `size="sm"` with `size="icon-sm"`, `md` with `icon`. The group aligns them but cannot reconcile two heights into one edge.'
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
guidance: "do",
|
|
16
|
+
description: 'For a split button, put the main action first and a Menu whose trigger is the chevron Button second, with an aria-label on the trigger ("More page actions").'
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
guidance: "do",
|
|
20
|
+
description: "Name the group with aria-label when the buttons alone do not say what they act on."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
guidance: "dont",
|
|
24
|
+
description: "Do not round or border the children yourself to make them meet; that is the group's job, and an override breaks under a theme's button shape."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
guidance: "dont",
|
|
28
|
+
description: "Do not use it for a selection that stays on. That is ToggleGroup, which carries the pressed state."
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
related: {
|
|
33
|
+
components: ["Button", "Menu", "Toggle"]
|
|
34
|
+
},
|
|
35
|
+
props: [
|
|
36
|
+
{
|
|
37
|
+
name: "role",
|
|
38
|
+
type: "string",
|
|
39
|
+
default: '"group"',
|
|
40
|
+
description: "Group semantics, so a screen reader announces the buttons as one set. Pass `toolbar` when it carries arrow-key navigation of your own."
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: "className",
|
|
44
|
+
type: "string",
|
|
45
|
+
description: "Layout classes for the group (margins, alignment). The children's look comes from their own variants."
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "...props",
|
|
49
|
+
type: 'ComponentPropsWithRef<"div">',
|
|
50
|
+
description: "All native <div> attributes, including aria-label."
|
|
51
|
+
}
|
|
52
|
+
],
|
|
53
|
+
tokensUsed: [],
|
|
54
|
+
a11y: [
|
|
55
|
+
'Renders role="group", so assistive tech announces the buttons as one set; name it with aria-label.',
|
|
56
|
+
"Each child keeps its own focus stop and label; a focused child is raised above its neighbour so its ring is drawn whole.",
|
|
57
|
+
'An icon-only chevron that opens a menu needs its own aria-label, such as "More page actions".'
|
|
58
|
+
],
|
|
59
|
+
examples: [
|
|
60
|
+
{
|
|
61
|
+
label: "Split button with a menu",
|
|
62
|
+
code: '<ButtonGroup aria-label="Page actions"><Button variant="outline" size="sm" iconStart={<Icon name="copy" />} onClick={copy}>Copy page</Button><Menu><MenuTrigger render={<Button variant="outline" size="icon-sm" aria-label="More page actions" />}><Icon name="chevron-down" /></MenuTrigger><MenuContent align="end"><MenuItem icon={<Icon name="file" />} description="View this page as plain text">View as Markdown</MenuItem></MenuContent></Menu></ButtonGroup>'
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
label: "Related actions",
|
|
66
|
+
code: '<ButtonGroup aria-label="Zoom"><Button variant="secondary" size="icon-sm" aria-label="Zoom out"><Icon name="minus" /></Button><Button variant="secondary" size="sm">100%</Button><Button variant="secondary" size="icon-sm" aria-label="Zoom in"><Icon name="plus" /></Button></ButtonGroup>'
|
|
67
|
+
}
|
|
68
|
+
]
|
|
69
|
+
};
|
|
70
|
+
export {
|
|
71
|
+
buttonGroupMeta
|
|
72
|
+
};
|