@sableui/react 0.18.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 (84) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/bin/sable.mjs +108 -14
  3. package/dist/{_contract-CDa9-NdB.d.ts → _contract-DlklV0Im.d.ts} +2 -2
  4. package/dist/blocks/index.d.ts +1 -1
  5. package/dist/components/avatar/avatar.js +16 -7
  6. package/dist/components/avatar/avatar.meta.js +43 -10
  7. package/dist/components/avatar/avatar.variants.js +26 -2
  8. package/dist/components/collapsible/collapsible.js +6 -5
  9. package/dist/components/collapsible/collapsible.meta.js +14 -5
  10. package/dist/components/data-table/data-table.js +132 -45
  11. package/dist/components/data-table/data-table.meta.js +63 -4
  12. package/dist/components/date-picker/date-picker.js +13 -6
  13. package/dist/components/date-picker/date-picker.meta.js +22 -0
  14. package/dist/components/menu/menu.js +2 -0
  15. package/dist/components/menu/menu.meta.js +2 -2
  16. package/dist/components/panel-group/panel-group.js +17 -1
  17. package/dist/components/panel-group/panel-group.meta.js +43 -3
  18. package/dist/components/side-nav/side-nav.variants.js +8 -3
  19. package/dist/components/sidebar/index.js +4 -0
  20. package/dist/components/sidebar/sidebar.js +90 -11
  21. package/dist/components/sidebar/sidebar.meta.js +38 -4
  22. package/dist/components/table/table.js +7 -3
  23. package/dist/components/tabs/tabs.js +10 -2
  24. package/dist/components/tabs/tabs.meta.js +4 -0
  25. package/dist/components/workspace/workspace.js +13 -3
  26. package/dist/components/workspace/workspace.meta.js +109 -1
  27. package/dist/file-hashes.json +53 -49
  28. package/dist/foundations/typography.js +13 -0
  29. package/dist/icon/Icon.js +21 -2
  30. package/dist/icon/index.d.ts +36 -1
  31. package/dist/icon/index.js +5 -0
  32. package/dist/icon/names.js +240 -0
  33. package/dist/icon-sets/reicon.js +19 -1
  34. package/dist/index.d.ts +272 -50
  35. package/dist/index.js +11 -1
  36. package/dist/lib/chart/heat.js +1 -1
  37. package/dist/lib/cn.js +1 -0
  38. package/dist/lib/use-media-query.js +41 -0
  39. package/dist/{meta-DX5r5wQL.d.ts → meta-Dfm9AExe.d.ts} +6 -0
  40. package/dist/patterns/dashboard-page.js +23 -17
  41. package/dist/patterns/index.d.ts +7 -2
  42. package/dist/patterns/index.js +5 -1
  43. package/dist/patterns/tracker-page.js +392 -0
  44. package/dist/patterns/tracker-page.meta.js +40 -0
  45. package/dist/presets/index.d.ts +1 -1
  46. package/dist/styles.css +179 -74
  47. package/dist/{theme-provider-BydfM5Z0.d.ts → theme-provider-BUBealk_.d.ts} +1 -1
  48. package/dist/themes/_contract.js +9 -1
  49. package/dist/themes/atelier/index.d.ts +1 -1
  50. package/dist/themes/ember/index.d.ts +1 -1
  51. package/dist/themes/index.d.ts +2 -2
  52. package/dist/themes/meridian/index.d.ts +1 -1
  53. package/dist/themes/poppy/index.d.ts +1 -1
  54. package/dist/themes/prism/index.d.ts +1 -1
  55. package/dist/themes/sable/index.d.ts +1 -1
  56. package/dist/themes/verdant/index.d.ts +1 -1
  57. package/llms/components/avatar.md +32 -9
  58. package/llms/components/collapsible.md +12 -4
  59. package/llms/components/data-table.md +40 -3
  60. package/llms/components/date-picker.md +15 -1
  61. package/llms/components/menu.md +1 -1
  62. package/llms/components/panel-group.md +34 -2
  63. package/llms/components/sidebar.md +17 -4
  64. package/llms/components/tabs.md +1 -0
  65. package/llms/components/workspace.md +81 -1
  66. package/llms/foundations/typography.md +4 -1
  67. package/llms/patterns/dashboard-page.md +18 -15
  68. package/llms/patterns/tracker-page.md +450 -0
  69. package/llms/reference/token-intent.md +3 -1
  70. package/llms/reference/utilities.md +4 -1
  71. package/llms/tasks/auditing-an-app.md +4 -1
  72. package/llms/tasks/building-a-page.md +3 -2
  73. package/llms/tasks/building-a-tool.md +101 -5
  74. package/llms/tasks/building-with-sable.md +11 -3
  75. package/llms/tasks/creating-a-preset.md +9 -0
  76. package/llms-full.txt +843 -54
  77. package/llms.txt +8 -7
  78. package/package.json +2 -1
  79. package/registry.json +491 -36
  80. package/scripts/adoption.mjs +15 -2
  81. package/scripts/app-css.mjs +129 -0
  82. package/scripts/grade.mjs +161 -2
  83. package/scripts/lint-rules.mjs +126 -0
  84. package/skills/sable/SKILL.md +33 -4
