@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
package/scripts/grade.mjs
CHANGED
|
@@ -33,10 +33,12 @@ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"
|
|
|
33
33
|
import { createHash } from "node:crypto"
|
|
34
34
|
import { dirname, extname, join, relative, resolve } from "node:path"
|
|
35
35
|
import { fileURLToPath } from "node:url"
|
|
36
|
-
import { ignoreGlob, lintText, makeRules, surfaceInventory } from "./lint-rules.mjs"
|
|
36
|
+
import { compositionNotes, ignoreGlob, lintText, makeRules, partMirrors, surfaceInventory } from "./lint-rules.mjs"
|
|
37
|
+
import { cssNotes, modeCoverage } from "./app-css.mjs"
|
|
37
38
|
import { labelContrastScanner, statusTextDrift } from "./label-contrast.mjs"
|
|
38
39
|
import { skeletonize } from "./skeleton.mjs"
|
|
39
40
|
import { assessReview, reviewTemplate } from "./review-evidence.mjs"
|
|
41
|
+
import { adoptionReport, swapLines } from "./adoption.mjs"
|
|
40
42
|
|
|
41
43
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
42
44
|
const PKG_ROOT = join(HERE, "..")
|
|
@@ -112,6 +114,31 @@ const motionTokens = new Set(
|
|
|
112
114
|
const RULES = makeRules({ semanticTokens, motionTokens, labelContrast: await labelContrastScanner(PKG_ROOT, REGISTRY.themes?.default), statusText: (css) => statusTextDrift(css, REGISTRY.themes?.default) }).filter((r) => r.scope !== "package")
|
|
113
115
|
const PROP_SURFACE = REGISTRY.propSurface?.items ?? {}
|
|
114
116
|
const ICONS = new Set(REGISTRY.iconContract?.names ?? [])
|
|
117
|
+
/** Other sets' names <Icon> resolves to a contract name (`menu` → bars): they draw, so they are not off-contract. */
|
|
118
|
+
const ICON_ALIASES = REGISTRY.iconContract?.aliases ?? {}
|
|
119
|
+
const iconKnown = (n) => ICONS.has(n) || Boolean(ICON_ALIASES[n]) || Boolean(APP_ICONS?.names.has(n))
|
|
120
|
+
/** The contract names nearest an unknown one: sharing a word first, then by edit distance. */
|
|
121
|
+
function nearestIcons(name, limit = 3) {
|
|
122
|
+
const distance = (a, b) => {
|
|
123
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
124
|
+
for (let i = 1; i <= a.length; i++) {
|
|
125
|
+
let prev = row[0]
|
|
126
|
+
row[0] = i
|
|
127
|
+
for (let j = 1; j <= b.length; j++) {
|
|
128
|
+
const cur = row[j]
|
|
129
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1))
|
|
130
|
+
prev = cur
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return row[b.length]
|
|
134
|
+
}
|
|
135
|
+
const parts = new Set(name.toLowerCase().split(/[-_\s]+/))
|
|
136
|
+
return [...ICONS]
|
|
137
|
+
.map((c) => ({ c, s: distance(name.toLowerCase(), c) - (c.split("-").some((p) => parts.has(p)) ? 3 : 0) }))
|
|
138
|
+
.sort((a, b) => a.s - b.s)
|
|
139
|
+
.slice(0, limit)
|
|
140
|
+
.map((x) => x.c)
|
|
141
|
+
}
|
|
115
142
|
/**
|
|
116
143
|
* The names an app adds with `composeIconSets(appExtras, reiconIconSet)`. That is
|
|
117
144
|
* the sanctioned way to extend the set, and the grade cannot read a set's names
|
|
@@ -375,6 +402,18 @@ const routeSkeletons = []
|
|
|
375
402
|
of listing from memory. The lookalike-* lint rules fail the unambiguous
|
|
376
403
|
cases; this lists the rest. */
|
|
377
404
|
const inventory = []
|
|
405
|
+
/** UI sources for the adoption report: how much of the app is built from Sable. */
|
|
406
|
+
const uiSources = []
|
|
407
|
+
/** The app's own stylesheets, read for what its TSX does not show (app-css.mjs). */
|
|
408
|
+
const cssSources = []
|
|
409
|
+
/**
|
|
410
|
+
* Rebuilt parts and overrides: elements whose class names mirror a Sable part,
|
|
411
|
+
* and what the app's CSS does to Sable from outside. Notes for the reuse
|
|
412
|
+
* inventory, never scored: each is answered there ("used the part" or "kept
|
|
413
|
+
* mine, because …").
|
|
414
|
+
*/
|
|
415
|
+
const rebuiltParts = []
|
|
416
|
+
const PART_NAMES = new Set((REGISTRY.components ?? []).flatMap((c) => [c.name, ...(c.subcomponents ?? []).map((s) => (typeof s === "string" ? s : s.name))]))
|
|
378
417
|
|
|
379
418
|
/**
|
|
380
419
|
* Attribute names on one JSX open tag, stopping at the tag's own `>`.
|
|
@@ -434,6 +473,11 @@ for (const file of files) {
|
|
|
434
473
|
findings.contract.push({ ...v, file: rel })
|
|
435
474
|
}
|
|
436
475
|
|
|
476
|
+
if (ctx.isStylesheet) {
|
|
477
|
+
cssSources.push({ rel, text })
|
|
478
|
+
for (const n of cssNotes(text)) rebuiltParts.push({ file: rel, ...n })
|
|
479
|
+
}
|
|
480
|
+
|
|
437
481
|
if (!/\.(tsx|jsx|ts|js)$/.test(extname(file) ? file : "")) continue
|
|
438
482
|
|
|
439
483
|
/* 2. Imports that name something the subpath they come from does not export.
|
|
@@ -496,13 +540,18 @@ for (const file of files) {
|
|
|
496
540
|
for (const [local, { name }] of imported) {
|
|
497
541
|
if (name !== "Icon") continue
|
|
498
542
|
for (const m of code.matchAll(new RegExp(`<${local}\\s+[^>]*name=["']([^"']+)["']`, "g"))) {
|
|
499
|
-
if (!
|
|
543
|
+
if (!iconKnown(m[1])) findings.offContractIcon.push({ file: rel, name: m[1], suggest: nearestIcons(m[1]), line: lineOf(text, m.index) })
|
|
500
544
|
}
|
|
501
545
|
}
|
|
502
546
|
|
|
503
547
|
/* 7. Surfaces the app built itself (informational). */
|
|
504
548
|
if (/\.(tsx|jsx)$/.test(file)) {
|
|
549
|
+
uiSources.push({ rel, text })
|
|
505
550
|
for (const { index, ...s } of surfaceInventory(text)) inventory.push({ file: rel, line: lineOf(text, index), ...s })
|
|
551
|
+
for (const m of partMirrors(text, PART_NAMES))
|
|
552
|
+
rebuiltParts.push({ file: rel, line: lineOf(text, m.index), kind: "rebuilt part", what: `<${m.tag} className="${m.token}">`, consider: `${m.part}${m.hint ? ` (${m.hint})` : ""}` })
|
|
553
|
+
for (const n of compositionNotes(text, PART_NAMES))
|
|
554
|
+
rebuiltParts.push({ file: rel, line: lineOf(text, n.index), kind: "composition", what: n.what, consider: n.consider })
|
|
506
555
|
// Arrow-key handling written by hand: list navigation is behaviour Sable
|
|
507
556
|
// owns (roving focus, active descendant, typeahead), and the storefront
|
|
508
557
|
// search wrote its own. A note, since a canvas or a game has its own keys.
|
|
@@ -521,6 +570,106 @@ for (const file of files) {
|
|
|
521
570
|
}
|
|
522
571
|
}
|
|
523
572
|
|
|
573
|
+
/* 6b. Icon names that reach <Icon> through the app's own wrapper. A work tracker
|
|
574
|
+
wrote `function Glyph({ name }) { return <Icon name={aliases[name] || name} /> }`
|
|
575
|
+
and passed it `users`, `menu`, `columns`: nine names the set lacks, each an
|
|
576
|
+
icon that rendered nothing, none of them visible to the check above, which
|
|
577
|
+
reads only literal `<Icon name="…">`. A component that forwards one of its
|
|
578
|
+
props into Icon's `name` (or into another wrapper's) is a wrapper; the
|
|
579
|
+
literals passed to it are judged, after its alias map if it has one. An
|
|
580
|
+
alias map is itself a note: it renames names the set does not have. */
|
|
581
|
+
const iconAliasNotes = []
|
|
582
|
+
{
|
|
583
|
+
const DECL = /(?:^|\n)[ \t]*(?:export\s+(?:default\s+)?)?(?:function\s+([A-Z]\w*)\s*\(\s*\{([^}]*)\}|const\s+([A-Z]\w*)\s*(?::[^=\n]+)?=\s*\(\s*\{([^}]*)\}[^\n]*?=>)/g
|
|
584
|
+
const wrappers = new Map() // name → { prop, target, aliases, file, line }
|
|
585
|
+
const sources = uiSources.map(({ rel, text }) => {
|
|
586
|
+
const icons = [...sableImports(text)].filter(([, v]) => v.name === "Icon").map(([local]) => local)
|
|
587
|
+
const decls = [...text.matchAll(DECL)].map((m) => ({ name: m[1] ?? m[3], params: (m[2] ?? m[4]).split(",").map((p) => p.trim().split(/[\s:=]/)[0]).filter(Boolean), index: m.index }))
|
|
588
|
+
return { rel, text, icons, decls }
|
|
589
|
+
})
|
|
590
|
+
const forwardsTo = (body, target, prop, params) => {
|
|
591
|
+
for (const m of body.matchAll(new RegExp(`<${target}\\b[^>]*?\\b${prop}=\\{([^}]*)\\}`, "g"))) {
|
|
592
|
+
const param = params.find((p) => new RegExp(`\\b${p}\\b`).test(m[1]))
|
|
593
|
+
if (param) return { param, expr: m[1] }
|
|
594
|
+
}
|
|
595
|
+
return null
|
|
596
|
+
}
|
|
597
|
+
for (let round = 0; round < 3; round++) {
|
|
598
|
+
for (const { rel, text, icons, decls } of sources) {
|
|
599
|
+
decls.forEach((d, i) => {
|
|
600
|
+
if (wrappers.has(d.name)) return
|
|
601
|
+
const body = text.slice(d.index, decls[i + 1]?.index ?? text.length)
|
|
602
|
+
const targets = [...icons.map((t) => [t, "name"]), ...[...wrappers].map(([w, v]) => [w, v.prop])]
|
|
603
|
+
for (const [target, prop] of targets) {
|
|
604
|
+
const hit = forwardsTo(body, target, prop, d.params)
|
|
605
|
+
if (!hit) continue
|
|
606
|
+
// `aliases[name] || name`: the object literal it indexes, read as pairs.
|
|
607
|
+
const mapName = /\b(\w+)\s*\[\s*\w+\s*\]/.exec(hit.expr)?.[1]
|
|
608
|
+
const literal = mapName ? new RegExp(`\\b${mapName}\\b[^=\\n]*=\\s*\\{([^{}]*)\\}`).exec(body)?.[1] : null
|
|
609
|
+
const aliases = literal ? new Map([...literal.matchAll(/["']?([\w-]+)["']?\s*:\s*["']([\w-]+)["']/g)].map((p) => [p[1], p[2]])) : null
|
|
610
|
+
wrappers.set(d.name, { prop: hit.param, target, aliases, file: rel, line: lineOf(text, d.index) })
|
|
611
|
+
break
|
|
612
|
+
}
|
|
613
|
+
})
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
const known = iconKnown
|
|
617
|
+
/** A literal passed to a wrapper, followed through its alias map to the name Icon gets. */
|
|
618
|
+
const resolveIcon = (wrapper, value) => {
|
|
619
|
+
let name = value
|
|
620
|
+
for (let w = wrappers.get(wrapper), hops = 0; w && hops < 5; w = wrappers.get(w.target), hops++) name = w.aliases?.get(name) ?? name
|
|
621
|
+
return name
|
|
622
|
+
}
|
|
623
|
+
for (const [name, w] of wrappers) {
|
|
624
|
+
if (!w.aliases) continue
|
|
625
|
+
const renamed = [...w.aliases].filter(([from, to]) => !ICONS.has(from) && !APP_ICONS?.names.has(from) && known(to))
|
|
626
|
+
for (const [from, to] of w.aliases) if (!known(to)) findings.offContractIcon.push({ file: w.file, name: to, via: `${name} alias map (${from})`, suggest: nearestIcons(to), line: w.line })
|
|
627
|
+
if (renamed.length)
|
|
628
|
+
iconAliasNotes.push({ file: w.file, line: w.line, kind: "icon aliases", what: `${name} renames ${renamed.length} icon name(s) the set does not have: ${renamed.slice(0, 6).map(([a, b]) => `${a}→${b}`).join(", ")}${renamed.length > 6 ? ", …" : ""}`, consider: "the set's own names (sable icon <name> says which exist), or composeIconSets to add the glyphs; a stand-in glyph (archive→inbox) makes two different things look the same" })
|
|
629
|
+
}
|
|
630
|
+
for (const { rel, text } of sources) {
|
|
631
|
+
for (const [name, w] of wrappers) {
|
|
632
|
+
for (const m of text.matchAll(new RegExp(`<${name}\\b[^>]*?\\b${w.prop}=["']([^"']+)["']`, "g"))) {
|
|
633
|
+
const resolved = resolveIcon(name, m[1])
|
|
634
|
+
if (!known(resolved)) findings.offContractIcon.push({ file: rel, name: m[1], via: name, suggest: nearestIcons(resolved), line: lineOf(text, m.index) })
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
rebuiltParts.push(...iconAliasNotes)
|
|
640
|
+
{
|
|
641
|
+
// One line per mirrored class per file (`crumb` ×4 is one finding), and the
|
|
642
|
+
// parts first: what to swap before what to retune.
|
|
643
|
+
const ORDER = ["rebuilt part", "composition", "icon aliases", "override", "fixed height"]
|
|
644
|
+
const grouped = new Map()
|
|
645
|
+
for (const r of rebuiltParts) {
|
|
646
|
+
const key = r.kind === "rebuilt part" ? `${r.file}|${r.what}` : `${r.file}|${r.line}|${r.what}`
|
|
647
|
+
const seen = grouped.get(key)
|
|
648
|
+
if (seen) seen.count = (seen.count ?? 1) + 1
|
|
649
|
+
else grouped.set(key, { ...r })
|
|
650
|
+
}
|
|
651
|
+
rebuiltParts.splice(0, rebuiltParts.length, ...[...grouped.values()].sort((a, b) => ORDER.indexOf(a.kind) - ORDER.indexOf(b.kind) || a.file.localeCompare(b.file) || a.line - b.line))
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/* 9. Both colour modes. An app that retints Sable on :root and writes nothing
|
|
655
|
+
for .dark ships a dark mode nobody designed: Sable's dark palette under the
|
|
656
|
+
app's light accents. A warning, not a score: one mode can be the brief, but
|
|
657
|
+
then the app should say so by never offering the other. */
|
|
658
|
+
const SEMANTIC = new Set((REGISTRY.tokens?.semantic ?? []).map((t) => t.name))
|
|
659
|
+
const coverage = modeCoverage(cssSources, SEMANTIC)
|
|
660
|
+
const MODE_CONTROL = /^(?:ThemeSwitcher|ThemeSelector|useSableTheme)$/
|
|
661
|
+
const modeControl = uiSources.some(({ text }) => [...sableImports(text).values()].some((v) => MODE_CONTROL.test(v.name)))
|
|
662
|
+
const modes = {
|
|
663
|
+
informationalOnly: true,
|
|
664
|
+
lightTokens: coverage.light,
|
|
665
|
+
darkTokens: coverage.dark,
|
|
666
|
+
lightOnly: coverage.lightOnly,
|
|
667
|
+
modeControl,
|
|
668
|
+
status: coverage.lightOnly.length >= 3 ? "warn" : "ok",
|
|
669
|
+
meaning: "Colour tokens the app sets for light with no dark value, and whether it ships a light/dark control. Not scored.",
|
|
670
|
+
fix: 'Put both modes in a preset (sable preset --template, then sable preset --css), and mount ThemeSwitcher under SableThemeProvider so both can be reviewed. A light-only brief pins light (SableThemeProvider defaultPreference="light") and says so.',
|
|
671
|
+
}
|
|
672
|
+
|
|
524
673
|
function lineOf(text, index) {
|
|
525
674
|
return text.slice(0, index).split("\n").length
|
|
526
675
|
}
|
|
@@ -632,6 +781,12 @@ if (reviewPath) {
|
|
|
632
781
|
}
|
|
633
782
|
const review = assessReview(reviewDocument, { baseDir: reviewPath ? dirname(resolve(CWD, reviewPath)) : CWD, sourceHash })
|
|
634
783
|
const clean = !Object.values(counts).some(Boolean)
|
|
784
|
+
let appDeps = null
|
|
785
|
+
try {
|
|
786
|
+
const pkg = JSON.parse(readFileSync(join(CWD, "package.json"), "utf8"))
|
|
787
|
+
appDeps = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
788
|
+
} catch { /* no package.json here: imports alone decide */ }
|
|
789
|
+
const adoption = adoptionReport({ files: uiSources, components: REGISTRY.components ?? [], deps: appDeps })
|
|
635
790
|
const exitCode = flags.has("strict") && (!clean || review.status !== "review-recorded") ? 1 : 0
|
|
636
791
|
|
|
637
792
|
const result = {
|
|
@@ -655,6 +810,9 @@ const result = {
|
|
|
655
810
|
byKind: Object.fromEntries(["surface", "media frame", "round"].map((k) => [k, inventory.filter((s) => s.kind === k).length])),
|
|
656
811
|
surfaces: inventory,
|
|
657
812
|
},
|
|
813
|
+
adoption,
|
|
814
|
+
rebuiltParts: { informationalOnly: true, meaning: "Elements and styles that stand in for a Sable part or reach into one: use the part (or its variant or prop), or record why the app keeps its own. Not scored.", total: rebuiltParts.length, items: rebuiltParts },
|
|
815
|
+
modes,
|
|
658
816
|
cliUsage,
|
|
659
817
|
appIcons: APP_ICONS ? { file: APP_ICONS.file, names: APP_ICONS.names.size } : null,
|
|
660
818
|
findings,
|
|
@@ -696,6 +854,24 @@ if (inventory.length) {
|
|
|
696
854
|
for (const s of inventory.slice(0, 12)) L(` ${s.file}:${s.line} <${s.tag}> ${s.kind} (${s.classes}) → Sable has ${s.consider}`)
|
|
697
855
|
if (inventory.length > 12) L(` … ${inventory.length - 12} more (--json lists every one)`)
|
|
698
856
|
}
|
|
857
|
+
if (rebuiltParts.length) {
|
|
858
|
+
L()
|
|
859
|
+
L(` rebuilt parts and overrides (notes, not scored) — ${rebuiltParts.length}`)
|
|
860
|
+
for (const r of rebuiltParts.slice(0, 12)) L(` ${r.file}:${r.line} ${r.what}${r.count ? ` ×${r.count}` : ""} → ${r.consider}`)
|
|
861
|
+
if (rebuiltParts.length > 12) L(` … ${rebuiltParts.length - 12} more (--json → rebuiltParts.items)`)
|
|
862
|
+
}
|
|
863
|
+
if (modes.status === "warn" || !modeControl) {
|
|
864
|
+
L()
|
|
865
|
+
if (modes.status === "warn") L(` colour modes (warning, not scored) — ${modes.lightOnly.length} colour token(s) set for light only: ${modes.lightOnly.slice(0, 6).map((t) => `--${t.name}`).join(", ")}${modes.lightOnly.length > 6 ? ", …" : ""}`)
|
|
866
|
+
if (!modeControl) L(" colour modes — no ThemeSwitcher, ThemeSelector or useSableTheme: nothing lets anyone switch modes to review both")
|
|
867
|
+
L(` ${modes.fix}`)
|
|
868
|
+
}
|
|
869
|
+
if (adoption.level !== "empty") {
|
|
870
|
+
L()
|
|
871
|
+
L(` adoption (notes, not scored) — ${adoption.level}: ${adoption.summary}`)
|
|
872
|
+
L(` ${adoption.recommendation}`)
|
|
873
|
+
for (const line of swapLines(adoption, 12, " ")) L(line)
|
|
874
|
+
}
|
|
699
875
|
if (cliUsage) {
|
|
700
876
|
L()
|
|
701
877
|
L(` sable CLI ${cliUsage.invocations} invocation(s)`)
|
package/scripts/lint-rules.mjs
CHANGED
|
@@ -399,6 +399,132 @@ export function surfaceInventory(text) {
|
|
|
399
399
|
return out
|
|
400
400
|
}
|
|
401
401
|
|
|
402
|
+
/**
|
|
403
|
+
* Class names that say which Sable part an element is standing in for. An app
|
|
404
|
+
* that writes `<span className="crumb">` and styles `.crumb` in its own CSS
|
|
405
|
+
* passes every token rule and the surface inventory above, which reads only
|
|
406
|
+
* Tailwind's utilities: a work tracker shipped hand-built breadcrumbs, a topbar,
|
|
407
|
+
* property rows, a brand row, status dots and an avatar stack through a 100/100
|
|
408
|
+
* grade that way, each a part Sable ships. Matched on the whole class, or the
|
|
409
|
+
* class minus its leading app words (`issue-toolbar` → `toolbar`), so a word
|
|
410
|
+
* inside a longer name (`property-label`) does not count. Curated, like
|
|
411
|
+
* adoption's aliases: these are names an app gives a rebuilt part.
|
|
412
|
+
*/
|
|
413
|
+
const PART_CLASSES = [
|
|
414
|
+
[/^(?:bread)?crumbs?$/, "Breadcrumbs"],
|
|
415
|
+
[/^(?:top|app|header|title)-?bar$/, "WorkspaceBar", "in a Workspace; AppHeader on a page"],
|
|
416
|
+
[/^tool-?bar$/, "Toolbar"],
|
|
417
|
+
[/^tab-?bar$/, "TabsList"],
|
|
418
|
+
[/^status-?bar$/, "StatusBar"],
|
|
419
|
+
[/^(?:property|prop-row|property-row|meta-row)$/, "PropertyRow"],
|
|
420
|
+
[/^(?:brand|logo)(?:-row)?$/, "SidebarBrand"],
|
|
421
|
+
[/^(?:[a-z]+-)?dot$/, "StatusDot"],
|
|
422
|
+
[/^(?:avatars|avatar-(?:stack|group|list|row)|(?:team|user|member)-avatars|facepile)$/, "AvatarGroup"],
|
|
423
|
+
[/^(?:account|profile)-(?:row|menu|footer|switcher)$|^user-(?:menu|footer)$|^(?:sidebar|nav|rail)-(?:footer|account|user)$/, "SidebarAccount", "in SidebarFooter, as the account Menu's trigger"],
|
|
424
|
+
[/^(?:nav|sidebar|rail)-(?:group|section)(?:-label)?$/, "SidebarGroup"],
|
|
425
|
+
[/^(?:notifications?|(?:notification|file|list|user|member|contact)-(?:row|item))$/, "Entity", "rows in a CardList"],
|
|
426
|
+
[/^(?:kbd|keycap)$/, "Kbd"],
|
|
427
|
+
[/^(?:badge|chip|pill)$/, "Badge"],
|
|
428
|
+
[/^(?:empty-state|blank-state)$/, "EmptyState"],
|
|
429
|
+
[/^(?:spinner|loader)$/, "Spinner"],
|
|
430
|
+
[/^skeleton$/, "Skeleton"],
|
|
431
|
+
[/^(?:divider|separator)$/, "Separator"],
|
|
432
|
+
[/^progress(?:-bar|-track)?$/, "Progress"],
|
|
433
|
+
[/^(?:modal|dialog)$/, "Dialog"],
|
|
434
|
+
[/^(?:drawer|side-sheet)$/, "Sheet"],
|
|
435
|
+
[/^segmented(?:-control)?$/, "ToggleGroup"],
|
|
436
|
+
]
|
|
437
|
+
/** Tailwind utilities, whose last word can look like a part name (`bg-card`). */
|
|
438
|
+
const UTILITY_CLASS = /^(?:bg|text|border|ring|shadow|fill|stroke|outline|from|to|via|divide|accent|caret|decoration|placeholder|size|w|h|min|max|p[xytrbl]?|m[xytrbl]?|gap|rounded|motion|sable)-/
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* The elements in one file whose class names mirror a Sable part, for the
|
|
442
|
+
* reuse inventory. `parts` is the set of names Sable exports (components and
|
|
443
|
+
* their parts), so a map entry the catalog does not have is never reported.
|
|
444
|
+
* An element that already holds the part (`<div className="tabbar"><TabsList>`)
|
|
445
|
+
* is a layout wrapper, not a rebuild, and is skipped.
|
|
446
|
+
*/
|
|
447
|
+
export function partMirrors(text, parts) {
|
|
448
|
+
const src = maskMarkupTemplates(stripComments(text))
|
|
449
|
+
const out = []
|
|
450
|
+
for (const m of openingTags(src, /^[a-z][a-z0-9]*$/)) {
|
|
451
|
+
if (insideRender(src, m.index)) continue
|
|
452
|
+
for (const token of classesOf(m.attrs).plain) {
|
|
453
|
+
if (UTILITY_CLASS.test(token) || !/^[a-z][a-z0-9-]*$/.test(token)) continue
|
|
454
|
+
const words = token.split("-")
|
|
455
|
+
const candidates = words.map((_, i) => words.slice(i).join("-"))
|
|
456
|
+
const hit = PART_CLASSES.find(([re]) => candidates.some((c) => re.test(c)))
|
|
457
|
+
if (!hit || !parts.has(hit[1])) continue
|
|
458
|
+
if (new RegExp(`<${hit[1]}\\b`).test(elementContent(src, m).inner)) continue
|
|
459
|
+
out.push({ index: m.index, tag: m.tag, token, part: hit[1], ...(hit[2] ? { hint: hit[2] } : {}) })
|
|
460
|
+
break
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return out
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Sable parts composed in a way another part already does better: the shape a
|
|
468
|
+
* records workspace drifted into when nothing said otherwise. Read lexically
|
|
469
|
+
* from one file's JSX, so each is a note for the reuse inventory, never a
|
|
470
|
+
* failure. `parts` is the set of names the installed catalog exports, so a note
|
|
471
|
+
* never points at a part this version does not have.
|
|
472
|
+
*/
|
|
473
|
+
const COMPOSITION_NOTES = [
|
|
474
|
+
{
|
|
475
|
+
within: "Sidebar",
|
|
476
|
+
has: /<SideNav\b/,
|
|
477
|
+
what: "SideNav inside a Sidebar",
|
|
478
|
+
consider: "SidebarNav with SidebarGroup and SidebarItem: the rail's own rows, on the same columns as SidebarBrand and SidebarAccount (SideNav brings another set)",
|
|
479
|
+
needs: "SidebarGroup",
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
within: "SidebarFooter",
|
|
483
|
+
has: /<(?:Entity|Badge)\b/,
|
|
484
|
+
what: "an Entity or Badge in the rail's footer",
|
|
485
|
+
consider: "SidebarAccount as the account Menu's trigger: one row, with settings and appearance in its menu and a detail line in place of a badge",
|
|
486
|
+
needs: "SidebarAccount",
|
|
487
|
+
},
|
|
488
|
+
{
|
|
489
|
+
within: "CollapsiblePanel",
|
|
490
|
+
has: /<DataTable\b/,
|
|
491
|
+
what: "a DataTable per group inside Collapsibles",
|
|
492
|
+
consider: "one DataTable with groupBy and groups: one header, columns that line up, a count and an add action per group",
|
|
493
|
+
needs: "DataTable",
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
within: "WorkspaceBar",
|
|
497
|
+
has: /<TabsList(?![^>]*variant=)/,
|
|
498
|
+
what: "underline tabs in a WorkspaceBar",
|
|
499
|
+
consider: "TabsList variant=\"pill\": a view switch in tool chrome reads as a control beside the bar's others; underline is the page-section idiom",
|
|
500
|
+
needs: "Tabs",
|
|
501
|
+
},
|
|
502
|
+
]
|
|
503
|
+
|
|
504
|
+
export function compositionNotes(text, parts) {
|
|
505
|
+
const src = stripComments(text)
|
|
506
|
+
const out = []
|
|
507
|
+
for (const note of COMPOSITION_NOTES) {
|
|
508
|
+
if (!parts.has(note.needs)) continue
|
|
509
|
+
const open = new RegExp(`<${note.within}\\b[^>]*>`, "g")
|
|
510
|
+
for (const m of src.matchAll(open)) {
|
|
511
|
+
const end = src.indexOf(`</${note.within}>`, m.index)
|
|
512
|
+
if (end < 0) continue
|
|
513
|
+
const inner = src.slice(m.index + m[0].length, end)
|
|
514
|
+
const hit = note.has.exec(inner)
|
|
515
|
+
if (!hit) continue
|
|
516
|
+
out.push({ index: m.index + m[0].length + hit.index, what: note.what, consider: note.consider })
|
|
517
|
+
break
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
// A native date field: the OS draws its own format and size, beside a
|
|
521
|
+
// product that formats dates its own way everywhere else.
|
|
522
|
+
const date = /<(?:input|Input)\b[^>]*\btype=["']date["']/.exec(src)
|
|
523
|
+
if (date && parts.has("DatePicker"))
|
|
524
|
+
out.push({ index: date.index, what: "a native date input", consider: "DatePicker (size=\"xs\" in a property row, formatOptions for the trigger text)" })
|
|
525
|
+
return out
|
|
526
|
+
}
|
|
527
|
+
|
|
402
528
|
/**
|
|
403
529
|
* Build the rule table against a token contract.
|
|
404
530
|
*
|
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,11 @@ 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`), an Adoption block
|
|
39
|
+
listing app-built components Sable already ships, which you put to the user
|
|
40
|
+
(see "Adoption" under Verify and deliver), a Rebuilt parts block (elements
|
|
41
|
+
and app CSS standing in for a part, or reaching into one), and a colour-modes
|
|
42
|
+
warning, which you fix rather than explain. Also run the
|
|
39
43
|
project's own build and type checks.
|
|
40
44
|
2. Review the rendered design: `sable docs reviewing-a-design`. Capture desktop and
|
|
41
45
|
mobile in every promised colour mode, and judge brief fit, composition,
|
|
@@ -49,8 +53,8 @@ the check turns up more than the fix. Say that you escalated, and why.
|
|
|
49
53
|
`"reduced"`), state still changes and nothing keeps moving.
|
|
50
54
|
6. Write the reuse inventory: the Sable components, parts, utilities, and token
|
|
51
55
|
groups used, and every app-owned UI or CSS addition with the catalog search
|
|
52
|
-
that justified it. Start from the app-owned surfaces
|
|
53
|
-
which are notes, not failures: for each, write one line saying you used the
|
|
56
|
+
that justified it. Start from the app-owned surfaces and rebuilt parts
|
|
57
|
+
`sable check` lists, which are notes, not failures: for each, write one line saying you used the
|
|
54
58
|
Sable part named beside it or kept your own, and why ("composed our own
|
|
55
59
|
product tile from AspectBox + Badge: the brief wants the price over the
|
|
56
60
|
photo"). Keeping your own layout is fine. Behaviour still comes from Sable.
|
|
@@ -64,6 +68,9 @@ results, plus any step its notes call for. A passing check is a contract result,
|
|
|
64
68
|
|
|
65
69
|
These are agent skill modes, not shell commands. Use `$sable` in Codex and
|
|
66
70
|
`/sable` in Claude Code or Cursor; natural requests work in compatible agents.
|
|
71
|
+
A request that never says "Sable" ("add a pricing page", "make the header
|
|
72
|
+
sticky") in a project that depends on `@sableui/react` is still this skill:
|
|
73
|
+
route it by the rules below and build it from the library.
|
|
67
74
|
|
|
68
75
|
| Mode | Outcome |
|
|
69
76
|
| --- | --- |
|
|
@@ -75,6 +82,11 @@ These are agent skill modes, not shell commands. Use `$sable` in Codex and
|
|
|
75
82
|
|
|
76
83
|
`create` is an alias for `build`. A bare new-product brief means `build`; an editing
|
|
77
84
|
follow-up means `polish`, unless it names one defect, which is a `fix`. A request for direction only stops before implementation.
|
|
85
|
+
"Move this app onto Sable" (or convert, migrate, "use Sable properly") is never
|
|
86
|
+
a `build`: that would start the product over. Run `sable check`, show the user
|
|
87
|
+
the Adoption swaps in one short list, ask once which to move, then `polish` them
|
|
88
|
+
a screen at a time without asking again for each. The user should not need to
|
|
89
|
+
know the mode names; route plain requests yourself.
|
|
78
90
|
“Show alternatives” produces bounded rendered studies and stops at comparison when
|
|
79
91
|
the user reserves the choice. Explore only when requested or when competing choices
|
|
80
92
|
materially affect the brief; do not require three designs for every build.
|
|
@@ -89,6 +101,15 @@ IosSheet, NavigationStack). Never import from `konsta`; sable-lint flags it
|
|
|
89
101
|
(`native-ui-import`). `sable target ios --component <Name>` confirms a decision;
|
|
90
102
|
almost every component is `preserve`. Every rule below applies to the prototype.
|
|
91
103
|
|
|
104
|
+
A **tool** brief (an editor, a console, or a records workspace such as an issue
|
|
105
|
+
tracker, a support queue or a pipeline, "like Linear", "compact and technical")
|
|
106
|
+
selects the workspace register. Read `sable docs building-a-tool` before
|
|
107
|
+
composing; a records workspace starts from `sable pattern TrackerPage
|
|
108
|
+
--skeleton`. Its rail is Sidebar's own parts (SidebarGroup, SidebarAccount), its
|
|
109
|
+
list is one grouped DataTable, its inspector is a `Panel narrow="sheet"`, reading
|
|
110
|
+
tabs sit in `<Density value="comfortable">`, and "compact" in the brief means a
|
|
111
|
+
preset with `type-base` 13 or 14, not per-tier type overrides.
|
|
112
|
+
|
|
92
113
|
For inspiration, extract useful visual relationships into a preset. For an audit,
|
|
93
114
|
evaluate the target product. A URL does not automatically mean copying its page.
|
|
94
115
|
An explicit reconstruction request fixes reference fidelity; preserve that scope
|
|
@@ -151,7 +172,11 @@ rather than redoing it:
|
|
|
151
172
|
|
|
152
173
|
`sable init` installs the discoverable entry files for Codex/Cursor and Claude
|
|
153
174
|
Code; in an app wired by hand, run `sable setup --agent` once from the project root. `--target codex|claude|cursor|all` selects
|
|
154
|
-
hosts.
|
|
175
|
+
hosts. Setup also writes a short managed block into `AGENTS.md` and `CLAUDE.md`,
|
|
176
|
+
the instructions every task reads, that sends UI requests through this skill
|
|
177
|
+
even when they don't name it. It replaces only the text between its
|
|
178
|
+
`<!-- sable:begin … -->` and `<!-- sable:end -->` markers; `--no-instructions`
|
|
179
|
+
skips it. For Claude Code, setup also prints a Stop hook that runs `sable check`
|
|
155
180
|
before a task can end; it writes that hook to `.claude/settings.json` only with
|
|
156
181
|
`--hook`, so add it only when the user wants it. The hook is one plain command
|
|
157
182
|
(for example `pnpm exec sable check --stop-hook`) with no `cd` or `&&`, so it runs
|
|
@@ -186,6 +211,16 @@ The installed catalog and guides are authoritative for API and token names.
|
|
|
186
211
|
MediaTile): use one when its layout fits the brief. When it doesn't, compose
|
|
187
212
|
your own from parts; that is not a gap and needs no apology. Apps on Sable
|
|
188
213
|
should not all look alike.
|
|
214
|
+
|
|
215
|
+
Search before you name anything. Before writing an app-local component or
|
|
216
|
+
CSS class for a part you can recognize, search its plain name and its role:
|
|
217
|
+
`sable search breadcrumb`, `sable search "top bar"`, `sable search "property
|
|
218
|
+
row"`. Parts count: `PropertyRow`, `WorkspaceBar` and `CollapsibleIndicator`
|
|
219
|
+
are listed by name and `sable component <Part>` answers with the entry that
|
|
220
|
+
documents them. A disagreement with what you found (a height, a size, where a
|
|
221
|
+
heading sits) is answered by the part's variant or prop, or reported as a
|
|
222
|
+
missing variant; it is never a reason to rebuild the part. Check icon names
|
|
223
|
+
with `sable icon <name>` before using them: an unknown name draws nothing.
|
|
189
224
|
2. Resolve styling in this order: Sable component/variant, Sable utility or recipe,
|
|
190
225
|
semantic token, then app-owned CSS for a genuinely uncovered need. Colour
|
|
191
226
|
beyond the roles (tags, avatars, labels, legends) comes from the generated
|
|
@@ -213,7 +248,8 @@ The installed catalog and guides are authoritative for API and token names.
|
|
|
213
248
|
consume it. In another app, implement only genuinely app-specific structure;
|
|
214
249
|
report a missing library hook. Do not modify installed package files or build
|
|
215
250
|
parallel controls as a shortcut. Preserve existing non-Sable work unless a
|
|
216
|
-
migration was requested
|
|
251
|
+
migration was requested, but don't leave it unmentioned: when `sable check`
|
|
252
|
+
reports adoption `none` or `low`, ask the user (see "Adoption" below).
|
|
217
253
|
|
|
218
254
|
## Develop or use a preset
|
|
219
255
|
|
|
@@ -240,6 +276,14 @@ the build and the finish checklist, and look at it rendered. Don't stop at a sav
|
|
|
240
276
|
file plus a list of wiring steps for the user. Stop after saving only when the
|
|
241
277
|
user says so or the project has no app yet.
|
|
242
278
|
|
|
279
|
+
Ship both colour modes unless the brief rules one out. App colours go in a
|
|
280
|
+
preset with light and dark values (`sable preset --template`, then `sable
|
|
281
|
+
preset --css`), never as light-only overrides on `:root`: those leave dark mode
|
|
282
|
+
as Sable's palette under the app's accents. Mount `ThemeSwitcher` under
|
|
283
|
+
`SableThemeProvider` (a settings menu, the sidebar footer) so the user can
|
|
284
|
+
switch modes and review both. A one-mode brief pins that mode
|
|
285
|
+
(`defaultPreference`) and says so in the brief.
|
|
286
|
+
|
|
243
287
|
Without a preset, develop an appropriate direction from the brief. Use the bundled
|
|
244
288
|
presets as starting points or create original app-owned tokens. When the project has
|
|
245
289
|
its own visual atlas, retrieve references with `sable atlas --search "<problem>"
|
|
@@ -281,6 +325,20 @@ contract violations, design judgments, intentional choices, and verified library
|
|
|
281
325
|
gaps. A visual-only review leaves code and unobserved behavior untested. For
|
|
282
326
|
`polish`, reuse the existing direction and inspect the affected product or preset.
|
|
283
327
|
|
|
328
|
+
**Adoption.** An app can be wired for Sable and barely use it: an earlier agent
|
|
329
|
+
never loaded this skill and wrote its own Modal, Button and Tabs on Sable's
|
|
330
|
+
tokens. `sable check` reports it under Adoption (`sable grade --json` →
|
|
331
|
+
`adoption`): how many UI files compose Sable, the app-built components Sable
|
|
332
|
+
already ships (behaviour first), and the app's `foundation`. When the level is
|
|
333
|
+
`none` or `low`, during any mode, ask the user before moving working screens, with
|
|
334
|
+
the host's question tool if it has one. Show the swaps and what each gains
|
|
335
|
+
("your Modal has no focus trap or Esc; Sable's Dialog has both"). Recommend the
|
|
336
|
+
move plainly when the foundation is `ad-hoc`; offer it gently when the app has
|
|
337
|
+
its `own` primitives; ask whether Sable replaces another `library` or only covers
|
|
338
|
+
new work. Record the answer in the project's instructions, outside the Sable
|
|
339
|
+
block, so the next task doesn't ask again. `sable docs auditing-an-app` has the
|
|
340
|
+
full guide.
|
|
341
|
+
|
|
284
342
|
Every build and polish ends with the finish checklist near the top of this skill. Notes on it:
|
|
285
343
|
|
|
286
344
|
- `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",
|