@sorb/seed 0.2.0 → 0.4.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/package.json +7 -3
- package/src/adapt/__fixtures__/Button.legacy.jsx +38 -0
- package/src/adapt/__fixtures__/Button.tokenized.tsx +28 -0
- package/src/adapt/__fixtures__/corpus/button-inline.case.json +10 -0
- package/src/adapt/__fixtures__/corpus/card-styled.case.json +9 -0
- package/src/adapt/__fixtures__/corpus/misc-unmapped.case.json +7 -0
- package/src/adapt/adaptCli.js +110 -0
- package/src/adapt/adaptCli.test.js +109 -0
- package/src/adapt/benchmark.js +113 -0
- package/src/adapt/benchmark.test.js +79 -0
- package/src/adapt/codemod.js +160 -0
- package/src/adapt/codemod.test.js +120 -0
- package/src/adapt/detectHardcoded.js +165 -0
- package/src/adapt/detectHardcoded.test.js +62 -0
- package/src/adapt/glob.js +87 -0
- package/src/adapt/mapToToken.js +94 -0
- package/src/adapt/mapToToken.test.js +103 -0
- package/src/adapt/report.js +53 -0
- package/src/adapt/report.test.js +66 -0
- package/src/adapt/runBenchmark.js +16 -0
- package/src/adapt/types.js +48 -0
- package/src/captureCli.js +38 -139
- package/src/cli.js +16 -1
- package/src/emit/sorbFormat.js +313 -0
- package/src/emit/sorbFormat.test.js +133 -0
- package/src/emit/sorbMantine.js +116 -0
- package/src/emit/sorbMantine.test.js +68 -0
- package/src/emit/sorbMatSys.js +159 -0
- package/src/emit/sorbMatSys.test.js +75 -0
- package/src/emit/sorbMui.js +165 -0
- package/src/emit/sorbMui.test.js +102 -0
- package/src/emit/sorbPrimevue.js +240 -0
- package/src/emit/sorbPrimevue.test.js +130 -0
- package/src/emit/sorbShadcn.js +186 -0
- package/src/emit/sorbShadcn.test.js +102 -0
- package/src/index.js +48 -0
- package/src/sources/figmaPlugin.js +83 -0
- package/src/sources/figmaPlugin.test.js +65 -0
- package/src/sources/storybookDom.js +188 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// Sorb custom Style Dictionary outputs.
|
|
2
|
+
//
|
|
3
|
+
// The key piece of the token pipeline: a "resolved bindable map" that Sorb's
|
|
4
|
+
// bridge serves, capture annotates against, and the plugin syncs to Figma. One
|
|
5
|
+
// build, one map — this is what retires the runtime esbuild-eval resolver and
|
|
6
|
+
// fixes the "two token lists" problem.
|
|
7
|
+
//
|
|
8
|
+
// Promoted here from `sorb-demo/sd/sorb-format.js` (component-compat-roadmap
|
|
9
|
+
// P0, part 2) — these formats used to live copy-locally in the demo app.
|
|
10
|
+
// `@sorb/seed` is the shared home so target adapters (Tailwind/shadcn/
|
|
11
|
+
// Mantine/… per the roadmap) IMPORT these formats instead of copy-pasting
|
|
12
|
+
// them again. Pure functions of Style Dictionary's `{ dictionary, options }`
|
|
13
|
+
// shape — no dependency on `style-dictionary` itself, so this file has zero
|
|
14
|
+
// new deps for `@sorb/seed`. `sorb-demo/sd/sorb-format.js` now re-exports
|
|
15
|
+
// from here for back-compat.
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Derive the token tier from the source file a token came from.
|
|
19
|
+
* @param {string} filePath
|
|
20
|
+
* @returns {'primitive'|'semantic'|'component'|'unknown'}
|
|
21
|
+
*/
|
|
22
|
+
export const tierOfFile = (filePath = '') =>
|
|
23
|
+
/primitive\./.test(filePath) ? 'primitive'
|
|
24
|
+
: /semantic\./.test(filePath) ? 'semantic'
|
|
25
|
+
: /component\./.test(filePath) ? 'component'
|
|
26
|
+
// bs.json — the bootstrap-styled brand overlay. Classified as `semantic` (a
|
|
27
|
+
// core-valid tier) so its `--bs-*` roles group + bind alongside the other
|
|
28
|
+
// semantic roles rather than falling through to an unranked `unknown` tier.
|
|
29
|
+
: /bs\./.test(filePath) ? 'semantic'
|
|
30
|
+
: 'unknown'
|
|
31
|
+
|
|
32
|
+
export const SORB_RESOLVED = 'sorb/resolved-map'
|
|
33
|
+
export const SORB_THEME_NESTED = 'sorb/theme-nested'
|
|
34
|
+
export const SORB_ALIASES = 'sorb/aliases-css'
|
|
35
|
+
export const SORB_VERSIONS = 'sorb/versions'
|
|
36
|
+
export const SORB_SET_META = 'sorb/set-meta'
|
|
37
|
+
export const SORB_TAILWIND = 'sorb/tailwind-theme'
|
|
38
|
+
export const SORB_TAILWIND_V3 = 'sorb/tailwind-v3-preset'
|
|
39
|
+
export const SORB_TOKENSET = 'sorb/tokenset-esm'
|
|
40
|
+
|
|
41
|
+
// ─── set-level metadata parser ───────────────────────────────────────────────
|
|
42
|
+
// Per-set `$version` lives at each token file's root. SD merges all sources
|
|
43
|
+
// into one tree, so three root `$version` keys collide ("token collision")
|
|
44
|
+
// during merge — and per-set versions wouldn't survive anyway. This parser runs
|
|
45
|
+
// PER FILE before the merge: it lifts `$version` out (stashing it by file) and
|
|
46
|
+
// strips it from the tree, so the merge is clean and versions are preserved for
|
|
47
|
+
// the `sorb/versions` output. (A preprocessor runs post-merge — too late.)
|
|
48
|
+
const _setVersions = {}
|
|
49
|
+
export const sorbSetMeta = {
|
|
50
|
+
name: SORB_SET_META,
|
|
51
|
+
pattern: /\.json$/,
|
|
52
|
+
parser: ({ filePath, contents }) => {
|
|
53
|
+
const obj = JSON.parse(contents)
|
|
54
|
+
if (obj.$version != null) {
|
|
55
|
+
_setVersions[tierOfFile(filePath)] = obj.$version
|
|
56
|
+
delete obj.$version
|
|
57
|
+
}
|
|
58
|
+
return obj
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** format: sorb/versions — { primitive, semantic, component } → version. */
|
|
63
|
+
export const sorbVersions = () =>
|
|
64
|
+
JSON.stringify(_setVersions, null, 2) + '\n'
|
|
65
|
+
|
|
66
|
+
const cssNameOf = (id) => '--' + String(id).split('.').join('-')
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* format: sorb/resolved-map
|
|
70
|
+
* Emits the bindable map: one entry per token. Schema:
|
|
71
|
+
* { id, cssVar, value, tier, type } plus { deprecated, replacedBy } when set.
|
|
72
|
+
*/
|
|
73
|
+
/**
|
|
74
|
+
* Flat **TokenSet** ESM module for `@sorb/leaf`'s `SorbProvider` — one entry per
|
|
75
|
+
* token, keyed by the CSS-var name WITHOUT the leading `--` (leaf's `applyTokens`
|
|
76
|
+
* re-adds it via `setProperty('--' + key, value)`). This is the committed token
|
|
77
|
+
* set bundled into the app at build time:
|
|
78
|
+
* export const tokens = { 'color-action-primary': '#0f65ef', ... }
|
|
79
|
+
* Same names/values as `variables.css` and `resolved.json` (one source, many
|
|
80
|
+
* surfaces). Consumed by `main.jsx` / `src/sorbConfig.js`.
|
|
81
|
+
*/
|
|
82
|
+
export const sorbTokenSet = ({ dictionary }) => {
|
|
83
|
+
const out = {}
|
|
84
|
+
for (const t of dictionary.allTokens) {
|
|
85
|
+
out[t.path.join('-')] = t.$value ?? t.value
|
|
86
|
+
}
|
|
87
|
+
return 'export const tokens = ' + JSON.stringify(out, null, 2) + '\n'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const sorbResolved = ({ dictionary }) => {
|
|
91
|
+
const deprecated = []
|
|
92
|
+
const out = dictionary.allTokens.map((t) => {
|
|
93
|
+
const entry = {
|
|
94
|
+
id: t.path.join('.'), // color.action.primary
|
|
95
|
+
cssVar: '--' + t.path.join('-'), // --color-action-primary
|
|
96
|
+
value: t.$value ?? t.value, // resolved (refs followed); SD v4 DTCG → $value
|
|
97
|
+
tier: tierOfFile(t.filePath), // primitive | semantic | component
|
|
98
|
+
type: t.$type ?? t.type, // color | dimension | fontWeight | …
|
|
99
|
+
}
|
|
100
|
+
if (t.$deprecated) {
|
|
101
|
+
entry.deprecated = true
|
|
102
|
+
const rb = t.$extensions && t.$extensions.sorb && t.$extensions.sorb.replacedBy
|
|
103
|
+
if (rb) entry.replacedBy = rb
|
|
104
|
+
deprecated.push(entry.id + (rb ? ` → ${rb}` : ''))
|
|
105
|
+
}
|
|
106
|
+
return entry
|
|
107
|
+
})
|
|
108
|
+
if (deprecated.length) {
|
|
109
|
+
console.warn(` ⚠ ${deprecated.length} deprecated token(s): ` + deprecated.join(', '))
|
|
110
|
+
}
|
|
111
|
+
return JSON.stringify(out, null, 2) + '\n'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* format: sorb/aliases-css — legacy back-compat layer (migration window).
|
|
116
|
+
* Reads `options.aliases` ({ legacyName: "new.dtcg.id" }) and emits
|
|
117
|
+
* --legacyName: var(--new-dtcg-id);
|
|
118
|
+
* Each target is validated against the built tokens; unknown targets warn and
|
|
119
|
+
* are skipped. Drop this platform once nothing references the legacy names.
|
|
120
|
+
*/
|
|
121
|
+
export const sorbAliases = ({ dictionary, options }) => {
|
|
122
|
+
const aliases = (options && options.aliases) || {}
|
|
123
|
+
const known = new Set(dictionary.allTokens.map((t) => t.path.join('.')))
|
|
124
|
+
const lines = ['/* AUTO-GENERATED legacy alias layer — @deprecated, remove after migration. */', ':root {']
|
|
125
|
+
const missing = []
|
|
126
|
+
for (const legacy of Object.keys(aliases)) {
|
|
127
|
+
if (legacy.startsWith('$')) continue // skip $comment / metadata keys
|
|
128
|
+
const targetId = aliases[legacy]
|
|
129
|
+
if (!known.has(targetId)) { missing.push(`${legacy} → ${targetId}`); continue }
|
|
130
|
+
lines.push(` --${legacy}: var(${cssNameOf(targetId)}); /* @deprecated → ${targetId} */`)
|
|
131
|
+
}
|
|
132
|
+
lines.push('}', '')
|
|
133
|
+
if (missing.length) {
|
|
134
|
+
console.warn(` ⚠ aliases.json: ${missing.length} unknown target(s) skipped: ` + missing.join(', '))
|
|
135
|
+
}
|
|
136
|
+
return lines.join('\n')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* format: sorb/theme-nested
|
|
141
|
+
* Emits a nested object of `var(--kebab, <fallback>)` strings so a
|
|
142
|
+
* styled-components theme can read `theme.color.action.primary`. The fallback
|
|
143
|
+
* is the committed value, so with no preview active rendering is unchanged.
|
|
144
|
+
*/
|
|
145
|
+
export const sorbThemeNested = ({ dictionary }) => {
|
|
146
|
+
const root = {}
|
|
147
|
+
for (const t of dictionary.allTokens) {
|
|
148
|
+
const cssVar = '--' + t.path.join('-')
|
|
149
|
+
let node = root
|
|
150
|
+
for (let i = 0; i < t.path.length - 1; i++) {
|
|
151
|
+
const k = t.path[i]
|
|
152
|
+
node[k] = node[k] || {}
|
|
153
|
+
node = node[k]
|
|
154
|
+
}
|
|
155
|
+
node[t.path[t.path.length - 1]] = `var(${cssVar}, ${t.$value ?? t.value})`
|
|
156
|
+
}
|
|
157
|
+
return (
|
|
158
|
+
'// AUTO-GENERATED by Style Dictionary — do not edit.\n' +
|
|
159
|
+
'export default ' +
|
|
160
|
+
JSON.stringify(root, null, 2) +
|
|
161
|
+
'\n'
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Map one Sorb token to a Tailwind v4 `@theme` entry: { key, ref }.
|
|
167
|
+
*
|
|
168
|
+
* `key` is the theme variable name — its prefix picks the Tailwind utility
|
|
169
|
+
* family (`--color-*`→bg/text/border, `--radius-*`→rounded, `--spacing-*`→
|
|
170
|
+
* p/m/gap/w/h, `--text-*`→font-size, `--font-weight-*`→font weight). `ref` is
|
|
171
|
+
* always `var(<the token's own --css-var>)` so the value stays a *reference* to
|
|
172
|
+
* the same runtime-swappable var the bridge overrides — never a baked literal.
|
|
173
|
+
* Because Sorb already names color vars `--color-*` (colliding with Tailwind's
|
|
174
|
+
* own namespace), the format emits `@theme inline`, where Tailwind uses the ref
|
|
175
|
+
* expression directly in utilities instead of redefining the key in `:root`.
|
|
176
|
+
* @param {{path: string[], $type?: string, type?: string}} t
|
|
177
|
+
* @returns {{key: string, ref: string}}
|
|
178
|
+
*/
|
|
179
|
+
export const tailwindThemeEntry = (t) => {
|
|
180
|
+
const slug = t.path.join('-') // color-action-primary | button-radius
|
|
181
|
+
const ref = `var(--${slug})` // points back at the Sorb css var
|
|
182
|
+
const type = t.$type ?? t.type
|
|
183
|
+
const strip = (re) => slug.replace(re, '').replace(/^-+|-+$/g, '')
|
|
184
|
+
|
|
185
|
+
if (type === 'color') {
|
|
186
|
+
// semantic/primitive already start with `color-`; component colors
|
|
187
|
+
// (button-primary-bg-default) keep their full path under the color family.
|
|
188
|
+
const name = slug.startsWith('color-') ? slug.slice('color-'.length) : slug
|
|
189
|
+
return { key: `--color-${name}`, ref }
|
|
190
|
+
}
|
|
191
|
+
if (type === 'fontWeight') {
|
|
192
|
+
return { key: `--font-weight-${strip(/font-weight-?/)}`, ref }
|
|
193
|
+
}
|
|
194
|
+
if (type === 'dimension') {
|
|
195
|
+
if (t.path.includes('radius')) {
|
|
196
|
+
// radius-100 → 100 ; button-radius → button (→ rounded-100 / rounded-button)
|
|
197
|
+
return { key: `--radius-${strip(/-?radius-?/) || 'DEFAULT'}`, ref }
|
|
198
|
+
}
|
|
199
|
+
if (t.path.includes('space') || t.path.includes('spacing')) {
|
|
200
|
+
return { key: `--spacing-${strip(/space-?/)}`, ref }
|
|
201
|
+
}
|
|
202
|
+
if (t.path.includes('size')) {
|
|
203
|
+
return { key: `--text-${strip(/font-size-?|size-?/)}`, ref }
|
|
204
|
+
}
|
|
205
|
+
return { key: `--spacing-${slug}`, ref } // unknown dimension → spacing family
|
|
206
|
+
}
|
|
207
|
+
return { key: `--sorb-${slug}`, ref } // anything else: registered, no utility family
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* format: sorb/tailwind-theme
|
|
212
|
+
* Emits a Tailwind v4 `@theme inline { … }` block — one entry per resolved
|
|
213
|
+
* token, each value a `var(--token)` reference. Pair it with `variables.css`
|
|
214
|
+
* (which defines those vars) so Tailwind utilities resolve through the exact
|
|
215
|
+
* CSS vars the bridge swaps at runtime → live preview works with zero
|
|
216
|
+
* Tailwind-specific bridge code. Duplicate theme keys are skipped (warned).
|
|
217
|
+
*/
|
|
218
|
+
export const sorbTailwind = ({ dictionary }) => {
|
|
219
|
+
const seen = new Map() // key → originating token id (collision guard)
|
|
220
|
+
const dupes = []
|
|
221
|
+
const lines = []
|
|
222
|
+
for (const t of dictionary.allTokens) {
|
|
223
|
+
const { key, ref } = tailwindThemeEntry(t)
|
|
224
|
+
const id = t.path.join('.')
|
|
225
|
+
if (seen.has(key)) { dupes.push(`${key} (${seen.get(key)} vs ${id})`); continue }
|
|
226
|
+
seen.set(key, id)
|
|
227
|
+
lines.push(` ${key}: ${ref};`)
|
|
228
|
+
}
|
|
229
|
+
if (dupes.length) {
|
|
230
|
+
console.warn(` ⚠ tailwind: ${dupes.length} duplicate theme key(s) skipped: ` + dupes.join(', '))
|
|
231
|
+
}
|
|
232
|
+
return (
|
|
233
|
+
'/* AUTO-GENERATED by Style Dictionary (sorb/tailwind-theme) — do not edit.\n' +
|
|
234
|
+
' Tailwind v4 theme mapped onto Sorb\'s runtime CSS vars. Import order in your\n' +
|
|
235
|
+
' entry CSS:\n' +
|
|
236
|
+
' @import "tailwindcss";\n' +
|
|
237
|
+
' @import "./variables.css"; sorb tokens — the bridge swaps these live\n' +
|
|
238
|
+
' @import "./tailwind-theme.css"; this file\n' +
|
|
239
|
+
' `@theme inline` makes utilities reference var(--token) directly, so a\n' +
|
|
240
|
+
' POST /preview recolors Tailwind-classed elements with no extra code. */\n' +
|
|
241
|
+
'@theme inline {\n' +
|
|
242
|
+
lines.join('\n') +
|
|
243
|
+
'\n}\n'
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Classify one Sorb token into a Tailwind v3 `theme.extend` slot.
|
|
249
|
+
* Returns the category (colors|borderRadius|spacing|fontSize|fontWeight), the
|
|
250
|
+
* nested key path within it (Tailwind v3 flattens nested keys with `-`, so
|
|
251
|
+
* `colors.action.primary` → utility `bg-action-primary`), and the var() ref.
|
|
252
|
+
* Returns null for token types with no v3 utility family.
|
|
253
|
+
* @param {{path: string[], $type?: string, type?: string}} t
|
|
254
|
+
* @returns {{category: string, keyPath: string[], ref: string}|null}
|
|
255
|
+
*/
|
|
256
|
+
export const tailwindV3Slot = (t) => {
|
|
257
|
+
const ref = `var(--${t.path.join('-')})`
|
|
258
|
+
const type = t.$type ?? t.type
|
|
259
|
+
const without = (seg) => t.path.filter((p) => p !== seg)
|
|
260
|
+
|
|
261
|
+
if (type === 'color') {
|
|
262
|
+
// strip a leading `color` segment (semantic/primitive); component colors keep full path
|
|
263
|
+
const keyPath = t.path[0] === 'color' ? t.path.slice(1) : t.path.slice()
|
|
264
|
+
return { category: 'colors', keyPath, ref }
|
|
265
|
+
}
|
|
266
|
+
if (type === 'fontWeight') {
|
|
267
|
+
return { category: 'fontWeight', keyPath: t.path.filter((p) => p !== 'font' && p !== 'weight'), ref }
|
|
268
|
+
}
|
|
269
|
+
if (type === 'dimension') {
|
|
270
|
+
if (t.path.includes('radius')) return { category: 'borderRadius', keyPath: without('radius'), ref }
|
|
271
|
+
if (t.path.includes('space') || t.path.includes('spacing'))
|
|
272
|
+
return { category: 'spacing', keyPath: t.path.filter((p) => p !== 'space' && p !== 'spacing'), ref }
|
|
273
|
+
if (t.path.includes('size'))
|
|
274
|
+
return { category: 'fontSize', keyPath: t.path.filter((p) => p !== 'font' && p !== 'size'), ref }
|
|
275
|
+
return { category: 'spacing', keyPath: t.path.slice(), ref } // unknown dimension → spacing
|
|
276
|
+
}
|
|
277
|
+
return null // unknown type → no v3 utility family
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* format: sorb/tailwind-v3-preset
|
|
282
|
+
* Emits a Tailwind v3 **preset** (CommonJS) — `theme.extend.{colors,borderRadius,
|
|
283
|
+
* spacing,fontSize,fontWeight}` whose leaves are `var(--token)` strings grouped by
|
|
284
|
+
* tier/role. Consumer: `presets: [require('./tailwind-sorb-preset.cjs')]`. Same
|
|
285
|
+
* live-preview behavior as v4 — utilities reference the runtime-swappable Sorb vars.
|
|
286
|
+
*/
|
|
287
|
+
export const sorbTailwindV3 = ({ dictionary }) => {
|
|
288
|
+
const theme = { colors: {}, borderRadius: {}, spacing: {}, fontSize: {}, fontWeight: {} }
|
|
289
|
+
const skipped = []
|
|
290
|
+
for (const t of dictionary.allTokens) {
|
|
291
|
+
const slot = tailwindV3Slot(t)
|
|
292
|
+
if (!slot) { skipped.push(t.path.join('.')); continue }
|
|
293
|
+
let node = theme[slot.category]
|
|
294
|
+
for (let i = 0; i < slot.keyPath.length - 1; i++) {
|
|
295
|
+
const k = slot.keyPath[i]
|
|
296
|
+
node[k] = node[k] || {}
|
|
297
|
+
node = node[k]
|
|
298
|
+
}
|
|
299
|
+
node[slot.keyPath[slot.keyPath.length - 1]] = slot.ref
|
|
300
|
+
}
|
|
301
|
+
if (skipped.length) {
|
|
302
|
+
console.warn(` ⚠ tailwind-v3: ${skipped.length} token(s) with no v3 family skipped: ` + skipped.join(', '))
|
|
303
|
+
}
|
|
304
|
+
return (
|
|
305
|
+
'// AUTO-GENERATED by Style Dictionary (sorb/tailwind-v3-preset) — do not edit.\n' +
|
|
306
|
+
'// Tailwind v3 preset of var(--token) refs from Sorb\'s resolved map. Usage:\n' +
|
|
307
|
+
'// presets: [require(\'./tailwind-sorb-preset.cjs\')]\n' +
|
|
308
|
+
'// Import variables.css globally so the vars resolve; the bridge swaps them live.\n' +
|
|
309
|
+
'module.exports = ' +
|
|
310
|
+
JSON.stringify({ theme: { extend: theme } }, null, 2) +
|
|
311
|
+
'\n'
|
|
312
|
+
)
|
|
313
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Tests for the Sorb Tailwind v4 theme format (`sorb/tailwind-theme`).
|
|
2
|
+
// Run: node --test (zero-dep, Node's built-in runner — matches the workspace convention).
|
|
3
|
+
import { test } from 'node:test'
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import { tailwindThemeEntry, sorbTailwind, tailwindV3Slot, sorbTailwindV3 } from './sorbFormat.js'
|
|
6
|
+
|
|
7
|
+
// Helper: a token as Style Dictionary hands it to a format (path + $type).
|
|
8
|
+
const tok = (id, type) => ({ path: id.split('.'), $type: type })
|
|
9
|
+
|
|
10
|
+
test('color tokens map to the Tailwind --color-* family, value = var(self)', () => {
|
|
11
|
+
// semantic: leading `color.` is the namespace, not doubled
|
|
12
|
+
assert.deepEqual(tailwindThemeEntry(tok('color.action.primary', 'color')), {
|
|
13
|
+
key: '--color-action-primary',
|
|
14
|
+
ref: 'var(--color-action-primary)',
|
|
15
|
+
})
|
|
16
|
+
// primitive ramp
|
|
17
|
+
assert.deepEqual(tailwindThemeEntry(tok('color.blue.300', 'color')), {
|
|
18
|
+
key: '--color-blue-300',
|
|
19
|
+
ref: 'var(--color-blue-300)',
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('component colors keep their full path under the color family', () => {
|
|
24
|
+
// button.primary.bg.default has $type color but path does NOT start with `color`
|
|
25
|
+
assert.deepEqual(tailwindThemeEntry(tok('button.primary.bg.default', 'color')), {
|
|
26
|
+
key: '--color-button-primary-bg-default',
|
|
27
|
+
ref: 'var(--button-primary-bg-default)', // ref always points at the token's own css var
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('radius dimensions map to --radius-* (→ rounded-* utilities)', () => {
|
|
32
|
+
assert.deepEqual(tailwindThemeEntry(tok('radius.100', 'dimension')), {
|
|
33
|
+
key: '--radius-100',
|
|
34
|
+
ref: 'var(--radius-100)',
|
|
35
|
+
})
|
|
36
|
+
// component radius is remapped into the radius family so rounded-button works
|
|
37
|
+
assert.deepEqual(tailwindThemeEntry(tok('button.radius', 'dimension')), {
|
|
38
|
+
key: '--radius-button',
|
|
39
|
+
ref: 'var(--button-radius)',
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('space → --spacing-*, font.size → --text-*, fontWeight → --font-weight-*', () => {
|
|
44
|
+
assert.equal(tailwindThemeEntry(tok('space.200', 'dimension')).key, '--spacing-200')
|
|
45
|
+
assert.equal(tailwindThemeEntry(tok('space.200', 'dimension')).ref, 'var(--space-200)')
|
|
46
|
+
assert.equal(tailwindThemeEntry(tok('font.size.300', 'dimension')).key, '--text-300')
|
|
47
|
+
assert.equal(tailwindThemeEntry(tok('font.weight.semibold', 'fontWeight')).key, '--font-weight-semibold')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('the ref is ALWAYS a var() reference, never a baked literal (live-preview invariant)', () => {
|
|
51
|
+
// This is the property that makes the bridge's runtime var-swap recolor
|
|
52
|
+
// Tailwind utilities with no Tailwind-specific code. Every entry must be var(…).
|
|
53
|
+
for (const t of [
|
|
54
|
+
tok('color.action.primary', 'color'),
|
|
55
|
+
tok('button.radius', 'dimension'),
|
|
56
|
+
tok('space.100', 'dimension'),
|
|
57
|
+
]) {
|
|
58
|
+
assert.match(tailwindThemeEntry(t).ref, /^var\(--[a-z0-9-]+\)$/)
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('sorbTailwind emits an `@theme inline` block, one entry per token', () => {
|
|
63
|
+
const dictionary = {
|
|
64
|
+
allTokens: [
|
|
65
|
+
tok('color.action.primary', 'color'),
|
|
66
|
+
tok('button.radius', 'dimension'),
|
|
67
|
+
tok('space.100', 'dimension'),
|
|
68
|
+
],
|
|
69
|
+
}
|
|
70
|
+
const css = sorbTailwind({ dictionary })
|
|
71
|
+
assert.match(css, /@theme inline \{/)
|
|
72
|
+
assert.match(css, /\}\s*$/)
|
|
73
|
+
assert.match(css, /^ {2}--color-action-primary: var\(--color-action-primary\);$/m)
|
|
74
|
+
assert.match(css, /^ {2}--radius-button: var\(--button-radius\);$/m)
|
|
75
|
+
// exactly 3 entries
|
|
76
|
+
assert.equal((css.match(/: var\(/g) || []).length, 3)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('duplicate theme keys are skipped (collision guard), not emitted twice', () => {
|
|
80
|
+
const dictionary = {
|
|
81
|
+
// two distinct tokens that would collapse to the same theme key
|
|
82
|
+
allTokens: [tok('color.action.primary', 'color'), tok('color.action.primary', 'color')],
|
|
83
|
+
}
|
|
84
|
+
const css = sorbTailwind({ dictionary })
|
|
85
|
+
assert.equal((css.match(/--color-action-primary: /g) || []).length, 1)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// ─── Tailwind v3 preset (sorb/tailwind-v3-preset) ────────────────────────────
|
|
89
|
+
|
|
90
|
+
test('v3 slot: colors strip leading `color`, components keep full path', () => {
|
|
91
|
+
assert.deepEqual(tailwindV3Slot(tok('color.action.primary', 'color')), {
|
|
92
|
+
category: 'colors', keyPath: ['action', 'primary'], ref: 'var(--color-action-primary)',
|
|
93
|
+
})
|
|
94
|
+
assert.deepEqual(tailwindV3Slot(tok('button.primary.bg.default', 'color')), {
|
|
95
|
+
category: 'colors', keyPath: ['button', 'primary', 'bg', 'default'], ref: 'var(--button-primary-bg-default)',
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('v3 slot: dimensions/weights map to the right v3 categories', () => {
|
|
100
|
+
assert.deepEqual(tailwindV3Slot(tok('radius.100', 'dimension')), { category: 'borderRadius', keyPath: ['100'], ref: 'var(--radius-100)' })
|
|
101
|
+
assert.deepEqual(tailwindV3Slot(tok('button.radius', 'dimension')), { category: 'borderRadius', keyPath: ['button'], ref: 'var(--button-radius)' })
|
|
102
|
+
assert.deepEqual(tailwindV3Slot(tok('space.200', 'dimension')), { category: 'spacing', keyPath: ['200'], ref: 'var(--space-200)' })
|
|
103
|
+
assert.deepEqual(tailwindV3Slot(tok('font.size.300', 'dimension')), { category: 'fontSize', keyPath: ['300'], ref: 'var(--font-size-300)' })
|
|
104
|
+
assert.deepEqual(tailwindV3Slot(tok('font.weight.regular', 'fontWeight')), { category: 'fontWeight', keyPath: ['regular'], ref: 'var(--font-weight-regular)' })
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('v3 slot: unknown type → null (no v3 family)', () => {
|
|
108
|
+
assert.equal(tailwindV3Slot({ path: ['z', 'index'], $type: 'number' }), null)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('sorbTailwindV3 emits a requireable preset with nested theme.extend of var() refs', () => {
|
|
112
|
+
const dictionary = {
|
|
113
|
+
allTokens: [
|
|
114
|
+
tok('color.action.primary', 'color'),
|
|
115
|
+
tok('button.primary.bg.default', 'color'),
|
|
116
|
+
tok('button.radius', 'dimension'),
|
|
117
|
+
tok('space.100', 'dimension'),
|
|
118
|
+
],
|
|
119
|
+
}
|
|
120
|
+
const src = sorbTailwindV3({ dictionary })
|
|
121
|
+
assert.match(src, /^module\.exports = /m)
|
|
122
|
+
// evaluate the generated CommonJS to prove it's a valid, requireable preset
|
|
123
|
+
const mod = { exports: {} }
|
|
124
|
+
new Function('module', 'exports', src)(mod, mod.exports)
|
|
125
|
+
const ext = mod.exports.theme.extend
|
|
126
|
+
assert.equal(ext.colors.action.primary, 'var(--color-action-primary)') // → bg-action-primary
|
|
127
|
+
assert.equal(ext.colors.button.primary.bg.default, 'var(--button-primary-bg-default)')
|
|
128
|
+
assert.equal(ext.borderRadius.button, 'var(--button-radius)') // → rounded-button
|
|
129
|
+
assert.equal(ext.spacing['100'], 'var(--space-100)') // → p-100
|
|
130
|
+
// every leaf is a var() ref (the live-preview invariant), never a literal
|
|
131
|
+
const leaves = (o) => Object.values(o).flatMap((v) => (typeof v === 'string' ? [v] : leaves(v)))
|
|
132
|
+
for (const v of leaves(ext)) assert.match(v, /^var\(--[a-z0-9-]+\)$/)
|
|
133
|
+
})
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Sorb Mantine v7 CSS-var override format (`sorb/mantine-vars`).
|
|
2
|
+
//
|
|
3
|
+
// PROMOTED from `sorb-demo-mantine/sd/mantine-format.js` (P2 spike →
|
|
4
|
+
// framework-targets-productization T2, first of the six JJ-demo formats to
|
|
5
|
+
// graduate into `@sorb/seed`). The demo file stays in place for now (T8
|
|
6
|
+
// retrofit is founder-gated); this is the canonical, generalized home.
|
|
7
|
+
//
|
|
8
|
+
// GENERALIZATION (T0 semantic-role contract): the spike hardcoded Janes
|
|
9
|
+
// Jeans' own token ids as the map's left-hand column. Promotion makes that
|
|
10
|
+
// column ROLE-RESOLVED — a non-JJ kit passes `options.roleMap` (role id →
|
|
11
|
+
// its own token id) instead of forking this file. Resolution is the same
|
|
12
|
+
// shape as `@sorb/core`'s `resolveRole` (see `sorb-core/src/index.js`
|
|
13
|
+
// `DEFAULT_ROLE_IDS`/`resolveRole`), inlined here rather than imported so
|
|
14
|
+
// this format degrades gracefully against an older published `@sorb/core`
|
|
15
|
+
// that predates the role contract (same feature-detect posture as the
|
|
16
|
+
// TargetAdapter registration side — see `sorb-leaf/src/targets/mantine.js`).
|
|
17
|
+
// Not every key below is one of `@sorb/core`'s canonical `DEFAULT_ROLE_IDS`
|
|
18
|
+
// — several (`button.primary.bg.*`, `button.radius`, `card.radius`) are
|
|
19
|
+
// Janes-Jeans component-tier ids with no canonical role counterpart yet
|
|
20
|
+
// (kit-private, per the productization spec's "contract scope = the union
|
|
21
|
+
// of the six maps' role columns, not a kit's full token tree"). They still
|
|
22
|
+
// go through the same `roleMap` resolution for consistency and so a kit
|
|
23
|
+
// that DOES want to override them can.
|
|
24
|
+
//
|
|
25
|
+
// The `--mantine-*` right-hand column is Mantine's own documented CSS
|
|
26
|
+
// surface — universal, and stays baked (not role-resolved).
|
|
27
|
+
//
|
|
28
|
+
// PRECEDENCE FINDINGS (carried verbatim from the P2 spike header — this is
|
|
29
|
+
// the load-bearing reason every declaration below is `!important`, so don't
|
|
30
|
+
// drop it in a future edit): Mantine's OWN core stylesheet declares these
|
|
31
|
+
// vars on `:root[data-mantine-color-scheme="light"]` — an attribute-selector
|
|
32
|
+
// rule, specificity (0,2,0) — and `MantineProvider` injects that stylesheet
|
|
33
|
+
// as a runtime `<style>` tag whose position in `<head>` is NOT guaranteed to
|
|
34
|
+
// precede ours. A first attempt used `:where(html) { ... }`, which carries
|
|
35
|
+
// ZERO specificity by definition and lost outright (confirmed via Playwright:
|
|
36
|
+
// overriding the underlying Sorb var had no effect on the Button's computed
|
|
37
|
+
// background). The fix is a plain `:root` block with `!important` on every
|
|
38
|
+
// declaration — `!important` author declarations win over normal-importance
|
|
39
|
+
// declarations regardless of specificity or source order, which is exactly
|
|
40
|
+
// the guarantee needed against a dynamically-injected, order-unstable
|
|
41
|
+
// stylesheet. Zero-dep, zero-JS (Mechanism A): a static CSS file of `var()`
|
|
42
|
+
// refs, reacting to a live bridge push the same way `variables.css` already
|
|
43
|
+
// does.
|
|
44
|
+
|
|
45
|
+
export const SORB_MANTINE_VARS = 'sorb/mantine-vars'
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* role id (JJ id when unmapped) → Mantine CSS var name.
|
|
49
|
+
* Left side is resolved through `options.roleMap` at format time (identity
|
|
50
|
+
* when the kit uses these ids directly — the JJ reference); right side is
|
|
51
|
+
* the Mantine v7 documented variable it overrides.
|
|
52
|
+
*/
|
|
53
|
+
export const MANTINE_VAR_MAP = {
|
|
54
|
+
'color.surface': '--mantine-color-body',
|
|
55
|
+
'color.ink': '--mantine-color-text',
|
|
56
|
+
'color.ink-muted': '--mantine-color-placeholder',
|
|
57
|
+
'color.brand': '--mantine-color-anchor',
|
|
58
|
+
'color.danger': '--mantine-color-error',
|
|
59
|
+
'color.border': '--mantine-default-border-color',
|
|
60
|
+
|
|
61
|
+
'button.primary.bg.default': '--mantine-primary-color-filled',
|
|
62
|
+
'button.primary.bg.hover': '--mantine-primary-color-filled-hover',
|
|
63
|
+
|
|
64
|
+
'color.surface-raised': '--mantine-primary-color-light',
|
|
65
|
+
'color.surface-sunken': '--mantine-primary-color-light-hover',
|
|
66
|
+
|
|
67
|
+
'radius.control': '--mantine-radius-default',
|
|
68
|
+
'button.radius': '--mantine-radius-md',
|
|
69
|
+
'card.radius': '--mantine-radius-lg',
|
|
70
|
+
'radius.pill': '--mantine-radius-xl',
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const cssVarOf = (id) => '--' + String(id).split('.').join('-')
|
|
74
|
+
|
|
75
|
+
// Same shape as `@sorb/core`'s `resolveRole` — inlined per the promotion
|
|
76
|
+
// spec so this format has no hard runtime dependency on a core version that
|
|
77
|
+
// ships the role contract (see file header).
|
|
78
|
+
const resolveRoleId = (roleId, roleMap) => (roleMap && roleMap[roleId]) || roleId
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* format: sorb/mantine-vars
|
|
82
|
+
* Emits a CSS file that redeclares the mapped `--mantine-*` vars as
|
|
83
|
+
* `var(--<kit-token>) !important` refs. `options.roleMap` (role id → kit
|
|
84
|
+
* token id) lets a non-JJ kit reuse this format unmodified; defaults to the
|
|
85
|
+
* canonical/JJ ids (identity resolution) when omitted.
|
|
86
|
+
* @param {{dictionary: {allTokens: Array}, options?: {roleMap?: Record<string,string>}}} args
|
|
87
|
+
* @returns {string} the generated CSS.
|
|
88
|
+
*/
|
|
89
|
+
export const sorbMantineVars = ({ dictionary, options }) => {
|
|
90
|
+
const roleMap = options && options.roleMap
|
|
91
|
+
const byId = new Map(dictionary.allTokens.map((t) => [t.path.join('.'), t]))
|
|
92
|
+
const lines = []
|
|
93
|
+
const missing = []
|
|
94
|
+
for (const [roleId, mantineVar] of Object.entries(MANTINE_VAR_MAP)) {
|
|
95
|
+
const tokenId = resolveRoleId(roleId, roleMap)
|
|
96
|
+
const t = byId.get(tokenId)
|
|
97
|
+
if (!t) { missing.push(tokenId); continue }
|
|
98
|
+
// !important is load-bearing here — see file header "Precedence findings".
|
|
99
|
+
lines.push(` ${mantineVar}: var(${cssVarOf(tokenId)}) !important;`)
|
|
100
|
+
}
|
|
101
|
+
if (missing.length) {
|
|
102
|
+
console.warn(` ⚠ sorb/mantine-vars: ${missing.length} unmapped token id(s) skipped: ` + missing.join(', '))
|
|
103
|
+
}
|
|
104
|
+
return (
|
|
105
|
+
'/* AUTO-GENERATED by Style Dictionary (sorb/mantine-vars) — do not edit.\n' +
|
|
106
|
+
' Overrides Mantine v7 core CSS vars with var(--token) refs so a Sorb bridge\n' +
|
|
107
|
+
' push re-themes Mantine components with zero component-level code changes.\n' +
|
|
108
|
+
' !important is required — see this format\'s header "Precedence findings":\n' +
|
|
109
|
+
' Mantine\'s :root[data-mantine-color-scheme] rule otherwise wins regardless\n' +
|
|
110
|
+
' of this file\'s load order (its <style> is injected by MantineProvider at\n' +
|
|
111
|
+
' runtime, not statically ordered in <head>). */\n' +
|
|
112
|
+
':root {\n' +
|
|
113
|
+
lines.join('\n') +
|
|
114
|
+
'\n}\n'
|
|
115
|
+
)
|
|
116
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Tests for the Sorb Mantine v7 CSS-var override format (`sorb/mantine-vars`).
|
|
2
|
+
// Run: node --test (zero-dep, Node's built-in runner — matches the workspace convention).
|
|
3
|
+
import { test } from 'node:test'
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import { SORB_MANTINE_VARS, MANTINE_VAR_MAP, sorbMantineVars } from './sorbMantine.js'
|
|
6
|
+
|
|
7
|
+
// Helper: a fabricated dictionary of resolved tokens, keyed by dot-path id.
|
|
8
|
+
const tok = (id) => ({ path: id.split('.') })
|
|
9
|
+
const dictOf = (ids) => ({ allTokens: ids.map(tok) })
|
|
10
|
+
|
|
11
|
+
test('format name constant is the documented id', () => {
|
|
12
|
+
assert.equal(SORB_MANTINE_VARS, 'sorb/mantine-vars')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('emits a :root block with every mapped id present in the dictionary', () => {
|
|
16
|
+
const dictionary = dictOf(Object.keys(MANTINE_VAR_MAP))
|
|
17
|
+
const css = sorbMantineVars({ dictionary })
|
|
18
|
+
assert.match(css, /^:root \{/m)
|
|
19
|
+
assert.match(css, /\}\s*$/)
|
|
20
|
+
// one declaration per map entry
|
|
21
|
+
const declCount = (css.match(/!important;/g) || []).length
|
|
22
|
+
assert.equal(declCount, Object.keys(MANTINE_VAR_MAP).length)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('each declaration maps the correct --mantine-* var to var(--kebab-id) and keeps !important', () => {
|
|
26
|
+
const dictionary = dictOf(Object.keys(MANTINE_VAR_MAP))
|
|
27
|
+
const css = sorbMantineVars({ dictionary })
|
|
28
|
+
assert.match(css, /--mantine-color-body: var\(--color-surface\) !important;/)
|
|
29
|
+
assert.match(css, /--mantine-color-text: var\(--color-ink\) !important;/)
|
|
30
|
+
assert.match(css, /--mantine-color-anchor: var\(--color-brand\) !important;/)
|
|
31
|
+
assert.match(css, /--mantine-color-error: var\(--color-danger\) !important;/)
|
|
32
|
+
assert.match(css, /--mantine-default-border-color: var\(--color-border\) !important;/)
|
|
33
|
+
assert.match(css, /--mantine-primary-color-filled: var\(--button-primary-bg-default\) !important;/)
|
|
34
|
+
assert.match(css, /--mantine-primary-color-filled-hover: var\(--button-primary-bg-hover\) !important;/)
|
|
35
|
+
assert.match(css, /--mantine-radius-default: var\(--radius-control\) !important;/)
|
|
36
|
+
assert.match(css, /--mantine-radius-md: var\(--button-radius\) !important;/)
|
|
37
|
+
assert.match(css, /--mantine-radius-lg: var\(--card-radius\) !important;/)
|
|
38
|
+
assert.match(css, /--mantine-radius-xl: var\(--radius-pill\) !important;/)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('every value is a var() reference, never a baked literal (live-preview invariant)', () => {
|
|
42
|
+
const dictionary = dictOf(Object.keys(MANTINE_VAR_MAP))
|
|
43
|
+
const css = sorbMantineVars({ dictionary })
|
|
44
|
+
for (const line of css.split('\n').filter((l) => l.trim().startsWith('--mantine'))) {
|
|
45
|
+
assert.match(line, /: var\(--[a-z0-9-]+\) !important;$/)
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('options.roleMap remaps the JJ-token-id column without forking the format', () => {
|
|
50
|
+
// A hypothetical non-JJ kit names its surface/ink/brand roles differently.
|
|
51
|
+
const roleMap = {
|
|
52
|
+
'color.surface': 'kit.bg',
|
|
53
|
+
'color.brand': 'kit.primary',
|
|
54
|
+
}
|
|
55
|
+
const dictionary = dictOf(['kit.bg', 'kit.primary', 'color.ink'])
|
|
56
|
+
const css = sorbMantineVars({ dictionary, options: { roleMap } })
|
|
57
|
+
assert.match(css, /--mantine-color-body: var\(--kit-bg\) !important;/)
|
|
58
|
+
assert.match(css, /--mantine-color-anchor: var\(--kit-primary\) !important;/)
|
|
59
|
+
// unmapped roles fall back to their canonical/default id
|
|
60
|
+
assert.match(css, /--mantine-color-text: var\(--color-ink\) !important;/)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('unmapped/missing token ids are skipped, not emitted as broken var() refs', () => {
|
|
64
|
+
const dictionary = dictOf(['color.surface']) // only one of the map's ids present
|
|
65
|
+
const css = sorbMantineVars({ dictionary })
|
|
66
|
+
assert.match(css, /--mantine-color-body: var\(--color-surface\) !important;/)
|
|
67
|
+
assert.equal((css.match(/!important;/g) || []).length, 1)
|
|
68
|
+
})
|