@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.
- package/CHANGELOG.md +129 -0
- package/bin/sable.mjs +108 -14
- package/dist/{_contract-CDa9-NdB.d.ts → _contract-DlklV0Im.d.ts} +2 -2
- package/dist/blocks/index.d.ts +1 -1
- 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/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/menu/menu.js +2 -0
- package/dist/components/menu/menu.meta.js +2 -2
- package/dist/components/panel-group/panel-group.js +17 -1
- package/dist/components/panel-group/panel-group.meta.js +43 -3
- 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 +53 -49
- 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 +19 -1
- package/dist/index.d.ts +272 -50
- package/dist/index.js +11 -1
- package/dist/lib/chart/heat.js +1 -1
- package/dist/lib/cn.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 +179 -74
- 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/avatar.md +32 -9
- 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/menu.md +1 -1
- package/llms/components/panel-group.md +34 -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/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 +4 -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-full.txt +843 -54
- package/llms.txt +8 -7
- package/package.json +2 -1
- package/registry.json +491 -36
- package/scripts/adoption.mjs +15 -2
- package/scripts/app-css.mjs +129 -0
- package/scripts/grade.mjs +161 -2
- package/scripts/lint-rules.mjs +126 -0
- package/skills/sable/SKILL.md +33 -4
package/scripts/adoption.mjs
CHANGED
|
@@ -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 (!
|
|
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}`)
|
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/skills/sable/SKILL.md
CHANGED
|
@@ -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`),
|
|
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)
|
|
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
|
|
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>"
|