view-contracts 0.3.3 → 0.3.5

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 (37) hide show
  1. package/README.md +6 -6
  2. package/cli-contract.yaml +1 -1
  3. package/dist/view-contracts.bundle.mjs +169 -153
  4. package/dist/view-contracts.bundle.mjs.map +4 -4
  5. package/docs/cli-reference.md +1 -1
  6. package/package.json +1 -1
  7. package/renderers/NATIVE_THEME_EMIT.md +73 -0
  8. package/renderers/compose/README.md +7 -0
  9. package/renderers/compose/templates/Screen.kt.hbs +3 -5
  10. package/renderers/compose/templates/VcTheme.kt.hbs +7 -13
  11. package/renderers/react/defaults/theme.yaml +7 -0
  12. package/renderers/react/defaults/tokens.yaml +11 -0
  13. package/renderers/react/lowering/foldStyle.ts +87 -0
  14. package/renderers/react/lowering/lowerToJsx.ts +58 -69
  15. package/renderers/react/renderComponents.ts +180 -24
  16. package/renderers/react/renderRuntime.ts +36 -43
  17. package/renderers/react/routes.yaml +2 -2
  18. package/renderers/react/runtime/index.ts +1 -1
  19. package/renderers/react/runtime/style.ts +3 -55
  20. package/renderers/react/runtime/theme.css +1 -1
  21. package/renderers/react/runtime/types.ts +0 -24
  22. package/renderers/react/style/foldLiterals.ts +129 -0
  23. package/renderers/react/syncGeneratedRuntime.ts +35 -47
  24. package/renderers/react/templates/components-index.ts.hbs +2 -2
  25. package/renderers/react/templates/runtime.ts.hbs +10 -0
  26. package/renderers/react/templates/style-helpers.ts.hbs +2 -2
  27. package/renderers/swiftui/README.md +7 -0
  28. package/renderers/swiftui/templates/Screen.swift.hbs +3 -5
  29. package/renderers/swiftui/templates/VcTheme.swift.hbs +8 -16
  30. package/spec/ui-dsl.schema.json +1331 -345
  31. package/src/generated/schema/allowed-components.ts +1 -1
  32. package/src/generated/schema/dsl-enums.ts +1 -1
  33. package/src/generated/schema/dsl-properties.ts +1 -1
  34. package/src/generated/schema/index.ts +1 -1
  35. package/src/generated/schema/lowering-style-properties.ts +9 -2
  36. package/src/generated/schema/react-renderer-element-config.ts +1 -1
  37. package/src/generated/schema/schema-document.ts +1 -1
@@ -1,6 +1,12 @@
1
1
  import type { ViewIrDocument } from '../../src/ir/types.js';
2
+ import { resolve } from 'node:path';
2
3
  import { loadReactElementMap } from './elementMap.js';
3
- import { collectExtensionComponents, createNodeRenderer } from './lowering/lowerToJsx.js';
4
+ import {
5
+ collectExtensionComponents,
6
+ collectViewRefComponents,
7
+ createNodeRenderer,
8
+ } from './lowering/lowerToJsx.js';
9
+ import type { IrComponentNode, IrNode } from '../../src/ir/types.js';
4
10
 
