@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.
Files changed (39) hide show
  1. package/package.json +7 -3
  2. package/src/adapt/__fixtures__/Button.legacy.jsx +38 -0
  3. package/src/adapt/__fixtures__/Button.tokenized.tsx +28 -0
  4. package/src/adapt/__fixtures__/corpus/button-inline.case.json +10 -0
  5. package/src/adapt/__fixtures__/corpus/card-styled.case.json +9 -0
  6. package/src/adapt/__fixtures__/corpus/misc-unmapped.case.json +7 -0
  7. package/src/adapt/adaptCli.js +110 -0
  8. package/src/adapt/adaptCli.test.js +109 -0
  9. package/src/adapt/benchmark.js +113 -0
  10. package/src/adapt/benchmark.test.js +79 -0
  11. package/src/adapt/codemod.js +160 -0
  12. package/src/adapt/codemod.test.js +120 -0
  13. package/src/adapt/detectHardcoded.js +165 -0
  14. package/src/adapt/detectHardcoded.test.js +62 -0
  15. package/src/adapt/glob.js +87 -0
  16. package/src/adapt/mapToToken.js +94 -0
  17. package/src/adapt/mapToToken.test.js +103 -0
  18. package/src/adapt/report.js +53 -0
  19. package/src/adapt/report.test.js +66 -0
  20. package/src/adapt/runBenchmark.js +16 -0
  21. package/src/adapt/types.js +48 -0
  22. package/src/captureCli.js +38 -139
  23. package/src/cli.js +16 -1
  24. package/src/emit/sorbFormat.js +313 -0
  25. package/src/emit/sorbFormat.test.js +133 -0
  26. package/src/emit/sorbMantine.js +116 -0
  27. package/src/emit/sorbMantine.test.js +68 -0
  28. package/src/emit/sorbMatSys.js +159 -0
  29. package/src/emit/sorbMatSys.test.js +75 -0
  30. package/src/emit/sorbMui.js +165 -0
  31. package/src/emit/sorbMui.test.js +102 -0
  32. package/src/emit/sorbPrimevue.js +240 -0
  33. package/src/emit/sorbPrimevue.test.js +130 -0
  34. package/src/emit/sorbShadcn.js +186 -0
  35. package/src/emit/sorbShadcn.test.js +102 -0
  36. package/src/index.js +48 -0
  37. package/src/sources/figmaPlugin.js +83 -0
  38. package/src/sources/figmaPlugin.test.js +65 -0
  39. package/src/sources/storybookDom.js +188 -0
