view-contracts 0.2.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +7 -9
  2. package/cli-contract.yaml +1 -38
  3. package/dist/view-contracts.bundle.mjs +208 -254
  4. package/dist/view-contracts.bundle.mjs.map +4 -4
  5. package/docs/cli-reference.md +1 -38
  6. package/package.json +7 -4
  7. package/renderers/compose/templates/Screen.kt.hbs +15 -0
  8. package/renderers/compose/templates/VcTheme.kt.hbs +19 -0
  9. package/renderers/react/README.md +1 -0
  10. package/renderers/react/defaults/theme.yaml +26 -0
  11. package/renderers/react/defaults/tokens.yaml +59 -0
  12. package/renderers/react/element-config.meta.json +2 -1
  13. package/renderers/react/elementMap.ts +4 -2
  14. package/renderers/react/elements.yaml +76 -21
  15. package/renderers/react/lowering/lowerToJsx.ts +183 -28
  16. package/renderers/react/renderComponents.ts +23 -8
  17. package/renderers/react/renderRuntime.ts +24 -75
  18. package/renderers/react/routes.yaml +1 -1
  19. package/renderers/react/runtime/style.ts +45 -36
  20. package/renderers/react/runtime/theme.css +51 -54
  21. package/renderers/react/runtime/types.ts +20 -43
  22. package/renderers/react/syncGeneratedRuntime.ts +77 -0
  23. package/renderers/react/templates/components-index.ts.hbs +1 -1
  24. package/renderers/react/templates/runtime-types.ts.hbs +5 -0
  25. package/renderers/react/templates/style-helpers.ts.hbs +1 -1
  26. package/renderers/swiftui/templates/Screen.swift.hbs +21 -0
  27. package/renderers/swiftui/templates/VcTheme.swift.hbs +21 -0
  28. package/spec/ui-dsl-extensions.meta.json +62 -0
  29. package/spec/ui-dsl.schema.json +24 -16
  30. package/src/generated/schema/allowed-components.ts +1 -1
  31. package/src/generated/schema/{tokens.ts → dsl-enums.ts} +1 -1
  32. package/src/generated/schema/dsl-properties.ts +3 -2
  33. package/src/generated/schema/index.ts +2 -2
  34. package/src/generated/schema/lowering-style-properties.ts +1 -9
  35. package/src/generated/schema/react-renderer-element-config.ts +3 -2
  36. package/src/generated/schema/schema-document.ts +2 -2
@@ -96,9 +96,40 @@ function extraFlexStyle(entry: ElementMapEntry, props: Record<string, IrValue>):
96
96
  return `, ${parts.join(', ')}`;
97
97
  }
98
98
 
