@sparknest/theme-contract-vue 0.0.0-stage → 1.1.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,34 @@
1
1
  {
2
2
  "name": "@sparknest/theme-contract-vue",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.1.0",
4
+ "description": "Shared Cedar and Maple theme token contract",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./src/index.mjs",
9
+ "./checker": "./src/checker.mjs",
10
+ "./contract": "./contracts/theme-contract.json",
11
+ "./legacy-denylist": "./contracts/legacy-denylist.json"
12
+ },
13
+ "bin": {
14
+ "ui-theme-contract": "./bin/theme-contract.mjs"
15
+ },
16
+ "files": [
17
+ "bin",
18
+ "contracts",
19
+ "schema",
20
+ "src",
21
+ "README.md"
22
+ ],
23
+ "engines": {
24
+ "node": ">=20"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public",
28
+ "registry": "https://registry.npmjs.org/"
29
+ },
30
+ "scripts": {
31
+ "check": "node bin/theme-contract.mjs check",
32
+ "sync": "node bin/theme-contract.mjs sync"
33
+ }
6
34
  }
@@ -0,0 +1,105 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "UI Foundation theme contract",
4
+ "type": "object",
5
+ "required": ["version", "prefixes", "rules", "systemTokenValues", "componentTokenValues", "components", "tokens", "platformExtensions"],
6
+ "properties": {
7
+ "version": { "type": "integer", "minimum": 1 },
8
+ "prefixes": {
9
+ "type": "object",
10
+ "required": ["cedar", "maple"],
11
+ "properties": {
12
+ "cedar": { "const": "c" },
13
+ "maple": { "const": "mp" }
14
+ },
15
+ "additionalProperties": false
16
+ },
17
+ "rules": { "type": "object" },
18
+ "systemTokenValues": {
19
+ "type": "object",
20
+ "propertyNames": { "pattern": "^[a-z][a-z0-9-]*$" },
21
+ "additionalProperties": { "type": "string", "minLength": 1 }
22
+ },
23
+ "componentTokenValues": {
24
+ "type": "object",
25
+ "propertyNames": { "pattern": "^[a-z][a-z0-9-]*$" },
26
+ "additionalProperties": {
27
+ "type": "object",
28
+ "propertyNames": { "pattern": "^[a-z][a-z0-9-]*$" },
29
+ "additionalProperties": { "type": "string", "minLength": 1 }
30
+ }
31
+ },
32
+ "platformSystemTokenValues": {
33
+ "type": "object",
34
+ "properties": {
35
+ "cedar": { "$ref": "#/$defs/tokenValues" },
36
+ "maple": { "$ref": "#/$defs/tokenValues" }
37
+ },
38
+ "additionalProperties": false
39
+ },
40
+ "components": {
41
+ "type": "array",
42
+ "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
43
+ "uniqueItems": true
44
+ },
45
+ "platformComponentTokenValues": {
46
+ "type": "object",
47
+ "properties": {
48
+ "cedar": { "$ref": "#/$defs/componentValues" },
49
+ "maple": { "$ref": "#/$defs/componentValues" }
50
+ },
51
+ "additionalProperties": false
52
+ },
53
+ "tokens": {
54
+ "type": "object",
55
+ "required": ["system", "components"],
56
+ "properties": {
57
+ "system": {
58
+ "type": "array",
59
+ "items": { "type": "string" },
60
+ "uniqueItems": true
61
+ },
62
+ "components": {
63
+ "type": "object",
64
+ "additionalProperties": {
65
+ "type": "array",
66
+ "items": { "type": "string" },
67
+ "uniqueItems": true
68
+ }
69
+ }
70
+ },
71
+ "additionalProperties": false
72
+ },
73
+ "platformExtensions": {
74
+ "type": "object",
75
+ "required": ["cedar", "maple"],
76
+ "properties": {
77
+ "cedar": { "$ref": "#/$defs/componentNames" },
78
+ "maple": { "$ref": "#/$defs/componentNames" }
79
+ },
80
+ "additionalProperties": false
81
+ }
82
+ },
83
+ "additionalProperties": false,
84
+ "$defs": {
85
+ "tokenValues": {
86
+ "type": "object",
87
+ "propertyNames": { "pattern": "^[a-z][a-z0-9-]*$" },
88
+ "additionalProperties": { "type": "string", "minLength": 1 }
89
+ },
90
+ "componentValues": {
91
+ "type": "object",
92
+ "propertyNames": { "pattern": "^[a-z][a-z0-9-]*$" },
93
+ "additionalProperties": {
94
+ "type": "object",
95
+ "propertyNames": { "pattern": "^[a-z][a-z0-9-]*$" },
96
+ "additionalProperties": { "type": "string", "minLength": 1 }
97
+ }
98
+ },
99
+ "componentNames": {
100
+ "type": "array",
101
+ "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
102
+ "uniqueItems": true
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,257 @@
1
+ import assert from 'node:assert/strict'
2
+ import { readFileSync, readdirSync } from 'node:fs'
3
+ import { extname, resolve } from 'node:path'
4
+ import contract from '../contracts/theme-contract.json' with { type: 'json' }
5
+ import legacyDenylist from '../contracts/legacy-denylist.json' with { type: 'json' }
6
+ import { normalizeTokenValue } from './normalize-value.mjs'
7
+
8
+ const sourceExtensions = new Set(['.less', '.css', '.scss', '.vue', '.ts', '.tsx', '.md'])
9
+ const ignoredDirectories = new Set(['node_modules', 'dist', 'coverage'])
10
+
11
+ function walk(directory) {
12
+ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
13
+ const path = resolve(directory, entry.name)
14
+ if (entry.isDirectory()) return ignoredDirectories.has(entry.name) ? [] : walk(path)
15
+ return sourceExtensions.has(extname(entry.name)) ? [path] : []
16
+ })
17
+ }
18
+
19
+ function definitions(source, prefix) {
20
+ const pattern = new RegExp(`(--${prefix}-[a-z0-9-]+)\\s*:`, 'g')
21
+ return new Set([...source.matchAll(pattern)].map((match) => match[1]))
22
+ }
23
+
24
+ function customPropertyValues(source, prefix) {
25
+ const pattern = new RegExp(`(--${prefix}-[a-z0-9-]+)\\s*:\\s*([^;]+);`, 'g')
26
+ return new Map([...source.matchAll(pattern)].map((match) => [match[1], match[2].replace(/\\s+/g, ' ').trim()]))
27
+ }
28
+
29
+ function systemReferences(source, prefix) {
30
+ const pattern = new RegExp(`var\\(\\s*(--${prefix}-sys-[a-z0-9-]+)`, 'g')
31
+ return new Set([...source.matchAll(pattern)].map((match) => match[1]))
32
+ }
33
+
34
+ function allDefinitions(source, prefix) {
35
+ const names = new Set()
36
+ for (const file of walk(source)) {
37
+ for (const name of definitions(readFileSync(file, 'utf8'), prefix)) names.add(name)
38
+ }
39
+ return names
40
+ }
41
+
42
+ function checkThemeOverrides(name, source, prefix) {
43
+ const defined = allDefinitions(source, prefix)
44
+ const violations = []
45
+ const themesRoot = resolve(source, 'themes')
46
+ for (const file of walk(themesRoot)) {
47
+ if (/\/(?:__tests__|__demos__|example|docs)\//.test(file)) continue
48
+ const text = readFileSync(file, 'utf8')
49
+ const keys = [...text.matchAll(new RegExp(`['\"](--${prefix}-[a-z0-9-]+)['\"]\\s*:`, 'g'))].map(
50
+ (match) => match[1],
51
+ )
52
+ for (const key of keys) {
53
+ if (key.startsWith(`--${prefix}-internal-`)) violations.push(`${key}: internal token in ${file}`)
54
+ else if (!defined.has(key)) violations.push(`${key}: undefined theme override in ${file}`)
55
+ }
56
+ }
57
+ assert.deepEqual(violations, [], `${name}: invalid theme overrides`)
58
+ }
59
+
60
+ function checkLegacyTokens(name, source, prefix, denied) {
61
+ const violations = []
62
+ const ignored = /\/(?:docs|example|__demos__|__tests__)\//
63
+ for (const file of walk(source)) {
64
+ if (ignored.test(file)) continue
65
+ const text = readFileSync(file, 'utf8')
66
+ for (const token of denied) {
67
+ const fullName = `--${prefix}-${token}`
68
+ const pattern = new RegExp(`${fullName}(?![a-z0-9-])`)
69
+ if (pattern.test(text)) violations.push(`${fullName} in ${file}`)
70
+ }
71
+ }
72
+ assert.deepEqual(violations, [], `${name}: legacy tokens are forbidden`)
73
+ }
74
+
75
+ function checkComponentDocs(name, source, prefix) {
76
+ const violations = []
77
+ const platform = prefix === contract.prefixes.cedar ? 'cedar' : 'maple'
78
+ for (const component of contract.components) {
79
+ const namespace = component === 'field-decorator' ? 'field' : component
80
+ const allowed = new Set(contract.tokens.components[component])
81
+ if (contract.platformExtensions[platform]?.includes(component)) {
82
+ for (const token of logicalComponentTokens({ prefix, source }, component)) allowed.add(token)
83
+ }
84
+ const docsRoot = resolve(source, component, 'docs')
85
+ let files = []
86
+ try {
87
+ files = walk(docsRoot)
88
+ } catch {
89
+ continue
90
+ }
91
+ for (const file of files.filter((path) => path.endsWith('.md'))) {
92
+ const text = readFileSync(file, 'utf8')
93
+ const pattern = new RegExp(
94
+ `--(?:${prefix}-)?${namespace}-([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?![a-z0-9-])`,
95
+ 'g',
96
+ )
97
+ for (const match of text.matchAll(pattern)) {
98
+ if (!allowed.has(match[1])) violations.push(`${match[0]} in ${file}`)
99
+ }
100
+ }
101
+ }
102
+ assert.deepEqual(violations, [], `${name}: component docs contain tokens outside the contract`)
103
+ }
104
+
105
+ function logicalSystemTokens(library) {
106
+ const source = readFileSync(resolve(library.source, 'style/theme/system.less'), 'utf8')
107
+ return [...definitions(source, library.prefix)]
108
+ .filter((name) => name.startsWith(`--${library.prefix}-sys-`))
109
+ .map((name) => name.slice(`--${library.prefix}-sys-`.length))
110
+ .sort()
111
+ }
112
+
113
+ function logicalSystemTokenValues(library, expected = contract.systemTokenValues) {
114
+ const source = readFileSync(resolve(library.source, 'style/theme/system.less'), 'utf8')
115
+ const values = customPropertyValues(source, library.prefix)
116
+ return Object.fromEntries(
117
+ Object.keys(expected).map((token) => {
118
+ const name = `--${library.prefix}-sys-${token}`
119
+ assert.ok(values.has(name), `${library.name}: ${name} has no value`)
120
+ return [token, normalizeTokenValue(values.get(name), library.prefix)]
121
+ }),
122
+ )
123
+ }
124
+
125
+ function logicalComponentTokenValues(library, component, expected = contract.componentTokenValues[component]) {
126
+ const namespace = component === 'field-decorator' ? 'field' : component
127
+ // Govern the public :root defaults, not theme-specific overrides later in token.less.
128
+ const source = readFileSync(resolve(library.source, component, 'style/token.less'), 'utf8')
129
+ // Naming examples in comments may contain braces (e.g. {variant}); they are not CSS blocks.
130
+ // Preserve quoted strings so URLs and string-valued defaults are not treated as comments.
131
+ const uncommented = source.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\/\*[\s\S]*?\*\/|\/\/[^\n]*/g,
132
+ (match) => match.startsWith('//') || match.startsWith('/*') ? '' : match)
133
+ const defaults = [...uncommented.matchAll(/(?:^|\n)\s*:root\s*\{([^}]*)\}/g)].map((match) => match[1]).join('\n')
134
+ const values = customPropertyValues(defaults, library.prefix)
135
+ return Object.fromEntries(
136
+ Object.keys(expected).map((token) => {
137
+ const name = `--${library.prefix}-${namespace}-${token}`
138
+ assert.ok(values.has(name), `${library.name}: ${name} has no value`)
139
+ return [token, normalizeTokenValue(values.get(name), library.prefix)]
140
+ }),
141
+ )
142
+ }
143
+
144
+ function logicalComponentTokens(library, component) {
145
+ const namespace = component === 'field-decorator' ? 'field' : component
146
+ const tokenPrefix = `--${library.prefix}-${namespace}-`
147
+ const names = new Set()
148
+ for (const file of walk(resolve(library.source, component))) {
149
+ if (/\/(?:docs|example|__demos__|__tests__)\//.test(file)) continue
150
+ for (const name of definitions(readFileSync(file, 'utf8'), library.prefix)) {
151
+ if (name.startsWith(tokenPrefix)) names.add(name.slice(tokenPrefix.length))
152
+ }
153
+ }
154
+ return [...names].sort()
155
+ }
156
+
157
+ export function collectThemeContract({ cedarSource, mapleSource }) {
158
+ const libraries = [
159
+ { name: 'Cedar', prefix: contract.prefixes.cedar, source: cedarSource },
160
+ { name: 'Maple', prefix: contract.prefixes.maple, source: mapleSource },
161
+ ]
162
+ const [cedar, maple] = libraries
163
+ const cedarSystem = logicalSystemTokens(cedar)
164
+ const mapleSystem = logicalSystemTokens(maple)
165
+ assert.deepEqual(cedarSystem, mapleSystem, 'Cedar and Maple system token names differ')
166
+
167
+ const components = {}
168
+ for (const component of contract.components) {
169
+ const cedarTokens = logicalComponentTokens(cedar, component)
170
+ const mapleTokens = logicalComponentTokens(maple, component)
171
+ const sharedTokens = cedarTokens.filter((token) => mapleTokens.includes(token))
172
+ components[component] = sharedTokens
173
+ }
174
+ return { system: cedarSystem, components }
175
+ }
176
+
177
+ export function checkThemeContract(options) {
178
+ const actual = collectThemeContract(options)
179
+ assert.deepEqual(actual.system, contract.tokens.system, 'System tokens differ from @sparknest/theme-contract-vue')
180
+ assert.deepEqual(actual.components, contract.tokens.components, 'Component tokens differ from @sparknest/theme-contract-vue')
181
+
182
+ for (const library of [
183
+ { name: 'Cedar', key: 'cedar', prefix: contract.prefixes.cedar, source: options.cedarSource },
184
+ { name: 'Maple', key: 'maple', prefix: contract.prefixes.maple, source: options.mapleSource },
185
+ ]) {
186
+ assert.deepEqual(
187
+ logicalSystemTokenValues(library),
188
+ contract.systemTokenValues,
189
+ `${library.name}: system token values differ from @sparknest/theme-contract-vue`,
190
+ )
191
+ const platformSystem = contract.platformSystemTokenValues?.[library.key] ?? {}
192
+ for (const token of Object.keys(platformSystem)) assert.ok(!(token in contract.systemTokenValues), `${library.name}: duplicate system default ${token}`)
193
+ assert.deepEqual(logicalSystemTokenValues(library, platformSystem), platformSystem, `${library.name}: platform system defaults differ`)
194
+ assert.deepEqual([...Object.keys(contract.systemTokenValues), ...Object.keys(platformSystem)].sort(), actual.system, `${library.name}: every system default must be governed`)
195
+ for (const [component, expected] of Object.entries(contract.componentTokenValues)) {
196
+ assert.deepEqual(
197
+ logicalComponentTokenValues(library, component),
198
+ expected,
199
+ `${library.name} ${component}: component token values differ from @sparknest/theme-contract-vue`,
200
+ )
201
+ }
202
+ for (const [component, expected] of Object.entries(contract.platformComponentTokenValues?.[library.key] ?? {})) {
203
+ for (const token of Object.keys(expected)) {
204
+ assert.ok(!(token in (contract.componentTokenValues[component] ?? {})), `${library.name} ${component}/${token}: duplicate shared and platform default`)
205
+ }
206
+ assert.deepEqual(
207
+ logicalComponentTokenValues(library, component, expected),
208
+ expected,
209
+ `${library.name} ${component}: registered platform defaults differ`,
210
+ )
211
+ }
212
+ for (const component of contract.components) {
213
+ const common = contract.tokens.components[component] ?? []
214
+ const current = logicalComponentTokens(library, component)
215
+ const missing = common.filter((token) => !current.includes(token))
216
+ assert.deepEqual(missing, [], `${library.name} ${component} misses common component tokens`)
217
+ if (!contract.platformExtensions[library.key]?.includes(component)) {
218
+ assert.deepEqual(current, common, `${library.name} ${component} has undeclared platform token extensions`)
219
+ }
220
+ }
221
+ }
222
+
223
+ for (const [name, prefix, source] of [
224
+ ['Cedar', contract.prefixes.cedar, options.cedarSource],
225
+ ['Maple', contract.prefixes.maple, options.mapleSource],
226
+ ]) {
227
+ const system = new Set(actual.system.map((token) => `--${prefix}-sys-${token}`))
228
+ const unresolved = []
229
+ for (const file of walk(source)) {
230
+ for (const reference of systemReferences(readFileSync(file, 'utf8'), prefix)) {
231
+ if (!system.has(reference)) unresolved.push(`${reference} in ${file}`)
232
+ }
233
+ }
234
+ assert.deepEqual(unresolved, [], `${name}: unresolved system token references`)
235
+ }
236
+
237
+ checkThemeOverrides('Cedar', options.cedarSource, contract.prefixes.cedar)
238
+ checkThemeOverrides('Maple', options.mapleSource, contract.prefixes.maple)
239
+ checkLegacyTokens('Cedar', options.cedarSource, contract.prefixes.cedar, legacyDenylist.cedar)
240
+ checkLegacyTokens('Maple', options.mapleSource, contract.prefixes.maple, legacyDenylist.maple)
241
+ checkComponentDocs('Cedar', options.cedarSource, contract.prefixes.cedar)
242
+ checkComponentDocs('Maple', options.mapleSource, contract.prefixes.maple)
243
+
244
+ return {
245
+ systemCount: actual.system.length,
246
+ systemValueCount: Object.keys(contract.systemTokenValues).length,
247
+ platformSystemValueCount: Object.values(contract.platformSystemTokenValues ?? {}).reduce((count, values) => count + Object.keys(values).length, 0),
248
+ componentValueCount: Object.values(contract.componentTokenValues).reduce(
249
+ (count, values) => count + Object.keys(values).length,
250
+ 0,
251
+ ),
252
+ componentCount: contract.components.length,
253
+ platformComponentValueCount: Object.values(contract.platformComponentTokenValues ?? {}).reduce(
254
+ (count, components) => count + Object.values(components).reduce((sum, values) => sum + Object.keys(values).length, 0), 0,
255
+ ),
256
+ }
257
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,25 @@
1
+ import contract from '../contracts/theme-contract.json' with { type: 'json' }
2
+
3
+ export const themeContract = Object.freeze(contract)
4
+ export const parityComponents = Object.freeze([...contract.components])
5
+ export const contractRules = Object.freeze({ ...contract.rules })
6
+ export const systemTokens = Object.freeze([...contract.tokens.system])
7
+ export const systemTokenValues = Object.freeze({ ...contract.systemTokenValues })
8
+ export const platformSystemTokenValues = Object.freeze(
9
+ Object.fromEntries(Object.entries(contract.platformSystemTokenValues ?? {}).map(([name, values]) => [name, Object.freeze({ ...values })])),
10
+ )
11
+ export const componentTokenValues = Object.freeze(
12
+ Object.fromEntries(
13
+ Object.entries(contract.componentTokenValues).map(([name, values]) => [name, Object.freeze({ ...values })]),
14
+ ),
15
+ )
16
+ export const componentTokens = Object.freeze(
17
+ Object.fromEntries(Object.entries(contract.tokens.components).map(([name, tokens]) => [name, Object.freeze([...tokens])])),
18
+ )
19
+ export const platformExtensions = Object.freeze(contract.platformExtensions)
20
+ export const platformComponentTokenValues = Object.freeze(
21
+ Object.fromEntries(Object.entries(contract.platformComponentTokenValues ?? {}).map(([platform, components]) => [
22
+ platform,
23
+ Object.freeze(Object.fromEntries(Object.entries(components).map(([name, values]) => [name, Object.freeze({ ...values })]))),
24
+ ])),
25
+ )
@@ -0,0 +1,14 @@
1
+ // Normalize syntax only. Preserve semantic var() dependencies and units.
2
+ export function normalizeTokenValue(value, prefix) {
3
+ // Only unwrap a complete Less escaped value. Ordinary quoted CSS strings and
4
+ // partial/mismatched expressions must retain their string semantics.
5
+ const literal = value.trim().replace(/^~(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)')$/, (_, double, single) => double ?? single)
6
+ const names = literal.replaceAll(`--${prefix}-`, '--PREFIX-')
7
+ .replace(/var\(~(['"])(--PREFIX-[a-z0-9-]+)\1\)/g, 'var($2)')
8
+ return names.split(/("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')/g).map((part, index) => index % 2 ? part : part
9
+ .replace(/\s+/g, ' ').replace(/\(\s+/g, '(').replace(/\s+\)/g, ')')
10
+ .replace(/(^|[\s,(])(-?)\.(\d+)/g, '$1$20.$3')
11
+ .replace(/rgb\(\s*(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s*\/\s*([\d.]+)(%)?\s*\)/g,
12
+ (_, red, green, blue, alpha, percent) => `rgba(${red}, ${green}, ${blue}, ${Number(alpha) / (percent ? 100 : 1)})`)
13
+ ).join('')
14
+ }
@@ -0,0 +1,24 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { normalizeTokenValue } from './normalize-value.mjs';
4
+
5
+ test('normalizes literal modern alpha colors without erasing token relationships', () => {
6
+ assert.equal(normalizeTokenValue('0 4px 10px rgb(0 0 0 / 10%)', 'c'), '0 4px 10px rgba(0, 0, 0, 0.1)');
7
+ assert.equal(normalizeTokenValue('rgb(1 2 3 / .5)', 'mp'), 'rgba(1, 2, 3, 0.5)');
8
+ assert.equal(normalizeTokenValue("var(~'--c-sys-radius-md')", 'c'), 'var(--PREFIX-sys-radius-md)');
9
+ assert.equal(normalizeTokenValue('rgba(var(--mp-gray-6), 0.2)', 'mp'), 'rgba(var(--PREFIX-gray-6), 0.2)');
10
+ assert.equal(normalizeTokenValue('calc(var(--c-x) * 2)', 'c'), 'calc(var(--PREFIX-x) * 2)');
11
+ assert.equal(normalizeTokenValue('.5', 'c'), '0.5');
12
+ assert.equal(normalizeTokenValue('-.5s .2s', 'c'), '-0.5s 0.2s');
13
+ assert.equal(normalizeTokenValue('calc(\n (var(--c-a) / 2) -\n (var(--c-b) / 2)\n)', 'c'), 'calc((var(--PREFIX-a) / 2) - (var(--PREFIX-b) / 2))');
14
+ assert.equal(normalizeTokenValue('"Font Name .5"', 'c'), '"Font Name .5"');
15
+ });
16
+
17
+ test('unwraps only complete Less escaped literals before syntax normalization', () => {
18
+ assert.equal(normalizeTokenValue("~'rgb(255 255 255 / 35%)'", 'c'), 'rgba(255, 255, 255, 0.35)');
19
+ assert.equal(normalizeTokenValue(' ~"rgb(0 0 0 / 55%)" ', 'mp'), 'rgba(0, 0, 0, 0.55)');
20
+ assert.equal(normalizeTokenValue("~'var(--c-sys-content-brand)'", 'c'), 'var(--PREFIX-sys-content-brand)');
21
+ assert.equal(normalizeTokenValue("'rgb(0 0 0 / 35%)'", 'c'), "'rgb(0 0 0 / 35%)'");
22
+ assert.equal(normalizeTokenValue("~'rgb(0 0 0 / 35%)' suffix", 'c'), "~'rgb(0 0 0 / 35%)' suffix");
23
+ assert.equal(normalizeTokenValue("~'red\"", 'c'), "~'red\"");
24
+ });
@@ -0,0 +1,62 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { readFileSync } from 'node:fs';
4
+ import contract from '../contracts/theme-contract.json' with { type: 'json' };
5
+ import { platformComponentTokenValues, platformSystemTokenValues } from './index.mjs';
6
+ import { normalizeTokenValue } from './normalize-value.mjs';
7
+
8
+ test('every system value is registered exactly once per platform', () => {
9
+ for (const platform of ['cedar', 'maple']) {
10
+ const names = [...Object.keys(contract.systemTokenValues), ...Object.keys(contract.platformSystemTokenValues[platform])];
11
+ assert.equal(new Set(names).size, names.length);
12
+ assert.deepEqual(names.sort(), contract.tokens.system);
13
+ assert.ok(Object.isFrozen(platformSystemTokenValues[platform]));
14
+ }
15
+ });
16
+
17
+ test('platform component defaults never contradict shared defaults', () => {
18
+ for (const platform of ['cedar', 'maple']) {
19
+ for (const [component, values] of Object.entries(contract.platformComponentTokenValues[platform])) {
20
+ assert.ok(Object.isFrozen(platformComponentTokenValues[platform][component]));
21
+ for (const [token, value] of Object.entries(values)) {
22
+ assert.equal(contract.componentTokenValues[component]?.[token], undefined);
23
+ assert.equal(typeof value, 'string');
24
+ assert.ok(value.length);
25
+ }
26
+ }
27
+ }
28
+ });
29
+
30
+ test('Checkbox mark-color is shared by name and preserves both existing platform defaults', () => {
31
+ assert.ok(contract.tokens.components.checkbox.includes('mark-color'));
32
+ assert.equal(contract.componentTokenValues.checkbox['mark-color'], undefined);
33
+ for (const [platform, repository, prefix, role] of [
34
+ ['cedar', 'cedar-design-web', 'c', 'surface-default'],
35
+ ['maple', 'maple-design-mobile', 'mp', 'content-on-bright'],
36
+ ]) {
37
+ const expected = `var(--PREFIX-sys-color-${role})`;
38
+ const source = readFileSync(new URL(`../../../../${repository}/packages/ui/src/checkbox/style/token.less`, import.meta.url), 'utf8');
39
+ const value = source.match(new RegExp(`--${prefix}-checkbox-mark-color:\\s*([^;]+);`))?.[1];
40
+ assert.ok(value, `${platform}: Checkbox mark-color must be declared`);
41
+ assert.equal(normalizeTokenValue(value, prefix), expected);
42
+ assert.equal(platformComponentTokenValues[platform].checkbox['mark-color'], expected);
43
+ }
44
+ });
45
+
46
+ test('Switch checked text has a public contract without changing platform foreground defaults', () => {
47
+ assert.ok(contract.tokens.components.switch.includes('checked-text-color'));
48
+ assert.equal(contract.componentTokenValues.switch['checked-text-color'], undefined);
49
+ for (const [platform, repository, prefix, expected] of [
50
+ ['cedar', 'cedar-design-web', 'c', 'var(--PREFIX-switch-text-color)'],
51
+ ['maple', 'maple-design-mobile', 'mp', 'var(--PREFIX-switch-text-color)'],
52
+ ]) {
53
+ const root = new URL(`../../../../${repository}/packages/ui/src/`, import.meta.url);
54
+ const tokens = readFileSync(new URL('switch/style/token.less', root), 'utf8');
55
+ const value = tokens.match(new RegExp(`--${prefix}-switch-checked-text-color:\\s*([^;]+);`))?.[1];
56
+ assert.ok(value);
57
+ assert.equal(normalizeTokenValue(value, prefix), expected);
58
+ assert.equal(platformComponentTokenValues[platform].switch['checked-text-color'], expected);
59
+ const style = readFileSync(new URL(`switch/style/${platform === 'maple' ? 'switch' : 'index'}.less`, root), 'utf8');
60
+ assert.match(style, new RegExp(`&--checked &__text\\s*\\{[^}]*color: var\\(--${prefix}-switch-checked-text-color\\)`));
61
+ }
62
+ });