view-contracts 0.3.4 → 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.
Files changed (45) hide show
  1. package/README.md +10 -3
  2. package/cli-contract.yaml +41 -1
  3. package/dist/preview-style-helpers.js +2 -0
  4. package/dist/preview-style-helpers.js.map +7 -0
  5. package/dist/preview.mjs +153 -0
  6. package/dist/preview.mjs.map +7 -0
  7. package/dist/view-contracts.bundle.mjs +402 -237
  8. package/dist/view-contracts.bundle.mjs.map +4 -4
  9. package/docs/cli-reference.md +37 -1
  10. package/package.json +28 -1
  11. package/renderers/compose/renderRuntime.ts +40 -0
  12. package/renderers/react/defaults/theme.yaml +23 -0
  13. package/renderers/react/defaults/tokens.yaml +15 -0
  14. package/renderers/react/elementMap.ts +30 -6
  15. package/renderers/react/elements.yaml +17 -5
  16. package/renderers/react/lowering/foldStyle.ts +13 -1
  17. package/renderers/react/lowering/lowerToJsx.ts +58 -40
  18. package/renderers/react/paths.ts +1 -10
  19. package/renderers/react/preview/components.ts +23 -0
  20. package/renderers/react/preview/index.ts +3 -0
  21. package/renderers/react/preview/start.tsx +42 -0
  22. package/renderers/react/preview/style-helpers.ts +5 -0
  23. package/renderers/react/preview/vocabulary.tsx +75 -0
  24. package/renderers/react/renderComponents.ts +169 -14
  25. package/renderers/react/runtime/theme.css +9 -17
  26. package/renderers/react/style/foldLiterals.ts +11 -0
  27. package/renderers/swiftui/README.md +48 -10
  28. package/renderers/swiftui/elementMap.ts +45 -0
  29. package/renderers/swiftui/elements.yaml +82 -0
  30. package/renderers/swiftui/index.ts +5 -0
  31. package/renderers/swiftui/lowering/lowerToSwiftUI.ts +542 -0
  32. package/renderers/swiftui/lowering/modifiers.ts +142 -0
  33. package/renderers/swiftui/renderComponents.ts +79 -0
  34. package/renderers/swiftui/renderRuntime.ts +91 -0
  35. package/renderers/swiftui/renderTheme.ts +208 -0
  36. package/spec/ui-dsl.linter-rules.json +0 -7
  37. package/spec/ui-dsl.meta.json +5 -0
  38. package/spec/ui-dsl.schema.json +333 -259
  39. package/src/generated/schema/allowed-components.ts +0 -36
  40. package/src/generated/schema/dsl-enums.ts +0 -19
  41. package/src/generated/schema/dsl-properties.ts +0 -64
  42. package/src/generated/schema/index.ts +0 -4
  43. package/src/generated/schema/lowering-style-properties.ts +0 -35
  44. package/src/generated/schema/react-renderer-element-config.ts +0 -27
  45. package/src/generated/schema/schema-document.ts +0 -33
@@ -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.3.3
5
+ **Version:** 0.3.5
6
6
 
7
7
  ## Table of Contents
8
8
 