@@ -98,6 +98,11 @@ const ALIASES = {
98
98
  chip: ["Pill", "Badge"], tag: ["Badge"], alert: ["Banner"], callout: ["Note"], confirmdialog: ["DestructiveActionModal", "Dialog"],
99
99
  contextmenu: ["ContextMenu"], hovercard: ["ContextCard"], numberinput: ["NumberField"], otpinput: ["OtpField"], pininput: ["OtpField"],
100
100
  colorpicker: ["ColorField"], rating: ["RadioCard"], stepper: ["Stepper"], wizard: ["Stepper"], kbd: ["Kbd"],
101
+ property: ["PropertyRow"], propertyrow: ["PropertyRow"], proprow: ["PropertyRow"], picker: ["Select", "Combobox"],
102
+ statusmark: ["StatusDot"], statusindicator: ["StatusDot"], presencedot: ["StatusDot"], avatarstack: ["AvatarGroup"],
103
+ facepile: ["AvatarGroup"], topnav: ["WorkspaceBar", "AppHeader"], appbar: ["WorkspaceBar", "AppHeader"],
104
+ headerbar: ["WorkspaceBar", "AppHeader"], themetoggle: ["ThemeSwitcher"], modetoggle: ["ThemeSwitcher"],
105
+ darkmodetoggle: ["ThemeSwitcher"], colormodeswitch: ["ThemeSwitcher"],
101
106
  }
102
107
 
103
108
  /**
@@ -107,6 +112,14 @@ const ALIASES = {
107
112
  */