@@ -0,0 +1,240 @@
1
+ // Sorb PrimeVue v4 preset format (`sorb/primevue-preset`).
2
+ //
3
+ // PROMOTED from `sorb-demo-primevue/src/jjPreset.js` (P4/P4a spike →
4
+ // framework-targets-productization T4, first of the six JJ-demo formats that
5
+ // is a JS-EMITTING target rather than a CSS one). The demo file stays in
6
+ // place for now (T8 retrofit is founder-gated); this is the canonical,
7
+ // generalized home.
8
+ //
9
+ // MECHANISM (carried from the spike): a `definePreset` leaf value that is a
10
+ // plain string (not one of PrimeVue's own `{a.b.c}` refs) is written VERBATIM
11
+ // as the corresponding `--p-*` custom property's value. `var(--kebab-token)`
12
+ // strings therefore make every `--p-*` var a pure indirection onto the kit's
13
+ // own CSS vars — a Sorb bridge push re-themes PrimeVue with zero
14
+ // component-level code changes. Two override tiers, same as the spike:
15
+ // 1. SEMANTIC tier — a small set of roots (`primary`, `text`, `content`,
16
+ // `formField`, `navigation.item`, `overlay.*`, `colorScheme.light.surface`)
17
+ // that most PrimeVue components read from, so overriding them re-themes
18
+ // Card/InputText/Select/Dialog/Toast/Tabs/… all at once.
19
+ // 2. COMPONENT tier — overrides for components that read straight from the
20
+ // PrimeVue PRIMITIVE palette instead of a semantic root (severity-colored
21
+ // Tag/Toast) or need an explicit per-component override (Menubar's
22
+ // brand-colored bar — the kit's `nav.*` tokens are a deliberately
23
+ // different color story than `content`/`surface`).
24
+ //
25
+ // RISK (framework-targets-productization risk table, T4): the emitted
26
+ // module's `import { definePreset } from '@primeuix/themes'` (+ the base
27
+ // preset import, e.g. `@primeuix/themes/aura`) is a PEER EXPECTATION — this
28
+ // format does not install or vendor `@primeuix/themes`; the consuming app's
29
+ // own bundler/dependency tree must already have it (any PrimeVue v4 app
30
+ // does). `new Function`/eval can't prove this module resolves for real (no
31
+ // `@primeuix/themes` in this package's — or even the consumer's CI —
32
+ // test env); it only proves the emitted source is syntactically the right
33
+ // shape. The real bundler path is proven by the T8 demo retrofit, not this
34
+ // format's test. Document this in the adapter/README snippet too.
35
+ //
36
+ // GENERALIZATION (T0 semantic-role contract): the spike hardcoded Janes
37
+ // Jeans' own token ids as every leaf. Promotion makes every leaf
38
+ // ROLE-RESOLVED — a non-JJ kit passes `options.roleMap` (role id → its own
39
+ // token id) instead of forking this file. Resolution is the same shape as
40
+ // `@sorb/core`'s `resolveRole`/`DEFAULT_ROLE_IDS`, inlined here rather than
41
+ // imported so this format degrades gracefully against an older published
42
+ // `@sorb/core` that predates the role contract (same feature-detect posture
43
+ // as the TargetAdapter registration side — see
44
+ // `sorb-leaf/src/targets/primevue.js`). None of the role ids below are in
45
+ // `@sorb/core`'s canonical `DEFAULT_ROLE_IDS` (JJ's component-tier ids —
46
+ // `button.primary.bg.default`, `card.bg`, `nav.bg`, `toast.bg.success`, …
47
+ // have no canonical counterpart yet; they're kit-private, per the
48
+ // productization spec's "contract scope = the union of the six maps' role
49
+ // columns, not a kit's full token tree"). They still go through the same
50
+ // `roleMap` resolution for consistency, and so a kit that DOES want to
51
+ // override them can.
52
+ //
53
+ // `options.basePreset` (default `'Aura'`) controls BOTH the base-theme
54
+ // import (`@primeuix/themes/<lowercased-name>`) and the `definePreset(<Name>,
55
+ // …)` call's first argument — PrimeVue v4 ships `Aura`/`Material`/`Lara`/
56
+ // `Nora` as documented base presets, each importable the same way.
57
+
58
+ export const SORB_PRIMEVUE_PRESET = 'sorb/primevue-preset'
59
+
60
+ /**
61
+ * Structural role tree describing the PrimeVue `definePreset` shape this
62
+ * format generates, mirroring `jjPreset.js`'s hand-authored nesting exactly.
63
+ * Every leaf is a role id (a dot-path token id, canonical-or-JJ) resolved
64
+ * through `options.roleMap` before becoming a `var(--kebab-id)` ref.
65
+ */
66
+ export const PRIMEVUE_ROLE_TREE = {
67
+ semantic: {
68
+ primary: {
69
+ color: 'button.primary.bg.default',
70
+ hoverColor: 'button.primary.bg.hover',
71
+ activeColor: 'button.primary.bg.hover',
72
+ contrastColor: 'button.primary.fg.default',
73
+ },
74
+ text: {
75
+ color: 'color.ink',
76
+ hoverColor: 'color.ink',
77
+ mutedColor: 'color.ink-muted',
78
+ hoverMutedColor: 'color.ink-muted',
79
+ },
80
+ content: {
81
+ background: 'card.bg',
82
+ hoverBackground: 'color.surface-raised',
83
+ borderColor: 'card.border',
84
+ color: 'color.ink',
85
+ hoverColor: 'color.ink',
86
+ borderRadius: 'card.radius',
87
+ },
88
+ formField: {
89
+ background: 'input.bg.default',
90
+ disabledBackground: 'input.bg.disabled',
91
+ filledBackground: 'input.bg.default',
92
+ filledHoverBackground: 'input.bg.default',
93
+ filledFocusBackground: 'input.bg.default',
94
+ borderColor: 'input.border.default',
95
+ hoverBorderColor: 'input.border.default',
96
+ focusBorderColor: 'input.border.focus',
97
+ invalidBorderColor: 'input.border.invalid',
98
+ color: 'input.fg',
99
+ disabledColor: 'color.ink-muted',
100
+ placeholderColor: 'input.placeholder',
101
+ invalidPlaceholderColor: 'input.placeholder',
102
+ borderRadius: 'input.radius',
103
+ },
104
+ navigation: {
105
+ item: {
106
+ activeColor: 'nav.link-active',
107
+ focusColor: 'nav.link-active',
108
+ },
109
+ },
110
+ overlay: {
111
+ select: { background: 'card.bg', borderColor: 'card.border', color: 'color.ink' },
112
+ popover: { background: 'card.bg', borderColor: 'card.border', color: 'color.ink' },
113
+ modal: { background: 'card.bg', borderColor: 'card.border', color: 'color.ink' },
114
+ },
115
+ colorScheme: {
116
+ light: {
117
+ surface: {
118
+ 50: 'color.surface-raised',
119
+ 100: 'color.surface-raised',
120
+ 200: 'color.surface-sunken',
121
+ 300: 'color.border',
122
+ },
123
+ },
124
+ },
125
+ },
126
+ components: {
127
+ // The brand-colored top bar — the one place the kit's nav.* tokens
128
+ // (distinct from the content/surface story used everywhere else) apply.
129
+ menubar: {
130
+ colorScheme: {
131
+ light: {
132
+ root: { background: 'nav.bg', borderColor: 'nav.bg', color: 'nav.fg' },
133
+ item: {
134
+ color: 'nav.fg',
135
+ focusColor: 'nav.link-active',
136
+ activeColor: 'nav.link-active',
137
+ icon: { color: 'nav.fg-muted', focusColor: 'nav.link-active', activeColor: 'nav.link-active' },
138
+ },
139
+ },
140
+ },
141
+ },
142
+ // Toast severities: success/danger/info only (no "warn" — PrimeVue's
143
+ // default stays themed by the semantic content/text overrides above).
144
+ toast: {
145
+ colorScheme: {
146
+ light: {
147
+ success: { background: 'toast.bg.success', color: 'toast.fg', borderColor: 'toast.bg.success' },
148
+ error: { background: 'toast.bg.danger', color: 'toast.fg', borderColor: 'toast.bg.danger' },
149
+ info: { background: 'toast.bg.info', color: 'toast.fg', borderColor: 'toast.bg.info' },
150
+ },
151
+ },
152
+ },
153
+ },
154
+ }
155
+
156
+ const cssVarOf = (id) => '--' + String(id).split('.').join('-')
157
+
158
+ // Same shape as `@sorb/core`'s `resolveRole` — inlined per the promotion
159
+ // spec so this format has no hard runtime dependency on a core version that
160
+ // ships the role contract (see file header).
161
+ const resolveRoleId = (roleId, roleMap) => (roleMap && roleMap[roleId]) || roleId
162
+
163
+ /**
164
+ * Recursively walk `PRIMEVUE_ROLE_TREE` (or a subtree), resolving each leaf
165
+ * role id through `roleMap` and, when `known` is supplied, dropping any leaf
166
+ * whose resolved token id isn't in the dictionary (skip-missing, same
167
+ * posture as `sorbMantineVars` — a partial kit still emits a valid, if
168
+ * smaller, preset rather than a broken var() ref). Empty objects left behind
169
+ * by an all-missing branch are pruned too.
170
+ * @param {*} node
171
+ * @param {Set<string>|undefined} known
172
+ * @param {Record<string,string>|undefined} roleMap
173
+ * @param {string[]} missing accumulator for unresolved role ids (mutated)
174
+ * @returns {*}
175
+ */
176
+ const buildPresetNode = (node, known, roleMap, missing) => {
177
+ if (typeof node === 'string') {
178
+ const tokenId = resolveRoleId(node, roleMap)
179
+ if (known && !known.has(tokenId)) {
180
+ missing.push(tokenId)
181
+ return undefined
182
+ }
183
+ return `var(${cssVarOf(tokenId)})`
184
+ }
185
+ const out = {}
186
+ for (const [key, value] of Object.entries(node)) {
187
+ const resolved = buildPresetNode(value, known, roleMap, missing)
188
+ if (resolved === undefined) continue
189
+ if (typeof resolved === 'object' && Object.keys(resolved).length === 0) continue
190
+ out[key] = resolved
191
+ }
192
+ return out
193
+ }
194
+
195
+ const BASE_PRESET_IMPORTS = {
196
+ Aura: '@primeuix/themes/aura',
197
+ Material: '@primeuix/themes/material',
198
+ Lara: '@primeuix/themes/lara',
199
+ Nora: '@primeuix/themes/nora',
200
+ }
201
+
202
+ /**
203
+ * format: sorb/primevue-preset
204
+ * Emits a JS/ESM module string:
205
+ * import { definePreset } from '@primeuix/themes';
206
+ * import Aura from '@primeuix/themes/aura';
207
+ * export const preset = definePreset(Aura, { semantic: {...}, components: {...} });
208
+ * Every leaf is a `var(--kebab-token-id)` string — the live-preview
209
+ * invariant, same as every other Sorb format (no baked literals). Pure
210
+ * function of `{dictionary, options}`; zero `style-dictionary` dep.
211
+ * @param {{dictionary: {allTokens: Array}, options?: {roleMap?: Record<string,string>, basePreset?: string}}} args
212
+ * @returns {string} the generated ESM module source.
213
+ */
214
+ export const sorbPrimevuePreset = ({ dictionary, options }) => {
215
+ const roleMap = options && options.roleMap
216
+ const basePreset = (options && options.basePreset) || 'Aura'
217
+ const known = new Set((dictionary.allTokens || []).map((t) => t.path.join('.')))
218
+ const missing = []
219
+
220
+ const semantic = buildPresetNode(PRIMEVUE_ROLE_TREE.semantic, known, roleMap, missing)
221
+ const components = buildPresetNode(PRIMEVUE_ROLE_TREE.components, known, roleMap, missing)
222
+
223
+ if (missing.length) {
224
+ console.warn(` ⚠ sorb/primevue-preset: ${missing.length} unmapped token id(s) skipped: ` + missing.join(', '))
225
+ }
226
+
227
+ const basePresetImport = BASE_PRESET_IMPORTS[basePreset] || `@primeuix/themes/${String(basePreset).toLowerCase()}`
228
+ const presetBody = JSON.stringify({ semantic, components }, null, 2)
229
+
230
+ return (
231
+ '// AUTO-GENERATED by Style Dictionary (sorb/primevue-preset) — do not edit.\n' +
232
+ '// PEER EXPECTATION: this module imports `@primeuix/themes` (+ its base preset\n' +
233
+ '// subpath) — install it in the consuming app; this format neither installs nor\n' +
234
+ '// vendors it. See sorb-seed/src/emit/sorbPrimevue.js header "RISK".\n' +
235
+ `import { definePreset } from '@primeuix/themes';\n` +
236
+ `import ${basePreset} from '${basePresetImport}';\n` +
237
+ '\n' +
238
+ `export const preset = definePreset(${basePreset}, ` + presetBody + ');\n'
239
+ )
240
+ }
@@ -0,0 +1,130 @@
1
+ // Tests for the Sorb PrimeVue v4 preset format (`sorb/primevue-preset`) — the
2
+ // first JS-EMITTING target format (the others emit CSS). `@primeuix/themes`
3
+ // isn't installed in this package's test env (it's a peer expectation of the
4
+ // EMITTED module, not a dep of `@sorb/seed` — see the format's file header),
5
+ // so these tests can't `import()` the real output. Instead they (a) assert
6
+ // the raw source string's shape by regex, and (b) `new Function`-evaluate a
7
+ // transformed copy (imports stripped, `definePreset`/base preset stubbed) to
8
+ // prove the object-literal body is syntactically valid and structurally
9
+ // correct — the same "prove it evaluates" spirit as sorbFormat.test.js's
10
+ // `new Function('module','exports', src)` check, adapted for ESM `import`/
11
+ // `export` syntax that `new Function` cannot parse directly.
12
+ import { test } from 'node:test'
13
+ import assert from 'node:assert/strict'
14
+ import { SORB_PRIMEVUE_PRESET, PRIMEVUE_ROLE_TREE, sorbPrimevuePreset } from './sorbPrimevue.js'
15
+
16
+ // Helper: a fabricated dictionary of resolved tokens, keyed by dot-path id.
17
+ const tok = (id) => ({ path: id.split('.') })
18
+ const dictOf = (ids) => ({ allTokens: ids.map(tok) })
19
+
20
+ // Every role id referenced anywhere in PRIMEVUE_ROLE_TREE, flattened.
21
+ const flattenRoleIds = (node, out = []) => {
22
+ if (typeof node === 'string') { out.push(node); return out }
23
+ for (const v of Object.values(node)) flattenRoleIds(v, out)
24
+ return out
25
+ }
26
+ const ALL_ROLE_IDS = flattenRoleIds(PRIMEVUE_ROLE_TREE)
27
+
28
+ // Evaluate the object-literal body of an emitted module: strip the `import`
29
+ // lines and the `export const preset = definePreset(Base, ` … `);` wrapper,
30
+ // then run the remaining `{ ... }` object literal through `new Function` with
31
+ // no external bindings needed (it's pure data — strings only).
32
+ const evalPresetBody = (src) => {
33
+ const withoutImports = src.replace(/^import .*$/gm, '')
34
+ const match = withoutImports.match(/export const preset = definePreset\(\w+, ([\s\S]*)\);\s*$/)
35
+ assert.ok(match, 'module source must match the definePreset(Base, {...}); shape')
36
+ return new Function(`return (${match[1]})`)()
37
+ }
38
+
39
+ test('format name constant is the documented id', () => {
40
+ assert.equal(SORB_PRIMEVUE_PRESET, 'sorb/primevue-preset')
41
+ })
42
+
43
+ test('emits the documented import lines + definePreset(Aura, ...) call by default', () => {
44
+ const dictionary = dictOf(ALL_ROLE_IDS)
45
+ const src = sorbPrimevuePreset({ dictionary })
46
+ assert.match(src, /^import \{ definePreset \} from '@primeuix\/themes';$/m)
47
+ assert.match(src, /^import Aura from '@primeuix\/themes\/aura';$/m)
48
+ assert.match(src, /^export const preset = definePreset\(Aura, \{/m)
49
+ assert.match(src, /\}\);\s*$/)
50
+ })
51
+
52
+ test('every leaf is a var() reference, never a baked literal (live-preview invariant)', () => {
53
+ const dictionary = dictOf(ALL_ROLE_IDS)
54
+ const src = sorbPrimevuePreset({ dictionary })
55
+ const preset = evalPresetBody(src)
56
+ const leaves = (o) => Object.values(o).flatMap((v) => (typeof v === 'string' ? [v] : leaves(v)))
57
+ const values = [...leaves(preset.semantic), ...leaves(preset.components)]
58
+ assert.ok(values.length > 0)
59
+ for (const val of values) assert.match(val, /^var\(--[a-z0-9-]+\)$/)
60
+ })
61
+
62
+ test('semantic tier matches jjPreset.js structure/values exactly (identity roleMap)', () => {
63
+ const dictionary = dictOf(ALL_ROLE_IDS)
64
+ const src = sorbPrimevuePreset({ dictionary })
65
+ const preset = evalPresetBody(src)
66
+ assert.equal(preset.semantic.primary.color, 'var(--button-primary-bg-default)')
67
+ assert.equal(preset.semantic.primary.contrastColor, 'var(--button-primary-fg-default)')
68
+ assert.equal(preset.semantic.text.mutedColor, 'var(--color-ink-muted)')
69
+ assert.equal(preset.semantic.content.borderRadius, 'var(--card-radius)')
70
+ assert.equal(preset.semantic.formField.focusBorderColor, 'var(--input-border-focus)')
71
+ assert.equal(preset.semantic.navigation.item.activeColor, 'var(--nav-link-active)')
72
+ assert.equal(preset.semantic.overlay.popover.background, 'var(--card-bg)')
73
+ assert.equal(preset.semantic.colorScheme.light.surface['200'], 'var(--color-surface-sunken)')
74
+ })
75
+
76
+ test('component tier (Menubar/Toast reading the primitive palette) matches jjPreset.js', () => {
77
+ const dictionary = dictOf(ALL_ROLE_IDS)
78
+ const src = sorbPrimevuePreset({ dictionary })
79
+ const preset = evalPresetBody(src)
80
+ assert.equal(preset.components.menubar.colorScheme.light.root.background, 'var(--nav-bg)')
81
+ assert.equal(preset.components.menubar.colorScheme.light.item.icon.color, 'var(--nav-fg-muted)')
82
+ assert.equal(preset.components.toast.colorScheme.light.success.background, 'var(--toast-bg-success)')
83
+ assert.equal(preset.components.toast.colorScheme.light.error.borderColor, 'var(--toast-bg-danger)')
84
+ assert.equal(preset.components.toast.colorScheme.light.info.color, 'var(--toast-fg)')
85
+ })
86
+
87
+ test('options.roleMap remaps the role-id column without forking the format', () => {
88
+ const roleMap = {
89
+ 'button.primary.bg.default': 'kit.primary',
90
+ 'color.ink': 'kit.text',
91
+ }
92
+ const dictionary = dictOf(['kit.primary', 'kit.text', ...ALL_ROLE_IDS.filter((id) => id !== 'button.primary.bg.default' && id !== 'color.ink')])
93
+ const src = sorbPrimevuePreset({ dictionary, options: { roleMap } })
94
+ const preset = evalPresetBody(src)
95
+ assert.equal(preset.semantic.primary.color, 'var(--kit-primary)')
96
+ assert.equal(preset.semantic.text.color, 'var(--kit-text)')
97
+ // unmapped roles fall back to their canonical/default id
98
+ assert.equal(preset.semantic.text.mutedColor, 'var(--color-ink-muted)')
99
+ })
100
+
101
+ test('options.basePreset swaps both the base import and the definePreset() base arg', () => {
102
+ const dictionary = dictOf(ALL_ROLE_IDS)
103
+ const src = sorbPrimevuePreset({ dictionary, options: { basePreset: 'Lara' } })
104
+ assert.match(src, /^import Lara from '@primeuix\/themes\/lara';$/m)
105
+ assert.match(src, /^export const preset = definePreset\(Lara, \{/m)
106
+ })
107
+
108
+ test('an unmapped/missing token id is skipped, not emitted as a broken var() ref', () => {
109
+ // Only supply the tokens for the `primary` subtree; everything else absent.
110
+ const dictionary = dictOf([
111
+ 'button.primary.bg.default',
112
+ 'button.primary.bg.hover',
113
+ 'button.primary.fg.default',
114
+ ])
115
+ const src = sorbPrimevuePreset({ dictionary })
116
+ const preset = evalPresetBody(src)
117
+ assert.equal(preset.semantic.primary.color, 'var(--button-primary-bg-default)')
118
+ // text/content/formField/etc never had their tokens present → pruned away
119
+ assert.equal(preset.semantic.text, undefined)
120
+ assert.equal(preset.components.menubar, undefined)
121
+ })
122
+
123
+ test('emitted module source contains no baked literal — only var(--kebab) leaves', () => {
124
+ const dictionary = dictOf(ALL_ROLE_IDS)
125
+ const src = sorbPrimevuePreset({ dictionary })
126
+ // Every quoted string value in the object body must be a var() ref.
127
+ const stringValues = [...src.matchAll(/: "([^"]*)"/g)].map((m) => m[1])
128
+ assert.ok(stringValues.length > 0)
129
+ for (const v of stringValues) assert.match(v, /^var\(--[a-z0-9-]+\)$/)
130
+ })
@@ -0,0 +1,186 @@
1
+ // Sorb shadcn/ui theme format (`sorb/shadcn-theme`) — framework-targets-
2
+ // productization T1 (Tailwind v4 + shadcn/ui).
3
+ //
4
+ // Promoted from `sorb-demo-tailwind/src/tokens/shadcn-map.css` +
5
+ // `shadcn-theme.css` (hand-authored source material) into ONE generated CSS
6
+ // artifact: shadcn's conventional `:root{}` var map (--background,
7
+ // --primary, …) chained onto Sorb's own tokens via the T0 semantic-role
8
+ // contract, plus the `@theme inline{}` Tailwind-utility binding block
9
+ // (including the `calc()` radius scale). Zero `style-dictionary` dep — pure
10
+ // function of `{ dictionary, options }`, matching `sorbFormat.js`'s
11
+ // conventions.
12
+ //
13
+ // Every leaf is a `var(--<kebab>)` reference (live-preview invariant) — the
14
+ // bridge's runtime var-swap re-themes shadcn components with no
15
+ // shadcn-specific bridge code.
16
+
17
+ /** @type {'sorb/shadcn-theme'} */
18
+ export const SORB_SHADCN = 'sorb/shadcn-theme'
19
+
20
+ /**
21
+ * Resolve a canonical role id to the kit's actual token id via an optional
22
+ * override map — inlined one-liner matching `@sorb/core`'s `resolveRole`
23
+ * (T0). Kept local rather than imported so `@sorb/seed` doesn't take a
24
+ * runtime dep on a specific `@sorb/core` version (formats are plain
25
+ * functions; the published core can lag the connector contract).
26
+ * @param {string} roleId
27
+ * @param {Record<string,string>} [roleMap]
28
+ * @returns {string}
29
+ */
30
+ const resolveShadcnRole = (roleId, roleMap) => (roleMap && roleMap[roleId]) || roleId
31
+
32
+ const cssVarOf = (tokenId) => '--' + String(tokenId).split('.').join('-')
33
+
34
+ // shadcn CSS var → canonical role id (DEFAULT_ROLE_IDS.color / .radius from
35
+ // `@sorb/core`). Order matches the hand-authored `shadcn-map.css` for a
36
+ // clean round-trip diff.
37
+ const SHADCN_COLOR_ROLE_MAP = [
38
+ ['--background', 'color.surface'],
39
+ ['--foreground', 'color.ink'],
40
+ ['--card', 'color.surface'],
41
+ ['--card-foreground', 'color.ink'],
42
+ ['--popover', 'color.surface'],
43
+ ['--popover-foreground', 'color.ink'],
44
+ ['--primary', 'color.brand'],
45
+ ['--primary-foreground', 'color.brand-contrast'],
46
+ ['--secondary', 'color.surface-raised'],
47
+ ['--secondary-foreground', 'color.ink'],
48
+ ['--muted', 'color.surface-sunken'],
49
+ ['--muted-foreground', 'color.ink-muted'],
50
+ ['--accent', 'color.accent'],
51
+ ['--accent-foreground', 'color.accent-contrast'],
52
+ ['--destructive', 'color.danger'],
53
+ ['--border', 'color.border'],
54
+ ['--input', 'color.border'],
55
+ ['--ring', 'color.focus-ring'],
56
+ ]
57
+
58
+ // ROLE-CONTRACT GAP (flagged, not invented — framework-targets-
59
+ // productization T1 note): shadcn's `--destructive-foreground` wants "the
60
+ // ink color that reads on top of --destructive". `@sorb/core`'s
61
+ // `DEFAULT_ROLE_IDS` only defines `-contrast` roles for brand/accent, not
62
+ // danger — there is no canonical `color.danger-contrast`. Rather than invent
63
+ // one unilaterally, this references the kit's raw `color.white` primitive
64
+ // (matches the JJ demo's hand-authored value) directly — NOT run through
65
+ // `options.roleMap`, since it isn't a role id. A kit without a `color.white`
66
+ // token overrides via `options.destructiveForeground`.
67
+ const DESTRUCTIVE_FOREGROUND_DEFAULT = 'color.white'
68
+
69
+ // `--radius` role — the one non-color entry in the `:root{}` map.
70
+ const RADIUS_ROLE_DEFAULT = 'radius.control'
71
+
72
+ // `@theme inline{}` block: purely mechanical — chains Tailwind's `--color-*`
73
+ // theme keys onto the shadcn vars defined above (never onto Sorb token ids
74
+ // directly), plus the fixed `calc()` radius scale. Independent of roleMap.
75
+ const THEME_INLINE_COLOR_ENTRIES = [
76
+ ['--color-background', '--background'],
77
+ ['--color-foreground', '--foreground'],
78
+ ['--color-card', '--card'],
79
+ ['--color-card-foreground', '--card-foreground'],
80
+ ['--color-popover', '--popover'],
81
+ ['--color-popover-foreground', '--popover-foreground'],
82
+ ['--color-primary', '--primary'],
83
+ ['--color-primary-foreground', '--primary-foreground'],
84
+ ['--color-secondary', '--secondary'],
85
+ ['--color-secondary-foreground', '--secondary-foreground'],
86
+ ['--color-muted', '--muted'],
87
+ ['--color-muted-foreground', '--muted-foreground'],
88
+ ['--color-accent', '--accent'],
89
+ ['--color-accent-foreground', '--accent-foreground'],
90
+ ['--color-destructive', '--destructive'],
91
+ ['--color-destructive-foreground', '--destructive-foreground'],
92
+ ['--color-border', '--border'],
93
+ ['--color-input', '--input'],
94
+ ['--color-ring', '--ring'],
95
+ ]
96
+
97
+ /**
98
+ * Build the `:root{}` shadcn-var → Sorb-token map, resolving roles through
99
+ * `options.roleMap` (defaulting to identity — the JJ reference kit already
100
+ * uses canonical role ids).
101
+ * @param {{roleMap?: Record<string,string>, destructiveForeground?: string, radiusRole?: string}} [options]
102
+ * @param {Set<string>} [knownTokenIds] when provided, unknown resolved ids are warned (not skipped — CSS still emits, just points at an undefined var).
103
+ * @returns {string[]} lines
104
+ */
105
+ export const shadcnRootLines = (options = {}, knownTokenIds) => {
106
+ const { roleMap, destructiveForeground, radiusRole } = options
107
+ const missing = []
108
+ const resolve = (roleId) => resolveShadcnRole(roleId, roleMap)
109
+ const checkAndVar = (tokenId, roleId) => {
110
+ if (knownTokenIds && !knownTokenIds.has(tokenId)) missing.push(`${roleId} → ${tokenId}`)
111
+ return `var(${cssVarOf(tokenId)})`
112
+ }
113
+
114
+ const lines = []
115
+ for (const [shadcnVar, roleId] of SHADCN_COLOR_ROLE_MAP) {
116
+ const tokenId = resolve(roleId)
117
+ lines.push(` ${shadcnVar}: ${checkAndVar(tokenId, roleId)};`)
118
+ if (shadcnVar === '--destructive') {
119
+ // --destructive-foreground has no canonical role (see the
120
+ // ROLE-CONTRACT GAP note above) — emitted right after --destructive to
121
+ // match the hand-authored `shadcn-map.css` ordering byte-for-byte.
122
+ const destructiveFgId = destructiveForeground || DESTRUCTIVE_FOREGROUND_DEFAULT
123
+ lines.push(` --destructive-foreground: ${checkAndVar(destructiveFgId, '(non-role) destructiveForeground')};`)
124
+ }
125
+ }
126
+ const radiusId = resolve(radiusRole || RADIUS_ROLE_DEFAULT)
127
+ lines.push(` --radius: ${checkAndVar(radiusId, radiusRole || RADIUS_ROLE_DEFAULT)};`)
128
+
129
+ if (missing.length) {
130
+ // eslint-disable-next-line no-console
131
+ console.warn(` ⚠ sorb/shadcn-theme: ${missing.length} unresolved token id(s): ` + missing.join(', '))
132
+ }
133
+ return lines
134
+ }
135
+
136
+ /** The fixed `@theme inline{}` block lines (mechanical; no roleMap input). */
137
+ export const shadcnThemeInlineLines = () => {
138
+ const lines = THEME_INLINE_COLOR_ENTRIES.map(([key, ref]) => ` ${key}: var(${ref});`)
139
+ lines.push(
140
+ ' --radius-sm: calc(var(--radius) - 4px);',
141
+ ' --radius-md: calc(var(--radius) - 2px);',
142
+ ' --radius-lg: var(--radius);',
143
+ ' --radius-xl: calc(var(--radius) + 4px);',
144
+ )
145
+ return lines
146
+ }
147
+
148
+ /**
149
+ * format: sorb/shadcn-theme
150
+ * Emits ONE CSS artifact: shadcn's `:root{}` semantic-var map (chained onto
151
+ * Sorb tokens via the role contract) followed by the `@theme inline{}`
152
+ * Tailwind-utility binding block — reproducing what
153
+ * `sorb-demo-tailwind/src/tokens/shadcn-map.css` + `shadcn-theme.css` hand-
154
+ * authored. Pair with `variables.css` (defines the Sorb vars this format
155
+ * references) in the consumer's CSS import order:
156
+ * @import "tailwindcss";
157
+ * @import "./variables.css"; sorb tokens — the bridge swaps these live
158
+ * @import "./shadcn-theme.css"; this file
159
+ * @param {{dictionary: {allTokens: Array<{path: string[]}>}, options?: {roleMap?: Record<string,string>, destructiveForeground?: string, radiusRole?: string}}} args
160
+ * @returns {string}
161
+ */
162
+ export const sorbShadcn = ({ dictionary, options } = {}) => {
163
+ const known = dictionary && Array.isArray(dictionary.allTokens)
164
+ ? new Set(dictionary.allTokens.map((t) => t.path.join('.')))
165
+ : undefined
166
+
167
+ const rootLines = shadcnRootLines(options || {}, known)
168
+ const themeLines = shadcnThemeInlineLines()
169
+
170
+ return (
171
+ '/* AUTO-GENERATED by Style Dictionary (sorb/shadcn-theme) — do not edit.\n' +
172
+ ' shadcn/ui semantic-var map, chained onto Sorb\'s runtime CSS vars via the\n' +
173
+ ' semantic-role contract (@sorb/core DEFAULT_ROLE_IDS), plus the `@theme\n' +
174
+ ' inline` Tailwind-utility bindings shadcn components read (bg-primary,\n' +
175
+ ' text-foreground, border-input, …). Import order in your entry CSS:\n' +
176
+ ' @import "tailwindcss";\n' +
177
+ ' @import "./variables.css"; sorb tokens — the bridge swaps these live\n' +
178
+ ' @import "./shadcn-theme.css"; this file */\n' +
179
+ ':root {\n' +
180
+ rootLines.join('\n') +
181
+ '\n}\n\n' +
182
+ '@theme inline {\n' +
183
+ themeLines.join('\n') +
184
+ '\n}\n'
185
+ )
186
+ }
@@ -0,0 +1,102 @@
1
+ // Tests for the Sorb shadcn/ui theme format (`sorb/shadcn-theme`).
2
+ // Run: node --test
3
+ import { test } from 'node:test'
4
+ import assert from 'node:assert/strict'
5
+ import { SORB_SHADCN, sorbShadcn, shadcnRootLines, shadcnThemeInlineLines } from './sorbShadcn.js'
6
+
7
+ // A fabricated dictionary carrying the JJ role tokens the format resolves
8
+ // against (identity roleMap case — the JJ reference kit already uses
9
+ // canonical role ids from `@sorb/core`'s DEFAULT_ROLE_IDS).
10
+ const JJ_ROLE_TOKEN_IDS = [
11
+ 'color.surface', 'color.surface-raised', 'color.surface-sunken',
12
+ 'color.ink', 'color.ink-muted',
13
+ 'color.brand', 'color.brand-contrast',
14
+ 'color.accent', 'color.accent-contrast',
15
+ 'color.danger', 'color.border', 'color.focus-ring',
16
+ 'color.white',
17
+ 'radius.control',
18
+ ]
19
+ const dictionary = { allTokens: JJ_ROLE_TOKEN_IDS.map((id) => ({ path: id.split('.') })) }
20
+
21
+ test('format id is sorb/shadcn-theme', () => {
22
+ assert.equal(SORB_SHADCN, 'sorb/shadcn-theme')
23
+ })
24
+
25
+ test('root map: identity roleMap resolves every shadcn var to its canonical role\'s var()', () => {
26
+ const css = sorbShadcn({ dictionary })
27
+ assert.match(css, /--background: var\(--color-surface\);/)
28
+ assert.match(css, /--foreground: var\(--color-ink\);/)
29
+ assert.match(css, /--primary: var\(--color-brand\);/)
30
+ assert.match(css, /--primary-foreground: var\(--color-brand-contrast\);/)
31
+ assert.match(css, /--destructive: var\(--color-danger\);/)
32
+ assert.match(css, /--border: var\(--color-border\);/)
33
+ assert.match(css, /--input: var\(--color-border\);/)
34
+ assert.match(css, /--ring: var\(--color-focus-ring\);/)
35
+ assert.match(css, /--radius: var\(--radius-control\);/)
36
+ })
37
+
38
+ test('root map: destructive-foreground falls back to the color.white primitive (role-contract gap)', () => {
39
+ const css = sorbShadcn({ dictionary })
40
+ assert.match(css, /--destructive-foreground: var\(--color-white\);/)
41
+ })
42
+
43
+ test('root map: options.roleMap overrides a canonical role id to a non-JJ kit token', () => {
44
+ const css = sorbShadcn({
45
+ dictionary: { allTokens: [{ path: ['brand', 'primary'] }, ...dictionary.allTokens] },
46
+ options: { roleMap: { 'color.brand': 'brand.primary' } },
47
+ })
48
+ assert.match(css, /--primary: var\(--brand-primary\);/)
49
+ })
50
+
51
+ test('options.destructiveForeground / options.radiusRole override the non-role defaults', () => {
52
+ const css = sorbShadcn({
53
+ dictionary: { allTokens: [{ path: ['ink', 'onDanger'] }, { path: ['radius', 'lg'] }, ...dictionary.allTokens] },
54
+ options: { destructiveForeground: 'ink.onDanger', radiusRole: 'radius.lg' },
55
+ })
56
+ assert.match(css, /--destructive-foreground: var\(--ink-onDanger\);/)
57
+ assert.match(css, /--radius: var\(--radius-lg\);/)
58
+ })
59
+
60
+ test('emits an @theme inline block with the calc() radius scale, chained onto shadcn vars (not raw tokens)', () => {
61
+ const css = sorbShadcn({ dictionary })
62
+ assert.match(css, /@theme inline \{/)
63
+ assert.match(css, /--color-background: var\(--background\);/)
64
+ assert.match(css, /--color-primary-foreground: var\(--primary-foreground\);/)
65
+ assert.match(css, /--radius-sm: calc\(var\(--radius\) - 4px\);/)
66
+ assert.match(css, /--radius-md: calc\(var\(--radius\) - 2px\);/)
67
+ assert.match(css, /--radius-lg: var\(--radius\);/)
68
+ assert.match(css, /--radius-xl: calc\(var\(--radius\) \+ 4px\);/)
69
+ })
70
+
71
+ test('every emitted value is a var()/calc(var()) reference — never a baked literal (live-preview invariant)', () => {
72
+ const css = sorbShadcn({ dictionary })
73
+ const declLines = css.split('\n').filter((l) => /^\s*--[a-z-]+:/.test(l))
74
+ assert.ok(declLines.length > 0)
75
+ for (const line of declLines) {
76
+ assert.match(line, /:\s*(var\(--[a-zA-Z0-9-]+\)|calc\(var\(--[a-zA-Z0-9-]+\)[^)]*\))\s*;\s*$/, `unexpected literal: ${line}`)
77
+ }
78
+ })
79
+
80
+ test('warns (does not throw) when a resolved token id is not in the dictionary', () => {
81
+ const warnings = []
82
+ const origWarn = console.warn
83
+ console.warn = (msg) => warnings.push(msg)
84
+ try {
85
+ const css = sorbShadcn({ dictionary: { allTokens: [{ path: ['color', 'surface'] }] } })
86
+ assert.match(css, /--background: var\(--color-surface\);/) // still emits
87
+ } finally {
88
+ console.warn = origWarn
89
+ }
90
+ assert.ok(warnings.length > 0)
91
+ assert.match(warnings[0], /unresolved token id/)
92
+ })
93
+
94
+ test('shadcnRootLines / shadcnThemeInlineLines are pure and independently testable', () => {
95
+ const rootLines = shadcnRootLines({})
96
+ assert.equal(rootLines.length, SHADCN_ROOT_LINE_COUNT)
97
+ const themeLines = shadcnThemeInlineLines()
98
+ assert.equal(themeLines.filter((l) => l.includes('--radius-')).length, 4)
99
+ })
100
+
101
+ // 18 color-role pairs + destructive-foreground + radius
102
+ const SHADCN_ROOT_LINE_COUNT = 20