view-contracts 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 {
@@ -49,6 +49,10 @@ function swiftFontWeight(value: string | number | boolean): string | null {
49
49
  semibold: '.fontWeight(.semibold)',
50
50
  medium: '.fontWeight(.medium)',
51
51
  regular: '.fontWeight(.regular)',
52
+ '700': '.fontWeight(.bold)',
53
+ '600': '.fontWeight(.semibold)',
54
+ '500': '.fontWeight(.medium)',
55
+ '400': '.fontWeight(.regular)',
52
56
  };
53
57
  return map[value] ?? null;
54
58
  }
@@ -56,8 +60,15 @@ function swiftFontWeight(value: string | number | boolean): string | null {
56
60
  /**
57
61
  * Convert merged DSL visual props to a single SwiftUI modifier chain.
58
62
  *
59
- * Correct SwiftUI order:
63
+ * SwiftUI modifier order for fill elements:
64
+ * foreground / font → padding → frame(fill) → background → clipShape → shadow → overlay → frame(fixed) → opacity
65
+ *
66
+ * For non-fill elements:
60
67
  * foreground / font → padding → background → clipShape → shadow → overlay → frame → opacity
68
+ *
69
+ * The key insight: .frame(maxWidth: .infinity) MUST come BEFORE .background()
70
+ * so the background fills the expanded area. Otherwise the background only
71
+ * covers the natural content width.
61
72
  */
62
73
  export function visualPropSetToModifiers(merged: VisualPropSet): string[] {
63
74
  const modifiers: string[] = [];
@@ -90,7 +101,43 @@ export function visualPropSetToModifiers(merged: VisualPropSet): string[] {
90
101
  }
91
102
  }
92
103
 
93
- // 3. Background (fills the padded area)
104
+ // Pre-compute frame/size values needed to determine fill vs fixed
105
+ const width = merged.width;
106
+ const height = merged.height;
107
+ const minHeight = merged.minHeight;
108
+ const maxWidth = merged.maxWidth;
109
+ const maxHeight = merged.maxHeight;
110
+ const margin = merged.margin;
111
+ const hasCenteringMargin = margin === '0 auto';
112
+
113
+ const needsFillWidth = width === '100%' || width === 'fill' || maxWidth === 'fill';
114
+ const needsFillHeight = height === '100%' || height === 'fill' || minHeight === '100vh';
115
+
116
+ // 2.5. Fill frame constraints — BEFORE background so bg fills the expanded area
117
+ // alignment: .leading/.top/.topLeading ensures content stays at top-left (matching CSS block behavior)
118
+ const maxWidthPx = maxWidth === 'fill' ? null : (maxWidth !== undefined ? parsePx(maxWidth) : null);
119
+
120
+ if (needsFillWidth && needsFillHeight) {
121
+ if (maxWidthPx !== null) {
122
+ modifiers.push(`.frame(maxWidth: ${maxWidthPx}, maxHeight: .infinity, alignment: .topLeading)`);
123
+ } else {
124
+ modifiers.push('.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)');
125
+ }
126
+ } else if (needsFillWidth) {
127
+ if (maxWidthPx !== null) {
128
+ modifiers.push(`.frame(maxWidth: ${maxWidthPx}, alignment: .leading)`);
129
+ } else {
130
+ modifiers.push('.frame(maxWidth: .infinity, alignment: .leading)');
131
+ }
132
+ } else if (needsFillHeight) {
133
+ modifiers.push('.frame(maxHeight: .infinity, alignment: .top)');
134
+ }
135
+
136
+ if (!needsFillHeight && minHeight === '100vh') {
137
+ modifiers.push('.frame(maxHeight: .infinity, alignment: .top)');
138
+ }
139
+
140
+ // 3. Background (fills the frame area — now expanded for fill elements)
94
141
  const background = merged.background;
95
142
  if (typeof background === 'string' && background !== 'transparent') {
96
143
  const color = parseHexColor(background);
@@ -115,17 +162,49 @@ export function visualPropSetToModifiers(merged: VisualPropSet): string[] {
115
162
  modifiers.push(`.overlay(RoundedRectangle(cornerRadius: ${radius ?? 0}).stroke(${colorExpr}, lineWidth: ${borderWidth}))`);
116
163
  }
117
164
 
118
- // 7. Frame constraints
119
- const minHeight = merged.minHeight;
120
- if (minHeight !== undefined) {
121
- const px = parsePx(minHeight);
122
- if (px !== null) modifiers.push(`.frame(minHeight: ${px})`);
165
+ // 7. Non-fill frame constraints (fixed sizes, maxWidth cap without fill)
166
+ if (!needsFillWidth) {
167
+ if (typeof width === 'number') {
168
+ modifiers.push(`.frame(width: ${width})`);
169
+ } else if (width !== undefined && width !== 'wrap') {
170
+ const px = parsePx(width);
171
+ if (px !== null) modifiers.push(`.frame(width: ${px})`);
172
+ }
173
+ if (maxWidth !== undefined && maxWidth !== 'fill') {
174
+ const px = parsePx(maxWidth);
175
+ if (px !== null) modifiers.push(`.frame(maxWidth: ${px})`);
176
+ }
123
177
  }
124
178
 
125
- const maxWidth = merged.maxWidth;
126
- if (maxWidth !== undefined) {
127
- const px = parsePx(maxWidth);
128
- if (px !== null) modifiers.push(`.frame(maxWidth: ${px})`);
179
+ if (!needsFillHeight) {
180
+ if (typeof height === 'number') {
181
+ modifiers.push(`.frame(height: ${height})`);
182
+ } else if (height !== undefined && height !== 'wrap') {
183
+ const px = parsePx(height);
184
+ if (px !== null) modifiers.push(`.frame(height: ${px})`);
185
+ }
186
+ if (minHeight !== undefined && minHeight !== '100vh') {
187
+ if (typeof minHeight === 'number') {
188
+ modifiers.push(`.frame(minHeight: ${minHeight})`);
189
+ } else {
190
+ const px = parsePx(minHeight);
191
+ if (px !== null) modifiers.push(`.frame(minHeight: ${px})`);
192
+ }
193
+ }
194
+ if (maxHeight !== undefined && maxHeight !== 'fill') {
195
+ const px = parsePx(maxHeight);
196
+ if (px !== null) modifiers.push(`.frame(maxHeight: ${px})`);
197
+ }
198
+ }
199
+
200
+ // margin: 0 auto → center within parent by expanding outer frame (positioning, not sizing)
201
+ if (hasCenteringMargin) {
202
+ modifiers.push('.frame(maxWidth: .infinity, alignment: .center)');
203
+ } else if (typeof margin === 'number') {
204
+ modifiers.push(`.padding(${margin})`);
205
+ } else if (margin !== undefined) {
206
+ const px = parsePx(margin);
207
+ if (px !== null) modifiers.push(`.padding(${px})`);
129
208
  }
130
209
 
131
210
  // 8. Opacity (last — affects everything)
@@ -9,6 +9,51 @@ import { renderSwiftTheme } from './renderTheme.js';
9
9
  import { renderSwiftComponent } from './renderComponents.js';
10
10
  import { loadPreviewMockYaml, previewMockFilePath, pathExists } from '../../src/preview/previewMock.js';
11
11
 
12
+ const FLOW_LAYOUT_SWIFT = `import SwiftUI
13
+
14
+ /// CSS flex-wrap equivalent: wraps children to next line when horizontal space runs out.
15
+ struct FlowLayout: Layout {
16
+ var spacing: CGFloat = 8
17
+
18
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
19
+ let containerWidth = proposal.width ?? .infinity
20
+ var x: CGFloat = 0
21
+ var y: CGFloat = 0
22
+ var rowHeight: CGFloat = 0
23
+
24
+ for subview in subviews {
25
+ let size = subview.sizeThatFits(.unspecified)
26
+ if x + size.width > containerWidth && x > 0 {
27
+ y += rowHeight + spacing
28
+ x = 0
29
+ rowHeight = 0
30
+ }
31
+ x += size.width + spacing
32
+ rowHeight = max(rowHeight, size.height)
33
+ }
34
+ return CGSize(width: containerWidth, height: y + rowHeight)
35
+ }
36
+
37
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
38
+ var x = bounds.minX
39
+ var y = bounds.minY
40
+ var rowHeight: CGFloat = 0
41
+
42
+ for subview in subviews {
43
+ let size = subview.sizeThatFits(.unspecified)
44
+ if x + size.width > bounds.maxX && x > bounds.minX {
45
+ y += rowHeight + spacing
46
+ x = bounds.minX
47
+ rowHeight = 0
48
+ }
49
+ subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
50
+ x += size.width + spacing
51
+ rowHeight = max(rowHeight, size.height)
52
+ }
53
+ }
54
+ }
55
+ `;
56
+
12
57
  function swiftuiTemplatesDir(): string {
13
58
  return resolve(packageRoot(), 'renderers/swiftui/templates');
14
59
  }
@@ -33,6 +78,9 @@ export async function renderSwiftUIRuntime(
33
78
  );
34
79
  }
35
80
 
81
+ // FlowLayout helper (CSS flex-wrap equivalent)
82
+ await writeFile(resolve(swiftuiDir, 'FlowLayout.swift'), FLOW_LAYOUT_SWIFT, 'utf-8');
83
+
36
84
  if (compiledDocs && design) {
37
85
  for (const partial of compiledDocs.partials) {
38
86
  try {