108
113
  function matcher(components) {
109
114
  const byName = new Map(components.map((c) => [c.name.toLowerCase(), c]))
115
+ // Parts count: `PropertyRow`, `WorkspaceBar` and `AvatarGroup` are exported
116
+ // names an app's `Property` or `TeamAvatars` stands in for. A part inherits
117
+ // its entry's group, so a part of a behaviour component is behaviour too.
118
+ for (const c of components)
119
+ for (const s of c.subcomponents ?? []) {
120
+ const name = typeof s === "string" ? s : s.name
121
+ if (name && !byName.has(name.toLowerCase())) byName.set(name.toLowerCase(), { name, group: c.group, partOf: c.name })
122
+ }
110
123
  return (appName) => {
111
124
  if (STRUCTURAL.test(appName)) return []
112
125
  const w = words(appName)
@@ -167,7 +180,7 @@ export function adoptionReport({ files, components, deps = null }) {
167
180
  const behaviour = hits.some(isBehaviour)
168
181
  const body = text.slice(index, starts.find((s) => s > index) ?? text.length)
169
182
  if (behaviour && !HAND_BEHAVIOUR.some((re) => re.test(body))) continue
170
- swaps.push({ file: rel, line: lineOf(text, index), name, sable: hits.map((c) => c.name), behaviour })
183
+ swaps.push({ file: rel, line: lineOf(text, index), name, sable: hits.map((c) => c.name), behaviour, ...(hits[0].partOf ? { partOf: hits[0].partOf } : {}) })
171
184
  }
172
185
  }
173
186
 
@@ -233,7 +246,7 @@ function recommendationOf({ level, foundation, libraries, behaviourSwaps }) {
233
246
 
234
247
  /** One line per swap, for text output. */
235
248
  export function swapLines(report, limit = 12, indent = " ") {
236
- const lines = report.swaps.slice(0, limit).map((s) => `${indent}${s.file}:${s.line} ${s.name} → Sable has ${s.sable.join(" or ")}${s.behaviour ? " (behaviour)" : ""}`)
249
+ const lines = report.swaps.slice(0, limit).map((s) => `${indent}${s.file}:${s.line} ${s.name} → Sable has ${s.sable.join(" or ")}${s.partOf ? ` (part of ${s.partOf})` : ""}${s.behaviour ? " (behaviour)" : ""}`)
237
250
  if (report.swaps.length > limit) lines.push(`${indent}… ${report.swaps.length - limit} more: sable grade <paths> --json → adoption.swaps`)
238
251
  return lines
239
252
  }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * app-css — what an app's own stylesheets do to Sable that its TSX does not show.
3
+ *
4
+ * The surface inventory and the part mirrors read className strings. An app that
5
+ * styles semantic classes in a .css file hides the rest: a work tracker passed a
6
+ * 100/100 grade with 48/42/34/25px bar and row heights, `.issue-table th { height:
7
+ * 25px; padding … }` re-laying Sable's table from outside, a disclosure chevron
8
+ * rotated by `[aria-expanded=false] svg`, and its light colours overridden on
9
+ * :root with nothing for dark — so dark mode rendered Sable's dark palette under
10
+ * the app's light accents, and nobody had a control to see it.
11
+ *
12
+ * Everything here is a note for the reuse inventory except `modes`, which grade
13
+ * reports as a warning: a half-themed palette is a defect a user sees the moment
14
+ * they switch modes, while a fixed height or a reached-in selector can be a
15
+ * deliberate choice. Lexical, like the rest of grade; it parses enough CSS to
16
+ * know each declaration's selector and the @-rules around it.
17
+ */
18
+
19
+ /** Every declaration block with its selector and enclosing @-rule preludes. */
20
+ export function cssRules(css) {
21
+ const src = css.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
22
+ const rules = []
23
+ const stack = []
24
+ let start = 0
25
+ let quote = null
26
+ for (let i = 0; i < src.length; i++) {
27
+ const c = src[i]
28
+ if (quote) {
29
+ if (c === "\\") i++
30
+ else if (c === quote) quote = null
31
+ continue
32
+ }
33
+ if (c === '"' || c === "'") quote = c
34
+ else if (c === "{") {
35
+ const prelude = src.slice(start, i).split(";").at(-1).trim()
36
+ stack.push({ prelude, index: start + src.slice(start, i).lastIndexOf(prelude), body: i + 1 })
37
+ start = i + 1
38
+ } else if (c === "}") {
39
+ const top = stack.pop()
40
+ if (top && !top.prelude.startsWith("@")) {
41
+ const body = src.slice(start, i)
42
+ const context = stack.map((s) => s.prelude)
43
+ // Nested rules: `.a { .b { … } }` — the declarations are the block's own text.
44
+ rules.push({ selector: top.prelude, context, body, index: top.index, bodyIndex: top.body })
45
+ }
46
+ start = i + 1
47
+ }
48
+ }
49
+ return rules
50
+ }
51
+
52
+ const declarations = (body) =>
53
+ [...body.matchAll(/(?:^|;|\})\s*(--[\w-]+|[a-z-]+)\s*:\s*([^;{}]+)/g)].map((m) => ({ prop: m[1], value: m[2].trim(), offset: m.index + m[0].indexOf(m[1]) }))
54
+
55
+ const lineOf = (text, index) => text.slice(0, index).split("\n").length
56
+
57
+ /** A selector's last compound names a bar, a row, a header or a table cell. */
58
+ const BAR_OR_ROW = /(?:^|[\s>+~])(?:th|td|tr|thead|header)(?![\w-])|\.[\w-]*(?:bar|row|header|cell|tab|toolbar|item)(?![\w-])/
59
+ /** The density ladder's own names, re-declared by hand. */
60
+ const LADDER = /^--(?:control-(?:xs|sm|md|lg|cta)|chrome-bar-height|status-bar-height)$/
61
+
62
+ /**
63
+ * Notes on one stylesheet. `semantic` is the theme contract's colour token
64
+ * names, used by `modes` across all of an app's stylesheets, not here.
65
+ */
66
+ export function cssNotes(css) {
67
+ const notes = []
68
+ const seen = new Set()
69
+ const add = (index, kind, what, consider) => {
70
+ const key = `${kind}|${what}`
71
+ if (seen.has(key)) return
72
+ seen.add(key)
73
+ notes.push({ line: lineOf(css, index), kind, what, consider })
74
+ }
75
+ for (const r of cssRules(css)) {
76
+ const decls = declarations(r.body)
77
+ const at = (d) => r.bodyIndex + d.offset
78
+ if (/\[(?:aria-expanded|data-panel-open|data-open|data-state)[^\]]*\][^{,]*\bsvg\b/.test(r.selector) && decls.some((d) => /^(?:transform|rotate)$/.test(d.prop) && /rotate/.test(`${d.prop} ${d.value}`))) {
79
+ add(r.index, "rebuilt part", `${r.selector} rotates a disclosure chevron by hand`, "CollapsibleIndicator, which flips with the panel and eases (Accordion, Select and Menu triggers already flip theirs)")
80
+ }
81
+ if (/(?:^|[\s>+~])(?:th|td|thead|tr)(?![\w-])/.test(r.selector) && /^[.#[]/.test(r.selector.trim())) {
82
+ const props = decls.filter((d) => /^(?:height|min-height|padding|padding-block|padding-inline|font-size|font-weight)$/.test(d.prop))
83
+ if (props.length) add(r.index, "override", `${r.selector} { ${props.map((d) => d.prop).join(", ")} } re-lays a Sable table from outside`, "the table's density (rows follow a Workspace or Density scope), a column's className for its width, CardContent flush for edge padding")
84
+ }
85
+ if (/\[data-(?:selected|highlighted|active|checked|pressed)\]/.test(r.selector) && /^[.#]/.test(r.selector.trim())) {
86
+ add(r.index, "override", `${r.selector} restyles a Sable state from outside`, "the state's own tokens set on the theme (--muted for a selected row), or the component's variant")
87
+ }
88
+ for (const d of decls) {
89
+ const px = /^(\d+(?:\.\d+)?)px$/.exec(d.value)?.[1]
90
+ if (/^(?:height|min-height)$/.test(d.prop) && px && Number(px) >= 16 && Number(px) <= 64 && BAR_OR_ROW.test(r.selector)) {
91
+ add(at(d), "fixed height", `${r.selector} { ${d.prop}: ${d.value} }`, "the density ladder: --chrome-bar-height, --status-bar-height or --control-* follow the register (WorkspaceBar, StatusBar, a Density scope) instead of pinning one size")
92
+ }
93
+ }
94
+ const ladder = decls.filter((d) => LADDER.test(d.prop) && /^\d/.test(d.value))
95
+ if (ladder.length) {
96
+ const where = [...r.context.filter((c) => c.startsWith("@media")), r.selector].join(" ")
97
+ add(at(ladder[0]), "fixed height", `${where} { ${ladder.map((d) => `${d.prop}: ${d.value}`).join("; ")} } re-declares the density ladder`, "Workspace's density prop or <Density value=…>; the compact and comfortable ladders are already measured")
98
+ }
99
+ }
100
+ return notes
101
+ }
102
+
103
+ /** The selectors and @-rules that put a declaration in the dark mode. */
104
+ const DARK = /\.dark\b|\[data-(?:mode|theme|color-scheme)=["']?dark|prefers-color-scheme:\s*dark|:root\.dark|html\.dark|\[data-dark\]/
105
+
106
+ /**
107
+ * Light and dark coverage of the theme's colour tokens across every stylesheet.
108
+ * Returns the tokens an app sets only for light, and whether it set any dark
109
+ * values at all. A preset carries both modes; hand-written :root overrides
110
+ * usually carry one.
111
+ */
112
+ export function modeCoverage(sheets, semantic) {
113
+ const light = new Map()
114
+ const dark = new Set()
115
+ for (const { rel, text } of sheets) {
116
+ for (const r of cssRules(text)) {
117
+ const scope = [...r.context, r.selector].join(" ")
118
+ const isDark = DARK.test(scope)
119
+ for (const d of declarations(r.body)) {
120
+ const name = d.prop.slice(2)
121
+ if (!d.prop.startsWith("--") || !semantic.has(name)) continue
122
+ if (isDark) dark.add(name)
123
+ else if (!light.has(name)) light.set(name, { file: rel, line: lineOf(text, r.index) })
124
+ }
125
+ }
126
+ }
127
+ const lightOnly = [...light].filter(([name]) => !dark.has(name)).map(([name, at]) => ({ name, ...at }))
128
+ return { light: light.size, dark: dark.size, lightOnly }
129
+ }
package/scripts/grade.mjs CHANGED
@@ -33,7 +33,8 @@ 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"
@@ -113,6 +114,31 @@ const motionTokens = new Set(
113
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")
114
115
  const PROP_SURFACE = REGISTRY.propSurface?.items ?? {}
115
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
+ }
116
142
  /**
117
143
  * The names an app adds with `composeIconSets(appExtras, reiconIconSet)`. That is
118
144
  * the sanctioned way to extend the set, and the grade cannot read a set's names
@@ -378,6 +404,16 @@ const routeSkeletons = []
378
404
  const inventory = []
379
405
  /** UI sources for the adoption report: how much of the app is built from Sable. */
380
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))]))
381
417
 
