view-contracts 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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Contract-first UI design toolchain. Compiles restricted view-contract TSX to language-neutral View IR and renders platform targets (React, SwiftUI, Compose).
4
4
 
5
- **Version:** 0.1.0
5
+ **Version:** 0.3.0
6
6
 
7
7
  ## Table of Contents
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "view-contracts",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Contract-first UI design toolchain — restricted TSX to View IR to platform renderers",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -13,6 +13,7 @@
13
13
  "dist/view-contracts.bundle.mjs",
14
14
  "dist/view-contracts.bundle.mjs.map",
15
15
  "renderers/react/runtime",
16
+ "renderers/react/defaults",
16
17
  "src/generated/schema",
17
18
  "spec",
18
19
  "renderers",
@@ -93,6 +94,9 @@
93
94
  "ajv": "^8.18.0",
94
95
  "cli-contracts": "^0.33.13",
95
96
  "esbuild": "^0.28.0",
97
+ "pixelmatch": "^7.2.0",
98
+ "playwright": "^1.61.0",
99
+ "pngjs": "^7.0.0",
96
100
  "tsx": "^4.21.0",
97
101
  "vite": "^6.4.1",
98
102
  "vitest": "^4.1.9"
@@ -0,0 +1,15 @@
1
+ // {{screenName}} — Jetpack Compose scaffold (Phase 5)
2
+ // Theme IR: {{themeJsonPath}}
3
+ // Token IR: {{tokensJsonPath}}
4
+ //
5
+ // TODO(Phase C): Load theme.json and emit Modifier.primaryButton(), vcTextMuted(), etc.
6
+
7
+ package {{packageName}}
8
+
9
+ import androidx.compose.material3.Text
10
+ import androidx.compose.runtime.Composable
11
+
12
+ @Composable
13
+ fun {{screenName}}Screen() {
14
+ Text("{{screenName}} — Compose scaffold")
15
+ }
@@ -0,0 +1,19 @@
1
+ // VcTheme — Compose theme scaffold (Phase 5)
2
+ // Load Token IR + Theme IR from assets:
3
+ // {{tokensJsonPath}}
4
+ // {{themeJsonPath}}
5
+ //
6
+ // TODO(Phase C): Map Theme IR variants to Modifier extensions and ColorScheme.
7
+
8
+ package {{packageName}}
9
+
10
+ import android.content.Context
11
+
12
+ object VcTheme {
13
+ const val TOKENS_ASSET = "{{tokensJsonPath}}"
14
+ const val THEME_ASSET = "{{themeJsonPath}}"
15
+
16
+ /** Placeholder — read theme.json from assets in Phase C. */
17
+ fun loadThemeJson(context: Context): String? =
18
+ context.assets.open(THEME_ASSET).bufferedReader().use { it.readText() }
19
+ }
@@ -0,0 +1,41 @@
1
+ # Default theme variants — component appearance via .vc-{element}--{variant} rules.
2
+ variants:
3
+ Button:
4
+ primary:
5
+ background: "{ui.primary}"
6
+ foreground: "{ui.on-primary}"
7
+ secondary:
8
+ background: "{ui.button-secondary}"
9
+ foreground: "{ui.text}"
10
+ ghost:
11
+ background: "{ui.transparent}"
12
+ foreground: "{ui.text}"
13
+ danger:
14
+ background: "{ui.danger}"
15
+ foreground: "{ui.on-primary}"
16
+ Text:
17
+ muted:
18
+ foreground: "{ui.text-muted}"
19
+ Card:
20
+ default:
21
+ background: "{ui.surface}"
22
+ Badge:
23
+ default:
24
+ background: "{ui.surface-muted}"
25
+ primary:
26
+ background: "{ui.primary-weak}"
27
+ success:
28
+ background: "{ui.success-weak}"
29
+ warning:
30
+ background: "{ui.warning-weak}"
31
+ danger:
32
+ background: "{ui.danger-weak}"
33
+ Alert:
34
+ default:
35
+ background: "{ui.surface}"
36
+ success:
37
+ background: "{ui.success-weak}"
38
+ warning:
39
+ background: "{ui.warning-weak}"
40
+ danger:
41
+ background: "{ui.danger-weak}"
@@ -0,0 +1,59 @@
1
+ # Default design tokens — mirrors renderers/react/runtime/theme.css :root variables.
2
+ ui:
3
+ bg:
4
+ $type: color
5
+ $value: "#f6f8fb"
6
+ surface:
7
+ $type: color
8
+ $value: "#ffffff"
9
+ surface-muted:
10
+ $type: color
11
+ $value: "#f8fafc"
12
+ text:
13
+ $type: color
14
+ $value: "#111827"
15
+ text-muted:
16
+ $type: color
17
+ $value: "#6b7280"
18
+ primary:
19
+ $type: color
20
+ $value: "#2563eb"
21
+ primary-weak:
22
+ $type: color
23
+ $value: "#dbeafe"
24
+ success:
25
+ $type: color
26
+ $value: "#047857"
27
+ success-weak:
28
+ $type: color
29
+ $value: "#d1fae5"
30
+ warning:
31
+ $type: color
32
+ $value: "#b45309"
33
+ warning-weak:
34
+ $type: color
35
+ $value: "#fef3c7"
36
+ danger:
37
+ $type: color
38
+ $value: "#dc2626"
39
+ danger-weak:
40
+ $type: color
41
+ $value: "#fee2e2"
42
+ on-primary:
43
+ $type: color
44
+ $value: "#ffffff"
45
+ button-secondary:
46
+ $type: color
47
+ $value: "#eef2f7"
48
+ transparent:
49
+ $type: color
50
+ $value: "transparent"
51
+ border:
52
+ $type: color
53
+ $value: "#e5e7eb"
54
+ shadow:
55
+ $type: shadow
56
+ $value: "0 10px 30px rgba(15, 23, 42, 0.08)"
57
+ font:
58
+ $type: typography
59
+ $value: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
@@ -12,7 +12,6 @@
12
12
  "levelProp": { "type": "string" },