@@ -13,6 +13,7 @@ Contract-first UI design toolchain. Compiles restricted view-contract TSX to lan
13
13
  - [build](#view-contracts-build)
14
14
  - [contracts.generate](#view-contracts-contracts-generate)
15
15
  - [contracts.check](#view-contracts-contracts-check)
16
+ - [preview](#view-contracts-preview)
16
17
 
17
18
  ---
18
19
 
@@ -200,3 +201,38 @@ view-contracts contracts check -c view-contracts.config.yaml
200
201
  **Exit 1:** Lint failed.
201
202
 
202
203
  ---
204
+
205
+ ### preview
206
+
207
+ Start DSL preview HTTP server.
208
+
209
+ Compiles views/*.view.tsx on demand and serves a React preview over HTTP. No Vite setup required in the consumer project.
210
+
211
+ **Usage:**
212
+
213
+ ```
214
+ view-contracts preview
215
+ ```
216
+ ```
217
+ view-contracts preview -c view-contracts.config.yaml
218
+ ```
219
+ ```
220
+ view-contracts preview --port 3000 --screen AdminDashboard
221
+ ```
222
+
223
+ #### Options
224
+
225
+ | Option | Aliases | Required | Default | Description |
226
+ |---|---|---|---|---|
227
+ | `--config` | -c | No | | Path to view-contracts.config.yaml (discovered upward from cwd when omitted). |
228
+ | `--port` | -p | No | | HTTP listen port. |
229
+ | `--host` | | No | | HTTP listen host. |
230
+ | `--screen` | | No | | Screen name from view-bindings.yaml (default first react target). |
231
+
232
+ #### Exit Codes
233
+
234
+ **Exit 0:** Server stopped normally.
235
+
236
+ **Exit 1:** Startup or compile failed.
237
+
238
+ ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "view-contracts",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "description": "Contract-first UI design toolchain — restricted TSX to View IR to platform renderers",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -9,9 +9,18 @@
9
9
  "bin": {
10
10
  "view-contracts": "dist/view-contracts.bundle.mjs"
11
11
  },
12
+ "exports": {
13
+ ".": "./dist/view-contracts.bundle.mjs",
14
+ "./preview": "./dist/preview.mjs",
15
+ "./package.json": "./package.json"
16
+ },
12
17
  "files": [
13
18
  "dist/view-contracts.bundle.mjs",
14
19
  "dist/view-contracts.bundle.mjs.map",
20
+ "dist/preview.mjs",
21
+ "dist/preview.mjs.map",
22
+ "dist/preview-style-helpers.js",
23
+ "dist/preview-style-helpers.js.map",
15
24
  "renderers/react/runtime",
16
25
  "renderers/react/defaults",
17
26
  "src/generated/schema",
@@ -36,6 +45,7 @@
36
45
  "bundle": "node esbuild.bundle.mjs",
37
46
  "bundle:min": "node esbuild.bundle.mjs --minify",
38
47
  "build:views": "tsx src/cli.ts build -c examples/admin-dashboard/view-contracts.config.yaml",
48
+ "render:swiftui-prototype": "tsx scripts/render-swiftui-prototype.ts",
39
49
  "build:example": "vite build --config examples/admin-dashboard/.vite/vite.config.ts",
40
50
  "compile": "tsx src/cli.ts compile -c examples/admin-dashboard/view-contracts.config.yaml",
41
51
  "render:react": "tsx src/cli.ts render react -c examples/admin-dashboard/view-contracts.config.yaml",
@@ -74,8 +84,25 @@
74
84
  "engines": {
75
85
  "node": ">=20.0.0"
76
86
  },
87
+ "peerDependencies": {
88
+ "vite": "^5.0.0 || ^6.0.0",
89
+ "react": "^18.0.0 || ^19.0.0",
90
+ "react-dom": "^18.0.0 || ^19.0.0"
91
+ },
92
+ "peerDependenciesMeta": {
93
+ "vite": {
94
+ "optional": true
95
+ },
96
+ "react": {
97
+ "optional": true
98
+ },
99
+ "react-dom": {
100
+ "optional": true
101
+ }
102
+ },
77
103
  "dependencies": {
78
104
  "commander": "^14.0.3",
105
+ "esbuild": "^0.28.0",
79
106
  "handlebars": "^4.7.9",
80
107
  "js-yaml": "^4.1.1",
81
108
  "micro-contracts": "^0.17.10",
@@ -0,0 +1,40 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import type { ViewBindingsFile, ViewContractsConfig } from '../../src/config.js';
4
+ import { packageRoot } from '../../src/lib/packageRoot.js';
5
+ import { renderTemplate } from '../../src/renderer/template.js';
6
+
7
+ function composeTemplatesDir(): string {
8
+ return resolve(packageRoot(), 'renderers/compose/templates');
9
+ }
10
+
11
+ export async function renderComposeRuntime(
12
+ config: ViewContractsConfig,
13
+ bindings: ViewBindingsFile,
14
+ ): Promise<void> {
15
+ const composeDir = resolve(config.generated.reactDir, '../compose');
16
+ await mkdir(composeDir, { recursive: true });
17
+
18
+ await writeFile(
19
+ resolve(composeDir, 'VcTheme.kt'),
20
+ await renderTemplate(composeTemplatesDir(), 'VcTheme.kt.hbs', {
21
+ packageName: 'generated.compose',
22
+ }),
23
+ 'utf-8',
24
+ );
25
+
26
+ for (const view of bindings.views) {
27
+ if (view.targets.includes('compose')) {
28
+ const out = resolve(composeDir, `${view.screenName}Screen.kt`);
29
+ await writeFile(
30
+ out,
31
+ await renderTemplate(composeTemplatesDir(), 'Screen.kt.hbs', {
32
+ screenName: view.screenName,
33
+ packageName: 'generated.compose',
34
+ }),
35
+ 'utf-8',
36
+ );
37
+ console.log(` compose → ${out}`);
38
+ }
39
+ }
40
+ }
@@ -6,6 +6,29 @@ defaults:
6
6
  radius: "{ui.space.radius-md}"
7
7
  padding: "{ui.space.button-padding}"
8
8
  fontWeight: "{ui.layout.font-bold}"
9
+ TextInput:
10
+ background: "{ui.surface}"
11
+ foreground: "{ui.text}"
12
+ radius: "{ui.space.radius-md}"
13
+ padding: "{ui.space.input-padding}"
14
+ border: "{ui.layout.border-1}"
15
+ borderColor: "{ui.border}"
16
+ minHeight: "{ui.space.input-min-height}"
17
+ TextArea:
18
+ background: "{ui.surface}"
19
+ foreground: "{ui.text}"
20
+ radius: "{ui.space.radius-md}"
21
+ padding: "{ui.space.textarea-padding}"
22
+ border: "{ui.layout.border-1}"
23
+ borderColor: "{ui.border}"
24
+ Select:
25
+ background: "{ui.surface}"
26
+ foreground: "{ui.text}"
27
+ radius: "{ui.space.radius-md}"
28
+ padding: "{ui.space.select-padding}"
29
+ border: "{ui.layout.border-1}"
30
+ borderColor: "{ui.border}"
31
+ minHeight: "{ui.space.input-min-height}"
9
32
  variants:
10
33
  Button:
11
34
  primary:
@@ -61,6 +61,9 @@ ui:
61
61
  font-bold:
62
62
  $type: layout
63
63
  $value: "700"
64
+ border-1:
65
+ $type: layout
66
+ $value: "1px solid"
64
67
  space:
65
68
  radius-md:
66
69
  $type: dimension
@@ -68,3 +71,15 @@ ui:
68
71
  button-padding:
69
72
  $type: dimension
70
73
  $value: "10px 14px"
74
+ input-padding:
75
+ $type: dimension
76
+ $value: "9px 12px"
77
+ input-min-height:
78
+ $type: dimension
79
+ $value: "42px"
80
+ select-padding:
81
+ $type: dimension
82
+ $value: "9px 36px 9px 12px"
83
+ textarea-padding:
84
+ $type: dimension
85
+ $value: "10px 12px"
@@ -1,15 +1,39 @@
1
1
  import { resolve } from 'node:path';
2
- import { ALLOWED_COMPONENTS } from '../../src/generated/schema/allowed-components.js';
3
- import type { ReactElementMapFile, ReactRendererElementConfig } from '../../src/generated/schema/react-renderer-element-config.js';
2
+ import { ALLOWED_COMPONENTS } from '../../src/schema/dsl-catalog.js';
3
+ import { ELEMENT_CATALOG } from '../../src/schema/dsl-catalog.js';
4
4
  import { packageRoot } from '../../src/lib/packageRoot.js';
5
5
  import {
6
6
  loadYamlFile,
7
7
  validateMapEntryFields,
8
8
  validateVocabularyElementNames,
9
9
  } from '../../src/renderer/elementMapValidation.js';
10
- import { loadUiDslSchema } from '../../src/schema/loadSchema.js';
11
10
 
12
- export type { ReactElementMapFile, ReactRendererElementConfig };
11
+ /** Shape of entries in renderers/react/elements.yaml (renderer-owned SSoT). */
12
+ export interface ReactRendererElementConfig {
13
+ tag: string;
14
+ className?: string;
15
+ ariaLabel?: boolean;
16
+ ariaHidden?: boolean;
17
+ flex?: 'row' | 'column';
18
+ wrapProp?: string;
19
+ flexGrowProp?: string;
20
+ void?: boolean;
21
+ actionEvent?: 'onClick' | 'onSubmit';
22
+ fieldWrap?: boolean;
23
+ inputType?: string;
24
+ sizeClass?: string;
25
+ optionsProp?: string;
26
+ checkbox?: boolean;
27
+ fieldContainer?: boolean;
28
+ scrollContainer?: boolean;
29
+ defaultRole?: string;
30
+ tableCell?: boolean;
31
+ }
32
+
33
+ export interface ReactElementMapFile {
34
+ elements: Record<string, ReactRendererElementConfig>;
35
+ }
36
+
13
37
  export type ReactElementMap = ReactElementMapFile;
14
38
  export type ElementMapEntry = ReactRendererElementConfig;
15
39
 
@@ -41,8 +65,8 @@ export function isLowerableVocabularyElement(
41
65
  }
42
66
 
43
67
  export async function validateElementMapCoversVocabulary(customPath?: string): Promise<void> {
44
- const [schema, map] = await Promise.all([loadUiDslSchema(), loadReactElementMap(customPath)]);
45
- const missing = Object.keys(schema.elements).filter((name) => !(name in map.elements));
68
+ const map = await loadReactElementMap(customPath);
69
+ const missing = Object.keys(ELEMENT_CATALOG).filter((name) => !(name in map.elements));
46
70
  if (missing.length > 0) {
47
71
  throw new Error(
48
72
  `${SOURCE_LABEL}: ${missing.length} DSL element(s) have no React HTML map yet: ${missing.join(', ')}`,
@@ -45,11 +45,6 @@ elements:
45
45
  tag: hr
46
46
  className: vc-divider
47
47
  void: true
48
- Heading:
49
- tag: dynamic-heading
50
- className: vc-heading
51
- levelProp: level
52
- defaultLevel: 2
53
48
  Text:
54
49
  tag: p
55
50
  className: vc-text
@@ -140,3 +135,20 @@ elements:
140
135
  tag: li
141
136
  className: vc-list-item
142
137
  defaultRole: listitem
138
+ Table:
139
+ tag: table
140
+ className: vc-table
141
+ TableHeader:
142
+ tag: thead
143
+ className: vc-table-header
144
+ TableColumn:
145
+ tag: th
146
+ className: vc-table-column
147
+ tableCell: true
148
+ TableRow:
149
+ tag: tr
150
+ className: vc-table-row
151
+ TableCell:
152
+ tag: td
153
+ className: vc-table-cell
154
+ tableCell: true
@@ -2,7 +2,7 @@
2
2
  * Compile-time style folding for React lowering.
3
3
  * Production generated JSX must not call runtime style helpers.
4
4
  */
5
- import { LOWERING_STYLE_PROPERTIES } from '../../../src/generated/schema/lowering-style-properties.js';
5
+ import { LOWERING_STYLE_PROPERTIES } from '../../../src/schema/dsl-catalog.js';
6
6
  import type { IrLiteral, IrValue } from '../../../src/ir/types.js';
7
7
  import type { ElementMapEntry } from '../elementMap.js';
8
8
  import { foldLiteralsToCss, foldedStyleToJsx, type FoldedCss } from '../style/foldLiterals.js';
@@ -57,6 +57,18 @@ export function styleAttrForElement(entry: ElementMapEntry, props: Record<string
57
57
  let folded = foldLiteralsToCss(literals);
58
58
  folded = { ...folded, ...foldFlexContainer(entry) };
59
59
 
60
+ if (entry.tableCell) {
61
+ if ('alignItems' in folded) {
62
+ const ai = String(folded.alignItems);
63
+ folded.textAlign = ai === 'flex-end' ? 'right' : ai === 'center' ? 'center' : 'left';
64
+ delete folded.alignItems;
65
+ }
66
+ if ('width' in folded && !('flex' in folded)) {
67
+ folded.flexShrink = 0;
68
+ folded.flexGrow = 0;
69
+ }
70
+ }
71
+
60
72
  if (entry.scrollContainer) {
61
73
  const scrollLit = propLiteral(props, 'scroll');
62
74
  const axisLit = propLiteral(props, 'axis');
@@ -4,12 +4,14 @@ import type {
4
4
  IrAction,
5
5
  IrBinding,
6
6
  IrComponentNode,
7
+ IrConcat,
7
8
  IrFragmentNode,
8
9
  IrLiteral,
9
10
  IrMapNode,
10
11
  IrNode,
11
12
  IrTextNode,
12
13
  IrValue,
14
+ IrViewRefNode,
13
15
  } from '../../../src/ir/types.js';
14
16
 
15
17
  function indent(level: number): string {
@@ -21,6 +23,14 @@ function bindingExpr(binding: IrBinding, vmRef = 'vm'): string {
21
23
  return binding.path ? `${root}.${binding.path}` : root;
22
24
  }
23
25
 
26
+ function concatSegments(concat: IrConcat): string {
27
+ return concat.parts.map(p => typeof p === 'string' ? p : `\${${bindingExpr(p)}}`).join('');
28
+ }
29
+
30
+ function renderConcat(concat: IrConcat): string {
31
+ return `\`${concatSegments(concat)}\``;
32
+ }
33
+
24
34
  function renderAction(action: IrAction, cast = false): string {
25
35
  const suffix = cast ? ' as ActionDescriptor' : '';
26
36
  if (!action.params || Object.keys(action.params).length === 0) {
@@ -61,6 +71,7 @@ export function renderValue(value: IrValue, level: number, castActions = false):
61
71
  if (value.kind === 'binding') return bindingExpr(value as IrBinding);
62
72
  if (value.kind === 'action') return renderAction(value as IrAction, castActions);
63
73
  if (value.kind === 'literal') return renderLiteralValue((value as IrLiteral).value, level, castActions);
74
+ if (value.kind === 'concat') return renderConcat(value as IrConcat);
64
75
  }
65
76
  return 'undefined';
66
77
  }
@@ -107,6 +118,10 @@ function variantClassSuffix(elementName: string, props: Record<string, IrValue>)
107
118
  if (typeof variantLit === 'string') {
108
119
  return ` vc-${componentToKebab(elementName)}--${variantLit}`;
109
120
  }
121
+ const variantVal = props.variant;
122
+ if (variantVal && typeof variantVal === 'object' && 'kind' in variantVal && variantVal.kind === 'concat') {
123
+ return ` vc-${componentToKebab(elementName)}--${concatSegments(variantVal as IrConcat)}`;
124
+ }
110
125
  const variant = propExpr(props, 'variant');
111
126
  if (variant) return ` vc-${componentToKebab(elementName)}--\${${variant}}`;
112
127
  return undefined;
@@ -116,19 +131,8 @@ function classNameAttr(
116
131
  elementName: string,
117
132
  entry: ElementMapEntry,
118
133
  props: Record<string, IrValue>,
119
- headingLevel?: number,
120
134
  ): string {
121
135
  const base = entry.className ?? '';
122
- if (entry.tag === 'dynamic-heading' && headingLevel !== undefined) {
123
- const levelClass = `${base} vc-heading-${headingLevel}`;
124
- const variantSuffix = variantClassSuffix(elementName, props);
125
- if (!variantSuffix) return `className="${levelClass}"`;
126
- const variantLit = propLiteral(props, 'variant');
127
- if (typeof variantLit === 'string') {
128
- return `className="${levelClass}${variantSuffix}"`;
129
- }
130
- return `className={\`${levelClass}${variantSuffix}\`}`;
131
- }
132
136
  const variantSuffix = variantClassSuffix(elementName, props);
133
137
  if (variantSuffix) {
134
138
  const variantLit = propLiteral(props, 'variant');
@@ -279,7 +283,6 @@ function emitOpenTag(
279
283
  entry: ElementMapEntry,
280
284
  props: Record<string, IrValue>,
281
285
  tag: string,
282
- headingLevel?: number,
283
286
  ): string {
284
287
  const attrs: string[] = [];
285
288
  if (entry.ariaHidden) attrs.push('aria-hidden');
@@ -298,7 +301,7 @@ function emitOpenTag(
298
301
  if (entry.tag === 'button' && props.enabled) attrs.push(`disabled={${propExpr(props, 'enabled')} === false}`);
299
302
  if (entry.tag === 'a' && props.href) attrs.push(`href={${propExpr(props, 'href')}}`);
300
303
  if (entry.tag === 'button') attrs.push(`type={${propExpr(props, 'type') ?? '"button"'}}`);
301
- attrs.push(classNameAttr(elementName, entry, props, headingLevel));
304
+ attrs.push(classNameAttr(elementName, entry, props));
302
305
  pushStyleAttr(attrs, entry, props);
303
306
  return `<${tag} ${attrs.join(' ')}`;
304
307
  }
@@ -309,7 +312,7 @@ function visibilityGuard(inner: string, props: Record<string, IrValue>, level: n
309
312
  return `${indent(level)}{${vis} !== false ? (\n${inner}\n${indent(level)}) : null}`;
310
313
  }
311
314
 
312
- type NodeRenderer = (node: IrComponentNode, level: number, itemScope?: string) => string;
315
+ type NodeRenderer = (node: IrNode, level: number, itemScope?: string) => string;
313
316
 
314
317
  function renderChildren(
315
318
  children: IrNode[],
@@ -331,15 +334,14 @@ function renderChildren(
331
334
  if (child.kind === 'fragment') {
332
335
  return renderChildren(child.children, level, renderNested, itemScope);
333
336
  }
334
- if (child.kind === 'component') return renderNested(child, level, itemScope);
335
- return '';
337
+ return renderNested(child, level, itemScope);
336
338
  }).filter(Boolean);
337
339
  return parts.join('\n');
338
340
  }
339
341
 
340
342
  function renderMap(node: IrMapNode, level: number, renderNested: NodeRenderer): string {
341
343
  const item = node.item;
342
- let body = renderNested(node.body as IrComponentNode, level + 1, item);
344
+ let body = renderNested(node.body, level + 1, item);
343
345
  if (node.key) {
344
346
  const keyAttr = ` key={${bindingExpr(node.key, item)}}`;
345
347
  body = body.replace(/^( +)<(\w+)/, `$1<$2${keyAttr}`);
@@ -402,27 +404,6 @@ function lowerVocabularyElement(
402
404
  return visibilityGuard(emitFieldWrap(inner, props, level), props, level);
403
405
  }
404
406
 
405
- if (entry.tag === 'dynamic-heading') {
406
- const levelLit = propLiteral(props, 'level');
407
- if (typeof levelLit === 'number') {
408
- const clamped = Math.min(6, Math.max(1, levelLit));
409
- const tag = `h${clamped}`;
410
- const open = emitOpenTag(node.name, entry, props, tag, clamped);
411
- const body = childContent
412
- ? `${indent(level)}${open}>\n${childContent}\n${indent(level)}</${tag}>`
413
- : `${indent(level)}${open} />`;
414
- return visibilityGuard(body, props, level);
415
- }
416
- const levelExpr = propExpr(props, 'level') ?? '2';
417
- const foldedStyle = foldedStyleToJsx(foldLiteralsToCss(collectLiteralStyleProps(props)));
418
- const styleFragment = foldedStyle ? ` ${foldedStyle}` : '';
419
- const inner = childContent
420
- ? `${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}\`}${styleFragment}>{/* children below */}</Tag>; })()}`
421
- : '';
422
- const fallbackStyle = styleAttrForElement(entry, props);
423
- return visibilityGuard(inner || `${indent(level)}<h2 ${classNameAttr(node.name, entry, props, 2)}${fallbackStyle ? ` ${fallbackStyle}` : ''} />`, props, level);
424
- }
425
-
426
407
  const tag = entry.tag;
427
408
  if (entry.void) {
428
409
  return visibilityGuard(emitVoidInput(node.name, entry, props, level), props, level);
@@ -440,6 +421,22 @@ function lowerVocabularyElement(
440
421
  return visibilityGuard(body, props, level);
441
422
  }
442
423
 
424
+ function renderViewRefProps(props: Record<string, IrValue>, itemScope?: string): string {
425
+ const vmRef = itemScope ?? 'vm';
426
+ return Object.entries(props).map(([key, value]) => {
427
+ if (typeof value === 'object' && value !== null && 'kind' in value && value.kind === 'binding') {
428
+ return `${key}={${bindingExpr(value as IrBinding, vmRef)}}`;
429
+ }
430
+ return `${key}={${renderValue(value, 0)}}`;
431
+ }).join(' ');
432
+ }
433
+
434
+ function renderViewRefElement(node: IrViewRefNode, level: number, itemScope?: string): string {
435
+ const props = renderViewRefProps(node.props, itemScope);
436
+ const propStr = props ? ` ${props}` : '';
437
+ return `${indent(level)}<${node.name}${propStr} />`;
438
+ }
439
+
443
440
  function renderExtensionProps(props: Record<string, IrValue>, itemScope?: string): string {
444
441
  const vmRef = itemScope ?? 'vm';
445
442
  return Object.entries(props).map(([key, value]) => {
@@ -473,6 +470,21 @@ function isVocabulary(name: string, map: ReactElementMap, allowlistExtra: string
473
470
  return name in map.elements && !allowlistExtra.includes(name);
474
471
  }
475
472
 
473
+ export function collectViewRefComponents(node: IrNode, names: Set<string>): void {
474
+ if (node.kind === 'viewRef') {
475
+ names.add(node.name);
476
+ return;
477
+ }
478
+ if (node.kind === 'component') {
479
+ for (const child of node.children) collectViewRefComponents(child, names);
480
+ return;
481
+ }
482
+ if (node.kind === 'map') collectViewRefComponents(node.body, names);
483
+ if (node.kind === 'fragment') {
484
+ for (const child of node.children) collectViewRefComponents(child, names);
485
+ }
486
+ }
487
+
476
488
  export function collectExtensionComponents(
477
489
  node: IrNode,
478
490
  map: ReactElementMap,
@@ -493,12 +505,18 @@ export function collectExtensionComponents(
493
505
  export function createNodeRenderer(
494
506
  map: ReactElementMap,
495
507
  allowlistExtra: string[],
496
- ): NodeRenderer {
508
+ ): (node: IrComponentNode, level: number, itemScope?: string) => string {
497
509
  const renderNested: NodeRenderer = (node, level, itemScope) => {
510
+ if (node.kind === 'viewRef') {
511
+ return renderViewRefElement(node, level, itemScope);
512
+ }
513
+ if (node.kind !== 'component') {
514
+ return '';
515
+ }
498
516
  if (isVocabulary(node.name, map, allowlistExtra)) {
499
517
  return lowerVocabularyElement(node, map.elements[node.name]!, level, itemScope, renderNested);
500
518
  }
501
519
  return renderExtensionElement(node, level, itemScope, renderNested);
502
520
  };
503
- return renderNested;
521
+ return (node, level, itemScope) => renderNested(node, level, itemScope);
504
522
  }
@@ -1,10 +1 @@
1
- import { resolve } from 'node:path';
2
- import { packageRoot } from '../../src/lib/packageRoot.js';
3
-
4
- export function reactTemplatesDir(customDir?: string): string {
5
- return customDir ?? resolve(packageRoot(), 'renderers/react/templates');
6
- }
7
-
8
- export function reactRuntimeDir(): string {
9
- return resolve(packageRoot(), 'renderers/react/runtime');
10
- }
1
+ export { reactTemplatesDir, reactRuntimeDir } from '../../src/lib/rendererPaths.js';
@@ -0,0 +1,23 @@
1
+ /**
2
+ * React preview composition root.
3
+ * Wires vocabulary stubs + renderer runtime + project extensions.
4
+ * This file belongs to the React renderer, NOT to core src/.
5
+ */
6
+ import { vocabularyStubs } from './vocabulary.js';
7
+
8
+ export const {
9
+ App, Box, Button, Checkbox, Column, Divider, Drawer,
10
+ Field, Form, Heading, Icon, IconButton, Image, Link,
11
+ List, ListItem, Modal, Page, Popover, Progress, Radio,
12
+ Row, SafeArea, ScrollArea, Select, Spacer, Spinner,
13
+ Stack, Switch, Table, TableCell, TableColumn, TableHeader,
14
+ TableRow, Text, TextArea, TextInput, Tooltip,
15
+ } = vocabularyStubs;
16
+
17
+ export {
18
+ isHidden,
19
+ toneClass,
20
+ registerPreviewActions,
21
+ clearPreviewActions,
22
+ dispatchPreviewAction,
23
+ } from '../runtime/index.js';
@@ -0,0 +1,3 @@
1
+ export { startPreview, PreviewApp } from './start.js';
2
+ export type { PreviewOptions } from './start.js';
3
+ export { PreviewShell } from '../../../src/preview/shell.js';
@@ -0,0 +1,42 @@
1
+ import type { ComponentType, ReactElement } from 'react';
2
+ import type { ActionHandlers } from '@view-contracts/core';
3
+ import React from 'react';
4
+ import ReactDOM from 'react-dom/client';
5
+ import '@view-contracts/theme';
6
+ import { registerPreviewActions } from '../runtime/actionRuntime.js';
7
+ import { PreviewShell } from '../../../src/preview/shell.js';
8
+
9
+ export interface PreviewOptions<TVm> {
10
+ View: ComponentType<{ vm: TVm; actions?: ActionHandlers }>;
11
+ vm: TVm;
12
+ actions?: ActionHandlers;
13
+ onAction?: ActionHandlers;
14
+ }
15
+
16
+ /** Mount a view-contract screen for design preview (views/ + view-contracts packages only). */
17
+ export function startPreview<TVm>(options: PreviewOptions<TVm>): void {
18
+ const actions = options.actions ?? {};
19
+ if (options.onAction) {
20
+ registerPreviewActions({ ...actions, ...options.onAction });
21
+ } else {
22
+ registerPreviewActions(actions);
23
+ }
24
+
25
+ const View = options.View;
26
+ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
27
+ <React.StrictMode>
28
+ <PreviewShell>
29
+ <View vm={options.vm} actions={actions} />
30
+ </PreviewShell>
31
+ </React.StrictMode>,
32
+ );
33
+ }
34
+
35
+ export function PreviewApp<TVm>(props: PreviewOptions<TVm>): ReactElement {
36
+ const View = props.View;
37
+ return (
38
+ <PreviewShell>
39
+ <View vm={props.vm} actions={props.actions} />
40
+ </PreviewShell>
41
+ );
42
+ }
@@ -0,0 +1,5 @@
1
+ /** Preview style helpers — re-export from renderer runtime. */
2
+ export {
3
+ extensionClassName,
4
+ resolveExtensionVariant,
5
+ } from '../runtime/style.js';