382
418
  /**
383
419
  * Attribute names on one JSX open tag, stopping at the tag's own `>`.
@@ -437,6 +473,11 @@ for (const file of files) {
437
473
  findings.contract.push({ ...v, file: rel })
438
474
  }
439
475
 
476
+ if (ctx.isStylesheet) {
477
+ cssSources.push({ rel, text })
478
+ for (const n of cssNotes(text)) rebuiltParts.push({ file: rel, ...n })
479
+ }
480
+
440
481
  if (!/\.(tsx|jsx|ts|js)$/.test(extname(file) ? file : "")) continue
441
482
 
442
483
  /* 2. Imports that name something the subpath they come from does not export.
@@ -499,7 +540,7 @@ for (const file of files) {
499
540
  for (const [local, { name }] of imported) {
500
541
  if (name !== "Icon") continue
501
542
  for (const m of code.matchAll(new RegExp(`<${local}\\s+[^>]*name=["']([^"']+)["']`, "g"))) {
502
- 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) })
503
544
  }
504
545
  }
505
546
 
@@ -507,6 +548,10 @@ for (const file of files) {
507
548
  if (/\.(tsx|jsx)$/.test(file)) {
508
549
  uiSources.push({ rel, text })
509
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 })
510
555
  // Arrow-key handling written by hand: list navigation is behaviour Sable
511
556
  // owns (roving focus, active descendant, typeahead), and the storefront
512
557
  // search wrote its own. A note, since a canvas or a game has its own keys.
@@ -525,6 +570,106 @@ for (const file of files) {
525
570
  }
526
571
  }
527
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
+
528
673
  function lineOf(text, index) {
529
674
  return text.slice(0, index).split("\n").length
530
675
  }
@@ -666,6 +811,8 @@ const result = {
666
811
  surfaces: inventory,
667
812
  },
668
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,
669
816
  cliUsage,
670
817
  appIcons: APP_ICONS ? { file: APP_ICONS.file, names: APP_ICONS.names.size } : null,
671
818
  findings,
@@ -707,6 +854,18 @@ if (inventory.length) {
707
854
  for (const s of inventory.slice(0, 12)) L(` ${s.file}:${s.line} <${s.tag}> ${s.kind} (${s.classes}) → Sable has ${s.consider}`)
708
855
  if (inventory.length > 12) L(` … ${inventory.length - 12} more (--json lists every one)`)
709
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
+ }
710
869
  if (adoption.level !== "empty") {
711
870
  L()
712
871
  L(` adoption (notes, not scored) — ${adoption.level}: ${adoption.summary}`)
@@ -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
  *
@@ -35,9 +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`), or an Adoption block
38
+ CSS to the components the app imports (`sable sources`), an Adoption block
39
39
  listing app-built components Sable already ships, which you put to the user
40
- (see "Adoption" under Verify and deliver). Also run the
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
41
43
  project's own build and type checks.
42
44
  2. Review the rendered design: `sable docs reviewing-a-design`. Capture desktop and
43
45
  mobile in every promised colour mode, and judge brief fit, composition,
@@ -51,8 +53,8 @@ the check turns up more than the fix. Say that you escalated, and why.
51
53
  `"reduced"`), state still changes and nothing keeps moving.
52
54
  6. Write the reuse inventory: the Sable components, parts, utilities, and token
53
55
  groups used, and every app-owned UI or CSS addition with the catalog search
54
- that justified it. Start from the app-owned surfaces `sable check` lists,
55
- 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
56
58
  Sable part named beside it or kept your own, and why ("composed our own
57
59
  product tile from AspectBox + Badge: the brief wants the price over the
58
60
  photo"). Keeping your own layout is fine. Behaviour still comes from Sable.
@@ -99,6 +101,15 @@ IosSheet, NavigationStack). Never import from `konsta`; sable-lint flags it
99
101
  (`native-ui-import`). `sable target ios --component <Name>` confirms a decision;
100
102
  almost every component is `preserve`. Every rule below applies to the prototype.
101
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
+
102
113
  For inspiration, extract useful visual relationships into a preset. For an audit,
103
114
  evaluate the target product. A URL does not automatically mean copying its page.
104
115
  An explicit reconstruction request fixes reference fidelity; preserve that scope
@@ -200,6 +211,16 @@ The installed catalog and guides are authoritative for API and token names.
200
211
  MediaTile): use one when its layout fits the brief. When it doesn't, compose
201
212
  your own from parts; that is not a gap and needs no apology. Apps on Sable
202
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.
203
224
  2. Resolve styling in this order: Sable component/variant, Sable utility or recipe,
204
225
  semantic token, then app-owned CSS for a genuinely uncovered need. Colour
205
226
  beyond the roles (tags, avatars, labels, legends) comes from the generated
@@ -255,6 +276,14 @@ the build and the finish checklist, and look at it rendered. Don't stop at a sav
255
276
  file plus a list of wiring steps for the user. Stop after saving only when the
256
277
  user says so or the project has no app yet.
257
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
+
258
287
  Without a preset, develop an appropriate direction from the brief. Use the bundled
259
288
  presets as starting points or create original app-owned tokens. When the project has
260
289
  its own visual atlas, retrieve references with `sable atlas --search "<problem>"