view-contracts 0.4.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "view-contracts",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Contract-first UI design toolchain — restricted TSX to View IR to platform renderers",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -29,6 +29,21 @@ defaults:
29
29
  border: "{ui.layout.border-1}"
30
30
  borderColor: "{ui.border}"
31
31
  minHeight: "{ui.space.input-min-height}"
32
+ TableHeader:
33
+ background: "{ui.surface-muted}"
34
+ alignItems: "{ui.layout.align-center}"
35
+ TableColumn:
36
+ padding: "{ui.space.table-column-padding}"
37
+ fontSize: "{ui.space.table-column-font-size}"
38
+ fontWeight: "{ui.layout.font-semibold}"
39
+ foreground: "{ui.text-muted}"
40
+ TableCell:
41
+ padding: "{ui.space.table-cell-padding}"
42
+ fontSize: "{ui.space.table-cell-font-size}"
43
+ TableRow:
44
+ alignItems: "{ui.layout.align-center}"
45
+ Box:
46
+ width: "{ui.layout.width-full}"
32
47
  variants:
33
48
  Button:
34
49
  primary:
@@ -46,6 +61,16 @@ variants:
46
61
  Text:
47
62
  muted:
48
63
  foreground: "{ui.text-muted}"
64
+ field-label:
65
+ fontSize: "{ui.space.font-field-label}"
66
+ fontWeight: "{ui.layout.font-bold}"
67
+ foreground: "{ui.text}"
68
+ field-help:
69
+ fontSize: "{ui.space.font-field-help}"
70
+ foreground: "{ui.text-muted}"
71
+ field-error:
72
+ fontSize: "{ui.space.font-field-help}"
73
+ foreground: "{ui.danger}"
49
74
  primary:
50
75
  foreground: "{ui.primary}"
51
76
  success:
@@ -54,3 +79,7 @@ variants:
54
79
  foreground: "{ui.warning}"
55
80
  danger:
56
81
  foreground: "{ui.danger}"
82
+ Box:
83
+ card:
84
+ background: "{ui.surface}"
85
+ width: "{ui.layout.width-full}"
@@ -1,4 +1,4 @@
1
- # Default design tokens — mirrors renderers/react/runtime/theme.css :root variables.
1
+ # Default design tokens — merged into generated/react/theme.css via emitTokenCss().
2
2
  ui:
3
3
  bg:
4
4
  $type: color
@@ -61,9 +61,18 @@ ui:
61
61
  font-bold:
62
62
  $type: layout
63
63
  $value: "700"
64
+ font-semibold:
65
+ $type: layout
66
+ $value: "600"
64
67
  border-1:
65
68
  $type: layout
66
69
  $value: "1px solid"
70
+ align-center:
71
+ $type: layout
72
+ $value: "center"
73
+ width-full:
74
+ $type: layout
75
+ $value: "100%"
67
76
  space:
68
77
  radius-md:
69
78
  $type: dimension
@@ -83,3 +92,54 @@ ui:
83
92
  textarea-padding:
84
93
  $type: dimension
85
94
  $value: "10px 12px"