5
11
  export interface PreviewImportSpecifiers {
6
12
  dispatch: string;
@@ -10,24 +16,71 @@ export interface PreviewImportSpecifiers {
10
16
  }
11
17
 
12
18
  export interface RenderReactOptions {
13
- componentsImportFrom: string;
19
+ runtimeImportFrom: string;
20
+ extensionsImportFrom: string;
21
+ contractsImportFrom: string;
14
22
  allowlistExtra?: string[];
15
23
  elementMapPath?: string;
16
24
  previewImports?: PreviewImportSpecifiers;
25
+ /** When set, viewRef nodes import from these paths instead of sibling .generated.js files. */
26
+ viewRefImportFrom?: Map<string, string>;
27
+ /** When true, omit viewRef import lines (partials defined in the same module). */
28
+ inlineViewRefs?: boolean;
29
+ /** @deprecated Use runtimeImportFrom */
30
+ componentsImportFrom?: string;
17
31
  }
18
32
 
19
- export async function renderReactComponent(
33
+ function componentExportName(doc: ViewIrDocument): string {
34
+ if (doc.isPartial) return doc.exportName;
35
+ const baseName = doc.exportName.replace(/View$/, '') || doc.exportName;
36
+ return baseName.charAt(0).toUpperCase() + baseName.slice(1);
37
+ }
38
+
39
+ function requireComponentRoot(root: IrNode, source: string): IrComponentNode {
40
+ if (root.kind !== 'component') {
41
+ throw new Error(`View IR root must be a component node (${source})`);
42
+ }
43
+ return root;
44
+ }
45
+
46
+ function extractContractTypeNames(propsType: string | undefined): string[] {
47
+ if (!propsType) return [];
48
+ const matches = propsType.match(/\b[A-Z][A-Za-z0-9]*ViewModel\b/g);
49
+ return matches ? [...new Set(matches)] : [];
50
+ }
51
+
52
+ function partialFunctionParams(doc: ViewIrDocument): string {
53
+ if (!doc.propsType) return '';
54
+ const match = doc.propsType.match(/^\{\s*([^}]+)\s*\}$/);
55
+ if (!match) return `props: ${doc.propsType}`;
56
+ const inner = match[1].trim();
57
+ const propNames = inner.split(',').map((part) => part.split(':')[0]!.trim());
58
+ return `{ ${propNames.join(', ')} }: ${doc.propsType}`;
59
+ }
60
+
61
+ /** Root-level `{cond ? ...}` from visibilityGuard is invalid inside `return (...)` — wrap in fragment. */
62
+ function formatComponentReturn(body: string): string {
63
+ const trimmed = body.trimStart();
64
+ if (trimmed.startsWith('{')) {
65
+ return `return (\n <>${body}\n </>\n );`;
66
+ }
67
+ return `return (\n${body}\n );`;
68
+ }
69
+
70
+ function stripModulePreamble(source: string): string {
71
+ let rest = source.replace(/^\/\*\*[\s\S]*?\*\/\s*/, '');
72
+ rest = rest.replace(/^import\s[^\n]+\n/gm, '');
73
+ return rest.trimStart();
74
+ }
75
+
76
+ async function buildRenderedModule(
20
77
  doc: ViewIrDocument,
21
78
  options: RenderReactOptions,
22
79
  ): Promise<string> {
23
80
  const map = await loadReactElementMap(options.elementMapPath);
24
81
  const allowlistExtra = options.allowlistExtra ?? [];
25
- const baseName = doc.exportName.replace(/View$/, '') || doc.exportName;
26
- const componentName = baseName.charAt(0).toUpperCase() + baseName.slice(1);
27
- const root = doc.root.kind === 'component' ? doc.root : null;
28
- if (!root) {
29
- throw new Error(`View IR root must be a component node (${doc.source})`);
30
- }
82
+ const componentName = componentExportName(doc);
83
+ const root = requireComponentRoot(doc.root, doc.source);
31
84
 
32
85
  const renderNode = createNodeRenderer(map, allowlistExtra);
33
86
  const body = renderNode(root, 2);
@@ -36,37 +89,76 @@ export async function renderReactComponent(
36
89
  collectExtensionComponents(doc.root, map, allowlistExtra, extensions);
37
90
  const extensionImports = [...extensions].sort();
38
91
 
92
+ const viewRefs = new Set<string>();
93
+ collectViewRefComponents(doc.root, viewRefs);
94
+ const viewRefImports = [...viewRefs].sort();
95
+
39
96
  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;
97
+ const runtimeModule = preview?.dispatch ?? options.runtimeImportFrom;
98
+ const contractsModule = preview?.contracts ?? options.contractsImportFrom;
99
+ const extensionsModule = preview?.extensions ?? options.extensionsImportFrom;
100
+
101
+ const contractTypes = new Set<string>();
102
+ if (doc.isPartial) {
103
+ for (const typeName of extractContractTypeNames(doc.propsType)) {
104
+ contractTypes.add(typeName);
105
+ }
106
+ } else {
107
+ contractTypes.add(doc.viewModel);
108
+ }
109
+
110
+ const imports: string[] = [`import type { ReactElement } from 'react';`];
111
+ imports.push(`import { dispatchAction } from '${runtimeModule}';`);
112
+
113
+ const typeImports = [...contractTypes].sort();
114
+ const typeImportList = typeImports.length > 0
115
+ ? `${typeImports.join(', ')}, ActionDescriptor`
116
+ : 'ActionDescriptor';
117
+ imports.push(`import type { ${typeImportList} } from '${contractsModule}';`);
44
118
 
45
- const imports: string[] = [
46
- `import type { ReactElement } from 'react';`,
47
- `import { dispatchAction } from '${dispatchModule}';`,
48
- `import { commonStyle, mapAlign, mapJustify } from '${styleModule}';`,
49
- `import type { ${doc.viewModel}, ActionDescriptor } from '${contractsModule}';`,
50
- ];
51
119
  if (extensionImports.length > 0) {
52
- imports.push(`import { ${extensionImports.join(', ')} } from '${componentsModule}';`);
120
+ imports.push(`import { ${extensionImports.join(', ')} } from '${extensionsModule}';`);
121
+ }
122
+
123
+ for (const refName of viewRefImports) {
124
+ if (options.inlineViewRefs) continue;
125
+ const importFrom = options.viewRefImportFrom?.get(refName)
126
+ ?? `./${refName}.generated.js`;
127
+ imports.push(`import { ${refName} } from '${importFrom}';`);
53
128
  }
54
129
 
55
130
  const headerComment = preview
56
131
  ? `/**\n * Preview module — compiled live from ${doc.source}\n * Same lowering as generated/; not written to disk.\n */`
57
132
  : `/**\n * Auto-generated React component from ${doc.source}\n * DO NOT EDIT — regenerate with: view-contracts render react\n */`;
58
133
 
134
+ if (doc.isPartial) {
135
+ const fnParams = partialFunctionParams(doc);
136
+
137
+ return `${headerComment}
138
+ ${imports.join('\n')}
139
+
140
+ export function ${componentName}(${fnParams}): ReactElement {
141
+ ${formatComponentReturn(body)}
142
+ }
143
+ `;
144
+ }
145
+
59
146
  return `${headerComment}
60
147
  ${imports.join('\n')}
61
148
 
62
149
  export function ${componentName}({ vm }: { vm: ${doc.viewModel} }): ReactElement {
63
- return (
64
- ${body}
65
- );
150
+ ${formatComponentReturn(body)}
66
151
  }
67
152
  `;
68
153
  }
69
154
 
155
+ export async function renderReactComponent(
156
+ doc: ViewIrDocument,
157
+ options: RenderReactOptions,
158
+ ): Promise<string> {
159
+ return buildRenderedModule(doc, options);
160
+ }
161
+
70
162
  export async function renderReactFromIr(
71
163
  doc: ViewIrDocument,
72
164
  outputPath: string,
@@ -74,7 +166,71 @@ export async function renderReactFromIr(
74
166
  ): Promise<void> {
75
167
  const { writeFile, mkdir } = await import('node:fs/promises');
76
168
  const { dirname } = await import('node:path');
77
- const content = await renderReactComponent(doc, options);
169
+ const content = await buildRenderedModule(doc, options);
78
170
  await mkdir(dirname(outputPath), { recursive: true });
79
171
  await writeFile(outputPath, content, 'utf-8');
80
172
  }
173
+
174
+ /** Render screen + partial modules for preview (single file, no cross-imports). */
175
+ export async function renderReactPreviewBundle(
176
+ screen: ViewIrDocument,
177
+ partials: ViewIrDocument[],
178
+ options: RenderReactOptions,
179
+ ): Promise<string> {
180
+ const inlineOptions: RenderReactOptions = { ...options, inlineViewRefs: true };
181
+
182
+ const partialModules = await Promise.all(
183
+ partials.map((partial) => renderReactComponent(partial, inlineOptions)),
184
+ );
185
+ const screenModule = await renderReactComponent(screen, inlineOptions);
186
+
187
+ const stripImports = stripModulePreamble;
188
+
189
+ const map = await loadReactElementMap(options.elementMapPath);
190
+ const allowlistExtra = options.allowlistExtra ?? [];
191
+ const extensions = new Set<string>();
192
+ collectExtensionComponents(screen.root, map, allowlistExtra, extensions);
193
+ for (const partial of partials) {
194
+ collectExtensionComponents(partial.root, map, allowlistExtra, extensions);
195
+ }
196
+
197
+ const preview = options.previewImports;
198
+ const runtimeModule = preview?.dispatch ?? options.runtimeImportFrom;
199
+ const contractsModule = preview?.contracts ?? options.contractsImportFrom;
200
+ const extensionsModule = preview?.extensions ?? options.extensionsImportFrom;
201
+
202
+ const contractTypes = new Set<string>([screen.viewModel]);
203
+ for (const partial of partials) {
204
+ for (const typeName of extractContractTypeNames(partial.propsType)) {
205
+ contractTypes.add(typeName);
206
+ }
207
+ }
208
+
209
+ const imports: string[] = [
210
+ `import type { ReactElement } from 'react';`,
211
+ `import { dispatchAction } from '${runtimeModule}';`,
212
+ `import type { ${[...contractTypes].sort().join(', ')}, ActionDescriptor } from '${contractsModule}';`,
213
+ ];
214
+ const extensionImports = [...extensions].sort();
215
+ if (extensionImports.length > 0) {
216
+ imports.push(`import { ${extensionImports.join(', ')} } from '${extensionsModule}';`);
217
+ }
218
+
219
+ const headerComment = preview
220
+ ? `/**\n * Preview module — compiled live from ${screen.source}\n * Same lowering as generated/; not written to disk.\n */`
221
+ : '';
222
+
223
+ const partialBodies = partialModules.map(stripImports);
224
+ const screenBody = stripImports(screenModule);
225
+
226
+ return `${headerComment}
227
+ ${imports.join('\n')}
228
+
229
+ ${partialBodies.join('\n\n')}
230
+
231
+ ${screenBody}`;
232
+ }
233
+
234
+ export function partialReactOutputPath(reactDir: string, partial: ViewIrDocument): string {
235
+ return resolve(reactDir, 'components', `${partial.exportName}.generated.tsx`);
236
+ }
@@ -1,12 +1,11 @@
1
- import { readFile, writeFile, mkdir, stat, unlink } from 'node:fs/promises';
2
- import { resolve } from 'node:path';
1
+ import { writeFile, stat, unlink, rm } from 'node:fs/promises';
2
+ import { relative, resolve } from 'node:path';
3
3
  import type { ViewContractsConfig } from '../../src/config.js';
4
- import { projectRoot, requireDesignPaths } from '../../src/config.js';
4
+ import { projectRoot } from '../../src/config.js';
5
5
  import { buildThemeCss } from '../../src/design/buildThemeCss.js';
6
- import { loadValidateDesign } from '../../src/design/index.js';
7
6
  import { renderTemplate } from '../../src/renderer/template.js';
8
7
  import { reactTemplatesDir } from './paths.js';
9
- import { rewriteExtensionImportsForGenerated, syncGeneratedRuntime } from './syncGeneratedRuntime.js';
8
+ import { removeLegacyReactRuntimeLayout, writeGeneratedRuntimeBundle } from './syncGeneratedRuntime.js';
10
9
 
11
10
  async function pathExists(target: string): Promise<boolean> {
12
11
  try {
@@ -26,58 +25,52 @@ async function writeThemeCss(
26
25
  const css = await buildThemeCss(config, configPath);
27
26
  const content = await renderTemplate(reactTemplatesDir(templatesDir), 'theme.css.hbs', { themeCss: css });
28
27
  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');
36
28
  }
37
29
 
38
- async function removeStaleGeneratedFiles(componentsDir: string): Promise<void> {
39
- for (const name of ['ui-components.tsx', 'core.ts', 'actionRuntime.ts', 'shared.tsx', 'shared.ts'] as const) {
40
- const target = resolve(componentsDir, name);
41
- if (await pathExists(target)) {
42
- await unlink(target);
30
+ async function removeLegacyGeneratedRootArtifacts(reactDir: string): Promise<void> {
31
+ const generatedRoot = resolve(reactDir, '..');
32
+ for (const name of ['theme.css', 'contracts.ts', 'view-bridge.generated.ts'] as const) {
33
+ const legacy = resolve(generatedRoot, name);
34
+ if (await pathExists(legacy)) {
35
+ await unlink(legacy);
43
36
  }
44
37
  }
45
38
  }
46
39
 
40
+ async function writeExtensionsReExport(configPath: string, config: ViewContractsConfig): Promise<string> {
41
+ const root = projectRoot(configPath);
42
+ const reactDir = config.generated.reactDir;
43
+ const extensionsSrc = resolve(root, 'src/components/extensions.tsx');
44
+ let rel = relative(reactDir, extensionsSrc).replace(/\\/g, '/');
45
+ if (!rel.startsWith('.')) rel = `./${rel}`;
46
+ const importPath = rel.replace(/\.tsx?$/, '.js');
47
+ const outPath = resolve(reactDir, 'extensions.generated.ts');
48
+ await writeFile(
49
+ outPath,
50
+ `/** Auto-generated extension re-export — DO NOT EDIT */\nexport * from '${importPath}';\n`,
51
+ 'utf-8',
52
+ );
53
+ return outPath;
54
+ }
55
+
47
56
  /** Emit standalone generated runtime (no preview vocabulary copy; no @view-contracts imports). */
48
57
  export async function renderReactRuntime(configPath: string, config: ViewContractsConfig): Promise<void> {
49
58
  const root = projectRoot(configPath);
50
59
  const templatesDir = config.rendererTemplatesDir
51
60
  ? resolve(root, config.rendererTemplatesDir)
52
61
  : undefined;
53
- const templates = reactTemplatesDir(templatesDir);
54
- const srcComponentsDir = resolve(root, 'src/components');
62
+ const reactDir = config.generated.reactDir;
55
63
 
56
- const { componentsDir, runtimeDir } = await syncGeneratedRuntime(configPath, config, templatesDir);
57
- await removeStaleGeneratedFiles(componentsDir);
64
+ await removeLegacyGeneratedRootArtifacts(reactDir);
65
+ await removeLegacyReactRuntimeLayout(reactDir);
58
66
 
59
- for (const name of ['extensions.tsx', 'sample-types.ts'] as const) {
60
- const src = resolve(srcComponentsDir, name);
61
- let body = await readFile(src, 'utf-8');
62
- if (name === 'extensions.tsx') {
63
- body = rewriteExtensionImportsForGenerated(body);
64
- }
65
- await writeFile(resolve(componentsDir, name), body, 'utf-8');
66
- }
67
-
68
- const handlersSrc = resolve(root, 'src/runtime/handlers.ts');
69
- const handlersBody = (await readFile(handlersSrc, 'utf-8')).replace(
70
- /export async function handleAppAction\(action:\s*\{[\s\S]*?\}\)/,
71
- 'export async function handleAppAction(action: DispatchableAction)',
72
- );
73
- await writeFile(
74
- resolve(runtimeDir, 'handlers.ts'),
75
- await renderTemplate(templates, 'handlers.ts.hbs', { handlersBody }),
76
- 'utf-8',
77
- );
67
+ const { runtimePath } = await writeGeneratedRuntimeBundle(configPath, config, templatesDir);
68
+ const extensionsPath = await writeExtensionsReExport(configPath, config);
78
69
 
79
- await writeThemeCss(configPath, resolve(config.generated.reactDir, '../theme.css'), config, templatesDir);
70
+ const themeOut = resolve(reactDir, 'theme.css');
71
+ await writeThemeCss(configPath, themeOut, config, templatesDir);
80
72
 
81
- console.log(` components → ${componentsDir}`);
82
- console.log(` theme → ${resolve(config.generated.reactDir, '../theme.css')}`);
73
+ console.log(` react/runtime → ${runtimePath}`);
74
+ console.log(` react/extensions → ${extensionsPath}`);
75
+ console.log(` react/theme → ${themeOut}`);
83
76
  }
@@ -3,8 +3,8 @@
3
3
 
4
4
  react:
5
5
  description: Production React via compile-time IR lowering + template-emitted runtime
6
- importFrom: '../components/index.js'
7
- themeCss: theme.css
6
+ importFrom: './runtime.js'
7
+ themeCss: react/theme.css
8
8
  templates: renderers/react/templates
9
9
  outputs:
10
10
  screen:
@@ -1,5 +1,5 @@
1
1
  /** Preview/toolchain React runtime primitives — not copied to generated/. */
2
2
  export * from './types.js';
3
- export { commonStyle, isHidden, toneClass, mapAlign, mapJustify } from './style.js';
3
+ export { isHidden, toneClass, extensionClassName, resolveExtensionVariant } from './style.js';
4
4
  export { registerPreviewActions, clearPreviewActions, dispatchPreviewAction } from './actionRuntime.js';
5
5
  export { registerActionHandler, dispatchAction } from './dispatch.js';
@@ -1,7 +1,4 @@
1
- import type { CSSProperties } from 'react';
2
- import type { LoweredStyleInput } from './types.js';
3
-
4
- /** Map flex alignment shorthand (from lowering) to CSS align-items / align-self values. */
1
+ /** Map flex alignment shorthand to CSS align-items / align-self values. */
5
2
  export function mapAlign(value?: string): string | undefined {
6
3
  if (!value) return undefined;
7
4
  if (value === 'start') return 'flex-start';
@@ -9,7 +6,7 @@ export function mapAlign(value?: string): string | undefined {
9
6
  return value;
10
7
  }
11
8
 
12
- /** Map flex justification shorthand (from lowering) to CSS justify-content values. */
9
+ /** Map flex justification shorthand to CSS justify-content values. */
13
10
  export function mapJustify(value?: string): string | undefined {
14
11
  if (!value) return undefined;
15
12
  if (value === 'start') return 'flex-start';
@@ -20,56 +17,7 @@ export function mapJustify(value?: string): string | undefined {
20
17
  return value;
21
18
  }
22
19
 
23
- function len(value: number): string {
24
- return `${value}px`;
25
- }
26
-
27
- const PLACE_ITEMS: Record<string, string> = {
28
- topStart: 'start start',
29
- top: 'center start',
30
- topEnd: 'end start',
31
- start: 'start center',
32
- center: 'center center',
33
- end: 'end center',
34
- bottomStart: 'start end',
35
- bottom: 'center end',
36
- bottomEnd: 'end end',
37
- };
38
-
39
- function fontSizeStyle(size: number | string | undefined): string | undefined {
40
- if (size === undefined) return undefined;
41
- return typeof size === 'number' ? len(size) : size;
42
- }
43
-
44
- export function commonStyle(props: LoweredStyleInput): CSSProperties {
45
- const style: CSSProperties = {};
46
- if (typeof props.width === 'number') style.width = len(props.width);
47
- if (typeof props.minWidth === 'number') style.minWidth = len(props.minWidth);
48
- if (typeof props.maxWidth === 'number') style.maxWidth = len(props.maxWidth);
49
- if (typeof props.height === 'number') style.height = len(props.height);
50
- if (typeof props.minHeight === 'number') style.minHeight = len(props.minHeight);
51
- if (typeof props.maxHeight === 'number') style.maxHeight = len(props.maxHeight);
52
- if (props.aspectRatio !== undefined) style.aspectRatio = String(props.aspectRatio);
53
- if (typeof props.weight === 'number') style.flexGrow = props.weight;
54
- if (typeof props.padding === 'number') style.padding = len(props.padding);
55
- if (typeof props.margin === 'number') style.margin = len(props.margin);
56
- if (typeof props.gap === 'number') style.gap = len(props.gap);
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
- }
65
- const fontSize = fontSizeStyle(props.size);
66
- if (fontSize) style.fontSize = fontSize;
67
- const fontWeight = typeof props.weight === 'string' ? props.weight : props.fontWeight;
68
- if (fontWeight) style.fontWeight = fontWeight;
69
- return style;
70
- }
71
-
72
- /** Preview vocabulary helper — lowered production JSX does not use visible. */
20
+ /** Preview vocabulary helper — production lowered JSX uses visibility guards instead. */
73
21
  export function isHidden(props: { visible?: boolean; hidden?: boolean }): boolean {
74
22
  return props.hidden === true || props.visible === false;
75
23
  }
@@ -27,7 +27,7 @@ button, input, textarea, select { font: inherit; }
27
27
  .ui-tone-warning { color: var(--ui-warning); }
28
28
  .ui-tone-danger { color: var(--ui-danger); }
29
29
 
30
- .vc-button { border: 0; border-radius: 10px; padding: 10px 14px; font-weight: 700; cursor: pointer; }
30
+ .vc-button { border: 0; cursor: pointer; }
31
31
 
32
32
  .vc-form { display: flex; flex-direction: column; }
33
33
  .vc-field { display: flex; flex-direction: column; gap: 6px; }
@@ -10,30 +10,6 @@ export type DispatchableAction = ViewAction;
10
10
  /** Action binding from lowered views or hand-written extensions. */
11
11
  export type Action = ViewAction;
12
12
 
13
- /**
14
- * Style props passed to commonStyle() by the React lowering pass.
15
- * Numeric dimensions are logical units; commonStyle maps them to px.
16
- */
17
- export interface LoweredStyleInput {
18
- width?: number | string;
19
- minWidth?: number | string;
20
- maxWidth?: number | string;
21
- height?: number | string;
22
- minHeight?: number | string;
23
- maxHeight?: number | string;
24
- aspectRatio?: number | string;
25
- weight?: number | string;
26
- padding?: number | string;
27
- margin?: number;
28
- gap?: number | string;
29
- textAlign?: string;
30
- contentAlign?: string;
31
- selfAlign?: string;
32
- overflow?: string;
33
- size?: number | string;
34
- fontWeight?: string;
35
- }
36
-
37
13
  export interface Option {
38
14
  label: string;
39
15
  value: string;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Pure compile-time style folding from literal DSL prop values.
3
+ * Used by lowering (static JSX) and preview vocabulary stubs only.
4
+ */
5
+ import { mapAlign, mapJustify } from '../runtime/style.js';
6
+
7
+ export type FoldedCss = Record<string, string | number>;
8
+
9
+ function len(value: number): string {
10
+ return `${value}px`;
11
+ }
12
+
13
+ const PLACE_ITEMS: Record<string, string> = {
14
+ topStart: 'start start',
15
+ top: 'center start',
16
+ topEnd: 'end start',
17
+ start: 'start center',
18
+ center: 'center center',
19
+ end: 'end center',
20
+ bottomStart: 'start end',
21
+ bottom: 'center end',
22
+ bottomEnd: 'end end',
23
+ };
24
+
25
+ const SHADOW_PRESETS: Record<string, string> = {
26
+ none: 'none',
27
+ sm: 'var(--ui-shadow-sm, 0 1px 2px rgba(0,0,0,0.05))',
28
+ md: 'var(--ui-shadow)',
29
+ lg: 'var(--ui-shadow-lg, 0 10px 15px rgba(0,0,0,0.1))',
30
+ };
31
+
32
+ function tokenRefToCssVar(value: string): string | undefined {
33
+ const match = value.match(/^\{([^}]+)\}$/);
34
+ if (!match) return undefined;
35
+ return `var(--${match[1]!.replace(/\./g, '-')})`;
36
+ }
37
+
38
+ export function foldLiteralsToCss(literals: Record<string, unknown>): FoldedCss {
39
+ const style: FoldedCss = {};
40
+ const width = literals.width;
41
+ if (typeof width === 'number') style.width = len(width);
42
+ else if (typeof width === 'string' && width !== 'wrap' && width !== 'fill') style.width = width;
43
+
44
+ if (typeof literals.minWidth === 'number') style.minWidth = len(literals.minWidth);
45
+ if (typeof literals.maxWidth === 'number') style.maxWidth = len(literals.maxWidth);
46
+ if (typeof literals.height === 'number') style.height = len(literals.height);
47
+ if (typeof literals.minHeight === 'number') style.minHeight = len(literals.minHeight);
48
+ if (typeof literals.maxHeight === 'number') style.maxHeight = len(literals.maxHeight);
49
+ if (literals.aspectRatio !== undefined) style.aspectRatio = String(literals.aspectRatio);
50
+
51
+ const weight = literals.weight;
52
+ if (typeof weight === 'number') style.flexGrow = weight;
53
+
54
+ if (typeof literals.padding === 'number') style.padding = len(literals.padding);
55
+ if (typeof literals.margin === 'number') style.margin = len(literals.margin);
56
+ if (typeof literals.gap === 'number') style.gap = len(literals.gap);
57
+
58
+ const textAlign = literals.textAlign;
59
+ if (typeof textAlign === 'string') {
60
+ style.textAlign = textAlign === 'start' ? 'left' : textAlign === 'end' ? 'right' : textAlign;
61
+ }
62
+
63
+ const contentAlign = literals.contentAlign;
64
+ if (typeof contentAlign === 'string' && contentAlign in PLACE_ITEMS) {
65
+ style.placeItems = PLACE_ITEMS[contentAlign]!;
66
+ }
67
+
68
+ if (typeof literals.overflow === 'string') style.overflow = literals.overflow;
69
+
70
+ const selfAlign = literals.selfAlign;
71
+ if (typeof selfAlign === 'string') {
72
+ style.alignSelf = selfAlign === 'start' ? 'flex-start' : selfAlign === 'end' ? 'flex-end' : selfAlign;
73
+ }
74
+
75
+ const size = literals.size;
76
+ if (typeof size === 'number') style.fontSize = len(size);
77
+ else if (typeof size === 'string') style.fontSize = size;
78
+
79
+ const fontWeight = typeof weight === 'string' ? weight : literals.fontWeight;
80
+ if (typeof fontWeight === 'string') style.fontWeight = fontWeight;
81
+
82
+ const background = literals.background;
83
+ if (typeof background === 'string') style.background = tokenRefToCssVar(background) ?? background;
84
+
85
+ const foreground = literals.foreground;
86
+ if (typeof foreground === 'string') style.color = tokenRefToCssVar(foreground) ?? foreground;
87
+
88
+ const radius = literals.radius;
89
+ if (typeof radius === 'number') style.borderRadius = len(radius);
90
+ else if (typeof radius === 'string') style.borderRadius = tokenRefToCssVar(radius) ?? radius;
91
+
92
+ const shadow = literals.shadow;
93
+ if (shadow === true) style.boxShadow = 'var(--ui-shadow)';
94
+ else if (shadow === false) style.boxShadow = 'none';
95
+ else if (typeof shadow === 'string') {
96
+ style.boxShadow = SHADOW_PRESETS[shadow] ?? tokenRefToCssVar(shadow) ?? shadow;
97
+ }
98
+
99
+ const border = literals.border;
100
+ if (typeof border === 'string') style.border = tokenRefToCssVar(border) ?? border;
101
+
102
+ if (typeof literals.opacity === 'number') style.opacity = literals.opacity;
103
+
104
+ const clip = literals.clip;
105
+ if (typeof clip === 'string') style.overflow = clip === 'hidden' ? 'hidden' : clip;
106
+
107
+ const align = literals.align;
108
+ if (typeof align === 'string') {
109
+ const mapped = mapAlign(align);
110
+ if (mapped) style.alignItems = mapped;
111
+ }
112
+
113
+ const justify = literals.justify;
114
+ if (typeof justify === 'string') {
115
+ const mapped = mapJustify(justify);
116
+ if (mapped) style.justifyContent = mapped;
117
+ }
118
+
119
+ return style;
120
+ }
121
+
122
+ export function foldedStyleToJsx(style: FoldedCss): string {
123
+ const entries = Object.entries(style);
124
+ if (entries.length === 0) return '';
125
+ const body = entries
126
+ .map(([key, value]) => (typeof value === 'number' ? `${key}: ${value}` : `${key}: ${JSON.stringify(value)}`))
127
+ .join(', ');
128
+ return `style={{ ${body} }}`;
129
+ }