13
13
  "defaultLevel": { "type": "number" },
14
14
  "actionEvent": { "type": "enum", "values": ["onClick", "onSubmit"] },
15
- "variantClass": { "type": "boolean" },
16
15
  "fieldWrap": { "type": "boolean" },
17
16
  "inputType": { "type": "string" },
18
17
  "sizeClass": { "type": "string" },
@@ -5,83 +5,82 @@
5
5
  elements:
6
6
  Page:
7
7
  tag: main
8
- className: ui-page
8
+ className: vc-page
9
9
  ariaLabel: true
10
10
  Box:
11
11
  tag: div
12
- className: ui-box
12
+ className: vc-box
13
13
  ariaLabel: true
14
14
  Column:
15
15
  tag: div
16
- className: ui-column
16
+ className: vc-column
17
17
  flex: column
18
18
  ariaLabel: true
19
19
  Row:
20
20
  tag: div
21
- className: ui-row
21
+ className: vc-row
22
22
  flex: row
23
23
  wrapProp: wrap
24
24
  ariaLabel: true
25
25
  Stack:
26
26
  tag: div
27
- className: ui-stack
27
+ className: vc-stack
28
28
  ariaLabel: true
29
29
  Spacer:
30
30
  tag: div
31
- className: ui-spacer
31
+ className: vc-spacer
32
32
  ariaHidden: true
33
33
  flexGrowProp: weight
34
34
  Divider:
35
35
  tag: hr
36
- className: ui-divider
36
+ className: vc-divider
37
37
  void: true
38
38
  Heading:
39
39
  tag: dynamic-heading
40
- className: ui-heading
40
+ className: vc-heading
41
41
  levelProp: level
42
42
  defaultLevel: 2
43
43
  Text:
44
44
  tag: p
45
- className: ui-text
45
+ className: vc-text
46
46
  Link:
47
47
  tag: a
48
- className: ui-link
48
+ className: vc-link
49
49
  actionEvent: onClick
50
50
  Button:
51
51
  tag: button
52
- className: ui-button
53
- variantClass: true
52
+ className: vc-button
54
53
  actionEvent: onClick
55
54
  Form:
56
55
  tag: form
57
- className: ui-form
56
+ className: vc-form
58
57
  actionEvent: onSubmit
59
58
  ariaLabel: true
60
59
  TextInput:
61
60
  tag: input
62
- className: ui-input
61
+ className: vc-text-input
63
62
  fieldWrap: true
64
63
  inputType: text
65
- sizeClass: ui-field
64
+ sizeClass: vc-field
66
65
  TextArea:
67
66
  tag: textarea
68
- className: ui-textarea
67
+ className: vc-text-area
69
68
  fieldWrap: true
70
69
  Select:
71
70
  tag: select
