@sorb/seed 0.2.0 → 0.3.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.
@@ -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
+ })
package/src/index.js CHANGED
@@ -5,3 +5,30 @@
5
5
  // so the old `resolveBindableTokens` export is gone. What remains useful as a
6
6
  // library is the token-annotation layer used by `capture`.
7
7
  export { buildTokenIndex, annotateTree, matchColor, matchDimension } from './annotateTokens.js'
8
+
9
+ // Sorb's custom Style Dictionary outputs (component-compat-roadmap P0, part
10
+ // 2) — promoted from sorb-demo's copy-local `sd/sorb-format.js` so target
11
+ // adapters import these formats instead of duplicating them. A consumer's
12
+ // `sd.config.js` registers these with `StyleDictionary.registerFormat`/
13
+ // `registerParser` the same way sorb-demo's does.
14
+ export {
15
+ tierOfFile,
16
+ SORB_RESOLVED,
17
+ SORB_THEME_NESTED,
18
+ SORB_ALIASES,
19
+ SORB_VERSIONS,
20
+ SORB_SET_META,
21
+ SORB_TAILWIND,
22
+ SORB_TAILWIND_V3,
23
+ SORB_TOKENSET,
24
+ sorbSetMeta,
25
+ sorbVersions,
26
+ sorbTokenSet,
27
+ sorbResolved,
28
+ sorbAliases,
29
+ sorbThemeNested,
30
+ tailwindThemeEntry,
31
+ sorbTailwind,
32
+ tailwindV3Slot,
33
+ sorbTailwindV3,
34
+ } from './emit/sorbFormat.js'
@@ -0,0 +1,83 @@
1
+ // `figma-plugin` SourceConnector — the DEFAULT Figma tokens source (all
2
+ // tiers; no Enterprise gate). spec/sorb/figma-source-connector.md §1a.
3
+ //
4
+ // Unlike `storybook-dom` (a local Playwright/Node process this package drives
5
+ // directly), this connector's data does NOT come from a locally-callable API.
6
+ // It comes from the Figma plugin (`sorb-canopy`), which reads
7
+ // `figma.variables.*` **in-file** (via the shared token-mapping walk —
8
+ // `sorb-canopy/lib/token-mapping.js`), maps it to Sorb's resolved-map shape,
9
+ // and PUSHES it to the juice bridge (`POST /tokens/figma`, the plugin's
10
+ // "Export variables" action). So this connector is a BRIDGE CLIENT, not a
11
+ // Figma caller — spec's flagged riskiest coupling ("the plugin is the
12
+ // executor, the seed-side connector is a bridge client"). `readTokens()`
13
+ // reads the latest pushed artifact back via `GET /tokens/figma`.
14
+ //
15
+ // v1 scope is tokens-first (figma-source-connector.md §1a Fork F /
16
+ // §R "Cut"): `listUnits`/`captureGeometry` need a geometry-capable bridge
17
+ // endpoint the plugin doesn't push yet (component/node geometry, not just
18
+ // variables) — that's a deferred follow-on phase, so they throw a clear,
19
+ // actionable error instead of pretending to call Figma directly.
20
+ //
21
+ // No local token-mapping fork: this connector does no Figma-value mapping of
22
+ // its own — the resolved-map entries it reads back already match
23
+ // `@sorb/core`'s `ResolvedToken` shape because `sorb-canopy/lib/token-
24
+ // mapping.js` produced them before the push. There is nothing to share/
25
+ // extract on the seed side (the mapping stays owned by canopy, the one place
26
+ // that actually touches `figma.variables.*`).
27
+
28
+ import { registerSource } from '@sorb/core'
29
+
30
+ // sorb.config.json may set seed.bridgeOrigin (mirrors storybookUrlOf's
31
+ // seed.storybookUrl convention). Falls back to juice's default dev port.
32
+ export const bridgeOriginOf = (config) =>
33
+ ((config && config.seed && config.seed.bridgeOrigin) || (config && config.bridgeOrigin) || 'http://localhost:7777').replace(
34
+ /\/$/,
35
+ '',
36
+ )
37
+
38
+ const readTokens = async (config) => {
39
+ const origin = bridgeOriginOf(config)
40
+ const url = `${origin}/tokens/figma`
41
+ let res
42
+ try {
43
+ res = await fetch(url)
44
+ } catch (e) {
45
+ throw new Error(
46
+ `figma-plugin source: could not reach the bridge at ${origin} (${e.message}). ` +
47
+ 'Run `sorb dev` first.',
48
+ )
49
+ }
50
+ if (res.status === 404) {
51
+ throw new Error(
52
+ 'figma-plugin source: no Figma export yet — in the Sorb Figma plugin, run ' +
53
+ `"Export variables" (bridge: ${origin}).`,
54
+ )
55
+ }
56
+ if (!res.ok) {
57
+ throw new Error(`figma-plugin source: bridge GET /tokens/figma failed — HTTP ${res.status}`)
58
+ }
59
+ const artifact = await res.json()
60
+ return Array.isArray(artifact.tokens) ? artifact.tokens : []
61
+ }
62
+
63
+ const GEOMETRY_NOT_YET_IMPLEMENTED =
64
+ 'figma-plugin source: listUnits/captureGeometry are not implemented in v1 (tokens-first — ' +
65
+ 'see spec/sorb/figma-source-connector.md §1a Fork F). Use readTokens() for the token half; ' +
66
+ 'geometry capture from Figma is a deferred follow-on phase.'
67
+
68
+ const listUnits = async () => {
69
+ throw new Error(GEOMETRY_NOT_YET_IMPLEMENTED)
70
+ }
71
+
72
+ const captureGeometry = async () => {
73
+ throw new Error(GEOMETRY_NOT_YET_IMPLEMENTED)
74
+ }
75
+
76
+ export const figmaPluginConnector = {
77
+ id: 'figma-plugin',
78
+ listUnits,
79
+ captureGeometry,
80
+ readTokens,
81
+ }
82
+
83
+ registerSource(figmaPluginConnector)
@@ -0,0 +1,65 @@
1
+ // Tests for the `figma-plugin` SourceConnector (figma-source-connector.md
2
+ // §1a). Mirrors the contract-registration style of sorb-core's
3
+ // connectors.test.js dummy-source test, plus exercises readTokens() as a
4
+ // bridge CLIENT against a mocked `fetch` (no real juice server needed —
5
+ // the contract is "GET /tokens/figma returns { tokens: ResolvedToken[] }").
6
+ import test from 'node:test'
7
+ import assert from 'node:assert/strict'
8
+ import { connectors, getSource } from '@sorb/core'
9
+ import { figmaPluginConnector, bridgeOriginOf } from './figmaPlugin.js'
10
+
11
+ test('registers under id "figma-plugin" and round-trips via getSource (contract dispatch)', () => {
12
+ assert.equal(getSource('figma-plugin'), figmaPluginConnector)
13
+ assert.equal(connectors.source.get('figma-plugin'), figmaPluginConnector)
14
+ })
15
+
16
+ test('bridgeOriginOf: defaults to the juice dev port, honors seed.bridgeOrigin, strips trailing slash', () => {
17
+ assert.equal(bridgeOriginOf({}), 'http://localhost:7777')
18
+ assert.equal(bridgeOriginOf(), 'http://localhost:7777')
19
+ assert.equal(bridgeOriginOf({ seed: { bridgeOrigin: 'http://localhost:9999/' } }), 'http://localhost:9999')
20
+ assert.equal(bridgeOriginOf({ bridgeOrigin: 'http://example.com/' }), 'http://example.com')
21
+ })
22
+
23
+ test('readTokens: reads the resolved-map array back from GET /tokens/figma', async (t) => {
24
+ const tokens = [{ id: 'color.bg.primary', cssVar: '--color-bg-primary', value: '#ffffff', tier: 'primitive', type: 'color' }]
25
+ const calls = []
26
+ const realFetch = global.fetch
27
+ global.fetch = async (url) => {
28
+ calls.push(url)
29
+ return { ok: true, status: 200, json: async () => ({ fileKey: 'abc', exportedAt: '2026-08-29T00:00:00Z', tokens }) }
30
+ }
31
+ t.after(() => { global.fetch = realFetch })
32
+
33
+ const got = await figmaPluginConnector.readTokens({ seed: { bridgeOrigin: 'http://localhost:7777' } })
34
+ assert.deepEqual(got, tokens)
35
+ assert.equal(calls[0], 'http://localhost:7777/tokens/figma')
36
+ })
37
+
38
+ test('readTokens: 404 (no export yet) throws an actionable "Export variables" error', async (t) => {
39
+ const realFetch = global.fetch
40
+ global.fetch = async () => ({ ok: false, status: 404, json: async () => ({ error: 'No Figma export yet.' }) })
41
+ t.after(() => { global.fetch = realFetch })
42
+
43
+ await assert.rejects(() => figmaPluginConnector.readTokens({}), /Export variables/)
44
+ })
45
+
46
+ test('readTokens: non-2xx, non-404 → surfaces the HTTP status', async (t) => {
47
+ const realFetch = global.fetch
48
+ global.fetch = async () => ({ ok: false, status: 500, json: async () => ({}) })
49
+ t.after(() => { global.fetch = realFetch })
50
+
51
+ await assert.rejects(() => figmaPluginConnector.readTokens({}), /HTTP 500/)
52
+ })
53
+
54
+ test('readTokens: network failure (bridge not running) → actionable error naming the origin', async (t) => {
55
+ const realFetch = global.fetch
56
+ global.fetch = async () => { throw new Error('ECONNREFUSED') }
57
+ t.after(() => { global.fetch = realFetch })
58
+
59
+ await assert.rejects(() => figmaPluginConnector.readTokens({}), /could not reach the bridge at http:\/\/localhost:7777/)
60
+ })
61
+
62
+ test('listUnits/captureGeometry: v1 tokens-first scope — throw a clear not-yet-implemented error, not a silent no-op', async () => {
63
+ await assert.rejects(() => figmaPluginConnector.listUnits({}), /not implemented in v1/)
64
+ await assert.rejects(() => figmaPluginConnector.captureGeometry({ id: 'u1' }, {}), /not implemented in v1/)
65
+ })