view-contracts 0.3.5 → 0.4.1

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 +40 -0
  3. package/dist/preview-style-helpers.js +2 -0
  4. package/dist/preview-style-helpers.js.map +7 -0
  5. package/dist/preview.mjs +152 -0
  6. package/dist/preview.mjs.map +7 -0
  7. package/dist/view-contracts.bundle.mjs +457 -248
  8. package/dist/view-contracts.bundle.mjs.map +4 -4
  9. package/docs/cli-reference.md +36 -0
  10. package/package.json +28 -1
  11. package/renderers/compose/renderRuntime.ts +40 -0
  12. package/renderers/react/defaults/theme.yaml +52 -0
  13. package/renderers/react/defaults/tokens.yaml +76 -1
  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 +21 -40
  18. package/renderers/react/paths.ts +5 -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/runtime/structural.css +38 -0
  25. package/renderers/react/style/foldLiterals.ts +25 -4
  26. package/renderers/swiftui/README.md +48 -10
  27. package/renderers/swiftui/elementMap.ts +45 -0
  28. package/renderers/swiftui/elements.yaml +82 -0
  29. package/renderers/swiftui/index.ts +5 -0
  30. package/renderers/swiftui/lowering/lowerToSwiftUI.ts +761 -0
  31. package/renderers/swiftui/lowering/modifiers.ts +221 -0
  32. package/renderers/swiftui/renderComponents.ts +79 -0
  33. package/renderers/swiftui/renderRuntime.ts +139 -0
  34. package/renderers/swiftui/renderTheme.ts +274 -0
  35. package/spec/ui-dsl.linter-rules.json +0 -7
  36. package/spec/ui-dsl.meta.json +5 -0
  37. package/spec/ui-dsl.schema.json +333 -259
  38. package/renderers/react/runtime/theme.css +0 -65
  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