72
- className: ui-select
71
+ className: vc-select
73
72
  fieldWrap: true
74
- sizeClass: ui-field
73
+ sizeClass: vc-field
75
74
  optionsProp: options
76
75
  Checkbox:
77
76
  tag: label
78
- className: ui-check
77
+ className: vc-checkbox
79
78
  checkbox: true
80
79
  List:
81
80
  tag: ul
82
- className: ui-list
81
+ className: vc-list
83
82
  defaultRole: list
84
83
  ListItem:
85
84
  tag: li
86
- className: ui-list-item
85
+ className: vc-list-item
87
86
  defaultRole: listitem
@@ -119,22 +119,47 @@ function actionHandler(props: Record<string, IrValue>, event: 'onClick' | 'onSub
119
119
  return `onClick={() => { void dispatchAction(${expr}); }}`;
120
120
  }
121
121
 
122
- function classNameAttr(entry: ElementMapEntry, props: Record<string, IrValue>, headingLevel?: number): string {
122
+ function componentToKebab(name: string): string {
123
+ return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
124
+ }
125
+
126
+ function variantClassSuffix(elementName: string, props: Record<string, IrValue>): string | undefined {
127
+ const variantLit = propLiteral(props, 'variant');
128
+ if (typeof variantLit === 'string') {
129
+ return ` vc-${componentToKebab(elementName)}--${variantLit}`;
130
+ }
131
+ const variant = propExpr(props, 'variant');
132
+ if (variant) return ` vc-${componentToKebab(elementName)}--\${${variant}}`;
133
+ return undefined;
134
+ }
135
+
136
+ function classNameAttr(
137
+ elementName: string,
138
+ entry: ElementMapEntry,
139
+ props: Record<string, IrValue>,
140
+ headingLevel?: number,
141
+ ): string {
123
142
  const base = entry.className ?? '';
124
143
  if (entry.tag === 'dynamic-heading' && headingLevel !== undefined) {
125
- return `className="ui-heading ui-heading-${headingLevel}"`;
144
+ const levelClass = `${base} vc-heading-${headingLevel}`;
145
+ const variantSuffix = variantClassSuffix(elementName, props);
146
+ if (!variantSuffix) return `className="${levelClass}"`;
147
+ const variantLit = propLiteral(props, 'variant');
148
+ if (typeof variantLit === 'string') {
149
+ return `className="${levelClass}${variantSuffix}"`;
150
+ }
151
+ return `className={\`${levelClass}${variantSuffix}\`}`;
126
152
  }
127
- if (entry.variantClass) {
153
+ const variantSuffix = variantClassSuffix(elementName, props);
154
+ if (variantSuffix) {
128
155
  const variantLit = propLiteral(props, 'variant');
129
156
  if (typeof variantLit === 'string') {
130
- return `className="${base} ui-button-${variantLit}"`;
157
+ return `className="${base}${variantSuffix}"`;
131
158
  }
132
- const variant = propExpr(props, 'variant');
133
- if (variant) return `className={\`${base} ui-button-\${${variant} ?? 'secondary'}\`}`;
134
- return `className="${base} ui-button-secondary"`;
159
+ return `className={\`${base}${variantSuffix}\`}`;
135
160
  }
136
161
  if (entry.wrapProp && propLiteral(props, entry.wrapProp) === true) {
137
- return `className="${base} ui-wrap"`;
162
+ return `className="${base} vc-wrap"`;
138
163
  }
139
164
  if (entry.sizeClass && props.size) {
140
165
  const size = propExpr(props, 'size');
@@ -150,22 +175,22 @@ function emitFieldWrap(inner: string, props: Record<string, IrValue>, level: num
150
175
  const label = propExpr(props, 'label');
151
176
  const help = propExpr(props, 'help');
152
177
  const error = propExpr(props, 'error');
153
- const lines = [`${indent(level)}<label className="ui-field">`];
178
+ const lines = [`${indent(level)}<label className="vc-field">`];
154
179
  if (typeof labelLit === 'string') {
155
- lines.push(`${indent(level + 1)}<span className="ui-field-label">{${JSON.stringify(labelLit)}}</span>`);
180
+ lines.push(`${indent(level + 1)}<span className="vc-field-label">{${JSON.stringify(labelLit)}}</span>`);
156
181
  } else if (label) {
157
- lines.push(`${indent(level + 1)}{${label} ? <span className="ui-field-label">{${label}}</span> : null}`);
182
+ lines.push(`${indent(level + 1)}{${label} ? <span className="vc-field-label">{${label}}</span> : null}`);
158
183
  }
159
184
  lines.push(`${indent(level + 1)}${inner}`);
160
185
  if (typeof helpLit === 'string') {
161
- lines.push(`${indent(level + 1)}<span className="ui-field-help">{${JSON.stringify(helpLit)}}</span>`);
186
+ lines.push(`${indent(level + 1)}<span className="vc-field-help">{${JSON.stringify(helpLit)}}</span>`);
162
187
  } else if (help) {
163
- lines.push(`${indent(level + 1)}{${help} ? <span className="ui-field-help">{${help}}</span> : null}`);
188
+ lines.push(`${indent(level + 1)}{${help} ? <span className="vc-field-help">{${help}}</span> : null}`);
164
189
  }
165
190
  if (typeof errorLit === 'string') {
166
- lines.push(`${indent(level + 1)}<span className="ui-field-error">{${JSON.stringify(errorLit)}}</span>`);
191
+ lines.push(`${indent(level + 1)}<span className="vc-field-error">{${JSON.stringify(errorLit)}}</span>`);
167
192
  } else if (error) {
168
- lines.push(`${indent(level + 1)}{${error} ? <span className="ui-field-error">{${error}}</span> : null}`);
193
+ lines.push(`${indent(level + 1)}{${error} ? <span className="vc-field-error">{${error}}</span> : null}`);
169
194
  }
170
195
  lines.push(`${indent(level)}</label>`);
171
196
  return lines.join('\n');
@@ -176,7 +201,7 @@ function emitCheckbox(props: Record<string, IrValue>, level: number): string {
176
201
  const label = propExpr(props, 'label') ?? '""';
177
202
  const checked = props.checked ? `defaultChecked={${propExpr(props, 'checked')}}` : '';
178
203
  const disabled = props.enabled ? `disabled={${propExpr(props, 'enabled')} === false}` : '';
179
- return `${indent(level)}<label className="ui-check">
204
+ return `${indent(level)}<label className="vc-checkbox">
180
205
  ${indent(level + 1)}<input type="checkbox" name={${name}} ${checked} ${disabled} />
181
206
  ${indent(level + 1)}<span>{${label}}</span>
182
207
  ${indent(level)}</label>`;
@@ -193,8 +218,13 @@ function emitSelectOptions(props: Record<string, IrValue>, level: number): strin
193
218
  return `${indent(level + 1)}{${expr}.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}`;
194
219
  }
195
220
 
196
- function emitVoidInput(entry: ElementMapEntry, props: Record<string, IrValue>, level: number): string {
197
- const attrs: string[] = [classNameAttr(entry, props), styleAttr(entry, props)];
221
+ function emitVoidInput(
222
+ elementName: string,
223
+ entry: ElementMapEntry,
224
+ props: Record<string, IrValue>,
225
+ level: number,
226
+ ): string {
227
+ const attrs: string[] = [classNameAttr(elementName, entry, props), styleAttr(entry, props)];
198
228
  if (props.name) attrs.push(`name={${propExpr(props, 'name')}}`);
199
229
  if (props.placeholder) attrs.push(`placeholder={${propExpr(props, 'placeholder')}}`);
200
230
  if (entry.inputType) attrs.push(`type={${propExpr(props, 'type') ?? JSON.stringify(entry.inputType)}}`);
@@ -205,7 +235,13 @@ function emitVoidInput(entry: ElementMapEntry, props: Record<string, IrValue>, l
205
235
  return `${indent(level)}<${entry.tag} ${attrs.join(' ')} />`;
206
236
  }
207
237
 
208
- function emitOpenTag(entry: ElementMapEntry, props: Record<string, IrValue>, tag: string, headingLevel?: number): string {
238
+ function emitOpenTag(
239
+ elementName: string,
240
+ entry: ElementMapEntry,
241
+ props: Record<string, IrValue>,
242
+ tag: string,
243
+ headingLevel?: number,
244
+ ): string {
209
245
  const attrs: string[] = [];
210
246
  if (entry.ariaHidden) attrs.push('aria-hidden');
211
247
  if (props.id) attrs.push(`id={${propExpr(props, 'id')}}`);
@@ -223,7 +259,7 @@ function emitOpenTag(entry: ElementMapEntry, props: Record<string, IrValue>, tag
223
259
  if (entry.tag === 'button' && props.enabled) attrs.push(`disabled={${propExpr(props, 'enabled')} === false}`);
224
260
  if (entry.tag === 'a' && props.href) attrs.push(`href={${propExpr(props, 'href')}}`);
225
261
  if (entry.tag === 'button') attrs.push(`type={${propExpr(props, 'type') ?? '"button"'}}`);
226
- attrs.push(classNameAttr(entry, props, headingLevel));
262
+ attrs.push(classNameAttr(elementName, entry, props, headingLevel));
227
263
  attrs.push(styleAttr(entry, props));
228
264
  return `<${tag} ${attrs.join(' ')}`;
229
265
  }
@@ -290,10 +326,10 @@ function lowerVocabularyElement(
290
326
  const tag = entry.tag;
291
327
  let inner: string;
292
328
  if (entry.optionsProp && props[entry.optionsProp]) {
293
- const open = emitOpenTag(entry, props, tag);
329
+ const open = emitOpenTag(node.name, entry, props, tag);
294
330
  inner = `${indent(level + 1)}${open}>\n${emitSelectOptions(props, level + 2)}\n${indent(level + 1)}</${tag}>`;
295
331
  } else {
296
- inner = emitVoidInput(entry, props, level + 1);
332
+ inner = emitVoidInput(node.name, entry, props, level + 1);
297
333
  }
298
334
  return visibilityGuard(emitFieldWrap(inner, props, level), props, level);
299
335
  }
@@ -303,7 +339,7 @@ function lowerVocabularyElement(
303
339
  if (typeof levelLit === 'number') {
304
340
  const clamped = Math.min(6, Math.max(1, levelLit));
305
341
  const tag = `h${clamped}`;
306
- const open = emitOpenTag(entry, props, tag, clamped);
342
+ const open = emitOpenTag(node.name, entry, props, tag, clamped);
307
343
  const body = childContent
308
344
  ? `${indent(level)}${open}>\n${childContent}\n${indent(level)}</${tag}>`
309
345
  : `${indent(level)}${open} />`;
@@ -311,17 +347,17 @@ function lowerVocabularyElement(
311
347
  }
312
348
  const levelExpr = propExpr(props, 'level') ?? '2';
313
349
  const inner = childContent
314
- ? `${indent(level + 1)}{(() => { const __level = Math.min(6, Math.max(1, ${levelExpr} ?? 2)); const Tag = ('h' + __level) as keyof JSX.IntrinsicElements; return <Tag className={\`ui-heading ui-heading-\${__level}\`} style={commonStyle(${collectStyleProps(props)})}>{/* children below */}</Tag>; })()}`
350
+ ? `${indent(level + 1)}{(() => { const __level = Math.min(6, Math.max(1, ${levelExpr} ?? 2)); const Tag = ('h' + __level) as keyof JSX.IntrinsicElements; return <Tag className={\`vc-heading vc-heading-\${__level}\`} style={commonStyle(${collectStyleProps(props)})}>{/* children below */}</Tag>; })()}`
315
351
  : '';
316
- return visibilityGuard(inner || `${indent(level)}<h2 ${classNameAttr(entry, props, 2)} ${styleAttr(entry, props)} />`, props, level);
352
+ return visibilityGuard(inner || `${indent(level)}<h2 ${classNameAttr(node.name, entry, props, 2)} ${styleAttr(entry, props)} />`, props, level);
317
353
  }
318
354
 
319
355
  const tag = entry.tag;
320
356
  if (entry.void) {
321
- return visibilityGuard(`${indent(level)}${emitOpenTag(entry, props, tag)} />`, props, level);
357
+ return visibilityGuard(`${indent(level)}${emitOpenTag(node.name, entry, props, tag)} />`, props, level);
322
358
  }
323
359
 
324
- const open = emitOpenTag(entry, props, tag);
360
+ const open = emitOpenTag(node.name, entry, props, tag);
325
361
  let body: string;
326
362
  if (!childContent && entry.optionsProp && props[entry.optionsProp]) {
327
363
  body = `${indent(level)}${open}>\n${emitSelectOptions(props, level + 1)}\n${indent(level)}</${tag}>`;
@@ -3,6 +3,14 @@ import { resolve } from 'node:path';
3
3
  import type { ViewContractsConfig } from '../../src/config.js';
4
4
  import { projectRoot } from '../../src/config.js';
5
5
  import { loadThemeCss } from '../../src/catalog/loadThemeCss.js';
6
+ import {
7
+ defaultThemePath,
8
+ defaultTokensPath,
9
+ emitTokenCss,
10
+ emitVariantCss,
11
+ extractStructuralThemeCss,
12
+ loadValidateDesign,
13
+ } from '../../src/design/index.js';
6
14
  import { packageRoot } from '../../src/lib/packageRoot.js';
7
15
  import { renderTemplate } from '../../src/renderer/template.js';
8
16
  import { reactRuntimeDir, reactTemplatesDir } from './paths.js';
@@ -32,7 +40,7 @@ async function copySchemaBundle(componentsDir: string): Promise<void> {
32
40
 
33
41
  const schemaDest = resolve(componentsDir, 'schema');
34
42
  await cp(schemaSrc, schemaDest, { recursive: true });
35
- for (const name of ['dsl-properties.ts', 'tokens.ts', 'schema-document.ts', 'index.ts'] as const) {
43
+ for (const name of ['dsl-properties.ts', 'dsl-enums.ts', 'schema-document.ts', 'index.ts'] as const) {
36
44
  const file = resolve(schemaDest, name);
37
45
  if (await pathExists(file)) {
38
46
  await writeFile(file, rewriteSchemaImports(await readFile(file, 'utf-8')), 'utf-8');
@@ -49,15 +57,30 @@ async function copyStyleBundle(componentsDir: string): Promise<void> {
49
57
  await writeFile(resolve(componentsDir, 'types.ts'), types, 'utf-8');
50
58
  }
51
59
 
52
- async function writeThemeCss(root: string, themeOut: string, templatesDir?: string): Promise<void> {
60
+ async function writeThemeCss(
61
+ root: string,
62
+ themeOut: string,
63
+ config: ViewContractsConfig,
64
+ templatesDir?: string,
65
+ ): Promise<void> {
66
+ const tokensPath = config.design?.tokens ?? defaultTokensPath();
67
+ const themePath = config.design?.theme ?? defaultThemePath();
68
+ const { tokenIR, themeIR } = await loadValidateDesign(tokensPath, themePath);
53
69
  const runtimeTheme = resolve(reactRuntimeDir(), 'theme.css');
54
- let css = await loadThemeCss(runtimeTheme);
55
- const extensionsCss = resolve(root, 'src/theme.extensions.css');
70
+ const runtimeCss = await loadThemeCss(runtimeTheme);
71
+ const variantCss = emitVariantCss(tokenIR, themeIR);
72
+ let css = `${emitTokenCss(tokenIR)}\n\n${variantCss}\n\n${extractStructuralThemeCss(runtimeCss)}`;
73
+ const extensionsCss = config.design?.extensionsCss ?? resolve(root, 'src/theme.extensions.css');
56
74
  if (await pathExists(extensionsCss)) {
57
75
  css += `\n\n${await readFile(extensionsCss, 'utf-8')}`;
58
76
  }
59
77
  const content = await renderTemplate(reactTemplatesDir(templatesDir), 'theme.css.hbs', { themeCss: css });
60
78
  await writeFile(themeOut, content, 'utf-8');
79
+
80
+ const themeDir = resolve(config.generated.reactDir, '../theme');
81
+ await mkdir(themeDir, { recursive: true });
82
+ await writeFile(resolve(themeDir, 'tokens.json'), `${JSON.stringify(tokenIR, null, 2)}\n`, 'utf-8');
83
+ await writeFile(resolve(themeDir, 'theme.json'), `${JSON.stringify(themeIR, null, 2)}\n`, 'utf-8');
61
84
  }
62
85
 
63
86
  async function removeStaleGeneratedFiles(componentsDir: string): Promise<void> {
@@ -117,7 +140,7 @@ export async function renderReactRuntime(configPath: string, config: ViewContrac
117
140
  'utf-8',
118
141
  );
119
142
 
120
- await writeThemeCss(root, resolve(config.generated.reactDir, '../theme.css'), templatesDir);
143
+ await writeThemeCss(root, resolve(config.generated.reactDir, '../theme.css'), config, templatesDir);
121
144
  await writeFile(
122
145
  resolve(runtimeDir, 'types.ts'),
123
146
  await renderTemplate(templates, 'runtime-types.ts.hbs', {}),
@@ -1,5 +1,5 @@
1
1
  import type { CSSProperties } from 'react';
2
- import type { Align, Clip, ContentAlign, FontWeight, Justify, Shadow } from '../../../src/generated/schema/tokens.js';
2
+ import type { Align, ContentAlign, FontWeight, Justify } from '../../../src/generated/schema/dsl-enums.js';
3
3
  import type { CommonProps } from './types.js';
4
4
 
5
5
  export function mapAlign(value?: Align): string | undefined {
@@ -23,12 +23,6 @@ function len(value: number): string {
23
23
  return `${value}px`;
24
24
  }
25
25
 
26
- const SHADOW: Record<Exclude<Shadow, 'none'>, string> = {
27
- sm: '0 1px 3px rgba(15,23,42,0.08)',
28
- md: '0 10px 30px rgba(15, 23, 42, 0.08)',
29
- lg: '0 20px 40px rgba(15, 23, 42, 0.12)',
30
- };
31
-
32
26
  const CONTENT_ALIGN: Record<ContentAlign, string> = {
33
27
  topStart: 'start start',
34
28
  top: 'center start',
@@ -41,12 +35,6 @@ const CONTENT_ALIGN: Record<ContentAlign, string> = {
41
35
  bottomEnd: 'end end',
42
36
  };
43
37
 
44
- function shadowStyle(shadow: Shadow | boolean | undefined): string | undefined {
45
- if (shadow === true) return SHADOW.md;
46
- if (!shadow || shadow === 'none') return undefined;
47
- return SHADOW[shadow];
48
- }
49
-
50
38
  function fontSizeStyle(size: number | string | undefined): string | undefined {
51
39
  if (size === undefined) return undefined;
52
40
  return typeof size === 'number' ? len(size) : size;
@@ -65,14 +53,6 @@ export function commonStyle(props: CommonProps): CSSProperties {
65
53
  if (typeof props.padding === 'number') style.padding = len(props.padding);
66
54
  if (typeof (props as { margin?: number }).margin === 'number') style.margin = len((props as { margin: number }).margin);
67
55
  if (typeof props.gap === 'number') style.gap = len(props.gap);
68
- if (typeof props.radius === 'number') style.borderRadius = len(props.radius);
69
- if (props.border) style.border = '1px solid var(--ui-border)';
70
- const shadow = shadowStyle(props.shadow);
71
- if (shadow) style.boxShadow = shadow;
72
- if (props.opacity !== undefined) style.opacity = props.opacity;
73
- if (props.clip && props.clip !== 'none') style.overflow = 'hidden';
74
- if (props.background) style.background = `var(--ui-${props.background}, ${props.background})`;
75
- if (props.foreground) style.color = `var(--ui-${props.foreground}, ${props.foreground})`;
76
56
  if (props.textAlign) style.textAlign = props.textAlign === 'start' ? 'left' : props.textAlign === 'end' ? 'right' : 'center';
77
57
  if (props.contentAlign) style.placeItems = CONTENT_ALIGN[props.contentAlign];
78
58
  if ((props as { overflow?: string }).overflow) style.overflow = (props as { overflow: string }).overflow;
@@ -93,4 +73,29 @@ export function toneClass(tone: string | undefined): string {
93
73
  return tone ? `ui-tone-${tone}` : 'ui-tone-default';
94
74
  }
95
75
 
96
- export type { Clip, ContentAlign, Shadow };
76
+ function componentToKebab(name: string): string {
77
+ return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
78
+ }
79
+
80
+ const DEFAULT_VARIANT_ALIASES = new Set(['default', 'muted']);
81
+
82
+ /** Resolve theme variant name from variant or legacy tone prop. */
83
+ export function resolveExtensionVariant(variant?: string, tone?: string): string {
84
+ const raw = variant ?? tone;
85
+ if (!raw || DEFAULT_VARIANT_ALIASES.has(raw)) return 'default';
86
+ return raw;
87
+ }
88
+
89
+ /** Build vc-* base + --variant modifier for themed extension components. */
90
+ export function extensionClassName(
91
+ component: string,
92
+ options?: { variant?: string; tone?: string; extra?: string | false },
93
+ ): string {
94
+ const base = `vc-${componentToKebab(component)}`;
95
+ const variant = resolveExtensionVariant(options?.variant, options?.tone);
96
+ const parts = [base, `${base}--${variant}`];
97
+ if (options?.extra) parts.push(options.extra);
98
+ return parts.filter(Boolean).join(' ');
99
+ }
100
+
101
+ export type { ContentAlign };