95
+ field-gap:
96
+ $type: dimension
97
+ $value: "6px"
98
+ field-inner-gap:
99
+ $type: dimension
100
+ $value: "4px"
101
+ checkbox-gap:
102
+ $type: dimension
103
+ $value: "8px"
104
+ list-indent:
105
+ $type: dimension
106
+ $value: "20px"
107
+ list-item-padding:
108
+ $type: dimension
109
+ $value: "2px 0"
110
+ line-height-body:
111
+ $type: dimension
112
+ $value: "1.6"
113
+ line-height-heading:
114
+ $type: dimension
115
+ $value: "1.2"
116
+ font-field-label:
117
+ $type: dimension
118
+ $value: "13px"
119
+ font-field-help:
120
+ $type: dimension
121
+ $value: "12px"
122
+ table-column-padding:
123
+ $type: dimension
124
+ $value: "12px 14px"
125
+ table-column-font-size:
126
+ $type: dimension
127
+ $value: "12px"
128
+ table-cell-padding:
129
+ $type: dimension
130
+ $value: "14px"
131
+ table-cell-font-size:
132
+ $type: dimension
133
+ $value: "14px"
134
+ input-min-height-sm:
135
+ $type: dimension
136
+ $value: "34px"
137
+ input-min-height-lg:
138
+ $type: dimension
139
+ $value: "50px"
140
+ input-padding-sm:
141
+ $type: dimension
142
+ $value: "6px 10px"
143
+ input-padding-lg:
144
+ $type: dimension
145
+ $value: "12px 14px"
@@ -160,20 +160,20 @@ function emitFieldWrap(inner: string, props: Record<string, IrValue>, level: num
160
160
  const error = propExpr(props, 'error');
161
161
  const lines = [`${indent(level)}<label className="vc-field">`];
162
162
  if (typeof labelLit === 'string') {
163
- lines.push(`${indent(level + 1)}<span className="vc-field-label">{${JSON.stringify(labelLit)}}</span>`);
163
+ lines.push(`${indent(level + 1)}<span className="vc-text vc-text--field-label">{${JSON.stringify(labelLit)}}</span>`);
164
164
  } else if (label) {
165
- lines.push(`${indent(level + 1)}{${label} ? <span className="vc-field-label">{${label}}</span> : null}`);
165
+ lines.push(`${indent(level + 1)}{${label} ? <span className="vc-text vc-text--field-label">{${label}}</span> : null}`);
166
166
  }
167
167
  lines.push(`${indent(level + 1)}${inner}`);
168
168
  if (typeof helpLit === 'string') {
169
- lines.push(`${indent(level + 1)}<span className="vc-field-help">{${JSON.stringify(helpLit)}}</span>`);
169
+ lines.push(`${indent(level + 1)}<span className="vc-text vc-text--field-help">{${JSON.stringify(helpLit)}}</span>`);
170
170
  } else if (help) {
171
- lines.push(`${indent(level + 1)}{${help} ? <span className="vc-field-help">{${help}}</span> : null}`);
171
+ lines.push(`${indent(level + 1)}{${help} ? <span className="vc-text vc-text--field-help">{${help}}</span> : null}`);
172
172
  }
173
173
  if (typeof errorLit === 'string') {
174
- lines.push(`${indent(level + 1)}<span className="vc-field-error">{${JSON.stringify(errorLit)}}</span>`);
174
+ lines.push(`${indent(level + 1)}<span className="vc-text vc-text--field-error">{${JSON.stringify(errorLit)}}</span>`);
175
175
  } else if (error) {
176
- lines.push(`${indent(level + 1)}{${error} ? <span className="vc-field-error">{${error}}</span> : null}`);
176
+ lines.push(`${indent(level + 1)}{${error} ? <span className="vc-text vc-text--field-error">{${error}}</span> : null}`);
177
177
  }
178
178
  lines.push(`${indent(level)}</label>`);
179
179
  return lines.join('\n');
@@ -1 +1,5 @@
1
- export { reactTemplatesDir, reactRuntimeDir } from '../../src/lib/rendererPaths.js';
1
+ export {
2
+ reactTemplatesDir,
3
+ reactRuntimeDir,
4
+ reactStructuralCssPath,
5
+ } from '../../src/lib/rendererPaths.js';
@@ -0,0 +1,38 @@
1
+ /* DSL structural layer — layout/control mechanics only; no visual token values. */
2
+ * { box-sizing: border-box; }
3
+ body { margin: 0; background: var(--ui-bg); color: var(--ui-text); font-family: var(--ui-font); font-size: var(--ui-space-font-body); }
4
+ button, input, textarea, select { font: inherit; }
5
+
6
+ .vc-page { min-height: 100vh; min-width: 0; }
7
+ .vc-box { display: block; min-width: 0; }
8
+ .vc-column { display: flex; flex-direction: column; min-width: 0; }
9
+ .vc-stack { display: flex; flex-direction: column; min-width: 0; }
10
+ .vc-row { display: flex; flex-direction: row; min-width: 0; }
11
+ .vc-wrap { flex-wrap: wrap; }
12
+ .vc-spacer { min-width: 0; min-height: 0; }
13
+
14
+ .vc-heading { margin: 0; line-height: 1.2; letter-spacing: -0.02em; }
15
+ .vc-text { margin: 0; line-height: 1.6; }
16
+ .vc-link { color: var(--ui-primary); text-decoration: none; }
17
+ .vc-link:hover { text-decoration: underline; }
18
+ .vc-divider { border: 0; border-top: 1px solid var(--ui-border); width: 100%; }
19
+
20
+ .vc-button { border: 0; cursor: pointer; }
21
+
22
+ .vc-form { display: flex; flex-direction: column; min-width: 0; }
23
+ .vc-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
24
+
25
+ .vc-text-input, .vc-text-area, .vc-select { outline: none; }
26
+ .vc-text-input:focus, .vc-text-area:focus, .vc-select:focus { border-color: var(--ui-primary); box-shadow: 0 0 0 3px var(--ui-primary-weak); }
27
+ .vc-select { appearance: none; -webkit-appearance: none; -moz-appearance: none; cursor: pointer; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='M4 6l4 4 4-4' stroke='%236b7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; background-size: 16px; }
28
+ .vc-text-area { resize: vertical; }
29
+
30
+ .vc-checkbox { display: inline-flex; align-items: center; gap: 8px; }
31
+ .vc-list { margin: 0; padding-left: 20px; }
32
+ .vc-list-item { margin: 0; padding: 2px 0; }
33
+
34
+ .vc-table { width: 100%; display: flex; flex-direction: column; min-width: 0; }
35
+ .vc-table-header { display: flex; align-items: center; min-width: 0; }
36
+ .vc-table-column { display: flex; align-items: center; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
37
+ .vc-table-row { display: flex; align-items: center; border-top: 1px solid var(--ui-border); min-width: 0; }
38
+ .vc-table-cell { display: flex; align-items: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
@@ -39,13 +39,23 @@ export function foldLiteralsToCss(literals: Record<string, unknown>): FoldedCss
39
39
  const style: FoldedCss = {};
40
40
  const width = literals.width;
41
41
  if (typeof width === 'number') style.width = len(width);
42
- else if (typeof width === 'string' && width !== 'wrap' && width !== 'fill') style.width = width;
42
+ else if (width === 'fill') style.width = '100%';
43
+ else if (typeof width === 'string' && width !== 'wrap') style.width = width;
43
44
 
44
45
  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);
46
+ const maxWidth = literals.maxWidth;
47
+ if (typeof maxWidth === 'number') style.maxWidth = len(maxWidth);
48
+ else if (maxWidth === 'fill') style.maxWidth = '100%';
49
+
50
+ const height = literals.height;
51
+ if (typeof height === 'number') style.height = len(height);
52
+ else if (height === 'fill') style.height = '100%';
53
+ else if (typeof height === 'string' && height !== 'wrap') style.height = height;
54
+
47
55
  if (typeof literals.minHeight === 'number') style.minHeight = len(literals.minHeight);
48
- if (typeof literals.maxHeight === 'number') style.maxHeight = len(literals.maxHeight);
56
+ const maxHeight = literals.maxHeight;
57
+ if (typeof maxHeight === 'number') style.maxHeight = len(maxHeight);
58
+ else if (maxHeight === 'fill') style.maxHeight = '100%';
49
59
  if (literals.aspectRatio !== undefined) style.aspectRatio = String(literals.aspectRatio);
50
60
 
51
61
  const weight = literals.weight;
@@ -28,6 +28,45 @@ function indent(level: number): string {
28
28
  return ' '.repeat(level);
29
29
  }
30
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
+
31
70
  function propLiteral(props: Record<string, IrValue>, key: string): unknown | undefined {
32
71
  const value = props[key];
33
72
  if (value === null || value === undefined) return undefined;
@@ -103,9 +142,11 @@ function mergedModifiersForProps(
103
142
  instanceLiterals,
104
143
  });
105
144
  const instanceModifiers = visualPropSetToModifiers(instanceOnly);
145
+ const paddingMods = instanceModifiers.filter((mod) => mod.startsWith('.padding'));
146
+ const otherInstanceMods = instanceModifiers.filter((mod) => !mod.startsWith('.padding'));
106
147
 
107
- // Order: runtime lookup (base+variant) instance overrides
108
- return [runtimeLookup, ...instanceModifiers];
148
+ // Padding must sit inside background/border from applyVcStyle.
149
+ return [...paddingMods, runtimeLookup, ...otherInstanceMods];
109
150
  }
110
151
 
111
152
  const merged = mergeVisualProps({
@@ -118,6 +159,87 @@ function mergedModifiersForProps(
118
159
  return visualPropSetToModifiers(merged);
119
160
  }
120
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
+
121
243
  function bindingExpr(binding: IrBinding, ctx: SwiftUIRenderContext, itemScope?: string): string {
122
244
  const root = binding.scope ?? itemScope ?? ctx.propsRoot ?? 'vm';
123
245
  return binding.path ? `${root}.${binding.path}` : root;
@@ -185,25 +307,84 @@ function renderLeaf(
185
307
 
186
308
  if (entry.leaf === 'textfield') {
187
309
  const placeholder = propLiteral(node.props, 'placeholder');
188
- const name = propLiteral(node.props, 'name');
189
310
  const ph = typeof placeholder === 'string' ? JSON.stringify(placeholder) : '""';
190
- const binding = typeof name === 'string' ? name : 'text';
191
- const line = `${pad}TextField(${ph}, text: .constant(""))`;
192
- const modLines = modifiers.map((mod) => `${pad}${mod}`);
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}`);
193
315
  return [line, ...modLines].join('\n');
194
316
  }
195
317
 
196
318
  if (entry.leaf === 'texteditor') {
197
- const line = `${pad}TextEditor(text: .constant(""))`;
198
- const modLines = modifiers.map((mod) => `${pad}${mod}`);
199
- return [line, ...modLines].join('\n');
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');
200
353
  }
201
354
 
202
355
  if (entry.leaf === 'picker') {
203
- const label = propLiteral(node.props, 'label') ?? propLiteral(node.props, 'name') ?? '';
204
- const line = `${pad}Picker(${JSON.stringify(label)}, selection: .constant("")) { Text("--") }`;
205
- const modLines = modifiers.map((mod) => `${pad}${mod}`);
206
- return [line, ...modLines].join('\n');
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');
207
388
  }
208
389
 
209
390
  if (entry.leaf === 'spacer') {
@@ -253,21 +434,23 @@ function renderContainer(
253
434
  const spacingLit = entry.spacingProp ? propLiteral(node.props, entry.spacingProp) : undefined;
254
435
  const spacing = typeof spacingLit === 'number' ? spacingLit : 0;
255
436
  const inner = `${indent(level + 1)}VStack(alignment: .leading, spacing: ${spacing}) {\n${childBlock}\n${indent(level + 1)}}`;
256
- return applyModifiers(inner, [...modifiers, '.frame(maxWidth: .infinity, alignment: .leading)'], level + 1);
437
+ return applyModifiers(inner, modifiers, level + 1);
257
438
  }
258
439
 
259
440
  if (entry.container === 'hstack') {
260
441
  const spacingLit = entry.spacingProp ? propLiteral(node.props, entry.spacingProp) : undefined;
261
442
  const spacing = typeof spacingLit === 'number' ? spacingLit : 0;
262
443
  const alignLit = propLiteral(node.props, 'align');
263
- const alignment = alignLit === 'center' ? '.center' : '.top';
264
444
  const heightLit = propLiteral(node.props, 'height');
445
+ const alignment = alignLit === 'center' || typeof heightLit === 'number' ? '.center' : '.top';
265
446
  const justifyLit = propLiteral(node.props, 'justify');
266
447
  const flexLit = propLiteral(node.props, 'flex');
448
+ const wrapLit = propLiteral(node.props, 'wrap');
267
449
  const isTableElement = node.name === 'TableHeader' || node.name === 'TableRow';
268
450
  const shouldFill = isTableElement || justifyLit !== undefined || typeof flexLit === 'number';
451
+ const hStackFrameAlign = justifyLit === 'end' ? '.trailing' : justifyLit === 'center' ? '.center' : '.leading';
269
452
  const frameMods: string[] = [];
270
- if (shouldFill) frameMods.push('.frame(maxWidth: .infinity, alignment: .leading)');
453
+ if (shouldFill) frameMods.push(`.frame(maxWidth: .infinity, alignment: ${hStackFrameAlign})`);
271
454
  if (typeof heightLit === 'number') frameMods.push(`.frame(height: ${heightLit})`);
272
455
 
273
456
  let block = childBlock;
@@ -283,6 +466,19 @@ function renderContainer(
283
466
  const rest = childLines.slice(1);
284
467
  block = [first, `${indent(level + 2)}Spacer()`, ...rest].join('\n');
285
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);
286
482
  }
287
483
 
288
484
  const inner = `${indent(level + 1)}HStack(alignment: ${alignment}, spacing: ${spacing}) {\n${block}\n${indent(level + 1)}}`;
@@ -293,12 +489,12 @@ function renderContainer(
293
489
  const inner = childBlock
294
490
  ? `${indent(level + 1)}VStack(alignment: .leading, spacing: 0) {\n${childBlock}\n${indent(level + 1)}}`
295
491
  : `${indent(level + 1)}VStack { EmptyView() }`;
296
- return applyModifiers(inner, [...modifiers, '.frame(maxWidth: .infinity, alignment: .leading)'], level + 1);
492
+ return applyModifiers(inner, modifiers, level + 1);
297
493
  }
298
494
 
299
495
  if (entry.container === 'navstack') {
300
496
  const inner = childBlock;
301
- return applyModifiers(inner, [...modifiers, '.frame(maxWidth: .infinity, maxHeight: .infinity)'], level + 1);
497
+ return applyModifiers(inner, modifiers, level + 1);
302
498
  }
303
499
 
304
500
  if (entry.container === 'scrollview') {
@@ -308,8 +504,12 @@ function renderContainer(
308
504
 
309
505
  if (entry.container === 'field') {
310
506
  const label = propLiteral(node.props, 'label');
311
- const labelView = typeof label === 'string' ? `\n${indent(level + 2)}Text(${JSON.stringify(label)})\n${indent(level + 2)} .font(.caption)\n${indent(level + 2)} .foregroundStyle(.secondary)` : '';
312
- const inner = `${indent(level + 1)}VStack(alignment: .leading, spacing: 4) {${labelView}\n${childBlock}\n${indent(level + 1)}}`;
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)}}`;
313
513
  return applyModifiers(inner, modifiers, level + 1);
314
514
  }
315
515
 
@@ -349,7 +549,7 @@ function renderContainer(
349
549
  }
350
550
 
351
551
  const inner = `${indent(level + 1)}VStack(alignment: .leading, spacing: 0) {\n${body.join('\n')}\n${indent(level + 1)}}`;
352
- return applyModifiers(inner, [...modifiers, '.frame(maxWidth: .infinity, alignment: .leading)'], level + 1);
552
+ return applyModifiers(inner, modifiers, level + 1);
353
553
  }
354
554
 
355
555
  if (entry.container === 'cellgroup') {
@@ -358,15 +558,15 @@ function renderContainer(
358
558
  const alignLit = propLiteral(node.props, 'align');
359
559
  const swiftAlign = alignLit === 'end' ? '.trailing' : alignLit === 'center' ? '.center' : '.leading';
360
560
  const isFlex = typeof flexLit === 'number';
361
- const cellMods = [...modifiers, '.padding(.horizontal, 4)', '.lineLimit(1)'];
561
+ const cellMods = [...modifiers, '.lineLimit(1)'];
362
562
  if (typeof widthLit === 'number') {
363
563
  cellMods.push(`.frame(width: ${widthLit}, alignment: ${swiftAlign})`);
364
564
  } else if (isFlex) {
365
565
  cellMods.push(`.frame(maxWidth: .infinity, alignment: ${swiftAlign})`);
366
566
  }
367
567
  const inner = childBlock
368
- ? `${indent(level + 1)}VStack(alignment: ${swiftAlign}) {\n${childBlock}\n${indent(level + 1)}}`
369
- : `${indent(level + 1)}VStack { EmptyView() }`;
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() }`;
370
570
  return applyModifiers(inner, cellMods, level + 1);
371
571
  }
372
572
 
@@ -393,8 +593,10 @@ function resolvedVisualProps(
393
593
  });
394
594
  }
395
595
 
396
- function parseGridColumns(gridTemplateColumns: string | number | boolean | undefined): { kind: 'fixed-columns'; widths: string[] } | { kind: 'adaptive'; minWidth: number } | null {
596
+ function parseGridColumns(gridTemplateColumns: string | number | boolean | undefined): { kind: 'fixed-columns'; widths: string[] } | { kind: 'adaptive'; minWidth: number } | { kind: 'equal-columns'; count: number } | null {
397
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]) };
398
600
  const adaptiveMatch = gridTemplateColumns.match(/repeat\(auto-fit,\s*minmax\((\d+)px/);
399
601
  if (adaptiveMatch) return { kind: 'adaptive', minWidth: Number(adaptiveMatch[1]) };
400
602
  const parts = gridTemplateColumns.trim().split(/\s+/);
@@ -427,6 +629,9 @@ function renderComponent(
427
629
  if (parsed?.kind === 'adaptive') {
428
630
  return renderGridAdaptive(node, level, itemScope, renderNested, ctx, modifiers, parsed.minWidth);
429
631
  }
632
+ if (parsed?.kind === 'equal-columns') {
633
+ return renderGridEqualColumns(node, level, itemScope, renderNested, ctx, modifiers, parsed.count);
634
+ }
430
635
  if (parsed?.kind === 'fixed-columns') {
431
636
  return renderGridFixedColumns(node, level, itemScope, renderNested, ctx, modifiers, parsed.widths);
432
637
  }
@@ -437,7 +642,7 @@ function renderComponent(
437
642
  const gapLit = propLiteral(node.props, 'gap');
438
643
  const gap = typeof gapLit === 'number' ? gapLit : (typeof resolved.gap === 'number' ? resolved.gap : 0);
439
644
  const inner = `${indent(level + 1)}HStack(alignment: .top, spacing: ${gap}) {\n${childBlock}\n${indent(level + 1)}}`;
440
- return applyModifiers(inner, [...modifiers, '.frame(maxWidth: .infinity, alignment: .leading)'], level + 1);
645
+ return applyModifiers(inner, modifiers, level + 1);
441
646
  }
442
647
  }
443
648
 
@@ -454,9 +659,21 @@ function renderGridAdaptive(
454
659
  ): string {
455
660
  const gapLit = propLiteral(node.props, 'gap');
456
661
  const gap = typeof gapLit === 'number' ? gapLit : 16;
457
- const childBlock = renderChildren(node.children, level + 2, renderNested, ctx, itemScope);
458
- const inner = `${indent(level + 1)}LazyVGrid(columns: [GridItem(.adaptive(minimum: ${minWidth}))], spacing: ${gap}) {\n${childBlock}\n${indent(level + 1)}}`;
459
- return applyModifiers(inner, [...modifiers, '.frame(maxWidth: .infinity)'], level + 1);
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);
460
677
  }
461
678
 
462
679
  function resolveBackgroundColor(child: IrNode, ctx: SwiftUIRenderContext): string | null {
@@ -492,10 +709,12 @@ function renderGridFixedColumns(
492
709
  const pxMatch = w?.match(/^(\d+)px$/);
493
710
  if (pxMatch) {
494
711
  rendered += `\n${indent(level + 2)}.frame(width: ${Number(pxMatch[1])}, alignment: .topLeading)`;
495
- rendered += `\n${indent(level + 2)}.frame(maxHeight: .infinity)`;
712
+ rendered += `\n${indent(level + 2)}.frame(maxHeight: .infinity, alignment: .topLeading)`;
496
713
  const bgColor = resolveBackgroundColor(child, ctx);
497
714
  if (bgColor) rendered += `\n${indent(level + 2)}.background(${bgColor})`;
498
715
  } else if (w === '1fr') {
716
+ // Content inside ScrollView must explicitly fill width
717
+ rendered += `\n${indent(level + 3)}.frame(maxWidth: .infinity, alignment: .topLeading)`;
499
718
  rendered = `${indent(level + 2)}ScrollView {\n${rendered}\n${indent(level + 2)}}`;
500
719
  rendered += `\n${indent(level + 2)}.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)`;
501
720
  }
@@ -503,7 +722,7 @@ function renderGridFixedColumns(
503
722
  }
504
723
  const childBlock = parts.join('\n');
505
724
  const inner = `${indent(level + 1)}HStack(spacing: 0) {\n${childBlock}\n${indent(level + 1)}}`;
506
- return applyModifiers(inner, [...modifiers, '.frame(maxWidth: .infinity, maxHeight: .infinity)'], level + 1);
725
+ return applyModifiers(inner, [...modifiers, '.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)'], level + 1);
507
726
  }
508
727
 
509
728
  function renderViewRef(node: IrViewRefNode, level: number, itemScope: string | undefined, ctx: SwiftUIRenderContext): string {