99
+ function scrollOverflowLiteral(mode: string): { overflowX: string; overflowY: string } {
100
+ if (mode === 'horizontal') return { overflowX: 'auto', overflowY: 'hidden' };
101
+ if (mode === 'both') return { overflowX: 'auto', overflowY: 'auto' };
102
+ if (mode === 'none') return { overflowX: 'hidden', overflowY: 'hidden' };
103
+ return { overflowX: 'hidden', overflowY: 'auto' };
104
+ }
105
+
99
106
  function styleAttr(entry: ElementMapEntry, props: Record<string, IrValue>): string {
100
107
  const base = collectStyleProps(props);
101
108
  const flex = extraFlexStyle(entry, props);
109
+ if (entry.scrollContainer) {
110
+ const scrollLit = propLiteral(props, 'scroll');
111
+ const axisLit = propLiteral(props, 'axis');
112
+ const literalMode = typeof scrollLit === 'string'
113
+ ? scrollLit
114
+ : typeof axisLit === 'string'
115
+ ? (axisLit === 'horizontal' ? 'horizontal' : 'vertical')
116
+ : undefined;
117
+ if (literalMode) {
118
+ const { overflowX, overflowY } = scrollOverflowLiteral(literalMode);
119
+ return `style={{ ...commonStyle(${base})${flex}, overflowX: '${overflowX}', overflowY: '${overflowY}' }}`;
120
+ }
121
+ const scroll = propExpr(props, 'scroll');
122
+ const axis = propExpr(props, 'axis');
123
+ const modeExpr = scroll
124
+ ?? (axis ? `(${axis} === 'horizontal' ? 'horizontal' : 'vertical')` : `'vertical'`);
125
+ return `style={{ ...commonStyle(${base})${flex}, ...(() => {
126
+ const __scroll = ${modeExpr};
127
+ if (__scroll === 'horizontal') return { overflowX: 'auto', overflowY: 'hidden' };
128
+ if (__scroll === 'both') return { overflowX: 'auto', overflowY: 'auto' };
129
+ if (__scroll === 'none') return { overflowX: 'hidden', overflowY: 'hidden' };
130
+ return { overflowX: 'hidden', overflowY: 'auto' };
131
+ })() }}`;
132
+ }
102
133
  if (entry.flexGrowProp && props[entry.flexGrowProp]) {
103
134
  const grow = propExpr(props, entry.flexGrowProp);
104
135
  return `style={{ ...commonStyle(${base})${flex}, flexGrow: ${grow} ?? 1 }}`;
@@ -119,22 +150,47 @@ function actionHandler(props: Record<string, IrValue>, event: 'onClick' | 'onSub
119
150
  return `onClick={() => { void dispatchAction(${expr}); }}`;
120
151
  }
121
152
 
122
- function classNameAttr(entry: ElementMapEntry, props: Record<string, IrValue>, headingLevel?: number): string {
153
+ function componentToKebab(name: string): string {
154
+ return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
155
+ }
156
+
157
+ function variantClassSuffix(elementName: string, props: Record<string, IrValue>): string | undefined {
158
+ const variantLit = propLiteral(props, 'variant');
159
+ if (typeof variantLit === 'string') {
160
+ return ` vc-${componentToKebab(elementName)}--${variantLit}`;
161
+ }
162
+ const variant = propExpr(props, 'variant');
163
+ if (variant) return ` vc-${componentToKebab(elementName)}--\${${variant}}`;
164
+ return undefined;
165
+ }
166
+
167
+ function classNameAttr(
168
+ elementName: string,
169
+ entry: ElementMapEntry,
170
+ props: Record<string, IrValue>,
171
+ headingLevel?: number,
172
+ ): string {
123
173
  const base = entry.className ?? '';
124
174
  if (entry.tag === 'dynamic-heading' && headingLevel !== undefined) {
125
- return `className="ui-heading ui-heading-${headingLevel}"`;
175
+ const levelClass = `${base} vc-heading-${headingLevel}`;
176
+ const variantSuffix = variantClassSuffix(elementName, props);
177
+ if (!variantSuffix) return `className="${levelClass}"`;
178
+ const variantLit = propLiteral(props, 'variant');
179
+ if (typeof variantLit === 'string') {
180
+ return `className="${levelClass}${variantSuffix}"`;
181
+ }
182
+ return `className={\`${levelClass}${variantSuffix}\`}`;
126
183
  }
127
- if (entry.variantClass) {
184
+ const variantSuffix = variantClassSuffix(elementName, props);
185
+ if (variantSuffix) {
128
186
  const variantLit = propLiteral(props, 'variant');
129
187
  if (typeof variantLit === 'string') {
130
- return `className="${base} ui-button-${variantLit}"`;
188
+ return `className="${base}${variantSuffix}"`;
131
189
  }
132
- const variant = propExpr(props, 'variant');
133
- if (variant) return `className={\`${base} ui-button-\${${variant} ?? 'secondary'}\`}`;
134
- return `className="${base} ui-button-secondary"`;
190
+ return `className={\`${base}${variantSuffix}\`}`;
135
191
  }
136
192
  if (entry.wrapProp && propLiteral(props, entry.wrapProp) === true) {
137
- return `className="${base} ui-wrap"`;
193
+ return `className="${base} vc-wrap"`;
138
194
  }
139
195
  if (entry.sizeClass && props.size) {
140
196
  const size = propExpr(props, 'size');
@@ -150,22 +206,22 @@ function emitFieldWrap(inner: string, props: Record<string, IrValue>, level: num
150
206
  const label = propExpr(props, 'label');
151
207
  const help = propExpr(props, 'help');
152
208
  const error = propExpr(props, 'error');
153
- const lines = [`${indent(level)}<label className="ui-field">`];
209
+ const lines = [`${indent(level)}<label className="vc-field">`];
154
210
  if (typeof labelLit === 'string') {
155
- lines.push(`${indent(level + 1)}<span className="ui-field-label">{${JSON.stringify(labelLit)}}</span>`);
211
+ lines.push(`${indent(level + 1)}<span className="vc-field-label">{${JSON.stringify(labelLit)}}</span>`);
156
212
  } else if (label) {
157
- lines.push(`${indent(level + 1)}{${label} ? <span className="ui-field-label">{${label}}</span> : null}`);
213
+ lines.push(`${indent(level + 1)}{${label} ? <span className="vc-field-label">{${label}}</span> : null}`);
158
214
  }
159
215
  lines.push(`${indent(level + 1)}${inner}`);
160
216
  if (typeof helpLit === 'string') {
161
- lines.push(`${indent(level + 1)}<span className="ui-field-help">{${JSON.stringify(helpLit)}}</span>`);
217
+ lines.push(`${indent(level + 1)}<span className="vc-field-help">{${JSON.stringify(helpLit)}}</span>`);
162
218
  } else if (help) {
163
- lines.push(`${indent(level + 1)}{${help} ? <span className="ui-field-help">{${help}}</span> : null}`);
219
+ lines.push(`${indent(level + 1)}{${help} ? <span className="vc-field-help">{${help}}</span> : null}`);
164
220
  }
165
221
  if (typeof errorLit === 'string') {
166
- lines.push(`${indent(level + 1)}<span className="ui-field-error">{${JSON.stringify(errorLit)}}</span>`);
222
+ lines.push(`${indent(level + 1)}<span className="vc-field-error">{${JSON.stringify(errorLit)}}</span>`);
167
223
  } else if (error) {
168
- lines.push(`${indent(level + 1)}{${error} ? <span className="ui-field-error">{${error}}</span> : null}`);
224
+ lines.push(`${indent(level + 1)}{${error} ? <span className="vc-field-error">{${error}}</span> : null}`);
169
225
  }
170
226
  lines.push(`${indent(level)}</label>`);
171
227
  return lines.join('\n');
@@ -176,12 +232,59 @@ function emitCheckbox(props: Record<string, IrValue>, level: number): string {
176
232
  const label = propExpr(props, 'label') ?? '""';
177
233
  const checked = props.checked ? `defaultChecked={${propExpr(props, 'checked')}}` : '';
178
234
  const disabled = props.enabled ? `disabled={${propExpr(props, 'enabled')} === false}` : '';
179
- return `${indent(level)}<label className="ui-check">
235
+ return `${indent(level)}<label className="vc-checkbox">
180
236
  ${indent(level + 1)}<input type="checkbox" name={${name}} ${checked} ${disabled} />
181
237
  ${indent(level + 1)}<span>{${label}}</span>
182
238
  ${indent(level)}</label>`;
183
239
  }
184
240
 
241
+ function emitRadio(props: Record<string, IrValue>, level: number): string {
242
+ const name = propExpr(props, 'binding') ?? propExpr(props, 'name') ?? '""';
243
+ const value = propExpr(props, 'value') ?? '""';
244
+ const label = propExpr(props, 'label') ?? '""';
245
+ const checked = props.checked ? `defaultChecked={${propExpr(props, 'checked')}}` : '';
246
+ const disabled = props.enabled ? `disabled={${propExpr(props, 'enabled')} === false}` : '';
247
+ return `${indent(level)}<label className="vc-radio">
248
+ ${indent(level + 1)}<input type="radio" name={${name}} value={${value}} ${checked} ${disabled} />
249
+ ${indent(level + 1)}<span>{${label}}</span>
250
+ ${indent(level)}</label>`;
251
+ }
252
+
253
+ function emitSwitch(props: Record<string, IrValue>, level: number): string {
254
+ const name = propExpr(props, 'binding') ?? propExpr(props, 'name') ?? '""';
255
+ const label = propExpr(props, 'label') ?? '""';
256
+ const checked = props.checked ? `defaultChecked={${propExpr(props, 'checked')}}` : '';
257
+ const disabled = props.enabled ? `disabled={${propExpr(props, 'enabled')} === false}` : '';
258
+ return `${indent(level)}<label className="vc-switch">
259
+ ${indent(level + 1)}<input type="checkbox" role="switch" name={${name}} ${checked} ${disabled} />
260
+ ${indent(level + 1)}<span>{${label}}</span>
261
+ ${indent(level)}</label>`;
262
+ }
263
+
264
+ function emitIcon(node: IrComponentNode, entry: ElementMapEntry, level: number): string {
265
+ const props = node.props;
266
+ const hasAlt = props.alt !== undefined;
267
+ if (hasAlt) {
268
+ const attrs: string[] = [
269
+ classNameAttr(node.name, entry, props),
270
+ styleAttr(entry, props),
271
+ `src={${propExpr(props, 'name') ?? '""'}}`,
272
+ `alt={${propExpr(props, 'alt') ?? '""'}}`,
273
+ ];
274
+ if (props.id) attrs.push(`id={${propExpr(props, 'id')}}`);
275
+ return `${indent(level)}<img ${attrs.join(' ')} />`;
276
+ }
277
+
278
+ const attrs: string[] = [
279
+ classNameAttr(node.name, entry, props),
280
+ styleAttr(entry, props),
281
+ `aria-hidden`,
282
+ ];
283
+ if (props.id) attrs.push(`id={${propExpr(props, 'id')}}`);
284
+ if (props.name) attrs.push(`data-icon={${propExpr(props, 'name')}}`);
285
+ return `${indent(level)}<span ${attrs.join(' ')} />`;
286
+ }
287
+
185
288
  function emitSelectOptions(props: Record<string, IrValue>, level: number): string {
186
289
  const options = props.options;
187
290
  if (!options || typeof options !== 'object' || !('kind' in options)) return '';
@@ -193,19 +296,42 @@ function emitSelectOptions(props: Record<string, IrValue>, level: number): strin
193
296
  return `${indent(level + 1)}{${expr}.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}`;
194
297
  }
195
298
 
196
- function emitVoidInput(entry: ElementMapEntry, props: Record<string, IrValue>, level: number): string {
197
- const attrs: string[] = [classNameAttr(entry, props), styleAttr(entry, props)];
299
+ function emitVoidInput(
300
+ elementName: string,
301
+ entry: ElementMapEntry,
302
+ props: Record<string, IrValue>,
303
+ level: number,
304
+ ): string {
305
+ const attrs: string[] = [classNameAttr(elementName, entry, props), styleAttr(entry, props)];
306
+ if (entry.ariaHidden) attrs.push('aria-hidden');
307
+ if (props.id) attrs.push(`id={${propExpr(props, 'id')}}`);
308
+ if (props.role) attrs.push(`role={${propExpr(props, 'role')}}`);
309
+ else if (entry.defaultRole) attrs.push(`role="${entry.defaultRole}"`);
310
+ if (entry.ariaLabel) {
311
+ if (props.ariaLabel) attrs.push(`aria-label={${propExpr(props, 'ariaLabel')}}`);
312
+ else if (props.label) attrs.push(`aria-label={${propExpr(props, 'label')}}`);
313
+ else if (props.title) attrs.push(`aria-label={${propExpr(props, 'title')}}`);
314
+ }
198
315
  if (props.name) attrs.push(`name={${propExpr(props, 'name')}}`);
199
316
  if (props.placeholder) attrs.push(`placeholder={${propExpr(props, 'placeholder')}}`);
200
317
  if (entry.inputType) attrs.push(`type={${propExpr(props, 'type') ?? JSON.stringify(entry.inputType)}}`);
201
318
  if (props.required) attrs.push(`required={${propExpr(props, 'required')}}`);
202
319
  if (props.readonly) attrs.push(`readOnly={${propExpr(props, 'readonly')}}`);
203
320
  if (props.enabled) attrs.push(`disabled={${propExpr(props, 'enabled')} === false}`);
204
- if (props.rows) attrs.push(`rows={${propExpr(props, 'rows') ?? '4'}`);
321
+ if (entry.tag === 'img' && props.src) attrs.push(`src={${propExpr(props, 'src')}}`);
322
+ if (entry.tag === 'img' && props.alt) attrs.push(`alt={${propExpr(props, 'alt')}}`);
323
+ if (entry.tag === 'progress' && props.value) attrs.push(`value={${propExpr(props, 'value')}}`);
324
+ if (props.rows) attrs.push(`rows={${propExpr(props, 'rows') ?? '4'}}`);
205
325
  return `${indent(level)}<${entry.tag} ${attrs.join(' ')} />`;
206
326
  }
207
327
 
208
- function emitOpenTag(entry: ElementMapEntry, props: Record<string, IrValue>, tag: string, headingLevel?: number): string {
328
+ function emitOpenTag(
329
+ elementName: string,
330
+ entry: ElementMapEntry,
331
+ props: Record<string, IrValue>,
332
+ tag: string,
333
+ headingLevel?: number,
334
+ ): string {
209
335
  const attrs: string[] = [];
210
336
  if (entry.ariaHidden) attrs.push('aria-hidden');
211
337
  if (props.id) attrs.push(`id={${propExpr(props, 'id')}}`);
@@ -223,7 +349,7 @@ function emitOpenTag(entry: ElementMapEntry, props: Record<string, IrValue>, tag
223
349
  if (entry.tag === 'button' && props.enabled) attrs.push(`disabled={${propExpr(props, 'enabled')} === false}`);
224
350
  if (entry.tag === 'a' && props.href) attrs.push(`href={${propExpr(props, 'href')}}`);
225
351
  if (entry.tag === 'button') attrs.push(`type={${propExpr(props, 'type') ?? '"button"'}}`);
226
- attrs.push(classNameAttr(entry, props, headingLevel));
352
+ attrs.push(classNameAttr(elementName, entry, props, headingLevel));
227
353
  attrs.push(styleAttr(entry, props));
228
354
  return `<${tag} ${attrs.join(' ')}`;
229
355
  }
@@ -286,14 +412,43 @@ function lowerVocabularyElement(
286
412
  return visibilityGuard(emitCheckbox(props, level), props, level);
287
413
  }
288
414
 
415
+ if (node.name === 'Radio') {
416
+ return visibilityGuard(emitRadio(props, level), props, level);
417
+ }
418
+
419
+ if (node.name === 'Switch') {
420
+ return visibilityGuard(emitSwitch(props, level), props, level);
421
+ }
422
+
423
+ if (node.name === 'Icon') {
424
+ return visibilityGuard(emitIcon(node, entry, level), props, level);
425
+ }
426
+
427
+ if (node.name === 'Spinner') {
428
+ const attrs = [
429
+ classNameAttr(node.name, entry, props),
430
+ styleAttr(entry, props),
431
+ 'aria-busy="true"',
432
+ ];
433
+ if (props.id) attrs.push(`id={${propExpr(props, 'id')}}`);
434
+ if (props.role) attrs.push(`role={${propExpr(props, 'role')}}`);
435
+ else if (entry.defaultRole) attrs.push(`role="${entry.defaultRole}"`);
436
+ return visibilityGuard(`${indent(level)}<div ${attrs.join(' ')} />`, props, level);
437
+ }
438
+
439
+ if (entry.fieldContainer) {
440
+ const inner = childContent || `${indent(level + 1)}<div />`;
441
+ return visibilityGuard(emitFieldWrap(inner, props, level), props, level);
442
+ }
443
+
289
444
  if (entry.fieldWrap) {
290
445
  const tag = entry.tag;
291
446
  let inner: string;
292
447
  if (entry.optionsProp && props[entry.optionsProp]) {
293
- const open = emitOpenTag(entry, props, tag);
448
+ const open = emitOpenTag(node.name, entry, props, tag);
294
449
  inner = `${indent(level + 1)}${open}>\n${emitSelectOptions(props, level + 2)}\n${indent(level + 1)}</${tag}>`;
295
450
  } else {
296
- inner = emitVoidInput(entry, props, level + 1);
451
+ inner = emitVoidInput(node.name, entry, props, level + 1);
297
452
  }
298
453
  return visibilityGuard(emitFieldWrap(inner, props, level), props, level);
299
454
  }
@@ -303,7 +458,7 @@ function lowerVocabularyElement(
303
458
  if (typeof levelLit === 'number') {
304
459
  const clamped = Math.min(6, Math.max(1, levelLit));
305
460
  const tag = `h${clamped}`;
306
- const open = emitOpenTag(entry, props, tag, clamped);
461
+ const open = emitOpenTag(node.name, entry, props, tag, clamped);
307
462
  const body = childContent
308
463
  ? `${indent(level)}${open}>\n${childContent}\n${indent(level)}</${tag}>`
309
464
  : `${indent(level)}${open} />`;
@@ -311,17 +466,17 @@ function lowerVocabularyElement(
311
466
  }
312
467
  const levelExpr = propExpr(props, 'level') ?? '2';
313
468
  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>; })()}`
469
+ ? `${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
470
  : '';
316
- return visibilityGuard(inner || `${indent(level)}<h2 ${classNameAttr(entry, props, 2)} ${styleAttr(entry, props)} />`, props, level);
471
+ return visibilityGuard(inner || `${indent(level)}<h2 ${classNameAttr(node.name, entry, props, 2)} ${styleAttr(entry, props)} />`, props, level);
317
472
  }
318
473
 
319
474
  const tag = entry.tag;
320
475
  if (entry.void) {
321
- return visibilityGuard(`${indent(level)}${emitOpenTag(entry, props, tag)} />`, props, level);
476
+ return visibilityGuard(emitVoidInput(node.name, entry, props, level), props, level);
322
477
  }
323
478
 
324
- const open = emitOpenTag(entry, props, tag);
479
+ const open = emitOpenTag(node.name, entry, props, tag);
325
480
  let body: string;
326
481
  if (!childContent && entry.optionsProp && props[entry.optionsProp]) {
327
482
  body = `${indent(level)}${open}>\n${emitSelectOptions(props, level + 1)}\n${indent(level)}</${tag}>`;
@@ -2,10 +2,18 @@ import type { ViewIrDocument } from '../../src/ir/types.js';
2
2
  import { loadReactElementMap } from './elementMap.js';
3
3
  import { collectExtensionComponents, createNodeRenderer } from './lowering/lowerToJsx.js';
4
4
 
5
+ export interface PreviewImportSpecifiers {
6
+ dispatch: string;
7
+ styleHelpers: string;
8
+ contracts: string;
9
+ extensions: string;
10
+ }
11
+
5
12
  export interface RenderReactOptions {
6
13
  componentsImportFrom: string;
7
14
  allowlistExtra?: string[];
8
15
  elementMapPath?: string;
16
+ previewImports?: PreviewImportSpecifiers;
9
17
  }
10
18
 
11
19
  export async function renderReactComponent(
@@ -28,20 +36,27 @@ export async function renderReactComponent(
28
36
  collectExtensionComponents(doc.root, map, allowlistExtra, extensions);
29
37
  const extensionImports = [...extensions].sort();
30
38
 
39
+ const preview = options.previewImports;
40
+ const dispatchModule = preview?.dispatch ?? '../runtime/dispatch.js';
41
+ const styleModule = preview?.styleHelpers ?? '../components/style-helpers.js';
42
+ const contractsModule = preview?.contracts ?? '../contracts';
43
+ const componentsModule = preview?.extensions ?? options.componentsImportFrom;
44
+
31
45
  const imports: string[] = [
32
46
  `import type { ReactElement } from 'react';`,
33
- `import { dispatchAction } from '../runtime/dispatch.js';`,
34
- `import { commonStyle, mapAlign, mapJustify } from '../components/style-helpers.js';`,
35
- `import type { ${doc.viewModel}, ActionDescriptor } from '../contracts';`,
47
+ `import { dispatchAction } from '${dispatchModule}';`,
48
+ `import { commonStyle, mapAlign, mapJustify } from '${styleModule}';`,
49
+ `import type { ${doc.viewModel}, ActionDescriptor } from '${contractsModule}';`,
36
50
  ];
37
51
  if (extensionImports.length > 0) {
38
- imports.push(`import { ${extensionImports.join(', ')} } from '${options.componentsImportFrom}';`);
52
+ imports.push(`import { ${extensionImports.join(', ')} } from '${componentsModule}';`);
39
53
  }
40
54
 
41
- return `/**
42
- * Auto-generated React component from ${doc.source}
43
- * DO NOT EDIT — regenerate with: view-contracts render react
44
- */
55
+ const headerComment = preview
56
+ ? `/**\n * Preview module compiled live from ${doc.source}\n * Same lowering as generated/; not written to disk.\n */`
57
+ : `/**\n * Auto-generated React component from ${doc.source}\n * DO NOT EDIT — regenerate with: view-contracts render react\n */`;
58
+
59
+ return `${headerComment}
45
60
  ${imports.join('\n')}
46
61
 
47
62
  export function ${componentName}({ vm }: { vm: ${doc.viewModel} }): ReactElement {
@@ -1,17 +1,12 @@
1
- import { readFile, writeFile, mkdir, stat, cp, unlink } from 'node:fs/promises';
1
+ import { readFile, writeFile, mkdir, stat, unlink } from 'node:fs/promises';
2
2
  import { resolve } from 'node:path';
3
3
  import type { ViewContractsConfig } from '../../src/config.js';
4
- import { projectRoot } from '../../src/config.js';
5
- import { loadThemeCss } from '../../src/catalog/loadThemeCss.js';
6
- import { packageRoot } from '../../src/lib/packageRoot.js';
4
+ import { projectRoot, requireDesignPaths } from '../../src/config.js';
5
+ import { buildThemeCss } from '../../src/design/buildThemeCss.js';
6
+ import { loadValidateDesign } from '../../src/design/index.js';
7
7
  import { renderTemplate } from '../../src/renderer/template.js';
8
- import { reactRuntimeDir, reactTemplatesDir } from './paths.js';
9
-
10
- const toolchainRoot = packageRoot();
11
-
12
- function toolchainSchemaDir(): string {
13
- return resolve(toolchainRoot, 'src/generated/schema');
14
- }
8
+ import { reactTemplatesDir } from './paths.js';
9
+ import { rewriteExtensionImportsForGenerated, syncGeneratedRuntime } from './syncGeneratedRuntime.js';
15
10
 
16
11
  async function pathExists(target: string): Promise<boolean> {
17
12
  try {
@@ -22,42 +17,22 @@ async function pathExists(target: string): Promise<boolean> {
22
17
  }
23
18
  }
24
19
 
25
- function rewriteSchemaImports(content: string): string {
26
- return content.split('../../../src/generated/schema/').join('./schema/');
27
- }
28
-
29
- async function copySchemaBundle(componentsDir: string): Promise<void> {
30
- const schemaSrc = toolchainSchemaDir();
31
- if (!(await pathExists(schemaSrc))) return;
32
-
33
- const schemaDest = resolve(componentsDir, 'schema');
34
- await cp(schemaSrc, schemaDest, { recursive: true });
35
- for (const name of ['dsl-properties.ts', 'tokens.ts', 'schema-document.ts', 'index.ts'] as const) {
36
- const file = resolve(schemaDest, name);
37
- if (await pathExists(file)) {
38
- await writeFile(file, rewriteSchemaImports(await readFile(file, 'utf-8')), 'utf-8');
39
- }
40
- }
41
- }
42
-
43
- async function copyStyleBundle(componentsDir: string): Promise<void> {
44
- const runtimeSrc = reactRuntimeDir();
45
- const style = rewriteSchemaImports(await readFile(resolve(runtimeSrc, 'style.ts'), 'utf-8'));
46
- await writeFile(resolve(componentsDir, 'style.ts'), style, 'utf-8');
47
-
48
- const types = rewriteSchemaImports(await readFile(resolve(runtimeSrc, 'types.ts'), 'utf-8'));
49
- await writeFile(resolve(componentsDir, 'types.ts'), types, 'utf-8');
50
- }
51
-
52
- async function writeThemeCss(root: string, themeOut: string, templatesDir?: string): Promise<void> {
53
- const runtimeTheme = resolve(reactRuntimeDir(), 'theme.css');
54
- let css = await loadThemeCss(runtimeTheme);
55
- const extensionsCss = resolve(root, 'src/theme.extensions.css');
56
- if (await pathExists(extensionsCss)) {
57
- css += `\n\n${await readFile(extensionsCss, 'utf-8')}`;
58
- }
20
+ async function writeThemeCss(
21
+ configPath: string,
22
+ themeOut: string,
23
+ config: ViewContractsConfig,
24
+ templatesDir?: string,
25
+ ): Promise<void> {
26
+ const css = await buildThemeCss(config, configPath);
59
27
  const content = await renderTemplate(reactTemplatesDir(templatesDir), 'theme.css.hbs', { themeCss: css });
60
28
  await writeFile(themeOut, content, 'utf-8');
29
+
30
+ const { tokensPath, themePath } = requireDesignPaths(config);
31
+ const { tokenIR, themeIR } = await loadValidateDesign(tokensPath, themePath);
32
+ const themeDir = resolve(config.generated.reactDir, '../theme');
33
+ await mkdir(themeDir, { recursive: true });
34
+ await writeFile(resolve(themeDir, 'tokens.json'), `${JSON.stringify(tokenIR, null, 2)}\n`, 'utf-8');
35
+ await writeFile(resolve(themeDir, 'theme.json'), `${JSON.stringify(themeIR, null, 2)}\n`, 'utf-8');
61
36
  }
62
37
 
63
38
  async function removeStaleGeneratedFiles(componentsDir: string): Promise<void> {
@@ -76,32 +51,16 @@ export async function renderReactRuntime(configPath: string, config: ViewContrac
76
51
  ? resolve(root, config.rendererTemplatesDir)
77
52
  : undefined;
78
53
  const templates = reactTemplatesDir(templatesDir);
79
- const componentsDir = resolve(config.generated.reactDir, '../components');
80
- const runtimeDir = resolve(config.generated.reactDir, '../runtime');
81
54
  const srcComponentsDir = resolve(root, 'src/components');
82
55
 
83
- await mkdir(componentsDir, { recursive: true });
84
- await mkdir(runtimeDir, { recursive: true });
85
-
56
+ const { componentsDir, runtimeDir } = await syncGeneratedRuntime(configPath, config, templatesDir);
86
57
  await removeStaleGeneratedFiles(componentsDir);
87
- await copySchemaBundle(componentsDir);
88
- await copyStyleBundle(componentsDir);
89
- await writeFile(
90
- resolve(componentsDir, 'style-helpers.ts'),
91
- await renderTemplate(templates, 'style-helpers.ts.hbs', {}),
92
- 'utf-8',
93
- );
94
- await writeFile(
95
- resolve(componentsDir, 'index.ts'),
96
- await renderTemplate(templates, 'components-index.ts.hbs', {}),
97
- 'utf-8',
98
- );
99
58
 
100
59
  for (const name of ['extensions.tsx', 'sample-types.ts'] as const) {
101
60
  const src = resolve(srcComponentsDir, name);
102
61
  let body = await readFile(src, 'utf-8');
103
- if (name === 'sample-types.ts') {
104
- body = body.replace("from './core.js'", "from './types.js'");
62
+ if (name === 'extensions.tsx') {
63
+ body = rewriteExtensionImportsForGenerated(body);
105
64
  }
106
65
  await writeFile(resolve(componentsDir, name), body, 'utf-8');
107
66
  }
@@ -117,17 +76,7 @@ export async function renderReactRuntime(configPath: string, config: ViewContrac
117
76
  'utf-8',
118
77
  );
119
78
 
120
- await writeThemeCss(root, resolve(config.generated.reactDir, '../theme.css'), templatesDir);
121
- await writeFile(
122
- resolve(runtimeDir, 'types.ts'),
123
- await renderTemplate(templates, 'runtime-types.ts.hbs', {}),
124
- 'utf-8',
125
- );
126
- await writeFile(
127
- resolve(runtimeDir, 'dispatch.ts'),
128
- await renderTemplate(templates, 'dispatch.ts.hbs', {}),
129
- 'utf-8',
130
- );
79
+ await writeThemeCss(configPath, resolve(config.generated.reactDir, '../theme.css'), config, templatesDir);
131
80
 
132
81
  console.log(` components → ${componentsDir}`);
133
82
  console.log(` theme → ${resolve(config.generated.reactDir, '../theme.css')}`);
@@ -10,7 +10,7 @@ react:
10
10
  screen:
11
11
  pattern: '{{projectRoot}}/generated/react/{{screenName}}.generated.tsx'
12
12
  preview:
13
- entry: views/preview.entry.tsx
13
+ entry: .vite/preview-main.tsx
14
14
  generatedEntry: src/main.tsx
15
15
 
16
16
  swiftui:
@@ -1,15 +1,16 @@
1
1
  import type { CSSProperties } from 'react';
2
- import type { Align, Clip, ContentAlign, FontWeight, Justify, Shadow } from '../../../src/generated/schema/tokens.js';
3
- import type { CommonProps } from './types.js';
2
+ import type { LoweredStyleInput } from './types.js';
4
3
 
5
- export function mapAlign(value?: Align): string | undefined {
4
+ /** Map flex alignment shorthand (from lowering) to CSS align-items / align-self values. */
5
+ export function mapAlign(value?: string): string | undefined {
6
6
  if (!value) return undefined;
7
7
  if (value === 'start') return 'flex-start';
8
8
  if (value === 'end') return 'flex-end';
9
9
  return value;
10
10
  }
11
11
 
12
- export function mapJustify(value?: Justify | string): string | undefined {
12
+ /** Map flex justification shorthand (from lowering) to CSS justify-content values. */
13
+ export function mapJustify(value?: string): string | undefined {
13
14
  if (!value) return undefined;
14
15
  if (value === 'start') return 'flex-start';
15
16
  if (value === 'end') return 'flex-end';
@@ -23,13 +24,7 @@ function len(value: number): string {
23
24
  return `${value}px`;
24
25
  }
25
26
 
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
- const CONTENT_ALIGN: Record<ContentAlign, string> = {
27
+ const PLACE_ITEMS: Record<string, string> = {
33
28
  topStart: 'start start',
34
29
  top: 'center start',
35
30
  topEnd: 'end start',
@@ -41,18 +36,12 @@ const CONTENT_ALIGN: Record<ContentAlign, string> = {
41
36
  bottomEnd: 'end end',
42
37
  };
43
38
 
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
39
  function fontSizeStyle(size: number | string | undefined): string | undefined {
51
40
  if (size === undefined) return undefined;
52
41
  return typeof size === 'number' ? len(size) : size;
53
42
  }
54
43
 
55
- export function commonStyle(props: CommonProps): CSSProperties {
44
+ export function commonStyle(props: LoweredStyleInput): CSSProperties {
56
45
  const style: CSSProperties = {};
57
46
  if (typeof props.width === 'number') style.width = len(props.width);
58
47
  if (typeof props.minWidth === 'number') style.minWidth = len(props.minWidth);
@@ -63,34 +52,54 @@ export function commonStyle(props: CommonProps): CSSProperties {
63
52
  if (props.aspectRatio !== undefined) style.aspectRatio = String(props.aspectRatio);
64
53
  if (typeof props.weight === 'number') style.flexGrow = props.weight;
65
54
  if (typeof props.padding === 'number') style.padding = len(props.padding);
66
- if (typeof (props as { margin?: number }).margin === 'number') style.margin = len((props as { margin: number }).margin);
55
+ if (typeof props.margin === 'number') style.margin = len(props.margin);
67
56
  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
- if (props.textAlign) style.textAlign = props.textAlign === 'start' ? 'left' : props.textAlign === 'end' ? 'right' : 'center';
77
- if (props.contentAlign) style.placeItems = CONTENT_ALIGN[props.contentAlign];
78
- if ((props as { overflow?: string }).overflow) style.overflow = (props as { overflow: string }).overflow;
79
- if (props.selfAlign) style.alignSelf = props.selfAlign === 'start' ? 'flex-start' : props.selfAlign === 'end' ? 'flex-end' : props.selfAlign;
57
+ if (props.textAlign) {
58
+ style.textAlign = props.textAlign === 'start' ? 'left' : props.textAlign === 'end' ? 'right' : props.textAlign as CSSProperties['textAlign'];
59
+ }
60
+ if (props.contentAlign) style.placeItems = PLACE_ITEMS[props.contentAlign];
61
+ if (props.overflow) style.overflow = props.overflow as CSSProperties['overflow'];
62
+ if (props.selfAlign) {
63
+ style.alignSelf = props.selfAlign === 'start' ? 'flex-start' : props.selfAlign === 'end' ? 'flex-end' : props.selfAlign;
64
+ }
80
65
  const fontSize = fontSizeStyle(props.size);
81
66
  if (fontSize) style.fontSize = fontSize;
82
67
  const fontWeight = typeof props.weight === 'string' ? props.weight : props.fontWeight;
83
- if (fontWeight) style.fontWeight = fontWeight as FontWeight;
68
+ if (fontWeight) style.fontWeight = fontWeight;
84
69
  return style;
85
70
  }
86
71
 
87
- export function isHidden(props: CommonProps): boolean {
88
- return props.visible === false;
72
+ /** Preview vocabulary helper lowered production JSX does not use visible. */
73
+ export function isHidden(props: { visible?: boolean; hidden?: boolean }): boolean {
74
+ return props.hidden === true || props.visible === false;
89
75
  }
90
76
 
91
- /** Sample/extension helper tone is not in ui-dsl.schema.json */
77
+ /** Sample/extension helper for preview tone class names. */
92
78
  export function toneClass(tone: string | undefined): string {
93
79
  return tone ? `ui-tone-${tone}` : 'ui-tone-default';
94
80
  }
95
81
 
96
- export type { Clip, ContentAlign, Shadow };
82
+ function componentToKebab(name: string): string {
83
+ return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
84
+ }
85
+
86
+ const DEFAULT_VARIANT_ALIASES = new Set(['default', 'muted']);
87
+
88
+ /** Resolve theme variant name from variant or legacy tone prop. */
89
+ export function resolveExtensionVariant(variant?: string, tone?: string): string {
90
+ const raw = variant ?? tone;
91
+ if (!raw || DEFAULT_VARIANT_ALIASES.has(raw)) return 'default';
92
+ return raw;
93
+ }
94
+
95
+ /** Build vc-* base + --variant modifier for themed extension components. */
96
+ export function extensionClassName(
97
+ component: string,
98
+ options?: { variant?: string; tone?: string; extra?: string | false },
99
+ ): string {
100
+ const base = `vc-${componentToKebab(component)}`;
101
+ const variant = resolveExtensionVariant(options?.variant, options?.tone);
102
+ const parts = [base, `${base}--${variant}`];
103
+ if (options?.extra) parts.push(options.extra);
104
+ return parts.filter(Boolean).join(' ');
105
+ }