@sorb/seed 0.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sorb/seed",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Storybook→Figma capture for Sorb, the design-token bridge for your running app. (Seed.)",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -35,7 +35,7 @@
35
35
  "@babel/generator": "^7.29.7",
36
36
  "@babel/parser": "^7.29.7",
37
37
  "@babel/traverse": "^7.29.7",
38
- "@sorb/core": "^0.2.0",
38
+ "@sorb/core": "^0.3.0",
39
39
  "esbuild": "^0.21.0"
40
40
  },
41
41
  "peerDependencies": {
@@ -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
+ })
@@ -0,0 +1,159 @@
1
+ // Sorb Angular Material 20 M3 system-variable override format
2
+ // (`sorb/mat-sys-vars`).
3
+ //
4
+ // PROMOTED from `sorb-demo-angular/sd.config.js:96-113` (the inline
5
+ // `SORB_MAT_SYS_VARS` format + `MAT_SYS_MAP`) — framework-targets-
6
+ // productization T5, the fifth of the six JJ-demo formats to graduate into
7
+ // `@sorb/seed`. The demo file stays in place for now (T8 retrofit is
8
+ // founder-gated); this is the canonical, generalized home.
9
+ //
10
+ // GENERALIZATION (T0 semantic-role contract): the demo hardcoded Janes
11
+ // Jeans' own token ids (`color.brand`, `radius.control`, …) as the map's
12
+ // right-hand column. Promotion makes that column ROLE-RESOLVED —
13
+ // `options.roleMap` (role id → kit-token-id overrides) lets a non-JJ kit
14
+ // reuse this format unmodified instead of forking it. Resolution is the
15
+ // same shape as `@sorb/core`'s `resolveRole` (`sorb-core/src/index.js`
16
+ // `DEFAULT_ROLE_IDS`/`resolveRole`), inlined here rather than imported so
17
+ // this format degrades gracefully against an older published `@sorb/core`
18
+ // that predates the role contract (same feature-detect posture as the
19
+ // TargetAdapter registration side — see
20
+ // `sorb-leaf/src/targets/angularMaterial.js`).
21
+ //
22
+ // Not every role id below is one of `@sorb/core`'s canonical
23
+ // `DEFAULT_ROLE_IDS` — `color.white` (the on-error/on-error-container rows)
24
+ // is a kit-private primitive, carried over from the demo's original literal
25
+ // `var(--color-white)` mapping. All ids go through the same `roleMap`
26
+ // resolution for consistency and so a kit without that primitive can
27
+ // override it.
28
+ //
29
+ // The `--mat-sys-*` left-hand column is Angular Material's own documented
30
+ // M3 system-variable surface (emitted by the `mat.theme()` mixin) —
31
+ // universal, and stays baked (not role-resolved). The map is left keyed by
32
+ // `--mat-sys-*` var (not role id, unlike `sorb/mantine-vars`) because several
33
+ // distinct Material system slots legitimately resolve to the same kit role
34
+ // (e.g. `--mat-sys-surface` and `--mat-sys-surface-bright` both track
35
+ // `color.surface`) — duplicate values are fine, duplicate object keys are
36
+ // not, so keying by the unique `--mat-sys-*` name preserves the full
37
+ // ~40-row demo mapping verbatim.
38
+ //
39
+ // PRECEDENCE FINDINGS (carried verbatim from the demo's header — this is the
40
+ // load-bearing reason every declaration below is `!important`, so don't drop
41
+ // it in a future edit): Angular Material's `mat.theme()` mixin emits its own
42
+ // `html { ... }` rule at a point in the stylesheet whose order relative to
43
+ // this override is not guaranteed across HMR/rebuild ordering. CSS's "later
44
+ // wins on equal specificity" rule alone won't reliably beat a component
45
+ // library's own injected styles — the same P2/Mantine specificity lesson
46
+ // travels here. `!important` on every declaration is the fix, not
47
+ // decoration.
48
+ //
49
+ // Zero-dep, zero-JS (Mechanism A): a static CSS file of `var()` refs,
50
+ // reacting to a live bridge push the same way `variables.css` already does.
51
+
52
+ export const SORB_MAT_SYS_VARS = 'sorb/mat-sys-vars'
53
+
54
+ /**
55
+ * Angular Material `--mat-sys-*` CSS var name → role id (JJ id when
56
+ * unmapped). The role id is resolved through `options.roleMap` at format
57
+ * time (identity when the kit uses these ids directly — the JJ reference).
58
+ * Semantic-tier-first per the field corrections carried from P2/P4: only
59
+ * the handful of system roots Material's own components actually cascade
60
+ * from — not every `--mat-sys-*` var Material defines (many, e.g.
61
+ * per-component elevation/state-layer opacities, aren't part of the kit's
62
+ * vocabulary and are intentionally left at Material's own defaults).
63
+ */
64
+ export const MAT_SYS_MAP = {
65
+ // Brand / primary action
66
+ '--mat-sys-primary': 'color.brand',
67
+ '--mat-sys-on-primary': 'color.brand-contrast',
68
+ '--mat-sys-primary-container': 'color.brand-hover',
69
+ '--mat-sys-on-primary-container': 'color.brand-contrast',
70
+ '--mat-sys-inverse-primary': 'color.brand-hover',
71
+ // Accent / secondary action
72
+ '--mat-sys-secondary': 'color.accent',
73
+ '--mat-sys-on-secondary': 'color.accent-contrast',
74
+ '--mat-sys-secondary-container': 'color.surface-raised',
75
+ '--mat-sys-on-secondary-container': 'color.ink',
76
+ '--mat-sys-tertiary': 'color.accent',
77
+ '--mat-sys-on-tertiary': 'color.accent-contrast',
78
+ // Surfaces
79
+ '--mat-sys-surface': 'color.surface',
80
+ '--mat-sys-on-surface': 'color.ink',
81
+ '--mat-sys-on-surface-variant': 'color.ink-muted',
82
+ '--mat-sys-surface-container': 'color.surface-raised',
83
+ '--mat-sys-surface-container-low': 'color.surface-raised',
84
+ '--mat-sys-surface-container-high': 'color.surface-sunken',
85
+ '--mat-sys-surface-container-highest': 'color.surface-sunken',
86
+ '--mat-sys-surface-dim': 'color.surface-sunken',
87
+ '--mat-sys-surface-bright': 'color.surface',
88
+ '--mat-sys-inverse-surface': 'color.ink',
89
+ '--mat-sys-inverse-on-surface': 'color.surface',
90
+ '--mat-sys-background': 'color.surface',
91
+ '--mat-sys-on-background': 'color.ink',
92
+ // Structure
93
+ '--mat-sys-outline': 'color.border',
94
+ '--mat-sys-outline-variant': 'color.border-subtle',
95
+ // Status
96
+ '--mat-sys-error': 'color.danger',
97
+ // `color.white` is a kit-private primitive (not a canonical role), carried
98
+ // verbatim from the demo's original literal `var(--color-white)` mapping
99
+ // for byte-parity (T8) — still routed through the same roleMap resolution
100
+ // so a kit without a `color.white` id can override it.
101
+ '--mat-sys-on-error': 'color.white',
102
+ '--mat-sys-error-container': 'color.danger',
103
+ '--mat-sys-on-error-container': 'color.white',
104
+ // Shape (corner radii)
105
+ '--mat-sys-corner-small': 'radius.control',
106
+ '--mat-sys-corner-medium': 'radius.control',
107
+ '--mat-sys-corner-large': 'radius.card',
108
+ '--mat-sys-corner-extra-large': 'radius.card',
109
+ '--mat-sys-corner-full': 'radius.pill',
110
+ }
111
+
112
+ const cssVarOf = (id) => '--' + String(id).split('.').join('-')
113
+
114
+ // Same shape as `@sorb/core`'s `resolveRole` — inlined per the promotion
115
+ // spec so this format has no hard runtime dependency on a core version that
116
+ // ships the role contract (see file header).
117
+ const resolveRoleId = (roleId, roleMap) => (roleMap && roleMap[roleId]) || roleId
118
+
119
+ /**
120
+ * format: sorb/mat-sys-vars
121
+ * Emits a CSS file that redeclares the mapped `--mat-sys-*` vars as
122
+ * `var(--<kit-token>) !important` refs. `options.roleMap` (role id → kit
123
+ * token id) lets a non-JJ kit reuse this format unmodified; defaults to the
124
+ * canonical/JJ ids (identity resolution) when omitted.
125
+ * @param {{dictionary: {allTokens: Array}, options?: {roleMap?: Record<string,string>}}} args
126
+ * @returns {string} the generated CSS.
127
+ */
128
+ export const sorbMatSysVars = ({ dictionary, options }) => {
129
+ const roleMap = options && options.roleMap
130
+ const byId = new Map(dictionary.allTokens.map((t) => [t.path.join('.'), t]))
131
+ const lines = []
132
+ const missing = []
133
+ for (const [matVar, roleId] of Object.entries(MAT_SYS_MAP)) {
134
+ const tokenId = resolveRoleId(roleId, roleMap)
135
+ const t = byId.get(tokenId)
136
+ if (!t) { missing.push(tokenId); continue }
137
+ // !important is load-bearing here — see file header "Precedence findings".
138
+ lines.push(` ${matVar}: var(${cssVarOf(tokenId)}) !important;`)
139
+ }
140
+ if (missing.length) {
141
+ console.warn(` ⚠ sorb/mat-sys-vars: ${missing.length} unmapped token id(s) skipped: ` + missing.join(', '))
142
+ }
143
+ return (
144
+ '/**\n' +
145
+ ' * AUTO-GENERATED by Style Dictionary (sorb/mat-sys-vars) — do not edit.\n' +
146
+ ' *\n' +
147
+ ' * Remaps Angular Material\'s M3 system-variable layer onto the kit\n' +
148
+ ' * vocabulary. Import AFTER the kit\'s own variables.css and AFTER Angular\n' +
149
+ ' * Material\'s own theme styles so this :root block wins the cascade —\n' +
150
+ ' * !important is required, not decorative (Angular Material\'s `mat.theme()`\n' +
151
+ ' * mixin emits its own `html{}` rule whose order relative to this file is\n' +
152
+ ' * not guaranteed across HMR/rebuild ordering; see this format\'s header\n' +
153
+ ' * "Precedence findings").\n' +
154
+ ' */\n' +
155
+ ':root {\n' +
156
+ lines.join('\n') +
157
+ '\n}\n'
158
+ )
159
+ }
@@ -0,0 +1,75 @@
1
+ // Tests for the Sorb Angular Material M3 system-variable override format
2
+ // (`sorb/mat-sys-vars`).
3
+ // Run: node --test (zero-dep, Node's built-in runner — matches the workspace convention).
4
+ import { test } from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import { SORB_MAT_SYS_VARS, MAT_SYS_MAP, sorbMatSysVars } from './sorbMatSys.js'
7
+
8
+ // Helper: a fabricated dictionary of resolved tokens, keyed by dot-path id.
9
+ const tok = (id) => ({ path: id.split('.') })
10
+ const dictOf = (ids) => ({ allTokens: ids.map(tok) })
11
+
12
+ test('format name constant is the documented id', () => {
13
+ assert.equal(SORB_MAT_SYS_VARS, 'sorb/mat-sys-vars')
14
+ })
15
+
16
+ test('emits a :root block with every mapped id present in the dictionary', () => {
17
+ const dictionary = dictOf([...new Set(Object.values(MAT_SYS_MAP))])
18
+ const css = sorbMatSysVars({ dictionary })
19
+ assert.match(css, /^\/\*\*/) // header comment
20
+ assert.match(css, /:root \{/)
21
+ assert.match(css, /\}\s*$/)
22
+ // one declaration per map entry (right-hand roles can repeat, so count
23
+ // declarations, not unique role ids)
24
+ const declCount = (css.match(/!important;/g) || []).length
25
+ assert.equal(declCount, Object.keys(MAT_SYS_MAP).length)
26
+ })
27
+
28
+ test('each declaration maps the correct --mat-sys-* var to var(--kebab-id) and keeps !important', () => {
29
+ const dictionary = dictOf([...new Set(Object.values(MAT_SYS_MAP))])
30
+ const css = sorbMatSysVars({ dictionary })
31
+ assert.match(css, /--mat-sys-primary: var\(--color-brand\) !important;/)
32
+ assert.match(css, /--mat-sys-on-primary: var\(--color-brand-contrast\) !important;/)
33
+ assert.match(css, /--mat-sys-secondary: var\(--color-accent\) !important;/)
34
+ assert.match(css, /--mat-sys-surface: var\(--color-surface\) !important;/)
35
+ assert.match(css, /--mat-sys-on-surface: var\(--color-ink\) !important;/)
36
+ assert.match(css, /--mat-sys-outline: var\(--color-border\) !important;/)
37
+ assert.match(css, /--mat-sys-error: var\(--color-danger\) !important;/)
38
+ assert.match(css, /--mat-sys-on-error: var\(--color-white\) !important;/)
39
+ assert.match(css, /--mat-sys-corner-small: var\(--radius-control\) !important;/)
40
+ assert.match(css, /--mat-sys-corner-large: var\(--radius-card\) !important;/)
41
+ assert.match(css, /--mat-sys-corner-full: var\(--radius-pill\) !important;/)
42
+ })
43
+
44
+ test('every value is a var() reference, never a baked literal (live-preview invariant)', () => {
45
+ const dictionary = dictOf([...new Set(Object.values(MAT_SYS_MAP))])
46
+ const css = sorbMatSysVars({ dictionary })
47
+ for (const line of css.split('\n').filter((l) => l.trim().startsWith('--mat-sys'))) {
48
+ assert.match(line, /: var\(--[a-z0-9-]+\) !important;$/)
49
+ }
50
+ })
51
+
52
+ test('options.roleMap remaps the JJ-token-id column without forking the format', () => {
53
+ // A hypothetical non-JJ kit names its brand/surface roles differently.
54
+ const roleMap = {
55
+ 'color.brand': 'kit.primary',
56
+ 'color.surface': 'kit.bg',
57
+ }
58
+ const dictionary = dictOf(['kit.primary', 'kit.bg', 'color.brand-contrast'])
59
+ const css = sorbMatSysVars({ dictionary, options: { roleMap } })
60
+ assert.match(css, /--mat-sys-primary: var\(--kit-primary\) !important;/)
61
+ assert.match(css, /--mat-sys-surface: var\(--kit-bg\) !important;/)
62
+ // unmapped roles fall back to their canonical/default id
63
+ assert.match(css, /--mat-sys-on-primary: var\(--color-brand-contrast\) !important;/)
64
+ })
65
+
66
+ test('unmapped/missing token ids are skipped, not emitted as broken var() refs', () => {
67
+ const dictionary = dictOf(['color.brand']) // only one of the map's ids present
68
+ const css = sorbMatSysVars({ dictionary })
69
+ assert.match(css, /--mat-sys-primary: var\(--color-brand\) !important;/)
70
+ // color.brand also backs --mat-sys-inverse-primary (both map to color.brand-hover
71
+ // actually) — assert exact count matches how many map entries resolve to
72
+ // 'color.brand' specifically.
73
+ const brandEntries = Object.values(MAT_SYS_MAP).filter((r) => r === 'color.brand').length
74
+ assert.equal((css.match(/!important;/g) || []).length, brandEntries)
75
+ })
@@ -0,0 +1,165 @@
1
+ // Sorb MUI v6 CSS-var override format (`sorb/mui-vars`).
2
+ //
3
+ // PROMOTED from `sorb-demo-mui/sd.config.js:18-86` (P3 spike →
4
+ // framework-targets-productization T3, third 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
+ // WHY THE INDIRECTION LAYER EXISTS: MUI v6 `createTheme({ cssVariables: true })`
9
+ // rejects `var(...)` strings as palette input (it needs real colors to compute
10
+ // contrast/tonal variants — verified via spike, see the demo's README §MUI
11
+ // integration). So MUI still computes its own `--mui-*` custom properties from
12
+ // hardcoded seed values, and this format emits a *second*, later-cascading
13
+ // `:root` block that re-points each covered `--mui-*` var at the matching kit
14
+ // token via `var(--kit-token, <seed-fallback>)`. Any component reading
15
+ // `var(--mui-palette-primary-main)` transitively resolves through to the kit
16
+ // token, and a Sorb bridge push (which sets `--color-*` on `:root`) re-themes
17
+ // it live with zero MUI reinitialization.
18
+ //
19
+ // GENERALIZATION (T0 semantic-role contract): the spike hardcoded two things
20
+ // that promotion must generalize —
21
+ // (a) the JJ-token-id column (left side of the map): now ROLE-RESOLVED via
22
+ // `options.roleMap` (role id → kit token id), same shape as
23
+ // `@sorb/core`'s `resolveRole`/`DEFAULT_ROLE_IDS`, inlined here rather
24
+ // than imported so this format degrades gracefully against an older
25
+ // published `@sorb/core` that predates the role contract (same
26
+ // feature-detect posture as the TargetAdapter registration side — see
27
+ // `sorb-leaf/src/targets/mui.js`).
28
+ // (b) the MUI fallback LITERAL (var()'s 2nd arg, e.g. `#1976d2`): this was
29
+ // the demo's own Janes-Jeans hex values baked directly into the format
30
+ // function. Promotion REQUIRES callers to supply these via
31
+ // `options.seedValues` (role id → literal) — a public `@sorb/seed`
32
+ // format must never ship one kit's brand hexes as its defaults (RISK,
33
+ // framework-targets-productization.md risk table: "MUI fallback
34
+ // literals leak kit values into public seed"). A role with no
35
+ // `seedValues` entry emits `var(--kit-token)` with NO fallback — MUI may
36
+ // then fail to compute tonal variants for that slot until a real value
37
+ // resolves at runtime (documented, not silently patched over).
38
+ //
39
+ // Not every key below is one of `@sorb/core`'s canonical `DEFAULT_ROLE_IDS`
40
+ // — several (contrast-text roles) are Janes-Jeans-shaped ids with no
41
+ // canonical role counterpart yet (kit-private, per the productization spec's
42
+ // "contract scope = the union of the six maps' role columns, not a kit's
43
+ // full token tree"). They still go through the same `roleMap` resolution for
44
+ // consistency and so a kit that DOES want to override them can.
45
+ //
46
+ // The `--mui-*` right-hand column is MUI's own documented CSS-variables
47
+ // surface (verified against @mui/material 6.5.0's actual `theme.vars`
48
+ // shape) — universal, and stays baked (not role-resolved).
49
+ //
50
+ // COVERAGE BOUNDARY (EVIDENCE framing — verified against @mui/material
51
+ // 6.5.0's actual `theme.vars` shape, carried from the P3 spike header):
52
+ // COVERED — palette.{primary,secondary,error,success}.{main,dark,contrastText}
53
+ // (secondary/error/success contrastText not yet mapped — see
54
+ // role gaps in the T3 report), palette.background.{default,paper},
55
+ // palette.text.{primary,secondary}, palette.divider,
56
+ // shape.borderRadius.
57
+ // NOT COVERED — typography (fontFamily/fontSize/fontWeight per variant — MUI
58
+ // v6 does not expose these as CSS vars by default), spacing
59
+ // function, transitions, zIndex, breakpoints. These stay
60
+ // JS-only theme values; a live preview cannot re-theme them.
61
+ //
62
+ // PRECEDENCE: !important, not just document-order — MUI/emotion injects its
63
+ // cssVariables stylesheet at RUNTIME (on ThemeProvider mount), so there is no
64
+ // guaranteed head-order relative to this override layer (a sibling pod, P2
65
+ // Mantine, hit exactly this as a silent no-op with a bare `:root` rule).
66
+ // `!important` makes the win unconditional regardless of insertion order.
67
+
68
+ export const SORB_MUI_VARS = 'sorb/mui-vars'
69
+
70
+ /**
71
+ * role id (JJ id when unmapped) → MUI CSS var name.
72
+ * Left side is resolved through `options.roleMap` at format time (identity
73
+ * when the kit uses these ids directly — the JJ reference); right side is
74
+ * the MUI v6 documented `cssVariables:true` var it overrides.
75
+ */
76
+ export const MUI_VAR_MAP = {
77
+ 'color.brand': '--mui-palette-primary-main',
78
+ 'color.brand-hover': '--mui-palette-primary-dark',
79
+ 'color.brand-contrast': '--mui-palette-primary-contrastText',
80
+ 'color.accent': '--mui-palette-secondary-main',
81
+ 'color.accent-hover': '--mui-palette-secondary-dark',
82
+ 'color.accent-contrast': '--mui-palette-secondary-contrastText',
83
+ 'color.danger': '--mui-palette-error-main',
84
+ 'color.danger-hover': '--mui-palette-error-dark',
85
+ 'color.success': '--mui-palette-success-main',
86
+ 'color.success-hover': '--mui-palette-success-dark',
87
+ 'color.surface': '--mui-palette-background-default',
88
+ 'color.surface-raised': '--mui-palette-background-paper',
89
+ 'color.ink': '--mui-palette-text-primary',
90
+ 'color.ink-muted': '--mui-palette-text-secondary',
91
+ 'color.border': '--mui-palette-divider',
92
+ 'radius.control': '--mui-shape-borderRadius',
93
+ }
94
+
95
+ const cssVarOf = (id) => '--' + String(id).split('.').join('-')
96
+
97
+ // Same shape as `@sorb/core`'s `resolveRole` — inlined per the promotion
98
+ // spec so this format has no hard runtime dependency on a core version that
99
+ // ships the role contract (see file header).
100
+ const resolveRoleId = (roleId, roleMap) => (roleMap && roleMap[roleId]) || roleId
101
+
102
+ /**
103
+ * format: sorb/mui-vars
104
+ * Emits a CSS file that redeclares the mapped `--mui-*` vars as
105
+ * `var(--<kit-token>, <seed-fallback>) !important` refs. `options.roleMap`
106
+ * (role id → kit token id) lets a non-JJ kit reuse this format unmodified;
107
+ * defaults to the canonical/JJ ids (identity resolution) when omitted.
108
+ * `options.seedValues` (role id → literal fallback) is REQUIRED to avoid
109
+ * baking any one kit's hex values into the public format — a role with no
110
+ * `seedValues` entry emits `var(--kit-token)` with no fallback.
111
+ * @param {{dictionary: {allTokens: Array}, options?: {roleMap?: Record<string,string>, seedValues?: Record<string,string>}}} args
112
+ * @returns {string} the generated CSS.
113
+ */
114
+ export const sorbMuiVars = ({ dictionary, options }) => {
115
+ const roleMap = options && options.roleMap
116
+ const seedValues = (options && options.seedValues) || {}
117
+ if (!options || !options.seedValues) {
118
+ // eslint-disable-next-line no-console
119
+ console.warn(
120
+ ' ⚠ sorb/mui-vars: options.seedValues not supplied — every declaration will emit ' +
121
+ 'var(--kit-token) with no fallback; MUI may not compute tonal variants until a real ' +
122
+ 'value resolves at runtime.'
123
+ )
124
+ }
125
+ const byId = new Map(dictionary.allTokens.map((t) => [t.path.join('.'), t]))
126
+ const lines = []
127
+ const missing = []
128
+ const noFallback = []
129
+ for (const [roleId, muiVar] of Object.entries(MUI_VAR_MAP)) {
130
+ const tokenId = resolveRoleId(roleId, roleMap)
131
+ const t = byId.get(tokenId)
132
+ if (!t) { missing.push(tokenId); continue }
133
+ const seed = seedValues[roleId]
134
+ const ref = seed != null ? `var(${cssVarOf(tokenId)}, ${seed})` : `var(${cssVarOf(tokenId)})`
135
+ if (seed == null) noFallback.push(roleId)
136
+ // !important is load-bearing here — see file header "Precedence".
137
+ lines.push(` ${muiVar}: ${ref} !important;`)
138
+ }
139
+ if (missing.length) {
140
+ console.warn(` ⚠ sorb/mui-vars: ${missing.length} unmapped token id(s) skipped: ` + missing.join(', '))
141
+ }
142
+ if (noFallback.length) {
143
+ console.warn(
144
+ ` ⚠ sorb/mui-vars: ${noFallback.length} role(s) with no seedValues fallback (var() with ` +
145
+ 'no 2nd arg): ' + noFallback.join(', ')
146
+ )
147
+ }
148
+ return (
149
+ '/* AUTOGENERATED by Style Dictionary (sorb/mui-vars) — do not edit.\n' +
150
+ ' Maps MUI cssVariables:true output vars -> kit token vars, so a Sorb bridge push\n' +
151
+ ' against --color-*, --radius-*, etc. cascades through to MUI. Load AFTER MUI\'s\n' +
152
+ ' own theme stylesheet (see the consuming app\'s entry import order).\n' +
153
+ ' !important is load-bearing, not decorative — see this format\'s header "Precedence".\n' +
154
+ '\n' +
155
+ ' COVERAGE (verified against @mui/material 6.5.0\'s actual theme.vars shape):\n' +
156
+ ' COVERED — palette.{primary,secondary,error,success}.{main,dark,contrastText},\n' +
157
+ ' palette.background.{default,paper}, palette.text.{primary,secondary},\n' +
158
+ ' palette.divider, shape.borderRadius.\n' +
159
+ ' NOT COVERED — typography, spacing function, transitions, zIndex, breakpoints\n' +
160
+ ' (JS-only theme values; a live preview cannot re-theme them). */\n' +
161
+ ':root, [data-mui-color-scheme] {\n' +
162
+ lines.join('\n') +
163
+ '\n}\n'
164
+ )
165
+ }