@sableui/react 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/AGENTS.md +10 -3
  2. package/CHANGELOG.md +235 -0
  3. package/README.md +11 -4
  4. package/bin/sable.mjs +266 -30
  5. package/dist/{_contract-CDa9-NdB.d.ts → _contract-DlklV0Im.d.ts} +2 -2
  6. package/dist/activity-DT8VK9fx.d.ts +25 -0
  7. package/dist/adapters/github.d.ts +41 -0
  8. package/dist/adapters/github.js +24 -0
  9. package/dist/blocks/index.d.ts +1 -1
  10. package/dist/components/activity-heatmap/activity-heatmap.js +420 -0
  11. package/dist/components/activity-heatmap/activity-heatmap.meta.js +142 -0
  12. package/dist/components/activity-heatmap/index.js +9 -0
  13. package/dist/components/avatar/avatar.js +16 -7
  14. package/dist/components/avatar/avatar.meta.js +43 -10
  15. package/dist/components/avatar/avatar.variants.js +26 -2
  16. package/dist/components/code-block/code-block.js +5 -3
  17. package/dist/components/code-block/code-block.meta.js +6 -0
  18. package/dist/components/collapsible/collapsible.js +6 -5
  19. package/dist/components/collapsible/collapsible.meta.js +14 -5
  20. package/dist/components/data-table/data-table.js +132 -45
  21. package/dist/components/data-table/data-table.meta.js +63 -4
  22. package/dist/components/date-picker/date-picker.js +13 -6
  23. package/dist/components/date-picker/date-picker.meta.js +22 -0
  24. package/dist/components/dialog/dialog.js +25 -5
  25. package/dist/components/dialog/dialog.meta.js +26 -5
  26. package/dist/components/media-image/media-image.js +3 -1
  27. package/dist/components/media-image/media-image.meta.js +15 -1
  28. package/dist/components/media-tile/media-tile.js +13 -1
  29. package/dist/components/media-tile/media-tile.meta.js +10 -0
  30. package/dist/components/menu/menu.js +2 -0
  31. package/dist/components/menu/menu.meta.js +2 -2
  32. package/dist/components/page-header/page-header.js +9 -4
  33. package/dist/components/page-header/page-header.meta.js +26 -1
  34. package/dist/components/panel-group/panel-group.js +17 -1
  35. package/dist/components/panel-group/panel-group.meta.js +43 -3
  36. package/dist/components/pill/pill.meta.js +15 -6
  37. package/dist/components/pill/pill.variants.js +1 -0
  38. package/dist/components/search-input/search-input.js +30 -5
  39. package/dist/components/search-input/search-input.meta.js +29 -4
  40. package/dist/components/section-header/section-header.js +27 -17
  41. package/dist/components/section-header/section-header.meta.js +27 -5
  42. package/dist/components/sheet/sheet.js +15 -3
  43. package/dist/components/sheet/sheet.meta.js +2 -2
  44. package/dist/components/side-nav/side-nav.variants.js +8 -3
  45. package/dist/components/sidebar/index.js +4 -0
  46. package/dist/components/sidebar/sidebar.js +90 -11
  47. package/dist/components/sidebar/sidebar.meta.js +38 -4
  48. package/dist/components/table/table.js +7 -3
  49. package/dist/components/tabs/tabs.js +10 -2
  50. package/dist/components/tabs/tabs.meta.js +4 -0
  51. package/dist/components/workspace/workspace.js +13 -3
  52. package/dist/components/workspace/workspace.meta.js +109 -1
  53. package/dist/file-hashes.json +94 -80
  54. package/dist/foundations/typography.js +13 -0
  55. package/dist/icon/Icon.js +21 -2
  56. package/dist/icon/index.d.ts +36 -1
  57. package/dist/icon/index.js +5 -0
  58. package/dist/icon/names.js +240 -0
  59. package/dist/icon-sets/reicon.js +41 -1
  60. package/dist/index.d.ts +540 -75
  61. package/dist/index.js +18 -1
  62. package/dist/layout/AspectBox.js +2 -5
  63. package/dist/layout/Bleed.js +10 -6
  64. package/dist/layout/Center.js +11 -3
  65. package/dist/layout/Columns.js +6 -3
  66. package/dist/layout/Grid.js +14 -6
  67. package/dist/layout/GridOverlay.js +7 -7
  68. package/dist/layout/Region.js +18 -3
  69. package/dist/layout/Row.js +27 -20
  70. package/dist/layout/Spacer.js +13 -2
  71. package/dist/layout/SplitPane.js +1 -1
  72. package/dist/layout/Stack.js +25 -18
  73. package/dist/layout/index.d.ts +57 -6
  74. package/dist/lib/chart/activity.js +32 -0
  75. package/dist/lib/chart/calendar.js +67 -0
  76. package/dist/lib/chart/chart-tooltip.js +2 -2
  77. package/dist/lib/chart/heat.js +55 -0
  78. package/dist/lib/cn.js +1 -0
  79. package/dist/lib/focus-return.js +53 -0
  80. package/dist/lib/taxonomy.js +1 -0
  81. package/dist/lib/use-media-query.js +41 -0
  82. package/dist/{meta-DX5r5wQL.d.ts → meta-Dfm9AExe.d.ts} +6 -0
  83. package/dist/patterns/dashboard-page.js +23 -17
  84. package/dist/patterns/index.d.ts +7 -2
  85. package/dist/patterns/index.js +5 -1
  86. package/dist/patterns/tracker-page.js +392 -0
  87. package/dist/patterns/tracker-page.meta.js +40 -0
  88. package/dist/presets/index.d.ts +1 -1
  89. package/dist/styles.css +301 -188
  90. package/dist/{theme-provider-BydfM5Z0.d.ts → theme-provider-BUBealk_.d.ts} +1 -1
  91. package/dist/themes/_contract.js +9 -1
  92. package/dist/themes/atelier/index.d.ts +1 -1
  93. package/dist/themes/ember/index.d.ts +1 -1
  94. package/dist/themes/index.d.ts +2 -2
  95. package/dist/themes/meridian/index.d.ts +1 -1
  96. package/dist/themes/poppy/index.d.ts +1 -1
  97. package/dist/themes/prism/index.d.ts +1 -1
  98. package/dist/themes/sable/index.d.ts +1 -1
  99. package/dist/themes/verdant/index.d.ts +1 -1
  100. package/llms/components/activity-heatmap.md +133 -0
  101. package/llms/components/avatar.md +32 -9
  102. package/llms/components/code-block.md +1 -0
  103. package/llms/components/collapsible.md +12 -4
  104. package/llms/components/data-table.md +40 -3
  105. package/llms/components/date-picker.md +15 -1
  106. package/llms/components/dialog.md +34 -3
  107. package/llms/components/media-image.md +12 -0
  108. package/llms/components/media-tile.md +21 -0
  109. package/llms/components/menu.md +1 -1
  110. package/llms/components/page-header.md +16 -1
  111. package/llms/components/panel-group.md +34 -2
  112. package/llms/components/pill.md +24 -5
  113. package/llms/components/search-input.md +37 -4
  114. package/llms/components/section-header.md +28 -4
  115. package/llms/components/sheet.md +2 -2
  116. package/llms/components/sidebar.md +17 -4
  117. package/llms/components/tabs.md +1 -0
  118. package/llms/components/workspace.md +81 -1
  119. package/llms/foundations/typography.md +4 -1
  120. package/llms/layout/aspect-box.md +1 -1
  121. package/llms/layout/bleed.md +1 -1
  122. package/llms/layout/center.md +1 -1
  123. package/llms/layout/columns.md +1 -1
  124. package/llms/layout/grid.md +1 -1
  125. package/llms/layout/region.md +1 -1
  126. package/llms/layout/row.md +9 -1
  127. package/llms/layout/spacer.md +1 -1
  128. package/llms/layout/split-pane.md +1 -1
  129. package/llms/layout/stack.md +1 -1
  130. package/llms/patterns/dashboard-page.md +18 -15
  131. package/llms/patterns/tracker-page.md +450 -0
  132. package/llms/reference/token-intent.md +3 -1
  133. package/llms/reference/utilities.md +4 -1
  134. package/llms/tasks/auditing-an-app.md +35 -1
  135. package/llms/tasks/building-a-page.md +3 -2
  136. package/llms/tasks/building-a-tool.md +101 -5
  137. package/llms/tasks/building-with-sable.md +11 -3
  138. package/llms/tasks/creating-a-preset.md +9 -0
  139. package/llms/tasks/getting-started.md +10 -3
  140. package/llms/tasks/using-presets.md +1 -2
  141. package/llms-full.txt +1215 -89
  142. package/llms.txt +11 -9
  143. package/package.json +7 -1
  144. package/registry.json +1021 -70
  145. package/scripts/adoption.mjs +252 -0
  146. package/scripts/agent-setup.mjs +110 -18
  147. package/scripts/app-css.mjs +129 -0
  148. package/scripts/grade.mjs +178 -2
  149. package/scripts/lint-rules.mjs +126 -0
  150. package/scripts/wiring.mjs +2 -1
  151. package/skills/entry/SKILL.md +1 -1
  152. package/skills/sable/SKILL.md +64 -6
  153. package/skills/sable/agents/openai.yaml +1 -1
  154. package/targets/ios.json +9 -0
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 (!ICONS.has(m[1]) && !APP_ICONS?.names.has(m[1])) findings.offContractIcon.push({ file: rel, name: m[1], line: lineOf(text, m.index) })
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)`)
@@ -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
  *
@@ -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
- const flagged = new Map(report.checks.filter((c) => c.status === "fail" || c.status === "warn" || (c.status === "info" && c.name === "dedupe")).map((c) => [c.name, c]))
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)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: sable
3
- description: Build websites and apps with Sable, develop reusable presets from inspiration, audit interfaces, polish designs, and fix scoped defects using the installed library.
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
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: sable
3
- description: Build original websites and apps with Sable, turn inspiration into reusable presets, audit existing interfaces, polish a selected design, and fix a scoped defect on a short path. Use for Sable design and UI work, not unrelated backend tasks.
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`). Also run the
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 `sable check` lists,
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. For Claude Code, setup also prints a Stop hook that runs `sable check`
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: "Build, develop presets, audit, and polish with Sable"
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",