@@ -0,0 +1,761 @@
1
+ import { LOWERING_STYLE_PROPERTIES } from '../../../src/schema/dsl-catalog.js';
2
+ import type { ThemeIR, TokenIR } from '../../../src/design/types.js';
3
+ import { mergeVisualProps, type VisualPropSet } from '../../../src/renderer/style/mergeVisualProps.js';
4
+ import type {
5
+ IrBinding,
6
+ IrComponentNode,
7
+ IrConcat,
8
+ IrLiteral,
9
+ IrMapNode,
10
+ IrNode,
11
+ IrTextNode,
12
+ IrValue,
13
+ IrViewRefNode,
14
+ } from '../../../src/ir/types.js';
15
+ import type { SwiftUIElementConfig, SwiftUIElementMap } from '../elementMap.js';
16
+ import { applyModifiers, visualPropSetToModifiers } from './modifiers.js';
17
+
18
+ export interface SwiftUIRenderContext {
19
+ map: SwiftUIElementMap;
20
+ theme: ThemeIR;
21
+ tokens: TokenIR;
22
+ allowlistExtra: string[];
23
+ /** Root prop name for partial scope (e.g. "item" for KpiCard). */
24
+ propsRoot?: string;
25
+ }
26
+
27
+ function indent(level: number): string {
28
+ return ' '.repeat(level);
29
+ }
30
+
31
+ function tokenHexColorExpr(tokens: TokenIR, path: string, fallback: string): string {
32
+ const entry = tokens.entries.find((item) => item.path === path);
33
+ const value = entry?.value ?? fallback;
34
+ const match = value.match(/^#([0-9a-fA-F]{6})$/);
35
+ if (!match) return `Color(${JSON.stringify(value)})`;
36
+ const hex = match[1]!;
37
+ const r = parseInt(hex.slice(0, 2), 16) / 255;
38
+ const g = parseInt(hex.slice(2, 4), 16) / 255;
39
+ const b = parseInt(hex.slice(4, 6), 16) / 255;
40
+ return `Color(red: ${r}, green: ${g}, blue: ${b})`;
41
+ }
42
+
43
+ function renderGridChildBlock(
44
+ children: IrNode[],
45
+ level: number,
46
+ renderNested: NodeRenderer,
47
+ ctx: SwiftUIRenderContext,
48
+ itemScope: string | undefined,
49
+ ): string {
50
+ const childFrameMod = `${indent(level + 2)}.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)`;
51
+ const parts = children.map((child) => {
52
+ if (child.kind === 'map') {
53
+ const mapNode = child as IrMapNode;
54
+ const item = mapNode.item;
55
+ let body = renderNested(mapNode.body, level + 2, item);
56
+ body += `\n${childFrameMod}`;
57
+ const source = bindingExpr(mapNode.source, ctx, itemScope);
58
+ return `${indent(level + 2)}ForEach(${source}, id: \\.id) { ${item} in\n${body}\n${indent(level + 2)}}`;
59
+ }
60
+ let rendered = '';
61
+ if (child.kind === 'text') rendered = `${indent(level + 2)}Text(${JSON.stringify((child as IrTextNode).value)})`;
62
+ else if (child.kind === 'binding') rendered = `${indent(level + 2)}Text(${bindingExpr(child as IrBinding, ctx, itemScope)})`;
63
+ else rendered = renderNested(child, level + 2, itemScope);
64
+ if (!rendered) return '';
65
+ return `${rendered}\n${childFrameMod}`;
66
+ }).filter(Boolean);
67
+ return parts.join('\n');
68
+ }
69
+
70
+ function propLiteral(props: Record<string, IrValue>, key: string): unknown | undefined {
71
+ const value = props[key];
72
+ if (value === null || value === undefined) return undefined;
73
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value;
74
+ if (typeof value === 'object' && 'kind' in value && value.kind === 'literal') {
75
+ return (value as IrLiteral).value;
76
+ }
77
+ return undefined;
78
+ }
79
+
80
+ function collectInstanceVisualLiterals(props: Record<string, IrValue>): Record<string, unknown> {
81
+ const literals: Record<string, unknown> = {};
82
+ for (const [key, value] of Object.entries(props)) {
83
+ if (key === 'variant' || key === 'id') continue;
84
+ if (!LOWERING_STYLE_PROPERTIES.has(key)) continue;
85
+ const lit = propLiteral(props, key);
86
+ if (lit !== undefined) literals[key] = lit;
87
+ }
88
+ return literals;
89
+ }
90
+
91
+ function isDynamicVariant(props: Record<string, IrValue>): boolean {
92
+ const v = props.variant;
93
+ if (v === null || v === undefined) return false;
94
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return false;
95
+ if (typeof v === 'object' && 'kind' in v) {
96
+ if (v.kind === 'literal') return false;
97
+ return v.kind === 'binding' || v.kind === 'concat';
98
+ }
99
+ return false;
100
+ }
101
+
102
+ function dynamicVariantSwiftExpr(props: Record<string, IrValue>, ctx: SwiftUIRenderContext, itemScope?: string): string | null {
103
+ const v = props.variant;
104
+ if (!v || typeof v !== 'object' || !('kind' in v)) return null;
105
+ if (v.kind === 'binding') return bindingExpr(v as IrBinding, ctx, itemScope);
106
+ if (v.kind === 'concat') {
107
+ const concat = v as IrConcat;
108
+ const parts = concat.parts.map(p => {
109
+ if (typeof p === 'string') return p;
110
+ return `\\(${bindingExpr(p, ctx, itemScope)})`;
111
+ }).join('');
112
+ return `"${parts}"`;
113
+ }
114
+ return null;
115
+ }
116
+
117
+ function mergedModifiersForProps(
118
+ elementName: string,
119
+ props: Record<string, IrValue>,
120
+ ctx: SwiftUIRenderContext,
121
+ itemScope?: string,
122
+ ): string[] {
123
+ const entry = ctx.map.elements[elementName];
124
+ const variantLit = propLiteral(props, 'variant');
125
+ const instanceLiterals = collectInstanceVisualLiterals(props);
126
+ if (entry?.spacingProp && entry.spacingProp in instanceLiterals) {
127
+ delete instanceLiterals[entry.spacingProp];
128
+ }
129
+
130
+ const dynamic = isDynamicVariant(props);
131
+
132
+ if (dynamic) {
133
+ // Dynamic variant: base+variant are resolved at runtime via theme dictionary.
134
+ // Compile-time applies only instance literals (which override variant at runtime).
135
+ const variantExpr = dynamicVariantSwiftExpr(props, ctx, itemScope)!;
136
+ const runtimeLookup = `.applyVcStyle(VcTheme.resolve(${JSON.stringify(elementName)}, variant: ${variantExpr}))`;
137
+
138
+ const instanceOnly = mergeVisualProps({
139
+ elementName,
140
+ theme: { defaults: {}, variants: {} },
141
+ tokens: ctx.tokens,
142
+ instanceLiterals,
143
+ });
144
+ const instanceModifiers = visualPropSetToModifiers(instanceOnly);
145
+ const paddingMods = instanceModifiers.filter((mod) => mod.startsWith('.padding'));
146
+ const otherInstanceMods = instanceModifiers.filter((mod) => !mod.startsWith('.padding'));
147
+
148
+ // Padding must sit inside background/border from applyVcStyle.
149
+ return [...paddingMods, runtimeLookup, ...otherInstanceMods];
150
+ }
151
+
152
+ const merged = mergeVisualProps({
153
+ elementName,
154
+ theme: ctx.theme,
155
+ tokens: ctx.tokens,
156
+ variantLiteral: typeof variantLit === 'string' ? variantLit : undefined,
157
+ instanceLiterals,
158
+ });
159
+ return visualPropSetToModifiers(merged);
160
+ }
161
+
162
+ function emitPickerSelection(
163
+ props: Record<string, IrValue>,
164
+ ctx: SwiftUIRenderContext,
165
+ itemScope: string | undefined,
166
+ ): string {
167
+ const options = props.options;
168
+ if (typeof options === 'object' && options !== null && 'kind' in options && options.kind === 'literal' && Array.isArray((options as IrLiteral).value)) {
169
+ const items = (options as IrLiteral).value as Array<{ value: string }>;
170
+ return `.constant(${JSON.stringify(items[0]?.value ?? '')})`;
171
+ }
172
+ if (typeof options === 'object' && options !== null && 'kind' in options && options.kind === 'binding') {
173
+ const expr = bindingExpr(options as IrBinding, ctx, itemScope);
174
+ return `Binding(get: { ${expr}.first?.value ?? "" }, set: { _ in })`;
175
+ }
176
+ return '.constant("")';
177
+ }
178
+
179
+ function emitPickerOptions(
180
+ props: Record<string, IrValue>,
181
+ ctx: SwiftUIRenderContext,
182
+ itemScope: string | undefined,
183
+ level: number,
184
+ ): string {
185
+ const options = props.options;
186
+ if (!options || typeof options !== 'object' || !('kind' in options)) {
187
+ return `${indent(level + 1)}Text("--").tag("")`;
188
+ }
189
+ if (options.kind === 'binding') {
190
+ const expr = bindingExpr(options as IrBinding, ctx, itemScope);
191
+ return `${indent(level + 1)}ForEach(${expr}, id: \\.value) { option in\n${indent(level + 2)}Text(option.label).tag(option.value)\n${indent(level + 1)}}`;
192
+ }
193
+ if (options.kind === 'literal' && Array.isArray((options as IrLiteral).value)) {
194
+ const items = (options as IrLiteral).value as Array<{ label: string; value: string }>;
195
+ return items.map((opt) =>
196
+ `${indent(level + 1)}Text(${JSON.stringify(opt.label)}).tag(${JSON.stringify(opt.value)})`,
197
+ ).join('\n');
198
+ }
199
+ return `${indent(level + 1)}Text("--").tag("")`;
200
+ }
201
+
202
+ function leafExtraModifiers(elementName: string): string[] {
203
+ return [];
204
+ }
205
+
206
+ function emitMenuOptions(
207
+ props: Record<string, IrValue>,
208
+ ctx: SwiftUIRenderContext,
209
+ itemScope: string | undefined,
210
+ level: number,
211
+ ): string {
212
+ const options = props.options;
213
+ if (typeof options === 'object' && options !== null && 'kind' in options && options.kind === 'binding') {
214
+ const expr = bindingExpr(options as IrBinding, ctx, itemScope);
215
+ return `${indent(level + 1)}ForEach(${expr}, id: \\.value) { option in\n${indent(level + 2)}Button(option.label) {}\n${indent(level + 1)}}`;
216
+ }
217
+ if (typeof options === 'object' && options !== null && 'kind' in options && options.kind === 'literal' && Array.isArray((options as IrLiteral).value)) {
218
+ const items = (options as IrLiteral).value as Array<{ label: string; value: string }>;
219
+ return items.map((opt) =>
220
+ `${indent(level + 1)}Button(${JSON.stringify(opt.label)}) {}`,
221
+ ).join('\n');
222
+ }
223
+ return `${indent(level + 1)}Button("--") {}`;
224
+ }
225
+
226
+ function emitSelectLabelText(
227
+ props: Record<string, IrValue>,
228
+ ctx: SwiftUIRenderContext,
229
+ itemScope: string | undefined,
230
+ ): string {
231
+ const options = props.options;
232
+ if (typeof options === 'object' && options !== null && 'kind' in options && options.kind === 'binding') {
233
+ const expr = bindingExpr(options as IrBinding, ctx, itemScope);
234
+ return `${expr}.first?.label ?? ""`;
235
+ }
236
+ if (typeof options === 'object' && options !== null && 'kind' in options && options.kind === 'literal' && Array.isArray((options as IrLiteral).value)) {
237
+ const items = (options as IrLiteral).value as Array<{ label: string }>;
238
+ return JSON.stringify(items[0]?.label ?? '');
239
+ }
240
+ return '""';
241
+ }
242
+
243
+ function bindingExpr(binding: IrBinding, ctx: SwiftUIRenderContext, itemScope?: string): string {
244
+ const root = binding.scope ?? itemScope ?? ctx.propsRoot ?? 'vm';
245
+ return binding.path ? `${root}.${binding.path}` : root;
246
+ }
247
+
248
+ function renderTextContent(children: IrNode[], ctx: SwiftUIRenderContext, itemScope?: string): string | null {
249
+ for (const child of children) {
250
+ if (child.kind === 'text') return JSON.stringify((child as IrTextNode).value);
251
+ if (child.kind === 'binding') return bindingExpr(child as IrBinding, ctx, itemScope);
252
+ }
253
+ return null;
254
+ }
255
+
256
+ type NodeRenderer = (node: IrNode, level: number, itemScope?: string) => string;
257
+
258
+ function renderChildren(
259
+ children: IrNode[],
260
+ level: number,
261
+ renderNested: NodeRenderer,
262
+ ctx: SwiftUIRenderContext,
263
+ itemScope?: string,
264
+ ): string {
265
+ const parts = children.map((child) => {
266
+ if (child.kind === 'map') return renderMap(child, level, renderNested, ctx);
267
+ if (child.kind === 'text') return `${indent(level)}Text(${JSON.stringify((child as IrTextNode).value)})`;
268
+ if (child.kind === 'binding') return `${indent(level)}Text(${bindingExpr(child as IrBinding, ctx, itemScope)})`;
269
+ return renderNested(child, level, itemScope);
270
+ }).filter(Boolean);
271
+ return parts.join('\n');
272
+ }
273
+
274
+ function renderMap(node: IrMapNode, level: number, renderNested: NodeRenderer, ctx: SwiftUIRenderContext): string {
275
+ const item = node.item;
276
+ const body = renderNested(node.body, level + 2, item);
277
+ const source = bindingExpr(node.source, ctx, item);
278
+ return `${indent(level)}ForEach(${source}, id: \\.id) { ${item} in\n${body}\n${indent(level)}}`;
279
+ }
280
+
281
+ function renderLeaf(
282
+ node: IrComponentNode,
283
+ entry: SwiftUIElementConfig,
284
+ level: number,
285
+ itemScope: string | undefined,
286
+ ctx: SwiftUIRenderContext,
287
+ modifiers: string[],
288
+ ): string {
289
+ const pad = indent(level);
290
+
291
+ if (entry.leaf === 'text') {
292
+ const content = renderTextContent(node.children, ctx, itemScope);
293
+ if (!content) return `${pad}Text("")`;
294
+ const textLine = `${pad}Text(${content})`;
295
+ const modLines = modifiers.map((mod) => `${pad}${mod}`);
296
+ return [textLine, ...modLines].join('\n');
297
+ }
298
+
299
+ if (entry.leaf === 'button') {
300
+ const label = propLiteral(node.props, 'label');
301
+ const textContent = renderTextContent(node.children, ctx, itemScope);
302
+ const title = textContent ?? (typeof label === 'string' ? JSON.stringify(label) : '"Button"');
303
+ const line = `${pad}Button(${title}) {}`;
304
+ const modLines = modifiers.map((mod) => `${pad}${mod}`);
305
+ return [line, ...modLines].join('\n');
306
+ }
307
+
308
+ if (entry.leaf === 'textfield') {
309
+ const placeholder = propLiteral(node.props, 'placeholder');
310
+ const ph = typeof placeholder === 'string' ? JSON.stringify(placeholder) : '""';
311
+ const mutedColor = tokenHexColorExpr(ctx.tokens, 'ui.text-muted', '#6b7280');
312
+ const line = `${pad}TextField("", text: .constant(""), prompt: Text(${ph}).foregroundStyle(${mutedColor}))`;
313
+ const allMods = [...modifiers, ...leafExtraModifiers(node.name)];
314
+ const modLines = allMods.map((mod) => `${pad}${mod}`);
315
+ return [line, ...modLines].join('\n');
316
+ }
317
+
318
+ if (entry.leaf === 'texteditor') {
319
+ const placeholder = propLiteral(node.props, 'placeholder');
320
+ const ph = typeof placeholder === 'string' ? JSON.stringify(placeholder) : '""';
321
+ const mutedColor = tokenHexColorExpr(ctx.tokens, 'ui.text-muted', '#6b7280');
322
+ const minHeightMod = modifiers.find((mod) => mod.startsWith('.frame(minHeight:'));
323
+ const minHeight = minHeightMod?.match(/minHeight: (\d+)/)?.[1] ?? '72';
324
+
325
+ // Split modifiers: text-related go on TextEditor, visual/layout go on ZStack
326
+ const textMods = modifiers.filter((mod) =>
327
+ mod.startsWith('.foregroundStyle(') || mod.startsWith('.font(') || mod.startsWith('.fontWeight(')
328
+ );
329
+ const containerMods = modifiers.filter((mod) =>
330
+ !mod.startsWith('.foregroundStyle(') && !mod.startsWith('.font(') &&
331
+ !mod.startsWith('.fontWeight(') && !mod.startsWith('.frame(minHeight:')
332
+ );
333
+
334
+ const textModLines = textMods.map((mod) => `${pad} ${mod}`);
335
+ const containerModLines = containerMods.map((mod) => `${pad}${mod}`);
336
+ return [
337
+ `${pad}ZStack(alignment: .topLeading) {`,
338
+ `${pad} if true {`,
339
+ `${pad} Text(${ph})`,
340
+ `${pad} .font(.system(size: 14))`,
341
+ `${pad} .foregroundStyle(${mutedColor})`,
342
+ `${pad} .padding(.top, 8)`,
343
+ `${pad} .padding(.horizontal, 4)`,
344
+ `${pad} .allowsHitTesting(false)`,
345
+ `${pad} }`,
346
+ `${pad} TextEditor(text: .constant(""))`,
347
+ `${pad} .scrollContentBackground(.hidden)`,
348
+ ...textModLines,
349
+ `${pad}}`,
350
+ `${pad}.frame(minHeight: ${minHeight})`,
351
+ ...containerModLines,
352
+ ].join('\n');
353
+ }
354
+
355
+ if (entry.leaf === 'picker') {
356
+ const textColor = tokenHexColorExpr(ctx.tokens, 'ui.text', '#111827');
357
+ const mutedColor = tokenHexColorExpr(ctx.tokens, 'ui.text-muted', '#6b7280');
358
+ const optionsBlock = emitMenuOptions(node.props, ctx, itemScope, level);
359
+ const labelText = emitSelectLabelText(node.props, ctx, itemScope);
360
+ const labelMods = modifiers.filter((mod) => !mod.startsWith('.frame'));
361
+ const widthLit = propLiteral(node.props, 'width');
362
+ const widthMod = typeof widthLit === 'number'
363
+ ? `${pad}.frame(width: ${widthLit})`
364
+ : `${pad}.frame(maxWidth: .infinity)`;
365
+ const lines = [
366
+ `${pad}Menu {`,
367
+ optionsBlock,
368
+ `${pad}} label: {`,
369
+ `${pad} HStack(spacing: 0) {`,
370
+ `${pad} Text(${labelText})`,
371
+ `${pad} .foregroundStyle(${textColor})`,
372
+ `${pad} .lineLimit(1)`,
373
+ `${pad} Spacer(minLength: 0)`,
374
+ `${pad} Image(systemName: "chevron.down")`,
375
+ `${pad} .font(.system(size: 12, weight: .semibold))`,
376
+ `${pad} .foregroundStyle(${mutedColor})`,
377
+ `${pad} }`,
378
+ `${pad} .padding(.vertical, 9)`,
379
+ `${pad} .padding(.leading, 12)`,
380
+ `${pad} .padding(.trailing, 12)`,
381
+ ...labelMods.map((mod) => `${pad} ${mod}`),
382
+ `${pad}}`,
383
+ widthMod,
384
+ `${pad}.frame(minHeight: 42)`,
385
+ `${pad}.frame(maxWidth: .infinity)`,
386
+ ];
387
+ return lines.join('\n');
388
+ }
389
+
390
+ if (entry.leaf === 'spacer') {
391
+ return `${pad}Spacer()`;
392
+ }
393
+
394
+ if (entry.leaf === 'divider') {
395
+ return `${pad}Divider()`;
396
+ }
397
+
398
+ if (entry.leaf === 'image') {
399
+ const src = propLiteral(node.props, 'src') ?? propLiteral(node.props, 'name');
400
+ const systemName = typeof src === 'string' ? src : 'photo';
401
+ const line = `${pad}Image(systemName: ${JSON.stringify(systemName)})`;
402
+ const modLines = modifiers.map((mod) => `${pad}${mod}`);
403
+ return [line, ...modLines].join('\n');
404
+ }
405
+
406
+ if (entry.leaf === 'progressview') {
407
+ const line = `${pad}ProgressView()`;
408
+ const modLines = modifiers.map((mod) => `${pad}${mod}`);
409
+ return [line, ...modLines].join('\n');
410
+ }
411
+
412
+ if (entry.leaf === 'toggle') {
413
+ const label = propLiteral(node.props, 'label') ?? '';
414
+ const line = `${pad}Toggle(${JSON.stringify(label)}, isOn: .constant(false))`;
415
+ const modLines = modifiers.map((mod) => `${pad}${mod}`);
416
+ return [line, ...modLines].join('\n');
417
+ }
418
+
419
+ return `${pad}Text("/* unsupported leaf: ${node.name} */")`;
420
+ }
421
+
422
+ function renderContainer(
423
+ node: IrComponentNode,
424
+ entry: SwiftUIElementConfig,
425
+ level: number,
426
+ itemScope: string | undefined,
427
+ renderNested: NodeRenderer,
428
+ ctx: SwiftUIRenderContext,
429
+ modifiers: string[],
430
+ ): string {
431
+ const childBlock = renderChildren(node.children, level + 2, renderNested, ctx, itemScope);
432
+
433
+ if (entry.container === 'vstack') {
434
+ const spacingLit = entry.spacingProp ? propLiteral(node.props, entry.spacingProp) : undefined;
435
+ const spacing = typeof spacingLit === 'number' ? spacingLit : 0;
436
+ const inner = `${indent(level + 1)}VStack(alignment: .leading, spacing: ${spacing}) {\n${childBlock}\n${indent(level + 1)}}`;
437
+ return applyModifiers(inner, modifiers, level + 1);
438
+ }
439
+
440
+ if (entry.container === 'hstack') {
441
+ const spacingLit = entry.spacingProp ? propLiteral(node.props, entry.spacingProp) : undefined;
442
+ const spacing = typeof spacingLit === 'number' ? spacingLit : 0;
443
+ const alignLit = propLiteral(node.props, 'align');
444
+ const heightLit = propLiteral(node.props, 'height');
445
+ const alignment = alignLit === 'center' || typeof heightLit === 'number' ? '.center' : '.top';
446
+ const justifyLit = propLiteral(node.props, 'justify');
447
+ const flexLit = propLiteral(node.props, 'flex');
448
+ const wrapLit = propLiteral(node.props, 'wrap');
449
+ const isTableElement = node.name === 'TableHeader' || node.name === 'TableRow';
450
+ const shouldFill = isTableElement || justifyLit !== undefined || typeof flexLit === 'number';
451
+ const hStackFrameAlign = justifyLit === 'end' ? '.trailing' : justifyLit === 'center' ? '.center' : '.leading';
452
+ const frameMods: string[] = [];
453
+ if (shouldFill) frameMods.push(`.frame(maxWidth: .infinity, alignment: ${hStackFrameAlign})`);
454
+ if (typeof heightLit === 'number') frameMods.push(`.frame(height: ${heightLit})`);
455
+
456
+ let block = childBlock;
457
+ if (justifyLit === 'between') {
458
+ const childLines = node.children.map((child) => {
459
+ if (child.kind === 'map') return renderMap(child, level + 2, renderNested, ctx);
460
+ if (child.kind === 'text') return `${indent(level + 2)}Text(${JSON.stringify((child as IrTextNode).value)})`;
461
+ if (child.kind === 'binding') return `${indent(level + 2)}Text(${bindingExpr(child as IrBinding, ctx, itemScope)})`;
462
+ return renderNested(child, level + 2, itemScope);
463
+ }).filter(Boolean);
464
+ if (childLines.length >= 2) {
465
+ const first = childLines[0]!;
466
+ const rest = childLines.slice(1);
467
+ block = [first, `${indent(level + 2)}Spacer()`, ...rest].join('\n');
468
+ }
469
+ } else if (wrapLit === true) {
470
+ block = node.children.map((child) => {
471
+ if (child.kind === 'map') return renderMap(child, level + 2, renderNested, ctx);
472
+ if (child.kind === 'text') return `${indent(level + 2)}Text(${JSON.stringify((child as IrTextNode).value)})`;
473
+ if (child.kind === 'binding') return `${indent(level + 2)}Text(${bindingExpr(child as IrBinding, ctx, itemScope)})`;
474
+ const rendered = renderNested(child, level + 2, itemScope);
475
+ if (!rendered) return '';
476
+ return rendered;
477
+ }).filter(Boolean).join('\n');
478
+
479
+ // Use FlowLayout (custom Layout protocol) for wrapping
480
+ const inner = `${indent(level + 1)}FlowLayout(spacing: ${spacing}) {\n${block}\n${indent(level + 1)}}`;
481
+ return applyModifiers(inner, [...modifiers, ...frameMods], level + 1);
482
+ }
483
+
484
+ const inner = `${indent(level + 1)}HStack(alignment: ${alignment}, spacing: ${spacing}) {\n${block}\n${indent(level + 1)}}`;
485
+ return applyModifiers(inner, [...modifiers, ...frameMods], level + 1);
486
+ }
487
+
488
+ if (entry.container === 'group') {
489
+ const inner = childBlock
490
+ ? `${indent(level + 1)}VStack(alignment: .leading, spacing: 0) {\n${childBlock}\n${indent(level + 1)}}`
491
+ : `${indent(level + 1)}VStack { EmptyView() }`;
492
+ return applyModifiers(inner, modifiers, level + 1);
493
+ }
494
+
495
+ if (entry.container === 'navstack') {
496
+ const inner = childBlock;
497
+ return applyModifiers(inner, modifiers, level + 1);
498
+ }
499
+
500
+ if (entry.container === 'scrollview') {
501
+ const inner = `${indent(level + 1)}ScrollView {\n${childBlock}\n${indent(level + 1)}}`;
502
+ return applyModifiers(inner, modifiers, level + 1);
503
+ }
504
+
505
+ if (entry.container === 'field') {
506
+ const label = propLiteral(node.props, 'label');
507
+ const gapLit = propLiteral(node.props, 'gap');
508
+ const gap = typeof gapLit === 'number' ? gapLit : 4;
509
+ const labelView = typeof label === 'string'
510
+ ? `\n${indent(level + 2)}Text(${JSON.stringify(label)})\n${indent(level + 2)} .applyVcStyle(VcTheme.resolve("Text", variant: "field-label"))`
511
+ : '';
512
+ const inner = `${indent(level + 1)}VStack(alignment: .leading, spacing: ${gap}) {${labelView}\n${childBlock}\n${indent(level + 1)}}`;
513
+ return applyModifiers(inner, modifiers, level + 1);
514
+ }
515
+
516
+ if (entry.container === 'tablestack') {
517
+ const separatorLit = propLiteral(node.props, 'separator');
518
+ const separatorColorLit = propLiteral(node.props, 'separatorColor');
519
+ const hasSeparator = typeof separatorLit === 'number' && separatorLit > 0;
520
+
521
+ const childParts = node.children.map((child) => {
522
+ if (child.kind === 'map') return renderMap(child, level + 2, renderNested, ctx);
523
+ return renderNested(child, level + 2, itemScope);
524
+ }).filter(Boolean);
525
+
526
+ let body: string[];
527
+ if (hasSeparator) {
528
+ const resolved = resolvedVisualProps(node.name, node.props, ctx);
529
+ const colorStr = resolved.separatorColor ?? separatorColorLit;
530
+ let colorExpr = 'Color.gray.opacity(0.2)';
531
+ if (typeof colorStr === 'string') {
532
+ const hex = colorStr.match(/^#([0-9a-fA-F]{6})$/);
533
+ if (hex) {
534
+ const r = parseInt(hex[1]!.slice(0, 2), 16) / 255;
535
+ const g = parseInt(hex[1]!.slice(2, 4), 16) / 255;
536
+ const b = parseInt(hex[1]!.slice(4, 6), 16) / 255;
537
+ colorExpr = `Color(red: ${r}, green: ${g}, blue: ${b})`;
538
+ }
539
+ }
540
+ const thickness = separatorLit as number;
541
+ const divLine = thickness === 1
542
+ ? `${indent(level + 2)}Divider().overlay(${colorExpr})`
543
+ : `${indent(level + 2)}Rectangle().fill(${colorExpr}).frame(height: ${thickness})`;
544
+ body = childParts.flatMap((part, i) =>
545
+ i < childParts.length - 1 ? [part, divLine] : [part],
546
+ );
547
+ } else {
548
+ body = childParts;
549
+ }
550
+
551
+ const inner = `${indent(level + 1)}VStack(alignment: .leading, spacing: 0) {\n${body.join('\n')}\n${indent(level + 1)}}`;
552
+ return applyModifiers(inner, modifiers, level + 1);
553
+ }
554
+
555
+ if (entry.container === 'cellgroup') {
556
+ const widthLit = propLiteral(node.props, 'width');
557
+ const flexLit = propLiteral(node.props, 'flex');
558
+ const alignLit = propLiteral(node.props, 'align');
559
+ const swiftAlign = alignLit === 'end' ? '.trailing' : alignLit === 'center' ? '.center' : '.leading';
560
+ const isFlex = typeof flexLit === 'number';
561
+ const cellMods = [...modifiers, '.lineLimit(1)'];
562
+ if (typeof widthLit === 'number') {
563
+ cellMods.push(`.frame(width: ${widthLit}, alignment: ${swiftAlign})`);
564
+ } else if (isFlex) {
565
+ cellMods.push(`.frame(maxWidth: .infinity, alignment: ${swiftAlign})`);
566
+ }
567
+ const inner = childBlock
568
+ ? `${indent(level + 1)}Group {\n${childBlock}\n${indent(level + 2)}.frame(maxWidth: .infinity, alignment: ${swiftAlign})\n${indent(level + 1)}}`
569
+ : `${indent(level + 1)}Group { EmptyView() }`;
570
+ return applyModifiers(inner, cellMods, level + 1);
571
+ }
572
+
573
+ const inner = childBlock
574
+ ? `${indent(level + 1)}Group {\n${childBlock}\n${indent(level + 1)}}`
575
+ : `${indent(level + 1)}Group { EmptyView() }`;
576
+ return applyModifiers(inner, modifiers, level + 1);
577
+ }
578
+
579
+
580
+ function resolvedVisualProps(
581
+ elementName: string,
582
+ props: Record<string, IrValue>,
583
+ ctx: SwiftUIRenderContext,
584
+ ): VisualPropSet {
585
+ const variantLit = propLiteral(props, 'variant');
586
+ const instanceLiterals = collectInstanceVisualLiterals(props);
587
+ return mergeVisualProps({
588
+ elementName,
589
+ theme: ctx.theme,
590
+ tokens: ctx.tokens,
591
+ variantLiteral: typeof variantLit === 'string' ? variantLit : undefined,
592
+ instanceLiterals,
593
+ });
594
+ }
595
+
596
+ function parseGridColumns(gridTemplateColumns: string | number | boolean | undefined): { kind: 'fixed-columns'; widths: string[] } | { kind: 'adaptive'; minWidth: number } | { kind: 'equal-columns'; count: number } | null {
597
+ if (!gridTemplateColumns || typeof gridTemplateColumns !== 'string') return null;
598
+ const equalRepeatMatch = gridTemplateColumns.match(/repeat\((\d+),\s*minmax\(0,\s*1fr\)\)/);
599
+ if (equalRepeatMatch) return { kind: 'equal-columns', count: Number(equalRepeatMatch[1]) };
600
+ const adaptiveMatch = gridTemplateColumns.match(/repeat\(auto-fit,\s*minmax\((\d+)px/);
601
+ if (adaptiveMatch) return { kind: 'adaptive', minWidth: Number(adaptiveMatch[1]) };
602
+ const parts = gridTemplateColumns.trim().split(/\s+/);
603
+ if (parts.length >= 2) return { kind: 'fixed-columns', widths: parts };
604
+ return null;
605
+ }
606
+
607
+ function renderComponent(
608
+ node: IrComponentNode,
609
+ entry: SwiftUIElementConfig,
610
+ level: number,
611
+ itemScope: string | undefined,
612
+ renderNested: NodeRenderer,
613
+ ctx: SwiftUIRenderContext,
614
+ ): string {
615
+ const modifiers = mergedModifiersForProps(node.name, node.props, ctx, itemScope);
616
+
617
+ if (entry.leaf) {
618
+ return renderLeaf(node, entry, level, itemScope, ctx, modifiers);
619
+ }
620
+
621
+ if (entry.container === 'group' && !isDynamicVariant(node.props)) {
622
+ const resolved = resolvedVisualProps(node.name, node.props, ctx);
623
+ const display = resolved.display;
624
+ const gridCols = resolved.gridTemplateColumns;
625
+ const flexDir = resolved.flexDirection;
626
+
627
+ if (display === 'grid' && gridCols) {
628
+ const parsed = parseGridColumns(gridCols);
629
+ if (parsed?.kind === 'adaptive') {
630
+ return renderGridAdaptive(node, level, itemScope, renderNested, ctx, modifiers, parsed.minWidth);
631
+ }
632
+ if (parsed?.kind === 'equal-columns') {
633
+ return renderGridEqualColumns(node, level, itemScope, renderNested, ctx, modifiers, parsed.count);
634
+ }
635
+ if (parsed?.kind === 'fixed-columns') {
636
+ return renderGridFixedColumns(node, level, itemScope, renderNested, ctx, modifiers, parsed.widths);
637
+ }
638
+ }
639
+
640
+ if (display === 'flex' && flexDir === 'row') {
641
+ const childBlock = renderChildren(node.children, level + 2, renderNested, ctx, itemScope);
642
+ const gapLit = propLiteral(node.props, 'gap');
643
+ const gap = typeof gapLit === 'number' ? gapLit : (typeof resolved.gap === 'number' ? resolved.gap : 0);
644
+ const inner = `${indent(level + 1)}HStack(alignment: .top, spacing: ${gap}) {\n${childBlock}\n${indent(level + 1)}}`;
645
+ return applyModifiers(inner, modifiers, level + 1);
646
+ }
647
+ }
648
+
649
+ if (entry.container) {
650
+ return renderContainer(node, entry, level, itemScope, renderNested, ctx, modifiers);
651
+ }
652
+
653
+ throw new Error(`Unsupported SwiftUI element config for ${node.name}`);
654
+ }
655
+
656
+ function renderGridAdaptive(
657
+ node: IrComponentNode, level: number, itemScope: string | undefined,
658
+ renderNested: NodeRenderer, ctx: SwiftUIRenderContext, modifiers: string[], minWidth: number,
659
+ ): string {
660
+ const gapLit = propLiteral(node.props, 'gap');
661
+ const gap = typeof gapLit === 'number' ? gapLit : 16;
662
+ const childBlock = renderGridChildBlock(node.children, level, renderNested, ctx, itemScope);
663
+ const inner = `${indent(level + 1)}LazyVGrid(columns: [GridItem(.adaptive(minimum: ${minWidth}))], spacing: ${gap}) {\n${childBlock}\n${indent(level + 1)}}\n${indent(level + 1)}.frame(maxWidth: .infinity, alignment: .topLeading)`;
664
+ return applyModifiers(inner, modifiers, level + 1);
665
+ }
666
+
667
+ function renderGridEqualColumns(
668
+ node: IrComponentNode, level: number, itemScope: string | undefined,
669
+ renderNested: NodeRenderer, ctx: SwiftUIRenderContext, modifiers: string[], count: number,
670
+ ): string {
671
+ const gapLit = propLiteral(node.props, 'gap');
672
+ const gap = typeof gapLit === 'number' ? gapLit : 16;
673
+ const columns = `Array(repeating: GridItem(.flexible(), spacing: ${gap}), count: ${count})`;
674
+ const childBlock = renderGridChildBlock(node.children, level, renderNested, ctx, itemScope);
675
+ const inner = `${indent(level + 1)}LazyVGrid(columns: ${columns}, spacing: ${gap}) {\n${childBlock}\n${indent(level + 1)}}\n${indent(level + 1)}.frame(maxWidth: .infinity, alignment: .topLeading)`;
676
+ return applyModifiers(inner, modifiers, level + 1);
677
+ }
678
+
679
+ function resolveBackgroundColor(child: IrNode, ctx: SwiftUIRenderContext): string | null {
680
+ if (child.kind !== 'component') return null;
681
+ const resolved = resolvedVisualProps(child.name, (child as IrComponentNode).props, ctx);
682
+ const bg = resolved.background;
683
+ if (typeof bg !== 'string' || bg === 'transparent') return null;
684
+ const hex = bg.match(/^#([0-9a-fA-F]{6})$/);
685
+ if (hex) {
686
+ const r = parseInt(hex[1]!.slice(0, 2), 16) / 255;
687
+ const g = parseInt(hex[1]!.slice(2, 4), 16) / 255;
688
+ const b = parseInt(hex[1]!.slice(4, 6), 16) / 255;
689
+ return `Color(red: ${r}, green: ${g}, blue: ${b})`;
690
+ }
691
+ return `Color(${JSON.stringify(bg)})`;
692
+ }
693
+
694
+ function renderGridFixedColumns(
695
+ node: IrComponentNode, level: number, itemScope: string | undefined,
696
+ renderNested: NodeRenderer, ctx: SwiftUIRenderContext, modifiers: string[], widths: string[],
697
+ ): string {
698
+ const children = node.children;
699
+ const parts: string[] = [];
700
+ for (let i = 0; i < children.length; i++) {
701
+ const child = children[i]!;
702
+ let rendered = '';
703
+ if (child.kind === 'map') rendered = renderMap(child, level + 2, renderNested, ctx);
704
+ else if (child.kind === 'text') rendered = `${indent(level + 2)}Text(${JSON.stringify((child as IrTextNode).value)})`;
705
+ else if (child.kind === 'binding') rendered = `${indent(level + 2)}Text(${bindingExpr(child as IrBinding, ctx, itemScope)})`;
706
+ else rendered = renderNested(child, level + 2, itemScope);
707
+ if (!rendered) continue;
708
+ const w = widths[i];
709
+ const pxMatch = w?.match(/^(\d+)px$/);
710
+ if (pxMatch) {
711
+ rendered += `\n${indent(level + 2)}.frame(width: ${Number(pxMatch[1])}, alignment: .topLeading)`;
712
+ rendered += `\n${indent(level + 2)}.frame(maxHeight: .infinity, alignment: .topLeading)`;
713
+ const bgColor = resolveBackgroundColor(child, ctx);
714
+ if (bgColor) rendered += `\n${indent(level + 2)}.background(${bgColor})`;
715
+ } else if (w === '1fr') {
716
+ // Content inside ScrollView must explicitly fill width
717
+ rendered += `\n${indent(level + 3)}.frame(maxWidth: .infinity, alignment: .topLeading)`;
718
+ rendered = `${indent(level + 2)}ScrollView {\n${rendered}\n${indent(level + 2)}}`;
719
+ rendered += `\n${indent(level + 2)}.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)`;
720
+ }
721
+ parts.push(rendered);
722
+ }
723
+ const childBlock = parts.join('\n');
724
+ const inner = `${indent(level + 1)}HStack(spacing: 0) {\n${childBlock}\n${indent(level + 1)}}`;
725
+ return applyModifiers(inner, [...modifiers, '.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)'], level + 1);
726
+ }
727
+
728
+ function renderViewRef(node: IrViewRefNode, level: number, itemScope: string | undefined, ctx: SwiftUIRenderContext): string {
729
+ const props = Object.entries(node.props).map(([key, value]) => {
730
+ if (typeof value === 'object' && value !== null && 'kind' in value && value.kind === 'binding') {
731
+ return `${key}: ${bindingExpr(value as IrBinding, ctx, itemScope)}`;
732
+ }
733
+ return `${key}: ${JSON.stringify(value)}`;
734
+ });
735
+ const argStr = props.length > 0 ? `(${props.join(', ')})` : '()';
736
+ return `${indent(level)}${node.name}${argStr}`;
737
+ }
738
+
739
+ export function createSwiftUINodeRenderer(ctx: SwiftUIRenderContext): NodeRenderer {
740
+ const renderNested: NodeRenderer = (node, level, itemScope) => {
741
+ if (node.kind === 'viewRef') {
742
+ return renderViewRef(node, level, itemScope, ctx);
743
+ }
744
+ if (node.kind !== 'component') {
745
+ return '';
746
+ }
747
+ if (!ctx.map.elements[node.name]) {
748
+ throw new Error(`Element "${node.name}" is not in SwiftUI element map (prototype subset)`);
749
+ }
750
+ return renderComponent(node, ctx.map.elements[node.name]!, level, itemScope, renderNested, ctx);
751
+ };
752
+ return renderNested;
753
+ }
754
+
755
+ export function lowerIrToSwiftBody(root: IrNode, ctx: SwiftUIRenderContext, level = 2): string {
756
+ const render = createSwiftUINodeRenderer(ctx);
757
+ if (root.kind !== 'component') {
758
+ throw new Error('SwiftUI lowering root must be a component node');
759
+ }
760
+ return render(root, level);
761
+ }